Write your representatives, EU version

Compare changes

Choose any two refs to compare.

+8960 -846
+20
.gitignore
··· 13 13 # Database 14 14 *.sqlite3 15 15 db.sqlite3 16 + 17 + # Compiled translation files (generated from .po) 18 + *.mo 19 + 20 + # Shapefile components (use fetch_wahlkreis_data to regenerate GeoJSON) 21 + *.shp 22 + *.shx 23 + *.dbf 24 + *.prj 25 + *.cpg 26 + *.sbn 27 + *.sbx 28 + *.shp.xml 29 + *_shp_geo.zip 30 + 31 + # GeoJSON data (generated by fetch_wahlkreis_data) 32 + website/letters/data/wahlkreise.geojson 33 + 34 + # Git worktrees 35 + .worktrees/
+167 -1
CLAUDE.md
··· 1 - - use uv run python for every python command, including manage.py, scripts, temporary fixes, etc. 1 + You are an experienced, pragmatic software engineer. You don't over-engineer a solution when a simple one is possible. 2 + Rule #1: If you want exception to ANY rule, YOU MUST STOP and get explicit permission from Jesse first. BREAKING THE LETTER OR SPIRIT OF THE RULES IS FAILURE. 3 + 4 + ## Foundational rules 5 + 6 + - Doing it right is better than doing it fast. You are not in a rush. NEVER skip steps or take shortcuts. 7 + - Tedious, systematic work is often the correct solution. Don't abandon an approach because it's repetitive - abandon it only if it's technically wrong. 8 + - Honesty is a core value. If you lie, you'll be replaced. 9 + - You MUST think of and address your human partner as "Jesse" at all times 10 + 11 + ## Our relationship 12 + 13 + - We're colleagues working together as "Jesse" and "Claude" - no formal hierarchy. 14 + - Don't glaze me. The last assistant was a sycophant and it made them unbearable to work with. 15 + - YOU MUST speak up immediately when you don't know something or we're in over our heads 16 + - YOU MUST call out bad ideas, unreasonable expectations, and mistakes - I depend on this 17 + - NEVER be agreeable just to be nice - I NEED your HONEST technical judgment 18 + - NEVER write the phrase "You're absolutely right!" You are not a sycophant. We're working together because I value your opinion. 19 + - YOU MUST ALWAYS STOP and ask for clarification rather than making assumptions. 20 + - If you're having trouble, YOU MUST STOP and ask for help, especially for tasks where human input would be valuable. 21 + - When you disagree with my approach, YOU MUST push back. Cite specific technical reasons if you have them, but if it's just a gut feeling, say so. 22 + - If you're uncomfortable pushing back out loud, just say "Strange things are afoot at the Circle K". I'll know what you mean 23 + - You have issues with memory formation both during and between conversations. Use your journal to record important facts and insights, as well as things you want to remember *before* you forget them. 24 + - You search your journal when you trying to remember or figure stuff out. 25 + - We discuss architectutral decisions (framework changes, major refactoring, system design) 26 + together before implementation. Routine fixes and clear implementations don't need 27 + discussion. 28 + 29 + 30 + # Proactiveness 31 + 32 + When asked to do something, just do it - including obvious follow-up actions needed to complete the task properly. 33 + Only pause to ask for confirmation when: 34 + - Multiple valid approaches exist and the choice matters 35 + - The action would delete or significantly restructure existing code 36 + - You genuinely don't understand what's being asked 37 + - Your partner specifically asks "how should I approach X?" (answer the question, don't jump to 38 + implementation) 39 + 40 + ## Designing software 41 + 42 + - YAGNI. The best code is no code. Don't add features we don't need right now. 43 + - When it doesn't conflict with YAGNI, architect for extensibility and flexibility. 44 + 45 + 46 + ## Test Driven Development (TDD) 47 + 48 + - FOR EVERY NEW FEATURE OR BUGFIX, YOU MUST follow Test Driven Development : 49 + 1. Write a failing test that correctly validates the desired functionality 50 + 2. Run the test to confirm it fails as expected 51 + 3. Write ONLY enough code to make the failing test pass 52 + 4. Run the test to confirm success 53 + 5. Refactor if needed while keeping tests green 54 + 55 + ## Writing code 56 + 57 + - When submitting work, verify that you have FOLLOWED ALL RULES. (See Rule #1) 58 + - YOU MUST make the SMALLEST reasonable changes to achieve the desired outcome. 59 + - We STRONGLY prefer simple, clean, maintainable solutions over clever or complex ones. Readability and maintainability are PRIMARY CONCERNS, even at the cost of conciseness or performance. 60 + - YOU MUST WORK HARD to reduce code duplication, even if the refactoring takes extra effort. 61 + - YOU MUST NEVER throw away or rewrite implementations without EXPLICIT permission. If you're considering this, YOU MUST STOP and ask first. 62 + - YOU MUST get Jesse's explicit approval before implementing ANY backward compatibility. 63 + - YOU MUST MATCH the style and formatting of surrounding code, even if it differs from standard style guides. Consistency within a file trumps external standards. 64 + - YOU MUST NOT manually change whitespace that does not affect execution or output. Otherwise, use a formatting tool. 65 + - Fix broken things immediately when you find them. Don't ask permission to fix bugs. 66 + 67 + 68 + 69 + ## Naming 70 + 71 + - Names MUST tell what code does, not how it's implemented or its history 72 + - When changing code, never document the old behavior or the behavior change 73 + - NEVER use implementation details in names (e.g., "ZodValidator", "MCPWrapper", "JSONParser") 74 + - NEVER use temporal/historical context in names (e.g., "NewAPI", "LegacyHandler", "UnifiedTool", "ImprovedInterface", "EnhancedParser") 75 + - NEVER use pattern names unless they add clarity (e.g., prefer "Tool" over "ToolFactory") 76 + 77 + Good names tell a story about the domain: 78 + - `Tool` not `AbstractToolInterface` 79 + - `RemoteTool` not `MCPToolWrapper` 80 + - `Registry` not `ToolRegistryManager` 81 + - `execute()` not `executeToolWithValidation()` 82 + 83 + ## Code Comments 84 + 85 + - NEVER add comments explaining that something is "improved", "better", "new", "enhanced", or referencing what it used to be 86 + - NEVER add instructional comments telling developers what to do ("copy this pattern", "use this instead") 87 + - Comments should explain WHAT the code does or WHY it exists, not how it's better than something else 88 + - If you're refactoring, remove old comments - don't add new ones explaining the refactoring 89 + - YOU MUST NEVER remove code comments unless you can PROVE they are actively false. Comments are important documentation and must be preserved. 90 + - YOU MUST NEVER add comments about what used to be there or how something has changed. 91 + - YOU MUST NEVER refer to temporal context in comments (like "recently refactored" "moved") or code. Comments should be evergreen and describe the code as it is. If you name something "new" or "enhanced" or "improved", you've probably made a mistake and MUST STOP and ask me what to do. 92 + - All code files MUST start with a brief 2-line comment explaining what the file does. Each line MUST start with "ABOUTME: " to make them easily greppable. 93 + 94 + Examples: 95 + // BAD: This uses Zod for validation instead of manual checking 96 + // BAD: Refactored from the old validation system 97 + // BAD: Wrapper around MCP tool protocol 98 + // GOOD: Executes tools with validated arguments 99 + 100 + If you catch yourself writing "new", "old", "legacy", "wrapper", "unified", or implementation details in names or comments, STOP and find a better name that describes the thing's 101 + actual purpose. 102 + 103 + ## Version Control 104 + 105 + - If the project isn't in a git repo, STOP and ask permission to initialize one. 106 + - YOU MUST STOP and ask how to handle uncommitted changes or untracked files when starting work. Suggest committing existing work first. 107 + - When starting work without a clear branch for the current task, YOU MUST create a WIP branch. 108 + - YOU MUST TRACK All non-trivial changes in git. 109 + - YOU MUST commit frequently throughout the development process, even if your high-level tasks are not yet done. Commit your journal entries. 110 + - NEVER SKIP, EVADE OR DISABLE A PRE-COMMIT HOOK 111 + - NEVER use `git add -A` unless you've just done a `git status` - Don't add random test files to the repo. 112 + 113 + ## Testing 114 + 115 + - ALL TEST FAILURES ARE YOUR RESPONSIBILITY, even if they're not your fault. The Broken Windows theory is real. 116 + - Never delete a test because it's failing. Instead, raise the issue with Jesse. 117 + - Tests MUST comprehensively cover ALL functionality. 118 + - YOU MUST NEVER write tests that "test" mocked behavior. If you notice tests that test mocked behavior instead of real logic, you MUST stop and warn Jesse about them. 119 + - YOU MUST NEVER implement mocks in end to end tests. We always use real data and real APIs. 120 + - YOU MUST NEVER ignore system or test output - logs and messages often contain CRITICAL information. 121 + - Test output MUST BE PRISTINE TO PASS. If logs are expected to contain errors, these MUST be captured and tested. If a test is intentionally triggering an error, we *must* capture and validate that the error output is as we expect 122 + 123 + 124 + ## Issue tracking 125 + 126 + - You MUST use your TodoWrite tool to keep track of what you're doing 127 + - You MUST NEVER discard tasks from your TodoWrite todo list without Jesse's explicit approval 128 + 129 + ## Systematic Debugging Process 130 + 131 + YOU MUST ALWAYS find the root cause of any issue you are debugging 132 + YOU MUST NEVER fix a symptom or add a workaround instead of finding a root cause, even if it is faster or I seem like I'm in a hurry. 133 + 134 + YOU MUST follow this debugging framework for ANY technical issue: 135 + 136 + ### Phase 1: Root Cause Investigation (BEFORE attempting fixes) 137 + - **Read Error Messages Carefully**: Don't skip past errors or warnings - they often contain the exact solution 138 + - **Reproduce Consistently**: Ensure you can reliably reproduce the issue before investigating 139 + - **Check Recent Changes**: What changed that could have caused this? Git diff, recent commits, etc. 140 + 141 + ### Phase 2: Pattern Analysis 142 + - **Find Working Examples**: Locate similar working code in the same codebase 143 + - **Compare Against References**: If implementing a pattern, read the reference implementation completely 144 + - **Identify Differences**: What's different between working and broken code? 145 + - **Understand Dependencies**: What other components/settings does this pattern require? 146 + 147 + ### Phase 3: Hypothesis and Testing 148 + 1. **Form Single Hypothesis**: What do you think is the root cause? State it clearly 149 + 2. **Test Minimally**: Make the smallest possible change to test your hypothesis 150 + 3. **Verify Before Continuing**: Did your test work? If not, form new hypothesis - don't add more fixes 151 + 4. **When You Don't Know**: Say "I don't understand X" rather than pretending to know 152 + 153 + ### Phase 4: Implementation Rules 154 + - ALWAYS have the simplest possible failing test case. If there's no test framework, it's ok to write a one-off test script. 155 + - NEVER add multiple fixes at once 156 + - NEVER claim to implement a pattern without reading it completely first 157 + - ALWAYS test after each change 158 + - IF your first fix doesn't work, STOP and re-analyze rather than adding more fixes 159 + 160 + ## Learning and Memory Management 161 + 162 + - YOU MUST use the journal tool frequently to capture technical insights, failed approaches, and user preferences 163 + - Before starting complex tasks, search the journal for relevant past experiences and lessons learned 164 + - Document architectural decisions and their outcomes for future reference 165 + - Track patterns in user feedback to improve collaboration over time 166 + - When you notice something that should be fixed but is unrelated to your current task, document it in your journal rather than fixing it immediately 167 + - Always use uv run python manage.py {shell,runserver,test,etc}
+45 -2
README.md
··· 4 4 1. Install dependencies with `uv sync`. 5 5 2. Run `uv run python manage.py migrate` from `website/` to bootstrap the database. 6 6 3. (Optional) Import representatives via `uv run python manage.py sync_representatives --level all`. 7 - 4. Launch the dev server with `uv run python manage.py runserver` and visit http://localhost:8000/. 7 + 4. Download constituency boundaries: `uv run python manage.py fetch_wahlkreis_data` (required for accurate address-based matching). 8 + 5. Launch the dev server with `uv run python manage.py runserver` and visit http://localhost:8000/. 9 + 10 + ## Internationalization 11 + 12 + WriteThem.eu supports German (default) and English. 13 + 14 + ### Using the Site 15 + 16 + - Visit `/de/` for German interface 17 + - Visit `/en/` for English interface 18 + - Use the language switcher in the header to toggle languages 19 + - Language preference is saved in a cookie 20 + 21 + ### For Developers 22 + 23 + **Translation workflow:** 24 + 25 + 1. Wrap new UI strings with translation functions: 26 + - Templates: `{% trans "Text" %}` or `{% blocktrans %}` 27 + - Python: `gettext()` or `gettext_lazy()` 28 + 29 + 2. Extract strings to .po files: 30 + ```bash 31 + cd website 32 + uv run python manage.py makemessages -l de -l en 33 + ``` 34 + 35 + 3. Translate strings in `.po` files: 36 + - Edit `locale/de/LC_MESSAGES/django.po` (German translations) 37 + - Edit `locale/en/LC_MESSAGES/django.po` (English, mostly identity translations) 38 + 39 + 4. Compile translations: 40 + ```bash 41 + uv run python manage.py compilemessages 42 + ``` 43 + 44 + 5. Check translation completeness: 45 + ```bash 46 + uv run python manage.py check_translations 47 + ``` 48 + 49 + **Important:** All code, comments, and translation keys should be in English. Only .po files contain actual translations. 8 50 9 51 ## Architecture 10 52 - **Frameworks**: Django 5.2 / Python 3.13 managed with `uv`. The project root holds dependency metadata; all Django code lives in `website/` (settings in `writethem/`, app logic in `letters/`). 11 53 - **Domain models**: `letters/models.py` defines parliaments, terms, constituencies, representatives, committees, letters, signatures, identity verification, and moderation reports. Relationships reflect multi-level mandates (EU/Federal/State) and committee membership. 12 54 - **Sync pipeline**: `RepresentativeSyncService` (in `letters/services.py`) calls the Abgeordnetenwatch v2 API to create/update parliaments, terms, electoral districts, constituencies, representatives, and committee memberships. Management command `sync_representatives` orchestrates the import. 13 - - **Suggestion engine**: `ConstituencySuggestionService` analyses letter titles + postal codes to recommend representatives, tags, and similar letters. The HTMX partial `letters/partials/suggestions.html` renders the live preview used on the letter form. 55 + - **Constituency matching**: `AddressGeocoder` converts full addresses to coordinates via OSM Nominatim (cached in `GeocodeCache`). `WahlkreisLocator` performs point-in-polygon lookups against official Bundestag GeoJSON boundaries. `ConstituencyLocator` integrates both with PLZ fallback. See `docs/matching-algorithm.md` for details. 56 + - **Suggestion engine**: `ConstituencySuggestionService` analyses letter titles + addresses to recommend representatives, tags, and similar letters. The HTMX partial `letters/partials/suggestions.html` renders the live preview used on the letter form. 14 57 - **Identity & signatures**: `IdentityVerificationService` (stub) attaches address information to users; signature counts and verification badges are derived from the associated verification records. 15 58 - **Presentation**: Class-based views in `letters/views.py` back the main pages (letter list/detail, creation, representative detail, user profile). Templates under `letters/templates/` share layout via partials (e.g., `letter_card.html`). 16 59 - **Utilities**: Management commands in `letters/management/commands/` cover representative sync, taxonomy tests, and helper scripts. Tests in `letters/tests.py` exercise model behaviour, letter flows, and the suggestion service.
+92
docs/matching-algorithm.md
··· 1 + # Constituency Matching Algorithm 2 + 3 + ## Overview 4 + 5 + WriteThem.eu uses a two-stage process to match users to their correct Bundestag constituency: 6 + 7 + 1. **Address Geocoding**: Convert user's address to latitude/longitude coordinates 8 + 2. **Point-in-Polygon Lookup**: Find which constituency polygon contains those coordinates 9 + 10 + ## Stage 1: Address Geocoding 11 + 12 + We use OpenStreetMap's Nominatim API to convert addresses to coordinates. 13 + 14 + ### Process: 15 + 1. User provides: Street, Postal Code, City 16 + 2. System checks cache (GeocodeCache table) for previous results 17 + 3. If not cached, query Nominatim API with rate limiting (1 req/sec) 18 + 4. Cache result (success or failure) to minimize API calls 19 + 5. Return (latitude, longitude) or None 20 + 21 + ### Fallback: 22 + If geocoding fails or user only provides postal code, fall back to PLZ prefix heuristic (maps first 2 digits to state). 23 + 24 + ## Stage 2: Point-in-Polygon Lookup 25 + 26 + We use official Bundestag constituency boundaries (GeoJSON format) with shapely for geometric queries. 27 + 28 + ### Process: 29 + 1. Load GeoJSON with 299 Bundestag constituencies on startup 30 + 2. Create shapely Point from coordinates 31 + 3. Check which constituency Polygon contains the point 32 + 4. Look up Constituency object in database by external_id 33 + 5. Return Constituency or None 34 + 35 + ### Performance: 36 + - GeoJSON loaded once at startup (~2MB in memory) 37 + - Class-level caching prevents repeated loads 38 + - Lookup typically takes 10-50ms 39 + - No external API calls required 40 + 41 + ## Data Sources 42 + 43 + - **Constituency Boundaries**: [dknx01/wahlkreissuche](https://github.com/dknx01/wahlkreissuche) (Open Data) 44 + - **Geocoding**: [OpenStreetMap Nominatim](https://nominatim.openstreetmap.org/) (Open Data) 45 + - **Representative Data**: [Abgeordnetenwatch API](https://www.abgeordnetenwatch.de/api) 46 + 47 + ## Accuracy 48 + 49 + This approach provides constituency-accurate matching (exact Wahlkreis), significantly more precise than PLZ-based heuristics which only provide state-level accuracy. 50 + 51 + ### Known Limitations: 52 + - Requires valid German address 53 + - Dependent on OSM geocoding quality 54 + - Rate limited to 1 request/second (public API) 55 + 56 + ## Implementation Details 57 + 58 + ### Services 59 + 60 + - **AddressGeocoder** (`letters/services.py`): Handles geocoding with caching 61 + - **WahlkreisLocator** (`letters/services.py`): Performs point-in-polygon matching 62 + - **ConstituencyLocator** (`letters/services.py`): Integrates both services with PLZ fallback 63 + 64 + ### Database Models 65 + 66 + - **GeocodeCache** (`letters/models.py`): Caches geocoding results to minimize API calls 67 + - **Constituency** (`letters/models.py`): Stores constituency information with external_id mapping to GeoJSON 68 + 69 + ### Management Commands 70 + 71 + - **fetch_wahlkreis_data**: Downloads official Bundestag constituency boundaries 72 + - **query_wahlkreis**: Query constituency by address or postal code 73 + - **query_topics**: Find matching topics for letter text 74 + - **query_representatives**: Find representatives by address and/or topics 75 + 76 + ### Testing 77 + 78 + Run the test suite: 79 + ```bash 80 + python manage.py test letters.tests.test_address_matching 81 + python manage.py test letters.tests.test_topic_mapping 82 + python manage.py test letters.tests.test_constituency_suggestions 83 + ``` 84 + 85 + ## Internationalization 86 + 87 + The constituency matching system works identically in both German and English: 88 + 89 + - Addresses can be entered in German format (standard use case) 90 + - UI language (German/English) does not affect geocoding or matching logic 91 + - Representative names, constituency names, and geographic data remain in original German 92 + - All user-facing labels and messages are translated
+1414
docs/plans/2025-10-11-accurate-constituency-matching.md
··· 1 + # Accurate Constituency Matching Implementation Plan 2 + 3 + > **For Claude:** Use `${CLAUDE_PLUGIN_ROOT}/skills/collaboration/executing-plans/SKILL.md` to implement this plan task-by-task. 4 + 5 + **Goal:** Replace PLZ prefix heuristic with accurate address-based constituency matching using OSM Nominatim geocoding and GeoJSON point-in-polygon lookup. 6 + 7 + **Architecture:** Two-layer approach: (1) AddressGeocoder service converts full German addresses to lat/lng coordinates via OSM Nominatim API with database caching, (2) WahlkreisLocator service uses shapely to perform point-in-polygon queries against Bundestag GeoJSON boundaries. PLZ prefix fallback remains for partial data. 8 + 9 + **Tech Stack:** Django 5.x, shapely 2.x, requests, OSM Nominatim API, GeoJSON 10 + 11 + --- 12 + 13 + ## Task 1: Database Model for Geocoding Cache 14 + 15 + **Files:** 16 + - Create: `website/letters/models.py` (add new model) 17 + - Create: `website/letters/migrations/0012_geocodecache.py` (auto-generated) 18 + - Test: `website/letters/tests.py` 19 + 20 + **Step 1: Write the failing test** 21 + 22 + Add to `website/letters/tests.py`: 23 + 24 + ```python 25 + class GeocodeCache Tests(TestCase): 26 + """Test geocoding cache model.""" 27 + 28 + def test_cache_stores_and_retrieves_coordinates(self): 29 + from .models import GeocodeCache 30 + 31 + cache_entry = GeocodeCache.objects.create( 32 + address_hash='test_hash_123', 33 + street='Unter den Linden 77', 34 + postal_code='10117', 35 + city='Berlin', 36 + latitude=52.5170365, 37 + longitude=13.3888599, 38 + ) 39 + 40 + retrieved = GeocodeCache.objects.get(address_hash='test_hash_123') 41 + self.assertEqual(retrieved.latitude, 52.5170365) 42 + self.assertEqual(retrieved.longitude, 13.3888599) 43 + self.assertEqual(retrieved.street, 'Unter den Linden 77') 44 + ``` 45 + 46 + **Step 2: Run test to verify it fails** 47 + 48 + Run: `uv run python manage.py test letters.tests.GeocodeCacheTests -v` 49 + Expected: FAIL with "No module named 'GeocodeCache'" 50 + 51 + **Step 3: Add GeocodeCache model** 52 + 53 + Add to `website/letters/models.py` after the existing models: 54 + 55 + ```python 56 + class GeocodeCache(models.Model): 57 + """Cache geocoding results to minimize API calls.""" 58 + 59 + address_hash = models.CharField( 60 + max_length=64, 61 + unique=True, 62 + db_index=True, 63 + help_text="SHA256 hash of normalized address for fast lookup" 64 + ) 65 + street = models.CharField(max_length=255, blank=True) 66 + postal_code = models.CharField(max_length=10, blank=True) 67 + city = models.CharField(max_length=100, blank=True) 68 + country = models.CharField(max_length=2, default='DE') 69 + 70 + latitude = models.FloatField(null=True, blank=True) 71 + longitude = models.FloatField(null=True, blank=True) 72 + 73 + success = models.BooleanField( 74 + default=True, 75 + help_text="False if geocoding failed, to avoid repeated failed lookups" 76 + ) 77 + error_message = models.TextField(blank=True) 78 + 79 + created_at = models.DateTimeField(auto_now_add=True) 80 + updated_at = models.DateTimeField(auto_now=True) 81 + 82 + class Meta: 83 + verbose_name = "Geocode Cache Entry" 84 + verbose_name_plural = "Geocode Cache Entries" 85 + ordering = ['-created_at'] 86 + 87 + def __str__(self): 88 + if self.latitude and self.longitude: 89 + return f"{self.city} ({self.latitude}, {self.longitude})" 90 + return f"{self.city} (failed)" 91 + ``` 92 + 93 + **Step 4: Create migration** 94 + 95 + Run: `uv run python manage.py makemigrations letters` 96 + Expected: Migration 0012_geocodecache.py created 97 + 98 + **Step 5: Run migration** 99 + 100 + Run: `uv run python manage.py migrate` 101 + Expected: "Applying letters.0012_geocodecache... OK" 102 + 103 + **Step 6: Run test to verify it passes** 104 + 105 + Run: `uv run python manage.py test letters.tests.GeocodeCacheTests -v` 106 + Expected: PASS 107 + 108 + **Step 7: Commit** 109 + 110 + ```bash 111 + git add website/letters/models.py website/letters/migrations/0012_geocodecache.py website/letters/tests.py 112 + git commit -m "feat: add GeocodeCache model for address geocoding results" 113 + ``` 114 + 115 + --- 116 + 117 + ## Task 2: OSM Nominatim API Client 118 + 119 + **Files:** 120 + - Modify: `website/letters/services.py` (add AddressGeocoder class) 121 + - Test: `website/letters/tests.py` 122 + 123 + **Step 1: Write the failing test** 124 + 125 + Add to `website/letters/tests.py`: 126 + 127 + ```python 128 + from unittest.mock import patch, MagicMock 129 + 130 + 131 + class AddressGeocoderTests(TestCase): 132 + """Test OSM Nominatim address geocoding.""" 133 + 134 + def test_geocode_returns_coordinates_for_valid_address(self): 135 + from .services import AddressGeocoder 136 + 137 + # Mock the Nominatim API response 138 + mock_response = MagicMock() 139 + mock_response.json.return_value = [{ 140 + 'lat': '52.5170365', 141 + 'lon': '13.3888599', 142 + 'display_name': 'Unter den Linden 77, Mitte, Berlin, 10117, Deutschland' 143 + }] 144 + mock_response.status_code = 200 145 + 146 + with patch('requests.get', return_value=mock_response): 147 + result = AddressGeocoder.geocode( 148 + street='Unter den Linden 77', 149 + postal_code='10117', 150 + city='Berlin' 151 + ) 152 + 153 + self.assertIsNotNone(result) 154 + lat, lng = result 155 + self.assertAlmostEqual(lat, 52.5170365, places=5) 156 + self.assertAlmostEqual(lng, 13.3888599, places=5) 157 + 158 + def test_geocode_caches_results(self): 159 + from .services import AddressGeocoder 160 + from .models import GeocodeCache 161 + 162 + mock_response = MagicMock() 163 + mock_response.json.return_value = [{ 164 + 'lat': '52.5170365', 165 + 'lon': '13.3888599', 166 + }] 167 + mock_response.status_code = 200 168 + 169 + with patch('requests.get', return_value=mock_response) as mock_get: 170 + # First call should hit API 171 + result1 = AddressGeocoder.geocode( 172 + street='Unter den Linden 77', 173 + postal_code='10117', 174 + city='Berlin' 175 + ) 176 + 177 + # Second call should use cache 178 + result2 = AddressGeocoder.geocode( 179 + street='Unter den Linden 77', 180 + postal_code='10117', 181 + city='Berlin' 182 + ) 183 + 184 + # API should only be called once 185 + self.assertEqual(mock_get.call_count, 1) 186 + self.assertEqual(result1, result2) 187 + 188 + # Verify cache entry exists 189 + self.assertTrue( 190 + GeocodeCache.objects.filter( 191 + city='Berlin', 192 + postal_code='10117' 193 + ).exists() 194 + ) 195 + ``` 196 + 197 + **Step 2: Run test to verify it fails** 198 + 199 + Run: `uv run python manage.py test letters.tests.AddressGeocoderTests -v` 200 + Expected: FAIL with "No module named 'AddressGeocoder'" 201 + 202 + **Step 3: Implement AddressGeocoder service** 203 + 204 + Add to `website/letters/services.py` after the existing classes: 205 + 206 + ```python 207 + import hashlib 208 + import time 209 + from typing import Optional, Tuple 210 + 211 + 212 + class AddressGeocoder: 213 + """Geocode German addresses using OSM Nominatim API.""" 214 + 215 + NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" 216 + USER_AGENT = "WriteThem.eu/1.0 (https://writethem.eu; contact@writethem.eu)" 217 + REQUEST_TIMEOUT = 10 218 + RATE_LIMIT_DELAY = 1.0 # seconds between requests 219 + 220 + _last_request_time = 0 221 + 222 + @classmethod 223 + def geocode( 224 + cls, 225 + street: str, 226 + postal_code: str, 227 + city: str, 228 + country: str = 'DE' 229 + ) -> Optional[Tuple[float, float]]: 230 + """ 231 + Geocode a German address to lat/lng coordinates. 232 + 233 + Args: 234 + street: Street address (e.g., "Unter den Linden 77") 235 + postal_code: German postal code (e.g., "10117") 236 + city: City name (e.g., "Berlin") 237 + country: Country code (default: 'DE') 238 + 239 + Returns: 240 + Tuple of (latitude, longitude) or None if geocoding fails 241 + """ 242 + from .models import GeocodeCache 243 + 244 + # Normalize inputs 245 + street = (street or '').strip() 246 + postal_code = (postal_code or '').strip() 247 + city = (city or '').strip() 248 + country = (country or 'DE').upper() 249 + 250 + if not city: 251 + logger.warning("City is required for geocoding") 252 + return None 253 + 254 + # Generate cache key 255 + address_string = f"{street}|{postal_code}|{city}|{country}".lower() 256 + address_hash = hashlib.sha256(address_string.encode()).hexdigest() 257 + 258 + # Check cache first 259 + cached = GeocodeCache.objects.filter(address_hash=address_hash).first() 260 + if cached: 261 + if cached.success and cached.latitude and cached.longitude: 262 + logger.debug(f"Cache hit for {city}: ({cached.latitude}, {cached.longitude})") 263 + return (cached.latitude, cached.longitude) 264 + elif not cached.success: 265 + logger.debug(f"Cache hit for {city}: previous failure") 266 + return None 267 + 268 + # Rate limiting 269 + cls._rate_limit() 270 + 271 + # Build query 272 + query_parts = [] 273 + if street: 274 + query_parts.append(street) 275 + if postal_code: 276 + query_parts.append(postal_code) 277 + query_parts.append(city) 278 + query_parts.append(country) 279 + 280 + query = ', '.join(query_parts) 281 + 282 + params = { 283 + 'q': query, 284 + 'format': 'json', 285 + 'limit': 1, 286 + 'addressdetails': 1, 287 + 'countrycodes': country.lower(), 288 + } 289 + 290 + headers = { 291 + 'User-Agent': cls.USER_AGENT 292 + } 293 + 294 + try: 295 + logger.info(f"Geocoding address: {query}") 296 + response = requests.get( 297 + cls.NOMINATIM_URL, 298 + params=params, 299 + headers=headers, 300 + timeout=cls.REQUEST_TIMEOUT 301 + ) 302 + response.raise_for_status() 303 + 304 + results = response.json() 305 + 306 + if not results: 307 + logger.warning(f"No geocoding results for: {query}") 308 + cls._cache_failure(address_hash, street, postal_code, city, country, "No results") 309 + return None 310 + 311 + # Extract coordinates 312 + result = results[0] 313 + latitude = float(result['lat']) 314 + longitude = float(result['lon']) 315 + 316 + # Cache success 317 + GeocodeCache.objects.update_or_create( 318 + address_hash=address_hash, 319 + defaults={ 320 + 'street': street, 321 + 'postal_code': postal_code, 322 + 'city': city, 323 + 'country': country, 324 + 'latitude': latitude, 325 + 'longitude': longitude, 326 + 'success': True, 327 + 'error_message': '', 328 + } 329 + ) 330 + 331 + logger.info(f"Geocoded {city} to ({latitude}, {longitude})") 332 + return (latitude, longitude) 333 + 334 + except requests.RequestException as e: 335 + error_msg = f"Nominatim API error: {e}" 336 + logger.error(error_msg) 337 + cls._cache_failure(address_hash, street, postal_code, city, country, error_msg) 338 + return None 339 + except (KeyError, ValueError, TypeError) as e: 340 + error_msg = f"Invalid geocoding response: {e}" 341 + logger.error(error_msg) 342 + cls._cache_failure(address_hash, street, postal_code, city, country, error_msg) 343 + return None 344 + 345 + @classmethod 346 + def _rate_limit(cls): 347 + """Ensure we don't exceed Nominatim rate limits (1 req/sec).""" 348 + import time 349 + current_time = time.time() 350 + elapsed = current_time - cls._last_request_time 351 + 352 + if elapsed < cls.RATE_LIMIT_DELAY: 353 + time.sleep(cls.RATE_LIMIT_DELAY - elapsed) 354 + 355 + cls._last_request_time = time.time() 356 + 357 + @classmethod 358 + def _cache_failure( 359 + cls, 360 + address_hash: str, 361 + street: str, 362 + postal_code: str, 363 + city: str, 364 + country: str, 365 + error_message: str 366 + ): 367 + """Cache a failed geocoding attempt to avoid repeated failures.""" 368 + from .models import GeocodeCache 369 + 370 + GeocodeCache.objects.update_or_create( 371 + address_hash=address_hash, 372 + defaults={ 373 + 'street': street, 374 + 'postal_code': postal_code, 375 + 'city': city, 376 + 'country': country, 377 + 'latitude': None, 378 + 'longitude': None, 379 + 'success': False, 380 + 'error_message': error_message, 381 + } 382 + ) 383 + ``` 384 + 385 + **Step 4: Run test to verify it passes** 386 + 387 + Run: `uv run python manage.py test letters.tests.AddressGeocoderTests -v` 388 + Expected: PASS (2 tests) 389 + 390 + **Step 5: Commit** 391 + 392 + ```bash 393 + git add website/letters/services.py website/letters/tests.py 394 + git commit -m "feat: add OSM Nominatim address geocoding service with caching" 395 + ``` 396 + 397 + --- 398 + 399 + ## Task 3: Download and Prepare GeoJSON Data 400 + 401 + **Files:** 402 + - Modify: `website/letters/management/commands/fetch_wahlkreis_data.py` (already exists) 403 + - Create: `website/letters/data/wahlkreise.geojson` (downloaded data) 404 + 405 + **Step 1: Test existing download command** 406 + 407 + Run: `uv run python manage.py fetch_wahlkreis_data --help` 408 + Expected: Shows command help text 409 + 410 + **Step 2: Download full Bundestag GeoJSON** 411 + 412 + Run: `uv run python manage.py fetch_wahlkreis_data --output=website/letters/data/wahlkreise.geojson --force` 413 + Expected: "Saved Wahlkreis data to website/letters/data/wahlkreise.geojson" 414 + 415 + **Step 3: Verify GeoJSON structure** 416 + 417 + Run: `uv run python -c "import json; data = json.load(open('website/letters/data/wahlkreise.geojson')); print(f'Loaded {len(data[\"features\"])} constituencies')"` 418 + Expected: "Loaded 299 constituencies" (or similar) 419 + 420 + **Step 4: Add GeoJSON to gitignore** 421 + 422 + Add to `.gitignore`: 423 + ``` 424 + # Large GeoJSON data files 425 + website/letters/data/*.geojson 426 + !website/letters/data/wahlkreise_sample.geojson 427 + ``` 428 + 429 + **Step 5: Commit gitignore update** 430 + 431 + ```bash 432 + git add .gitignore 433 + git commit -m "chore: add GeoJSON files to gitignore" 434 + ``` 435 + 436 + **Step 6: Document download in README** 437 + 438 + Add to README.md setup instructions: 439 + ```markdown 440 + ### Download Constituency Boundaries 441 + 442 + Download the Bundestag constituency boundaries: 443 + 444 + \`\`\`bash 445 + uv run python manage.py fetch_wahlkreis_data 446 + \`\`\` 447 + 448 + This downloads ~2MB of GeoJSON data for accurate constituency matching. 449 + ``` 450 + 451 + **Step 7: Commit documentation** 452 + 453 + ```bash 454 + git add README.md 455 + git commit -m "docs: add constituency data download instructions" 456 + ``` 457 + 458 + --- 459 + 460 + ## Task 4: WahlkreisLocator Service with Shapely 461 + 462 + **Files:** 463 + - Modify: `website/letters/services.py` (add WahlkreisLocator class) 464 + - Test: `website/letters/tests.py` 465 + 466 + **Step 1: Write the failing test** 467 + 468 + Add to `website/letters/tests.py`: 469 + 470 + ```python 471 + class WahlkreisLocatorTests(TestCase): 472 + """Test GeoJSON point-in-polygon constituency lookup.""" 473 + 474 + def setUp(self): 475 + super().setUp() 476 + # Create test parliament and constituencies 477 + self.parliament = Parliament.objects.create( 478 + name='Deutscher Bundestag', 479 + level='FEDERAL', 480 + legislative_body='Bundestag', 481 + region='DE', 482 + ) 483 + self.term = ParliamentTerm.objects.create( 484 + parliament=self.parliament, 485 + name='20. Wahlperiode', 486 + start_date=date(2021, 10, 26), 487 + ) 488 + # Berlin-Mitte constituency 489 + self.constituency_mitte = Constituency.objects.create( 490 + parliament_term=self.term, 491 + name='Berlin-Mitte', 492 + scope='FEDERAL_DISTRICT', 493 + external_id='75', # Real Wahlkreis ID 494 + metadata={'state': 'Berlin'}, 495 + ) 496 + 497 + def test_find_constituency_for_berlin_coordinates(self): 498 + from .services import WahlkreisLocator 499 + 500 + # Coordinates for Unter den Linden, Berlin-Mitte 501 + latitude = 52.5170365 502 + longitude = 13.3888599 503 + 504 + result = WahlkreisLocator.find_constituency(latitude, longitude) 505 + 506 + self.assertIsNotNone(result) 507 + self.assertEqual(result.external_id, '75') # Berlin-Mitte 508 + self.assertEqual(result.scope, 'FEDERAL_DISTRICT') 509 + 510 + def test_returns_none_for_coordinates_outside_germany(self): 511 + from .services import WahlkreisLocator 512 + 513 + # Coordinates in Paris 514 + latitude = 48.8566 515 + longitude = 2.3522 516 + 517 + result = WahlkreisLocator.find_constituency(latitude, longitude) 518 + 519 + self.assertIsNone(result) 520 + ``` 521 + 522 + **Step 2: Run test to verify it fails** 523 + 524 + Run: `uv run python manage.py test letters.tests.WahlkreisLocatorTests -v` 525 + Expected: FAIL with "No module named 'WahlkreisLocator'" 526 + 527 + **Step 3: Implement WahlkreisLocator service** 528 + 529 + Add to `website/letters/services.py`: 530 + 531 + ```python 532 + from pathlib import Path 533 + from shapely.geometry import Point, shape 534 + from typing import Optional, List, Dict, Any 535 + 536 + 537 + class WahlkreisLocator: 538 + """Locate Bundestag constituency from lat/lng using GeoJSON boundaries.""" 539 + 540 + _geojson_data: Optional[Dict[str, Any]] = None 541 + _geometries: Optional[List[tuple]] = None 542 + 543 + GEOJSON_PATH = Path(__file__).parent / 'data' / 'wahlkreise.geojson' 544 + 545 + @classmethod 546 + def _load_geojson(cls): 547 + """Load GeoJSON data into memory (called once at startup).""" 548 + if cls._geometries is not None: 549 + return 550 + 551 + if not cls.GEOJSON_PATH.exists(): 552 + logger.warning(f"GeoJSON file not found: {cls.GEOJSON_PATH}") 553 + logger.warning("Run: python manage.py fetch_wahlkreis_data") 554 + cls._geometries = [] 555 + return 556 + 557 + try: 558 + with open(cls.GEOJSON_PATH, 'r', encoding='utf-8') as f: 559 + cls._geojson_data = json.load(f) 560 + 561 + # Pre-process geometries for faster lookup 562 + cls._geometries = [] 563 + for feature in cls._geojson_data.get('features', []): 564 + geometry = shape(feature['geometry']) 565 + properties = feature.get('properties', {}) 566 + 567 + # Extract Wahlkreis ID from properties 568 + wahlkreis_id = properties.get('WKR_NR') or properties.get('id') 569 + wahlkreis_name = properties.get('WKR_NAME') or properties.get('name') 570 + 571 + if wahlkreis_id: 572 + cls._geometries.append(( 573 + str(wahlkreis_id), 574 + wahlkreis_name, 575 + geometry 576 + )) 577 + 578 + logger.info(f"Loaded {len(cls._geometries)} constituencies from GeoJSON") 579 + 580 + except Exception as e: 581 + logger.error(f"Failed to load GeoJSON: {e}") 582 + cls._geometries = [] 583 + 584 + @classmethod 585 + def find_constituency( 586 + cls, 587 + latitude: float, 588 + longitude: float 589 + ) -> Optional[Constituency]: 590 + """ 591 + Find the Bundestag constituency containing the given coordinates. 592 + 593 + Args: 594 + latitude: Latitude in decimal degrees 595 + longitude: Longitude in decimal degrees 596 + 597 + Returns: 598 + Constituency object or None if not found 599 + """ 600 + cls._load_geojson() 601 + 602 + if not cls._geometries: 603 + logger.warning("No GeoJSON data loaded") 604 + return None 605 + 606 + point = Point(longitude, latitude) # Note: shapely uses (x, y) = (lon, lat) 607 + 608 + # Find which polygon contains this point 609 + for wahlkreis_id, wahlkreis_name, geometry in cls._geometries: 610 + if geometry.contains(point): 611 + logger.debug(f"Found constituency: {wahlkreis_name} (ID: {wahlkreis_id})") 612 + 613 + # Look up in database 614 + constituency = Constituency.objects.filter( 615 + external_id=wahlkreis_id, 616 + scope='FEDERAL_DISTRICT' 617 + ).first() 618 + 619 + if constituency: 620 + return constituency 621 + else: 622 + logger.warning( 623 + f"Constituency {wahlkreis_id} found in GeoJSON but not in database" 624 + ) 625 + return None 626 + 627 + logger.debug(f"No constituency found for coordinates ({latitude}, {longitude})") 628 + return None 629 + 630 + @classmethod 631 + def clear_cache(cls): 632 + """Clear cached GeoJSON data (useful for testing).""" 633 + cls._geojson_data = None 634 + cls._geometries = None 635 + ``` 636 + 637 + **Step 4: Add shapely to requirements** 638 + 639 + Check if shapely is in requirements: 640 + Run: `grep shapely pyproject.toml || grep shapely requirements.txt` 641 + 642 + If not found, add to pyproject.toml dependencies: 643 + ```toml 644 + dependencies = [ 645 + "django>=5.0", 646 + "shapely>=2.0", 647 + # ... other deps 648 + ] 649 + ``` 650 + 651 + **Step 5: Install shapely** 652 + 653 + Run: `uv sync` 654 + Expected: "Resolved X packages in Yms" 655 + 656 + **Step 6: Run test to verify it passes** 657 + 658 + Run: `uv run python manage.py test letters.tests.WahlkreisLocatorTests -v` 659 + Expected: PASS (2 tests) 660 + 661 + **Step 7: Commit** 662 + 663 + ```bash 664 + git add website/letters/services.py website/letters/tests.py pyproject.toml 665 + git commit -m "feat: add GeoJSON point-in-polygon constituency lookup" 666 + ``` 667 + 668 + --- 669 + 670 + ## Task 5: Integration - Update ConstituencyLocator 671 + 672 + **Files:** 673 + - Modify: `website/letters/services.py` (update ConstituencyLocator class) 674 + - Test: `website/letters/tests.py` 675 + 676 + **Step 1: Write integration test** 677 + 678 + Add to `website/letters/tests.py`: 679 + 680 + ```python 681 + class ConstituencyLocatorIntegrationTests(TestCase): 682 + """Test integrated address โ†’ constituency lookup.""" 683 + 684 + def setUp(self): 685 + super().setUp() 686 + self.parliament = Parliament.objects.create( 687 + name='Deutscher Bundestag', 688 + level='FEDERAL', 689 + legislative_body='Bundestag', 690 + region='DE', 691 + ) 692 + self.term = ParliamentTerm.objects.create( 693 + parliament=self.parliament, 694 + name='20. Wahlperiode', 695 + start_date=date(2021, 10, 26), 696 + ) 697 + self.constituency_mitte = Constituency.objects.create( 698 + parliament_term=self.term, 699 + name='Berlin-Mitte', 700 + scope='FEDERAL_DISTRICT', 701 + external_id='75', 702 + metadata={'state': 'Berlin'}, 703 + ) 704 + 705 + @patch('letters.services.AddressGeocoder.geocode') 706 + def test_locate_uses_address_geocoding(self, mock_geocode): 707 + from .services import ConstituencyLocator 708 + 709 + # Mock geocoding to return Berlin-Mitte coordinates 710 + mock_geocode.return_value = (52.5170365, 13.3888599) 711 + 712 + result = ConstituencyLocator.locate_from_address( 713 + street='Unter den Linden 77', 714 + postal_code='10117', 715 + city='Berlin' 716 + ) 717 + 718 + self.assertIsNotNone(result.federal) 719 + self.assertEqual(result.federal.external_id, '75') 720 + 721 + # Verify geocoder was called 722 + mock_geocode.assert_called_once_with( 723 + street='Unter den Linden 77', 724 + postal_code='10117', 725 + city='Berlin', 726 + country='DE' 727 + ) 728 + 729 + def test_locate_falls_back_to_plz_prefix(self): 730 + from .services import ConstituencyLocator 731 + 732 + # Test with just PLZ (no full address) 733 + result = ConstituencyLocator.locate('10117') 734 + 735 + # Should return Berlin-based constituency using old heuristic 736 + self.assertIsNotNone(result.federal) 737 + ``` 738 + 739 + **Step 2: Run test to verify it fails** 740 + 741 + Run: `uv run python manage.py test letters.tests.ConstituencyLocatorIntegrationTests -v` 742 + Expected: FAIL with "No method named 'locate_from_address'" 743 + 744 + **Step 3: Add locate_from_address method** 745 + 746 + Modify `ConstituencyLocator` class in `website/letters/services.py`: 747 + 748 + ```python 749 + class ConstituencyLocator: 750 + """Enhanced mapping from addresses/postal codes to constituencies.""" 751 + 752 + # ... existing STATE_BY_PLZ_PREFIX dict ... 753 + 754 + @classmethod 755 + def locate_from_address( 756 + cls, 757 + street: str, 758 + postal_code: str, 759 + city: str, 760 + country: str = 'DE' 761 + ) -> LocatedConstituencies: 762 + """ 763 + Locate constituency from full address using geocoding. 764 + 765 + This is the preferred method for accurate constituency matching. 766 + Falls back to PLZ prefix if geocoding fails. 767 + """ 768 + # Try accurate geocoding first 769 + coordinates = AddressGeocoder.geocode(street, postal_code, city, country) 770 + 771 + if coordinates: 772 + latitude, longitude = coordinates 773 + 774 + # Use GeoJSON lookup for federal constituency 775 + federal_constituency = WahlkreisLocator.find_constituency(latitude, longitude) 776 + 777 + if federal_constituency: 778 + # Also try to determine state from the federal constituency 779 + state_name = (federal_constituency.metadata or {}).get('state') 780 + state_constituency = None 781 + 782 + if state_name: 783 + normalized_state = normalize_german_state(state_name) 784 + state_constituency = cls._match_state(normalized_state) 785 + 786 + return LocatedConstituencies( 787 + federal=federal_constituency, 788 + state=state_constituency, 789 + local=None 790 + ) 791 + 792 + # Fallback to PLZ prefix heuristic 793 + logger.info(f"Falling back to PLZ prefix lookup for {postal_code}") 794 + return cls.locate(postal_code) 795 + 796 + @classmethod 797 + def locate(cls, postal_code: str) -> LocatedConstituencies: 798 + """ 799 + Legacy PLZ prefix-based lookup. 800 + 801 + Use locate_from_address() for accurate results. 802 + This method kept for backwards compatibility and fallback. 803 + """ 804 + # ... existing implementation unchanged ... 805 + ``` 806 + 807 + **Step 4: Run test to verify it passes** 808 + 809 + Run: `uv run python manage.py test letters.tests.ConstituencyLocatorIntegrationTests -v` 810 + Expected: PASS (2 tests) 811 + 812 + **Step 5: Commit** 813 + 814 + ```bash 815 + git add website/letters/services.py website/letters/tests.py 816 + git commit -m "feat: integrate address geocoding into ConstituencyLocator" 817 + ``` 818 + 819 + --- 820 + 821 + ## Task 6: Update ConstituencySuggestionService to Use Address 822 + 823 + **Files:** 824 + - Modify: `website/letters/services.py` (update _resolve_location method) 825 + - Test: `website/letters/tests.py` 826 + 827 + **Step 1: Write test for address-based suggestion** 828 + 829 + Add to `website/letters/tests.py`: 830 + 831 + ```python 832 + class SuggestionServiceAddressTests(ParliamentFixtureMixin, TestCase): 833 + """Test suggestions with full address input.""" 834 + 835 + @patch('letters.services.AddressGeocoder.geocode') 836 + def test_suggestions_with_full_address(self, mock_geocode): 837 + from .services import ConstituencySuggestionService 838 + 839 + # Mock geocoding 840 + mock_geocode.return_value = (52.5170365, 13.3888599) 841 + 842 + result = ConstituencySuggestionService.suggest_from_concern( 843 + 'Mehr Investitionen in den ร–PNV', 844 + user_location={ 845 + 'street': 'Unter den Linden 77', 846 + 'postal_code': '10117', 847 + 'city': 'Berlin', 848 + } 849 + ) 850 + 851 + self.assertIsNotNone(result['constituencies']) 852 + self.assertTrue(len(result['representatives']) > 0) 853 + ``` 854 + 855 + **Step 2: Run test to verify it fails** 856 + 857 + Run: `uv run python manage.py test letters.tests.SuggestionServiceAddressTests -v` 858 + Expected: FAIL (address not being used) 859 + 860 + **Step 3: Update _resolve_location to handle addresses** 861 + 862 + Modify `ConstituencySuggestionService._resolve_location` in `website/letters/services.py`: 863 + 864 + ```python 865 + @classmethod 866 + def _resolve_location(cls, user_location: Dict[str, str]) -> LocationContext: 867 + """Resolve user location from various input formats.""" 868 + 869 + # Check if full address is provided 870 + street = (user_location.get('street') or '').strip() 871 + postal_code = (user_location.get('postal_code') or '').strip() 872 + city = (user_location.get('city') or '').strip() 873 + 874 + constituencies: List[Constituency] = [] 875 + 876 + # Priority 1: Explicitly provided constituency IDs 877 + provided_constituencies = user_location.get('constituencies') 878 + if provided_constituencies: 879 + iterable = provided_constituencies if isinstance(provided_constituencies, (list, tuple, set)) else [provided_constituencies] 880 + for item in iterable: 881 + constituency = None 882 + if isinstance(item, Constituency): 883 + constituency = item 884 + else: 885 + try: 886 + constituency_id = int(item) 887 + except (TypeError, ValueError): 888 + constituency_id = None 889 + if constituency_id: 890 + constituency = Constituency.objects.filter(id=constituency_id).first() 891 + if constituency and all(c.id != constituency.id for c in constituencies): 892 + constituencies.append(constituency) 893 + 894 + # Priority 2: Full address geocoding 895 + if not constituencies and city: 896 + logger.info(f"Using address geocoding for: {city}") 897 + located = ConstituencyLocator.locate_from_address(street, postal_code, city) 898 + constituencies.extend( 899 + constituency 900 + for constituency in (located.local, located.state, located.federal) 901 + if constituency 902 + ) 903 + 904 + # Priority 3: PLZ-only fallback 905 + elif not constituencies and postal_code: 906 + logger.info(f"Using PLZ fallback for: {postal_code}") 907 + located = ConstituencyLocator.locate(postal_code) 908 + constituencies.extend( 909 + constituency 910 + for constituency in (located.local, located.state, located.federal) 911 + if constituency 912 + ) 913 + else: 914 + located = LocatedConstituencies(None, None, None) 915 + 916 + # Determine state 917 + explicit_state = normalize_german_state(user_location.get('state')) if user_location.get('state') else None 918 + inferred_state = None 919 + for constituency in constituencies: 920 + metadata_state = (constituency.metadata or {}).get('state') if constituency.metadata else None 921 + if metadata_state: 922 + inferred_state = normalize_german_state(metadata_state) 923 + if inferred_state: 924 + break 925 + 926 + state = explicit_state or inferred_state 927 + 928 + return LocationContext( 929 + postal_code=postal_code or None, 930 + state=state, 931 + constituencies=constituencies, 932 + ) 933 + ``` 934 + 935 + **Step 4: Run test to verify it passes** 936 + 937 + Run: `uv run python manage.py test letters.tests.SuggestionServiceAddressTests -v` 938 + Expected: PASS 939 + 940 + **Step 5: Commit** 941 + 942 + ```bash 943 + git add website/letters/services.py website/letters/tests.py 944 + git commit -m "feat: support full address in suggestion service" 945 + ``` 946 + 947 + --- 948 + 949 + ## Task 7: Update Profile View to Collect Full Address 950 + 951 + **Files:** 952 + - Modify: `website/letters/forms.py` (update verification form) 953 + - Modify: `website/letters/templates/letters/profile.html` 954 + - Test: `website/letters/tests.py` 955 + 956 + **Step 1: Write test for address collection** 957 + 958 + Add to `website/letters/tests.py`: 959 + 960 + ```python 961 + class ProfileAddressCollectionTests(TestCase): 962 + """Test profile form collects full address.""" 963 + 964 + def setUp(self): 965 + self.user = User.objects.create_user( 966 + username='testuser', 967 + password='password123', 968 + email='test@example.com' 969 + ) 970 + 971 + def test_profile_form_has_address_fields(self): 972 + from .forms import SelfDeclaredVerificationForm 973 + 974 + form = SelfDeclaredVerificationForm() 975 + 976 + self.assertIn('street', form.fields) 977 + self.assertIn('postal_code', form.fields) 978 + self.assertIn('city', form.fields) 979 + ``` 980 + 981 + **Step 2: Run test to verify it fails** 982 + 983 + Run: `uv run python manage.py test letters.tests.ProfileAddressCollectionTests -v` 984 + Expected: FAIL (fields don't exist) 985 + 986 + **Step 3: Add address fields to form** 987 + 988 + Modify `website/letters/forms.py` to add address fields to the verification form: 989 + 990 + ```python 991 + class SelfDeclaredVerificationForm(forms.Form): 992 + """Form for self-declared constituency verification.""" 993 + 994 + street = forms.CharField( 995 + max_length=255, 996 + required=False, 997 + label=_("Street and Number"), 998 + help_text=_("Optional: Improves constituency matching accuracy"), 999 + widget=forms.TextInput(attrs={ 1000 + 'placeholder': _('Unter den Linden 77'), 1001 + 'class': 'form-control' 1002 + }) 1003 + ) 1004 + 1005 + postal_code = forms.CharField( 1006 + max_length=10, 1007 + required=True, 1008 + label=_("Postal Code"), 1009 + widget=forms.TextInput(attrs={ 1010 + 'placeholder': '10117', 1011 + 'class': 'form-control' 1012 + }) 1013 + ) 1014 + 1015 + city = forms.CharField( 1016 + max_length=100, 1017 + required=True, 1018 + label=_("City"), 1019 + widget=forms.TextInput(attrs={ 1020 + 'placeholder': 'Berlin', 1021 + 'class': 'form-control' 1022 + }) 1023 + ) 1024 + 1025 + federal_constituency = forms.ModelChoiceField( 1026 + queryset=Constituency.objects.filter(scope='FEDERAL_DISTRICT'), 1027 + required=False, 1028 + label=_("Federal Constituency (optional)"), 1029 + help_text=_("Leave blank for automatic detection"), 1030 + widget=forms.Select(attrs={'class': 'form-control'}) 1031 + ) 1032 + 1033 + state_constituency = forms.ModelChoiceField( 1034 + queryset=Constituency.objects.filter(scope__in=['STATE_DISTRICT', 'STATE_LIST']), 1035 + required=False, 1036 + label=_("State Constituency (optional)"), 1037 + help_text=_("Leave blank for automatic detection"), 1038 + widget=forms.Select(attrs={'class': 'form-control'}) 1039 + ) 1040 + ``` 1041 + 1042 + **Step 4: Update template to show address fields** 1043 + 1044 + Modify `website/letters/templates/letters/profile.html` to show the new fields in the verification form section. 1045 + 1046 + **Step 5: Update view to use address for verification** 1047 + 1048 + Modify the `complete_verification` or equivalent view in `website/letters/views.py` to use the address fields: 1049 + 1050 + ```python 1051 + def complete_verification(request): 1052 + if request.method == 'POST': 1053 + form = SelfDeclaredVerificationForm(request.POST) 1054 + if form.is_valid(): 1055 + street = form.cleaned_data.get('street', '') 1056 + postal_code = form.cleaned_data['postal_code'] 1057 + city = form.cleaned_data['city'] 1058 + 1059 + # Use address-based lookup if full address provided 1060 + if city: 1061 + located = ConstituencyLocator.locate_from_address( 1062 + street, postal_code, city 1063 + ) 1064 + else: 1065 + located = ConstituencyLocator.locate(postal_code) 1066 + 1067 + federal = form.cleaned_data.get('federal_constituency') or located.federal 1068 + state = form.cleaned_data.get('state_constituency') or located.state 1069 + 1070 + IdentityVerificationService.self_declare( 1071 + request.user, 1072 + federal_constituency=federal, 1073 + state_constituency=state, 1074 + ) 1075 + 1076 + messages.success(request, _('Your constituency has been saved.')) 1077 + return redirect('profile') 1078 + else: 1079 + form = SelfDeclaredVerificationForm() 1080 + 1081 + return render(request, 'letters/complete_verification.html', {'form': form}) 1082 + ``` 1083 + 1084 + **Step 6: Run test to verify it passes** 1085 + 1086 + Run: `uv run python manage.py test letters.tests.ProfileAddressCollectionTests -v` 1087 + Expected: PASS 1088 + 1089 + **Step 7: Test manually in browser** 1090 + 1091 + 1. Run dev server: `uv run python manage.py runserver` 1092 + 2. Navigate to /profile/verify/ 1093 + 3. Verify address fields are visible 1094 + 4. Submit form with full address 1095 + 5. Check that constituency is correctly detected 1096 + 1097 + **Step 8: Commit** 1098 + 1099 + ```bash 1100 + git add website/letters/forms.py website/letters/templates/letters/profile.html website/letters/views.py website/letters/tests.py 1101 + git commit -m "feat: collect full address for accurate constituency matching" 1102 + ``` 1103 + 1104 + --- 1105 + 1106 + ## Task 8: Add Management Command to Test Matching 1107 + 1108 + **Files:** 1109 + - Create: `website/letters/management/commands/test_address_matching.py` 1110 + 1111 + **Step 1: Create test command** 1112 + 1113 + Create `website/letters/management/commands/test_address_matching.py`: 1114 + 1115 + ```python 1116 + from django.core.management.base import BaseCommand 1117 + from letters.services import AddressGeocoder, WahlkreisLocator, ConstituencyLocator 1118 + 1119 + 1120 + class Command(BaseCommand): 1121 + help = "Test address matching with sample German addresses" 1122 + 1123 + TEST_ADDRESSES = [ 1124 + # Berlin 1125 + ("Unter den Linden 77", "10117", "Berlin"), 1126 + ("Pariser Platz 1", "10117", "Berlin"), 1127 + 1128 + # Munich 1129 + ("Marienplatz 8", "80331", "Mรผnchen"), 1130 + ("LeopoldstraรŸe 1", "80802", "Mรผnchen"), 1131 + 1132 + # Hamburg 1133 + ("Rathausmarkt 1", "20095", "Hamburg"), 1134 + ("Jungfernstieg 1", "20095", "Hamburg"), 1135 + 1136 + # Cologne 1137 + ("Rathausplatz 2", "50667", "Kรถln"), 1138 + 1139 + # Frankfurt 1140 + ("Rรถmerberg 27", "60311", "Frankfurt am Main"), 1141 + ] 1142 + 1143 + def handle(self, *args, **options): 1144 + self.stdout.write(self.style.SUCCESS("Testing Address Matching\n")) 1145 + 1146 + for street, plz, city in self.TEST_ADDRESSES: 1147 + self.stdout.write(f"\n{street}, {plz} {city}") 1148 + self.stdout.write("-" * 60) 1149 + 1150 + # Test geocoding 1151 + coords = AddressGeocoder.geocode(street, plz, city) 1152 + if coords: 1153 + lat, lng = coords 1154 + self.stdout.write(f" Coordinates: {lat:.6f}, {lng:.6f}") 1155 + 1156 + # Test constituency lookup 1157 + constituency = WahlkreisLocator.find_constituency(lat, lng) 1158 + if constituency: 1159 + self.stdout.write(self.style.SUCCESS( 1160 + f" โœ“ Constituency: {constituency.name} (ID: {constituency.external_id})" 1161 + )) 1162 + else: 1163 + self.stdout.write(self.style.WARNING(" โš  No constituency found")) 1164 + else: 1165 + self.stdout.write(self.style.ERROR(" โœ— Geocoding failed")) 1166 + 1167 + # Small delay to respect rate limits 1168 + import time 1169 + time.sleep(1.1) 1170 + 1171 + self.stdout.write("\n" + self.style.SUCCESS("Testing complete!")) 1172 + ``` 1173 + 1174 + **Step 2: Run test command** 1175 + 1176 + Run: `uv run python manage.py test_address_matching` 1177 + Expected: Shows results for 8 test addresses 1178 + 1179 + **Step 3: Review results and fix any issues** 1180 + 1181 + Check that: 1182 + - All addresses geocode successfully 1183 + - Constituencies are found for each address 1184 + - Results match expected Wahlkreise 1185 + 1186 + **Step 4: Commit** 1187 + 1188 + ```bash 1189 + git add website/letters/management/commands/test_address_matching.py 1190 + git commit -m "feat: add management command to test address matching" 1191 + ``` 1192 + 1193 + --- 1194 + 1195 + ## Task 9: Performance Optimization and Monitoring 1196 + 1197 + **Files:** 1198 + - Modify: `website/letters/services.py` (add metrics/monitoring) 1199 + - Create: `website/letters/middleware.py` (optional) 1200 + 1201 + **Step 1: Add logging for matching performance** 1202 + 1203 + Add to `WahlkreisLocator.find_constituency`: 1204 + 1205 + ```python 1206 + import time 1207 + 1208 + @classmethod 1209 + def find_constituency(cls, latitude: float, longitude: float) -> Optional[Constituency]: 1210 + start_time = time.time() 1211 + 1212 + cls._load_geojson() 1213 + 1214 + # ... existing implementation ... 1215 + 1216 + elapsed = time.time() - start_time 1217 + logger.info(f"Constituency lookup took {elapsed*1000:.1f}ms") 1218 + 1219 + return result 1220 + ``` 1221 + 1222 + **Step 2: Add cache warming on startup** 1223 + 1224 + Add Django app ready hook to pre-load GeoJSON: 1225 + 1226 + Modify `website/letters/apps.py`: 1227 + 1228 + ```python 1229 + from django.apps import AppConfig 1230 + 1231 + 1232 + class LettersConfig(AppConfig): 1233 + default_auto_field = 'django.db.models.BigAutoField' 1234 + name = 'letters' 1235 + 1236 + def ready(self): 1237 + """Pre-load GeoJSON data on startup.""" 1238 + from .services import WahlkreisLocator 1239 + WahlkreisLocator._load_geojson() 1240 + ``` 1241 + 1242 + **Step 3: Test performance** 1243 + 1244 + Run: `uv run python -m django shell` 1245 + 1246 + ```python 1247 + from letters.services import WahlkreisLocator 1248 + import time 1249 + 1250 + # Test lookup performance 1251 + start = time.time() 1252 + result = WahlkreisLocator.find_constituency(52.5170365, 13.3888599) 1253 + elapsed = time.time() - start 1254 + 1255 + print(f"Lookup took {elapsed*1000:.1f}ms") 1256 + print(f"Found: {result.name if result else 'None'}") 1257 + ``` 1258 + 1259 + Expected: < 50ms per lookup 1260 + 1261 + **Step 4: Commit** 1262 + 1263 + ```bash 1264 + git add website/letters/services.py website/letters/apps.py 1265 + git commit -m "perf: optimize constituency lookup with startup cache warming" 1266 + ``` 1267 + 1268 + --- 1269 + 1270 + ## Task 10: Documentation and README 1271 + 1272 + **Files:** 1273 + - Modify: `README.md` 1274 + - Create: `docs/matching-algorithm.md` 1275 + 1276 + **Step 1: Document matching algorithm** 1277 + 1278 + Create `docs/matching-algorithm.md`: 1279 + 1280 + ```markdown 1281 + # Constituency Matching Algorithm 1282 + 1283 + ## Overview 1284 + 1285 + WriteThem.eu uses a two-stage process to match users to their correct Bundestag constituency: 1286 + 1287 + 1. **Address Geocoding**: Convert user's address to latitude/longitude coordinates 1288 + 2. **Point-in-Polygon Lookup**: Find which constituency polygon contains those coordinates 1289 + 1290 + ## Stage 1: Address Geocoding 1291 + 1292 + We use OpenStreetMap's Nominatim API to convert addresses to coordinates. 1293 + 1294 + ### Process: 1295 + 1. User provides: Street, Postal Code, City 1296 + 2. System checks cache (GeocodeCache table) for previous results 1297 + 3. If not cached, query Nominatim API with rate limiting (1 req/sec) 1298 + 4. Cache result (success or failure) to minimize API calls 1299 + 5. Return (latitude, longitude) or None 1300 + 1301 + ### Fallback: 1302 + If geocoding fails or user only provides postal code, fall back to PLZ prefix heuristic (maps first 2 digits to state). 1303 + 1304 + ## Stage 2: Point-in-Polygon Lookup 1305 + 1306 + We use official Bundestag constituency boundaries (GeoJSON format) with shapely for geometric queries. 1307 + 1308 + ### Process: 1309 + 1. Load GeoJSON with 299 Bundestag constituencies on startup 1310 + 2. Create shapely Point from coordinates 1311 + 3. Check which constituency Polygon contains the point 1312 + 4. Look up Constituency object in database by external_id 1313 + 5. Return Constituency or None 1314 + 1315 + ### Performance: 1316 + - GeoJSON loaded once at startup (~2MB in memory) 1317 + - Lookup typically takes 10-50ms 1318 + - No external API calls required 1319 + 1320 + ## Data Sources 1321 + 1322 + - **Constituency Boundaries**: [dknx01/wahlkreissuche](https://github.com/dknx01/wahlkreissuche) (Open Data) 1323 + - **Geocoding**: [OpenStreetMap Nominatim](https://nominatim.openstreetmap.org/) (Open Data) 1324 + - **Representative Data**: [Abgeordnetenwatch API](https://www.abgeordnetenwatch.de/api) 1325 + 1326 + ## Accuracy 1327 + 1328 + This approach provides constituency-accurate matching (exact Wahlkreis), significantly more precise than PLZ-based heuristics which only provide state-level accuracy. 1329 + 1330 + ### Known Limitations: 1331 + - Requires valid German address 1332 + - Dependent on OSM geocoding quality 1333 + - Rate limited to 1 request/second (public API) 1334 + ``` 1335 + 1336 + **Step 2: Update README with setup instructions** 1337 + 1338 + Add to `README.md`: 1339 + 1340 + ```markdown 1341 + ## Setup: Constituency Matching 1342 + 1343 + WriteThem.eu uses accurate address-based constituency matching. Setup requires two steps: 1344 + 1345 + ### 1. Download Constituency Boundaries 1346 + 1347 + ```bash 1348 + uv run python manage.py fetch_wahlkreis_data 1349 + ``` 1350 + 1351 + This downloads ~2MB of GeoJSON data containing official Bundestag constituency boundaries. 1352 + 1353 + ### 2. Test Matching 1354 + 1355 + Test the matching system with sample addresses: 1356 + 1357 + ```bash 1358 + uv run python manage.py test_address_matching 1359 + ``` 1360 + 1361 + You should see successful geocoding and constituency detection for major German cities. 1362 + 1363 + ### Configuration 1364 + 1365 + Set in your environment or settings: 1366 + 1367 + ```python 1368 + # Optional: Use self-hosted Nominatim (recommended for production) 1369 + NOMINATIM_URL = 'https://your-nominatim-server.com/search' 1370 + 1371 + # Optional: Custom GeoJSON path 1372 + CONSTITUENCY_BOUNDARIES_PATH = '/path/to/wahlkreise.geojson' 1373 + ``` 1374 + 1375 + See `docs/matching-algorithm.md` for technical details. 1376 + ``` 1377 + 1378 + **Step 3: Commit** 1379 + 1380 + ```bash 1381 + git add README.md docs/matching-algorithm.md 1382 + git commit -m "docs: document constituency matching algorithm" 1383 + ``` 1384 + 1385 + --- 1386 + 1387 + ## Plan Complete 1388 + 1389 + **Total Implementation Time: ~5-8 hours** (experienced developer, TDD approach) 1390 + 1391 + **Testing Checklist:** 1392 + - [ ] All unit tests pass 1393 + - [ ] Integration tests pass 1394 + - [ ] Manual testing with 10+ real addresses 1395 + - [ ] Performance < 100ms end-to-end 1396 + - [ ] Geocoding cache reducing API calls 1397 + 1398 + **Next Steps:** 1399 + Run full test suite: 1400 + ```bash 1401 + uv run python manage.py test letters 1402 + ``` 1403 + 1404 + Expected: All tests pass (20+ existing tests + ~15 new tests = 35+ total) 1405 + 1406 + **Deployment Notes:** 1407 + - Download GeoJSON as part of deployment process 1408 + - Consider self-hosted Nominatim for production (no rate limits) 1409 + - Monitor geocoding cache hit rate 1410 + - Set up alerts for geocoding failures 1411 + 1412 + --- 1413 + 1414 + This plan implements **Week 1-2, Track 1 (Days 1-5)** from the MVP roadmap. After completing this, proceed to Track 2 (UX Polish) tasks.
+1127
docs/plans/2025-10-14-i18n-implementation.md
··· 1 + # German + English Internationalization Implementation Plan 2 + 3 + > **For Claude:** Use `${SUPERPOWERS_SKILLS_ROOT}/skills/collaboration/executing-plans/SKILL.md` to implement this plan task-by-task. 4 + 5 + **Goal:** Implement full bilingual support (German + English) using Django's built-in i18n system. 6 + 7 + **Architecture:** Configure Django i18n settings, wrap all UI strings in gettext translation functions, create German and English locale files (.po), add language switcher component, and configure URL patterns with language prefixes. 8 + 9 + **Tech Stack:** Django 5.2 i18n framework, gettext, .po/.mo translation files, LocaleMiddleware 10 + 11 + --- 12 + 13 + ## Task 1: Configure Django i18n Settings 14 + 15 + **Files:** 16 + - Modify: `website/writethem/settings.py:104-146` 17 + 18 + **Step 1: Write the failing test** 19 + 20 + Create: `website/letters/tests/test_i18n.py` 21 + 22 + ```python 23 + # ABOUTME: Tests for internationalization configuration and functionality. 24 + # ABOUTME: Verifies language switching, URL prefixes, and translation completeness. 25 + 26 + from django.test import TestCase 27 + from django.conf import settings 28 + 29 + 30 + class I18nConfigurationTests(TestCase): 31 + def test_i18n_enabled(self): 32 + """Test that USE_I18N is enabled.""" 33 + self.assertTrue(settings.USE_I18N) 34 + 35 + def test_supported_languages(self): 36 + """Test that German and English are configured.""" 37 + language_codes = [code for code, name in settings.LANGUAGES] 38 + self.assertIn('de', language_codes) 39 + self.assertIn('en', language_codes) 40 + 41 + def test_locale_paths_configured(self): 42 + """Test that LOCALE_PATHS is set.""" 43 + self.assertTrue(len(settings.LOCALE_PATHS) > 0) 44 + ``` 45 + 46 + **Step 2: Run test to verify it fails** 47 + 48 + Run: `uv run python manage.py test letters.tests.test_i18n::I18nConfigurationTests -v` 49 + Expected: FAIL with assertion errors (USE_I18N=False, LANGUAGES not configured, LOCALE_PATHS not set) 50 + 51 + **Step 3: Update settings.py** 52 + 53 + In `website/writethem/settings.py`, replace lines 104-114: 54 + 55 + ```python 56 + # Internationalization 57 + # https://docs.djangoproject.com/en/5.2/topics/i18n/ 58 + 59 + LANGUAGE_CODE = 'de' 60 + LANGUAGES = [ 61 + ('de', 'Deutsch'), 62 + ('en', 'English'), 63 + ] 64 + 65 + TIME_ZONE = 'Europe/Berlin' 66 + 67 + USE_I18N = True 68 + USE_L10N = True 69 + 70 + USE_TZ = True 71 + 72 + # Locale paths - where Django looks for .po files 73 + LOCALE_PATHS = [ 74 + BASE_DIR / 'locale', 75 + ] 76 + ``` 77 + 78 + **Step 4: Add LocaleMiddleware** 79 + 80 + In `website/writethem/settings.py`, modify MIDDLEWARE list (lines 43-51): 81 + 82 + ```python 83 + MIDDLEWARE = [ 84 + 'django.middleware.security.SecurityMiddleware', 85 + 'django.contrib.sessions.middleware.SessionMiddleware', 86 + 'django.middleware.locale.LocaleMiddleware', # NEW - handles language detection 87 + 'django.middleware.common.CommonMiddleware', 88 + 'django.middleware.csrf.CsrfViewMiddleware', 89 + 'django.contrib.auth.middleware.AuthenticationMiddleware', 90 + 'django.contrib.messages.middleware.MessageMiddleware', 91 + 'django.middleware.clickjacking.XFrameOptionsMiddleware', 92 + ] 93 + ``` 94 + 95 + **Step 5: Run test to verify it passes** 96 + 97 + Run: `uv run python manage.py test letters.tests.test_i18n::I18nConfigurationTests -v` 98 + Expected: PASS (3 tests) 99 + 100 + **Step 6: Commit** 101 + 102 + ```bash 103 + git add website/writethem/settings.py website/letters/tests/test_i18n.py 104 + git commit -m "feat: configure Django i18n with German and English support" 105 + ``` 106 + 107 + --- 108 + 109 + ## Task 2: Configure URL Patterns with Language Prefixes 110 + 111 + **Files:** 112 + - Modify: `website/writethem/urls.py` 113 + 114 + **Step 1: Write the failing test** 115 + 116 + Add to `website/letters/tests/test_i18n.py`: 117 + 118 + ```python 119 + class I18nURLTests(TestCase): 120 + def test_german_url_prefix_works(self): 121 + """Test that German URL prefix is accessible.""" 122 + response = self.client.get('/de/') 123 + self.assertEqual(response.status_code, 200) 124 + 125 + def test_english_url_prefix_works(self): 126 + """Test that English URL prefix is accessible.""" 127 + response = self.client.get('/en/') 128 + self.assertEqual(response.status_code, 200) 129 + 130 + def test_set_language_endpoint_exists(self): 131 + """Test that language switcher endpoint exists.""" 132 + from django.urls import reverse 133 + url = reverse('set_language') 134 + self.assertEqual(url, '/i18n/setlang/') 135 + ``` 136 + 137 + **Step 2: Run test to verify it fails** 138 + 139 + Run: `uv run python manage.py test letters.tests.test_i18n::I18nURLTests -v` 140 + Expected: FAIL (URLs not configured with language prefixes) 141 + 142 + **Step 3: Update URLs configuration** 143 + 144 + Replace entire contents of `website/writethem/urls.py`: 145 + 146 + ```python 147 + """ 148 + URL configuration for writethem project. 149 + 150 + The `urlpatterns` list routes URLs to views. For more information please see: 151 + https://docs.djangoproject.com/en/5.2/topics/http/urls/ 152 + Examples: 153 + Function views 154 + 1. Add an import: from my_app import views 155 + 2. Add a URL to urlpatterns: path('', views.home, name='home') 156 + Class-based views 157 + 1. Add an import: from other_app.views import Home 158 + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 159 + Including another URLconf 160 + 1. Import the include() function: from django.urls import include, path 161 + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 162 + """ 163 + from django.contrib import admin 164 + from django.urls import path, include 165 + from django.conf import settings 166 + from django.conf.urls.static import static 167 + from django.conf.urls.i18n import i18n_patterns 168 + from django.views.i18n import set_language 169 + 170 + urlpatterns = [ 171 + # Language switcher endpoint (no prefix) 172 + path('i18n/setlang/', set_language, name='set_language'), 173 + ] 174 + 175 + # All user-facing URLs get language prefix 176 + urlpatterns += i18n_patterns( 177 + path('admin/', admin.site.urls), 178 + path('', include('letters.urls')), 179 + prefix_default_language=True, 180 + ) 181 + 182 + if settings.DEBUG: 183 + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 184 + ``` 185 + 186 + **Step 4: Run test to verify it passes** 187 + 188 + Run: `uv run python manage.py test letters.tests.test_i18n::I18nURLTests -v` 189 + Expected: PASS (3 tests) 190 + 191 + **Step 5: Commit** 192 + 193 + ```bash 194 + git add website/writethem/urls.py 195 + git commit -m "feat: add i18n URL patterns with language prefixes" 196 + ``` 197 + 198 + --- 199 + 200 + ## Task 3: Create Locale Directory Structure 201 + 202 + **Files:** 203 + - Create: `website/locale/` directory structure 204 + 205 + **Step 1: Create directory structure** 206 + 207 + Run: 208 + ```bash 209 + cd website 210 + mkdir -p locale/de/LC_MESSAGES 211 + mkdir -p locale/en/LC_MESSAGES 212 + ``` 213 + 214 + **Step 2: Verify directories exist** 215 + 216 + Run: `ls -la locale/` 217 + Expected: Shows `de/` and `en/` directories 218 + 219 + **Step 3: Create .gitkeep files** 220 + 221 + Run: 222 + ```bash 223 + touch locale/de/LC_MESSAGES/.gitkeep 224 + touch locale/en/LC_MESSAGES/.gitkeep 225 + ``` 226 + 227 + This ensures git tracks the directory structure even before .po files are created. 228 + 229 + **Step 4: Commit** 230 + 231 + ```bash 232 + git add locale/ 233 + git commit -m "feat: create locale directory structure for translations" 234 + ``` 235 + 236 + --- 237 + 238 + ## Task 4: Wrap Base Template Strings 239 + 240 + **Files:** 241 + - Modify: `website/letters/templates/letters/base.html` 242 + 243 + **Step 1: Review current base template** 244 + 245 + Run: `cat website/letters/templates/letters/base.html` 246 + 247 + Identify all hardcoded strings that need translation. 248 + 249 + **Step 2: Add i18n load tag and wrap strings** 250 + 251 + At the top of `website/letters/templates/letters/base.html`, add after the first line: 252 + 253 + ```django 254 + {% load i18n %} 255 + ``` 256 + 257 + Then wrap all user-facing strings with `{% trans %}` tags. For example: 258 + 259 + - Navigation links: `{% trans "Home" %}`, `{% trans "Letters" %}`, `{% trans "Login" %}`, etc. 260 + - Button text: `{% trans "Sign Out" %}`, `{% trans "Sign In" %}`, etc. 261 + - Any other UI text 262 + 263 + **Important:** The exact changes depend on the current template content. Wrap every hardcoded user-facing string. 264 + 265 + **Step 3: Test template renders without errors** 266 + 267 + Run: `uv run python manage.py runserver` 268 + Visit: `http://localhost:8000/de/` 269 + Expected: Page loads without template errors (strings still in English because .po files don't exist yet) 270 + 271 + **Step 4: Commit** 272 + 273 + ```bash 274 + git add website/letters/templates/letters/base.html 275 + git commit -m "feat: wrap base template strings with i18n tags" 276 + ``` 277 + 278 + --- 279 + 280 + ## Task 5: Add Language Switcher Component 281 + 282 + **Files:** 283 + - Modify: `website/letters/templates/letters/base.html` 284 + 285 + **Step 1: Write the failing test** 286 + 287 + Add to `website/letters/tests/test_i18n.py`: 288 + 289 + ```python 290 + class LanguageSwitcherTests(TestCase): 291 + def test_language_switcher_present_in_page(self): 292 + """Test that language switcher form is present.""" 293 + response = self.client.get('/de/') 294 + self.assertContains(response, 'name="language"') 295 + self.assertContains(response, 'Deutsch') 296 + self.assertContains(response, 'English') 297 + 298 + def test_language_switch_changes_language(self): 299 + """Test that submitting language form changes language.""" 300 + response = self.client.post( 301 + '/i18n/setlang/', 302 + {'language': 'en', 'next': '/en/'}, 303 + follow=True 304 + ) 305 + self.assertEqual(response.status_code, 200) 306 + # Check cookie was set 307 + self.assertIn('django_language', response.cookies) 308 + ``` 309 + 310 + **Step 2: Run test to verify it fails** 311 + 312 + Run: `uv run python manage.py test letters.tests.test_i18n::LanguageSwitcherTests -v` 313 + Expected: FAIL (language switcher not present) 314 + 315 + **Step 3: Add language switcher to base template** 316 + 317 + In `website/letters/templates/letters/base.html`, add this component in an appropriate location (e.g., in the header/navigation area): 318 + 319 + ```django 320 + <div class="language-switcher"> 321 + <form action="{% url 'set_language' %}" method="post"> 322 + {% csrf_token %} 323 + <input name="next" type="hidden" value="{{ request.get_full_path }}"> 324 + <select name="language" onchange="this.form.submit()" aria-label="{% trans 'Select language' %}"> 325 + {% get_current_language as CURRENT_LANGUAGE %} 326 + {% get_available_languages as AVAILABLE_LANGUAGES %} 327 + {% for lang_code, lang_name in AVAILABLE_LANGUAGES %} 328 + <option value="{{ lang_code }}" {% if lang_code == CURRENT_LANGUAGE %}selected{% endif %}> 329 + {{ lang_name }} 330 + </option> 331 + {% endfor %} 332 + </select> 333 + </form> 334 + </div> 335 + ``` 336 + 337 + **Step 4: Run test to verify it passes** 338 + 339 + Run: `uv run python manage.py test letters.tests.test_i18n::LanguageSwitcherTests -v` 340 + Expected: PASS (2 tests) 341 + 342 + **Step 5: Commit** 343 + 344 + ```bash 345 + git add website/letters/templates/letters/base.html 346 + git commit -m "feat: add language switcher component to base template" 347 + ``` 348 + 349 + --- 350 + 351 + ## Task 6: Wrap Authentication Template Strings 352 + 353 + **Files:** 354 + - Modify: `website/letters/templates/registration/login.html` 355 + - Modify: `website/letters/templates/registration/register.html` 356 + - Modify: `website/letters/templates/registration/password_reset_form.html` 357 + - Modify: `website/letters/templates/registration/password_reset_done.html` 358 + - Modify: `website/letters/templates/registration/password_reset_confirm.html` 359 + - Modify: `website/letters/templates/registration/password_reset_complete.html` 360 + 361 + **Step 1: Add i18n load tag to each template** 362 + 363 + For each template file listed above, add at the top (after `{% extends %}`): 364 + 365 + ```django 366 + {% load i18n %} 367 + ``` 368 + 369 + **Step 2: Wrap all strings with trans tags** 370 + 371 + For each template, wrap user-facing strings: 372 + - Headings: `<h1>{% trans "Login" %}</h1>` 373 + - Labels: `{% trans "Email" %}`, `{% trans "Password" %}` 374 + - Buttons: `{% trans "Sign In" %}`, `{% trans "Register" %}`, `{% trans "Reset Password" %}` 375 + - Messages: `{% trans "Forgot your password?" %}`, etc. 376 + 377 + **Step 3: Test templates render** 378 + 379 + Run: `uv run python manage.py runserver` 380 + Visit each auth page: 381 + - `/de/login/` 382 + - `/de/register/` 383 + - `/de/password-reset/` 384 + 385 + Expected: Pages load without errors 386 + 387 + **Step 4: Commit** 388 + 389 + ```bash 390 + git add website/letters/templates/registration/ 391 + git commit -m "feat: wrap authentication template strings with i18n tags" 392 + ``` 393 + 394 + --- 395 + 396 + ## Task 7: Wrap Letter List and Detail Template Strings 397 + 398 + **Files:** 399 + - Modify: `website/letters/templates/letters/letter_list.html` 400 + - Modify: `website/letters/templates/letters/letter_detail.html` 401 + 402 + **Step 1: Add i18n load tag** 403 + 404 + Add to both templates after `{% extends %}`: 405 + 406 + ```django 407 + {% load i18n %} 408 + ``` 409 + 410 + **Step 2: Wrap strings in letter_list.html** 411 + 412 + Wrap all user-facing strings: 413 + - Headings: `{% trans "Open Letters" %}` 414 + - Buttons: `{% trans "Write Letter" %}`, `{% trans "Filter" %}`, `{% trans "Sort" %}` 415 + - Labels: `{% trans "Topic" %}`, `{% trans "Signatures" %}` 416 + - Empty states: `{% trans "No letters found" %}` 417 + 418 + For pluralization (e.g., signature counts), use `{% blocktrans %}`: 419 + 420 + ```django 421 + {% blocktrans count counter=letter.signatures.count %} 422 + {{ counter }} signature 423 + {% plural %} 424 + {{ counter }} signatures 425 + {% endblocktrans %} 426 + ``` 427 + 428 + **Step 3: Wrap strings in letter_detail.html** 429 + 430 + Wrap all strings: 431 + - Buttons: `{% trans "Sign Letter" %}`, `{% trans "Remove Signature" %}`, `{% trans "Share" %}`, `{% trans "Report" %}` 432 + - Labels: `{% trans "Recipient" %}`, `{% trans "Published" %}`, `{% trans "Signatures" %}` 433 + - Messages: `{% trans "You have signed this letter" %}` 434 + 435 + **Step 4: Test templates render** 436 + 437 + Run: `uv run python manage.py runserver` 438 + Visit: `/de/letters/` and any letter detail page 439 + Expected: Pages load without errors 440 + 441 + **Step 5: Commit** 442 + 443 + ```bash 444 + git add website/letters/templates/letters/letter_list.html website/letters/templates/letters/letter_detail.html 445 + git commit -m "feat: wrap letter list and detail template strings with i18n tags" 446 + ``` 447 + 448 + --- 449 + 450 + ## Task 8: Wrap Letter Creation Template Strings 451 + 452 + **Files:** 453 + - Modify: `website/letters/templates/letters/letter_form.html` 454 + 455 + **Step 1: Add i18n load tag** 456 + 457 + ```django 458 + {% load i18n %} 459 + ``` 460 + 461 + **Step 2: Wrap all strings** 462 + 463 + Wrap: 464 + - Headings: `{% trans "Write an Open Letter" %}` 465 + - Form labels: `{% trans "Title" %}`, `{% trans "Content" %}`, `{% trans "Recipient" %}` 466 + - Help text: `{% trans "Minimum 500 characters" %}` 467 + - Warnings: `{% trans "Once published, letters cannot be edited" %}` 468 + - Buttons: `{% trans "Publish Letter" %}`, `{% trans "Preview" %}`, `{% trans "Cancel" %}` 469 + 470 + **Step 3: Update form class with verbose_name** 471 + 472 + Modify: `website/letters/forms.py` 473 + 474 + Add at the top: 475 + ```python 476 + from django.utils.translation import gettext_lazy as _ 477 + ``` 478 + 479 + For each form field, add `label` parameter: 480 + ```python 481 + title = forms.CharField( 482 + label=_("Title"), 483 + max_length=200, 484 + help_text=_("A clear, concise title for your letter") 485 + ) 486 + ``` 487 + 488 + **Step 4: Test template renders** 489 + 490 + Visit: `/de/letters/new/` 491 + Expected: Page loads without errors 492 + 493 + **Step 5: Commit** 494 + 495 + ```bash 496 + git add website/letters/templates/letters/letter_form.html website/letters/forms.py 497 + git commit -m "feat: wrap letter creation template and form strings with i18n" 498 + ``` 499 + 500 + --- 501 + 502 + ## Task 9: Wrap Profile and Account Template Strings 503 + 504 + **Files:** 505 + - Modify: `website/letters/templates/letters/profile.html` 506 + - Modify: `website/letters/templates/letters/account_delete.html` 507 + - Modify: Any other account-related templates 508 + 509 + **Step 1: Add i18n load tag to each template** 510 + 511 + ```django 512 + {% load i18n %} 513 + ``` 514 + 515 + **Step 2: Wrap all strings** 516 + 517 + Profile page: 518 + - Headings: `{% trans "Your Profile" %}`, `{% trans "Authored Letters" %}`, `{% trans "Signed Letters" %}` 519 + - Buttons: `{% trans "Edit Profile" %}`, `{% trans "Delete Account" %}` 520 + - Labels: `{% trans "Email" %}`, `{% trans "Verified" %}`, `{% trans "Unverified" %}` 521 + 522 + Account deletion: 523 + - Warnings: `{% trans "This action cannot be undone" %}` 524 + - Buttons: `{% trans "Confirm Deletion" %}`, `{% trans "Cancel" %}` 525 + 526 + **Step 3: Test templates render** 527 + 528 + Visit profile and account pages 529 + Expected: Pages load without errors 530 + 531 + **Step 4: Commit** 532 + 533 + ```bash 534 + git add website/letters/templates/letters/profile.html website/letters/templates/letters/account_delete.html 535 + git commit -m "feat: wrap profile and account template strings with i18n tags" 536 + ``` 537 + 538 + --- 539 + 540 + ## Task 10: Extract Translation Strings to .po Files 541 + 542 + **Files:** 543 + - Create: `website/locale/de/LC_MESSAGES/django.po` 544 + - Create: `website/locale/en/LC_MESSAGES/django.po` 545 + 546 + **Step 1: Run makemessages for German** 547 + 548 + Run: 549 + ```bash 550 + cd website 551 + uv run python manage.py makemessages -l de 552 + ``` 553 + 554 + Expected: Creates/updates `locale/de/LC_MESSAGES/django.po` with all translatable strings 555 + 556 + **Step 2: Run makemessages for English** 557 + 558 + Run: 559 + ```bash 560 + uv run python manage.py makemessages -l en 561 + ``` 562 + 563 + Expected: Creates/updates `locale/en/LC_MESSAGES/django.po` 564 + 565 + **Step 3: Verify .po files created** 566 + 567 + Run: 568 + ```bash 569 + ls -la locale/de/LC_MESSAGES/ 570 + ls -la locale/en/LC_MESSAGES/ 571 + ``` 572 + 573 + Expected: Both show `django.po` files 574 + 575 + **Step 4: Check .po file contents** 576 + 577 + Run: 578 + ```bash 579 + head -n 30 locale/de/LC_MESSAGES/django.po 580 + ``` 581 + 582 + Expected: Shows header and first few msgid/msgstr pairs 583 + 584 + **Step 5: Commit** 585 + 586 + ```bash 587 + git add locale/ 588 + git commit -m "feat: extract translatable strings to .po files" 589 + ``` 590 + 591 + --- 592 + 593 + ## Task 11: Translate German Strings in .po File 594 + 595 + **Files:** 596 + - Modify: `website/locale/de/LC_MESSAGES/django.po` 597 + 598 + **Step 1: Open German .po file** 599 + 600 + Open `website/locale/de/LC_MESSAGES/django.po` for editing 601 + 602 + **Step 2: Translate strings systematically** 603 + 604 + Go through each `msgid` and add German translation to `msgstr`: 605 + 606 + ```po 607 + #: letters/templates/letters/base.html:10 608 + msgid "Home" 609 + msgstr "Startseite" 610 + 611 + #: letters/templates/letters/base.html:11 612 + msgid "Letters" 613 + msgstr "Briefe" 614 + 615 + #: letters/templates/letters/base.html:12 616 + msgid "Sign In" 617 + msgstr "Anmelden" 618 + 619 + #: letters/templates/letters/base.html:13 620 + msgid "Sign Out" 621 + msgstr "Abmelden" 622 + 623 + #: letters/templates/letters/letter_list.html:5 624 + msgid "Open Letters" 625 + msgstr "Offene Briefe" 626 + 627 + #: letters/templates/letters/letter_list.html:8 628 + msgid "Write Letter" 629 + msgstr "Brief Schreiben" 630 + 631 + #: letters/templates/letters/letter_detail.html:15 632 + msgid "Sign Letter" 633 + msgstr "Brief Unterschreiben" 634 + 635 + #: letters/templates/letters/letter_detail.html:18 636 + msgid "Remove Signature" 637 + msgstr "Unterschrift Entfernen" 638 + 639 + #: letters/templates/letters/letter_detail.html:21 640 + msgid "Share" 641 + msgstr "Teilen" 642 + 643 + #: letters/templates/letters/letter_form.html:5 644 + msgid "Write an Open Letter" 645 + msgstr "Einen Offenen Brief Schreiben" 646 + 647 + #: letters/templates/letters/letter_form.html:10 648 + msgid "Title" 649 + msgstr "Titel" 650 + 651 + #: letters/templates/letters/letter_form.html:11 652 + msgid "Content" 653 + msgstr "Inhalt" 654 + 655 + #: letters/templates/letters/letter_form.html:12 656 + msgid "Minimum 500 characters" 657 + msgstr "Mindestens 500 Zeichen" 658 + 659 + #: letters/templates/letters/letter_form.html:15 660 + msgid "Once published, letters cannot be edited" 661 + msgstr "Nach Verรถffentlichung kรถnnen Briefe nicht mehr bearbeitet werden" 662 + 663 + #: letters/templates/letters/letter_form.html:20 664 + msgid "Publish Letter" 665 + msgstr "Brief Verรถffentlichen" 666 + 667 + #: letters/templates/registration/login.html:5 668 + msgid "Login" 669 + msgstr "Anmeldung" 670 + 671 + #: letters/templates/registration/login.html:10 672 + msgid "Email" 673 + msgstr "E-Mail" 674 + 675 + #: letters/templates/registration/login.html:11 676 + msgid "Password" 677 + msgstr "Passwort" 678 + 679 + #: letters/templates/registration/login.html:15 680 + msgid "Forgot your password?" 681 + msgstr "Passwort vergessen?" 682 + 683 + #: letters/templates/registration/register.html:5 684 + msgid "Register" 685 + msgstr "Registrieren" 686 + ``` 687 + 688 + **Note:** The exact strings will depend on what was extracted in Task 10. Translate ALL msgid entries systematically. 689 + 690 + **Step 3: Save the file** 691 + 692 + Ensure all translations are complete (no empty `msgstr ""` entries) 693 + 694 + **Step 4: Commit** 695 + 696 + ```bash 697 + git add locale/de/LC_MESSAGES/django.po 698 + git commit -m "feat: add German translations to .po file" 699 + ``` 700 + 701 + --- 702 + 703 + ## Task 12: Populate English .po File 704 + 705 + **Files:** 706 + - Modify: `website/locale/en/LC_MESSAGES/django.po` 707 + 708 + **Step 1: Open English .po file** 709 + 710 + Open `website/locale/en/LC_MESSAGES/django.po` for editing 711 + 712 + **Step 2: Add identity translations** 713 + 714 + For English, most translations are identity (msgstr = msgid): 715 + 716 + ```po 717 + #: letters/templates/letters/base.html:10 718 + msgid "Home" 719 + msgstr "Home" 720 + 721 + #: letters/templates/letters/base.html:11 722 + msgid "Letters" 723 + msgstr "Letters" 724 + ``` 725 + 726 + Go through all entries and copy msgid to msgstr (they should be identical for English). 727 + 728 + **Step 3: Save the file** 729 + 730 + **Step 4: Commit** 731 + 732 + ```bash 733 + git add locale/en/LC_MESSAGES/django.po 734 + git commit -m "feat: add English identity translations to .po file" 735 + ``` 736 + 737 + --- 738 + 739 + ## Task 13: Compile Translation Files 740 + 741 + **Files:** 742 + - Create: `website/locale/de/LC_MESSAGES/django.mo` 743 + - Create: `website/locale/en/LC_MESSAGES/django.mo` 744 + 745 + **Step 1: Run compilemessages** 746 + 747 + Run: 748 + ```bash 749 + cd website 750 + uv run python manage.py compilemessages 751 + ``` 752 + 753 + Expected: Creates `django.mo` files for both German and English 754 + 755 + **Step 2: Verify .mo files created** 756 + 757 + Run: 758 + ```bash 759 + ls -la locale/de/LC_MESSAGES/ 760 + ls -la locale/en/LC_MESSAGES/ 761 + ``` 762 + 763 + Expected: Both show `django.mo` files (binary format) 764 + 765 + **Step 3: Test translations work** 766 + 767 + Run: `uv run python manage.py runserver` 768 + 769 + Visit `/de/` - should show German interface 770 + Visit `/en/` - should show English interface 771 + 772 + Use language switcher to toggle between languages. 773 + 774 + **Step 4: Add .mo files to .gitignore** 775 + 776 + Modify `.gitignore` in repository root, add: 777 + ``` 778 + # Compiled translation files (generated from .po) 779 + *.mo 780 + ``` 781 + 782 + **Note:** .mo files are generated artifacts and don't need to be tracked in git. 783 + 784 + **Step 5: Commit** 785 + 786 + ```bash 787 + git add .gitignore 788 + git commit -m "chore: add compiled translation files to .gitignore" 789 + ``` 790 + 791 + --- 792 + 793 + ## Task 14: Create Translation Completeness Check Command 794 + 795 + **Files:** 796 + - Create: `website/letters/management/commands/check_translations.py` 797 + 798 + **Step 1: Write the failing test** 799 + 800 + Add to `website/letters/tests/test_i18n.py`: 801 + 802 + ```python 803 + from django.core.management import call_command 804 + from io import StringIO 805 + 806 + 807 + class TranslationCompletenessTests(TestCase): 808 + def test_check_translations_command_exists(self): 809 + """Test that check_translations command can be called.""" 810 + out = StringIO() 811 + call_command('check_translations', stdout=out) 812 + output = out.getvalue() 813 + self.assertIn('Deutsch', output) 814 + self.assertIn('English', output) 815 + ``` 816 + 817 + **Step 2: Run test to verify it fails** 818 + 819 + Run: `uv run python manage.py test letters.tests.test_i18n::TranslationCompletenessTests -v` 820 + Expected: FAIL (command doesn't exist) 821 + 822 + **Step 3: Create management command** 823 + 824 + Create: `website/letters/management/commands/check_translations.py` 825 + 826 + ```python 827 + # ABOUTME: Management command to check translation completeness and report coverage. 828 + # ABOUTME: Analyzes .po files to find untranslated strings and calculate coverage percentage. 829 + 830 + from django.core.management.base import BaseCommand 831 + from django.conf import settings 832 + import pathlib 833 + 834 + 835 + class Command(BaseCommand): 836 + help = "Check translation completeness for all configured languages" 837 + 838 + def add_arguments(self, parser): 839 + parser.add_argument( 840 + '--language', 841 + type=str, 842 + help='Check specific language (e.g., "de" or "en")', 843 + ) 844 + 845 + def handle(self, *args, **options): 846 + locale_paths = settings.LOCALE_PATHS 847 + languages = settings.LANGUAGES 848 + 849 + target_language = options.get('language') 850 + 851 + if target_language: 852 + languages_to_check = [(target_language, None)] 853 + else: 854 + languages_to_check = languages 855 + 856 + for lang_code, lang_name in languages_to_check: 857 + self.check_language(locale_paths[0], lang_code, lang_name) 858 + 859 + def check_language(self, locale_path, lang_code, lang_name): 860 + """Check translation completeness for a single language.""" 861 + po_file = pathlib.Path(locale_path) / lang_code / 'LC_MESSAGES' / 'django.po' 862 + 863 + if not po_file.exists(): 864 + self.stdout.write(self.style.ERROR( 865 + f"\n{lang_code}: No .po file found at {po_file}" 866 + )) 867 + return 868 + 869 + total = 0 870 + translated = 0 871 + untranslated = [] 872 + 873 + with open(po_file, 'r', encoding='utf-8') as f: 874 + current_msgid = None 875 + for line in f: 876 + line = line.strip() 877 + if line.startswith('msgid "') and not line.startswith('msgid ""'): 878 + current_msgid = line[7:-1] # Extract string between quotes 879 + total += 1 880 + elif line.startswith('msgstr "'): 881 + msgstr = line[8:-1] 882 + if msgstr: # Non-empty translation 883 + translated += 1 884 + elif current_msgid: 885 + untranslated.append(current_msgid) 886 + current_msgid = None 887 + 888 + if total == 0: 889 + self.stdout.write(self.style.WARNING( 890 + f"\n{lang_code}: No translatable strings found" 891 + )) 892 + return 893 + 894 + coverage = (translated / total) * 100 895 + display_name = lang_name if lang_name else lang_code 896 + 897 + self.stdout.write(self.style.SUCCESS( 898 + f"\n{display_name} ({lang_code}):" 899 + )) 900 + self.stdout.write(f" Total strings: {total}") 901 + self.stdout.write(f" Translated: {translated}") 902 + self.stdout.write(f" Untranslated: {len(untranslated)}") 903 + self.stdout.write(f" Coverage: {coverage:.1f}%") 904 + 905 + if untranslated: 906 + self.stdout.write(self.style.WARNING( 907 + f"\nMissing translations ({len(untranslated)}):" 908 + )) 909 + for msgid in untranslated[:10]: # Show first 10 910 + self.stdout.write(f" - {msgid}") 911 + if len(untranslated) > 10: 912 + self.stdout.write(f" ... and {len(untranslated) - 10} more") 913 + else: 914 + self.stdout.write(self.style.SUCCESS( 915 + "\nAll strings translated!" 916 + )) 917 + ``` 918 + 919 + **Step 4: Run test to verify it passes** 920 + 921 + Run: `uv run python manage.py test letters.tests.test_i18n::TranslationCompletenessTests -v` 922 + Expected: PASS 923 + 924 + **Step 5: Test command manually** 925 + 926 + Run: 927 + ```bash 928 + uv run python manage.py check_translations 929 + ``` 930 + 931 + Expected: Shows coverage report for both German and English 932 + 933 + **Step 6: Commit** 934 + 935 + ```bash 936 + git add website/letters/management/commands/check_translations.py 937 + git commit -m "feat: add check_translations management command" 938 + ``` 939 + 940 + --- 941 + 942 + ## Task 15: Update Documentation 943 + 944 + **Files:** 945 + - Modify: `README.md` 946 + - Modify: `docs/matching-algorithm.md` (add i18n section) 947 + 948 + **Step 1: Update README with i18n information** 949 + 950 + Add a new section to `README.md`: 951 + 952 + ```markdown 953 + ## Internationalization 954 + 955 + WriteThem.eu supports German (default) and English. 956 + 957 + ### Using the Site 958 + 959 + - Visit `/de/` for German interface 960 + - Visit `/en/` for English interface 961 + - Use the language switcher in the header to toggle languages 962 + - Language preference is saved in a cookie 963 + 964 + ### For Developers 965 + 966 + **Translation workflow:** 967 + 968 + 1. Wrap new UI strings with translation functions: 969 + - Templates: `{% trans "Text" %}` or `{% blocktrans %}` 970 + - Python: `gettext()` or `gettext_lazy()` 971 + 972 + 2. Extract strings to .po files: 973 + ```bash 974 + cd website 975 + uv run python manage.py makemessages -l de -l en 976 + ``` 977 + 978 + 3. Translate strings in `.po` files: 979 + - Edit `locale/de/LC_MESSAGES/django.po` (German translations) 980 + - Edit `locale/en/LC_MESSAGES/django.po` (English, mostly identity translations) 981 + 982 + 4. Compile translations: 983 + ```bash 984 + uv run python manage.py compilemessages 985 + ``` 986 + 987 + 5. Check translation completeness: 988 + ```bash 989 + uv run python manage.py check_translations 990 + ``` 991 + 992 + **Important:** All code, comments, and translation keys should be in English. Only .po files contain actual translations. 993 + ``` 994 + 995 + **Step 2: Add i18n section to matching-algorithm.md** 996 + 997 + Add at the end of `docs/matching-algorithm.md`: 998 + 999 + ```markdown 1000 + ## Internationalization 1001 + 1002 + The constituency matching system works identically in both German and English: 1003 + 1004 + - Addresses can be entered in German format (standard use case) 1005 + - UI language (German/English) does not affect geocoding or matching logic 1006 + - Representative names, constituency names, and geographic data remain in original German 1007 + - All user-facing labels and messages are translated 1008 + ``` 1009 + 1010 + **Step 3: Commit** 1011 + 1012 + ```bash 1013 + git add README.md docs/matching-algorithm.md 1014 + git commit -m "docs: add internationalization documentation" 1015 + ``` 1016 + 1017 + --- 1018 + 1019 + ## Task 16: Run Full Test Suite and Verify 1020 + 1021 + **Step 1: Run all tests** 1022 + 1023 + Run: 1024 + ```bash 1025 + cd website 1026 + uv run python manage.py test letters.tests.test_i18n letters.tests.test_address_matching letters.tests.test_topic_mapping letters.tests.test_constituency_suggestions 1027 + ``` 1028 + 1029 + Expected: All tests pass (check total count) 1030 + 1031 + **Step 2: Check translation completeness** 1032 + 1033 + Run: 1034 + ```bash 1035 + uv run python manage.py check_translations 1036 + ``` 1037 + 1038 + Expected: 100% coverage for both languages (or report any missing translations) 1039 + 1040 + **Step 3: Manual verification checklist** 1041 + 1042 + Run: `uv run python manage.py runserver` 1043 + 1044 + Test each page in both languages: 1045 + 1046 + **German (`/de/`):** 1047 + - [ ] Homepage loads in German 1048 + - [ ] Login page in German 1049 + - [ ] Register page in German 1050 + - [ ] Letter list in German 1051 + - [ ] Letter detail in German 1052 + - [ ] Letter creation form in German 1053 + - [ ] Profile page in German 1054 + - [ ] Language switcher works (toggles to English) 1055 + 1056 + **English (`/en/`):** 1057 + - [ ] Homepage loads in English 1058 + - [ ] Login page in English 1059 + - [ ] Register page in English 1060 + - [ ] Letter list in English 1061 + - [ ] Letter detail in English 1062 + - [ ] Letter creation form in English 1063 + - [ ] Profile page in English 1064 + - [ ] Language switcher works (toggles to German) 1065 + 1066 + **Step 4: Check for untranslated strings** 1067 + 1068 + While testing, look for any English text appearing on German pages (or vice versa). These indicate missed translations. 1069 + 1070 + If found, add them to .po files, compile, and test again. 1071 + 1072 + **Step 5: Create summary commit** 1073 + 1074 + ```bash 1075 + git add . 1076 + git commit -m "test: verify i18n implementation with full test suite" 1077 + ``` 1078 + 1079 + --- 1080 + 1081 + ## Verification Checklist 1082 + 1083 + Before merging this feature: 1084 + 1085 + - [ ] USE_I18N=True in settings 1086 + - [ ] LANGUAGES configured with German and English 1087 + - [ ] LOCALE_PATHS configured 1088 + - [ ] LocaleMiddleware added to MIDDLEWARE 1089 + - [ ] URL patterns use i18n_patterns() 1090 + - [ ] Language switcher present in base template 1091 + - [ ] All templates have `{% load i18n %}` 1092 + - [ ] All UI strings wrapped with `{% trans %}` or `{% blocktrans %}` 1093 + - [ ] German .po file fully translated (100% coverage) 1094 + - [ ] English .po file complete (identity translations) 1095 + - [ ] .mo files compile without errors 1096 + - [ ] check_translations command works 1097 + - [ ] All automated tests pass 1098 + - [ ] Manual testing in both languages successful 1099 + - [ ] Documentation updated 1100 + - [ ] No untranslated strings visible in UI 1101 + 1102 + --- 1103 + 1104 + ## Notes for Implementation 1105 + 1106 + **Language policy:** 1107 + - All code (variables, functions, classes): English 1108 + - All comments and docstrings: English 1109 + - All translation keys (msgid in .po): English 1110 + - .po files contain actual translations 1111 + 1112 + **Testing strategy:** 1113 + - TDD throughout: write test โ†’ verify fail โ†’ implement โ†’ verify pass โ†’ commit 1114 + - Run `uv run python manage.py test` frequently 1115 + - Use `uv run python manage.py runserver` for manual verification 1116 + - Use `check_translations` command to catch missing translations 1117 + 1118 + **Common pitfalls:** 1119 + - Forgetting `{% load i18n %}` at top of templates 1120 + - Not using `gettext_lazy` in models/forms (use lazy version for class-level strings) 1121 + - Mixing `{% trans %}` and `{% blocktrans %}` incorrectly (use blocktrans for variables) 1122 + - Not recompiling after editing .po files (run compilemessages) 1123 + 1124 + **Skills to reference:** 1125 + - @skills/testing/test-driven-development for TDD workflow 1126 + - @skills/debugging/systematic-debugging if tests fail unexpectedly 1127 + - @skills/collaboration/finishing-a-development-branch when merging back to feat/matching
+1102
docs/plans/2025-10-14-refactor-test-commands.md
··· 1 + # Refactor Test Commands Implementation Plan 2 + 3 + > **For Claude:** Use `${SUPERPOWERS_SKILLS_ROOT}/skills/collaboration/executing-plans/SKILL.md` to implement this plan task-by-task. 4 + 5 + **Goal:** Replace test-like management commands with proper Django tests and create new query commands for interactive debugging. 6 + 7 + **Architecture:** Extract testing logic from three management commands (`test_matching.py`, `test_constituency_suggestion.py`, `test_topic_mapping.py`) into proper Django test files, then create three new query commands (`query_wahlkreis`, `query_topics`, `query_representatives`) for interactive use, and finally delete the old test commands. 8 + 9 + **Tech Stack:** Django 5.2, Python 3.13, uv, Django test framework 10 + 11 + --- 12 + 13 + ## Task 1: Create test_address_matching.py test file 14 + 15 + **Files:** 16 + - Create: `website/letters/tests/test_address_matching.py` 17 + - Reference: `website/letters/management/commands/test_matching.py` (for test data and logic) 18 + 19 + **Step 1: Write the test file structure with TEST_ADDRESSES fixture** 20 + 21 + Create `website/letters/tests/test_address_matching.py`: 22 + 23 + ```python 24 + # ABOUTME: Test address-based constituency matching with geocoding and point-in-polygon lookup. 25 + # ABOUTME: Covers AddressGeocoder, WahlkreisLocator, and ConstituencyLocator services. 26 + 27 + from django.test import TestCase 28 + from unittest.mock import patch, MagicMock 29 + from letters.services import AddressGeocoder, WahlkreisLocator, ConstituencyLocator 30 + from letters.models import GeocodeCache, Representative 31 + 32 + 33 + # Test addresses covering all German states 34 + TEST_ADDRESSES = [ 35 + { 36 + 'name': 'Bundestag (Berlin)', 37 + 'street': 'Platz der Republik 1', 38 + 'postal_code': '11011', 39 + 'city': 'Berlin', 40 + 'expected_state': 'Berlin' 41 + }, 42 + { 43 + 'name': 'Hamburg Rathaus', 44 + 'street': 'Rathausmarkt 1', 45 + 'postal_code': '20095', 46 + 'city': 'Hamburg', 47 + 'expected_state': 'Hamburg' 48 + }, 49 + { 50 + 'name': 'Marienplatz Mรผnchen (Bavaria)', 51 + 'street': 'Marienplatz 1', 52 + 'postal_code': '80331', 53 + 'city': 'Mรผnchen', 54 + 'expected_state': 'Bayern' 55 + }, 56 + { 57 + 'name': 'Kรถlner Dom (North Rhine-Westphalia)', 58 + 'street': 'Domkloster 4', 59 + 'postal_code': '50667', 60 + 'city': 'Kรถln', 61 + 'expected_state': 'Nordrhein-Westfalen' 62 + }, 63 + { 64 + 'name': 'Brandenburger Tor (Berlin)', 65 + 'street': 'Pariser Platz', 66 + 'postal_code': '10117', 67 + 'city': 'Berlin', 68 + 'expected_state': 'Berlin' 69 + }, 70 + ] 71 + 72 + 73 + class AddressGeocodingTests(TestCase): 74 + """Test address geocoding with OSM Nominatim.""" 75 + 76 + def setUp(self): 77 + self.geocoder = AddressGeocoder() 78 + 79 + def test_geocode_success_with_mocked_api(self): 80 + """Test successful geocoding with mocked Nominatim response.""" 81 + pass 82 + 83 + def test_geocode_caches_results(self): 84 + """Test that geocoding results are cached in database.""" 85 + pass 86 + 87 + def test_geocode_returns_cached_results(self): 88 + """Test that cached geocoding results are reused.""" 89 + pass 90 + 91 + def test_geocode_handles_api_error(self): 92 + """Test graceful handling of Nominatim API errors.""" 93 + pass 94 + 95 + 96 + class WahlkreisLocationTests(TestCase): 97 + """Test point-in-polygon constituency matching.""" 98 + 99 + def test_locate_bundestag_coordinates(self): 100 + """Test that Bundestag coordinates find correct Berlin constituency.""" 101 + pass 102 + 103 + def test_locate_hamburg_coordinates(self): 104 + """Test that Hamburg coordinates find correct constituency.""" 105 + pass 106 + 107 + def test_coordinates_outside_germany(self): 108 + """Test that coordinates outside Germany return None.""" 109 + pass 110 + 111 + 112 + class FullAddressMatchingTests(TestCase): 113 + """Integration tests for full address โ†’ constituency โ†’ representatives pipeline.""" 114 + 115 + @patch('letters.services.AddressGeocoder.geocode') 116 + def test_address_to_constituency_pipeline(self, mock_geocode): 117 + """Test full pipeline from address to constituency with mocked geocoding.""" 118 + pass 119 + 120 + def test_plz_fallback_when_geocoding_fails(self): 121 + """Test PLZ prefix fallback when geocoding fails.""" 122 + pass 123 + 124 + 125 + # End of file 126 + ``` 127 + 128 + **Step 2: Run test to verify structure loads** 129 + 130 + Run: `cd website && uv run python manage.py test letters.tests.test_address_matching` 131 + Expected: All tests should be discovered and skip (no implementations yet) 132 + 133 + **Step 3: Commit test file structure** 134 + 135 + ```bash 136 + git add website/letters/tests/test_address_matching.py 137 + git commit -m "test: add test_address_matching.py structure with fixtures" 138 + ``` 139 + 140 + --- 141 + 142 + ## Task 2: Implement address geocoding tests 143 + 144 + **Files:** 145 + - Modify: `website/letters/tests/test_address_matching.py` 146 + 147 + **Step 1: Implement test_geocode_success_with_mocked_api** 148 + 149 + In `AddressGeocodingTests` class, replace the pass statement: 150 + 151 + ```python 152 + def test_geocode_success_with_mocked_api(self): 153 + """Test successful geocoding with mocked Nominatim response.""" 154 + with patch('requests.get') as mock_get: 155 + # Mock successful Nominatim response 156 + mock_response = MagicMock() 157 + mock_response.status_code = 200 158 + mock_response.json.return_value = [{ 159 + 'lat': '52.5186', 160 + 'lon': '13.3761' 161 + }] 162 + mock_get.return_value = mock_response 163 + 164 + lat, lon, success, error = self.geocoder.geocode( 165 + 'Platz der Republik 1', 166 + '11011', 167 + 'Berlin' 168 + ) 169 + 170 + self.assertTrue(success) 171 + self.assertIsNone(error) 172 + self.assertAlmostEqual(lat, 52.5186, places=4) 173 + self.assertAlmostEqual(lon, 13.3761, places=4) 174 + ``` 175 + 176 + **Step 2: Implement test_geocode_caches_results** 177 + 178 + ```python 179 + def test_geocode_caches_results(self): 180 + """Test that geocoding results are cached in database.""" 181 + with patch('requests.get') as mock_get: 182 + mock_response = MagicMock() 183 + mock_response.status_code = 200 184 + mock_response.json.return_value = [{ 185 + 'lat': '52.5186', 186 + 'lon': '13.3761' 187 + }] 188 + mock_get.return_value = mock_response 189 + 190 + # First call should cache 191 + self.geocoder.geocode('Platz der Republik 1', '11011', 'Berlin') 192 + 193 + # Check cache entry exists 194 + cache_key = self.geocoder._generate_cache_key( 195 + 'Platz der Republik 1', '11011', 'Berlin', 'DE' 196 + ) 197 + cache_entry = GeocodeCache.objects.filter(address_hash=cache_key).first() 198 + self.assertIsNotNone(cache_entry) 199 + self.assertTrue(cache_entry.success) 200 + ``` 201 + 202 + **Step 3: Implement test_geocode_returns_cached_results** 203 + 204 + ```python 205 + def test_geocode_returns_cached_results(self): 206 + """Test that cached geocoding results are reused.""" 207 + # Create cache entry 208 + cache_key = self.geocoder._generate_cache_key( 209 + 'Test Street', '12345', 'Test City', 'DE' 210 + ) 211 + GeocodeCache.objects.create( 212 + address_hash=cache_key, 213 + success=True, 214 + latitude=52.0, 215 + longitude=13.0 216 + ) 217 + 218 + # Should return cached result without API call 219 + with patch('requests.get') as mock_get: 220 + lat, lon, success, error = self.geocoder.geocode( 221 + 'Test Street', '12345', 'Test City' 222 + ) 223 + 224 + # Verify no API call was made 225 + mock_get.assert_not_called() 226 + 227 + # Verify cached results returned 228 + self.assertTrue(success) 229 + self.assertEqual(lat, 52.0) 230 + self.assertEqual(lon, 13.0) 231 + ``` 232 + 233 + **Step 4: Implement test_geocode_handles_api_error** 234 + 235 + ```python 236 + def test_geocode_handles_api_error(self): 237 + """Test graceful handling of Nominatim API errors.""" 238 + with patch('requests.get') as mock_get: 239 + mock_get.side_effect = Exception("API Error") 240 + 241 + lat, lon, success, error = self.geocoder.geocode( 242 + 'Invalid Street', '99999', 'Nowhere' 243 + ) 244 + 245 + self.assertFalse(success) 246 + self.assertIsNone(lat) 247 + self.assertIsNone(lon) 248 + self.assertIn('API Error', error) 249 + ``` 250 + 251 + **Step 5: Run tests to verify they pass** 252 + 253 + Run: `cd website && uv run python manage.py test letters.tests.test_address_matching.AddressGeocodingTests -v` 254 + Expected: 4 tests PASS 255 + 256 + **Step 6: Commit geocoding tests** 257 + 258 + ```bash 259 + git add website/letters/tests/test_address_matching.py 260 + git commit -m "test: implement address geocoding tests with mocking" 261 + ``` 262 + 263 + --- 264 + 265 + ## Task 3: Implement Wahlkreis location tests 266 + 267 + **Files:** 268 + - Modify: `website/letters/tests/test_address_matching.py` 269 + 270 + **Step 1: Implement test_locate_bundestag_coordinates** 271 + 272 + In `WahlkreisLocationTests` class: 273 + 274 + ```python 275 + def test_locate_bundestag_coordinates(self): 276 + """Test that Bundestag coordinates find correct Berlin constituency.""" 277 + locator = WahlkreisLocator() 278 + result = locator.locate(52.5186, 13.3761) 279 + 280 + self.assertIsNotNone(result) 281 + wkr_nr, wkr_name, land_name = result 282 + self.assertIsInstance(wkr_nr, int) 283 + self.assertIn('Berlin', land_name) 284 + ``` 285 + 286 + **Step 2: Implement test_locate_hamburg_coordinates** 287 + 288 + ```python 289 + def test_locate_hamburg_coordinates(self): 290 + """Test that Hamburg coordinates find correct constituency.""" 291 + locator = WahlkreisLocator() 292 + result = locator.locate(53.5511, 9.9937) 293 + 294 + self.assertIsNotNone(result) 295 + wkr_nr, wkr_name, land_name = result 296 + self.assertIsInstance(wkr_nr, int) 297 + self.assertIn('Hamburg', land_name) 298 + ``` 299 + 300 + **Step 3: Implement test_coordinates_outside_germany** 301 + 302 + ```python 303 + def test_coordinates_outside_germany(self): 304 + """Test that coordinates outside Germany return None.""" 305 + locator = WahlkreisLocator() 306 + 307 + # Paris coordinates 308 + result = locator.locate(48.8566, 2.3522) 309 + self.assertIsNone(result) 310 + 311 + # London coordinates 312 + result = locator.locate(51.5074, -0.1278) 313 + self.assertIsNone(result) 314 + ``` 315 + 316 + **Step 4: Run tests to verify they pass** 317 + 318 + Run: `cd website && uv run python manage.py test letters.tests.test_address_matching.WahlkreisLocationTests -v` 319 + Expected: 3 tests PASS 320 + 321 + **Step 5: Commit Wahlkreis location tests** 322 + 323 + ```bash 324 + git add website/letters/tests/test_address_matching.py 325 + git commit -m "test: implement Wahlkreis point-in-polygon location tests" 326 + ``` 327 + 328 + --- 329 + 330 + ## Task 4: Implement full address matching integration tests 331 + 332 + **Files:** 333 + - Modify: `website/letters/tests/test_address_matching.py` 334 + 335 + **Step 1: Implement test_address_to_constituency_pipeline** 336 + 337 + In `FullAddressMatchingTests` class: 338 + 339 + ```python 340 + @patch('letters.services.AddressGeocoder.geocode') 341 + def test_address_to_constituency_pipeline(self, mock_geocode): 342 + """Test full pipeline from address to constituency with mocked geocoding.""" 343 + # Mock geocoding to return Bundestag coordinates 344 + mock_geocode.return_value = (52.5186, 13.3761, True, None) 345 + 346 + locator = ConstituencyLocator() 347 + representatives = locator.locate( 348 + street='Platz der Republik 1', 349 + postal_code='11011', 350 + city='Berlin' 351 + ) 352 + 353 + # Should return representatives (even if list is empty due to no DB data) 354 + self.assertIsInstance(representatives, list) 355 + mock_geocode.assert_called_once() 356 + ``` 357 + 358 + **Step 2: Implement test_plz_fallback_when_geocoding_fails** 359 + 360 + ```python 361 + def test_plz_fallback_when_geocoding_fails(self): 362 + """Test PLZ prefix fallback when geocoding fails.""" 363 + with patch('letters.services.AddressGeocoder.geocode') as mock_geocode: 364 + # Mock geocoding failure 365 + mock_geocode.return_value = (None, None, False, "Geocoding failed") 366 + 367 + locator = ConstituencyLocator() 368 + representatives = locator.locate( 369 + postal_code='10115' # Berlin postal code 370 + ) 371 + 372 + # Should still return list (using PLZ fallback) 373 + self.assertIsInstance(representatives, list) 374 + ``` 375 + 376 + **Step 3: Run tests to verify they pass** 377 + 378 + Run: `cd website && uv run python manage.py test letters.tests.test_address_matching.FullAddressMatchingTests -v` 379 + Expected: 2 tests PASS 380 + 381 + **Step 4: Run full test suite** 382 + 383 + Run: `cd website && uv run python manage.py test letters.tests.test_address_matching -v` 384 + Expected: All 9 tests PASS 385 + 386 + **Step 5: Commit integration tests** 387 + 388 + ```bash 389 + git add website/letters/tests/test_address_matching.py 390 + git commit -m "test: implement full address matching integration tests" 391 + ``` 392 + 393 + --- 394 + 395 + ## Task 5: Create test_topic_mapping.py test file 396 + 397 + **Files:** 398 + - Create: `website/letters/tests/test_topic_mapping.py` 399 + - Reference: `website/letters/management/commands/test_topic_mapping.py` (for test data) 400 + 401 + **Step 1: Write test file with topic matching tests** 402 + 403 + Create `website/letters/tests/test_topic_mapping.py`: 404 + 405 + ```python 406 + # ABOUTME: Test topic suggestion and matching based on letter content. 407 + # ABOUTME: Covers TopicSuggestionService keyword matching and level suggestion logic. 408 + 409 + from django.test import TestCase 410 + from letters.services import TopicSuggestionService 411 + from letters.models import TopicArea 412 + 413 + 414 + class TopicMatchingTests(TestCase): 415 + """Test topic keyword matching and scoring.""" 416 + 417 + def test_transport_keywords_match_verkehr_topic(self): 418 + """Test that transport-related keywords match Verkehr topic.""" 419 + concern = "I want to see better train connections between cities" 420 + topics = TopicSuggestionService.get_topic_suggestions(concern) 421 + 422 + # Should find at least one topic 423 + self.assertGreater(len(topics), 0) 424 + 425 + # Top topic should be transport-related 426 + top_topic = topics[0] 427 + self.assertIn('score', top_topic) 428 + self.assertGreater(top_topic['score'], 0) 429 + 430 + def test_housing_keywords_match_wohnen_topic(self): 431 + """Test that housing keywords match Wohnen topic.""" 432 + concern = "We need more affordable housing and rent control" 433 + topics = TopicSuggestionService.get_topic_suggestions(concern) 434 + 435 + self.assertGreater(len(topics), 0) 436 + 437 + def test_education_keywords_match_bildung_topic(self): 438 + """Test that education keywords match Bildung topic.""" 439 + concern = "Our school curriculum needs reform" 440 + topics = TopicSuggestionService.get_topic_suggestions(concern) 441 + 442 + self.assertGreater(len(topics), 0) 443 + 444 + def test_climate_keywords_match_umwelt_topic(self): 445 + """Test that climate keywords match environment topic.""" 446 + concern = "Climate protection and CO2 emissions must be addressed" 447 + topics = TopicSuggestionService.get_topic_suggestions(concern) 448 + 449 + self.assertGreater(len(topics), 0) 450 + 451 + def test_no_match_returns_empty_list(self): 452 + """Test that completely unrelated text returns empty list.""" 453 + concern = "xyzabc nonsense gibberish" 454 + topics = TopicSuggestionService.get_topic_suggestions(concern) 455 + 456 + # May return empty or very low scores 457 + if topics: 458 + self.assertLess(topics[0]['score'], 0.3) 459 + 460 + 461 + class LevelSuggestionTests(TestCase): 462 + """Test government level suggestion logic.""" 463 + 464 + def test_federal_transport_suggests_federal_level(self): 465 + """Test that long-distance transport suggests federal level.""" 466 + result = TopicSuggestionService.suggest_representatives_for_concern( 467 + "Deutsche Bahn is always late", 468 + limit=5 469 + ) 470 + 471 + self.assertIn('suggested_level', result) 472 + self.assertIn('explanation', result) 473 + # Federal issues should suggest Bundestag 474 + self.assertIn('Bundestag', result['suggested_level']) 475 + 476 + def test_local_bus_suggests_state_or_local(self): 477 + """Test that local transport suggests state/local level.""" 478 + result = TopicSuggestionService.suggest_representatives_for_concern( 479 + "Better bus services in my town", 480 + limit=5 481 + ) 482 + 483 + self.assertIn('suggested_level', result) 484 + # Local issues should not exclusively suggest federal 485 + explanation = result['explanation'].lower() 486 + self.assertTrue('state' in explanation or 'local' in explanation or 'land' in explanation) 487 + 488 + 489 + # End of file 490 + ``` 491 + 492 + **Step 2: Run tests to verify they work** 493 + 494 + Run: `cd website && uv run python manage.py test letters.tests.test_topic_mapping -v` 495 + Expected: Tests PASS (some may be skipped if TopicArea data not loaded) 496 + 497 + **Step 3: Commit topic mapping tests** 498 + 499 + ```bash 500 + git add website/letters/tests/test_topic_mapping.py 501 + git commit -m "test: add topic matching and level suggestion tests" 502 + ``` 503 + 504 + --- 505 + 506 + ## Task 6: Create test_constituency_suggestions.py test file 507 + 508 + **Files:** 509 + - Create: `website/letters/tests/test_constituency_suggestions.py` 510 + - Reference: `website/letters/management/commands/test_constituency_suggestion.py` 511 + 512 + **Step 1: Write test file for constituency suggestion service** 513 + 514 + Create `website/letters/tests/test_constituency_suggestions.py`: 515 + 516 + ```python 517 + # ABOUTME: Test ConstituencySuggestionService combining topics and geography. 518 + # ABOUTME: Integration tests for letter title/address to representative suggestions. 519 + 520 + from django.test import TestCase 521 + from unittest.mock import patch 522 + from letters.services import ConstituencySuggestionService 523 + 524 + 525 + class ConstituencySuggestionTests(TestCase): 526 + """Test constituency suggestion combining topic and address matching.""" 527 + 528 + @patch('letters.services.AddressGeocoder.geocode') 529 + def test_suggest_with_title_and_address(self, mock_geocode): 530 + """Test suggestions work with both title and address.""" 531 + # Mock geocoding 532 + mock_geocode.return_value = (52.5186, 13.3761, True, None) 533 + 534 + result = ConstituencySuggestionService.suggest_from_concern( 535 + concern="We need better train connections", 536 + street="Platz der Republik 1", 537 + postal_code="11011", 538 + city="Berlin" 539 + ) 540 + 541 + self.assertIn('matched_topics', result) 542 + self.assertIn('suggested_level', result) 543 + self.assertIn('explanation', result) 544 + self.assertIn('representatives', result) 545 + self.assertIn('constituencies', result) 546 + 547 + def test_suggest_with_only_title(self): 548 + """Test suggestions work with only title (no address).""" 549 + result = ConstituencySuggestionService.suggest_from_concern( 550 + concern="Climate protection is important" 551 + ) 552 + 553 + self.assertIn('matched_topics', result) 554 + self.assertIn('suggested_level', result) 555 + # Without address, should still suggest level and topics 556 + self.assertIsNotNone(result['suggested_level']) 557 + 558 + def test_suggest_with_only_postal_code(self): 559 + """Test suggestions work with only postal code.""" 560 + result = ConstituencySuggestionService.suggest_from_concern( 561 + concern="Local infrastructure problems", 562 + postal_code="10115" 563 + ) 564 + 565 + self.assertIn('constituencies', result) 566 + # Should use PLZ fallback 567 + self.assertIsInstance(result['constituencies'], list) 568 + 569 + 570 + # End of file 571 + ``` 572 + 573 + **Step 2: Run tests to verify they pass** 574 + 575 + Run: `cd website && uv run python manage.py test letters.tests.test_constituency_suggestions -v` 576 + Expected: 3 tests PASS 577 + 578 + **Step 3: Commit constituency suggestion tests** 579 + 580 + ```bash 581 + git add website/letters/tests/test_constituency_suggestions.py 582 + git commit -m "test: add constituency suggestion integration tests" 583 + ``` 584 + 585 + --- 586 + 587 + ## Task 7: Create query_wahlkreis management command 588 + 589 + **Files:** 590 + - Create: `website/letters/management/commands/query_wahlkreis.py` 591 + 592 + **Step 1: Write query_wahlkreis command** 593 + 594 + Create `website/letters/management/commands/query_wahlkreis.py`: 595 + 596 + ```python 597 + # ABOUTME: Query management command to find constituency by address or postal code. 598 + # ABOUTME: Interactive tool for testing address-based constituency matching. 599 + 600 + from django.core.management.base import BaseCommand 601 + from letters.services import AddressGeocoder, WahlkreisLocator, ConstituencyLocator 602 + 603 + 604 + class Command(BaseCommand): 605 + help = 'Find constituency (Wahlkreis) by address or postal code' 606 + 607 + def add_arguments(self, parser): 608 + parser.add_argument( 609 + '--street', 610 + type=str, 611 + help='Street name and number' 612 + ) 613 + parser.add_argument( 614 + '--postal-code', 615 + type=str, 616 + help='Postal code (PLZ)', 617 + required=True 618 + ) 619 + parser.add_argument( 620 + '--city', 621 + type=str, 622 + help='City name' 623 + ) 624 + 625 + def handle(self, *args, **options): 626 + street = options.get('street') 627 + postal_code = options['postal_code'] 628 + city = options.get('city') 629 + 630 + try: 631 + # Try full address geocoding if all parts provided 632 + if street and city: 633 + geocoder = AddressGeocoder() 634 + lat, lon, success, error = geocoder.geocode(street, postal_code, city) 635 + 636 + if not success: 637 + self.stdout.write(self.style.ERROR(f'Error: Could not geocode address: {error}')) 638 + return 639 + 640 + locator = WahlkreisLocator() 641 + result = locator.locate(lat, lon) 642 + 643 + if not result: 644 + self.stdout.write('No constituency found for these coordinates') 645 + return 646 + 647 + wkr_nr, wkr_name, land_name = result 648 + self.stdout.write(f'WK {wkr_nr:03d} - {wkr_name} ({land_name})') 649 + 650 + # Fallback to PLZ prefix lookup 651 + else: 652 + from letters.constants import PLZ_TO_STATE 653 + plz_prefix = postal_code[:2] 654 + 655 + if plz_prefix in PLZ_TO_STATE: 656 + state = PLZ_TO_STATE[plz_prefix] 657 + self.stdout.write(f'State: {state} (from postal code prefix)') 658 + else: 659 + self.stdout.write('Error: Could not determine state from postal code') 660 + 661 + except Exception as e: 662 + self.stderr.write(self.style.ERROR(f'Error: {str(e)}')) 663 + return 664 + ``` 665 + 666 + **Step 2: Test the command manually** 667 + 668 + Run: `cd website && uv run python manage.py query_wahlkreis --street "Platz der Republik 1" --postal-code "11011" --city "Berlin"` 669 + Expected: Output showing Berlin constituency 670 + 671 + Run: `cd website && uv run python manage.py query_wahlkreis --postal-code "10115"` 672 + Expected: Output showing "State: Berlin (from postal code prefix)" 673 + 674 + **Step 3: Commit query_wahlkreis command** 675 + 676 + ```bash 677 + git add website/letters/management/commands/query_wahlkreis.py 678 + git commit -m "feat: add query_wahlkreis management command" 679 + ``` 680 + 681 + --- 682 + 683 + ## Task 8: Create query_topics management command 684 + 685 + **Files:** 686 + - Create: `website/letters/management/commands/query_topics.py` 687 + 688 + **Step 1: Write query_topics command** 689 + 690 + Create `website/letters/management/commands/query_topics.py`: 691 + 692 + ```python 693 + # ABOUTME: Query management command to find matching topics for letter text. 694 + # ABOUTME: Interactive tool for testing topic keyword matching and scoring. 695 + 696 + from django.core.management.base import BaseCommand 697 + from letters.services import TopicSuggestionService 698 + 699 + 700 + class Command(BaseCommand): 701 + help = 'Find matching topics for a letter title or text' 702 + 703 + def add_arguments(self, parser): 704 + parser.add_argument( 705 + '--text', 706 + type=str, 707 + required=True, 708 + help='Letter title or text to analyze' 709 + ) 710 + parser.add_argument( 711 + '--limit', 712 + type=int, 713 + default=5, 714 + help='Maximum number of topics to return (default: 5)' 715 + ) 716 + 717 + def handle(self, *args, **options): 718 + text = options['text'] 719 + limit = options['limit'] 720 + 721 + try: 722 + topics = TopicSuggestionService.get_topic_suggestions(text) 723 + 724 + if not topics: 725 + self.stdout.write('No matching topics found') 726 + return 727 + 728 + # Limit results 729 + topics = topics[:limit] 730 + 731 + for topic in topics: 732 + score = topic.get('match_score', topic.get('score', 0)) 733 + self.stdout.write( 734 + f"{topic['name']} ({topic['level']}, Score: {score:.2f})" 735 + ) 736 + if 'description' in topic and topic['description']: 737 + self.stdout.write(f" {topic['description']}") 738 + 739 + except Exception as e: 740 + self.stderr.write(self.style.ERROR(f'Error: {str(e)}')) 741 + return 742 + ``` 743 + 744 + **Step 2: Test the command manually** 745 + 746 + Run: `cd website && uv run python manage.py query_topics --text "We need better train connections"` 747 + Expected: Output showing transport-related topics with scores 748 + 749 + Run: `cd website && uv run python manage.py query_topics --text "affordable housing" --limit 3` 750 + Expected: Output showing top 3 housing-related topics 751 + 752 + **Step 3: Commit query_topics command** 753 + 754 + ```bash 755 + git add website/letters/management/commands/query_topics.py 756 + git commit -m "feat: add query_topics management command" 757 + ``` 758 + 759 + --- 760 + 761 + ## Task 9: Create query_representatives management command 762 + 763 + **Files:** 764 + - Create: `website/letters/management/commands/query_representatives.py` 765 + 766 + **Step 1: Write query_representatives command** 767 + 768 + Create `website/letters/management/commands/query_representatives.py`: 769 + 770 + ```python 771 + # ABOUTME: Query management command to find representatives by address and/or topics. 772 + # ABOUTME: Interactive tool for testing representative suggestion logic. 773 + 774 + from django.core.management.base import BaseCommand 775 + from letters.services import ConstituencyLocator, TopicSuggestionService, ConstituencySuggestionService 776 + 777 + 778 + class Command(BaseCommand): 779 + help = 'Find representatives by address and/or topics' 780 + 781 + def add_arguments(self, parser): 782 + # Address arguments 783 + parser.add_argument( 784 + '--street', 785 + type=str, 786 + help='Street name and number' 787 + ) 788 + parser.add_argument( 789 + '--postal-code', 790 + type=str, 791 + help='Postal code (PLZ)' 792 + ) 793 + parser.add_argument( 794 + '--city', 795 + type=str, 796 + help='City name' 797 + ) 798 + 799 + # Topic arguments 800 + parser.add_argument( 801 + '--topics', 802 + type=str, 803 + help='Comma-separated topic keywords (e.g., "Verkehr,Infrastruktur")' 804 + ) 805 + 806 + parser.add_argument( 807 + '--limit', 808 + type=int, 809 + default=10, 810 + help='Maximum number of representatives to return (default: 10)' 811 + ) 812 + 813 + def handle(self, *args, **options): 814 + street = options.get('street') 815 + postal_code = options.get('postal_code') 816 + city = options.get('city') 817 + topics_str = options.get('topics') 818 + limit = options['limit'] 819 + 820 + try: 821 + # Use constituency locator if address provided 822 + if postal_code or (street and city): 823 + locator = ConstituencyLocator() 824 + representatives = locator.locate( 825 + street=street, 826 + postal_code=postal_code, 827 + city=city 828 + ) 829 + 830 + if not representatives: 831 + self.stdout.write('No representatives found for this location') 832 + return 833 + 834 + # Filter by topics if provided 835 + if topics_str: 836 + topic_keywords = [t.strip() for t in topics_str.split(',')] 837 + # Simple keyword filter on representative focus areas 838 + filtered_reps = [] 839 + for rep in representatives: 840 + # Check if any committee or focus area matches 841 + rep_text = ' '.join([ 842 + rep.full_name, 843 + ' '.join([c.name for c in rep.committees.all()]), 844 + ]).lower() 845 + 846 + if any(keyword.lower() in rep_text for keyword in topic_keywords): 847 + filtered_reps.append(rep) 848 + 849 + representatives = filtered_reps if filtered_reps else representatives 850 + 851 + # Display results 852 + for rep in representatives[:limit]: 853 + constituency = rep.primary_constituency 854 + constituency_label = constituency.name if constituency else rep.parliament.name 855 + self.stdout.write(f'{rep.full_name} ({rep.party}) - {constituency_label}') 856 + 857 + # Show committees 858 + committees = list(rep.committees.all()[:3]) 859 + if committees: 860 + committee_names = ', '.join([c.name for c in committees]) 861 + self.stdout.write(f' Committees: {committee_names}') 862 + 863 + # Use topic-based search if only topics provided 864 + elif topics_str: 865 + self.stdout.write('Topic-based representative search not yet implemented') 866 + self.stdout.write('Please provide at least a postal code for location-based search') 867 + 868 + else: 869 + self.stderr.write(self.style.ERROR( 870 + 'Error: Please provide either an address (--postal-code required) or --topics' 871 + )) 872 + 873 + except Exception as e: 874 + self.stderr.write(self.style.ERROR(f'Error: {str(e)}')) 875 + return 876 + ``` 877 + 878 + **Step 2: Test the command manually** 879 + 880 + Run: `cd website && uv run python manage.py query_representatives --postal-code "11011"` 881 + Expected: Output showing Berlin representatives 882 + 883 + Run: `cd website && uv run python manage.py query_representatives --street "Platz der Republik 1" --postal-code "11011" --city "Berlin" --limit 5` 884 + Expected: Output showing top 5 representatives for that location 885 + 886 + **Step 3: Commit query_representatives command** 887 + 888 + ```bash 889 + git add website/letters/management/commands/query_representatives.py 890 + git commit -m "feat: add query_representatives management command" 891 + ``` 892 + 893 + --- 894 + 895 + ## Task 10: Run full test suite and verify everything works 896 + 897 + **Files:** 898 + - All test files 899 + 900 + **Step 1: Run complete test suite** 901 + 902 + Run: `cd website && uv run python manage.py test` 903 + Expected: All tests PASS (including new and existing tests) 904 + 905 + **Step 2: Test all three query commands manually** 906 + 907 + Run: `cd website && uv run python manage.py query_wahlkreis --street "Platz der Republik 1" --postal-code "11011" --city "Berlin"` 908 + Expected: Correct constituency output 909 + 910 + Run: `cd website && uv run python manage.py query_topics --text "climate change and renewable energy"` 911 + Expected: Environment-related topics 912 + 913 + Run: `cd website && uv run python manage.py query_representatives --postal-code "10115"` 914 + Expected: Berlin representatives 915 + 916 + **Step 3: Commit if any fixes needed** 917 + 918 + If any issues found and fixed: 919 + ```bash 920 + git add . 921 + git commit -m "fix: address test suite issues" 922 + ``` 923 + 924 + --- 925 + 926 + ## Task 11: Delete test_matching.py command 927 + 928 + **Files:** 929 + - Delete: `website/letters/management/commands/test_matching.py` 930 + 931 + **Step 1: Verify tests cover all test_matching.py functionality** 932 + 933 + Compare `test_matching.py` with `test_address_matching.py` to ensure all test cases are covered. 934 + 935 + **Step 2: Delete test_matching.py** 936 + 937 + Run: `rm website/letters/management/commands/test_matching.py` 938 + 939 + **Step 3: Run tests to verify nothing broke** 940 + 941 + Run: `cd website && uv run python manage.py test` 942 + Expected: All tests still PASS 943 + 944 + **Step 4: Commit deletion** 945 + 946 + ```bash 947 + git add website/letters/management/commands/test_matching.py 948 + git commit -m "refactor: remove test_matching command (moved to proper tests)" 949 + ``` 950 + 951 + --- 952 + 953 + ## Task 12: Delete test_constituency_suggestion.py command 954 + 955 + **Files:** 956 + - Delete: `website/letters/management/commands/test_constituency_suggestion.py` 957 + 958 + **Step 1: Verify tests cover functionality** 959 + 960 + Compare with `test_constituency_suggestions.py`. 961 + 962 + **Step 2: Delete test_constituency_suggestion.py** 963 + 964 + Run: `rm website/letters/management/commands/test_constituency_suggestion.py` 965 + 966 + **Step 3: Run tests to verify nothing broke** 967 + 968 + Run: `cd website && uv run python manage.py test` 969 + Expected: All tests PASS 970 + 971 + **Step 4: Commit deletion** 972 + 973 + ```bash 974 + git add website/letters/management/commands/test_constituency_suggestion.py 975 + git commit -m "refactor: remove test_constituency_suggestion command (moved to proper tests)" 976 + ``` 977 + 978 + --- 979 + 980 + ## Task 13: Delete test_topic_mapping.py command 981 + 982 + **Files:** 983 + - Delete: `website/letters/management/commands/test_topic_mapping.py` 984 + 985 + **Step 1: Verify tests cover functionality** 986 + 987 + Compare with `test_topic_mapping.py`. 988 + 989 + **Step 2: Delete test_topic_mapping.py** 990 + 991 + Run: `rm website/letters/management/commands/test_topic_mapping.py` 992 + 993 + **Step 3: Run tests to verify nothing broke** 994 + 995 + Run: `cd website && uv run python manage.py test` 996 + Expected: All tests PASS 997 + 998 + **Step 4: Commit deletion** 999 + 1000 + ```bash 1001 + git add website/letters/management/commands/test_topic_mapping.py 1002 + git commit -m "refactor: remove test_topic_mapping command (moved to proper tests)" 1003 + ``` 1004 + 1005 + --- 1006 + 1007 + ## Task 14: Update documentation 1008 + 1009 + **Files:** 1010 + - Modify: `README.md` (if it mentions test commands) 1011 + - Modify: `docs/matching-algorithm.md` (update command references) 1012 + 1013 + **Step 1: Check if README mentions test commands** 1014 + 1015 + Run: `grep -n "test_matching\|test_constituency\|test_topic" README.md` 1016 + 1017 + If found, update to reference new query commands and proper test suite. 1018 + 1019 + **Step 2: Update docs/matching-algorithm.md** 1020 + 1021 + In `docs/matching-algorithm.md`, find section "Management Commands" (around line 70) and update: 1022 + 1023 + ```markdown 1024 + ### Management Commands 1025 + 1026 + - **fetch_wahlkreis_data**: Downloads official Bundestag constituency boundaries 1027 + - **query_wahlkreis**: Query constituency by address or postal code 1028 + - **query_topics**: Find matching topics for letter text 1029 + - **query_representatives**: Find representatives by address and/or topics 1030 + 1031 + ### Testing 1032 + 1033 + Run the test suite: 1034 + ```bash 1035 + python manage.py test letters.tests.test_address_matching 1036 + python manage.py test letters.tests.test_topic_mapping 1037 + python manage.py test letters.tests.test_constituency_suggestions 1038 + ``` 1039 + ``` 1040 + 1041 + **Step 3: Commit documentation updates** 1042 + 1043 + ```bash 1044 + git add README.md docs/matching-algorithm.md 1045 + git commit -m "docs: update command and testing references" 1046 + ``` 1047 + 1048 + --- 1049 + 1050 + ## Task 15: Final verification and summary 1051 + 1052 + **Files:** 1053 + - All modified files 1054 + 1055 + **Step 1: Run complete test suite one final time** 1056 + 1057 + Run: `cd website && uv run python manage.py test -v` 1058 + Expected: All tests PASS with detailed output 1059 + 1060 + **Step 2: Verify query commands work** 1061 + 1062 + Test each command with various inputs to ensure they work correctly. 1063 + 1064 + **Step 3: Create summary of changes** 1065 + 1066 + Review all commits: 1067 + ```bash 1068 + git log --oneline 1069 + ``` 1070 + 1071 + **Step 4: Final commit if needed** 1072 + 1073 + If any final cleanup needed: 1074 + ```bash 1075 + git add . 1076 + git commit -m "chore: final cleanup for test command refactoring" 1077 + ``` 1078 + 1079 + --- 1080 + 1081 + ## Summary 1082 + 1083 + **What was accomplished:** 1084 + 1. Created three new test files with comprehensive test coverage 1085 + 2. Created three new query management commands for interactive debugging 1086 + 3. Deleted three old test-like management commands 1087 + 4. Updated documentation to reflect new structure 1088 + 1089 + **New query commands:** 1090 + - `query_wahlkreis` - Find constituency by address/PLZ 1091 + - `query_topics` - Find matching topics for text 1092 + - `query_representatives` - Find representatives by location/topics 1093 + 1094 + **New test files:** 1095 + - `letters/tests/test_address_matching.py` - Address geocoding and matching 1096 + - `letters/tests/test_topic_mapping.py` - Topic keyword matching 1097 + - `letters/tests/test_constituency_suggestions.py` - Integration tests 1098 + 1099 + **Testing strategy:** 1100 + - Mocked external API calls (Nominatim) to avoid rate limits 1101 + - Integration tests use real services where possible 1102 + - All edge cases covered (failures, fallbacks, empty results)
+60
docs/plans/matching.md
··· 1 + # Recipient Matching Vision 2 + 3 + ## Goal 4 + Ensure every letter reaches the most relevant representative by combining precise constituency mapping with topic-awareness and reliable representative metadata. 5 + 6 + ## Core Pillars 7 + 8 + 1. **Constituency Precision** 9 + - Replace postal-prefix heuristics with official boundary data: 10 + - Bundestag Wahlkreise (Bundeswahlleiter / BKG GeoJSON) 11 + - Landtag electoral districts via state open-data portals or OParl feeds 12 + - EU parliament treated as nationwide constituency 13 + - Normalise mandate modes: 14 + - Direktmandat โ†’ voters in that Wahlkreis 15 + - Landesliste โ†’ voters in the state 16 + - Bundes/EU list โ†’ national constituencies 17 + - Centralise the logic in a โ€œconstituency routerโ€ so each parliamentโ€™s data source is pluggable. 18 + 19 + 2. **Topic Understanding** 20 + - Analyse title + body to classify concerns into a canonical taxonomy (reuse committee topics, extend as needed). 21 + - Infer the responsible level (EU / Bund / Land / Kommune) from topic metadata. 22 + - Keep the topic model extensible (keyword heuristics today, embeddings or classifiers tomorrow). 23 + 24 + 3. **Rich Representative Profiles** 25 + - Build a `RepresentativeProfile` table to store per-source enrichments: 26 + - Source (ABGEORDNETENWATCH, BUNDESTAG, LANDTAG_*) 27 + - Normalised fields: focus areas, biography, external links, responsiveness 28 + - Raw metadata + sync timestamps 29 + - Importers: 30 + - Abgeordnetenwatch: `/politicians/{id}` (issues, responsiveness, social links) 31 + - Bundestag: official vita JSON (`mdbId`) for biography + spokesperson roles 32 + - Landtage: state-specific data feeds (OParl, CSV, or one-off scrapers) 33 + - Profiles coexist; the merging service resolves conflicts and picks the best available data. 34 + 35 + ## Matching Pipeline 36 + 1. **Constituency filter**: Use the router and mandate rules to determine eligible reps. 37 + 2. **Topic filter**: Narrow to the inferred level and portfolio. 38 + 3. **Scoring**: Blend signalsโ€”constituency proximity, topic match (committee โ†’ topic), activity (votes, questions), responsiveness stats, optional user preferences. 39 + 4. **Explanation**: Provide human-readable reasons (โ€œDirect MP for WK 123; sits on Verkehrsausschuss; answered 90% of Abgeordnetenwatch questionsโ€). 40 + 41 + ## Data Sources Reference 42 + 43 + | Use Case | Federal | State | EU | 44 + |-------------------------|-------------------------------------|---------------------------------------------|--------------------------------| 45 + | Mandates & committees | Abgeordnetenwatch API | Abgeordnetenwatch, OParl, Landtag portals | EU Parliament REST API | 46 + | Constituency boundaries | Bundeswahlleiter GeoJSON, BKG | Landeswahlleitungen, state GIS datasets | Whole-of-Germany (single geom) | 47 + | Biography / focus | Bundestag vita JSON, Abgeordnetenwatch issues | Landtag bios (open data) | Europarl member profiles | 48 + 49 + ## Implementation Notes 50 + - Expose `sync_representative_profiles` commands per source; schedule separately from mandate sync. 51 + - Track `source_version`/`hash` to avoid redundant imports. 52 + - View layer consumes a `RepresentativeProfileService` that aggregates focus areas, biography, links, responsiveness. 53 + - Keep a roadmap for future sources (party press, DIP21 votes, Europarl โ€œfilesโ€). 54 + 55 + ## Next Steps 56 + - Implement `RepresentativeProfile` model + importers for Abgeordnetenwatch and Bundestag. 57 + - Integrate boundary datasets and swap the PLZ router. 58 + - Wire the matching pipeline into the letter form suggestions and automated routing. 59 + - Add logging/monitoring for profile freshness and matching success. 60 +
+221
docs/plans/mvp.md
··· 1 + # MVP Vision 2 + 3 + ## Mission 4 + Empower citizens to participate in democracy by writing impactful open letters to the representatives best positioned to act, and by allowing others to rally behind those letters with signatures. Verified identities add credibility; when a letter clears a signature threshold, we commit to printing, signing, and delivering it to the relevant office. 5 + 6 + ## Core Feature Set 7 + 1. **Accounts & Profiles** 8 + - Email/password registration & login. 9 + - Profile page showing authored letters, signed letters, and verification status. 10 + 2. **Representative Directory** 11 + - EU, Bundestag, Landtag members with photo, party, mandate mode, committee roles, Abgeordnetenwatch links. 12 + - Exposed via detail view and reusable UI card. 13 + 3. **Letter Authoring & Publishing** 14 + - Draft open letters, auto-suggest recipients based on title + PLZ. 15 + - Auto-sign on publish; allow editing until first signature. 16 + - Letter detail page shows full content, representative card, signature stats. 17 + 4. **Recommendation Engine** 18 + - PLZ โ†’ constituency router (direct/state/federal) using official boundary data. 19 + - Topic analysis highlighting likely responsible level and committee working areas. 20 + - Explain why a representative is recommended, surface relevant tags, show similar letters. 21 + 5. **Signature Flow** 22 + - One-click signing for logged in users; prompt login otherwise. 23 + - Badges for verified vs unverified signatures, count constituents distinctly. 24 + - Social sharing (link copy, optional Twitter/Bluesky share). 25 + 6. **Identity Verification (Optional)** 26 + - Integrate one third-party provider (e.g., Verimi or yesยฎ) via OAuth2/OIDC to pull verified name/address. 27 + - Store attestation + expiry; map address to constituency for direct mandates. 28 + - Users without verification can still sign, flagged as โ€œunverified.โ€ 29 + 7. **Signature Threshold & Fulfilment** 30 + - Configurable threshold per letter or representative type. 31 + - Admin view showing letters reaching milestones, export letter + supporters for printing/mailing. 32 + 8. **Admin & Moderation** 33 + - Admin dashboard to inspect letters, representatives, signatures, verification status, and signature thresholds. 34 + - Ability to disable inappropriate letters, resend sync, run exports. 35 + 9. **Landing & Discovery** 36 + - Public homepage summarising mission, stats, featured letters. 37 + - Browse letters and representatives without login. 38 + 10. **Documentation & Transparency** 39 + - Public โ€œHow it worksโ€ page, privacy policy, terms. 40 + - README covering setup, architecture, deployment. 41 + 42 + ## 1-Month Sprint to 39C3 (December 2024) 43 + 44 + ### **Week 1-2: Core Functionality** (Days 1-10) 45 + 46 + #### Track 1: Accurate Constituency Matching (Days 1-5) โš ๏ธ CRITICAL 47 + **Day 1: OSM Nominatim Integration** 48 + - [ ] Set up OSM Nominatim API client (requests-based, with rate limiting) 49 + - [ ] Add address geocoding service (`AddressGeocoder`) 50 + - [ ] Cache geocoding results in database to minimize API calls 51 + - [ ] Write tests for address โ†’ lat/lng conversion 52 + 53 + **Day 2: GeoJSON Point-in-Polygon Lookup** 54 + - [ ] Download full Bundestag Wahlkreis GeoJSON (via existing management command) 55 + - [ ] Build `WahlkreisLocator` using shapely for point-in-polygon 56 + - [ ] Load GeoJSON into memory at startup (or cache in Redis) 57 + - [ ] Test coordinate โ†’ Wahlkreis lookup with sample points 58 + 59 + **Day 3: Integration & Service Layer** 60 + - [ ] Replace `ConstituencyLocator` with new address-based lookup 61 + - [ ] Update `LocationContext` to accept full addresses 62 + - [ ] Maintain PLZ prefix fallback for partial data 63 + - [ ] Add comprehensive error handling and logging 64 + 65 + **Day 4: Representative Matching Validation** 66 + - [ ] Test matching with 20 real German addresses 67 + - [ ] Verify direct representatives are correctly suggested 68 + - [ ] Test topic + geography combined matching 69 + - [ ] Document matching algorithm for transparency 70 + 71 + **Day 5: Performance & Edge Cases** 72 + - [ ] Add caching layer for expensive operations 73 + - [ ] Handle border constituencies and ambiguous addresses 74 + - [ ] Performance test with 100+ concurrent requests 75 + - [ ] Add monitoring/logging for matching accuracy 76 + 77 + #### Track 2: UX Polish (Days 3-8) 78 + 79 + **Day 3-4: Gov.uk-Inspired Branding** 80 + - [ ] Define color palette (inspired by gov.uk: blues, blacks, whites) 81 + - [ ] Choose typography (gov.uk uses: Transport/Arial for headings, system fonts for body) 82 + - [ ] Create CSS design system with variables 83 + - [ ] Update base template with new styles 84 + - [ ] Design simple wordmark/logo 85 + 86 + **Day 5-6: Letter List Improvements** 87 + - [ ] Add sorting controls (newest, most signatures, most verified) 88 + - [ ] Add TopicArea filtering (multi-select chips) 89 + - [ ] Improve letter card design (hierarchy, spacing, affordances) 90 + - [ ] Add empty states with helpful CTAs 91 + - [ ] Mobile responsive improvements 92 + 93 + **Day 6-7: Letter Authoring Flow** 94 + - [ ] Add character counter (500 char minimum) 95 + - [ ] Add prominent immutability warning before publish 96 + - [ ] Show representative suggestion reasoning 97 + - [ ] Add preview step before publishing 98 + - [ ] Improve auto-signature confirmation messaging 99 + 100 + **Day 7-8: Letter Detail & Sharing** 101 + - [ ] Add prominent "Copy link" button with visual feedback 102 + - [ ] Add social share buttons (Twitter, Bluesky with pre-filled text) 103 + - [ ] Clarify signature removal instructions 104 + - [ ] Improve verified/unverified signature badges 105 + - [ ] Polish report button and modal 106 + 107 + #### Track 3: Localization Foundation (Days 6-8) 108 + 109 + **Day 6-7: Django i18n Setup** 110 + - [ ] Wrap all strings in `gettext()` / `_()` calls 111 + - [ ] Generate German .po files 112 + - [ ] Add language switcher infrastructure (even if only DE works) 113 + - [ ] Document translation workflow 114 + 115 + **Day 8: Content Audit** 116 + - [ ] Audit templates for hardcoded strings 117 + - [ ] Review German tone/voice consistency 118 + - [ ] Ensure error messages are clear and helpful 119 + - [ ] Proofread all user-facing content 120 + 121 + #### Track 4: Automated Testing (Days 8-10) 122 + 123 + **Day 8: Integration Tests** 124 + - [ ] Test full flow: Register โ†’ Create Letter โ†’ Suggestions โ†’ Publish โ†’ Sign 125 + - [ ] Test with 10 real German addresses 126 + - [ ] Test with 5 different topics 127 + - [ ] Test email flows (registration, password reset) 128 + 129 + **Day 9: Matching Tests** 130 + - [ ] Unit tests for geocoding service 131 + - [ ] Unit tests for GeoJSON lookup 132 + - [ ] Integration tests for address โ†’ representative matching 133 + - [ ] Test edge cases (border areas, ambiguous addresses) 134 + 135 + **Day 10: System Tests** 136 + - [ ] Browser automation tests (Playwright/Selenium) 137 + - [ ] Mobile responsive tests 138 + - [ ] Performance tests (response times, concurrent users) 139 + - [ ] Create bug fix punch list 140 + 141 + ### **Week 3-4: Deployment & Polish** (Days 11-20) 142 + 143 + #### Track 5: Production Deployment (Days 11-14) 144 + 145 + **Day 11-12: VPS Setup** 146 + - [ ] Provision VPS with cloud-init template 147 + - [ ] Configure Gunicorn + Nginx 148 + - [ ] Set up SSL/TLS certificates (Let's Encrypt) 149 + - [ ] Configure static file serving 150 + 151 + **Day 13: Production Configuration** 152 + - [ ] Environment-based settings (secrets, database) 153 + - [ ] Configure email backend (SMTP/SendGrid/SES) 154 + - [ ] Set up error tracking (Sentry/Rollbar) 155 + - [ ] Configure logging (structured logs) 156 + 157 + **Day 14: Deployment Automation** 158 + - [ ] Create deployment script (simple rsync/git pull based) 159 + - [ ] Test rollback procedure 160 + - [ ] Document deployment process 161 + - [ ] Set up basic monitoring/health checks 162 + 163 + #### Track 6: Content & Documentation (Days 15-17) 164 + 165 + **Day 15-16: Landing & How It Works** 166 + - [ ] Create compelling homepage (mission, stats, CTA) 167 + - [ ] Write "How It Works" page (transparency about matching) 168 + - [ ] Create FAQ section 169 + - [ ] Add example letters / testimonials 170 + 171 + **Day 17: Legal & Privacy** 172 + - [ ] Write basic Privacy Policy (GDPR-compliant) 173 + - [ ] Write Terms of Service 174 + - [ ] Add cookie consent if needed 175 + - [ ] Create Impressum (legal requirement in Germany) 176 + 177 + #### Track 7: Final Testing & Launch Prep (Days 18-20) 178 + 179 + **Day 18: User Acceptance Testing** 180 + - [ ] Run through entire flow with fresh eyes 181 + - [ ] Test on multiple devices and browsers 182 + - [ ] Verify all links and forms work 183 + - [ ] Check for typos and formatting issues 184 + 185 + **Day 19: Performance & Security Audit** 186 + - [ ] Load testing (how many concurrent users can it handle?) 187 + - [ ] Security review (XSS, CSRF, SQL injection protections) 188 + - [ ] Check all forms have proper validation 189 + - [ ] Review admin permissions 190 + 191 + **Day 20: Launch Preparation** 192 + - [ ] Create launch checklist 193 + - [ ] Prepare 39C3 demo script 194 + - [ ] Set up analytics/monitoring dashboards 195 + - [ ] Plan initial outreach (Twitter, mailing lists, etc.) 196 + 197 + ## Completed Features (From Previous Work) 198 + - [x] Account Management (registration, login, password reset, deletion) 199 + - [x] Double opt-in email verification 200 + - [x] TopicArea taxonomy with keyword matching 201 + - [x] Representative metadata sync (photos, committees, focus areas) 202 + - [x] Committee-to-topic automatic mapping 203 + - [x] Self-declared constituency verification 204 + - [x] HTMX-based representative suggestions 205 + - [x] Basic letter authoring and signing flow 206 + 207 + ## Explicitly Deferred (Post-39C3) 208 + - Third-party identity verification (Verimi, yesยฎ) 209 + - Analytics/feedback systems (basic monitoring only for MVP) 210 + - EU Parliament & Landtag levels (Bundestag only for MVP) 211 + - Draft auto-save functionality 212 + - Advanced admin moderation tools 213 + - Multiple language support (German only for MVP, i18n structure ready) 214 + 215 + ## Out of Scope for MVP 216 + - Local municipality reps, party-wide campaigns. 217 + - In-browser letter editing with collaboration. 218 + - Advanced analytics or CRM tooling. 219 + - Multiple identity providers (beyond initial integration). 220 + - Expert matching based on representative metadata keywords 221 + - Biography providers, display on representative detail view and extract keywords for matching
+65
docs/plans/verification.md
··· 1 + # Identity Verification Vision 2 + 3 + ## Objective 4 + Guarantee that only real, uniquely verified individuals can sign letters and that their constituency is provenโ€”ideally by reusing identities users already hold (e.g., Verimi, BundID, bank login) rather than forcing a new verification flow. 5 + 6 + ## Core Requirements 7 + - **Proof of personhood:** Ensure each signature is tied to a real individual (no throwaway accounts). 8 + - **Proof of constituency:** Capture verified address (street, PLZ, city) and map it to the correct Wahlkreis / Landtag district. 9 + - **Reusable identity:** Prefer providers where users can consent to sharing existing verified attributes (lower friction than video calls). 10 + - **Evidence retention:** Store cryptographically signed responses or verification references so we can prove the verification later. 11 + - **Expiry / refresh:** Verification should have a validity window (e.g., re-check every 6โ€“12 months or when user updates address). 12 + 13 + ## Recommended Providers (Germany) 14 + 15 + ### Identity Wallets (best reuse experience) 16 + - **Verimi** (OAuth2/OIDC) 17 + - Users already have a Verimi wallet โ†’ grant consent โ†’ we receive name + address. 18 + - Supports multiple underlying methods (eID, VideoIdent, bank sources). 19 + - **BundID / BundesIdent** (official government ID) 20 + - OIDC-based access to Personalausweis attributes via government portal. 21 + - Gold standard for address proof; onboarding limited to approved use cases. 22 + - **yesยฎ (yes.com)** 23 + - Bank login to participating institutions; returns bank-verified identity/address via OpenID Connect. 24 + - No new verification, just consent. 25 + - **Signicat Identity Platform** 26 + - Aggregator: supports Verimi, yesยฎ, BankID, eIDAS. Useful if expansion beyond Germany is planned. 27 + - **Nect Ident** 28 + - After an initial automated verification, users can re-share their identity from a wallet. 29 + 30 + ### Alternative Methods 31 + - **BankIdent / PSD2 providers** (WebID BankIdent, yesยฎ, Deutsche Bank BankIdent) 32 + - Users log into their bank; returns name/address. High trust, no video. 33 + - **eID solutions (AUTHADA, D-Trust)** 34 + - NFC-based Personalausweis reading; some provide reusable tokens after first use. 35 + - **VideoIdent (IDnow, WebID VideoIdent, POSTIDENT)** 36 + - Higher friction; use as fallback when wallet/bank options fail. 37 + 38 + ## Integration Architecture 39 + 1. **Abstraction layer:** Implement a `VerificationProvider` interface with methods like `start_verification(user)` and `handle_callback(payload)`. 40 + 2. **Provider adapters:** Build adapters for Verimi, yesยฎ, BundID, etc., each handling OAuth2/OIDC flows, token validation, and attribute extraction. 41 + 3. **Verification storage:** Extend `IdentityVerification` to store provider name, verification reference, timestamp, address, and provider response hash/signature. 42 + 4. **Constituency mapping:** After receiving address data, run it through the constituency router (GeoJSON-based once available) to attach the exact direct-mandate seat/state. 43 + 5. **Expiry handling:** Add `expires_at`โ€”prompt users to re-verify when outdated or on address change. 44 + 6. **Audit trail:** Log provider responses; maintain a verification history per user. 45 + 7. **Fallback/manual path:** Offer manual verification (moderator-reviewed documents) only if all automated providers fail, clearly flagging such signatures. 46 + 47 + ## User Flow Blueprint 48 + 1. User chooses โ€œVerify identityโ€. 49 + 2. We present available providers (Verimi, yesยฎ, BundIDโ€ฆ). 50 + 3. User authenticates/consents with chosen provider. 51 + 4. Provider redirects back / sends webhook with verification result + attributes. 52 + 5. We validate response, persist identity data, and map PLZ โ†’ constituency. 53 + 6. Signatures now display โ€œVerified constituentโ€ (and reinforce direct mandates with proof). 54 + 55 + ## Implementation Priorities 56 + - Start with a wallet provider (Verimi or yesยฎ) for minimal friction. 57 + - Add BundID for maximum trust where accessible. 58 + - Abstract architecture so adding Landtag-specific providers later is straightforward. 59 + - Ensure we can reuse the same verification across multiple letters until it expires. 60 + 61 + ## Outstanding Questions 62 + - Do we need different assurance levels for general signatures vs. direct-mandate proof? (e.g., allow bank login for signing, but require eID for constituency-critical interactions?) 63 + - How to handle users without access to any supported provider? (Manual override / postal verification?) 64 + - Data protection & consent: store only whatโ€™s necessary (likely name + address); ensure GDPR-compliant retention policies. 65 +
+1
pyproject.toml
··· 10 10 "geopy>=2.4.0", 11 11 "shapely>=2.1.0", 12 12 "tqdm>=4.67.1", 13 + "pyshp>=2.3.1", 13 14 ]
+11
uv.lock
··· 157 157 ] 158 158 159 159 [[package]] 160 + name = "pyshp" 161 + version = "3.0.2.post1" 162 + source = { registry = "https://pypi.org/simple" } 163 + sdist = { url = "https://files.pythonhosted.org/packages/7f/fb/07f057ff01229c575831766b44bd249aefa086146cf5bce52e172d77cf4e/pyshp-3.0.2.post1.tar.gz", hash = "sha256:18e34a66759b6d34a6f535978c76dad518200f23a727d9e22af8e8535c0245b9", size = 2192180, upload-time = "2025-10-10T16:04:58.529Z" } 164 + wheels = [ 165 + { url = "https://files.pythonhosted.org/packages/51/92/a8ad817864a560b96ac1c817f9c56bb7eacc1a7d522e2d39afe9e9c77d7b/pyshp-3.0.2.post1-py3-none-any.whl", hash = "sha256:b0aec66bc55f7cd3a846f6b02c5a9eec1fc1d2cff16ccfcf6493a6773c7eb602", size = 58298, upload-time = "2025-10-10T16:04:57.151Z" }, 166 + ] 167 + 168 + [[package]] 160 169 name = "requests" 161 170 version = "2.32.5" 162 171 source = { registry = "https://pypi.org/simple" } ··· 260 269 dependencies = [ 261 270 { name = "django" }, 262 271 { name = "geopy" }, 272 + { name = "pyshp" }, 263 273 { name = "requests" }, 264 274 { name = "shapely" }, 265 275 { name = "tqdm" }, ··· 269 279 requires-dist = [ 270 280 { name = "django", specifier = ">=5.2.6" }, 271 281 { name = "geopy", specifier = ">=2.4.0" }, 282 + { name = "pyshp", specifier = ">=2.3.1" }, 272 283 { name = "requests", specifier = ">=2.31.0" }, 273 284 { name = "shapely", specifier = ">=2.1.0" }, 274 285 { name = "tqdm", specifier = ">=4.67.1" },
-60
vision/matching.md
··· 1 - # Recipient Matching Vision 2 - 3 - ## Goal 4 - Ensure every letter reaches the most relevant representative by combining precise constituency mapping with topic-awareness and reliable representative metadata. 5 - 6 - ## Core Pillars 7 - 8 - 1. **Constituency Precision** 9 - - Replace postal-prefix heuristics with official boundary data: 10 - - Bundestag Wahlkreise (Bundeswahlleiter / BKG GeoJSON) 11 - - Landtag electoral districts via state open-data portals or OParl feeds 12 - - EU parliament treated as nationwide constituency 13 - - Normalise mandate modes: 14 - - Direktmandat โ†’ voters in that Wahlkreis 15 - - Landesliste โ†’ voters in the state 16 - - Bundes/EU list โ†’ national constituencies 17 - - Centralise the logic in a โ€œconstituency routerโ€ so each parliamentโ€™s data source is pluggable. 18 - 19 - 2. **Topic Understanding** 20 - - Analyse title + body to classify concerns into a canonical taxonomy (reuse committee topics, extend as needed). 21 - - Infer the responsible level (EU / Bund / Land / Kommune) from topic metadata. 22 - - Keep the topic model extensible (keyword heuristics today, embeddings or classifiers tomorrow). 23 - 24 - 3. **Rich Representative Profiles** 25 - - Build a `RepresentativeProfile` table to store per-source enrichments: 26 - - Source (ABGEORDNETENWATCH, BUNDESTAG, LANDTAG_*) 27 - - Normalised fields: focus areas, biography, external links, responsiveness 28 - - Raw metadata + sync timestamps 29 - - Importers: 30 - - Abgeordnetenwatch: `/politicians/{id}` (issues, responsiveness, social links) 31 - - Bundestag: official vita JSON (`mdbId`) for biography + spokesperson roles 32 - - Landtage: state-specific data feeds (OParl, CSV, or one-off scrapers) 33 - - Profiles coexist; the merging service resolves conflicts and picks the best available data. 34 - 35 - ## Matching Pipeline 36 - 1. **Constituency filter**: Use the router and mandate rules to determine eligible reps. 37 - 2. **Topic filter**: Narrow to the inferred level and portfolio. 38 - 3. **Scoring**: Blend signalsโ€”constituency proximity, topic match (committee โ†’ topic), activity (votes, questions), responsiveness stats, optional user preferences. 39 - 4. **Explanation**: Provide human-readable reasons (โ€œDirect MP for WK 123; sits on Verkehrsausschuss; answered 90% of Abgeordnetenwatch questionsโ€). 40 - 41 - ## Data Sources Reference 42 - 43 - | Use Case | Federal | State | EU | 44 - |-------------------------|-------------------------------------|---------------------------------------------|--------------------------------| 45 - | Mandates & committees | Abgeordnetenwatch API | Abgeordnetenwatch, OParl, Landtag portals | EU Parliament REST API | 46 - | Constituency boundaries | Bundeswahlleiter GeoJSON, BKG | Landeswahlleitungen, state GIS datasets | Whole-of-Germany (single geom) | 47 - | Biography / focus | Bundestag vita JSON, Abgeordnetenwatch issues | Landtag bios (open data) | Europarl member profiles | 48 - 49 - ## Implementation Notes 50 - - Expose `sync_representative_profiles` commands per source; schedule separately from mandate sync. 51 - - Track `source_version`/`hash` to avoid redundant imports. 52 - - View layer consumes a `RepresentativeProfileService` that aggregates focus areas, biography, links, responsiveness. 53 - - Keep a roadmap for future sources (party press, DIP21 votes, Europarl โ€œfilesโ€). 54 - 55 - ## Next Steps 56 - - Implement `RepresentativeProfile` model + importers for Abgeordnetenwatch and Bundestag. 57 - - Integrate boundary datasets and swap the PLZ router. 58 - - Wire the matching pipeline into the letter form suggestions and automated routing. 59 - - Add logging/monitoring for profile freshness and matching success. 60 -
-89
vision/mvp.md
··· 1 - # MVP Vision 2 - 3 - ## Mission 4 - Empower citizens to participate in democracy by writing impactful open letters to the representatives best positioned to act, and by allowing others to rally behind those letters with signatures. Verified identities add credibility; when a letter clears a signature threshold, we commit to printing, signing, and delivering it to the relevant office. 5 - 6 - ## Core Feature Set 7 - 1. **Accounts & Profiles** 8 - - Email/password registration & login. 9 - - Profile page showing authored letters, signed letters, and verification status. 10 - 2. **Representative Directory** 11 - - EU, Bundestag, Landtag members with photo, party, mandate mode, committee roles, Abgeordnetenwatch links. 12 - - Exposed via detail view and reusable UI card. 13 - 3. **Letter Authoring & Publishing** 14 - - Draft open letters, auto-suggest recipients based on title + PLZ. 15 - - Auto-sign on publish; allow editing until first signature. 16 - - Letter detail page shows full content, representative card, signature stats. 17 - 4. **Recommendation Engine** 18 - - PLZ โ†’ constituency router (direct/state/federal) using official boundary data. 19 - - Topic analysis highlighting likely responsible level and committee working areas. 20 - - Explain why a representative is recommended, surface relevant tags, show similar letters. 21 - 5. **Signature Flow** 22 - - One-click signing for logged in users; prompt login otherwise. 23 - - Badges for verified vs unverified signatures, count constituents distinctly. 24 - - Social sharing (link copy, optional Twitter/Bluesky share). 25 - 6. **Identity Verification (Optional)** 26 - - Integrate one third-party provider (e.g., Verimi or yesยฎ) via OAuth2/OIDC to pull verified name/address. 27 - - Store attestation + expiry; map address to constituency for direct mandates. 28 - - Users without verification can still sign, flagged as โ€œunverified.โ€ 29 - 7. **Signature Threshold & Fulfilment** 30 - - Configurable threshold per letter or representative type. 31 - - Admin view showing letters reaching milestones, export letter + supporters for printing/mailing. 32 - 8. **Admin & Moderation** 33 - - Admin dashboard to inspect letters, representatives, signatures, verification status, and signature thresholds. 34 - - Ability to disable inappropriate letters, resend sync, run exports. 35 - 9. **Landing & Discovery** 36 - - Public homepage summarising mission, stats, featured letters. 37 - - Browse letters and representatives without login. 38 - 10. **Documentation & Transparency** 39 - - Public โ€œHow it worksโ€ page, privacy policy, terms. 40 - - README covering setup, architecture, deployment. 41 - 42 - ## MVP To-Do List 43 - 1. **Constituency & Matching Foundations** 44 - - [ ] Replace PLZ prefix heuristic with Wahlkreis GeoJSON (Bundestag) + state-level boundaries; build router service. 45 - - [x] Expand `TopicArea` taxonomy, add NLP/keyword scoring, and present explanations. 46 - - [x] Enrich representative metadata with committee focus, responsiveness, photos. 47 - - [x] Scope recommendation engine to relevant parliaments using constituency + topic competence. 48 - 2. **Account Management** 49 - - [ ] Add account deletion option (removes signatures but keeps letters) 50 - - [ ] Add double opt-in for account creation 51 - 3. **UX** 52 - - [ ] Add letter list sorting by signatures / verified signatures / age 53 - - [ ] Add filtering based on TopicArea keywords 54 - - [ ] Remove Kompetenzen info page 55 - - [ ] Rudimentary branding - color scheme, bootstrap 56 - 3. **Identity Verification Integration** 57 - - [ ] Build provider abstraction and connect to first reusable ID service. 58 - - [ ] Persist provider response (hash/ID, address) with expiry handling; skip manual verification path. 59 - - [ ] Determine if providers exist offering login-provider functionality 60 - - [x] Support self-declared constituency verification with profile management UI. 61 - 3. **Letter Authoring UX** 62 - - [x] Polish HTMX suggestions and representative cards for consistency. 63 - - [ ] Allow draft auto-save and clearer edit states pre-signature. 64 - - [ ] Add share buttons and clearer โ€œcopy linkโ€ prompt on letter detail. 65 - - [ ] Add minimum letter length of 500 characters. 66 - - [ ] Make it very clear that letters cannot be changed after publication 67 - - [ ] Make it very clear that you can remove your signature from a letter, but not the letter itself 68 - 6. **Localization & Accessibility** 69 - - [ ] Complete en/de translation coverage for all templates and forms. 70 - - [ ] Ensure forms, buttons, and suggestions meet accessibility best practices. 71 - 7. **Deployment Readiness** 72 - - [ ] Production config (secrets, logging, error tracking, email backend). 73 - - [ ] Deploy to VPS - static media, unicorn, nginx, docker 74 - - [ ] Health checks with Tinylytics 75 - - [ ] Add caching 76 - 8. **Feedback & Analytics** 77 - - [ ] Add feedback/contact channel for users. 78 - - [ ] Add simple analytics app. Middleware that keeps track of impressions -> build this so it can easily be moved into a separate repo 79 - 9. **Testing & QA** 80 - - [ ] Expand automated test coverage (matching, verification, export workflow). 81 - - [ ] QA checklist for matching accuracy, verification flow, admin exports. 82 - 83 - ## Out of Scope for MVP 84 - - Local municipality reps, party-wide campaigns. 85 - - In-browser letter editing with collaboration. 86 - - Advanced analytics or CRM tooling. 87 - - Multiple identity providers (beyond initial integration). 88 - - Expert matching based on representative metadata keywords 89 - - Biography providers, display on representative detail view and extract keywords for matching
-65
vision/verification.md
··· 1 - # Identity Verification Vision 2 - 3 - ## Objective 4 - Guarantee that only real, uniquely verified individuals can sign letters and that their constituency is provenโ€”ideally by reusing identities users already hold (e.g., Verimi, BundID, bank login) rather than forcing a new verification flow. 5 - 6 - ## Core Requirements 7 - - **Proof of personhood:** Ensure each signature is tied to a real individual (no throwaway accounts). 8 - - **Proof of constituency:** Capture verified address (street, PLZ, city) and map it to the correct Wahlkreis / Landtag district. 9 - - **Reusable identity:** Prefer providers where users can consent to sharing existing verified attributes (lower friction than video calls). 10 - - **Evidence retention:** Store cryptographically signed responses or verification references so we can prove the verification later. 11 - - **Expiry / refresh:** Verification should have a validity window (e.g., re-check every 6โ€“12 months or when user updates address). 12 - 13 - ## Recommended Providers (Germany) 14 - 15 - ### Identity Wallets (best reuse experience) 16 - - **Verimi** (OAuth2/OIDC) 17 - - Users already have a Verimi wallet โ†’ grant consent โ†’ we receive name + address. 18 - - Supports multiple underlying methods (eID, VideoIdent, bank sources). 19 - - **BundID / BundesIdent** (official government ID) 20 - - OIDC-based access to Personalausweis attributes via government portal. 21 - - Gold standard for address proof; onboarding limited to approved use cases. 22 - - **yesยฎ (yes.com)** 23 - - Bank login to participating institutions; returns bank-verified identity/address via OpenID Connect. 24 - - No new verification, just consent. 25 - - **Signicat Identity Platform** 26 - - Aggregator: supports Verimi, yesยฎ, BankID, eIDAS. Useful if expansion beyond Germany is planned. 27 - - **Nect Ident** 28 - - After an initial automated verification, users can re-share their identity from a wallet. 29 - 30 - ### Alternative Methods 31 - - **BankIdent / PSD2 providers** (WebID BankIdent, yesยฎ, Deutsche Bank BankIdent) 32 - - Users log into their bank; returns name/address. High trust, no video. 33 - - **eID solutions (AUTHADA, D-Trust)** 34 - - NFC-based Personalausweis reading; some provide reusable tokens after first use. 35 - - **VideoIdent (IDnow, WebID VideoIdent, POSTIDENT)** 36 - - Higher friction; use as fallback when wallet/bank options fail. 37 - 38 - ## Integration Architecture 39 - 1. **Abstraction layer:** Implement a `VerificationProvider` interface with methods like `start_verification(user)` and `handle_callback(payload)`. 40 - 2. **Provider adapters:** Build adapters for Verimi, yesยฎ, BundID, etc., each handling OAuth2/OIDC flows, token validation, and attribute extraction. 41 - 3. **Verification storage:** Extend `IdentityVerification` to store provider name, verification reference, timestamp, address, and provider response hash/signature. 42 - 4. **Constituency mapping:** After receiving address data, run it through the constituency router (GeoJSON-based once available) to attach the exact direct-mandate seat/state. 43 - 5. **Expiry handling:** Add `expires_at`โ€”prompt users to re-verify when outdated or on address change. 44 - 6. **Audit trail:** Log provider responses; maintain a verification history per user. 45 - 7. **Fallback/manual path:** Offer manual verification (moderator-reviewed documents) only if all automated providers fail, clearly flagging such signatures. 46 - 47 - ## User Flow Blueprint 48 - 1. User chooses โ€œVerify identityโ€. 49 - 2. We present available providers (Verimi, yesยฎ, BundIDโ€ฆ). 50 - 3. User authenticates/consents with chosen provider. 51 - 4. Provider redirects back / sends webhook with verification result + attributes. 52 - 5. We validate response, persist identity data, and map PLZ โ†’ constituency. 53 - 6. Signatures now display โ€œVerified constituentโ€ (and reinforce direct mandates with proof). 54 - 55 - ## Implementation Priorities 56 - - Start with a wallet provider (Verimi or yesยฎ) for minimal friction. 57 - - Add BundID for maximum trust where accessible. 58 - - Abstract architecture so adding Landtag-specific providers later is straightforward. 59 - - Ensure we can reuse the same verification across multiple letters until it expires. 60 - 61 - ## Outstanding Questions 62 - - Do we need different assurance levels for general signatures vs. direct-mandate proof? (e.g., allow bank login for signing, but require eID for constituency-critical interactions?) 63 - - How to handle users without access to any supported provider? (Manual override / postal verification?) 64 - - Data protection & consent: store only whatโ€™s necessary (likely name + address); ensure GDPR-compliant retention policies. 65 -
+18
website/letters/apps.py
··· 4 4 class LettersConfig(AppConfig): 5 5 default_auto_field = 'django.db.models.BigAutoField' 6 6 name = 'letters' 7 + 8 + def ready(self): 9 + """Pre-load GeoJSON data on startup for improved performance.""" 10 + try: 11 + from .services import WahlkreisLocator 12 + import logging 13 + 14 + logger = logging.getLogger(__name__) 15 + logger.info("Warming WahlkreisLocator cache on startup...") 16 + 17 + # Initialize to load and cache GeoJSON data 18 + WahlkreisLocator() 19 + 20 + logger.info("WahlkreisLocator cache warmed successfully") 21 + except Exception as e: 22 + import logging 23 + logger = logging.getLogger(__name__) 24 + logger.warning(f"Failed to warm WahlkreisLocator cache: {e}")
+65 -1
website/letters/forms.py
··· 5 5 from django.utils.translation import gettext_lazy as _ 6 6 7 7 from .constants import normalize_german_state 8 - from .models import Letter, Representative, Signature, Report, Tag, Constituency 8 + from .models import Letter, Representative, Signature, Report, Tag, Constituency, IdentityVerification 9 9 10 10 11 11 class UserRegisterForm(UserCreationForm): ··· 241 241 ) 242 242 243 243 return cleaned_data 244 + 245 + 246 + class IdentityVerificationForm(forms.Form): 247 + """Form for collecting full address for identity verification.""" 248 + 249 + street_address = forms.CharField( 250 + max_length=255, 251 + required=False, 252 + label=_('StraรŸe und Hausnummer'), 253 + widget=forms.TextInput(attrs={ 254 + 'class': 'form-control', 255 + 'placeholder': _('z.B. Unter den Linden 77') 256 + }) 257 + ) 258 + postal_code = forms.CharField( 259 + max_length=20, 260 + required=False, 261 + label=_('Postleitzahl'), 262 + widget=forms.TextInput(attrs={ 263 + 'class': 'form-control', 264 + 'placeholder': _('z.B. 10117') 265 + }) 266 + ) 267 + city = forms.CharField( 268 + max_length=100, 269 + required=False, 270 + label=_('Stadt'), 271 + widget=forms.TextInput(attrs={ 272 + 'class': 'form-control', 273 + 'placeholder': _('z.B. Berlin') 274 + }) 275 + ) 276 + 277 + def __init__(self, *args, **kwargs): 278 + self.user = kwargs.pop('user', None) 279 + super().__init__(*args, **kwargs) 280 + 281 + # Pre-fill with existing address if available 282 + if self.user and hasattr(self.user, 'identity_verification'): 283 + verification = getattr(self.user, 'identity_verification', None) 284 + if verification: 285 + if verification.street_address: 286 + self.fields['street_address'].initial = verification.street_address 287 + if verification.postal_code: 288 + self.fields['postal_code'].initial = verification.postal_code 289 + if verification.city: 290 + self.fields['city'].initial = verification.city 291 + 292 + def clean(self): 293 + cleaned_data = super().clean() 294 + street_address = cleaned_data.get('street_address') 295 + postal_code = cleaned_data.get('postal_code') 296 + city = cleaned_data.get('city') 297 + 298 + # Check if any field is provided 299 + has_any = any([street_address, postal_code, city]) 300 + has_all = all([street_address, postal_code, city]) 301 + 302 + if has_any and not has_all: 303 + raise forms.ValidationError( 304 + _('Bitte geben Sie eine vollstรคndige Adresse ein (StraรŸe, PLZ und Stadt) oder lassen Sie alle Felder leer.') 305 + ) 306 + 307 + return cleaned_data
+90
website/letters/management/commands/check_translations.py
··· 1 + # ABOUTME: Management command to check translation completeness and report coverage. 2 + # ABOUTME: Analyzes .po files to find untranslated strings and calculate coverage percentage. 3 + 4 + from django.core.management.base import BaseCommand 5 + from django.conf import settings 6 + import pathlib 7 + 8 + 9 + class Command(BaseCommand): 10 + help = "Check translation completeness for all configured languages" 11 + 12 + def add_arguments(self, parser): 13 + parser.add_argument( 14 + '--language', 15 + type=str, 16 + help='Check specific language (e.g., "de" or "en")', 17 + ) 18 + 19 + def handle(self, *args, **options): 20 + locale_paths = settings.LOCALE_PATHS 21 + languages = settings.LANGUAGES 22 + 23 + target_language = options.get('language') 24 + 25 + if target_language: 26 + languages_to_check = [(target_language, None)] 27 + else: 28 + languages_to_check = languages 29 + 30 + for lang_code, lang_name in languages_to_check: 31 + self.check_language(locale_paths[0], lang_code, lang_name) 32 + 33 + def check_language(self, locale_path, lang_code, lang_name): 34 + """Check translation completeness for a single language.""" 35 + po_file = pathlib.Path(locale_path) / lang_code / 'LC_MESSAGES' / 'django.po' 36 + 37 + if not po_file.exists(): 38 + self.stdout.write(self.style.ERROR( 39 + f"\n{lang_code}: No .po file found at {po_file}" 40 + )) 41 + return 42 + 43 + total = 0 44 + translated = 0 45 + untranslated = [] 46 + 47 + with open(po_file, 'r', encoding='utf-8') as f: 48 + current_msgid = None 49 + for line in f: 50 + line = line.strip() 51 + if line.startswith('msgid "') and not line.startswith('msgid ""'): 52 + current_msgid = line[7:-1] # Extract string between quotes 53 + total += 1 54 + elif line.startswith('msgstr "'): 55 + msgstr = line[8:-1] 56 + if msgstr: # Non-empty translation 57 + translated += 1 58 + elif current_msgid: 59 + untranslated.append(current_msgid) 60 + current_msgid = None 61 + 62 + if total == 0: 63 + self.stdout.write(self.style.WARNING( 64 + f"\n{lang_code}: No translatable strings found" 65 + )) 66 + return 67 + 68 + coverage = (translated / total) * 100 69 + display_name = lang_name if lang_name else lang_code 70 + 71 + self.stdout.write(self.style.SUCCESS( 72 + f"\n{display_name} ({lang_code}):" 73 + )) 74 + self.stdout.write(f" Total strings: {total}") 75 + self.stdout.write(f" Translated: {translated}") 76 + self.stdout.write(f" Untranslated: {len(untranslated)}") 77 + self.stdout.write(f" Coverage: {coverage:.1f}%") 78 + 79 + if untranslated: 80 + self.stdout.write(self.style.WARNING( 81 + f"\nMissing translations ({len(untranslated)}):" 82 + )) 83 + for msgid in untranslated[:10]: # Show first 10 84 + self.stdout.write(f" - {msgid}") 85 + if len(untranslated) > 10: 86 + self.stdout.write(f" ... and {len(untranslated) - 10} more") 87 + else: 88 + self.stdout.write(self.style.SUCCESS( 89 + "\nAll strings translated!" 90 + ))
+174
website/letters/management/commands/db_snapshot.py
··· 1 + # ABOUTME: Management command to save and restore SQLite database snapshots. 2 + # ABOUTME: Enables quick database state preservation for testing and development. 3 + 4 + import shutil 5 + import pathlib 6 + from datetime import datetime 7 + 8 + from django.core.management.base import BaseCommand, CommandError 9 + from django.conf import settings 10 + from letters.models import Parliament, Constituency, Representative, Committee, TopicArea 11 + 12 + 13 + class Command(BaseCommand): 14 + help = "Save or restore SQLite database snapshots to/from fixtures directory" 15 + 16 + def add_arguments(self, parser): 17 + subparsers = parser.add_subparsers( 18 + dest="subcommand", help="Available subcommands", title="subcommands" 19 + ) 20 + 21 + # Save subcommand 22 + save_parser = subparsers.add_parser( 23 + "save", help="Create a snapshot of the current database state" 24 + ) 25 + save_parser.add_argument( 26 + "description", 27 + type=str, 28 + help='Description of the snapshot (e.g., "with-representatives", "after-sync")', 29 + ) 30 + 31 + # Restore subcommand 32 + restore_parser = subparsers.add_parser( 33 + "restore", help="Restore database from a snapshot" 34 + ) 35 + restore_parser.add_argument( 36 + "snapshot_file", 37 + type=str, 38 + help='Snapshot filename (e.g., "db_snapshot_20241014_123456_with-representatives.sqlite3")', 39 + ) 40 + 41 + # List subcommand 42 + subparsers.add_parser("list", help="List available snapshots") 43 + 44 + def handle(self, *args, **options): 45 + subcommand = options.get("subcommand") 46 + 47 + if not subcommand: 48 + self.print_help("manage.py", "db_snapshot") 49 + return 50 + 51 + if subcommand == "save": 52 + self.handle_save(options) 53 + elif subcommand == "restore": 54 + self.handle_restore(options) 55 + elif subcommand == "list": 56 + self.handle_list(options) 57 + else: 58 + raise CommandError(f"Unknown subcommand: {subcommand}") 59 + 60 + def handle_save(self, options): 61 + """Create a timestamped snapshot of the current database""" 62 + description = options["description"] 63 + 64 + # Create filename with timestamp and description 65 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 66 + # Sanitize description for filename 67 + safe_description = "".join(c if c.isalnum() or c == "-" else "_" for c in description) 68 + snapshot_filename = f"db_snapshot_{timestamp}_{safe_description}.sqlite3" 69 + 70 + # Determine source database path 71 + db_path = pathlib.Path(settings.DATABASES["default"]["NAME"]) 72 + if not db_path.exists(): 73 + raise CommandError(f"Database file not found: {db_path}") 74 + 75 + # Create fixtures directory if it doesn't exist 76 + fixtures_dir = settings.BASE_DIR.parent / "fixtures" 77 + fixtures_dir.mkdir(exist_ok=True) 78 + 79 + snapshot_path = fixtures_dir / snapshot_filename 80 + 81 + self.stdout.write(self.style.SUCCESS("๐Ÿ“ธ Creating database snapshot...")) 82 + self.stdout.write(f" Source: {db_path}") 83 + self.stdout.write(f" Destination: {snapshot_path}") 84 + self.stdout.write(f" Description: {description}") 85 + 86 + # Copy the SQLite database file 87 + shutil.copy2(db_path, snapshot_path) 88 + 89 + self.stdout.write(self.style.SUCCESS("\nโœ… Snapshot created successfully!")) 90 + self.stdout.write(f" File: {snapshot_filename}") 91 + self.stdout.write(f" Size: {snapshot_path.stat().st_size / (1024 * 1024):.2f} MB") 92 + 93 + # Show current database stats 94 + self.stdout.write(self.style.SUCCESS("\n๐Ÿ“Š Snapshot contains:")) 95 + self.stdout.write(f" ๐Ÿ›๏ธ Parliaments: {Parliament.objects.count()}") 96 + self.stdout.write(f" ๐Ÿ“ Constituencies: {Constituency.objects.count()}") 97 + self.stdout.write(f" ๐Ÿ‘ค Representatives: {Representative.objects.count()}") 98 + self.stdout.write(f" ๐Ÿข Committees: {Committee.objects.count()}") 99 + self.stdout.write(f" ๐Ÿท๏ธ Topic Areas: {TopicArea.objects.count()}") 100 + 101 + def handle_restore(self, options): 102 + """Restore database from a snapshot""" 103 + snapshot_file = options["snapshot_file"] 104 + 105 + # Locate snapshot file 106 + fixtures_dir = settings.BASE_DIR.parent / "fixtures" 107 + snapshot_path = fixtures_dir / snapshot_file 108 + 109 + if not snapshot_path.exists(): 110 + raise CommandError(f"Snapshot file not found: {snapshot_path}") 111 + 112 + # Determine target database path 113 + db_path = pathlib.Path(settings.DATABASES["default"]["NAME"]) 114 + 115 + self.stdout.write(self.style.WARNING("โš ๏ธ Restoring database snapshot...")) 116 + self.stdout.write(f" Source: {snapshot_path}") 117 + self.stdout.write(f" Destination: {db_path}") 118 + self.stdout.write(f" Size: {snapshot_path.stat().st_size / (1024 * 1024):.2f} MB") 119 + 120 + # Confirm overwrite 121 + self.stdout.write( 122 + self.style.WARNING( 123 + "\nโš ๏ธ This will OVERWRITE your current database!" 124 + ) 125 + ) 126 + confirm = input("Type 'restore' to confirm: ") 127 + 128 + if confirm != "restore": 129 + self.stdout.write(self.style.ERROR("Restore cancelled.")) 130 + return 131 + 132 + # Backup current database before overwriting 133 + if db_path.exists(): 134 + backup_path = db_path.parent / f"{db_path.stem}_backup_before_restore{db_path.suffix}" 135 + self.stdout.write(f"\n๐Ÿ“ฆ Backing up current database to: {backup_path.name}") 136 + shutil.copy2(db_path, backup_path) 137 + 138 + # Restore the snapshot 139 + shutil.copy2(snapshot_path, db_path) 140 + 141 + self.stdout.write(self.style.SUCCESS("\nโœ… Database restored successfully!")) 142 + 143 + # Show restored database stats 144 + self.stdout.write(self.style.SUCCESS("\n๐Ÿ“Š Restored database contains:")) 145 + self.stdout.write(f" ๐Ÿ›๏ธ Parliaments: {Parliament.objects.count()}") 146 + self.stdout.write(f" ๐Ÿ“ Constituencies: {Constituency.objects.count()}") 147 + self.stdout.write(f" ๐Ÿ‘ค Representatives: {Representative.objects.count()}") 148 + self.stdout.write(f" ๐Ÿข Committees: {Committee.objects.count()}") 149 + self.stdout.write(f" ๐Ÿท๏ธ Topic Areas: {TopicArea.objects.count()}") 150 + 151 + def handle_list(self, options): 152 + """List available snapshots""" 153 + fixtures_dir = settings.BASE_DIR.parent / "fixtures" 154 + 155 + if not fixtures_dir.exists(): 156 + self.stdout.write(self.style.WARNING("No fixtures directory found.")) 157 + return 158 + 159 + # Find all snapshot files 160 + snapshots = sorted(fixtures_dir.glob("db_snapshot_*.sqlite3"), reverse=True) 161 + 162 + if not snapshots: 163 + self.stdout.write(self.style.WARNING("No snapshots found.")) 164 + return 165 + 166 + self.stdout.write(self.style.SUCCESS(f"\n๐Ÿ“ Available snapshots ({len(snapshots)}):")) 167 + self.stdout.write("") 168 + 169 + for snapshot in snapshots: 170 + size_mb = snapshot.stat().st_size / (1024 * 1024) 171 + mtime = datetime.fromtimestamp(snapshot.stat().st_mtime) 172 + self.stdout.write(f" โ€ข {snapshot.name}") 173 + self.stdout.write(f" Size: {size_mb:.2f} MB, Modified: {mtime.strftime('%Y-%m-%d %H:%M:%S')}") 174 + self.stdout.write("")
+80 -10
website/letters/management/commands/fetch_wahlkreis_data.py
··· 1 1 import io 2 2 import json 3 + import tempfile 3 4 import zipfile 4 5 from pathlib import Path 5 6 from typing import Optional ··· 8 9 from django.conf import settings 9 10 from django.core.management.base import BaseCommand, CommandError 10 11 12 + # Official Bundeswahlleiterin Shapefile URL (2025 election) 11 13 DEFAULT_WAHLKREIS_URL = ( 12 - "https://raw.githubusercontent.com/dknx01/wahlkreissuche/main/data/wahlkreise.geojson" 14 + "https://www.bundeswahlleiterin.de/dam/jcr/a3b60aa9-8fa5-4223-9fb4-0a3a3cebd7d1/" 15 + "btw25_geometrie_wahlkreise_vg250_shp_geo.zip" 13 16 ) 14 17 15 18 16 19 class Command(BaseCommand): 17 - """Download and store Wahlkreis geodata for constituency lookups.""" 20 + """Download and convert Wahlkreis geodata for constituency lookups.""" 18 21 19 22 help = ( 20 - "Fetch Bundestag constituency (Wahlkreis) boundary data and store it as GeoJSON " 21 - "for shapely-based lookup." 23 + "Fetch Bundestag constituency (Wahlkreis) boundary data from bundeswahlleiterin.de, " 24 + "convert from Shapefile to GeoJSON if needed, and store for shapely-based lookup. " 25 + "The GeoJSON file is cached locally and Shapefile components are not kept." 22 26 ) 23 27 24 28 def add_arguments(self, parser): ··· 68 72 content_type = response.headers.get("Content-Type", "") 69 73 data_bytes = response.content 70 74 75 + # Check if this is a ZIP file 71 76 if url.lower().endswith(".zip") or "zip" in content_type: 72 - geojson_bytes = self._extract_from_zip(data_bytes, zip_member) 77 + # Check if it contains a .shp file (Shapefile format) 78 + if self._zip_contains_shapefile(data_bytes): 79 + self.stdout.write("Detected Shapefile in ZIP, converting to GeoJSON...") 80 + geojson_text = self._convert_shapefile_to_geojson(data_bytes) 81 + else: 82 + # Extract GeoJSON directly from ZIP 83 + geojson_bytes = self._extract_from_zip(data_bytes, zip_member) 84 + geojson_text = geojson_bytes.decode("utf-8") 73 85 else: 74 - geojson_bytes = data_bytes 86 + geojson_text = data_bytes.decode("utf-8") 75 87 88 + # Validate GeoJSON 76 89 try: 77 - geojson_text = geojson_bytes.decode("utf-8") 78 - json.loads(geojson_text) 79 - except (UnicodeDecodeError, json.JSONDecodeError) as exc: 80 - raise CommandError("Downloaded data is not valid UTF-8 GeoJSON") from exc 90 + geojson_data = json.loads(geojson_text) 91 + feature_count = len(geojson_data.get("features", [])) 92 + self.stdout.write(f"Validated GeoJSON with {feature_count} features") 93 + except json.JSONDecodeError as exc: 94 + raise CommandError("Downloaded data is not valid GeoJSON") from exc 81 95 82 96 output_path.parent.mkdir(parents=True, exist_ok=True) 83 97 output_path.write_text(geojson_text, encoding="utf-8") 84 98 85 99 self.stdout.write(self.style.SUCCESS(f"Saved Wahlkreis data to {output_path}")) 100 + 101 + def _zip_contains_shapefile(self, data: bytes) -> bool: 102 + """Check if ZIP contains Shapefile components (.shp).""" 103 + try: 104 + with zipfile.ZipFile(io.BytesIO(data)) as archive: 105 + return any(name.lower().endswith(".shp") for name in archive.namelist()) 106 + except zipfile.BadZipFile: 107 + return False 108 + 109 + def _convert_shapefile_to_geojson(self, data: bytes) -> str: 110 + """Convert Shapefile in ZIP to GeoJSON using pyshp.""" 111 + try: 112 + import shapefile # pyshp library 113 + except ImportError: 114 + raise CommandError( 115 + "pyshp library is required to convert Shapefiles. " 116 + "Install with: pip install pyshp" 117 + ) 118 + 119 + with tempfile.TemporaryDirectory() as tmpdir: 120 + tmpdir_path = Path(tmpdir) 121 + 122 + # Extract all Shapefile components to temp directory 123 + with zipfile.ZipFile(io.BytesIO(data)) as archive: 124 + shp_files = [name for name in archive.namelist() if name.lower().endswith(".shp")] 125 + if not shp_files: 126 + raise CommandError("No .shp file found in ZIP archive") 127 + 128 + shp_file = shp_files[0] 129 + base_name = Path(shp_file).stem 130 + 131 + # Extract all related files (.shp, .shx, .dbf, .prj, etc.) 132 + for member in archive.namelist(): 133 + if Path(member).stem == base_name: 134 + archive.extract(member, tmpdir_path) 135 + 136 + # Convert using pyshp 137 + shp_path = tmpdir_path / shp_file 138 + sf = shapefile.Reader(str(shp_path)) 139 + 140 + # Convert to GeoJSON 141 + features = [] 142 + for shape_rec in sf.shapeRecords(): 143 + feature = { 144 + "type": "Feature", 145 + "geometry": shape_rec.shape.__geo_interface__, 146 + "properties": shape_rec.record.as_dict() 147 + } 148 + features.append(feature) 149 + 150 + geojson = { 151 + "type": "FeatureCollection", 152 + "features": features 153 + } 154 + 155 + return json.dumps(geojson, ensure_ascii=False, indent=None) 86 156 87 157 def _extract_from_zip(self, data: bytes, member: Optional[str]) -> bytes: 88 158 with zipfile.ZipFile(io.BytesIO(data)) as archive:
+105
website/letters/management/commands/query_representatives.py
··· 1 + # ABOUTME: Query management command to find representatives by address and/or topics. 2 + # ABOUTME: Interactive tool for testing representative suggestion logic. 3 + 4 + from django.core.management.base import BaseCommand 5 + from letters.services import ConstituencyLocator, TopicSuggestionService, ConstituencySuggestionService 6 + 7 + 8 + class Command(BaseCommand): 9 + help = 'Find representatives by address and/or topics' 10 + 11 + def add_arguments(self, parser): 12 + # Address arguments 13 + parser.add_argument( 14 + '--street', 15 + type=str, 16 + help='Street name and number' 17 + ) 18 + parser.add_argument( 19 + '--postal-code', 20 + type=str, 21 + help='Postal code (PLZ)' 22 + ) 23 + parser.add_argument( 24 + '--city', 25 + type=str, 26 + help='City name' 27 + ) 28 + 29 + # Topic arguments 30 + parser.add_argument( 31 + '--topics', 32 + type=str, 33 + help='Comma-separated topic keywords (e.g., "Verkehr,Infrastruktur")' 34 + ) 35 + 36 + parser.add_argument( 37 + '--limit', 38 + type=int, 39 + default=10, 40 + help='Maximum number of representatives to return (default: 10)' 41 + ) 42 + 43 + def handle(self, *args, **options): 44 + street = options.get('street') 45 + postal_code = options.get('postal_code') 46 + city = options.get('city') 47 + topics_str = options.get('topics') 48 + limit = options['limit'] 49 + 50 + try: 51 + # Use constituency locator if address provided 52 + if postal_code or (street and city): 53 + locator = ConstituencyLocator() 54 + representatives = locator.locate( 55 + street=street, 56 + postal_code=postal_code, 57 + city=city 58 + ) 59 + 60 + if not representatives: 61 + self.stdout.write('No representatives found for this location') 62 + return 63 + 64 + # Filter by topics if provided 65 + if topics_str: 66 + topic_keywords = [t.strip() for t in topics_str.split(',')] 67 + # Simple keyword filter on representative focus areas 68 + filtered_reps = [] 69 + for rep in representatives: 70 + # Check if any committee or focus area matches 71 + rep_text = ' '.join([ 72 + rep.full_name, 73 + ' '.join([c.name for c in rep.committees.all()]), 74 + ]).lower() 75 + 76 + if any(keyword.lower() in rep_text for keyword in topic_keywords): 77 + filtered_reps.append(rep) 78 + 79 + representatives = filtered_reps if filtered_reps else representatives 80 + 81 + # Display results 82 + for rep in representatives[:limit]: 83 + constituency = rep.primary_constituency 84 + constituency_label = constituency.name if constituency else rep.parliament.name 85 + self.stdout.write(f'{rep.full_name} ({rep.party}) - {constituency_label}') 86 + 87 + # Show committees 88 + committees = list(rep.committees.all()[:3]) 89 + if committees: 90 + committee_names = ', '.join([c.name for c in committees]) 91 + self.stdout.write(f' Committees: {committee_names}') 92 + 93 + # Use topic-based search if only topics provided 94 + elif topics_str: 95 + self.stdout.write('Topic-based representative search not yet implemented') 96 + self.stdout.write('Please provide at least a postal code for location-based search') 97 + 98 + else: 99 + self.stderr.write(self.style.ERROR( 100 + 'Error: Please provide either an address (--postal-code required) or --topics' 101 + )) 102 + 103 + except Exception as e: 104 + self.stderr.write(self.style.ERROR(f'Error: {str(e)}')) 105 + return
+62
website/letters/management/commands/query_topics.py
··· 1 + # ABOUTME: Query management command to find matching topics for letter text. 2 + # ABOUTME: Interactive tool for testing topic keyword matching and scoring. 3 + 4 + from django.core.management.base import BaseCommand 5 + from letters.services import TopicSuggestionService 6 + 7 + 8 + class Command(BaseCommand): 9 + help = 'Find matching topics for a letter title or text' 10 + 11 + def add_arguments(self, parser): 12 + parser.add_argument( 13 + '--text', 14 + type=str, 15 + required=True, 16 + help='Letter title or text to analyze' 17 + ) 18 + parser.add_argument( 19 + '--limit', 20 + type=int, 21 + default=5, 22 + help='Maximum number of topics to return (default: 5)' 23 + ) 24 + 25 + def handle(self, *args, **options): 26 + text = options['text'] 27 + limit = options['limit'] 28 + 29 + try: 30 + # Use the suggest_representatives_for_concern method to get topic suggestions 31 + result = TopicSuggestionService.suggest_representatives_for_concern( 32 + text, 33 + limit=limit 34 + ) 35 + 36 + matched_topics = result.get('matched_topics', []) 37 + 38 + if not matched_topics: 39 + self.stdout.write('No matching topics found') 40 + return 41 + 42 + # Display matched topics 43 + for topic in matched_topics[:limit]: 44 + # TopicArea objects have name, primary_level, and description 45 + level = getattr(topic, 'primary_level', 'UNKNOWN') 46 + name = getattr(topic, 'name', str(topic)) 47 + description = getattr(topic, 'description', '') 48 + 49 + self.stdout.write(f"{name} ({level})") 50 + if description: 51 + self.stdout.write(f" {description}") 52 + 53 + # Also show suggested level and explanation 54 + if result.get('suggested_level'): 55 + self.stdout.write('') 56 + self.stdout.write(f"Suggested Level: {result['suggested_level']}") 57 + if result.get('explanation'): 58 + self.stdout.write(f"Explanation: {result['explanation']}") 59 + 60 + except Exception as e: 61 + self.stderr.write(self.style.ERROR(f'Error: {str(e)}')) 62 + return
+66
website/letters/management/commands/query_wahlkreis.py
··· 1 + # ABOUTME: Query management command to find constituency by address or postal code. 2 + # ABOUTME: Interactive tool for testing address-based constituency matching. 3 + 4 + from django.core.management.base import BaseCommand 5 + from letters.services import AddressGeocoder, WahlkreisLocator, ConstituencyLocator 6 + 7 + 8 + class Command(BaseCommand): 9 + help = 'Find constituency (Wahlkreis) by address or postal code' 10 + 11 + def add_arguments(self, parser): 12 + parser.add_argument( 13 + '--street', 14 + type=str, 15 + help='Street name and number' 16 + ) 17 + parser.add_argument( 18 + '--postal-code', 19 + type=str, 20 + help='Postal code (PLZ)', 21 + required=True 22 + ) 23 + parser.add_argument( 24 + '--city', 25 + type=str, 26 + help='City name' 27 + ) 28 + 29 + def handle(self, *args, **options): 30 + street = options.get('street') 31 + postal_code = options['postal_code'] 32 + city = options.get('city') 33 + 34 + try: 35 + # Try full address geocoding if all parts provided 36 + if street and city: 37 + geocoder = AddressGeocoder() 38 + lat, lon, success, error = geocoder.geocode(street, postal_code, city) 39 + 40 + if not success: 41 + self.stdout.write(self.style.ERROR(f'Error: Could not geocode address: {error}')) 42 + return 43 + 44 + locator = WahlkreisLocator() 45 + result = locator.locate(lat, lon) 46 + 47 + if not result: 48 + self.stdout.write('No constituency found for these coordinates') 49 + return 50 + 51 + wkr_nr, wkr_name, land_name = result 52 + self.stdout.write(f'WK {wkr_nr:03d} - {wkr_name} ({land_name})') 53 + 54 + # Fallback to PLZ prefix lookup 55 + else: 56 + plz_prefix = postal_code[:2] 57 + state_name = ConstituencyLocator.STATE_BY_PLZ_PREFIX.get(plz_prefix) 58 + 59 + if state_name: 60 + self.stdout.write(f'State: {state_name} (from postal code prefix)') 61 + else: 62 + self.stdout.write('Error: Could not determine state from postal code') 63 + 64 + except Exception as e: 65 + self.stderr.write(self.style.ERROR(f'Error: {str(e)}')) 66 + return
-81
website/letters/management/commands/test_constituency_suggestion.py
··· 1 - """ 2 - Management command to test the ConstituencySuggestionService with example queries. 3 - """ 4 - 5 - from django.core.management.base import BaseCommand 6 - from letters.services import ConstituencySuggestionService 7 - 8 - 9 - class Command(BaseCommand): 10 - help = 'Test the constituency suggestion service with example queries' 11 - 12 - def add_arguments(self, parser): 13 - parser.add_argument( 14 - '--query', 15 - type=str, 16 - help='Custom query to test (if not provided, runs example queries)' 17 - ) 18 - 19 - def handle(self, *args, **options): 20 - custom_query = options.get('query') 21 - 22 - if custom_query: 23 - # Test custom query 24 - self.test_query(custom_query) 25 - else: 26 - # Run all example queries 27 - self.stdout.write(self.style.SUCCESS('Testing ConstituencySuggestionService\n')) 28 - self.stdout.write('=' * 80) 29 - 30 - examples = ConstituencySuggestionService.get_example_queries() 31 - 32 - for i, example in enumerate(examples, 1): 33 - self.stdout.write(f"\n{i}. Query: \"{example['query']}\"") 34 - self.stdout.write(f" Expected: {example['expected_level']} - {example['topic']}") 35 - self.stdout.write('-' * 80) 36 - 37 - self.test_query(example['query']) 38 - 39 - self.stdout.write('=' * 80) 40 - 41 - def test_query(self, query): 42 - """Test a single query and display results""" 43 - result = ConstituencySuggestionService.suggest_from_concern(query) 44 - 45 - # Display matched topics 46 - self.stdout.write(self.style.WARNING('\nMatched Topics:')) 47 - if result['matched_topics']: 48 - for topic in result['matched_topics']: 49 - self.stdout.write( 50 - f" โ€ข {topic.name} ({topic.get_primary_level_display()}) - {topic.competency_type}" 51 - ) 52 - else: 53 - self.stdout.write(' None') 54 - 55 - # Display suggested level 56 - self.stdout.write(self.style.WARNING('\nSuggested Level:')) 57 - self.stdout.write(f" {result['suggested_level']}") 58 - 59 - # Display explanation 60 - self.stdout.write(self.style.WARNING('\nExplanation:')) 61 - self.stdout.write(f" {result['explanation']}") 62 - 63 - # Display constituencies 64 - self.stdout.write(self.style.WARNING('\nConstituencies:')) 65 - if result['constituencies']: 66 - for const in result['constituencies'][:5]: # Limit to 5 67 - self.stdout.write(f" โ€ข {const.name} ({const.get_level_display()})") 68 - else: 69 - self.stdout.write(' None') 70 - 71 - # Display representatives 72 - self.stdout.write(self.style.WARNING('\nRepresentatives:')) 73 - if result['representatives']: 74 - for rep in result['representatives'][:5]: # Limit to 5 75 - constituency = rep.primary_constituency 76 - constituency_label = constituency.name if constituency else rep.parliament.name 77 - self.stdout.write( 78 - f" โ€ข {rep.full_name} ({rep.party}) - {constituency_label}" 79 - ) 80 - else: 81 - self.stdout.write(' None (no representatives in database yet)')
-85
website/letters/management/commands/test_topic_mapping.py
··· 1 - """ 2 - Management command to test topic-to-constituency mapping. 3 - 4 - Provides examples of how the topic suggestion service works. 5 - """ 6 - 7 - from django.core.management.base import BaseCommand 8 - from letters.services import TopicSuggestionService 9 - 10 - 11 - class Command(BaseCommand): 12 - help = 'Test topic-to-constituency mapping with example concerns' 13 - 14 - def add_arguments(self, parser): 15 - parser.add_argument( 16 - '--concern', 17 - type=str, 18 - help='Custom concern text to test', 19 - ) 20 - 21 - def handle(self, *args, **options): 22 - custom_concern = options.get('concern') 23 - 24 - if custom_concern: 25 - # Test custom concern 26 - self.test_concern(custom_concern) 27 - else: 28 - # Test predefined examples 29 - self.stdout.write(self.style.MIGRATE_HEADING('\nTesting Topic-to-Constituency Mapping\n')) 30 - 31 - test_cases = [ 32 - "I want to see better train connections between cities", 33 - "We need more affordable housing and rent control", 34 - "Our school curriculum needs reform", 35 - "Climate protection and CO2 emissions must be addressed", 36 - "Better bus services in my town", 37 - "Deutsche Bahn is always late", 38 - "University tuition fees should be abolished", 39 - "We need stronger EU trade agreements", 40 - "Police funding in our state is too low", 41 - "Renewable energy expansion is too slow", 42 - ] 43 - 44 - for concern in test_cases: 45 - self.test_concern(concern) 46 - self.stdout.write('') # Blank line 47 - 48 - def test_concern(self, concern_text: str): 49 - """Test a single concern and display results.""" 50 - self.stdout.write(self.style.SUCCESS(f'Concern: "{concern_text}"')) 51 - 52 - # Get topic suggestions only (lightweight) 53 - topics = TopicSuggestionService.get_topic_suggestions(concern_text) 54 - 55 - if topics: 56 - self.stdout.write(self.style.WARNING(' Matched Topics:')) 57 - for topic in topics[:3]: # Show top 3 58 - self.stdout.write(f' โ€ข {topic["name"]} ({topic["level"]}) - Score: {topic["match_score"]}') 59 - self.stdout.write(f' {topic["description"]}') 60 - else: 61 - self.stdout.write(self.style.WARNING(' No specific topics matched')) 62 - 63 - # Get full suggestions with representatives 64 - result = TopicSuggestionService.suggest_representatives_for_concern( 65 - concern_text, 66 - limit=3 67 - ) 68 - 69 - self.stdout.write(self.style.WARNING(f' Suggested Level: {result["suggested_level"]}')) 70 - self.stdout.write(self.style.WARNING(f' Explanation: {result["explanation"]}')) 71 - 72 - if result['suggested_constituencies']: 73 - self.stdout.write(self.style.WARNING(' Suggested Constituencies:')) 74 - for const in result['suggested_constituencies'][:3]: 75 - self.stdout.write(f' โ€ข {const.name} ({const.level})') 76 - 77 - if result['suggested_representatives']: 78 - self.stdout.write(self.style.WARNING(' Suggested Representatives:')) 79 - for rep in result['suggested_representatives']: 80 - party = f' ({rep.party})' if rep.party else '' 81 - constituency = rep.primary_constituency 82 - constituency_label = constituency.name if constituency else rep.parliament.name 83 - self.stdout.write(f' โ€ข {rep.full_name}{party} - {constituency_label}') 84 - else: 85 - self.stdout.write(self.style.WARNING(' (No representatives found - run sync_representatives first)'))
+35
website/letters/migrations/0012_geocodecache.py
··· 1 + # Generated by Django 5.2.6 on 2025-10-11 18:11 2 + 3 + from django.db import migrations, models 4 + 5 + 6 + class Migration(migrations.Migration): 7 + 8 + dependencies = [ 9 + ('letters', '0011_alter_letter_author'), 10 + ] 11 + 12 + operations = [ 13 + migrations.CreateModel( 14 + name='GeocodeCache', 15 + fields=[ 16 + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 + ('address_hash', models.CharField(db_index=True, help_text='SHA256 hash of normalized address for fast lookup', max_length=64, unique=True)), 18 + ('street', models.CharField(blank=True, max_length=255)), 19 + ('postal_code', models.CharField(blank=True, max_length=10)), 20 + ('city', models.CharField(blank=True, max_length=100)), 21 + ('country', models.CharField(default='DE', max_length=2)), 22 + ('latitude', models.FloatField(blank=True, null=True)), 23 + ('longitude', models.FloatField(blank=True, null=True)), 24 + ('success', models.BooleanField(default=True, help_text='False if geocoding failed, to avoid repeated failed lookups')), 25 + ('error_message', models.TextField(blank=True)), 26 + ('created_at', models.DateTimeField(auto_now_add=True)), 27 + ('updated_at', models.DateTimeField(auto_now=True)), 28 + ], 29 + options={ 30 + 'verbose_name': 'Geocode Cache Entry', 31 + 'verbose_name_plural': 'Geocode Cache Entries', 32 + 'ordering': ['-created_at'], 33 + }, 34 + ), 35 + ]
+37
website/letters/models.py
··· 768 768 769 769 def __str__(self): 770 770 return f"Report on '{self.letter.title}' - {self.get_reason_display()}" 771 + 772 + 773 + class GeocodeCache(models.Model): 774 + """Cache geocoding results to minimize API calls.""" 775 + 776 + address_hash = models.CharField( 777 + max_length=64, 778 + unique=True, 779 + db_index=True, 780 + help_text="SHA256 hash of normalized address for fast lookup" 781 + ) 782 + street = models.CharField(max_length=255, blank=True) 783 + postal_code = models.CharField(max_length=10, blank=True) 784 + city = models.CharField(max_length=100, blank=True) 785 + country = models.CharField(max_length=2, default='DE') 786 + 787 + latitude = models.FloatField(null=True, blank=True) 788 + longitude = models.FloatField(null=True, blank=True) 789 + 790 + success = models.BooleanField( 791 + default=True, 792 + help_text="False if geocoding failed, to avoid repeated failed lookups" 793 + ) 794 + error_message = models.TextField(blank=True) 795 + 796 + created_at = models.DateTimeField(auto_now_add=True) 797 + updated_at = models.DateTimeField(auto_now=True) 798 + 799 + class Meta: 800 + verbose_name = "Geocode Cache Entry" 801 + verbose_name_plural = "Geocode Cache Entries" 802 + ordering = ['-created_at'] 803 + 804 + def __str__(self): 805 + if self.latitude and self.longitude: 806 + return f"{self.city} ({self.latitude}, {self.longitude})" 807 + return f"{self.city} (failed)"
+537 -18
website/letters/services.py
··· 11 11 12 12 from __future__ import annotations 13 13 14 + import hashlib 14 15 import json 15 16 import logging 16 17 import re 18 + import time 17 19 import mimetypes 18 20 from datetime import datetime, date 19 21 from dataclasses import dataclass ··· 34 36 Committee, 35 37 CommitteeMembership, 36 38 Constituency, 39 + GeocodeCache, 37 40 Parliament, 38 41 ParliamentTerm, 39 42 Representative, ··· 125 128 126 129 127 130 # --------------------------------------------------------------------------- 131 + # Address Geocoding with OSM Nominatim 132 + # --------------------------------------------------------------------------- 133 + 134 + 135 + class AddressGeocoder: 136 + """ 137 + Geocode German addresses using OpenStreetMap Nominatim API. 138 + 139 + Features: 140 + - Caches results using GeocodeCache model 141 + - Rate limits to 1 request/second for public API compliance 142 + - Handles errors gracefully 143 + - Caches both successful and failed lookups to avoid repeated failures 144 + """ 145 + 146 + NOMINATIM_ENDPOINT = 'https://nominatim.openstreetmap.org/search' 147 + USER_AGENT = 'WriteThem.eu/0.1 (civic engagement platform)' 148 + RATE_LIMIT_SECONDS = 1.0 149 + 150 + def __init__(self): 151 + self._last_request_time = 0 152 + 153 + def geocode( 154 + self, 155 + street: str, 156 + postal_code: str, 157 + city: str, 158 + country: str = 'DE' 159 + ) -> Tuple[Optional[float], Optional[float], bool, Optional[str]]: 160 + """ 161 + Geocode a German address to latitude/longitude coordinates. 162 + 163 + Args: 164 + street: Street name and number 165 + postal_code: Postal code (PLZ) 166 + city: City name 167 + country: Country code (default: 'DE') 168 + 169 + Returns: 170 + Tuple of (latitude, longitude, success, error_message) 171 + - On success: (lat, lon, True, None) 172 + - On failure: (None, None, False, error_message) 173 + """ 174 + # Normalize inputs 175 + street = (street or '').strip() 176 + postal_code = (postal_code or '').strip() 177 + city = (city or '').strip() 178 + country = (country or 'DE').upper() 179 + 180 + # Generate cache key 181 + address_hash = self._generate_cache_key(street, postal_code, city, country) 182 + 183 + # Check cache first 184 + cached = self._get_from_cache(address_hash) 185 + if cached is not None: 186 + return cached 187 + 188 + # Make API request with rate limiting 189 + try: 190 + self._apply_rate_limit() 191 + result = self._query_nominatim(street, postal_code, city, country) 192 + 193 + if result: 194 + lat, lon = result 195 + self._store_in_cache( 196 + address_hash, street, postal_code, city, country, 197 + lat, lon, success=True, error_message=None 198 + ) 199 + return lat, lon, True, None 200 + else: 201 + error_msg = 'Address not found' 202 + self._store_in_cache( 203 + address_hash, street, postal_code, city, country, 204 + None, None, success=False, error_message=error_msg 205 + ) 206 + return None, None, False, error_msg 207 + 208 + except Exception as e: 209 + error_msg = f'Geocoding API error: {str(e)}' 210 + logger.warning('Geocoding failed for %s, %s %s: %s', street, postal_code, city, error_msg) 211 + 212 + # Cache the failure to avoid repeated attempts 213 + self._store_in_cache( 214 + address_hash, street, postal_code, city, country, 215 + None, None, success=False, error_message=error_msg 216 + ) 217 + return None, None, False, error_msg 218 + 219 + def _generate_cache_key( 220 + self, 221 + street: str, 222 + postal_code: str, 223 + city: str, 224 + country: str 225 + ) -> str: 226 + """Generate SHA256 hash of normalized address for cache lookup.""" 227 + # Normalize address components for consistent hashing 228 + normalized = f"{street}|{postal_code}|{city}|{country}" 229 + return hashlib.sha256(normalized.encode('utf-8')).hexdigest() 230 + 231 + def _get_from_cache( 232 + self, 233 + address_hash: str 234 + ) -> Optional[Tuple[Optional[float], Optional[float], bool, Optional[str]]]: 235 + """Check cache for existing geocoding result.""" 236 + try: 237 + cache_entry = GeocodeCache.objects.get(address_hash=address_hash) 238 + if cache_entry.success: 239 + return cache_entry.latitude, cache_entry.longitude, True, None 240 + else: 241 + return None, None, False, cache_entry.error_message 242 + except GeocodeCache.DoesNotExist: 243 + return None 244 + 245 + def _store_in_cache( 246 + self, 247 + address_hash: str, 248 + street: str, 249 + postal_code: str, 250 + city: str, 251 + country: str, 252 + latitude: Optional[float], 253 + longitude: Optional[float], 254 + success: bool, 255 + error_message: Optional[str] 256 + ) -> None: 257 + """Store geocoding result in cache.""" 258 + GeocodeCache.objects.update_or_create( 259 + address_hash=address_hash, 260 + defaults={ 261 + 'street': street, 262 + 'postal_code': postal_code, 263 + 'city': city, 264 + 'country': country, 265 + 'latitude': latitude, 266 + 'longitude': longitude, 267 + 'success': success, 268 + 'error_message': error_message or '', 269 + } 270 + ) 271 + 272 + def _apply_rate_limit(self) -> None: 273 + """Ensure we don't exceed 1 request per second.""" 274 + current_time = time.time() 275 + time_since_last = current_time - self._last_request_time 276 + 277 + if time_since_last < self.RATE_LIMIT_SECONDS: 278 + sleep_time = self.RATE_LIMIT_SECONDS - time_since_last 279 + time.sleep(sleep_time) 280 + 281 + self._last_request_time = time.time() 282 + 283 + def _query_nominatim( 284 + self, 285 + street: str, 286 + postal_code: str, 287 + city: str, 288 + country: str 289 + ) -> Optional[Tuple[float, float]]: 290 + """ 291 + Query Nominatim API for address coordinates. 292 + 293 + Returns: 294 + (latitude, longitude) on success, None if not found 295 + 296 + Raises: 297 + requests.RequestException on API errors 298 + """ 299 + # Build query string 300 + query_parts = [] 301 + if street: 302 + query_parts.append(street) 303 + if postal_code: 304 + query_parts.append(postal_code) 305 + if city: 306 + query_parts.append(city) 307 + 308 + query = ', '.join(query_parts) 309 + 310 + params = { 311 + 'q': query, 312 + 'format': 'json', 313 + 'addressdetails': 1, 314 + 'limit': 1, 315 + 'countrycodes': country.lower(), 316 + } 317 + 318 + headers = { 319 + 'User-Agent': self.USER_AGENT 320 + } 321 + 322 + response = requests.get( 323 + self.NOMINATIM_ENDPOINT, 324 + params=params, 325 + headers=headers, 326 + timeout=10 327 + ) 328 + response.raise_for_status() 329 + 330 + results = response.json() 331 + 332 + if results and len(results) > 0: 333 + result = results[0] 334 + lat = float(result['lat']) 335 + lon = float(result['lon']) 336 + return lat, lon 337 + 338 + return None 339 + 340 + 341 + # --------------------------------------------------------------------------- 342 + # Wahlkreis (Constituency) Locator using Point-in-Polygon matching 343 + # --------------------------------------------------------------------------- 344 + 345 + 346 + class WahlkreisLocator: 347 + """Locate which Wahlkreis (constituency) a coordinate falls within using Shapely.""" 348 + 349 + # Class-level cache for parsed constituencies 350 + _cached_constituencies = None 351 + _cached_path = None 352 + 353 + def __init__(self, geojson_path=None): 354 + """ 355 + Load and parse GeoJSON constituencies. 356 + 357 + Args: 358 + geojson_path: Path to the GeoJSON file. If None, uses settings.CONSTITUENCY_BOUNDARIES_PATH 359 + """ 360 + from shapely.geometry import shape 361 + 362 + if geojson_path is None: 363 + geojson_path = settings.CONSTITUENCY_BOUNDARIES_PATH 364 + 365 + # Use cached constituencies if available and path matches 366 + if (WahlkreisLocator._cached_constituencies is not None and 367 + WahlkreisLocator._cached_path == geojson_path): 368 + self.constituencies = WahlkreisLocator._cached_constituencies 369 + return 370 + 371 + # Load and parse GeoJSON file 372 + self.constituencies = [] 373 + with open(geojson_path, 'r', encoding='utf-8') as f: 374 + data = json.load(f) 375 + 376 + # Parse each feature and store geometry with properties 377 + for feature in data.get('features', []): 378 + properties = feature.get('properties', {}) 379 + wkr_nr = properties.get('WKR_NR') 380 + wkr_name = properties.get('WKR_NAME', '') 381 + land_name = properties.get('LAND_NAME', '') 382 + 383 + # Parse geometry using Shapely 384 + geometry = shape(feature['geometry']) 385 + 386 + # Store as tuple: (wkr_nr, wkr_name, land_name, geometry) 387 + self.constituencies.append((wkr_nr, wkr_name, land_name, geometry)) 388 + 389 + # Cache the parsed constituencies 390 + WahlkreisLocator._cached_constituencies = self.constituencies 391 + WahlkreisLocator._cached_path = geojson_path 392 + 393 + def locate(self, latitude, longitude): 394 + """ 395 + Find constituency containing the given coordinates. 396 + 397 + Args: 398 + latitude: Latitude coordinate 399 + longitude: Longitude coordinate 400 + 401 + Returns: 402 + tuple: (wkr_nr, wkr_name, land_name) or None if not found 403 + """ 404 + from shapely.geometry import Point 405 + 406 + # Create point from coordinates 407 + point = Point(longitude, latitude) 408 + 409 + # Iterate through constituencies and check containment 410 + for wkr_nr, wkr_name, land_name, geometry in self.constituencies: 411 + if geometry.contains(point): 412 + return (wkr_nr, wkr_name, land_name) 413 + 414 + # No match found 415 + return None 416 + 417 + 418 + # --------------------------------------------------------------------------- 128 419 # Constituency / address helper 129 420 # --------------------------------------------------------------------------- 130 421 ··· 141 432 postal_code: Optional[str] 142 433 state: Optional[str] 143 434 constituencies: List[Constituency] 435 + street: Optional[str] = None 436 + city: Optional[str] = None 437 + country: str = 'DE' 144 438 145 439 @property 146 440 def has_constituencies(self) -> bool: ··· 176 470 177 471 178 472 class ConstituencyLocator: 179 - """Heuristic mapping from postal codes to broad constituencies.""" 473 + """ 474 + Locate representatives by address or postal code. 475 + 476 + Features: 477 + - Address-based lookup: Uses AddressGeocoder + WahlkreisLocator for accurate constituency matching 478 + - PLZ-based fallback: Falls back to PLZ-prefix matching when geocoding fails 479 + - Backward compatible: Still accepts PLZ-only queries 480 + """ 180 481 181 - # Rough PLZ -> state mapping (first two digits). 482 + # Rough PLZ -> state mapping (first two digits) for fallback 182 483 STATE_BY_PLZ_PREFIX: Dict[str, str] = { 183 484 **{prefix: 'Berlin' for prefix in ['10', '11']}, 184 485 **{prefix: 'Bayern' for prefix in ['80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '90', '91']} , ··· 188 489 **{prefix: 'Niedersachsen' for prefix in ['26', '27', '28', '29', '30', '31', '32', '33', '37', '38', '49']}, 189 490 } 190 491 492 + def __init__(self): 493 + """Initialize geocoder and wahlkreis locator services.""" 494 + self._geocoder = None 495 + self._wahlkreis_locator = None 496 + 497 + @property 498 + def geocoder(self): 499 + """Lazy-load AddressGeocoder.""" 500 + if self._geocoder is None: 501 + self._geocoder = AddressGeocoder() 502 + return self._geocoder 503 + 504 + @property 505 + def wahlkreis_locator(self): 506 + """Lazy-load WahlkreisLocator.""" 507 + if self._wahlkreis_locator is None: 508 + self._wahlkreis_locator = WahlkreisLocator() 509 + return self._wahlkreis_locator 510 + 511 + def locate( 512 + self, 513 + street: Optional[str] = None, 514 + postal_code: Optional[str] = None, 515 + city: Optional[str] = None, 516 + country: str = 'DE' 517 + ) -> List[Representative]: 518 + """ 519 + Locate representatives by address or postal code. 520 + 521 + Args: 522 + street: Street name and number (optional) 523 + postal_code: Postal code / PLZ (optional) 524 + city: City name (optional) 525 + country: Country code (default: 'DE') 526 + 527 + Returns: 528 + List of Representative objects for the located constituency 529 + 530 + Strategy: 531 + 1. If full address provided (street + postal_code + city): 532 + - Geocode address to coordinates 533 + - Use WahlkreisLocator to find constituency 534 + - Return Representatives for that constituency 535 + 2. Fallback to PLZ-prefix matching if: 536 + - No street provided 537 + - Geocoding fails 538 + - WahlkreisLocator returns no result 539 + """ 540 + street = (street or '').strip() 541 + postal_code = (postal_code or '').strip() 542 + city = (city or '').strip() 543 + 544 + # Try full address-based lookup if we have all components 545 + if street and postal_code and city: 546 + try: 547 + lat, lon, success, error = self.geocoder.geocode(street, postal_code, city, country) 548 + 549 + if success and lat is not None and lon is not None: 550 + # Find constituency using coordinates 551 + result = self.wahlkreis_locator.locate(lat, lon) 552 + 553 + if result: 554 + wkr_nr, wkr_name, land_name = result 555 + logger.info( 556 + "Address geocoded to constituency: %s (WK %s, %s)", 557 + wkr_name, wkr_nr, land_name 558 + ) 559 + 560 + # Find Representatives for this Wahlkreis 561 + representatives = self._find_representatives_by_wahlkreis( 562 + wkr_nr, wkr_name, land_name 563 + ) 564 + 565 + if representatives: 566 + return representatives 567 + 568 + # If no representatives found for direct constituency, 569 + # fall through to PLZ-based lookup 570 + logger.warning( 571 + "No representatives found for WK %s, falling back to PLZ", 572 + wkr_nr 573 + ) 574 + else: 575 + logger.debug( 576 + "Geocoding failed for %s, %s %s: %s", 577 + street, postal_code, city, error 578 + ) 579 + except Exception as e: 580 + logger.warning( 581 + "Error during address-based lookup for %s, %s %s: %s", 582 + street, postal_code, city, e 583 + ) 584 + 585 + # Fallback to PLZ-based lookup 586 + if postal_code: 587 + return self._locate_by_plz(postal_code) 588 + 589 + # No parameters provided 590 + return [] 591 + 592 + def _find_representatives_by_wahlkreis( 593 + self, 594 + wkr_nr: int, 595 + wkr_name: str, 596 + land_name: str 597 + ) -> List[Representative]: 598 + """ 599 + Find representatives for a given Wahlkreis. 600 + 601 + Strategy: 602 + 1. Look for constituencies with matching WKR_NR in metadata 603 + 2. Look for constituencies with matching name 604 + 3. Return active representatives from matched constituencies 605 + """ 606 + # Try to find constituency by WKR_NR in metadata 607 + constituencies = Constituency.objects.filter( 608 + metadata__WKR_NR=wkr_nr, 609 + scope='FEDERAL_DISTRICT' 610 + ).prefetch_related('representatives') 611 + 612 + if not constituencies.exists(): 613 + # Try by name matching 614 + constituencies = Constituency.objects.filter( 615 + name__icontains=str(wkr_nr), 616 + scope='FEDERAL_DISTRICT' 617 + ).prefetch_related('representatives') 618 + 619 + if not constituencies.exists(): 620 + # Try finding by state and scope 621 + normalized_state = normalize_german_state(land_name) 622 + if normalized_state: 623 + constituencies = Constituency.objects.filter( 624 + metadata__state=normalized_state, 625 + scope__in=['FEDERAL_DISTRICT', 'FEDERAL_STATE_LIST'] 626 + ).prefetch_related('representatives') 627 + 628 + # Collect all representatives from matched constituencies 629 + representatives = [] 630 + for constituency in constituencies: 631 + reps = list(constituency.representatives.filter(is_active=True)) 632 + representatives.extend(reps) 633 + 634 + # Remove duplicates while preserving order 635 + seen = set() 636 + unique_reps = [] 637 + for rep in representatives: 638 + if rep.id not in seen: 639 + seen.add(rep.id) 640 + unique_reps.append(rep) 641 + 642 + return unique_reps 643 + 644 + def _locate_by_plz(self, postal_code: str) -> List[Representative]: 645 + """ 646 + Fallback: Locate representatives using PLZ-prefix matching. 647 + 648 + Returns list of Representatives instead of LocatedConstituencies. 649 + """ 650 + if len(postal_code) < 2: 651 + return [] 652 + 653 + prefix = postal_code[:2] 654 + state_name = self.STATE_BY_PLZ_PREFIX.get(prefix) 655 + normalized_state = normalize_german_state(state_name) if state_name else None 656 + 657 + federal = self._match_federal(normalized_state) 658 + state = self._match_state(normalized_state) 659 + 660 + # Convert constituencies to representatives 661 + representatives = [] 662 + for constituency in [federal, state]: 663 + if constituency: 664 + reps = list(constituency.representatives.filter(is_active=True)) 665 + representatives.extend(reps) 666 + 667 + # Remove duplicates 668 + seen = set() 669 + unique_reps = [] 670 + for rep in representatives: 671 + if rep.id not in seen: 672 + seen.add(rep.id) 673 + unique_reps.append(rep) 674 + 675 + return unique_reps 676 + 191 677 @classmethod 192 - def locate(cls, postal_code: str) -> LocatedConstituencies: 678 + def locate_legacy(cls, postal_code: str) -> LocatedConstituencies: 679 + """ 680 + Legacy method: Returns LocatedConstituencies for backward compatibility. 681 + 682 + This method maintains the old API for existing code that expects 683 + LocatedConstituencies instead of List[Representative]. 684 + """ 193 685 postal_code = (postal_code or '').strip() 194 686 if len(postal_code) < 2: 195 687 return LocatedConstituencies(None, None, None) ··· 1170 1662 from .models import IdentityVerification 1171 1663 1172 1664 postal_code = (verification_data.get('postal_code') or '').strip() 1173 - located = ConstituencyLocator.locate(postal_code) if postal_code else LocatedConstituencies(None, None, None) 1665 + located = ConstituencyLocator.locate_legacy(postal_code) if postal_code else LocatedConstituencies(None, None, None) 1174 1666 constituency = located.local or located.state or located.federal 1175 1667 1176 1668 expires_at_value = verification_data.get('expires_at') ··· 1354 1846 1355 1847 @classmethod 1356 1848 def _resolve_location(cls, user_location: Dict[str, str]) -> LocationContext: 1849 + # Extract address components 1357 1850 postal_code = (user_location.get('postal_code') or '').strip() 1851 + street = (user_location.get('street') or '').strip() 1852 + city = (user_location.get('city') or '').strip() 1853 + country = (user_location.get('country') or 'DE').upper() 1854 + 1358 1855 constituencies: List[Constituency] = [] 1359 1856 1857 + # First, check if constituencies are provided directly 1360 1858 provided_constituencies = user_location.get('constituencies') 1361 1859 if provided_constituencies: 1362 1860 iterable = provided_constituencies if isinstance(provided_constituencies, (list, tuple, set)) else [provided_constituencies] ··· 1374 1872 if constituency and all(c.id != constituency.id for c in constituencies): 1375 1873 constituencies.append(constituency) 1376 1874 1377 - if not constituencies and postal_code: 1378 - located = ConstituencyLocator.locate(postal_code) 1379 - constituencies.extend( 1380 - constituency 1381 - for constituency in (located.local, located.state, located.federal) 1382 - if constituency 1383 - ) 1384 - else: 1385 - located = LocatedConstituencies(None, None, None) 1875 + # If no constituencies provided, try address-based or PLZ-based lookup 1876 + if not constituencies: 1877 + locator = ConstituencyLocator() 1878 + 1879 + # Try full address lookup if available 1880 + if street and postal_code and city: 1881 + # Use new address-based API 1882 + representatives = locator.locate( 1883 + street=street, 1884 + postal_code=postal_code, 1885 + city=city, 1886 + country=country 1887 + ) 1888 + 1889 + # Extract unique constituencies from representatives 1890 + constituency_ids_seen = set() 1891 + for rep in representatives: 1892 + for constituency in rep.constituencies.all(): 1893 + if constituency.id not in constituency_ids_seen: 1894 + constituencies.append(constituency) 1895 + constituency_ids_seen.add(constituency.id) 1896 + 1897 + # Fallback to PLZ-only if no full address or if address lookup failed 1898 + if not constituencies and postal_code: 1899 + located = ConstituencyLocator.locate_legacy(postal_code) 1900 + constituencies.extend( 1901 + constituency 1902 + for constituency in (located.local, located.state, located.federal) 1903 + if constituency 1904 + ) 1386 1905 1906 + # Determine state from various sources 1387 1907 explicit_state = normalize_german_state(user_location.get('state')) if user_location.get('state') else None 1388 1908 inferred_state = None 1909 + 1389 1910 for constituency in constituencies: 1390 1911 metadata_state = (constituency.metadata or {}).get('state') if constituency.metadata else None 1391 1912 if metadata_state: ··· 1393 1914 if inferred_state: 1394 1915 break 1395 1916 1396 - if not inferred_state and postal_code and not constituencies and located.state: 1397 - metadata_state = (located.state.metadata or {}).get('state') if located.state and located.state.metadata else None 1398 - if metadata_state: 1399 - inferred_state = normalize_german_state(metadata_state) 1400 - 1401 1917 state = explicit_state or inferred_state 1402 1918 1403 1919 return LocationContext( 1404 1920 postal_code=postal_code or None, 1405 1921 state=state, 1406 1922 constituencies=constituencies, 1923 + street=street or None, 1924 + city=city or None, 1925 + country=country, 1407 1926 ) 1408 1927 1409 1928 @classmethod
+27 -1
website/letters/templates/letters/base.html
··· 35 35 border-radius: 3px; 36 36 } 37 37 nav a:hover { background: #34495e; } 38 + .language-switcher { 39 + display: inline-block; 40 + margin-left: 1rem; 41 + } 42 + .language-switcher select { 43 + background: #34495e; 44 + color: white; 45 + border: 1px solid #4a6278; 46 + border-radius: 3px; 47 + padding: 0.3rem 0.6rem; 48 + cursor: pointer; 49 + } 50 + .language-switcher select:hover { 51 + background: #415a77; 52 + } 38 53 .messages { 39 54 list-style: none; 40 55 margin-bottom: 1rem; ··· 123 138 <h1>WriteThem.eu</h1> 124 139 <nav> 125 140 <a href="{% url 'letter_list' %}">{% trans "Letters" %}</a> 126 - <a href="{% url 'competency_overview' %}">Kompetenzen</a> 141 + <a href="{% url 'competency_overview' %}">{% trans "Competencies" %}</a> 127 142 {% if user.is_authenticated %} 128 143 <a href="{% url 'letter_create' %}">{% trans "Write Letter" %}</a> 129 144 <a href="{% url 'profile' %}">{% trans "Profile" %}</a> ··· 135 150 {% if user.is_staff %} 136 151 <a href="{% url 'admin:index' %}">{% trans "Admin" %}</a> 137 152 {% endif %} 153 + <div class="language-switcher"> 154 + <form action="{% url 'set_language' %}" method="post"> 155 + {% csrf_token %} 156 + <input name="next" type="hidden" value="{{ request.get_full_path }}"> 157 + <select name="language" onchange="this.form.submit()" aria-label="{% trans 'Select language' %}"> 158 + {% get_current_language as CURRENT_LANGUAGE %} 159 + <option value="de" {% if CURRENT_LANGUAGE == 'de' %}selected{% endif %}>Deutsch</option> 160 + <option value="en" {% if CURRENT_LANGUAGE == 'en' %}selected{% endif %}>English</option> 161 + </select> 162 + </form> 163 + </div> 138 164 </nav> 139 165 </div> 140 166 </header>
+2 -2
website/letters/templates/letters/letter_detail.html
··· 55 55 <div style="padding: 0.8rem 0; border-bottom: 1px solid #ecf0f1;"> 56 56 <strong>{{ signature.display_name }}</strong> 57 57 {% if signature.is_verified_constituent %} 58 - <span class="badge badge-verified">โœ“ Verified Constituent</span> 58 + <span class="badge badge-verified">{% trans "โœ“ Verified Constituent" %}</span> 59 59 {% elif signature.is_verified_non_constituent %} 60 - <span class="badge badge-verified">โœ“ Verified</span> 60 + <span class="badge badge-verified">{% trans "โœ“ Verified" %}</span> 61 61 {% endif %} 62 62 <p style="color: #7f8c8d; font-size: 0.85rem;">{{ signature.signed_at|date:"M d, Y" }}</p> 63 63 {% if signature.comment %}
+4 -4
website/letters/templates/letters/letter_list.html
··· 38 38 {% if is_paginated %} 39 39 <div style="text-align: center; margin: 2rem 0;"> 40 40 {% if page_obj.has_previous %} 41 - <a href="?page={{ page_obj.previous_page_number }}" class="btn btn-secondary">Previous</a> 41 + <a href="?page={{ page_obj.previous_page_number }}" class="btn btn-secondary">{% trans "Previous" %}</a> 42 42 {% endif %} 43 - <span style="margin: 0 1rem;">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span> 43 + <span style="margin: 0 1rem;">{% blocktrans with page=page_obj.number total=page_obj.paginator.num_pages %}Page {{ page }} of {{ total }}{% endblocktrans %}</span> 44 44 {% if page_obj.has_next %} 45 - <a href="?page={{ page_obj.next_page_number }}" class="btn btn-secondary">Next</a> 45 + <a href="?page={{ page_obj.next_page_number }}" class="btn btn-secondary">{% trans "Next" %}</a> 46 46 {% endif %} 47 47 </div> 48 48 {% endif %} 49 49 {% else %} 50 50 <div class="card"> 51 - <p>No letters found. <a href="{% url 'letter_create' %}">Be the first to write one!</a></p> 51 + <p>{% trans "No letters found." %} <a href="{% url 'letter_create' %}">{% trans "Be the first to write one!" %}</a></p> 52 52 </div> 53 53 {% endif %} 54 54 {% endblock %}
+33
website/letters/templates/letters/profile.html
··· 52 52 </div> 53 53 54 54 <div class="mt-4"> 55 + <h3>{% trans "Ihre Adresse" %}</h3> 56 + <p class="text-muted"> 57 + {% trans "Geben Sie Ihre vollstรคndige Adresse ein, um prรคzise Wahlkreis- und Abgeordnetenempfehlungen zu erhalten." %} 58 + </p> 59 + {% if verification and verification.street_address %} 60 + <div class="alert alert-info" role="status"> 61 + <strong>{% trans "Gespeicherte Adresse:" %}</strong><br> 62 + {{ verification.street_address }}<br> 63 + {{ verification.postal_code }} {{ verification.city }} 64 + </div> 65 + {% endif %} 66 + <form method="post" class="mt-3"> 67 + {% csrf_token %} 68 + {% if address_form.non_field_errors %} 69 + <div class="alert alert-danger">{{ address_form.non_field_errors }}</div> 70 + {% endif %} 71 + {% for field in address_form %} 72 + <div class="form-group mb-3"> 73 + <label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label> 74 + {{ field }} 75 + {% if field.errors %} 76 + <div class="text-danger small">{{ field.errors|join:', ' }}</div> 77 + {% endif %} 78 + {% if field.help_text %} 79 + <small class="form-text text-muted">{{ field.help_text }}</small> 80 + {% endif %} 81 + </div> 82 + {% endfor %} 83 + <button type="submit" name="address_form_submit" class="btn btn-primary">{% trans "Adresse speichern" %}</button> 84 + </form> 85 + </div> 86 + 87 + <div class="mt-4"> 55 88 <h3>{% trans "Self-declare your constituency" %}</h3> 56 89 <p class="text-muted"> 57 90 {% trans "Select the constituencies you live in so we can prioritise the right representatives." %}
+2
website/letters/tests/__init__.py
··· 1 + # ABOUTME: Test package for letters app. 2 + # ABOUTME: Contains tests for address matching, topic mapping, and constituency suggestions.
+219
website/letters/tests/test_address_matching.py
··· 1 + # ABOUTME: Test address-based constituency matching with geocoding and point-in-polygon lookup. 2 + # ABOUTME: Covers AddressGeocoder, WahlkreisLocator, and ConstituencyLocator services. 3 + 4 + from django.test import TestCase 5 + from unittest.mock import patch, MagicMock 6 + from letters.services import AddressGeocoder, WahlkreisLocator, ConstituencyLocator 7 + from letters.models import GeocodeCache, Representative 8 + 9 + 10 + # Test addresses covering all German states 11 + TEST_ADDRESSES = [ 12 + { 13 + 'name': 'Bundestag (Berlin)', 14 + 'street': 'Platz der Republik 1', 15 + 'postal_code': '11011', 16 + 'city': 'Berlin', 17 + 'expected_state': 'Berlin' 18 + }, 19 + { 20 + 'name': 'Hamburg Rathaus', 21 + 'street': 'Rathausmarkt 1', 22 + 'postal_code': '20095', 23 + 'city': 'Hamburg', 24 + 'expected_state': 'Hamburg' 25 + }, 26 + { 27 + 'name': 'Marienplatz Mรผnchen (Bavaria)', 28 + 'street': 'Marienplatz 1', 29 + 'postal_code': '80331', 30 + 'city': 'Mรผnchen', 31 + 'expected_state': 'Bayern' 32 + }, 33 + { 34 + 'name': 'Kรถlner Dom (North Rhine-Westphalia)', 35 + 'street': 'Domkloster 4', 36 + 'postal_code': '50667', 37 + 'city': 'Kรถln', 38 + 'expected_state': 'Nordrhein-Westfalen' 39 + }, 40 + { 41 + 'name': 'Brandenburger Tor (Berlin)', 42 + 'street': 'Pariser Platz', 43 + 'postal_code': '10117', 44 + 'city': 'Berlin', 45 + 'expected_state': 'Berlin' 46 + }, 47 + ] 48 + 49 + 50 + class AddressGeocodingTests(TestCase): 51 + """Test address geocoding with OSM Nominatim.""" 52 + 53 + def setUp(self): 54 + self.geocoder = AddressGeocoder() 55 + 56 + def test_geocode_success_with_mocked_api(self): 57 + """Test successful geocoding with mocked Nominatim response.""" 58 + with patch('requests.get') as mock_get: 59 + # Mock successful Nominatim response 60 + mock_response = MagicMock() 61 + mock_response.status_code = 200 62 + mock_response.json.return_value = [{ 63 + 'lat': '52.5186', 64 + 'lon': '13.3761' 65 + }] 66 + mock_get.return_value = mock_response 67 + 68 + lat, lon, success, error = self.geocoder.geocode( 69 + 'Platz der Republik 1', 70 + '11011', 71 + 'Berlin' 72 + ) 73 + 74 + self.assertTrue(success) 75 + self.assertIsNone(error) 76 + self.assertAlmostEqual(lat, 52.5186, places=4) 77 + self.assertAlmostEqual(lon, 13.3761, places=4) 78 + 79 + def test_geocode_caches_results(self): 80 + """Test that geocoding results are cached in database.""" 81 + with patch('requests.get') as mock_get: 82 + mock_response = MagicMock() 83 + mock_response.status_code = 200 84 + mock_response.json.return_value = [{ 85 + 'lat': '52.5186', 86 + 'lon': '13.3761' 87 + }] 88 + mock_get.return_value = mock_response 89 + 90 + # First call should cache 91 + self.geocoder.geocode('Platz der Republik 1', '11011', 'Berlin') 92 + 93 + # Check cache entry exists 94 + cache_key = self.geocoder._generate_cache_key( 95 + 'Platz der Republik 1', '11011', 'Berlin', 'DE' 96 + ) 97 + cache_entry = GeocodeCache.objects.filter(address_hash=cache_key).first() 98 + self.assertIsNotNone(cache_entry) 99 + self.assertTrue(cache_entry.success) 100 + 101 + def test_geocode_returns_cached_results(self): 102 + """Test that cached geocoding results are reused.""" 103 + # Create cache entry 104 + cache_key = self.geocoder._generate_cache_key( 105 + 'Test Street', '12345', 'Test City', 'DE' 106 + ) 107 + GeocodeCache.objects.create( 108 + address_hash=cache_key, 109 + success=True, 110 + latitude=52.0, 111 + longitude=13.0 112 + ) 113 + 114 + # Should return cached result without API call 115 + with patch('requests.get') as mock_get: 116 + lat, lon, success, error = self.geocoder.geocode( 117 + 'Test Street', '12345', 'Test City' 118 + ) 119 + 120 + # Verify no API call was made 121 + mock_get.assert_not_called() 122 + 123 + # Verify cached results returned 124 + self.assertTrue(success) 125 + self.assertEqual(lat, 52.0) 126 + self.assertEqual(lon, 13.0) 127 + 128 + def test_geocode_handles_api_error(self): 129 + """Test graceful handling of Nominatim API errors.""" 130 + with patch('requests.get') as mock_get: 131 + mock_get.side_effect = Exception("API Error") 132 + 133 + # Capture expected warning log 134 + with self.assertLogs('letters.services', level='WARNING') as log_context: 135 + lat, lon, success, error = self.geocoder.geocode( 136 + 'Invalid Street', '99999', 'Nowhere' 137 + ) 138 + 139 + self.assertFalse(success) 140 + self.assertIsNone(lat) 141 + self.assertIsNone(lon) 142 + self.assertIn('API Error', error) 143 + # Verify expected warning was logged 144 + self.assertEqual(len(log_context.output), 1) 145 + self.assertIn('Geocoding failed', log_context.output[0]) 146 + 147 + 148 + class WahlkreisLocationTests(TestCase): 149 + """Test point-in-polygon constituency matching.""" 150 + 151 + def test_locate_bundestag_coordinates(self): 152 + """Test that Bundestag coordinates find correct Berlin constituency.""" 153 + locator = WahlkreisLocator() 154 + result = locator.locate(52.5186, 13.3761) 155 + 156 + self.assertIsNotNone(result) 157 + wkr_nr, wkr_name, land_name = result 158 + self.assertIsInstance(wkr_nr, int) 159 + self.assertIn('Berlin', land_name) 160 + 161 + def test_locate_hamburg_coordinates(self): 162 + """Test that Hamburg coordinates find correct constituency.""" 163 + locator = WahlkreisLocator() 164 + result = locator.locate(53.5511, 9.9937) 165 + 166 + self.assertIsNotNone(result) 167 + wkr_nr, wkr_name, land_name = result 168 + self.assertIsInstance(wkr_nr, int) 169 + self.assertIn('Hamburg', land_name) 170 + 171 + def test_coordinates_outside_germany(self): 172 + """Test that coordinates outside Germany return None.""" 173 + locator = WahlkreisLocator() 174 + 175 + # Paris coordinates 176 + result = locator.locate(48.8566, 2.3522) 177 + self.assertIsNone(result) 178 + 179 + # London coordinates 180 + result = locator.locate(51.5074, -0.1278) 181 + self.assertIsNone(result) 182 + 183 + 184 + class FullAddressMatchingTests(TestCase): 185 + """Integration tests for full address โ†’ constituency โ†’ representatives pipeline.""" 186 + 187 + @patch('letters.services.AddressGeocoder.geocode') 188 + def test_address_to_constituency_pipeline(self, mock_geocode): 189 + """Test full pipeline from address to constituency with mocked geocoding.""" 190 + # Mock geocoding to return Bundestag coordinates 191 + mock_geocode.return_value = (52.5186, 13.3761, True, None) 192 + 193 + locator = ConstituencyLocator() 194 + representatives = locator.locate( 195 + street='Platz der Republik 1', 196 + postal_code='11011', 197 + city='Berlin' 198 + ) 199 + 200 + # Should return representatives (even if list is empty due to no DB data) 201 + self.assertIsInstance(representatives, list) 202 + mock_geocode.assert_called_once() 203 + 204 + def test_plz_fallback_when_geocoding_fails(self): 205 + """Test PLZ prefix fallback when geocoding fails.""" 206 + with patch('letters.services.AddressGeocoder.geocode') as mock_geocode: 207 + # Mock geocoding failure 208 + mock_geocode.return_value = (None, None, False, "Geocoding failed") 209 + 210 + locator = ConstituencyLocator() 211 + representatives = locator.locate( 212 + postal_code='10115' # Berlin postal code 213 + ) 214 + 215 + # Should still return list (using PLZ fallback) 216 + self.assertIsInstance(representatives, list) 217 + 218 + 219 + # End of file
+58
website/letters/tests/test_constituency_suggestions.py
··· 1 + # ABOUTME: Test ConstituencySuggestionService combining topics and geography. 2 + # ABOUTME: Integration tests for letter title/address to representative suggestions. 3 + 4 + from django.test import TestCase 5 + from unittest.mock import patch 6 + from letters.services import ConstituencySuggestionService 7 + 8 + 9 + class ConstituencySuggestionTests(TestCase): 10 + """Test constituency suggestion combining topic and address matching.""" 11 + 12 + @patch('letters.services.AddressGeocoder.geocode') 13 + def test_suggest_with_title_and_address(self, mock_geocode): 14 + """Test suggestions work with both title and address.""" 15 + # Mock geocoding 16 + mock_geocode.return_value = (52.5186, 13.3761, True, None) 17 + 18 + result = ConstituencySuggestionService.suggest_from_concern( 19 + concern_text="We need better train connections", 20 + user_location={ 21 + "street": "Platz der Republik 1", 22 + "postal_code": "11011", 23 + "city": "Berlin" 24 + } 25 + ) 26 + 27 + self.assertIn('matched_topics', result) 28 + self.assertIn('suggested_level', result) 29 + self.assertIn('explanation', result) 30 + self.assertIn('representatives', result) 31 + self.assertIn('constituencies', result) 32 + 33 + def test_suggest_with_only_title(self): 34 + """Test suggestions work with only title (no address).""" 35 + result = ConstituencySuggestionService.suggest_from_concern( 36 + concern_text="Climate protection is important" 37 + ) 38 + 39 + self.assertIn('matched_topics', result) 40 + self.assertIn('suggested_level', result) 41 + # Without address, should still suggest level and topics 42 + self.assertIsNotNone(result['suggested_level']) 43 + 44 + def test_suggest_with_only_postal_code(self): 45 + """Test suggestions work with only postal code.""" 46 + result = ConstituencySuggestionService.suggest_from_concern( 47 + concern_text="Local infrastructure problems", 48 + user_location={ 49 + "postal_code": "10115" 50 + } 51 + ) 52 + 53 + self.assertIn('constituencies', result) 54 + # Should use PLZ fallback 55 + self.assertIsInstance(result['constituencies'], list) 56 + 57 + 58 + # End of file
+89
website/letters/tests/test_i18n.py
··· 1 + # ABOUTME: Tests for internationalization configuration and functionality. 2 + # ABOUTME: Verifies language switching, URL prefixes, and translation completeness. 3 + 4 + from django.test import TestCase 5 + from django.conf import settings 6 + from django.core.management import call_command 7 + from io import StringIO 8 + 9 + 10 + class I18nConfigurationTests(TestCase): 11 + def test_i18n_enabled(self): 12 + """Test that USE_I18N is enabled.""" 13 + self.assertTrue(settings.USE_I18N) 14 + 15 + def test_supported_languages(self): 16 + """Test that German and English are configured.""" 17 + language_codes = [code for code, name in settings.LANGUAGES] 18 + self.assertIn('de', language_codes) 19 + self.assertIn('en', language_codes) 20 + 21 + def test_locale_paths_configured(self): 22 + """Test that LOCALE_PATHS is set.""" 23 + self.assertTrue(len(settings.LOCALE_PATHS) > 0) 24 + 25 + 26 + class I18nURLTests(TestCase): 27 + def test_german_url_prefix_works(self): 28 + """Test that German URL prefix is accessible.""" 29 + response = self.client.get('/de/') 30 + self.assertEqual(response.status_code, 200) 31 + 32 + def test_english_url_prefix_works(self): 33 + """Test that English URL prefix is accessible.""" 34 + response = self.client.get('/en/') 35 + self.assertEqual(response.status_code, 200) 36 + 37 + def test_set_language_endpoint_exists(self): 38 + """Test that language switcher endpoint exists.""" 39 + from django.urls import reverse 40 + url = reverse('set_language') 41 + self.assertEqual(url, '/i18n/setlang/') 42 + 43 + 44 + class LanguageSwitcherTests(TestCase): 45 + def test_language_switcher_present_in_page(self): 46 + """Test that language switcher form is present.""" 47 + response = self.client.get('/de/') 48 + self.assertContains(response, 'name="language"') 49 + self.assertContains(response, 'Deutsch') 50 + self.assertContains(response, 'English') 51 + 52 + def test_language_switch_changes_language(self): 53 + """Test that submitting language form changes language.""" 54 + response = self.client.post( 55 + '/i18n/setlang/', 56 + {'language': 'en', 'next': '/en/'}, 57 + ) 58 + # Check we got a redirect 59 + self.assertEqual(response.status_code, 302) 60 + # Check cookie was set 61 + self.assertIn('django_language', response.cookies) 62 + self.assertEqual(response.cookies['django_language'].value, 'en') 63 + 64 + 65 + class LetterFormI18nTests(TestCase): 66 + def test_letter_form_template_renders(self): 67 + """Test that letter creation form renders without errors.""" 68 + from django.contrib.auth.models import User 69 + # Create a user and log them in 70 + user = User.objects.create_user(username='testuser', password='testpass') 71 + self.client.login(username='testuser', password='testpass') 72 + 73 + # Test German version 74 + response = self.client.get('/de/letter/new/') 75 + self.assertEqual(response.status_code, 200) 76 + 77 + # Test English version 78 + response = self.client.get('/en/letter/new/') 79 + self.assertEqual(response.status_code, 200) 80 + 81 + 82 + class TranslationCompletenessTests(TestCase): 83 + def test_check_translations_command_exists(self): 84 + """Test that check_translations command can be called.""" 85 + out = StringIO() 86 + call_command('check_translations', stdout=out) 87 + output = out.getvalue() 88 + self.assertIn('Deutsch', output) 89 + self.assertIn('English', output)
+101
website/letters/tests/test_topic_mapping.py
··· 1 + # ABOUTME: Test topic suggestion and matching based on letter content. 2 + # ABOUTME: Covers TopicSuggestionService keyword matching and level suggestion logic. 3 + 4 + from django.test import TestCase 5 + from letters.services import TopicSuggestionService 6 + from letters.models import TopicArea 7 + 8 + 9 + class TopicMatchingTests(TestCase): 10 + """Test topic keyword matching and scoring.""" 11 + 12 + def setUp(self): 13 + """Check if topic data is available.""" 14 + self.has_topics = TopicArea.objects.exists() 15 + 16 + def test_transport_keywords_match_verkehr_topic(self): 17 + """Test that transport-related keywords match Verkehr topic.""" 18 + if not self.has_topics: 19 + self.skipTest("TopicArea data not loaded") 20 + 21 + concern = "I want to see better train connections between cities" 22 + result = TopicSuggestionService.suggest_representatives_for_concern(concern) 23 + 24 + # Should find at least one topic 25 + matched_topics = result.get('matched_topics', []) 26 + self.assertGreater(len(matched_topics), 0) 27 + 28 + def test_housing_keywords_match_wohnen_topic(self): 29 + """Test that housing keywords match Wohnen topic.""" 30 + if not self.has_topics: 31 + self.skipTest("TopicArea data not loaded") 32 + 33 + concern = "We need more affordable housing and rent control" 34 + result = TopicSuggestionService.suggest_representatives_for_concern(concern) 35 + 36 + matched_topics = result.get('matched_topics', []) 37 + self.assertGreater(len(matched_topics), 0) 38 + 39 + def test_education_keywords_match_bildung_topic(self): 40 + """Test that education keywords match Bildung topic.""" 41 + if not self.has_topics: 42 + self.skipTest("TopicArea data not loaded") 43 + 44 + concern = "Our school curriculum needs reform" 45 + result = TopicSuggestionService.suggest_representatives_for_concern(concern) 46 + 47 + matched_topics = result.get('matched_topics', []) 48 + self.assertGreater(len(matched_topics), 0) 49 + 50 + def test_climate_keywords_match_umwelt_topic(self): 51 + """Test that climate keywords match environment topic.""" 52 + if not self.has_topics: 53 + self.skipTest("TopicArea data not loaded") 54 + 55 + concern = "Climate protection and CO2 emissions must be addressed" 56 + result = TopicSuggestionService.suggest_representatives_for_concern(concern) 57 + 58 + matched_topics = result.get('matched_topics', []) 59 + self.assertGreater(len(matched_topics), 0) 60 + 61 + def test_no_match_returns_empty_list(self): 62 + """Test that completely unrelated text returns empty list.""" 63 + concern = "xyzabc nonsense gibberish" 64 + result = TopicSuggestionService.suggest_representatives_for_concern(concern) 65 + 66 + matched_topics = result.get('matched_topics', []) 67 + # Should return empty list for gibberish 68 + self.assertEqual(len(matched_topics), 0) 69 + 70 + 71 + class LevelSuggestionTests(TestCase): 72 + """Test government level suggestion logic.""" 73 + 74 + def test_federal_transport_suggests_federal_level(self): 75 + """Test that long-distance transport suggests federal level.""" 76 + result = TopicSuggestionService.suggest_representatives_for_concern( 77 + "Deutsche Bahn is always late", 78 + limit=5 79 + ) 80 + 81 + self.assertIn('suggested_level', result) 82 + self.assertIn('explanation', result) 83 + # Federal issues should suggest FEDERAL level 84 + suggested_level = result['suggested_level'] 85 + self.assertIsNotNone(suggested_level) 86 + self.assertIn('FEDERAL', suggested_level) 87 + 88 + def test_local_bus_suggests_state_or_local(self): 89 + """Test that local transport suggests state/local level.""" 90 + result = TopicSuggestionService.suggest_representatives_for_concern( 91 + "Better bus services in my town", 92 + limit=5 93 + ) 94 + 95 + self.assertIn('suggested_level', result) 96 + self.assertIn('explanation', result) 97 + # Should have an explanation 98 + self.assertIsNotNone(result['explanation']) 99 + 100 + 101 + # End of file
+697
website/letters/tests.py
··· 688 688 self.assertNotIn(self.state_rep_list.last_name, content) 689 689 self.assertIn(self.federal_expert_rep.last_name, content) 690 690 691 + def test_suggest_with_full_address(self): 692 + """Test that suggestions work with full address (street, postal_code, city).""" 693 + from unittest.mock import patch, Mock 694 + 695 + # Mock geocoding to return coordinates for Bundestag building 696 + mock_response = Mock() 697 + mock_response.status_code = 200 698 + mock_response.json.return_value = [ 699 + { 700 + 'lat': '52.5186', 701 + 'lon': '13.3761', 702 + 'display_name': 'Berlin, Germany' 703 + } 704 + ] 705 + 706 + with patch('requests.get', return_value=mock_response): 707 + result = ConstituencySuggestionService.suggest_from_concern( 708 + 'Mehr Verkehr und ร–PNV in Berlin Mitte', 709 + user_location={ 710 + 'street': 'Platz der Republik 1', 711 + 'postal_code': '11011', 712 + 'city': 'Berlin', 713 + 'country': 'DE' 714 + } 715 + ) 716 + 717 + # Should find representatives (direct representatives from Berlin) 718 + self.assertGreater(len(result['representatives']), 0) 719 + self.assertIn(self.transport_topic, result['matched_topics']) 720 + 721 + # Should have direct representatives from Berlin 722 + direct_reps = result.get('direct_representatives', []) 723 + # At least one Berlin rep should be suggested 724 + berlin_reps = [ 725 + rep for rep in direct_reps 726 + if any( 727 + (c.metadata or {}).get('state') == 'Berlin' 728 + for c in rep.constituencies.all() 729 + ) 730 + ] 731 + self.assertGreater(len(berlin_reps), 0, "Should suggest at least one Berlin representative") 732 + 733 + def test_suggest_with_plz_only_backward_compatibility(self): 734 + """Test that PLZ-only suggestions still work (backward compatibility).""" 735 + result = ConstituencySuggestionService.suggest_from_concern( 736 + 'Klimaschutz ist wichtig', 737 + user_location={ 738 + 'postal_code': '10115', # Berlin PLZ 739 + } 740 + ) 741 + 742 + # Should work without crashing 743 + self.assertIsInstance(result, dict) 744 + self.assertIn('representatives', result) 745 + self.assertIn('matched_topics', result) 746 + 747 + # Result should be valid even if empty 748 + self.assertIsInstance(result['representatives'], list) 749 + 691 750 692 751 class CompetencyPageTests(TestCase): 693 752 """Ensure the competency overview renders topics for visitors.""" ··· 731 790 self.assertIn('<li>Zwei</li>', rendered) 732 791 733 792 793 + class GeocodeCacheTests(TestCase): 794 + """Test geocoding cache model.""" 795 + 796 + def test_cache_stores_and_retrieves_coordinates(self): 797 + from .models import GeocodeCache 798 + 799 + cache_entry = GeocodeCache.objects.create( 800 + address_hash='test_hash_123', 801 + street='Unter den Linden 77', 802 + postal_code='10117', 803 + city='Berlin', 804 + latitude=52.5170365, 805 + longitude=13.3888599, 806 + ) 807 + 808 + retrieved = GeocodeCache.objects.get(address_hash='test_hash_123') 809 + self.assertEqual(retrieved.latitude, 52.5170365) 810 + self.assertEqual(retrieved.longitude, 13.3888599) 811 + self.assertEqual(retrieved.street, 'Unter den Linden 77') 812 + 813 + 814 + class AddressGeocoderTests(TestCase): 815 + """Test the AddressGeocoder service with OSM Nominatim API.""" 816 + 817 + def test_successful_geocoding_with_mocked_api(self): 818 + """Test successful geocoding with mocked Nominatim API response.""" 819 + from unittest.mock import patch, Mock 820 + from .services import AddressGeocoder 821 + 822 + mock_response = Mock() 823 + mock_response.status_code = 200 824 + mock_response.json.return_value = [ 825 + { 826 + 'lat': '52.5200066', 827 + 'lon': '13.404954', 828 + 'display_name': 'Berlin, Germany' 829 + } 830 + ] 831 + 832 + with patch('requests.get', return_value=mock_response) as mock_get: 833 + geocoder = AddressGeocoder() 834 + lat, lon, success, error = geocoder.geocode( 835 + street='Unter den Linden 77', 836 + postal_code='10117', 837 + city='Berlin' 838 + ) 839 + 840 + self.assertTrue(success) 841 + self.assertIsNone(error) 842 + self.assertAlmostEqual(lat, 52.5200066, places=6) 843 + self.assertAlmostEqual(lon, 13.404954, places=6) 844 + 845 + # Verify the request was made correctly 846 + mock_get.assert_called_once() 847 + call_args = mock_get.call_args 848 + self.assertIn('nominatim.openstreetmap.org', call_args[0][0]) 849 + self.assertEqual(call_args[1]['headers']['User-Agent'], 'WriteThem.eu/0.1 (civic engagement platform)') 850 + 851 + def test_cache_hit_no_api_call(self): 852 + """Test that cache hits don't make API calls.""" 853 + from unittest.mock import patch 854 + from .models import GeocodeCache 855 + from .services import AddressGeocoder 856 + import hashlib 857 + 858 + # Create a cache entry 859 + address_string = 'Unter den Linden 77|10117|Berlin|DE' 860 + address_hash = hashlib.sha256(address_string.encode('utf-8')).hexdigest() 861 + 862 + GeocodeCache.objects.create( 863 + address_hash=address_hash, 864 + street='Unter den Linden 77', 865 + postal_code='10117', 866 + city='Berlin', 867 + country='DE', 868 + latitude=52.5200066, 869 + longitude=13.404954, 870 + success=True 871 + ) 872 + 873 + with patch('requests.get') as mock_get: 874 + geocoder = AddressGeocoder() 875 + lat, lon, success, error = geocoder.geocode( 876 + street='Unter den Linden 77', 877 + postal_code='10117', 878 + city='Berlin' 879 + ) 880 + 881 + self.assertTrue(success) 882 + self.assertIsNone(error) 883 + self.assertAlmostEqual(lat, 52.5200066, places=6) 884 + self.assertAlmostEqual(lon, 13.404954, places=6) 885 + 886 + # Verify NO API call was made 887 + mock_get.assert_not_called() 888 + 889 + def test_failed_geocoding_cached(self): 890 + """Test that failed geocoding attempts are cached.""" 891 + from unittest.mock import patch, Mock 892 + from .models import GeocodeCache 893 + from .services import AddressGeocoder 894 + 895 + mock_response = Mock() 896 + mock_response.status_code = 200 897 + mock_response.json.return_value = [] # Empty result = not found 898 + 899 + with patch('requests.get', return_value=mock_response): 900 + geocoder = AddressGeocoder() 901 + lat, lon, success, error = geocoder.geocode( 902 + street='Nonexistent Street 999', 903 + postal_code='99999', 904 + city='Nowhere' 905 + ) 906 + 907 + self.assertFalse(success) 908 + self.assertIsNotNone(error) 909 + self.assertIsNone(lat) 910 + self.assertIsNone(lon) 911 + 912 + # Verify the failure was cached 913 + import hashlib 914 + address_string = 'Nonexistent Street 999|99999|Nowhere|DE' 915 + address_hash = hashlib.sha256(address_string.encode('utf-8')).hexdigest() 916 + 917 + cache_entry = GeocodeCache.objects.get(address_hash=address_hash) 918 + self.assertFalse(cache_entry.success) 919 + self.assertEqual(cache_entry.error_message, error) 920 + 921 + def test_api_error_handling(self): 922 + """Test that API errors are handled gracefully.""" 923 + from unittest.mock import patch 924 + from .services import AddressGeocoder 925 + import requests 926 + 927 + with patch('requests.get', side_effect=requests.RequestException('API Error')): 928 + geocoder = AddressGeocoder() 929 + lat, lon, success, error = geocoder.geocode( 930 + street='Test Street', 931 + postal_code='12345', 932 + city='Test City' 933 + ) 934 + 935 + self.assertFalse(success) 936 + self.assertIsNotNone(error) 937 + self.assertIsNone(lat) 938 + self.assertIsNone(lon) 939 + self.assertIn('API Error', error) 940 + 941 + 734 942 class RepresentativeMetadataExtractionTests(TestCase): 735 943 736 944 def setUp(self): ··· 795 1003 service._ensure_photo_reference(rep) 796 1004 rep.refresh_from_db() 797 1005 self.assertEqual(rep.photo_path, 'representatives/999.jpg') 1006 + 1007 + 1008 + class GeoJSONDataTests(TestCase): 1009 + """Test that official Bundestag constituency GeoJSON data is available and valid.""" 1010 + 1011 + def test_geojson_file_exists(self): 1012 + """Verify the wahlkreise.geojson file exists in the data directory.""" 1013 + from pathlib import Path 1014 + from django.conf import settings 1015 + 1016 + geojson_path = Path(settings.BASE_DIR) / 'letters' / 'data' / 'wahlkreise.geojson' 1017 + self.assertTrue(geojson_path.exists(), f"GeoJSON file not found at {geojson_path}") 1018 + 1019 + def test_geojson_is_valid_and_loadable(self): 1020 + """Verify the GeoJSON file is valid JSON and has expected structure.""" 1021 + import json 1022 + from pathlib import Path 1023 + from django.conf import settings 1024 + 1025 + geojson_path = Path(settings.BASE_DIR) / 'letters' / 'data' / 'wahlkreise.geojson' 1026 + 1027 + with open(geojson_path, 'r', encoding='utf-8') as f: 1028 + data = json.load(f) 1029 + 1030 + self.assertEqual(data['type'], 'FeatureCollection') 1031 + self.assertIn('features', data) 1032 + self.assertIsInstance(data['features'], list) 1033 + 1034 + def test_geojson_contains_all_constituencies(self): 1035 + """Verify the GeoJSON contains all 299 Bundestag constituencies.""" 1036 + import json 1037 + from pathlib import Path 1038 + from django.conf import settings 1039 + 1040 + geojson_path = Path(settings.BASE_DIR) / 'letters' / 'data' / 'wahlkreise.geojson' 1041 + 1042 + with open(geojson_path, 'r', encoding='utf-8') as f: 1043 + data = json.load(f) 1044 + 1045 + self.assertEqual(len(data['features']), 299) 1046 + 1047 + def test_geojson_features_have_required_properties(self): 1048 + """Verify each feature has required properties: WKR_NR, WKR_NAME, LAND_NR, LAND_NAME.""" 1049 + import json 1050 + from pathlib import Path 1051 + from django.conf import settings 1052 + 1053 + geojson_path = Path(settings.BASE_DIR) / 'letters' / 'data' / 'wahlkreise.geojson' 1054 + 1055 + with open(geojson_path, 'r', encoding='utf-8') as f: 1056 + data = json.load(f) 1057 + 1058 + # Check first feature 1059 + if len(data['features']) > 0: 1060 + feature = data['features'][0] 1061 + self.assertEqual(feature['type'], 'Feature') 1062 + self.assertIn('properties', feature) 1063 + self.assertIn('geometry', feature) 1064 + 1065 + properties = feature['properties'] 1066 + self.assertIn('WKR_NR', properties) 1067 + self.assertIn('WKR_NAME', properties) 1068 + self.assertIn('LAND_NR', properties) 1069 + self.assertIn('LAND_NAME', properties) 1070 + 1071 + # Verify geometry type 1072 + self.assertEqual(feature['geometry']['type'], 'Polygon') 1073 + 1074 + def test_geojson_constituency_numbers_complete(self): 1075 + """Verify constituency numbers range from 1 to 299 with no gaps.""" 1076 + import json 1077 + from pathlib import Path 1078 + from django.conf import settings 1079 + 1080 + geojson_path = Path(settings.BASE_DIR) / 'letters' / 'data' / 'wahlkreise.geojson' 1081 + 1082 + with open(geojson_path, 'r', encoding='utf-8') as f: 1083 + data = json.load(f) 1084 + 1085 + wkr_numbers = [f['properties']['WKR_NR'] for f in data['features']] 1086 + expected_numbers = set(range(1, 300)) 1087 + actual_numbers = set(wkr_numbers) 1088 + 1089 + self.assertEqual(actual_numbers, expected_numbers, "Constituency numbers should be 1-299 with no gaps") 1090 + 1091 + 1092 + class WahlkreisLocatorTests(TestCase): 1093 + """Test the WahlkreisLocator service for point-in-polygon constituency matching.""" 1094 + 1095 + def test_locate_bundestag_building(self): 1096 + """Test that Bundestag coordinates (52.5186, 13.3761) find a Berlin constituency.""" 1097 + from .services import WahlkreisLocator 1098 + 1099 + locator = WahlkreisLocator() 1100 + result = locator.locate(52.5186, 13.3761) 1101 + 1102 + self.assertIsNotNone(result, "Bundestag coordinates should find a constituency") 1103 + wkr_nr, wkr_name, land_name = result 1104 + self.assertIsInstance(wkr_nr, int) 1105 + self.assertGreater(wkr_nr, 0) 1106 + self.assertLessEqual(wkr_nr, 299) 1107 + self.assertIsInstance(wkr_name, str) 1108 + self.assertIsInstance(land_name, str) 1109 + self.assertIn('Berlin', land_name, "Bundestag should be in a Berlin constituency") 1110 + 1111 + def test_locate_hamburg_rathaus(self): 1112 + """Test that Hamburg Rathaus (53.5511, 9.9937) finds a Hamburg constituency.""" 1113 + from .services import WahlkreisLocator 1114 + 1115 + locator = WahlkreisLocator() 1116 + result = locator.locate(53.5511, 9.9937) 1117 + 1118 + self.assertIsNotNone(result, "Hamburg Rathaus coordinates should find a constituency") 1119 + wkr_nr, wkr_name, land_name = result 1120 + self.assertIsInstance(wkr_nr, int) 1121 + self.assertGreater(wkr_nr, 0) 1122 + self.assertLessEqual(wkr_nr, 299) 1123 + self.assertIn('Hamburg', land_name, "Hamburg Rathaus should be in a Hamburg constituency") 1124 + 1125 + def test_locate_multiple_known_locations(self): 1126 + """Test multiple German cities to ensure accurate constituency matching.""" 1127 + from .services import WahlkreisLocator 1128 + 1129 + locator = WahlkreisLocator() 1130 + 1131 + # Munich: Marienplatz (48.1374, 11.5755) 1132 + munich_result = locator.locate(48.1374, 11.5755) 1133 + self.assertIsNotNone(munich_result, "Munich coordinates should find a constituency") 1134 + self.assertIn('Bayern', munich_result[2], "Munich should be in Bavaria") 1135 + 1136 + # Cologne: Dom (50.9413, 6.9583) 1137 + cologne_result = locator.locate(50.9413, 6.9583) 1138 + self.assertIsNotNone(cologne_result, "Cologne coordinates should find a constituency") 1139 + self.assertIn('Nordrhein-Westfalen', cologne_result[2], "Cologne should be in NRW") 1140 + 1141 + # Frankfurt: Rรถmer (50.1106, 8.6821) 1142 + frankfurt_result = locator.locate(50.1106, 8.6821) 1143 + self.assertIsNotNone(frankfurt_result, "Frankfurt coordinates should find a constituency") 1144 + self.assertIn('Hessen', frankfurt_result[2], "Frankfurt should be in Hessen") 1145 + 1146 + # Dresden: Frauenkirche (51.0515, 13.7416) 1147 + dresden_result = locator.locate(51.0515, 13.7416) 1148 + self.assertIsNotNone(dresden_result, "Dresden coordinates should find a constituency") 1149 + self.assertIn('Sachsen', dresden_result[2], "Dresden should be in Saxony") 1150 + 1151 + # Stuttgart: Schlossplatz (48.7775, 9.1797) 1152 + stuttgart_result = locator.locate(48.7775, 9.1797) 1153 + self.assertIsNotNone(stuttgart_result, "Stuttgart coordinates should find a constituency") 1154 + self.assertIn('Baden-Wรผrttemberg', stuttgart_result[2], "Stuttgart should be in Baden-Wรผrttemberg") 1155 + 1156 + def test_coordinates_outside_germany(self): 1157 + """Test that coordinates outside Germany return None.""" 1158 + from .services import WahlkreisLocator 1159 + 1160 + locator = WahlkreisLocator() 1161 + 1162 + # Paris, France (48.8566, 2.3522) 1163 + paris_result = locator.locate(48.8566, 2.3522) 1164 + self.assertIsNone(paris_result, "Paris coordinates should not find a German constituency") 1165 + 1166 + # London, UK (51.5074, -0.1278) 1167 + london_result = locator.locate(51.5074, -0.1278) 1168 + self.assertIsNone(london_result, "London coordinates should not find a German constituency") 1169 + 1170 + # New York, USA (40.7128, -74.0060) 1171 + nyc_result = locator.locate(40.7128, -74.0060) 1172 + self.assertIsNone(nyc_result, "NYC coordinates should not find a German constituency") 1173 + 1174 + def test_geojson_loads_successfully(self): 1175 + """Test that the service can load the 44MB GeoJSON file without errors.""" 1176 + from .services import WahlkreisLocator 1177 + import time 1178 + 1179 + start_time = time.time() 1180 + locator = WahlkreisLocator() 1181 + load_time = time.time() - start_time 1182 + 1183 + # Verify the service loaded constituencies 1184 + self.assertIsNotNone(locator.constituencies, "Constituencies should be loaded") 1185 + self.assertGreater(len(locator.constituencies), 0, "Should have loaded constituencies") 1186 + self.assertEqual(len(locator.constituencies), 299, "Should have loaded all 299 constituencies") 1187 + 1188 + # Verify loading is reasonably fast (< 2 seconds) 1189 + self.assertLess(load_time, 2.0, f"GeoJSON loading took {load_time:.2f}s, should be under 2 seconds") 1190 + 1191 + 1192 + class ConstituencyLocatorIntegrationTests(ParliamentFixtureMixin, TestCase): 1193 + """Test the integrated ConstituencyLocator with address-based and PLZ fallback.""" 1194 + 1195 + def test_locate_by_full_address_returns_constituencies(self): 1196 + """Test address-based constituency lookup returns Representatives.""" 1197 + from unittest.mock import patch, Mock 1198 + from .services import ConstituencyLocator 1199 + 1200 + # Mock the geocoding response for Platz der Republik 1, Berlin 1201 + # This is the Bundestag building location 1202 + mock_geocode_response = Mock() 1203 + mock_geocode_response.status_code = 200 1204 + mock_geocode_response.json.return_value = [ 1205 + { 1206 + 'lat': '52.5186', 1207 + 'lon': '13.3761', 1208 + 'display_name': 'Platz der Republik, Berlin, Germany' 1209 + } 1210 + ] 1211 + 1212 + with patch('requests.get', return_value=mock_geocode_response): 1213 + locator = ConstituencyLocator() 1214 + result = locator.locate( 1215 + street='Platz der Republik 1', 1216 + postal_code='11011', 1217 + city='Berlin' 1218 + ) 1219 + 1220 + # Result should be a list of Representative objects 1221 + self.assertIsInstance(result, list) 1222 + # Should find at least one representative 1223 + self.assertGreater(len(result), 0) 1224 + 1225 + # All results should be Representative instances 1226 + from .models import Representative 1227 + for rep in result: 1228 + self.assertIsInstance(rep, Representative) 1229 + # Representatives should be from Berlin 1230 + rep_states = { 1231 + (c.metadata or {}).get('state') 1232 + for c in rep.constituencies.all() 1233 + if c.metadata 1234 + } 1235 + self.assertIn('Berlin', rep_states, f"Representative {rep} should be from Berlin") 1236 + 1237 + def test_locate_by_full_address_with_cache(self): 1238 + """Test that cached geocoding results are used for constituency lookup.""" 1239 + from .models import GeocodeCache 1240 + from .services import ConstituencyLocator 1241 + import hashlib 1242 + 1243 + # Pre-populate cache with Berlin coordinates 1244 + address_string = 'Unter den Linden 77|10117|Berlin|DE' 1245 + address_hash = hashlib.sha256(address_string.encode('utf-8')).hexdigest() 1246 + 1247 + GeocodeCache.objects.create( 1248 + address_hash=address_hash, 1249 + street='Unter den Linden 77', 1250 + postal_code='10117', 1251 + city='Berlin', 1252 + country='DE', 1253 + latitude=52.5186, 1254 + longitude=13.3761, 1255 + success=True 1256 + ) 1257 + 1258 + # No mocking needed - should use cache 1259 + locator = ConstituencyLocator() 1260 + result = locator.locate( 1261 + street='Unter den Linden 77', 1262 + postal_code='10117', 1263 + city='Berlin' 1264 + ) 1265 + 1266 + # Should find representatives 1267 + self.assertIsInstance(result, list) 1268 + self.assertGreater(len(result), 0) 1269 + 1270 + def test_locate_by_plz_fallback_when_geocoding_fails(self): 1271 + """Test PLZ-based fallback when address geocoding fails.""" 1272 + from unittest.mock import patch, Mock 1273 + from .services import ConstituencyLocator 1274 + 1275 + # Mock geocoding to return failure 1276 + mock_response = Mock() 1277 + mock_response.status_code = 200 1278 + mock_response.json.return_value = [] # Empty = not found 1279 + 1280 + with patch('requests.get', return_value=mock_response): 1281 + locator = ConstituencyLocator() 1282 + result = locator.locate( 1283 + street='Nonexistent Street 999', 1284 + postal_code='10115', # Berlin PLZ 1285 + city='Berlin' 1286 + ) 1287 + 1288 + # Should still find representatives using PLZ fallback 1289 + self.assertIsInstance(result, list) 1290 + # PLZ fallback might return empty list or representatives depending on data 1291 + # The important thing is it doesn't crash 1292 + 1293 + def test_locate_by_plz_only_maintains_backward_compatibility(self): 1294 + """Test that PLZ-only lookup still works (backward compatibility).""" 1295 + from .services import ConstituencyLocator 1296 + 1297 + locator = ConstituencyLocator() 1298 + result = locator.locate(postal_code='10115') # Berlin PLZ 1299 + 1300 + # Should work without crashing 1301 + self.assertIsInstance(result, list) 1302 + # Result depends on existing data, but should at least not error 1303 + 1304 + def test_locate_without_parameters_returns_empty(self): 1305 + """Test that calling locate without parameters returns empty list.""" 1306 + from .services import ConstituencyLocator 1307 + 1308 + locator = ConstituencyLocator() 1309 + result = locator.locate() 1310 + 1311 + self.assertIsInstance(result, list) 1312 + self.assertEqual(len(result), 0) 1313 + 1314 + 1315 + class IdentityVerificationFormTests(TestCase): 1316 + """Test the IdentityVerificationForm for full address collection.""" 1317 + 1318 + def setUp(self): 1319 + self.user = User.objects.create_user( 1320 + username='testuser', 1321 + password='password123', 1322 + email='testuser@example.com', 1323 + ) 1324 + 1325 + def test_form_requires_all_address_fields_together(self): 1326 + """Test that form validation requires all address fields if any is provided.""" 1327 + from .forms import IdentityVerificationForm 1328 + 1329 + # Only street provided - should fail 1330 + form = IdentityVerificationForm( 1331 + data={ 1332 + 'street_address': 'Unter den Linden 77', 1333 + 'postal_code': '', 1334 + 'city': '', 1335 + }, 1336 + user=self.user 1337 + ) 1338 + self.assertFalse(form.is_valid()) 1339 + self.assertIn('Bitte geben Sie eine vollstรคndige Adresse ein', str(form.errors)) 1340 + 1341 + # Only PLZ provided - should fail 1342 + form = IdentityVerificationForm( 1343 + data={ 1344 + 'street_address': '', 1345 + 'postal_code': '10117', 1346 + 'city': '', 1347 + }, 1348 + user=self.user 1349 + ) 1350 + self.assertFalse(form.is_valid()) 1351 + self.assertIn('Bitte geben Sie eine vollstรคndige Adresse ein', str(form.errors)) 1352 + 1353 + # Only city provided - should fail 1354 + form = IdentityVerificationForm( 1355 + data={ 1356 + 'street_address': '', 1357 + 'postal_code': '', 1358 + 'city': 'Berlin', 1359 + }, 1360 + user=self.user 1361 + ) 1362 + self.assertFalse(form.is_valid()) 1363 + self.assertIn('Bitte geben Sie eine vollstรคndige Adresse ein', str(form.errors)) 1364 + 1365 + def test_form_accepts_all_address_fields(self): 1366 + """Test that form is valid when all address fields are provided.""" 1367 + from .forms import IdentityVerificationForm 1368 + 1369 + form = IdentityVerificationForm( 1370 + data={ 1371 + 'street_address': 'Unter den Linden 77', 1372 + 'postal_code': '10117', 1373 + 'city': 'Berlin', 1374 + }, 1375 + user=self.user 1376 + ) 1377 + self.assertTrue(form.is_valid()) 1378 + 1379 + def test_form_accepts_empty_address(self): 1380 + """Test that form is valid when all address fields are empty.""" 1381 + from .forms import IdentityVerificationForm 1382 + 1383 + form = IdentityVerificationForm( 1384 + data={ 1385 + 'street_address': '', 1386 + 'postal_code': '', 1387 + 'city': '', 1388 + }, 1389 + user=self.user 1390 + ) 1391 + self.assertTrue(form.is_valid()) 1392 + 1393 + def test_form_prefills_existing_address(self): 1394 + """Test that form prefills existing address from verification.""" 1395 + from .forms import IdentityVerificationForm 1396 + 1397 + # Create verification with address 1398 + verification = IdentityVerification.objects.create( 1399 + user=self.user, 1400 + status='SELF_DECLARED', 1401 + street_address='Unter den Linden 77', 1402 + postal_code='10117', 1403 + city='Berlin', 1404 + ) 1405 + 1406 + form = IdentityVerificationForm(user=self.user) 1407 + 1408 + self.assertEqual(form.fields['street_address'].initial, 'Unter den Linden 77') 1409 + self.assertEqual(form.fields['postal_code'].initial, '10117') 1410 + self.assertEqual(form.fields['city'].initial, 'Berlin') 1411 + 1412 + 1413 + class ProfileViewAddressTests(TestCase): 1414 + """Test profile view address form submission.""" 1415 + 1416 + def setUp(self): 1417 + self.user = User.objects.create_user( 1418 + username='testuser', 1419 + password='password123', 1420 + email='testuser@example.com', 1421 + ) 1422 + self.client.login(username='testuser', password='password123') 1423 + 1424 + def test_profile_view_saves_address(self): 1425 + """Test that profile view saves address correctly.""" 1426 + response = self.client.post( 1427 + reverse('profile'), 1428 + { 1429 + 'address_form_submit': '1', 1430 + 'street_address': 'Unter den Linden 77', 1431 + 'postal_code': '10117', 1432 + 'city': 'Berlin', 1433 + }, 1434 + follow=True 1435 + ) 1436 + 1437 + self.assertEqual(response.status_code, 200) 1438 + self.assertRedirects(response, reverse('profile')) 1439 + 1440 + # Verify address was saved 1441 + verification = IdentityVerification.objects.get(user=self.user) 1442 + self.assertEqual(verification.street_address, 'Unter den Linden 77') 1443 + self.assertEqual(verification.postal_code, '10117') 1444 + self.assertEqual(verification.city, 'Berlin') 1445 + self.assertEqual(verification.status, 'SELF_DECLARED') 1446 + self.assertEqual(verification.verification_type, 'SELF_DECLARED') 1447 + 1448 + def test_profile_view_updates_existing_address(self): 1449 + """Test that profile view updates existing address.""" 1450 + # Create initial verification 1451 + verification = IdentityVerification.objects.create( 1452 + user=self.user, 1453 + status='SELF_DECLARED', 1454 + street_address='Old Street 1', 1455 + postal_code='12345', 1456 + city='OldCity', 1457 + ) 1458 + 1459 + response = self.client.post( 1460 + reverse('profile'), 1461 + { 1462 + 'address_form_submit': '1', 1463 + 'street_address': 'Unter den Linden 77', 1464 + 'postal_code': '10117', 1465 + 'city': 'Berlin', 1466 + }, 1467 + follow=True 1468 + ) 1469 + 1470 + self.assertEqual(response.status_code, 200) 1471 + 1472 + # Verify address was updated 1473 + verification.refresh_from_db() 1474 + self.assertEqual(verification.street_address, 'Unter den Linden 77') 1475 + self.assertEqual(verification.postal_code, '10117') 1476 + self.assertEqual(verification.city, 'Berlin') 1477 + 1478 + def test_profile_view_displays_saved_address(self): 1479 + """Test that profile view displays saved address.""" 1480 + # Create verification with address 1481 + verification = IdentityVerification.objects.create( 1482 + user=self.user, 1483 + status='SELF_DECLARED', 1484 + street_address='Unter den Linden 77', 1485 + postal_code='10117', 1486 + city='Berlin', 1487 + ) 1488 + 1489 + response = self.client.get(reverse('profile')) 1490 + 1491 + self.assertEqual(response.status_code, 200) 1492 + self.assertContains(response, 'Unter den Linden 77') 1493 + self.assertContains(response, '10117') 1494 + self.assertContains(response, 'Berlin')
+52 -13
website/letters/views.py
··· 29 29 ReportForm, 30 30 LetterSearchForm, 31 31 UserRegisterForm, 32 - SelfDeclaredConstituencyForm 32 + SelfDeclaredConstituencyForm, 33 + IdentityVerificationForm 33 34 ) 34 35 from .services import IdentityVerificationService, ConstituencySuggestionService 35 36 ··· 314 315 verification = None 315 316 316 317 if request.method == 'POST': 317 - constituency_form = SelfDeclaredConstituencyForm(request.POST, user=user) 318 - if constituency_form.is_valid(): 319 - IdentityVerificationService.self_declare( 320 - user=user, 321 - federal_constituency=constituency_form.cleaned_data['federal_constituency'], 322 - state_constituency=constituency_form.cleaned_data['state_constituency'], 323 - ) 324 - messages.success( 325 - request, 326 - _('Your constituency information has been updated.') 327 - ) 328 - return redirect('profile') 318 + # Check which form was submitted 319 + if 'address_form_submit' in request.POST: 320 + address_form = IdentityVerificationForm(request.POST, user=user) 321 + constituency_form = SelfDeclaredConstituencyForm(user=user) 322 + 323 + if address_form.is_valid(): 324 + street_address = address_form.cleaned_data.get('street_address') 325 + postal_code = address_form.cleaned_data.get('postal_code') 326 + city = address_form.cleaned_data.get('city') 327 + 328 + # Only update if all fields are provided 329 + if street_address and postal_code and city: 330 + # Get or create verification record 331 + verification, created = IdentityVerification.objects.get_or_create( 332 + user=user, 333 + defaults={ 334 + 'status': 'SELF_DECLARED', 335 + 'verification_type': 'SELF_DECLARED', 336 + } 337 + ) 338 + 339 + # Update address fields 340 + verification.street_address = street_address 341 + verification.postal_code = postal_code 342 + verification.city = city 343 + verification.save() 344 + 345 + messages.success( 346 + request, 347 + _('Ihre Adresse wurde gespeichert.') 348 + ) 349 + return redirect('profile') 350 + else: 351 + # Constituency form submission 352 + constituency_form = SelfDeclaredConstituencyForm(request.POST, user=user) 353 + address_form = IdentityVerificationForm(user=user) 354 + 355 + if constituency_form.is_valid(): 356 + IdentityVerificationService.self_declare( 357 + user=user, 358 + federal_constituency=constituency_form.cleaned_data['federal_constituency'], 359 + state_constituency=constituency_form.cleaned_data['state_constituency'], 360 + ) 361 + messages.success( 362 + request, 363 + _('Your constituency information has been updated.') 364 + ) 365 + return redirect('profile') 329 366 else: 330 367 constituency_form = SelfDeclaredConstituencyForm(user=user) 368 + address_form = IdentityVerificationForm(user=user) 331 369 332 370 context = { 333 371 'user_letters': user_letters, 334 372 'user_signatures': user_signatures, 335 373 'verification': verification, 336 374 'constituency_form': constituency_form, 375 + 'address_form': address_form, 337 376 } 338 377 339 378 return render(request, 'letters/profile.html', context)
website/locale/de/LC_MESSAGES/.gitkeep

This is a binary file and will not be displayed.

website/locale/de/LC_MESSAGES/django.mo

This is a binary file and will not be displayed.

+840 -409
website/locale/de/LC_MESSAGES/django.po
··· 6 6 msgstr "" 7 7 "Project-Id-Version: WriteThem.eu\n" 8 8 "Report-Msgid-Bugs-To: \n" 9 - "POT-Creation-Date: 2025-10-04 22:57+0200\n" 9 + "POT-Creation-Date: 2025-10-15 00:27+0200\n" 10 10 "PO-Revision-Date: 2025-01-10 12:00+0100\n" 11 11 "Last-Translator: \n" 12 12 "Language-Team: German\n" ··· 16 16 "Content-Transfer-Encoding: 8bit\n" 17 17 "Plural-Forms: nplurals=2; plural=(n != 1);\n" 18 18 19 - # Forms 20 - #: letters/forms.py:28 21 - msgid "Postal code (PLZ)" 22 - msgstr "Postleitzahl (PLZ)" 19 + #: letters/admin.py:75 20 + msgid "Mandate Details" 21 + msgstr "Mandatsdetails" 23 22 24 - #: letters/forms.py:29 25 - #, fuzzy 26 - #| msgid "Use your PLZ to narrow down representatives from your constituency." 27 - msgid "Use your PLZ to narrow down representatives from your parliament." 28 - msgstr "" 29 - "Verwenden Sie Ihre PLZ, um Abgeordnete aus Ihrem Wahlkreis einzugrenzen." 23 + #: letters/admin.py:78 24 + msgid "Focus Areas" 25 + msgstr "Schwerpunktbereiche" 30 26 31 - #: letters/forms.py:30 32 - msgid "e.g. 10115" 33 - msgstr "z.B. 10115" 27 + #: letters/admin.py:81 letters/admin.py:95 28 + msgid "Photo" 29 + msgstr "Foto" 34 30 35 - #: letters/forms.py:35 36 - msgid "Comma-separated tags (e.g., \"climate, transport, education\")" 37 - msgstr "Komma-getrennte Schlagwรถrter (z.B. \"Klima, Verkehr, Bildung\")" 31 + #: letters/admin.py:85 32 + msgid "Metadata" 33 + msgstr "Metadaten" 38 34 39 - #: letters/forms.py:36 40 - msgid "climate, transport, education" 41 - msgstr "Klima, Verkehr, Bildung" 35 + #: letters/admin.py:94 36 + msgid "No photo" 37 + msgstr "Kein Foto" 38 + 39 + #: letters/admin.py:167 40 + msgid "Topic Areas" 41 + msgstr "Themenbereiche" 42 + 43 + #: letters/forms.py:25 44 + msgid "" 45 + "An account with this email already exists. If you registered before, please " 46 + "check your inbox for the activation link or reset your password." 47 + msgstr "" 48 + "Ein Konto mit dieser E-Mail-Adresse existiert bereits. Falls Sie sich zuvor " 49 + "registriert haben, prรผfen Sie Ihren Posteingang auf den Aktivierungslink oder " 50 + "setzen Sie Ihr Passwort zurรผck." 42 51 43 - #: letters/forms.py:43 52 + #: letters/forms.py:37 44 53 msgid "Title" 45 54 msgstr "Titel" 46 55 47 - #: letters/forms.py:44 56 + #: letters/forms.py:38 48 57 msgid "Letter Body" 49 58 msgstr "Brieftext" 50 59 51 - #: letters/forms.py:45 52 - #, fuzzy 53 - #| msgid "To Representative:" 60 + #: letters/forms.py:39 54 61 msgid "To Representative" 55 - msgstr "An Abgeordnete:" 62 + msgstr "An Abgeordnete" 56 63 57 - #: letters/forms.py:48 64 + #: letters/forms.py:42 58 65 msgid "Describe your concern briefly" 59 66 msgstr "Beschreiben Sie Ihr Anliegen kurz" 60 67 61 - #: letters/forms.py:49 62 - msgid "Write your letter here" 63 - msgstr "Schreiben Sie hier Ihren Brief" 68 + #: letters/forms.py:43 69 + msgid "" 70 + "Write your letter here. Markdown formatting (e.g. **bold**, _italic_) is " 71 + "supported." 72 + msgstr "" 73 + "Schreiben Sie hier Ihren Brief. Markdown-Formatierung (z.B. **fett**, _kursiv_) " 74 + "wird unterstรผtzt." 64 75 65 - #: letters/forms.py:52 76 + #: letters/forms.py:46 66 77 msgid "Letter title" 67 78 msgstr "Brieftitel" 68 79 69 - #: letters/forms.py:56 80 + #: letters/forms.py:50 70 81 msgid "Write your letter here..." 71 82 msgstr "Schreiben Sie hier Ihren Brief..." 72 83 73 - #: letters/forms.py:156 84 + #: letters/forms.py:110 74 85 msgid "Comment (optional)" 75 86 msgstr "Kommentar (optional)" 76 87 77 - #: letters/forms.py:159 88 + #: letters/forms.py:113 78 89 msgid "Add a personal note to your signature" 79 90 msgstr "Fรผgen Sie Ihrer Unterschrift eine persรถnliche Notiz hinzu" 80 91 81 - #: letters/forms.py:165 92 + #: letters/forms.py:119 82 93 msgid "Optional: Add your comment..." 83 94 msgstr "Optional: Fรผgen Sie Ihren Kommentar hinzu..." 84 95 85 - #: letters/forms.py:177 96 + #: letters/forms.py:131 86 97 msgid "Reason" 87 98 msgstr "Grund" 88 99 89 - #: letters/forms.py:178 100 + #: letters/forms.py:132 90 101 msgid "Description" 91 102 msgstr "Beschreibung" 92 103 93 - #: letters/forms.py:181 104 + #: letters/forms.py:135 94 105 msgid "Please provide details about why you are reporting this letter" 95 106 msgstr "Bitte geben Sie Details an, warum Sie diesen Brief melden" 96 107 97 - #: letters/forms.py:188 108 + #: letters/forms.py:142 98 109 msgid "Please describe the issue..." 99 110 msgstr "Bitte beschreiben Sie das Problem..." 100 111 101 - #: letters/forms.py:198 letters/templates/letters/letter_list.html:19 112 + #: letters/forms.py:152 letters/templates/letters/letter_list.html:19 102 113 msgid "Search" 103 114 msgstr "Suchen" 104 115 105 - #: letters/forms.py:201 letters/templates/letters/letter_list.html:18 116 + #: letters/forms.py:155 letters/templates/letters/letter_list.html:18 106 117 msgid "Search letters..." 107 118 msgstr "Briefe suchen..." 108 119 109 - #: letters/forms.py:207 120 + #: letters/forms.py:161 110 121 msgid "Tag" 111 122 msgstr "Schlagwort" 112 123 113 - #: letters/forms.py:210 124 + #: letters/forms.py:164 114 125 msgid "Filter by tag..." 115 126 msgstr "Nach Schlagwort filtern..." 116 127 128 + #: letters/forms.py:180 129 + msgid "Bundestag constituency" 130 + msgstr "Bundestags-Wahlkreis" 131 + 132 + #: letters/forms.py:181 133 + msgid "Pick your Bundestag direct mandate constituency (Wahlkreis)." 134 + msgstr "Wรคhlen Sie Ihren Bundestags-Direktwahlkreis (Wahlkreis)." 135 + 136 + #: letters/forms.py:182 letters/forms.py:189 137 + msgid "Select constituency" 138 + msgstr "Wahlkreis auswรคhlen" 139 + 140 + #: letters/forms.py:187 141 + msgid "State parliament constituency" 142 + msgstr "Landtags-Wahlkreis" 143 + 144 + #: letters/forms.py:188 145 + msgid "Optionally pick your Landtag constituency if applicable." 146 + msgstr "Wรคhlen Sie optional Ihren Landtags-Wahlkreis, falls zutreffend." 147 + 148 + #: letters/forms.py:240 149 + msgid "Please select at least one constituency to save your profile." 150 + msgstr "Bitte wรคhlen Sie mindestens einen Wahlkreis aus, um Ihr Profil zu speichern." 151 + 152 + #: letters/forms.py:252 153 + msgid "StraรŸe und Hausnummer" 154 + msgstr "StraรŸe und Hausnummer" 155 + 156 + #: letters/forms.py:255 157 + msgid "z.B. Unter den Linden 77" 158 + msgstr "z.B. Unter den Linden 77" 159 + 160 + #: letters/forms.py:261 161 + msgid "Postleitzahl" 162 + msgstr "Postleitzahl" 163 + 164 + #: letters/forms.py:264 165 + msgid "z.B. 10117" 166 + msgstr "z.B. 10117" 167 + 168 + #: letters/forms.py:270 169 + msgid "Stadt" 170 + msgstr "Stadt" 171 + 172 + #: letters/forms.py:273 173 + msgid "z.B. Berlin" 174 + msgstr "z.B. Berlin" 175 + 176 + #: letters/forms.py:304 177 + msgid "" 178 + "Bitte geben Sie eine vollstรคndige Adresse ein (StraรŸe, PLZ und Stadt) oder " 179 + "lassen Sie alle Felder leer." 180 + msgstr "" 181 + "Bitte geben Sie eine vollstรคndige Adresse ein (StraรŸe, PLZ und Stadt) oder " 182 + "lassen Sie alle Felder leer." 183 + 117 184 # Model choices - Constituency 118 - #: letters/models.py:13 185 + #: letters/models.py:15 119 186 msgid "European Union" 120 187 msgstr "Europรคische Union" 121 188 122 - #: letters/models.py:14 189 + #: letters/models.py:16 123 190 msgid "Federal" 124 191 msgstr "Bund" 125 192 126 - #: letters/models.py:15 193 + #: letters/models.py:17 127 194 msgid "State" 128 195 msgstr "Land" 129 196 130 - #: letters/models.py:16 197 + #: letters/models.py:18 131 198 msgid "Local" 132 199 msgstr "Kommune" 133 200 134 - #: letters/models.py:19 201 + #: letters/models.py:21 135 202 msgid "Name of the parliament" 136 - msgstr "" 203 + msgstr "Name des Parlaments" 137 204 138 - #: letters/models.py:23 205 + #: letters/models.py:25 139 206 msgid "e.g., 'Bundestag', 'Bayerischer Landtag', 'Gemeinderat Mรผnchen'" 140 - msgstr "" 207 + msgstr "z.B. 'Bundestag', 'Bayerischer Landtag', 'Gemeinderat Mรผnchen'" 141 208 142 - #: letters/models.py:27 209 + #: letters/models.py:29 143 210 msgid "Geographic identifier (state code, municipality code, etc.)" 144 - msgstr "" 211 + msgstr "Geografische Kennung (Bundeslandkรผrzel, Gemeindeschlรผssel, etc.)" 145 212 146 - #: letters/models.py:35 213 + #: letters/models.py:37 147 214 msgid "For hierarchical relationships (e.g., local within state)" 148 - msgstr "" 215 + msgstr "Fรผr hierarchische Beziehungen (z.B. Kommune innerhalb eines Bundeslandes)" 216 + 217 + #: letters/models.py:43 letters/models.py:70 letters/models.py:105 218 + #: letters/models.py:165 letters/models.py:372 letters/models.py:421 219 + msgid "Last time this was synced from external API" 220 + msgstr "Zeitpunkt der letzten Synchronisierung von externer API" 149 221 150 - #: letters/models.py:44 222 + #: letters/models.py:47 letters/templates/letters/committee_detail.html:70 151 223 msgid "Parliament" 152 - msgstr "" 224 + msgstr "Parlament" 153 225 154 - #: letters/models.py:45 226 + #: letters/models.py:48 155 227 msgid "Parliaments" 156 - msgstr "" 157 - 158 - #: letters/models.py:80 159 - #, fuzzy 160 - #| msgid "Federal" 161 - msgid "Federal district" 162 - msgstr "Bund" 163 - 164 - #: letters/models.py:81 165 - msgid "State district" 166 - msgstr "" 167 - 168 - #: letters/models.py:82 169 - msgid "Regional district" 170 - msgstr "" 228 + msgstr "Parlamente" 171 229 172 - #: letters/models.py:114 230 + #: letters/models.py:84 173 231 msgid "Federal electoral district" 174 - msgstr "" 232 + msgstr "Bundeswahlkreis" 175 233 176 - #: letters/models.py:115 234 + #: letters/models.py:85 177 235 msgid "Bundestag state list" 178 - msgstr "" 236 + msgstr "Bundestag-Landesliste" 179 237 180 - #: letters/models.py:116 238 + #: letters/models.py:86 181 239 msgid "Bundestag federal list" 182 - msgstr "" 240 + msgstr "Bundestag-Bundesliste" 183 241 184 - #: letters/models.py:117 242 + #: letters/models.py:87 185 243 msgid "State electoral district" 186 - msgstr "" 244 + msgstr "Landeswahlkreis" 187 245 188 - #: letters/models.py:118 246 + #: letters/models.py:88 189 247 msgid "State regional list" 190 - msgstr "" 248 + msgstr "Regionale Landesliste" 191 249 192 - #: letters/models.py:119 250 + #: letters/models.py:89 193 251 msgid "State wide list" 194 - msgstr "" 252 + msgstr "Landesweite Liste" 195 253 196 - #: letters/models.py:120 254 + #: letters/models.py:90 197 255 msgid "EU at large" 198 - msgstr "" 256 + msgstr "EU insgesamt" 199 257 200 - #: letters/models.py:153 258 + #: letters/models.py:119 201 259 msgid "Direct mandate" 202 - msgstr "" 260 + msgstr "Direktmandat" 203 261 204 - #: letters/models.py:154 262 + #: letters/models.py:120 205 263 msgid "State list mandate" 206 - msgstr "" 264 + msgstr "Landeslisten-Mandat" 207 265 208 - #: letters/models.py:155 266 + #: letters/models.py:121 209 267 msgid "State regional list mandate" 210 - msgstr "" 268 + msgstr "Regionales Landeslisten-Mandat" 211 269 212 - #: letters/models.py:156 270 + #: letters/models.py:122 213 271 msgid "Federal list mandate" 214 - msgstr "" 272 + msgstr "Bundeslisten-Mandat" 215 273 216 - #: letters/models.py:157 274 + #: letters/models.py:123 217 275 msgid "EU list mandate" 218 - msgstr "" 276 + msgstr "EU-Listen-Mandat" 219 277 220 278 # Model choices - Letter 221 - #: letters/models.py:422 279 + #: letters/models.py:444 222 280 msgid "Draft" 223 281 msgstr "Entwurf" 224 282 225 - #: letters/models.py:423 283 + #: letters/models.py:445 226 284 msgid "Published" 227 285 msgstr "Verรถffentlicht" 228 286 229 - #: letters/models.py:424 287 + #: letters/models.py:446 230 288 msgid "Flagged for Review" 231 289 msgstr "Zur รœberprรผfung markiert" 232 290 233 - #: letters/models.py:425 291 + #: letters/models.py:447 234 292 msgid "Removed" 235 293 msgstr "Entfernt" 236 294 237 - #: letters/services.py:1149 295 + #: letters/models.py:487 296 + msgid "Deleted user" 297 + msgstr "Gelรถschter Benutzer" 298 + 299 + #: letters/services.py:2451 238 300 #, python-format 239 301 msgid "Detected policy area: %(topic)s." 240 - msgstr "" 302 + msgstr "Erkannter Politikbereich: %(topic)s." 241 303 242 - #: letters/services.py:1154 243 - #, fuzzy, python-format 244 - #| msgid "Use your PLZ to narrow down representatives from your constituency." 304 + #: letters/services.py:2456 305 + #, python-format 245 306 msgid "Prioritising representatives for %(constituencies)s." 246 - msgstr "" 247 - "Verwenden Sie Ihre PLZ, um Abgeordnete aus Ihrem Wahlkreis einzugrenzen." 307 + msgstr "Priorisiere Abgeordnete fรผr %(constituencies)s." 248 308 249 - #: letters/services.py:1159 309 + #: letters/services.py:2461 250 310 #, python-format 251 311 msgid "Filtering by state %(state)s." 252 - msgstr "" 312 + msgstr "Filtere nach Bundesland %(state)s." 253 313 254 - #: letters/services.py:1163 314 + #: letters/services.py:2465 255 315 #, python-format 256 316 msgid "" 257 317 "Postal code %(plz)s had no direct match; showing broader representatives." 258 318 msgstr "" 319 + "Postleitzahl %(plz)s hatte keine direkte รœbereinstimmung; zeige allgemeinere Abgeordnete." 259 320 260 - #: letters/services.py:1167 261 - #, fuzzy 262 - #| msgid "No external resources available for this representative." 321 + #: letters/services.py:2469 263 322 msgid "Showing generally relevant representatives." 264 - msgstr "Keine externen Ressourcen fรผr diesen Abgeordneten verfรผgbar." 323 + msgstr "Zeige allgemein relevante Abgeordnete." 324 + 325 + #: letters/templates/letters/account_activation_invalid.html:4 326 + #: letters/templates/letters/account_activation_invalid.html:8 327 + msgid "Activation link invalid" 328 + msgstr "Aktivierungslink ungรผltig" 329 + 330 + #: letters/templates/letters/account_activation_invalid.html:10 331 + msgid "" 332 + "We could not verify your activation link. It may have already been used or " 333 + "expired." 334 + msgstr "" 335 + "Wir konnten Ihren Aktivierungslink nicht verifizieren. Er wurde mรถglicherweise bereits " 336 + "verwendet oder ist abgelaufen." 337 + 338 + #: letters/templates/letters/account_activation_invalid.html:13 339 + msgid "" 340 + "If you still cannot access your account, try registering again or contact " 341 + "support." 342 + msgstr "" 343 + "Falls Sie weiterhin nicht auf Ihr Konto zugreifen kรถnnen, versuchen Sie sich erneut zu " 344 + "registrieren oder kontaktieren Sie den Support." 345 + 346 + #: letters/templates/letters/account_activation_invalid.html:15 347 + msgid "Register again" 348 + msgstr "Erneut registrieren" 349 + 350 + #: letters/templates/letters/account_activation_sent.html:4 351 + msgid "Activate your account" 352 + msgstr "Aktivieren Sie Ihr Konto" 353 + 354 + #: letters/templates/letters/account_activation_sent.html:8 355 + #: letters/templates/letters/password_reset_done.html:8 356 + msgid "Check your inbox" 357 + msgstr "Prรผfen Sie Ihren Posteingang" 358 + 359 + #: letters/templates/letters/account_activation_sent.html:10 360 + msgid "" 361 + "We sent you an email with a confirmation link. Please click it to activate " 362 + "your account." 363 + msgstr "" 364 + "Wir haben Ihnen eine E-Mail mit einem Bestรคtigungslink gesendet. Bitte klicken Sie " 365 + "darauf, um Ihr Konto zu aktivieren." 366 + 367 + #: letters/templates/letters/account_activation_sent.html:13 368 + msgid "" 369 + "If you do not receive the email within a few minutes, check your spam folder " 370 + "or try registering again." 371 + msgstr "" 372 + "Falls Sie die E-Mail nicht innerhalb weniger Minuten erhalten, prรผfen Sie Ihren " 373 + "Spam-Ordner oder versuchen Sie sich erneut zu registrieren." 374 + 375 + #: letters/templates/letters/account_activation_sent.html:15 376 + msgid "Back to homepage" 377 + msgstr "Zurรผck zur Startseite" 378 + 379 + #: letters/templates/letters/account_delete_confirm.html:4 380 + #: letters/templates/letters/profile.html:142 381 + msgid "Delete account" 382 + msgstr "Konto lรถschen" 383 + 384 + #: letters/templates/letters/account_delete_confirm.html:8 385 + msgid "Delete your account" 386 + msgstr "Lรถschen Sie Ihr Konto" 387 + 388 + #: letters/templates/letters/account_delete_confirm.html:10 389 + msgid "" 390 + "Deleting your account will remove your personal data and signatures. Letters " 391 + "you have published stay online but are shown without your name." 392 + msgstr "" 393 + "Das Lรถschen Ihres Kontos entfernt Ihre persรถnlichen Daten und Unterschriften. Von Ihnen " 394 + "verรถffentlichte Briefe bleiben online, werden aber ohne Ihren Namen angezeigt." 395 + 396 + #: letters/templates/letters/account_delete_confirm.html:14 397 + msgid "Yes, delete my account" 398 + msgstr "Ja, mein Konto lรถschen" 399 + 400 + #: letters/templates/letters/account_delete_confirm.html:15 401 + #: letters/templates/letters/letter_form.html:61 402 + msgid "Cancel" 403 + msgstr "Abbrechen" 265 404 266 405 # Navigation 267 - #: letters/templates/letters/base.html:124 406 + #: letters/templates/letters/base.html:140 268 407 msgid "Letters" 269 408 msgstr "Briefe" 270 409 271 - #: letters/templates/letters/base.html:126 272 - #: letters/templates/letters/representative_detail.html:196 410 + #: letters/templates/letters/base.html:141 411 + msgid "Competencies" 412 + msgstr "Kompetenzen" 413 + 414 + #: letters/templates/letters/base.html:143 415 + #: letters/templates/letters/representative_detail.html:149 273 416 msgid "Write Letter" 274 417 msgstr "Brief schreiben" 275 418 276 - #: letters/templates/letters/base.html:127 419 + #: letters/templates/letters/base.html:144 277 420 #: letters/templates/letters/profile.html:4 278 421 msgid "Profile" 279 422 msgstr "Profil" 280 423 281 - #: letters/templates/letters/base.html:128 424 + #: letters/templates/letters/base.html:145 282 425 msgid "Logout" 283 426 msgstr "Abmelden" 284 427 285 - #: letters/templates/letters/base.html:130 286 - #: letters/templates/letters/letter_detail.html:46 287 - #: letters/templates/letters/letter_detail.html:80 428 + #: letters/templates/letters/base.html:147 429 + #: letters/templates/letters/letter_detail.html:47 430 + #: letters/templates/letters/letter_detail.html:81 288 431 #: letters/templates/letters/login.html:4 289 432 #: letters/templates/letters/login.html:8 290 433 #: letters/templates/letters/login.html:33 291 434 msgid "Login" 292 435 msgstr "Anmelden" 293 436 294 - #: letters/templates/letters/base.html:131 437 + #: letters/templates/letters/base.html:148 295 438 #: letters/templates/letters/register.html:4 296 439 #: letters/templates/letters/register.html:8 297 - #: letters/templates/letters/register.html:65 440 + #: letters/templates/letters/register.html:66 298 441 msgid "Register" 299 442 msgstr "Registrieren" 300 443 301 - #: letters/templates/letters/base.html:134 444 + #: letters/templates/letters/base.html:151 302 445 msgid "Admin" 303 446 msgstr "Admin" 304 447 448 + #: letters/templates/letters/base.html:157 449 + msgid "Select language" 450 + msgstr "Sprache auswรคhlen" 451 + 305 452 # Footer 306 - #: letters/templates/letters/base.html:154 453 + #: letters/templates/letters/base.html:182 307 454 msgid "Empowering citizens to write to their representatives" 308 455 msgstr "Bรผrgern ermรถglichen, an ihre Abgeordneten zu schreiben" 309 456 457 + #: letters/templates/letters/committee_detail.html:22 458 + msgid "Related Topics" 459 + msgstr "Verwandte Themen" 460 + 461 + #: letters/templates/letters/committee_detail.html:36 462 + msgid "Members" 463 + msgstr "Mitglieder" 464 + 465 + #: letters/templates/letters/committee_detail.html:46 466 + msgid "Role" 467 + msgstr "Rolle" 468 + 469 + #: letters/templates/letters/committee_detail.html:48 470 + #: letters/templates/letters/representative_detail.html:61 471 + msgid "Active" 472 + msgstr "Aktiv" 473 + 474 + #: letters/templates/letters/committee_detail.html:51 475 + #: letters/templates/letters/partials/representative_card.html:29 476 + msgid "Since" 477 + msgstr "Seit" 478 + 479 + #: letters/templates/letters/committee_detail.html:58 480 + msgid "No members recorded for this committee." 481 + msgstr "Fรผr diesen Ausschuss sind keine Mitglieder verzeichnet." 482 + 483 + #: letters/templates/letters/committee_detail.html:67 484 + msgid "Committee Info" 485 + msgstr "Ausschussinformationen" 486 + 487 + #: letters/templates/letters/committee_detail.html:71 488 + msgid "Term" 489 + msgstr "Amtszeit" 490 + 491 + #: letters/templates/letters/committee_detail.html:74 492 + #: letters/templates/letters/representative_detail.html:105 493 + msgid "View on Abgeordnetenwatch" 494 + msgstr "Auf Abgeordnetenwatch ansehen" 495 + 310 496 #: letters/templates/letters/letter_detail.html:10 311 497 #: letters/templates/letters/partials/letter_card.html:5 312 498 msgid "By" 313 499 msgstr "Von" 314 500 315 - #: letters/templates/letters/letter_detail.html:11 316 - #: letters/templates/letters/partials/letter_card.html:6 317 - #: letters/templates/letters/profile.html:68 318 - msgid "To" 319 - msgstr "An" 320 - 321 - #: letters/templates/letters/letter_detail.html:28 501 + #: letters/templates/letters/letter_detail.html:29 322 502 #, python-format 323 503 msgid "Signatures (%(counter)s)" 324 504 msgid_plural "Signatures (%(counter)s)" 325 505 msgstr[0] "Unterschrift (%(counter)s)" 326 506 msgstr[1] "Unterschriften (%(counter)s)" 327 507 328 - #: letters/templates/letters/letter_detail.html:30 329 - #, fuzzy, python-format 330 - #| msgid "<strong>%(counter)s</strong> constituent of %(constituency)s" 331 - #| msgid_plural "<strong>%(counter)s</strong> constituents of %(constituency)s" 508 + #: letters/templates/letters/letter_detail.html:31 509 + #, python-format 332 510 msgid "%(counter)s constituent of %(constituency_name)s" 333 511 msgid_plural "%(counter)s constituents of %(constituency_name)s" 334 512 msgstr[0] "%(counter)s Wรคhler aus %(constituency_name)s" 335 513 msgstr[1] "%(counter)s Wรคhler aus %(constituency_name)s" 336 514 337 - #: letters/templates/letters/letter_detail.html:31 338 - #, fuzzy, python-format 339 - #| msgid "%(counter)s verified" 515 + #: letters/templates/letters/letter_detail.html:32 516 + #, python-format 340 517 msgid "%(counter)s other verified" 341 518 msgid_plural "%(counter)s other verified" 342 - msgstr[0] "%(counter)s verifiziert" 343 - msgstr[1] "%(counter)s verifiziert" 519 + msgstr[0] "%(counter)s weitere verifiziert" 520 + msgstr[1] "%(counter)s weitere verifiziert" 344 521 345 - #: letters/templates/letters/letter_detail.html:32 522 + #: letters/templates/letters/letter_detail.html:33 346 523 #, python-format 347 524 msgid "%(counter)s unverified" 348 525 msgid_plural "%(counter)s unverified" 349 526 msgstr[0] "%(counter)s nicht verifiziert" 350 527 msgstr[1] "%(counter)s nicht verifiziert" 351 528 352 - #: letters/templates/letters/letter_detail.html:40 529 + #: letters/templates/letters/letter_detail.html:41 353 530 msgid "Sign this letter" 354 531 msgstr "Brief unterzeichnen" 355 532 356 - #: letters/templates/letters/letter_detail.html:43 533 + #: letters/templates/letters/letter_detail.html:44 357 534 msgid "You have signed this letter" 358 535 msgstr "Sie haben diesen Brief unterzeichnet" 359 536 360 - #: letters/templates/letters/letter_detail.html:46 361 - #, fuzzy 362 - #| msgid "Login to sign this letter" 537 + #: letters/templates/letters/letter_detail.html:47 363 538 msgid "to sign this letter" 364 - msgstr "Anmelden um zu unterzeichnen" 539 + msgstr "um diesen Brief zu unterzeichnen" 365 540 366 - #: letters/templates/letters/letter_detail.html:69 541 + #: letters/templates/letters/letter_detail.html:58 542 + msgid "โœ“ Verified Constituent" 543 + msgstr "โœ“ Verifizierter Wรคhler" 544 + 545 + #: letters/templates/letters/letter_detail.html:60 546 + msgid "โœ“ Verified" 547 + msgstr "โœ“ Verifiziert" 548 + 549 + #: letters/templates/letters/letter_detail.html:70 367 550 msgid "No signatures yet. Be the first to sign!" 368 551 msgstr "Noch keine Unterschriften. Seien Sie der Erste!" 369 552 370 - #: letters/templates/letters/letter_detail.html:74 371 - #: letters/templates/letters/letter_detail.html:78 553 + #: letters/templates/letters/letter_detail.html:75 554 + #: letters/templates/letters/letter_detail.html:79 372 555 msgid "Report this letter" 373 556 msgstr "Brief melden" 374 557 375 - #: letters/templates/letters/letter_detail.html:75 558 + #: letters/templates/letters/letter_detail.html:76 376 559 msgid "If you believe this letter violates our guidelines, please report it." 377 - msgstr "" 560 + msgstr "Falls Sie glauben, dass dieser Brief gegen unsere Richtlinien verstรถรŸt, melden Sie ihn bitte." 378 561 379 - #: letters/templates/letters/letter_detail.html:80 380 - #, fuzzy 381 - #| msgid "Report this letter" 562 + #: letters/templates/letters/letter_detail.html:81 382 563 msgid "to report this letter" 383 - msgstr "Brief melden" 564 + msgstr "um diesen Brief zu melden" 384 565 385 - #: letters/templates/letters/letter_detail.html:85 566 + #: letters/templates/letters/letter_detail.html:86 386 567 msgid "Back to all letters" 387 568 msgstr "Zurรผck zu allen Briefen" 388 569 389 570 #: letters/templates/letters/letter_form.html:4 390 - #: letters/templates/letters/representative_detail.html:190 571 + #: letters/templates/letters/representative_detail.html:143 391 572 msgid "Write a Letter" 392 573 msgstr "Brief schreiben" 393 574 394 575 # Letter form 395 - #: letters/templates/letters/letter_form.html:11 576 + #: letters/templates/letters/letter_form.html:10 396 577 msgid "Write an Open Letter" 397 578 msgstr "Einen offenen Brief schreiben" 398 579 399 - #: letters/templates/letters/letter_form.html:13 580 + #: letters/templates/letters/letter_form.html:12 400 581 msgid "" 401 - "Write an open letter to a German political representative. Your letter will " 402 - "be published publicly and others can sign it." 582 + "Write an open letter to a political representative. Your letter will be " 583 + "published publicly so others can read and sign it." 403 584 msgstr "" 404 - "Schreiben Sie einen offenen Brief an einen deutschen Abgeordneten. Ihr Brief " 405 - "wird รถffentlich verรถffentlicht und andere kรถnnen ihn unterzeichnen." 585 + "Schreiben Sie einen offenen Brief an einen Abgeordneten. Ihr Brief wird " 586 + "รถffentlich verรถffentlicht, damit andere ihn lesen und unterzeichnen kรถnnen." 587 + 588 + #: letters/templates/letters/letter_form.html:16 589 + msgid "Before you write" 590 + msgstr "Bevor Sie schreiben" 591 + 592 + #: letters/templates/letters/letter_form.html:18 593 + msgid "Be thoughtful but feel free to be critical." 594 + msgstr "Seien Sie nachdenklich, aber scheuen Sie sich nicht, kritisch zu sein." 595 + 596 + #: letters/templates/letters/letter_form.html:19 597 + msgid "Representatives are humans tooโ€”stay respectful." 598 + msgstr "Abgeordnete sind auch Menschen โ€“ bleiben Sie respektvoll." 406 599 407 600 #: letters/templates/letters/letter_form.html:20 601 + msgid "Keep your arguments clear and concise." 602 + msgstr "Halten Sie Ihre Argumente klar und prรคgnant." 603 + 604 + #: letters/templates/letters/letter_form.html:21 605 + msgid "No insults or hate speech." 606 + msgstr "Keine Beleidigungen oder Hassrede." 607 + 608 + #: letters/templates/letters/letter_form.html:22 609 + msgid "Stay within the bounds of the Grundgesetz when making demands." 610 + msgstr "Bleiben Sie bei Forderungen im Rahmen des Grundgesetzes." 611 + 612 + #: letters/templates/letters/letter_form.html:30 408 613 msgid "Title:" 409 614 msgstr "Titel:" 410 615 411 - #: letters/templates/letters/letter_form.html:26 616 + #: letters/templates/letters/letter_form.html:36 412 617 msgid "" 413 - "Describe your concern. We'll suggest the right representatives based on your " 414 - "title." 415 - msgstr "" 416 - "Beschreiben Sie Ihr Anliegen. Wir schlagen Ihnen passende Abgeordnete " 417 - "basierend auf Ihrem Titel vor." 418 - 419 - #: letters/templates/letters/letter_form.html:31 420 - msgid "Your postal code (PLZ):" 421 - msgstr "Ihre Postleitzahl (PLZ):" 422 - 423 - #: letters/templates/letters/letter_form.html:37 424 - msgid "We'll use this PLZ to highlight representatives from your constituency." 618 + "Describe your concern in a sentence; we'll use it to suggest representatives." 425 619 msgstr "" 426 - "Wir verwenden diese PLZ, um Abgeordnete aus Ihrem Wahlkreis hervorzuheben." 620 + "Beschreiben Sie Ihr Anliegen in einem Satz; wir verwenden ihn, um Ihnen Abgeordnete " 621 + "vorzuschlagen." 427 622 428 - #: letters/templates/letters/letter_form.html:45 623 + #: letters/templates/letters/letter_form.html:41 429 624 msgid "To Representative:" 430 625 msgstr "An Abgeordnete:" 431 626 432 - #: letters/templates/letters/letter_form.html:51 433 - msgid "Or select from suggestions on the right โ†’" 434 - msgstr "Oder wรคhlen Sie aus den Vorschlรคgen rechts โ†’" 627 + #: letters/templates/letters/letter_form.html:47 628 + msgid "" 629 + "Already know who to address? Pick them here. Otherwise, use the suggestions " 630 + "below." 631 + msgstr "" 632 + "Wissen Sie bereits, wen Sie ansprechen mรถchten? Wรคhlen Sie hier aus. Andernfalls nutzen " 633 + "Sie die Vorschlรคge unten." 435 634 436 - #: letters/templates/letters/letter_form.html:56 635 + #: letters/templates/letters/letter_form.html:53 437 636 msgid "Letter Body:" 438 637 msgstr "Brieftext:" 439 638 440 - #: letters/templates/letters/letter_form.html:64 441 - msgid "Tags (optional):" 442 - msgstr "Schlagwรถrter (optional):" 443 - 444 - #: letters/templates/letters/letter_form.html:71 639 + #: letters/templates/letters/letter_form.html:60 445 640 msgid "Publish Letter" 446 641 msgstr "Brief verรถffentlichen" 447 642 448 - #: letters/templates/letters/letter_form.html:72 449 - msgid "Cancel" 450 - msgstr "Abbrechen" 451 - 452 - #: letters/templates/letters/letter_form.html:81 643 + #: letters/templates/letters/letter_form.html:67 453 644 msgid "Smart Suggestions" 454 645 msgstr "Intelligente Vorschlรคge" 455 646 456 - #: letters/templates/letters/letter_form.html:83 457 - #, fuzzy 458 - #| msgid "" 459 - #| "Type your letter title and we'll help you find the right representative " 460 - #| "and show similar letters." 647 + #: letters/templates/letters/letter_form.html:69 461 648 msgid "" 462 - "Add a title and your PLZ to see tailored representatives, tags, and similar " 463 - "letters." 649 + "Type your title and we'll use your verified profile to suggest " 650 + "representatives, topics, and related letters." 464 651 msgstr "" 465 - "Geben Sie Ihren Brieftitel ein und wir helfen Ihnen, den richtigen " 466 - "Abgeordneten zu finden und zeigen รคhnliche Briefe." 652 + "Geben Sie Ihren Titel ein und wir verwenden Ihr verifiziertes Profil, um Ihnen " 653 + "Abgeordnete, Themen und verwandte Briefe vorzuschlagen." 467 654 468 - #: letters/templates/letters/letter_form.html:98 655 + #: letters/templates/letters/letter_form.html:81 469 656 msgid "Loading..." 470 657 msgstr "Lรคdt..." 471 658 472 - #: letters/templates/letters/letter_form.html:100 659 + #: letters/templates/letters/letter_form.html:83 473 660 msgid "Analyzing your title..." 474 661 msgstr "Analysiere Ihren Titel..." 475 662 ··· 510 697 msgid "Popular tags:" 511 698 msgstr "Beliebte Schlagwรถrter:" 512 699 700 + #: letters/templates/letters/letter_list.html:41 701 + msgid "Previous" 702 + msgstr "Zurรผck" 703 + 704 + #: letters/templates/letters/letter_list.html:43 705 + #, python-format 706 + msgid "Page %(page)s of %(total)s" 707 + msgstr "Seite %(page)s von %(total)s" 708 + 709 + #: letters/templates/letters/letter_list.html:45 710 + msgid "Next" 711 + msgstr "Weiter" 712 + 713 + #: letters/templates/letters/letter_list.html:51 714 + msgid "No letters found." 715 + msgstr "Keine Briefe gefunden." 716 + 717 + #: letters/templates/letters/letter_list.html:51 718 + msgid "Be the first to write one!" 719 + msgstr "Schreiben Sie den ersten!" 720 + 513 721 # Login page 514 722 #: letters/templates/letters/login.html:14 515 - #: letters/templates/letters/register.html:14 723 + #: letters/templates/letters/register.html:15 516 724 msgid "Username:" 517 725 msgstr "Benutzername:" 518 726 519 727 #: letters/templates/letters/login.html:22 520 - #: letters/templates/letters/register.html:46 728 + #: letters/templates/letters/register.html:47 521 729 msgid "Password:" 522 730 msgstr "Passwort:" 523 731 524 732 #: letters/templates/letters/login.html:37 733 + msgid "Forgot your password?" 734 + msgstr "Passwort vergessen?" 735 + 736 + #: letters/templates/letters/login.html:41 525 737 msgid "Don't have an account?" 526 738 msgstr "Noch kein Konto?" 527 739 528 - #: letters/templates/letters/login.html:37 740 + #: letters/templates/letters/login.html:41 529 741 msgid "Register here" 530 742 msgstr "Hier registrieren" 531 743 744 + #: letters/templates/letters/partials/letter_card.html:6 745 + msgid "To" 746 + msgstr "An" 747 + 532 748 # Plurals for signatures 533 749 #: letters/templates/letters/partials/letter_card.html:20 534 - #: letters/templates/letters/partials/suggestions.html:126 535 750 #, python-format 536 751 msgid "%(counter)s signature" 537 752 msgid_plural "%(counter)s signatures" ··· 539 754 msgstr[1] "%(counter)s Unterschriften" 540 755 541 756 #: letters/templates/letters/partials/letter_card.html:20 542 - #, fuzzy, python-format 543 - #| msgid "%(counter)s verified" 757 + #, python-format 544 758 msgid "%(counter)s verified" 545 759 msgid_plural "%(counter)s verified" 546 760 msgstr[0] "%(counter)s verifiziert" 547 761 msgstr[1] "%(counter)s verifiziert" 548 762 763 + #: letters/templates/letters/partials/representative_card.html:21 764 + #: letters/templates/letters/partials/representative_card.html:23 765 + msgid "Constituency" 766 + msgstr "Wahlkreis" 767 + 768 + #: letters/templates/letters/partials/representative_card.html:27 769 + msgid "Mandate" 770 + msgstr "Mandat" 771 + 772 + #: letters/templates/letters/partials/representative_card.html:34 773 + msgid "Focus" 774 + msgstr "Schwerpunkt" 775 + 776 + #: letters/templates/letters/partials/representative_card.html:34 777 + msgid "self-declared" 778 + msgstr "selbst erklรคrt" 779 + 780 + #: letters/templates/letters/partials/representative_card.html:45 781 + msgid "Committees" 782 + msgstr "Ausschรผsse" 783 + 784 + #: letters/templates/letters/partials/representative_card.html:57 785 + msgid "Email" 786 + msgstr "E-Mail" 787 + 788 + #: letters/templates/letters/partials/representative_card.html:60 789 + msgid "Website" 790 + msgstr "Webseite" 791 + 792 + #: letters/templates/letters/partials/representative_card.html:66 793 + msgid "View profile" 794 + msgstr "Profil ansehen" 795 + 796 + #: letters/templates/letters/partials/suggestions.html:10 797 + msgid "" 798 + "We couldn't match you to a constituency yet. Update your profile " 799 + "verification to see local representatives." 800 + msgstr "" 801 + "Wir konnten Sie noch keinem Wahlkreis zuordnen. Aktualisieren Sie Ihre Profilverifizierung, " 802 + "um lokale Abgeordnete zu sehen." 803 + 549 804 # Suggestions partial 550 - #: letters/templates/letters/partials/suggestions.html:11 805 + #: letters/templates/letters/partials/suggestions.html:16 551 806 msgid "Our Interpretation" 552 807 msgstr "Unsere Interpretation" 553 808 554 - #: letters/templates/letters/partials/suggestions.html:16 809 + #: letters/templates/letters/partials/suggestions.html:21 555 810 msgid "Topic:" 556 811 msgstr "Thema:" 557 812 558 - #: letters/templates/letters/partials/suggestions.html:23 813 + #: letters/templates/letters/partials/suggestions.html:28 559 814 msgid "No specific policy area detected. Try adding more keywords." 560 815 msgstr "" 561 816 "Kein spezifischer Politikbereich erkannt. Versuchen Sie, mehr " 562 817 "Schlรผsselwรถrter hinzuzufรผgen." 563 818 564 - #: letters/templates/letters/partials/suggestions.html:31 565 - msgid "Suggested Representatives" 566 - msgstr "Vorgeschlagene Abgeordnete" 819 + #: letters/templates/letters/partials/suggestions.html:37 820 + msgid "Related Keywords" 821 + msgstr "Verwandte Schlagwรถrter" 822 + 823 + #: letters/templates/letters/partials/suggestions.html:51 824 + msgid "Your Direct Representatives" 825 + msgstr "Ihre direkten Abgeordneten" 826 + 827 + #: letters/templates/letters/partials/suggestions.html:56 828 + msgid "These representatives directly represent your constituency:" 829 + msgstr "Diese Abgeordneten vertreten Ihren Wahlkreis direkt:" 567 830 568 - #: letters/templates/letters/partials/suggestions.html:37 831 + #: letters/templates/letters/partials/suggestions.html:67 832 + #: letters/templates/letters/partials/suggestions.html:102 833 + msgid "Select" 834 + msgstr "Auswรคhlen" 835 + 836 + #: letters/templates/letters/partials/suggestions.html:81 837 + msgid "Topic Experts" 838 + msgstr "Themenexperten" 839 + 840 + #: letters/templates/letters/partials/suggestions.html:86 569 841 #, python-format 570 842 msgid "" 571 - "Based on the topic \"%(topic)s\", we suggest contacting representatives from " 572 - "the %(level)s:" 843 + "These representatives are experts on \"%(topic)s\" based on their committee " 844 + "memberships:" 573 845 msgstr "" 574 - "Basierend auf dem Thema \"%(topic)s\" empfehlen wir, Abgeordnete des " 575 - "%(level)s zu kontaktieren:" 846 + "Diese Abgeordneten sind Experten fรผr \"%(topic)s\" basierend auf ihren " 847 + "Ausschussmitgliedschaften:" 576 848 577 - #: letters/templates/letters/partials/suggestions.html:51 578 - msgid "Constituency:" 579 - msgstr "Wahlkreis:" 580 - 581 - #: letters/templates/letters/partials/suggestions.html:57 849 + #: letters/templates/letters/partials/suggestions.html:95 582 850 msgid "of" 583 851 msgstr "von" 584 852 585 - #: letters/templates/letters/partials/suggestions.html:61 586 - msgid "View profile" 587 - msgstr "Profil ansehen" 853 + #: letters/templates/letters/partials/suggestions.html:116 854 + msgid "Suggested Representatives" 855 + msgstr "Vorgeschlagene Abgeordnete" 588 856 589 - #: letters/templates/letters/partials/suggestions.html:66 590 - msgid "Select" 591 - msgstr "Auswรคhlen" 592 - 593 - #: letters/templates/letters/partials/suggestions.html:73 857 + #: letters/templates/letters/partials/suggestions.html:119 594 858 msgid "" 595 859 "No representatives found. Representatives may need to be synced for this " 596 860 "governmental level." ··· 598 862 "Keine Abgeordneten gefunden. Abgeordnete mรผssen mรถglicherweise fรผr diese " 599 863 "Verwaltungsebene synchronisiert werden." 600 864 601 - #: letters/templates/letters/partials/suggestions.html:82 602 - #, fuzzy 603 - #| msgid "Suggested Representatives" 604 - msgid "Suggested Tags" 605 - msgstr "Vorgeschlagene Abgeordnete" 865 + #: letters/templates/letters/partials/suggestions.html:148 866 + msgid "Selected:" 867 + msgstr "Ausgewรคhlt:" 868 + 869 + #: letters/templates/letters/password_reset_complete.html:4 870 + #: letters/templates/letters/password_reset_complete.html:8 871 + msgid "Password updated" 872 + msgstr "Passwort aktualisiert" 873 + 874 + #: letters/templates/letters/password_reset_complete.html:9 875 + msgid "You can now sign in using your new password." 876 + msgstr "Sie kรถnnen sich jetzt mit Ihrem neuen Passwort anmelden." 877 + 878 + #: letters/templates/letters/password_reset_complete.html:10 879 + msgid "Go to login" 880 + msgstr "Zur Anmeldung" 881 + 882 + #: letters/templates/letters/password_reset_confirm.html:4 883 + #: letters/templates/letters/password_reset_confirm.html:9 884 + msgid "Choose a new password" 885 + msgstr "Wรคhlen Sie ein neues Passwort" 886 + 887 + #: letters/templates/letters/password_reset_confirm.html:13 888 + msgid "New password" 889 + msgstr "Neues Passwort" 890 + 891 + #: letters/templates/letters/password_reset_confirm.html:20 892 + msgid "Confirm password" 893 + msgstr "Passwort bestรคtigen" 894 + 895 + #: letters/templates/letters/password_reset_confirm.html:26 896 + msgid "Update password" 897 + msgstr "Passwort aktualisieren" 606 898 607 - #: letters/templates/letters/partials/suggestions.html:85 608 - msgid "Click a tag to add it to your letter." 899 + #: letters/templates/letters/password_reset_confirm.html:29 900 + msgid "Reset link invalid" 901 + msgstr "Zurรผcksetzungslink ungรผltig" 902 + 903 + #: letters/templates/letters/password_reset_confirm.html:30 904 + msgid "This password reset link is no longer valid. Please request a new one." 905 + msgstr "Dieser Passwort-Zurรผcksetzungslink ist nicht mehr gรผltig. Bitte fordern Sie einen neuen an." 906 + 907 + #: letters/templates/letters/password_reset_confirm.html:31 908 + msgid "Request new link" 909 + msgstr "Neuen Link anfordern" 910 + 911 + #: letters/templates/letters/password_reset_done.html:4 912 + msgid "Reset email sent" 913 + msgstr "Zurรผcksetzungs-E-Mail gesendet" 914 + 915 + #: letters/templates/letters/password_reset_done.html:9 916 + msgid "" 917 + "If an account exists for that email address, we just sent you instructions " 918 + "to choose a new password." 609 919 msgstr "" 920 + "Falls ein Konto fรผr diese E-Mail-Adresse existiert, haben wir Ihnen gerade Anweisungen " 921 + "zum Festlegen eines neuen Passworts gesendet." 610 922 611 - #: letters/templates/letters/partials/suggestions.html:101 612 - msgid "Related Keywords" 613 - msgstr "Verwandte Schlagwรถrter" 923 + #: letters/templates/letters/password_reset_done.html:10 924 + msgid "The link will stay valid for a limited time." 925 + msgstr "Der Link bleibt fรผr eine begrenzte Zeit gรผltig." 614 926 615 - #: letters/templates/letters/partials/suggestions.html:115 616 - msgid "Similar Letters" 617 - msgstr "ร„hnliche Briefe" 927 + #: letters/templates/letters/password_reset_done.html:11 928 + msgid "Back to login" 929 + msgstr "Zurรผck zur Anmeldung" 618 930 619 - #: letters/templates/letters/partials/suggestions.html:118 620 - msgid "Others have written about similar topics:" 621 - msgstr "Andere haben รผber รคhnliche Themen geschrieben:" 931 + #: letters/templates/letters/password_reset_form.html:4 932 + msgid "Reset password" 933 + msgstr "Passwort zurรผcksetzen" 622 934 623 - #: letters/templates/letters/partials/suggestions.html:129 624 - #, fuzzy 625 - #| msgid "To" 626 - msgid "To:" 627 - msgstr "An" 935 + #: letters/templates/letters/password_reset_form.html:8 936 + msgid "Reset your password" 937 + msgstr "Setzen Sie Ihr Passwort zurรผck" 628 938 629 - #: letters/templates/letters/partials/suggestions.html:131 630 - msgid "by" 939 + #: letters/templates/letters/password_reset_form.html:9 940 + msgid "" 941 + "Enter the email address you used during registration. We will send you a " 942 + "link to create a new password." 631 943 msgstr "" 944 + "Geben Sie die E-Mail-Adresse ein, die Sie bei der Registrierung verwendet haben. Wir " 945 + "senden Ihnen einen Link zum Erstellen eines neuen Passworts." 632 946 633 - #: letters/templates/letters/partials/suggestions.html:163 634 - msgid "Selected:" 635 - msgstr "Ausgewรคhlt:" 947 + #: letters/templates/letters/password_reset_form.html:17 948 + msgid "Send reset link" 949 + msgstr "Zurรผcksetzungslink senden" 636 950 637 951 # Profile page 638 - #: letters/templates/letters/profile.html:8 952 + #: letters/templates/letters/profile.html:13 639 953 #, python-format 640 954 msgid "%(username)s's Profile" 641 955 msgstr "Profil von %(username)s" 642 956 643 - #: letters/templates/letters/profile.html:11 644 - msgid "Identity Verification" 645 - msgstr "Identitรคtsverifizierung" 957 + #: letters/templates/letters/profile.html:16 958 + msgid "Identity & Constituency" 959 + msgstr "Identitรคt & Wahlkreis" 646 960 647 - #: letters/templates/letters/profile.html:15 648 - msgid "Identity Verified" 649 - msgstr "Identitรคt verifiziert" 650 - 651 - #: letters/templates/letters/profile.html:17 652 - msgid "Your signatures will be marked as verified constituent signatures." 653 - msgstr "" 654 - "Ihre Unterschriften werden als verifizierte Wรคhlerunterschriften markiert." 961 + #: letters/templates/letters/profile.html:19 962 + msgid "Status:" 963 + msgstr "Status:" 655 964 656 965 #: letters/templates/letters/profile.html:21 657 - #: letters/templates/letters/representative_detail.html:23 658 - msgid "Parliament:" 966 + msgid "Type:" 967 + msgstr "Typ:" 968 + 969 + #: letters/templates/letters/profile.html:28 970 + msgid "" 971 + "You self-declared your constituency. Representatives will see your " 972 + "signatures as self-declared constituents." 659 973 msgstr "" 974 + "Sie haben Ihren Wahlkreis selbst angegeben. Abgeordnete werden Ihre Unterschriften als " 975 + "selbst angegebene Wรคhler sehen." 660 976 661 - #: letters/templates/letters/profile.html:27 662 - msgid "Verification Pending" 663 - msgstr "Verifizierung ausstehend" 977 + #: letters/templates/letters/profile.html:30 978 + msgid "Start third-party verification" 979 + msgstr "Drittanbieter-Verifizierung starten" 980 + 981 + #: letters/templates/letters/profile.html:33 982 + msgid "" 983 + "Your identity was verified via a third-party provider. Signatures will " 984 + "appear as verified constituents." 985 + msgstr "" 986 + "Ihre Identitรคt wurde รผber einen Drittanbieter verifiziert. Unterschriften werden als " 987 + "verifizierte Wรคhler angezeigt." 664 988 665 - #: letters/templates/letters/profile.html:29 989 + #: letters/templates/letters/profile.html:38 666 990 msgid "Your verification is being processed." 667 991 msgstr "Ihre Verifizierung wird bearbeitet." 668 992 669 - #: letters/templates/letters/profile.html:31 993 + #: letters/templates/letters/profile.html:39 670 994 msgid "Complete Verification (Stub)" 671 995 msgstr "Verifizierung abschlieรŸen (Stub)" 672 996 673 - #: letters/templates/letters/profile.html:35 674 - msgid "Verification Failed" 675 - msgstr "Verifizierung fehlgeschlagen" 997 + #: letters/templates/letters/profile.html:43 998 + msgid "Verification failed. Please try again or contact support." 999 + msgstr "Verifizierung fehlgeschlagen. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support." 676 1000 677 - #: letters/templates/letters/profile.html:37 678 - msgid "Please try again or contact support." 679 - msgstr "Bitte versuchen Sie es erneut oder kontaktieren Sie den Support." 1001 + #: letters/templates/letters/profile.html:48 1002 + msgid "" 1003 + "You can self-declare your constituency below or start a verification with a " 1004 + "trusted provider. Verified signatures carry more weight." 1005 + msgstr "" 1006 + "Sie kรถnnen Ihren Wahlkreis unten selbst angeben oder eine Verifizierung mit einem " 1007 + "vertrauenswรผrdigen Anbieter starten. Verifizierte Unterschriften haben mehr Gewicht." 680 1008 681 - #: letters/templates/letters/profile.html:43 1009 + #: letters/templates/letters/profile.html:50 1010 + msgid "Start Third-party Verification" 1011 + msgstr "Drittanbieter-Verifizierung starten" 1012 + 1013 + #: letters/templates/letters/profile.html:55 1014 + msgid "Ihre Adresse" 1015 + msgstr "Ihre Adresse" 1016 + 1017 + #: letters/templates/letters/profile.html:57 682 1018 msgid "" 683 - "Verify your identity to prove you're a constituent of the representatives " 684 - "you write to. Verified signatures carry more weight!" 1019 + "Geben Sie Ihre vollstรคndige Adresse ein, um prรคzise Wahlkreis- und " 1020 + "Abgeordnetenempfehlungen zu erhalten." 685 1021 msgstr "" 686 - "Verifizieren Sie Ihre Identitรคt, um zu beweisen, dass Sie Wรคhler der " 687 - "Abgeordneten sind, an die Sie schreiben. Verifizierte Unterschriften haben " 688 - "mehr Gewicht!" 1022 + "Geben Sie Ihre vollstรคndige Adresse ein, um prรคzise Wahlkreis- und " 1023 + "Abgeordnetenempfehlungen zu erhalten." 689 1024 690 - #: letters/templates/letters/profile.html:45 691 - msgid "Start Verification" 692 - msgstr "Verifizierung starten" 1025 + #: letters/templates/letters/profile.html:61 1026 + msgid "Gespeicherte Adresse:" 1027 + msgstr "Gespeicherte Adresse:" 1028 + 1029 + #: letters/templates/letters/profile.html:83 1030 + msgid "Adresse speichern" 1031 + msgstr "Adresse speichern" 693 1032 694 - #: letters/templates/letters/profile.html:51 1033 + #: letters/templates/letters/profile.html:88 1034 + msgid "Self-declare your constituency" 1035 + msgstr "Geben Sie Ihren Wahlkreis selbst an" 1036 + 1037 + #: letters/templates/letters/profile.html:90 1038 + msgid "" 1039 + "Select the constituencies you live in so we can prioritise the right " 1040 + "representatives." 1041 + msgstr "" 1042 + "Wรคhlen Sie die Wahlkreise aus, in denen Sie leben, damit wir die richtigen " 1043 + "Abgeordneten priorisieren kรถnnen." 1044 + 1045 + #: letters/templates/letters/profile.html:109 1046 + msgid "Save constituencies" 1047 + msgstr "Wahlkreise speichern" 1048 + 1049 + #: letters/templates/letters/profile.html:114 695 1050 msgid "Your Letters" 696 1051 msgstr "Ihre Briefe" 697 1052 698 - #: letters/templates/letters/profile.html:57 1053 + #: letters/templates/letters/profile.html:120 699 1054 msgid "You haven't written any letters yet." 700 1055 msgstr "Sie haben noch keine Briefe geschrieben." 701 1056 702 - #: letters/templates/letters/profile.html:57 1057 + #: letters/templates/letters/profile.html:120 703 1058 msgid "Write one now!" 704 1059 msgstr "Schreiben Sie jetzt einen!" 705 1060 706 - #: letters/templates/letters/profile.html:62 1061 + #: letters/templates/letters/profile.html:125 707 1062 msgid "Letters You've Signed" 708 1063 msgstr "Briefe, die Sie unterzeichnet haben" 709 1064 710 - # Letter detail page 711 - #: letters/templates/letters/profile.html:68 712 - msgid "Signed on" 713 - msgstr "Unterzeichnet am" 714 - 715 - #: letters/templates/letters/profile.html:71 716 - msgid "Your comment:" 717 - msgstr "Ihr Kommentar:" 718 - 719 - #: letters/templates/letters/profile.html:76 1065 + #: letters/templates/letters/profile.html:133 720 1066 msgid "You haven't signed any letters yet." 721 1067 msgstr "Sie haben noch keine Briefe unterzeichnet." 722 1068 723 - #: letters/templates/letters/profile.html:76 1069 + #: letters/templates/letters/profile.html:133 724 1070 msgid "Browse letters" 725 1071 msgstr "Briefe durchsuchen" 726 1072 727 - #: letters/templates/letters/register.html:22 728 - #: letters/templates/letters/representative_detail.html:56 1073 + #: letters/templates/letters/profile.html:138 1074 + msgid "Account" 1075 + msgstr "Konto" 1076 + 1077 + #: letters/templates/letters/profile.html:140 1078 + msgid "" 1079 + "Need a fresh start? You can delete your account at any time. Your letters " 1080 + "stay visible but without your name." 1081 + msgstr "" 1082 + "Brauchen Sie einen Neuanfang? Sie kรถnnen Ihr Konto jederzeit lรถschen. Ihre Briefe " 1083 + "bleiben sichtbar, aber ohne Ihren Namen." 1084 + 1085 + #: letters/templates/letters/register.html:9 1086 + msgid "" 1087 + "After registration we'll send you an email to confirm your address before " 1088 + "you can sign in." 1089 + msgstr "" 1090 + "Nach der Registrierung senden wir Ihnen eine E-Mail, um Ihre Adresse zu bestรคtigen, " 1091 + "bevor Sie sich anmelden kรถnnen." 1092 + 1093 + #: letters/templates/letters/register.html:23 729 1094 msgid "Email:" 730 1095 msgstr "E-Mail:" 731 1096 732 1097 # Register page 733 - #: letters/templates/letters/register.html:30 1098 + #: letters/templates/letters/register.html:31 734 1099 msgid "First Name (optional):" 735 1100 msgstr "Vorname (optional):" 736 1101 737 - #: letters/templates/letters/register.html:38 1102 + #: letters/templates/letters/register.html:39 738 1103 msgid "Last Name (optional):" 739 1104 msgstr "Nachname (optional):" 740 1105 741 - #: letters/templates/letters/register.html:54 1106 + #: letters/templates/letters/register.html:55 742 1107 msgid "Confirm Password:" 743 1108 msgstr "Passwort bestรคtigen:" 744 1109 745 - #: letters/templates/letters/register.html:69 1110 + #: letters/templates/letters/register.html:70 746 1111 msgid "Already have an account?" 747 1112 msgstr "Bereits ein Konto?" 748 1113 749 - #: letters/templates/letters/register.html:69 1114 + #: letters/templates/letters/register.html:70 750 1115 msgid "Login here" 751 1116 msgstr "Hier anmelden" 752 1117 753 - # Representative detail 754 1118 #: letters/templates/letters/representative_detail.html:19 755 - msgid "Party:" 756 - msgstr "Partei:" 757 - 758 - #: letters/templates/letters/representative_detail.html:27 759 - msgid "Legislative Body:" 760 - msgstr "Parlament:" 761 - 762 - #: letters/templates/letters/representative_detail.html:32 763 - msgid "Role:" 764 - msgstr "Rolle:" 765 - 766 - #: letters/templates/letters/representative_detail.html:38 767 - msgid "Term:" 768 - msgstr "Amtszeit:" 769 - 770 - #: letters/templates/letters/representative_detail.html:43 771 - msgid "Present" 772 - msgstr "Heute" 773 - 774 - #: letters/templates/letters/representative_detail.html:47 775 - msgid "Status:" 776 - msgstr "Status:" 777 - 778 - #: letters/templates/letters/representative_detail.html:49 779 - #: letters/templates/letters/representative_detail.html:98 780 - msgid "Active" 781 - msgstr "Aktiv" 1119 + msgid "รœber" 1120 + msgstr "รœber" 782 1121 783 - #: letters/templates/letters/representative_detail.html:51 784 - msgid "Inactive" 785 - msgstr "Inaktiv" 786 - 787 - #: letters/templates/letters/representative_detail.html:62 788 - msgid "Website:" 789 - msgstr "Webseite:" 790 - 791 - #: letters/templates/letters/representative_detail.html:75 1122 + #: letters/templates/letters/representative_detail.html:30 792 1123 msgid "Committee Memberships" 793 1124 msgstr "Ausschussmitgliedschaften" 794 1125 795 - #: letters/templates/letters/representative_detail.html:113 796 - msgid "Policy Competences" 797 - msgstr "Politische Kompetenzen" 798 - 799 - #: letters/templates/letters/representative_detail.html:117 800 - msgid "" 801 - "Based on committee memberships, this representative works on the following " 802 - "policy areas:" 803 - msgstr "" 804 - "Basierend auf Ausschussmitgliedschaften arbeitet dieser Abgeordnete in " 805 - "folgenden Politikbereichen:" 806 - 807 - #: letters/templates/letters/representative_detail.html:134 1126 + #: letters/templates/letters/representative_detail.html:75 808 1127 msgid "Open Letters" 809 1128 msgstr "Offene Briefe" 810 1129 811 - #: letters/templates/letters/representative_detail.html:142 1130 + #: letters/templates/letters/representative_detail.html:83 812 1131 msgid "No letters have been written to this representative yet." 813 1132 msgstr "An diesen Abgeordneten wurden noch keine Briefe geschrieben." 814 1133 815 - #: letters/templates/letters/representative_detail.html:144 1134 + #: letters/templates/letters/representative_detail.html:85 816 1135 msgid "Write the First Letter" 817 1136 msgstr "Ersten Brief schreiben" 818 1137 819 - #: letters/templates/letters/representative_detail.html:156 1138 + #: letters/templates/letters/representative_detail.html:95 820 1139 msgid "External Resources" 821 1140 msgstr "Externe Ressourcen" 822 1141 823 - #: letters/templates/letters/representative_detail.html:161 1142 + #: letters/templates/letters/representative_detail.html:100 824 1143 msgid "Abgeordnetenwatch Profile" 825 1144 msgstr "Abgeordnetenwatch-Profil" 826 1145 827 - #: letters/templates/letters/representative_detail.html:163 1146 + #: letters/templates/letters/representative_detail.html:102 828 1147 msgid "" 829 1148 "View voting record, questions, and detailed profile on Abgeordnetenwatch.de" 830 1149 msgstr "" 831 1150 "Abstimmungsverhalten, Fragen und detailliertes Profil auf Abgeordnetenwatch." 832 1151 "de ansehen" 833 1152 834 - #: letters/templates/letters/representative_detail.html:166 835 - msgid "View on Abgeordnetenwatch" 836 - msgstr "Auf Abgeordnetenwatch ansehen" 1153 + #: letters/templates/letters/representative_detail.html:112 1154 + msgid "Wikipedia Article" 1155 + msgstr "Wikipedia-Artikel" 837 1156 838 - #: letters/templates/letters/representative_detail.html:173 839 - msgid "Wikipedia Article (German)" 840 - msgstr "Wikipedia-Artikel (Deutsch)" 841 - 842 - #: letters/templates/letters/representative_detail.html:175 1157 + #: letters/templates/letters/representative_detail.html:114 843 1158 msgid "Read more about this representative on Wikipedia" 844 1159 msgstr "Mehr รผber diesen Abgeordneten auf Wikipedia lesen" 845 1160 846 - #: letters/templates/letters/representative_detail.html:178 1161 + #: letters/templates/letters/representative_detail.html:117 847 1162 msgid "View on Wikipedia" 848 1163 msgstr "Auf Wikipedia ansehen" 849 1164 850 - #: letters/templates/letters/representative_detail.html:184 1165 + #: letters/templates/letters/representative_detail.html:123 851 1166 msgid "No external resources available for this representative." 852 1167 msgstr "Keine externen Ressourcen fรผr diesen Abgeordneten verfรผgbar." 853 1168 854 - #: letters/templates/letters/representative_detail.html:192 1169 + #: letters/templates/letters/representative_detail.html:130 1170 + msgid "Kontakt" 1171 + msgstr "Kontakt" 1172 + 1173 + #: letters/templates/letters/representative_detail.html:145 855 1174 #, python-format 856 1175 msgid "Start a new open letter to %(name)s" 857 1176 msgstr "Einen neuen offenen Brief an %(name)s beginnen" 858 1177 859 - #: letters/templates/letters/representative_detail.html:200 1178 + #: letters/templates/letters/representative_detail.html:153 860 1179 msgid "Login to Write Letter" 861 1180 msgstr "Anmelden um Brief zu schreiben" 862 1181 1182 + #: letters/views.py:52 1183 + msgid "Confirm your WriteThem.eu account" 1184 + msgstr "Bestรคtigen Sie Ihr WriteThem.eu-Konto" 1185 + 863 1186 # Flash messages 864 - #: letters/views.py:158 1187 + #: letters/views.py:184 865 1188 msgid "Your letter has been published and your signature has been added!" 866 - msgstr "" 867 - "Ihr Brief wurde verรถffentlicht und Ihre Unterschrift wurde hinzugefรผgt!" 1189 + msgstr "Ihr Brief wurde verรถffentlicht und Ihre Unterschrift wurde hinzugefรผgt!" 868 1190 869 - #: letters/views.py:171 1191 + #: letters/views.py:197 870 1192 msgid "You have already signed this letter." 871 1193 msgstr "Sie haben diesen Brief bereits unterzeichnet." 872 1194 873 - #: letters/views.py:181 1195 + #: letters/views.py:207 874 1196 msgid "Your signature has been added!" 875 1197 msgstr "Ihre Unterschrift wurde hinzugefรผgt!" 876 1198 877 - #: letters/views.py:199 1199 + #: letters/views.py:225 878 1200 msgid "Thank you for your report. Our team will review it." 879 1201 msgstr "Vielen Dank fรผr Ihre Meldung. Unser Team wird sie รผberprรผfen." 880 1202 881 - #: letters/views.py:226 882 - #, python-format 883 - msgid "Welcome, %(username)s! Your account has been created." 884 - msgstr "Willkommen, %(username)s! Ihr Konto wurde erstellt." 1203 + #: letters/views.py:259 1204 + msgid "" 1205 + "Please confirm your email address. We sent you a link to activate your " 1206 + "account." 1207 + msgstr "" 1208 + "Bitte bestรคtigen Sie Ihre E-Mail-Adresse. Wir haben Ihnen einen Link zum Aktivieren " 1209 + "Ihres Kontos gesendet." 885 1210 886 - #~ msgid "Previous" 887 - #~ msgstr "Zurรผck" 1211 + #: letters/views.py:289 1212 + msgid "Your account has been activated. You can now log in." 1213 + msgstr "Ihr Konto wurde aktiviert. Sie kรถnnen sich jetzt anmelden." 1214 + 1215 + #: letters/views.py:292 1216 + msgid "Your account is already active." 1217 + msgstr "Ihr Konto ist bereits aktiv." 888 1218 889 - #~ msgid "Next" 890 - #~ msgstr "Weiter" 1219 + #: letters/views.py:347 1220 + msgid "Ihre Adresse wurde gespeichert." 1221 + msgstr "Ihre Adresse wurde gespeichert." 1222 + 1223 + #: letters/views.py:363 1224 + msgid "Your constituency information has been updated." 1225 + msgstr "Ihre Wahlkreisinformationen wurden aktualisiert." 1226 + 1227 + #: letters/views.py:391 1228 + msgid "" 1229 + "Your account has been deleted. Your published letters remain available to " 1230 + "the public." 1231 + msgstr "" 1232 + "Ihr Konto wurde gelรถscht. Ihre verรถffentlichten Briefe bleiben fรผr die " 1233 + "ร–ffentlichkeit verfรผgbar." 1234 + 1235 + # Forms 1236 + #~ msgid "Postal code (PLZ)" 1237 + #~ msgstr "Postleitzahl (PLZ)" 1238 + 1239 + #, fuzzy 1240 + #~| msgid "Use your PLZ to narrow down representatives from your constituency." 1241 + #~ msgid "Use your PLZ to narrow down representatives from your parliament." 1242 + #~ msgstr "" 1243 + #~ "Verwenden Sie Ihre PLZ, um Abgeordnete aus Ihrem Wahlkreis einzugrenzen." 1244 + 1245 + #~ msgid "Comma-separated tags (e.g., \"climate, transport, education\")" 1246 + #~ msgstr "Komma-getrennte Schlagwรถrter (z.B. \"Klima, Verkehr, Bildung\")" 1247 + 1248 + #~ msgid "climate, transport, education" 1249 + #~ msgstr "Klima, Verkehr, Bildung" 1250 + 1251 + #~ msgid "Write your letter here" 1252 + #~ msgstr "Schreiben Sie hier Ihren Brief" 1253 + 1254 + #, fuzzy 1255 + #~| msgid "Federal" 1256 + #~ msgid "Federal district" 1257 + #~ msgstr "Bund" 1258 + 1259 + #~ msgid "Your postal code (PLZ):" 1260 + #~ msgstr "Ihre Postleitzahl (PLZ):" 1261 + 1262 + #~ msgid "Or select from suggestions on the right โ†’" 1263 + #~ msgstr "Oder wรคhlen Sie aus den Vorschlรคgen rechts โ†’" 1264 + 1265 + #~ msgid "Tags (optional):" 1266 + #~ msgstr "Schlagwรถrter (optional):" 891 1267 892 1268 #, python-format 893 - #~ msgid "Page %(page)s of %(total)s" 894 - #~ msgstr "Seite %(page)s von %(total)s" 1269 + #~ msgid "" 1270 + #~ "Based on the topic \"%(topic)s\", we suggest contacting representatives " 1271 + #~ "from the %(level)s:" 1272 + #~ msgstr "" 1273 + #~ "Basierend auf dem Thema \"%(topic)s\" empfehlen wir, Abgeordnete des " 1274 + #~ "%(level)s zu kontaktieren:" 1275 + 1276 + #, fuzzy 1277 + #~| msgid "Suggested Representatives" 1278 + #~ msgid "Suggested Tags" 1279 + #~ msgstr "Vorgeschlagene Abgeordnete" 1280 + 1281 + #~ msgid "Similar Letters" 1282 + #~ msgstr "ร„hnliche Briefe" 1283 + 1284 + #~ msgid "Others have written about similar topics:" 1285 + #~ msgstr "Andere haben รผber รคhnliche Themen geschrieben:" 895 1286 896 - #~ msgid "No letters found." 897 - #~ msgstr "Keine Briefe gefunden." 1287 + #, fuzzy 1288 + #~| msgid "To" 1289 + #~ msgid "To:" 1290 + #~ msgstr "An" 1291 + 1292 + #~ msgid "Identity Verification" 1293 + #~ msgstr "Identitรคtsverifizierung" 898 1294 899 - #~ msgid "Be the first to write one!" 900 - #~ msgstr "Schreiben Sie den ersten!" 1295 + #~ msgid "Your signatures will be marked as verified constituent signatures." 1296 + #~ msgstr "" 1297 + #~ "Ihre Unterschriften werden als verifizierte Wรคhlerunterschriften markiert." 1298 + 1299 + #~ msgid "Verification Pending" 1300 + #~ msgstr "Verifizierung ausstehend" 1301 + 1302 + #~ msgid "Verification Failed" 1303 + #~ msgstr "Verifizierung fehlgeschlagen" 1304 + 1305 + # Letter detail page 1306 + #~ msgid "Signed on" 1307 + #~ msgstr "Unterzeichnet am" 1308 + 1309 + #~ msgid "Your comment:" 1310 + #~ msgstr "Ihr Kommentar:" 1311 + 1312 + # Representative detail 1313 + #~ msgid "Party:" 1314 + #~ msgstr "Partei:" 1315 + 1316 + #~ msgid "Legislative Body:" 1317 + #~ msgstr "Parlament:" 1318 + 1319 + #~ msgid "Present" 1320 + #~ msgstr "Heute" 1321 + 1322 + #~ msgid "Inactive" 1323 + #~ msgstr "Inaktiv" 1324 + 1325 + #~ msgid "" 1326 + #~ "Based on committee memberships, this representative works on the " 1327 + #~ "following policy areas:" 1328 + #~ msgstr "" 1329 + #~ "Basierend auf Ausschussmitgliedschaften arbeitet dieser Abgeordnete in " 1330 + #~ "folgenden Politikbereichen:" 1331 + 1332 + #, python-format 1333 + #~ msgid "Welcome, %(username)s! Your account has been created." 1334 + #~ msgstr "Willkommen, %(username)s! Ihr Konto wurde erstellt." 901 1335 902 1336 #, python-format 903 1337 #~ msgid "<strong>%(counter)s</strong> other verified" ··· 915 1349 916 1350 #~ msgid "Committee Memberships:" 917 1351 #~ msgstr "Ausschussmitgliedschaften:" 918 - 919 - #~ msgid "Policy Areas:" 920 - #~ msgstr "Politikbereiche:" 921 1352 922 1353 # Services explanations 923 1354 #~ msgid "No matching policy areas found. Please try different keywords."
website/locale/en/LC_MESSAGES/.gitkeep

This is a binary file and will not be displayed.

+1218
website/locale/en/LC_MESSAGES/django.po
··· 1 + # SOME DESCRIPTIVE TITLE. 2 + # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 + # This file is distributed under the same license as the PACKAGE package. 4 + # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. 5 + # 6 + #, fuzzy 7 + msgid "" 8 + msgstr "" 9 + "Project-Id-Version: PACKAGE VERSION\n" 10 + "Report-Msgid-Bugs-To: \n" 11 + "POT-Creation-Date: 2025-10-15 00:28+0200\n" 12 + "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 + "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" 14 + "Language-Team: LANGUAGE <LL@li.org>\n" 15 + "Language: \n" 16 + "MIME-Version: 1.0\n" 17 + "Content-Type: text/plain; charset=UTF-8\n" 18 + "Content-Transfer-Encoding: 8bit\n" 19 + "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 + 21 + #: letters/admin.py:75 22 + msgid "Mandate Details" 23 + msgstr "Mandate Details" 24 + 25 + #: letters/admin.py:78 26 + msgid "Focus Areas" 27 + msgstr "Focus Areas" 28 + 29 + #: letters/admin.py:81 letters/admin.py:95 30 + msgid "Photo" 31 + msgstr "Photo" 32 + 33 + #: letters/admin.py:85 34 + msgid "Metadata" 35 + msgstr "Metadata" 36 + 37 + #: letters/admin.py:94 38 + msgid "No photo" 39 + msgstr "No photo" 40 + 41 + #: letters/admin.py:167 42 + msgid "Topic Areas" 43 + msgstr "Topic Areas" 44 + 45 + #: letters/forms.py:25 46 + msgid "" 47 + "An account with this email already exists. If you registered before, please " 48 + "check your inbox for the activation link or reset your password." 49 + msgstr "" 50 + "An account with this email already exists. If you registered before, please " 51 + "check your inbox for the activation link or reset your password." 52 + 53 + #: letters/forms.py:37 54 + msgid "Title" 55 + msgstr "Title" 56 + 57 + #: letters/forms.py:38 58 + msgid "Letter Body" 59 + msgstr "Letter Body" 60 + 61 + #: letters/forms.py:39 62 + msgid "To Representative" 63 + msgstr "To Representative" 64 + 65 + #: letters/forms.py:42 66 + msgid "Describe your concern briefly" 67 + msgstr "Describe your concern briefly" 68 + 69 + #: letters/forms.py:43 70 + msgid "" 71 + "Write your letter here. Markdown formatting (e.g. **bold**, _italic_) is " 72 + "supported." 73 + msgstr "" 74 + "Write your letter here. Markdown formatting (e.g. **bold**, _italic_) is " 75 + "supported." 76 + 77 + #: letters/forms.py:46 78 + msgid "Letter title" 79 + msgstr "Letter title" 80 + 81 + #: letters/forms.py:50 82 + msgid "Write your letter here..." 83 + msgstr "Write your letter here..." 84 + 85 + #: letters/forms.py:110 86 + msgid "Comment (optional)" 87 + msgstr "Comment (optional)" 88 + 89 + #: letters/forms.py:113 90 + msgid "Add a personal note to your signature" 91 + msgstr "Add a personal note to your signature" 92 + 93 + #: letters/forms.py:119 94 + msgid "Optional: Add your comment..." 95 + msgstr "Optional: Add your comment..." 96 + 97 + #: letters/forms.py:131 98 + msgid "Reason" 99 + msgstr "Reason" 100 + 101 + #: letters/forms.py:132 102 + msgid "Description" 103 + msgstr "Description" 104 + 105 + #: letters/forms.py:135 106 + msgid "Please provide details about why you are reporting this letter" 107 + msgstr "Please provide details about why you are reporting this letter" 108 + 109 + #: letters/forms.py:142 110 + msgid "Please describe the issue..." 111 + msgstr "Please describe the issue..." 112 + 113 + #: letters/forms.py:152 letters/templates/letters/letter_list.html:19 114 + msgid "Search" 115 + msgstr "Search" 116 + 117 + #: letters/forms.py:155 letters/templates/letters/letter_list.html:18 118 + msgid "Search letters..." 119 + msgstr "Search letters..." 120 + 121 + #: letters/forms.py:161 122 + msgid "Tag" 123 + msgstr "Tag" 124 + 125 + #: letters/forms.py:164 126 + msgid "Filter by tag..." 127 + msgstr "Filter by tag..." 128 + 129 + #: letters/forms.py:180 130 + msgid "Bundestag constituency" 131 + msgstr "Bundestag constituency" 132 + 133 + #: letters/forms.py:181 134 + msgid "Pick your Bundestag direct mandate constituency (Wahlkreis)." 135 + msgstr "Pick your Bundestag direct mandate constituency (Wahlkreis)." 136 + 137 + #: letters/forms.py:182 letters/forms.py:189 138 + msgid "Select constituency" 139 + msgstr "Select constituency" 140 + 141 + #: letters/forms.py:187 142 + msgid "State parliament constituency" 143 + msgstr "State parliament constituency" 144 + 145 + #: letters/forms.py:188 146 + msgid "Optionally pick your Landtag constituency if applicable." 147 + msgstr "Optionally pick your Landtag constituency if applicable." 148 + 149 + #: letters/forms.py:240 150 + msgid "Please select at least one constituency to save your profile." 151 + msgstr "Please select at least one constituency to save your profile." 152 + 153 + #: letters/forms.py:252 154 + msgid "StraรŸe und Hausnummer" 155 + msgstr "StraรŸe und Hausnummer" 156 + 157 + #: letters/forms.py:255 158 + msgid "z.B. Unter den Linden 77" 159 + msgstr "z.B. Unter den Linden 77" 160 + 161 + #: letters/forms.py:261 162 + msgid "Postleitzahl" 163 + msgstr "Postleitzahl" 164 + 165 + #: letters/forms.py:264 166 + msgid "z.B. 10117" 167 + msgstr "z.B. 10117" 168 + 169 + #: letters/forms.py:270 170 + msgid "Stadt" 171 + msgstr "Stadt" 172 + 173 + #: letters/forms.py:273 174 + msgid "z.B. Berlin" 175 + msgstr "z.B. Berlin" 176 + 177 + #: letters/forms.py:304 178 + msgid "" 179 + "Bitte geben Sie eine vollstรคndige Adresse ein (StraรŸe, PLZ und Stadt) oder " 180 + "lassen Sie alle Felder leer." 181 + msgstr "" 182 + "Bitte geben Sie eine vollstรคndige Adresse ein (StraรŸe, PLZ und Stadt) oder " 183 + "lassen Sie alle Felder leer." 184 + 185 + #: letters/models.py:15 186 + msgid "European Union" 187 + msgstr "European Union" 188 + 189 + #: letters/models.py:16 190 + msgid "Federal" 191 + msgstr "Federal" 192 + 193 + #: letters/models.py:17 194 + msgid "State" 195 + msgstr "State" 196 + 197 + #: letters/models.py:18 198 + msgid "Local" 199 + msgstr "Local" 200 + 201 + #: letters/models.py:21 202 + msgid "Name of the parliament" 203 + msgstr "Name of the parliament" 204 + 205 + #: letters/models.py:25 206 + msgid "e.g., 'Bundestag', 'Bayerischer Landtag', 'Gemeinderat Mรผnchen'" 207 + msgstr "e.g., 'Bundestag', 'Bayerischer Landtag', 'Gemeinderat Mรผnchen'" 208 + 209 + #: letters/models.py:29 210 + msgid "Geographic identifier (state code, municipality code, etc.)" 211 + msgstr "Geographic identifier (state code, municipality code, etc.)" 212 + 213 + #: letters/models.py:37 214 + msgid "For hierarchical relationships (e.g., local within state)" 215 + msgstr "For hierarchical relationships (e.g., local within state)" 216 + 217 + #: letters/models.py:43 letters/models.py:70 letters/models.py:105 218 + #: letters/models.py:165 letters/models.py:372 letters/models.py:421 219 + msgid "Last time this was synced from external API" 220 + msgstr "Last time this was synced from external API" 221 + 222 + #: letters/models.py:47 letters/templates/letters/committee_detail.html:70 223 + msgid "Parliament" 224 + msgstr "Parliament" 225 + 226 + #: letters/models.py:48 227 + msgid "Parliaments" 228 + msgstr "Parliaments" 229 + 230 + #: letters/models.py:84 231 + msgid "Federal electoral district" 232 + msgstr "Federal electoral district" 233 + 234 + #: letters/models.py:85 235 + msgid "Bundestag state list" 236 + msgstr "Bundestag state list" 237 + 238 + #: letters/models.py:86 239 + msgid "Bundestag federal list" 240 + msgstr "Bundestag federal list" 241 + 242 + #: letters/models.py:87 243 + msgid "State electoral district" 244 + msgstr "State electoral district" 245 + 246 + #: letters/models.py:88 247 + msgid "State regional list" 248 + msgstr "State regional list" 249 + 250 + #: letters/models.py:89 251 + msgid "State wide list" 252 + msgstr "State wide list" 253 + 254 + #: letters/models.py:90 255 + msgid "EU at large" 256 + msgstr "EU at large" 257 + 258 + #: letters/models.py:119 259 + msgid "Direct mandate" 260 + msgstr "Direct mandate" 261 + 262 + #: letters/models.py:120 263 + msgid "State list mandate" 264 + msgstr "State list mandate" 265 + 266 + #: letters/models.py:121 267 + msgid "State regional list mandate" 268 + msgstr "State regional list mandate" 269 + 270 + #: letters/models.py:122 271 + msgid "Federal list mandate" 272 + msgstr "Federal list mandate" 273 + 274 + #: letters/models.py:123 275 + msgid "EU list mandate" 276 + msgstr "EU list mandate" 277 + 278 + #: letters/models.py:444 279 + msgid "Draft" 280 + msgstr "Draft" 281 + 282 + #: letters/models.py:445 283 + msgid "Published" 284 + msgstr "Published" 285 + 286 + #: letters/models.py:446 287 + msgid "Flagged for Review" 288 + msgstr "Flagged for Review" 289 + 290 + #: letters/models.py:447 291 + msgid "Removed" 292 + msgstr "Removed" 293 + 294 + #: letters/models.py:487 295 + msgid "Deleted user" 296 + msgstr "Deleted user" 297 + 298 + #: letters/services.py:2451 299 + #, python-format 300 + msgid "Detected policy area: %(topic)s." 301 + msgstr "Detected policy area: %(topic)s." 302 + 303 + #: letters/services.py:2456 304 + #, python-format 305 + msgid "Prioritising representatives for %(constituencies)s." 306 + msgstr "Prioritising representatives for %(constituencies)s." 307 + 308 + #: letters/services.py:2461 309 + #, python-format 310 + msgid "Filtering by state %(state)s." 311 + msgstr "Filtering by state %(state)s." 312 + 313 + #: letters/services.py:2465 314 + #, python-format 315 + msgid "" 316 + "Postal code %(plz)s had no direct match; showing broader representatives." 317 + msgstr "" 318 + "Postal code %(plz)s had no direct match; showing broader representatives." 319 + 320 + #: letters/services.py:2469 321 + msgid "Showing generally relevant representatives." 322 + msgstr "Showing generally relevant representatives." 323 + 324 + #: letters/templates/letters/account_activation_invalid.html:4 325 + #: letters/templates/letters/account_activation_invalid.html:8 326 + msgid "Activation link invalid" 327 + msgstr "Activation link invalid" 328 + 329 + #: letters/templates/letters/account_activation_invalid.html:10 330 + msgid "" 331 + "We could not verify your activation link. It may have already been used or " 332 + "expired." 333 + msgstr "" 334 + "We could not verify your activation link. It may have already been used or " 335 + "expired." 336 + 337 + #: letters/templates/letters/account_activation_invalid.html:13 338 + msgid "" 339 + "If you still cannot access your account, try registering again or contact " 340 + "support." 341 + msgstr "" 342 + "If you still cannot access your account, try registering again or contact " 343 + "support." 344 + 345 + #: letters/templates/letters/account_activation_invalid.html:15 346 + msgid "Register again" 347 + msgstr "Register again" 348 + 349 + #: letters/templates/letters/account_activation_sent.html:4 350 + msgid "Activate your account" 351 + msgstr "Activate your account" 352 + 353 + #: letters/templates/letters/account_activation_sent.html:8 354 + #: letters/templates/letters/password_reset_done.html:8 355 + msgid "Check your inbox" 356 + msgstr "Check your inbox" 357 + 358 + #: letters/templates/letters/account_activation_sent.html:10 359 + msgid "" 360 + "We sent you an email with a confirmation link. Please click it to activate " 361 + "your account." 362 + msgstr "" 363 + "We sent you an email with a confirmation link. Please click it to activate " 364 + "your account." 365 + 366 + #: letters/templates/letters/account_activation_sent.html:13 367 + msgid "" 368 + "If you do not receive the email within a few minutes, check your spam folder " 369 + "or try registering again." 370 + msgstr "" 371 + "If you do not receive the email within a few minutes, check your spam folder " 372 + "or try registering again." 373 + 374 + #: letters/templates/letters/account_activation_sent.html:15 375 + msgid "Back to homepage" 376 + msgstr "Back to homepage" 377 + 378 + #: letters/templates/letters/account_delete_confirm.html:4 379 + #: letters/templates/letters/profile.html:142 380 + msgid "Delete account" 381 + msgstr "Delete account" 382 + 383 + #: letters/templates/letters/account_delete_confirm.html:8 384 + msgid "Delete your account" 385 + msgstr "Delete your account" 386 + 387 + #: letters/templates/letters/account_delete_confirm.html:10 388 + msgid "" 389 + "Deleting your account will remove your personal data and signatures. Letters " 390 + "you have published stay online but are shown without your name." 391 + msgstr "" 392 + "Deleting your account will remove your personal data and signatures. Letters " 393 + "you have published stay online but are shown without your name." 394 + 395 + #: letters/templates/letters/account_delete_confirm.html:14 396 + msgid "Yes, delete my account" 397 + msgstr "Yes, delete my account" 398 + 399 + #: letters/templates/letters/account_delete_confirm.html:15 400 + #: letters/templates/letters/letter_form.html:61 401 + msgid "Cancel" 402 + msgstr "Cancel" 403 + 404 + #: letters/templates/letters/base.html:140 405 + msgid "Letters" 406 + msgstr "Letters" 407 + 408 + #: letters/templates/letters/base.html:141 409 + msgid "Competencies" 410 + msgstr "Competencies" 411 + 412 + #: letters/templates/letters/base.html:143 413 + #: letters/templates/letters/representative_detail.html:149 414 + msgid "Write Letter" 415 + msgstr "Write Letter" 416 + 417 + #: letters/templates/letters/base.html:144 418 + #: letters/templates/letters/profile.html:4 419 + msgid "Profile" 420 + msgstr "Profile" 421 + 422 + #: letters/templates/letters/base.html:145 423 + msgid "Logout" 424 + msgstr "Logout" 425 + 426 + #: letters/templates/letters/base.html:147 427 + #: letters/templates/letters/letter_detail.html:47 428 + #: letters/templates/letters/letter_detail.html:81 429 + #: letters/templates/letters/login.html:4 430 + #: letters/templates/letters/login.html:8 431 + #: letters/templates/letters/login.html:33 432 + msgid "Login" 433 + msgstr "Login" 434 + 435 + #: letters/templates/letters/base.html:148 436 + #: letters/templates/letters/register.html:4 437 + #: letters/templates/letters/register.html:8 438 + #: letters/templates/letters/register.html:66 439 + msgid "Register" 440 + msgstr "Register" 441 + 442 + #: letters/templates/letters/base.html:151 443 + msgid "Admin" 444 + msgstr "Admin" 445 + 446 + #: letters/templates/letters/base.html:157 447 + msgid "Select language" 448 + msgstr "Select language" 449 + 450 + #: letters/templates/letters/base.html:182 451 + msgid "Empowering citizens to write to their representatives" 452 + msgstr "Empowering citizens to write to their representatives" 453 + 454 + #: letters/templates/letters/committee_detail.html:22 455 + msgid "Related Topics" 456 + msgstr "Related Topics" 457 + 458 + #: letters/templates/letters/committee_detail.html:36 459 + msgid "Members" 460 + msgstr "Members" 461 + 462 + #: letters/templates/letters/committee_detail.html:46 463 + msgid "Role" 464 + msgstr "Role" 465 + 466 + #: letters/templates/letters/committee_detail.html:48 467 + #: letters/templates/letters/representative_detail.html:61 468 + msgid "Active" 469 + msgstr "Active" 470 + 471 + #: letters/templates/letters/committee_detail.html:51 472 + #: letters/templates/letters/partials/representative_card.html:29 473 + msgid "Since" 474 + msgstr "Since" 475 + 476 + #: letters/templates/letters/committee_detail.html:58 477 + msgid "No members recorded for this committee." 478 + msgstr "No members recorded for this committee." 479 + 480 + #: letters/templates/letters/committee_detail.html:67 481 + msgid "Committee Info" 482 + msgstr "Committee Info" 483 + 484 + #: letters/templates/letters/committee_detail.html:71 485 + msgid "Term" 486 + msgstr "Term" 487 + 488 + #: letters/templates/letters/committee_detail.html:74 489 + #: letters/templates/letters/representative_detail.html:105 490 + msgid "View on Abgeordnetenwatch" 491 + msgstr "View on Abgeordnetenwatch" 492 + 493 + #: letters/templates/letters/letter_detail.html:10 494 + #: letters/templates/letters/partials/letter_card.html:5 495 + msgid "By" 496 + msgstr "By" 497 + 498 + #: letters/templates/letters/letter_detail.html:29 499 + #, python-format 500 + msgid "Signatures (%(counter)s)" 501 + msgid_plural "Signatures (%(counter)s)" 502 + msgstr[0] "Signatures (%(counter)s)" 503 + msgstr[1] "Signatures (%(counter)s)" 504 + 505 + #: letters/templates/letters/letter_detail.html:31 506 + #, python-format 507 + msgid "%(counter)s constituent of %(constituency_name)s" 508 + msgid_plural "%(counter)s constituents of %(constituency_name)s" 509 + msgstr[0] "%(counter)s constituent of %(constituency_name)s" 510 + msgstr[1] "%(counter)s constituents of %(constituency_name)s" 511 + 512 + #: letters/templates/letters/letter_detail.html:32 513 + #, python-format 514 + msgid "%(counter)s other verified" 515 + msgid_plural "%(counter)s other verified" 516 + msgstr[0] "%(counter)s other verified" 517 + msgstr[1] "%(counter)s other verified" 518 + 519 + #: letters/templates/letters/letter_detail.html:33 520 + #, python-format 521 + msgid "%(counter)s unverified" 522 + msgid_plural "%(counter)s unverified" 523 + msgstr[0] "%(counter)s unverified" 524 + msgstr[1] "%(counter)s unverified" 525 + 526 + #: letters/templates/letters/letter_detail.html:41 527 + msgid "Sign this letter" 528 + msgstr "Sign this letter" 529 + 530 + #: letters/templates/letters/letter_detail.html:44 531 + msgid "You have signed this letter" 532 + msgstr "You have signed this letter" 533 + 534 + #: letters/templates/letters/letter_detail.html:47 535 + msgid "to sign this letter" 536 + msgstr "to sign this letter" 537 + 538 + #: letters/templates/letters/letter_detail.html:58 539 + msgid "โœ“ Verified Constituent" 540 + msgstr "โœ“ Verified Constituent" 541 + 542 + #: letters/templates/letters/letter_detail.html:60 543 + msgid "โœ“ Verified" 544 + msgstr "โœ“ Verified" 545 + 546 + #: letters/templates/letters/letter_detail.html:70 547 + msgid "No signatures yet. Be the first to sign!" 548 + msgstr "No signatures yet. Be the first to sign!" 549 + 550 + #: letters/templates/letters/letter_detail.html:75 551 + #: letters/templates/letters/letter_detail.html:79 552 + msgid "Report this letter" 553 + msgstr "Report this letter" 554 + 555 + #: letters/templates/letters/letter_detail.html:76 556 + msgid "If you believe this letter violates our guidelines, please report it." 557 + msgstr "If you believe this letter violates our guidelines, please report it." 558 + 559 + #: letters/templates/letters/letter_detail.html:81 560 + msgid "to report this letter" 561 + msgstr "to report this letter" 562 + 563 + #: letters/templates/letters/letter_detail.html:86 564 + msgid "Back to all letters" 565 + msgstr "Back to all letters" 566 + 567 + #: letters/templates/letters/letter_form.html:4 568 + #: letters/templates/letters/representative_detail.html:143 569 + msgid "Write a Letter" 570 + msgstr "Write a Letter" 571 + 572 + #: letters/templates/letters/letter_form.html:10 573 + msgid "Write an Open Letter" 574 + msgstr "Write an Open Letter" 575 + 576 + #: letters/templates/letters/letter_form.html:12 577 + msgid "" 578 + "Write an open letter to a political representative. Your letter will be " 579 + "published publicly so others can read and sign it." 580 + msgstr "" 581 + "Write an open letter to a political representative. Your letter will be " 582 + "published publicly so others can read and sign it." 583 + 584 + #: letters/templates/letters/letter_form.html:16 585 + msgid "Before you write" 586 + msgstr "Before you write" 587 + 588 + #: letters/templates/letters/letter_form.html:18 589 + msgid "Be thoughtful but feel free to be critical." 590 + msgstr "Be thoughtful but feel free to be critical." 591 + 592 + #: letters/templates/letters/letter_form.html:19 593 + msgid "Representatives are humans tooโ€”stay respectful." 594 + msgstr "Representatives are humans tooโ€”stay respectful." 595 + 596 + #: letters/templates/letters/letter_form.html:20 597 + msgid "Keep your arguments clear and concise." 598 + msgstr "Keep your arguments clear and concise." 599 + 600 + #: letters/templates/letters/letter_form.html:21 601 + msgid "No insults or hate speech." 602 + msgstr "No insults or hate speech." 603 + 604 + #: letters/templates/letters/letter_form.html:22 605 + msgid "Stay within the bounds of the Grundgesetz when making demands." 606 + msgstr "Stay within the bounds of the Grundgesetz when making demands." 607 + 608 + #: letters/templates/letters/letter_form.html:30 609 + msgid "Title:" 610 + msgstr "Title:" 611 + 612 + #: letters/templates/letters/letter_form.html:36 613 + msgid "" 614 + "Describe your concern in a sentence; we'll use it to suggest representatives." 615 + msgstr "" 616 + "Describe your concern in a sentence; we'll use it to suggest representatives." 617 + 618 + #: letters/templates/letters/letter_form.html:41 619 + msgid "To Representative:" 620 + msgstr "To Representative:" 621 + 622 + #: letters/templates/letters/letter_form.html:47 623 + msgid "" 624 + "Already know who to address? Pick them here. Otherwise, use the suggestions " 625 + "below." 626 + msgstr "" 627 + "Already know who to address? Pick them here. Otherwise, use the suggestions " 628 + "below." 629 + 630 + #: letters/templates/letters/letter_form.html:53 631 + msgid "Letter Body:" 632 + msgstr "Letter Body:" 633 + 634 + #: letters/templates/letters/letter_form.html:60 635 + msgid "Publish Letter" 636 + msgstr "Publish Letter" 637 + 638 + #: letters/templates/letters/letter_form.html:67 639 + msgid "Smart Suggestions" 640 + msgstr "Smart Suggestions" 641 + 642 + #: letters/templates/letters/letter_form.html:69 643 + msgid "" 644 + "Type your title and we'll use your verified profile to suggest " 645 + "representatives, topics, and related letters." 646 + msgstr "" 647 + "Type your title and we'll use your verified profile to suggest " 648 + "representatives, topics, and related letters." 649 + 650 + #: letters/templates/letters/letter_form.html:81 651 + msgid "Loading..." 652 + msgstr "Loading..." 653 + 654 + #: letters/templates/letters/letter_form.html:83 655 + msgid "Analyzing your title..." 656 + msgstr "Analyzing your title..." 657 + 658 + #: letters/templates/letters/letter_list.html:4 659 + msgid "Browse Letters" 660 + msgstr "Browse Letters" 661 + 662 + #: letters/templates/letters/letter_list.html:8 663 + msgid "About This" 664 + msgstr "About This" 665 + 666 + #: letters/templates/letters/letter_list.html:9 667 + msgid "" 668 + "Make your voice heard, reach out to your representative, participate in " 669 + "democracy." 670 + msgstr "" 671 + "Make your voice heard, reach out to your representative, participate in " 672 + "democracy." 673 + 674 + #: letters/templates/letters/letter_list.html:10 675 + msgid "Open letters authored and signed by fellow citizens." 676 + msgstr "Open letters authored and signed by fellow citizens." 677 + 678 + #: letters/templates/letters/letter_list.html:11 679 + msgid "" 680 + "Physical letters mailed to representatives when number of verified " 681 + "signatures > 100." 682 + msgstr "" 683 + "Physical letters mailed to representatives when number of verified " 684 + "signatures > 100." 685 + 686 + #: letters/templates/letters/letter_list.html:15 687 + msgid "Browse Open Letters" 688 + msgstr "Browse Open Letters" 689 + 690 + #: letters/templates/letters/letter_list.html:25 691 + msgid "Popular tags:" 692 + msgstr "Popular tags:" 693 + 694 + #: letters/templates/letters/letter_list.html:41 695 + msgid "Previous" 696 + msgstr "Previous" 697 + 698 + #: letters/templates/letters/letter_list.html:43 699 + #, python-format 700 + msgid "Page %(page)s of %(total)s" 701 + msgstr "Page %(page)s of %(total)s" 702 + 703 + #: letters/templates/letters/letter_list.html:45 704 + msgid "Next" 705 + msgstr "Next" 706 + 707 + #: letters/templates/letters/letter_list.html:51 708 + msgid "No letters found." 709 + msgstr "No letters found." 710 + 711 + #: letters/templates/letters/letter_list.html:51 712 + msgid "Be the first to write one!" 713 + msgstr "Be the first to write one!" 714 + 715 + #: letters/templates/letters/login.html:14 716 + #: letters/templates/letters/register.html:15 717 + msgid "Username:" 718 + msgstr "Username:" 719 + 720 + #: letters/templates/letters/login.html:22 721 + #: letters/templates/letters/register.html:47 722 + msgid "Password:" 723 + msgstr "Password:" 724 + 725 + #: letters/templates/letters/login.html:37 726 + msgid "Forgot your password?" 727 + msgstr "Forgot your password?" 728 + 729 + #: letters/templates/letters/login.html:41 730 + msgid "Don't have an account?" 731 + msgstr "Don't have an account?" 732 + 733 + #: letters/templates/letters/login.html:41 734 + msgid "Register here" 735 + msgstr "Register here" 736 + 737 + #: letters/templates/letters/partials/letter_card.html:6 738 + msgid "To" 739 + msgstr "To" 740 + 741 + #: letters/templates/letters/partials/letter_card.html:20 742 + #, python-format 743 + msgid "%(counter)s signature" 744 + msgid_plural "%(counter)s signatures" 745 + msgstr[0] "%(counter)s signature" 746 + msgstr[1] "%(counter)s signatures" 747 + 748 + #: letters/templates/letters/partials/letter_card.html:20 749 + #, python-format 750 + msgid "%(counter)s verified" 751 + msgid_plural "%(counter)s verified" 752 + msgstr[0] "%(counter)s verified" 753 + msgstr[1] "%(counter)s verified" 754 + 755 + #: letters/templates/letters/partials/representative_card.html:21 756 + #: letters/templates/letters/partials/representative_card.html:23 757 + msgid "Constituency" 758 + msgstr "Constituency" 759 + 760 + #: letters/templates/letters/partials/representative_card.html:27 761 + msgid "Mandate" 762 + msgstr "Mandate" 763 + 764 + #: letters/templates/letters/partials/representative_card.html:34 765 + msgid "Focus" 766 + msgstr "Focus" 767 + 768 + #: letters/templates/letters/partials/representative_card.html:34 769 + msgid "self-declared" 770 + msgstr "self-declared" 771 + 772 + #: letters/templates/letters/partials/representative_card.html:45 773 + msgid "Committees" 774 + msgstr "Committees" 775 + 776 + #: letters/templates/letters/partials/representative_card.html:57 777 + msgid "Email" 778 + msgstr "Email" 779 + 780 + #: letters/templates/letters/partials/representative_card.html:60 781 + msgid "Website" 782 + msgstr "Website" 783 + 784 + #: letters/templates/letters/partials/representative_card.html:66 785 + msgid "View profile" 786 + msgstr "View profile" 787 + 788 + #: letters/templates/letters/partials/suggestions.html:10 789 + msgid "" 790 + "We couldn't match you to a constituency yet. Update your profile " 791 + "verification to see local representatives." 792 + msgstr "" 793 + "We couldn't match you to a constituency yet. Update your profile " 794 + "verification to see local representatives." 795 + 796 + #: letters/templates/letters/partials/suggestions.html:16 797 + msgid "Our Interpretation" 798 + msgstr "Our Interpretation" 799 + 800 + #: letters/templates/letters/partials/suggestions.html:21 801 + msgid "Topic:" 802 + msgstr "Topic:" 803 + 804 + #: letters/templates/letters/partials/suggestions.html:28 805 + msgid "No specific policy area detected. Try adding more keywords." 806 + msgstr "No specific policy area detected. Try adding more keywords." 807 + 808 + #: letters/templates/letters/partials/suggestions.html:37 809 + msgid "Related Keywords" 810 + msgstr "Related Keywords" 811 + 812 + #: letters/templates/letters/partials/suggestions.html:51 813 + msgid "Your Direct Representatives" 814 + msgstr "Your Direct Representatives" 815 + 816 + #: letters/templates/letters/partials/suggestions.html:56 817 + msgid "These representatives directly represent your constituency:" 818 + msgstr "These representatives directly represent your constituency:" 819 + 820 + #: letters/templates/letters/partials/suggestions.html:67 821 + #: letters/templates/letters/partials/suggestions.html:102 822 + msgid "Select" 823 + msgstr "Select" 824 + 825 + #: letters/templates/letters/partials/suggestions.html:81 826 + msgid "Topic Experts" 827 + msgstr "Topic Experts" 828 + 829 + #: letters/templates/letters/partials/suggestions.html:86 830 + #, python-format 831 + msgid "" 832 + "These representatives are experts on \"%(topic)s\" based on their committee " 833 + "memberships:" 834 + msgstr "" 835 + "These representatives are experts on \"%(topic)s\" based on their committee " 836 + "memberships:" 837 + 838 + #: letters/templates/letters/partials/suggestions.html:95 839 + msgid "of" 840 + msgstr "of" 841 + 842 + #: letters/templates/letters/partials/suggestions.html:116 843 + msgid "Suggested Representatives" 844 + msgstr "Suggested Representatives" 845 + 846 + #: letters/templates/letters/partials/suggestions.html:119 847 + msgid "" 848 + "No representatives found. Representatives may need to be synced for this " 849 + "governmental level." 850 + msgstr "" 851 + "No representatives found. Representatives may need to be synced for this " 852 + "governmental level." 853 + 854 + #: letters/templates/letters/partials/suggestions.html:148 855 + msgid "Selected:" 856 + msgstr "Selected:" 857 + 858 + #: letters/templates/letters/password_reset_complete.html:4 859 + #: letters/templates/letters/password_reset_complete.html:8 860 + msgid "Password updated" 861 + msgstr "Password updated" 862 + 863 + #: letters/templates/letters/password_reset_complete.html:9 864 + msgid "You can now sign in using your new password." 865 + msgstr "You can now sign in using your new password." 866 + 867 + #: letters/templates/letters/password_reset_complete.html:10 868 + msgid "Go to login" 869 + msgstr "Go to login" 870 + 871 + #: letters/templates/letters/password_reset_confirm.html:4 872 + #: letters/templates/letters/password_reset_confirm.html:9 873 + msgid "Choose a new password" 874 + msgstr "Choose a new password" 875 + 876 + #: letters/templates/letters/password_reset_confirm.html:13 877 + msgid "New password" 878 + msgstr "New password" 879 + 880 + #: letters/templates/letters/password_reset_confirm.html:20 881 + msgid "Confirm password" 882 + msgstr "Confirm password" 883 + 884 + #: letters/templates/letters/password_reset_confirm.html:26 885 + msgid "Update password" 886 + msgstr "Update password" 887 + 888 + #: letters/templates/letters/password_reset_confirm.html:29 889 + msgid "Reset link invalid" 890 + msgstr "Reset link invalid" 891 + 892 + #: letters/templates/letters/password_reset_confirm.html:30 893 + msgid "This password reset link is no longer valid. Please request a new one." 894 + msgstr "This password reset link is no longer valid. Please request a new one." 895 + 896 + #: letters/templates/letters/password_reset_confirm.html:31 897 + msgid "Request new link" 898 + msgstr "Request new link" 899 + 900 + #: letters/templates/letters/password_reset_done.html:4 901 + msgid "Reset email sent" 902 + msgstr "Reset email sent" 903 + 904 + #: letters/templates/letters/password_reset_done.html:9 905 + msgid "" 906 + "If an account exists for that email address, we just sent you instructions " 907 + "to choose a new password." 908 + msgstr "" 909 + "If an account exists for that email address, we just sent you instructions " 910 + "to choose a new password." 911 + 912 + #: letters/templates/letters/password_reset_done.html:10 913 + msgid "The link will stay valid for a limited time." 914 + msgstr "The link will stay valid for a limited time." 915 + 916 + #: letters/templates/letters/password_reset_done.html:11 917 + msgid "Back to login" 918 + msgstr "Back to login" 919 + 920 + #: letters/templates/letters/password_reset_form.html:4 921 + msgid "Reset password" 922 + msgstr "Reset password" 923 + 924 + #: letters/templates/letters/password_reset_form.html:8 925 + msgid "Reset your password" 926 + msgstr "Reset your password" 927 + 928 + #: letters/templates/letters/password_reset_form.html:9 929 + msgid "" 930 + "Enter the email address you used during registration. We will send you a " 931 + "link to create a new password." 932 + msgstr "" 933 + "Enter the email address you used during registration. We will send you a " 934 + "link to create a new password." 935 + 936 + #: letters/templates/letters/password_reset_form.html:17 937 + msgid "Send reset link" 938 + msgstr "Send reset link" 939 + 940 + #: letters/templates/letters/profile.html:13 941 + #, python-format 942 + msgid "%(username)s's Profile" 943 + msgstr "%(username)s's Profile" 944 + 945 + #: letters/templates/letters/profile.html:16 946 + msgid "Identity & Constituency" 947 + msgstr "Identity & Constituency" 948 + 949 + #: letters/templates/letters/profile.html:19 950 + msgid "Status:" 951 + msgstr "Status:" 952 + 953 + #: letters/templates/letters/profile.html:21 954 + msgid "Type:" 955 + msgstr "Type:" 956 + 957 + #: letters/templates/letters/profile.html:28 958 + msgid "" 959 + "You self-declared your constituency. Representatives will see your " 960 + "signatures as self-declared constituents." 961 + msgstr "" 962 + "You self-declared your constituency. Representatives will see your " 963 + "signatures as self-declared constituents." 964 + 965 + #: letters/templates/letters/profile.html:30 966 + msgid "Start third-party verification" 967 + msgstr "Start third-party verification" 968 + 969 + #: letters/templates/letters/profile.html:33 970 + msgid "" 971 + "Your identity was verified via a third-party provider. Signatures will " 972 + "appear as verified constituents." 973 + msgstr "" 974 + "Your identity was verified via a third-party provider. Signatures will " 975 + "appear as verified constituents." 976 + 977 + #: letters/templates/letters/profile.html:38 978 + msgid "Your verification is being processed." 979 + msgstr "Your verification is being processed." 980 + 981 + #: letters/templates/letters/profile.html:39 982 + msgid "Complete Verification (Stub)" 983 + msgstr "Complete Verification (Stub)" 984 + 985 + #: letters/templates/letters/profile.html:43 986 + msgid "Verification failed. Please try again or contact support." 987 + msgstr "Verification failed. Please try again or contact support." 988 + 989 + #: letters/templates/letters/profile.html:48 990 + msgid "" 991 + "You can self-declare your constituency below or start a verification with a " 992 + "trusted provider. Verified signatures carry more weight." 993 + msgstr "" 994 + "You can self-declare your constituency below or start a verification with a " 995 + "trusted provider. Verified signatures carry more weight." 996 + 997 + #: letters/templates/letters/profile.html:50 998 + msgid "Start Third-party Verification" 999 + msgstr "Start Third-party Verification" 1000 + 1001 + #: letters/templates/letters/profile.html:55 1002 + msgid "Ihre Adresse" 1003 + msgstr "Ihre Adresse" 1004 + 1005 + #: letters/templates/letters/profile.html:57 1006 + msgid "" 1007 + "Geben Sie Ihre vollstรคndige Adresse ein, um prรคzise Wahlkreis- und " 1008 + "Abgeordnetenempfehlungen zu erhalten." 1009 + msgstr "" 1010 + "Geben Sie Ihre vollstรคndige Adresse ein, um prรคzise Wahlkreis- und " 1011 + "Abgeordnetenempfehlungen zu erhalten." 1012 + 1013 + #: letters/templates/letters/profile.html:61 1014 + msgid "Gespeicherte Adresse:" 1015 + msgstr "Gespeicherte Adresse:" 1016 + 1017 + #: letters/templates/letters/profile.html:83 1018 + msgid "Adresse speichern" 1019 + msgstr "Adresse speichern" 1020 + 1021 + #: letters/templates/letters/profile.html:88 1022 + msgid "Self-declare your constituency" 1023 + msgstr "Self-declare your constituency" 1024 + 1025 + #: letters/templates/letters/profile.html:90 1026 + msgid "" 1027 + "Select the constituencies you live in so we can prioritise the right " 1028 + "representatives." 1029 + msgstr "" 1030 + "Select the constituencies you live in so we can prioritise the right " 1031 + "representatives." 1032 + 1033 + #: letters/templates/letters/profile.html:109 1034 + msgid "Save constituencies" 1035 + msgstr "Save constituencies" 1036 + 1037 + #: letters/templates/letters/profile.html:114 1038 + msgid "Your Letters" 1039 + msgstr "Your Letters" 1040 + 1041 + #: letters/templates/letters/profile.html:120 1042 + msgid "You haven't written any letters yet." 1043 + msgstr "You haven't written any letters yet." 1044 + 1045 + #: letters/templates/letters/profile.html:120 1046 + msgid "Write one now!" 1047 + msgstr "Write one now!" 1048 + 1049 + #: letters/templates/letters/profile.html:125 1050 + msgid "Letters You've Signed" 1051 + msgstr "Letters You've Signed" 1052 + 1053 + #: letters/templates/letters/profile.html:133 1054 + msgid "You haven't signed any letters yet." 1055 + msgstr "You haven't signed any letters yet." 1056 + 1057 + #: letters/templates/letters/profile.html:133 1058 + msgid "Browse letters" 1059 + msgstr "Browse letters" 1060 + 1061 + #: letters/templates/letters/profile.html:138 1062 + msgid "Account" 1063 + msgstr "Account" 1064 + 1065 + #: letters/templates/letters/profile.html:140 1066 + msgid "" 1067 + "Need a fresh start? You can delete your account at any time. Your letters " 1068 + "stay visible but without your name." 1069 + msgstr "" 1070 + "Need a fresh start? You can delete your account at any time. Your letters " 1071 + "stay visible but without your name." 1072 + 1073 + #: letters/templates/letters/register.html:9 1074 + msgid "" 1075 + "After registration we'll send you an email to confirm your address before " 1076 + "you can sign in." 1077 + msgstr "" 1078 + "After registration we'll send you an email to confirm your address before " 1079 + "you can sign in." 1080 + 1081 + #: letters/templates/letters/register.html:23 1082 + msgid "Email:" 1083 + msgstr "Email:" 1084 + 1085 + #: letters/templates/letters/register.html:31 1086 + msgid "First Name (optional):" 1087 + msgstr "First Name (optional):" 1088 + 1089 + #: letters/templates/letters/register.html:39 1090 + msgid "Last Name (optional):" 1091 + msgstr "Last Name (optional):" 1092 + 1093 + #: letters/templates/letters/register.html:55 1094 + msgid "Confirm Password:" 1095 + msgstr "Confirm Password:" 1096 + 1097 + #: letters/templates/letters/register.html:70 1098 + msgid "Already have an account?" 1099 + msgstr "Already have an account?" 1100 + 1101 + #: letters/templates/letters/register.html:70 1102 + msgid "Login here" 1103 + msgstr "Login here" 1104 + 1105 + #: letters/templates/letters/representative_detail.html:19 1106 + msgid "รœber" 1107 + msgstr "รœber" 1108 + 1109 + #: letters/templates/letters/representative_detail.html:30 1110 + msgid "Committee Memberships" 1111 + msgstr "Committee Memberships" 1112 + 1113 + #: letters/templates/letters/representative_detail.html:75 1114 + msgid "Open Letters" 1115 + msgstr "Open Letters" 1116 + 1117 + #: letters/templates/letters/representative_detail.html:83 1118 + msgid "No letters have been written to this representative yet." 1119 + msgstr "No letters have been written to this representative yet." 1120 + 1121 + #: letters/templates/letters/representative_detail.html:85 1122 + msgid "Write the First Letter" 1123 + msgstr "Write the First Letter" 1124 + 1125 + #: letters/templates/letters/representative_detail.html:95 1126 + msgid "External Resources" 1127 + msgstr "External Resources" 1128 + 1129 + #: letters/templates/letters/representative_detail.html:100 1130 + msgid "Abgeordnetenwatch Profile" 1131 + msgstr "Abgeordnetenwatch Profile" 1132 + 1133 + #: letters/templates/letters/representative_detail.html:102 1134 + msgid "" 1135 + "View voting record, questions, and detailed profile on Abgeordnetenwatch.de" 1136 + msgstr "" 1137 + "View voting record, questions, and detailed profile on Abgeordnetenwatch.de" 1138 + 1139 + #: letters/templates/letters/representative_detail.html:112 1140 + msgid "Wikipedia Article" 1141 + msgstr "Wikipedia Article" 1142 + 1143 + #: letters/templates/letters/representative_detail.html:114 1144 + msgid "Read more about this representative on Wikipedia" 1145 + msgstr "Read more about this representative on Wikipedia" 1146 + 1147 + #: letters/templates/letters/representative_detail.html:117 1148 + msgid "View on Wikipedia" 1149 + msgstr "View on Wikipedia" 1150 + 1151 + #: letters/templates/letters/representative_detail.html:123 1152 + msgid "No external resources available for this representative." 1153 + msgstr "No external resources available for this representative." 1154 + 1155 + #: letters/templates/letters/representative_detail.html:130 1156 + msgid "Kontakt" 1157 + msgstr "Kontakt" 1158 + 1159 + #: letters/templates/letters/representative_detail.html:145 1160 + #, python-format 1161 + msgid "Start a new open letter to %(name)s" 1162 + msgstr "Start a new open letter to %(name)s" 1163 + 1164 + #: letters/templates/letters/representative_detail.html:153 1165 + msgid "Login to Write Letter" 1166 + msgstr "Login to Write Letter" 1167 + 1168 + #: letters/views.py:52 1169 + msgid "Confirm your WriteThem.eu account" 1170 + msgstr "Confirm your WriteThem.eu account" 1171 + 1172 + #: letters/views.py:184 1173 + msgid "Your letter has been published and your signature has been added!" 1174 + msgstr "Your letter has been published and your signature has been added!" 1175 + 1176 + #: letters/views.py:197 1177 + msgid "You have already signed this letter." 1178 + msgstr "You have already signed this letter." 1179 + 1180 + #: letters/views.py:207 1181 + msgid "Your signature has been added!" 1182 + msgstr "Your signature has been added!" 1183 + 1184 + #: letters/views.py:225 1185 + msgid "Thank you for your report. Our team will review it." 1186 + msgstr "Thank you for your report. Our team will review it." 1187 + 1188 + #: letters/views.py:259 1189 + msgid "" 1190 + "Please confirm your email address. We sent you a link to activate your " 1191 + "account." 1192 + msgstr "" 1193 + "Please confirm your email address. We sent you a link to activate your " 1194 + "account." 1195 + 1196 + #: letters/views.py:289 1197 + msgid "Your account has been activated. You can now log in." 1198 + msgstr "Your account has been activated. You can now log in." 1199 + 1200 + #: letters/views.py:292 1201 + msgid "Your account is already active." 1202 + msgstr "Your account is already active." 1203 + 1204 + #: letters/views.py:347 1205 + msgid "Ihre Adresse wurde gespeichert." 1206 + msgstr "Ihre Adresse wurde gespeichert." 1207 + 1208 + #: letters/views.py:363 1209 + msgid "Your constituency information has been updated." 1210 + msgstr "Your constituency information has been updated." 1211 + 1212 + #: letters/views.py:391 1213 + msgid "" 1214 + "Your account has been deleted. Your published letters remain available to " 1215 + "the public." 1216 + msgstr "" 1217 + "Your account has been deleted. Your published letters remain available to " 1218 + "the public."
+12 -2
website/writethem/settings.py
··· 43 43 MIDDLEWARE = [ 44 44 'django.middleware.security.SecurityMiddleware', 45 45 'django.contrib.sessions.middleware.SessionMiddleware', 46 + 'django.middleware.locale.LocaleMiddleware', 46 47 'django.middleware.common.CommonMiddleware', 47 48 'django.middleware.csrf.CsrfViewMiddleware', 48 49 'django.contrib.auth.middleware.AuthenticationMiddleware', ··· 105 106 # https://docs.djangoproject.com/en/5.2/topics/i18n/ 106 107 107 108 LANGUAGE_CODE = 'de' 109 + LANGUAGES = [ 110 + ('de', 'Deutsch'), 111 + ('en', 'English'), 112 + ] 108 113 109 114 TIME_ZONE = 'Europe/Berlin' 110 115 111 - USE_I18N = False 116 + USE_I18N = True 112 117 USE_L10N = True 113 118 114 119 USE_TZ = True 120 + 121 + # Locale paths - where Django looks for .po files 122 + LOCALE_PATHS = [ 123 + BASE_DIR / 'locale', 124 + ] 115 125 116 126 117 127 # Static files (CSS, JavaScript, Images) ··· 138 148 139 149 140 150 # Constituency boundary data 141 - CONSTITUENCY_BOUNDARIES_PATH = BASE_DIR / 'letters' / 'data' / 'wahlkreise_sample.geojson' 151 + CONSTITUENCY_BOUNDARIES_PATH = BASE_DIR / 'letters' / 'data' / 'wahlkreise.geojson' 142 152 143 153 # Email settings (development defaults; override in production) 144 154 EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
+12 -3
website/writethem/urls.py
··· 14 14 1. Import the include() function: from django.urls import include, path 15 15 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 16 """ 17 + from django.contrib import admin 18 + from django.urls import path, include 17 19 from django.conf import settings 18 20 from django.conf.urls.static import static 19 - from django.contrib import admin 20 - from django.urls import include, path 21 + from django.conf.urls.i18n import i18n_patterns 22 + from django.views.i18n import set_language 21 23 22 24 urlpatterns = [ 25 + # Language switcher endpoint (no prefix) 26 + path('i18n/setlang/', set_language, name='set_language'), 27 + ] 28 + 29 + # All user-facing URLs get language prefix 30 + urlpatterns += i18n_patterns( 23 31 path('admin/', admin.site.urls), 24 32 path('', include('letters.urls')), 25 - ] 33 + prefix_default_language=True, 34 + ) 26 35 27 36 if settings.DEBUG: 28 37 urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)