···1313# Database
1414*.sqlite3
1515db.sqlite3
1616+1717+# Compiled translation files (generated from .po)
1818+*.mo
1919+2020+# Shapefile components (use fetch_wahlkreis_data to regenerate GeoJSON)
2121+*.shp
2222+*.shx
2323+*.dbf
2424+*.prj
2525+*.cpg
2626+*.sbn
2727+*.sbx
2828+*.shp.xml
2929+*_shp_geo.zip
3030+3131+# GeoJSON data (generated by fetch_wahlkreis_data)
3232+website/letters/data/wahlkreise.geojson
3333+3434+# Git worktrees
3535+.worktrees/
+167-1
CLAUDE.md
···11-- use uv run python for every python command, including manage.py, scripts, temporary fixes, etc.11+You are an experienced, pragmatic software engineer. You don't over-engineer a solution when a simple one is possible.
22+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.
33+44+## Foundational rules
55+66+- Doing it right is better than doing it fast. You are not in a rush. NEVER skip steps or take shortcuts.
77+- 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.
88+- Honesty is a core value. If you lie, you'll be replaced.
99+- You MUST think of and address your human partner as "Jesse" at all times
1010+1111+## Our relationship
1212+1313+- We're colleagues working together as "Jesse" and "Claude" - no formal hierarchy.
1414+- Don't glaze me. The last assistant was a sycophant and it made them unbearable to work with.
1515+- YOU MUST speak up immediately when you don't know something or we're in over our heads
1616+- YOU MUST call out bad ideas, unreasonable expectations, and mistakes - I depend on this
1717+- NEVER be agreeable just to be nice - I NEED your HONEST technical judgment
1818+- NEVER write the phrase "You're absolutely right!" You are not a sycophant. We're working together because I value your opinion.
1919+- YOU MUST ALWAYS STOP and ask for clarification rather than making assumptions.
2020+- If you're having trouble, YOU MUST STOP and ask for help, especially for tasks where human input would be valuable.
2121+- 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.
2222+- If you're uncomfortable pushing back out loud, just say "Strange things are afoot at the Circle K". I'll know what you mean
2323+- 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.
2424+- You search your journal when you trying to remember or figure stuff out.
2525+- We discuss architectutral decisions (framework changes, major refactoring, system design)
2626+ together before implementation. Routine fixes and clear implementations don't need
2727+ discussion.
2828+2929+3030+# Proactiveness
3131+3232+When asked to do something, just do it - including obvious follow-up actions needed to complete the task properly.
3333+ Only pause to ask for confirmation when:
3434+ - Multiple valid approaches exist and the choice matters
3535+ - The action would delete or significantly restructure existing code
3636+ - You genuinely don't understand what's being asked
3737+ - Your partner specifically asks "how should I approach X?" (answer the question, don't jump to
3838+ implementation)
3939+4040+## Designing software
4141+4242+- YAGNI. The best code is no code. Don't add features we don't need right now.
4343+- When it doesn't conflict with YAGNI, architect for extensibility and flexibility.
4444+4545+4646+## Test Driven Development (TDD)
4747+4848+- FOR EVERY NEW FEATURE OR BUGFIX, YOU MUST follow Test Driven Development :
4949+ 1. Write a failing test that correctly validates the desired functionality
5050+ 2. Run the test to confirm it fails as expected
5151+ 3. Write ONLY enough code to make the failing test pass
5252+ 4. Run the test to confirm success
5353+ 5. Refactor if needed while keeping tests green
5454+5555+## Writing code
5656+5757+- When submitting work, verify that you have FOLLOWED ALL RULES. (See Rule #1)
5858+- YOU MUST make the SMALLEST reasonable changes to achieve the desired outcome.
5959+- 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.
6060+- YOU MUST WORK HARD to reduce code duplication, even if the refactoring takes extra effort.
6161+- YOU MUST NEVER throw away or rewrite implementations without EXPLICIT permission. If you're considering this, YOU MUST STOP and ask first.
6262+- YOU MUST get Jesse's explicit approval before implementing ANY backward compatibility.
6363+- 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.
6464+- YOU MUST NOT manually change whitespace that does not affect execution or output. Otherwise, use a formatting tool.
6565+- Fix broken things immediately when you find them. Don't ask permission to fix bugs.
6666+6767+6868+6969+## Naming
7070+7171+ - Names MUST tell what code does, not how it's implemented or its history
7272+ - When changing code, never document the old behavior or the behavior change
7373+ - NEVER use implementation details in names (e.g., "ZodValidator", "MCPWrapper", "JSONParser")
7474+ - NEVER use temporal/historical context in names (e.g., "NewAPI", "LegacyHandler", "UnifiedTool", "ImprovedInterface", "EnhancedParser")
7575+ - NEVER use pattern names unless they add clarity (e.g., prefer "Tool" over "ToolFactory")
7676+7777+ Good names tell a story about the domain:
7878+ - `Tool` not `AbstractToolInterface`
7979+ - `RemoteTool` not `MCPToolWrapper`
8080+ - `Registry` not `ToolRegistryManager`
8181+ - `execute()` not `executeToolWithValidation()`
8282+8383+## Code Comments
8484+8585+ - NEVER add comments explaining that something is "improved", "better", "new", "enhanced", or referencing what it used to be
8686+ - NEVER add instructional comments telling developers what to do ("copy this pattern", "use this instead")
8787+ - Comments should explain WHAT the code does or WHY it exists, not how it's better than something else
8888+ - If you're refactoring, remove old comments - don't add new ones explaining the refactoring
8989+ - YOU MUST NEVER remove code comments unless you can PROVE they are actively false. Comments are important documentation and must be preserved.
9090+ - YOU MUST NEVER add comments about what used to be there or how something has changed.
9191+ - 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.
9292+ - 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.
9393+9494+ Examples:
9595+ // BAD: This uses Zod for validation instead of manual checking
9696+ // BAD: Refactored from the old validation system
9797+ // BAD: Wrapper around MCP tool protocol
9898+ // GOOD: Executes tools with validated arguments
9999+100100+ 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
101101+ actual purpose.
102102+103103+## Version Control
104104+105105+- If the project isn't in a git repo, STOP and ask permission to initialize one.
106106+- YOU MUST STOP and ask how to handle uncommitted changes or untracked files when starting work. Suggest committing existing work first.
107107+- When starting work without a clear branch for the current task, YOU MUST create a WIP branch.
108108+- YOU MUST TRACK All non-trivial changes in git.
109109+- YOU MUST commit frequently throughout the development process, even if your high-level tasks are not yet done. Commit your journal entries.
110110+- NEVER SKIP, EVADE OR DISABLE A PRE-COMMIT HOOK
111111+- NEVER use `git add -A` unless you've just done a `git status` - Don't add random test files to the repo.
112112+113113+## Testing
114114+115115+- ALL TEST FAILURES ARE YOUR RESPONSIBILITY, even if they're not your fault. The Broken Windows theory is real.
116116+- Never delete a test because it's failing. Instead, raise the issue with Jesse.
117117+- Tests MUST comprehensively cover ALL functionality.
118118+- 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.
119119+- YOU MUST NEVER implement mocks in end to end tests. We always use real data and real APIs.
120120+- YOU MUST NEVER ignore system or test output - logs and messages often contain CRITICAL information.
121121+- 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
122122+123123+124124+## Issue tracking
125125+126126+- You MUST use your TodoWrite tool to keep track of what you're doing
127127+- You MUST NEVER discard tasks from your TodoWrite todo list without Jesse's explicit approval
128128+129129+## Systematic Debugging Process
130130+131131+YOU MUST ALWAYS find the root cause of any issue you are debugging
132132+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.
133133+134134+YOU MUST follow this debugging framework for ANY technical issue:
135135+136136+### Phase 1: Root Cause Investigation (BEFORE attempting fixes)
137137+- **Read Error Messages Carefully**: Don't skip past errors or warnings - they often contain the exact solution
138138+- **Reproduce Consistently**: Ensure you can reliably reproduce the issue before investigating
139139+- **Check Recent Changes**: What changed that could have caused this? Git diff, recent commits, etc.
140140+141141+### Phase 2: Pattern Analysis
142142+- **Find Working Examples**: Locate similar working code in the same codebase
143143+- **Compare Against References**: If implementing a pattern, read the reference implementation completely
144144+- **Identify Differences**: What's different between working and broken code?
145145+- **Understand Dependencies**: What other components/settings does this pattern require?
146146+147147+### Phase 3: Hypothesis and Testing
148148+1. **Form Single Hypothesis**: What do you think is the root cause? State it clearly
149149+2. **Test Minimally**: Make the smallest possible change to test your hypothesis
150150+3. **Verify Before Continuing**: Did your test work? If not, form new hypothesis - don't add more fixes
151151+4. **When You Don't Know**: Say "I don't understand X" rather than pretending to know
152152+153153+### Phase 4: Implementation Rules
154154+- ALWAYS have the simplest possible failing test case. If there's no test framework, it's ok to write a one-off test script.
155155+- NEVER add multiple fixes at once
156156+- NEVER claim to implement a pattern without reading it completely first
157157+- ALWAYS test after each change
158158+- IF your first fix doesn't work, STOP and re-analyze rather than adding more fixes
159159+160160+## Learning and Memory Management
161161+162162+- YOU MUST use the journal tool frequently to capture technical insights, failed approaches, and user preferences
163163+- Before starting complex tasks, search the journal for relevant past experiences and lessons learned
164164+- Document architectural decisions and their outcomes for future reference
165165+- Track patterns in user feedback to improve collaboration over time
166166+- 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
167167+- Always use uv run python manage.py {shell,runserver,test,etc}
+45-2
README.md
···441. Install dependencies with `uv sync`.
552. Run `uv run python manage.py migrate` from `website/` to bootstrap the database.
663. (Optional) Import representatives via `uv run python manage.py sync_representatives --level all`.
77-4. Launch the dev server with `uv run python manage.py runserver` and visit http://localhost:8000/.
77+4. Download constituency boundaries: `uv run python manage.py fetch_wahlkreis_data` (required for accurate address-based matching).
88+5. Launch the dev server with `uv run python manage.py runserver` and visit http://localhost:8000/.
99+1010+## Internationalization
1111+1212+WriteThem.eu supports German (default) and English.
1313+1414+### Using the Site
1515+1616+- Visit `/de/` for German interface
1717+- Visit `/en/` for English interface
1818+- Use the language switcher in the header to toggle languages
1919+- Language preference is saved in a cookie
2020+2121+### For Developers
2222+2323+**Translation workflow:**
2424+2525+1. Wrap new UI strings with translation functions:
2626+ - Templates: `{% trans "Text" %}` or `{% blocktrans %}`
2727+ - Python: `gettext()` or `gettext_lazy()`
2828+2929+2. Extract strings to .po files:
3030+ ```bash
3131+ cd website
3232+ uv run python manage.py makemessages -l de -l en
3333+ ```
3434+3535+3. Translate strings in `.po` files:
3636+ - Edit `locale/de/LC_MESSAGES/django.po` (German translations)
3737+ - Edit `locale/en/LC_MESSAGES/django.po` (English, mostly identity translations)
3838+3939+4. Compile translations:
4040+ ```bash
4141+ uv run python manage.py compilemessages
4242+ ```
4343+4444+5. Check translation completeness:
4545+ ```bash
4646+ uv run python manage.py check_translations
4747+ ```
4848+4949+**Important:** All code, comments, and translation keys should be in English. Only .po files contain actual translations.
850951## Architecture
1052- **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/`).
1153- **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.
1254- **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.
1313-- **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.
5555+- **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.
5656+- **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.
1457- **Identity & signatures**: `IdentityVerificationService` (stub) attaches address information to users; signature counts and verification badges are derived from the associated verification records.
1558- **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`).
1659- **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
···11+# Constituency Matching Algorithm
22+33+## Overview
44+55+WriteThem.eu uses a two-stage process to match users to their correct Bundestag constituency:
66+77+1. **Address Geocoding**: Convert user's address to latitude/longitude coordinates
88+2. **Point-in-Polygon Lookup**: Find which constituency polygon contains those coordinates
99+1010+## Stage 1: Address Geocoding
1111+1212+We use OpenStreetMap's Nominatim API to convert addresses to coordinates.
1313+1414+### Process:
1515+1. User provides: Street, Postal Code, City
1616+2. System checks cache (GeocodeCache table) for previous results
1717+3. If not cached, query Nominatim API with rate limiting (1 req/sec)
1818+4. Cache result (success or failure) to minimize API calls
1919+5. Return (latitude, longitude) or None
2020+2121+### Fallback:
2222+If geocoding fails or user only provides postal code, fall back to PLZ prefix heuristic (maps first 2 digits to state).
2323+2424+## Stage 2: Point-in-Polygon Lookup
2525+2626+We use official Bundestag constituency boundaries (GeoJSON format) with shapely for geometric queries.
2727+2828+### Process:
2929+1. Load GeoJSON with 299 Bundestag constituencies on startup
3030+2. Create shapely Point from coordinates
3131+3. Check which constituency Polygon contains the point
3232+4. Look up Constituency object in database by external_id
3333+5. Return Constituency or None
3434+3535+### Performance:
3636+- GeoJSON loaded once at startup (~2MB in memory)
3737+- Class-level caching prevents repeated loads
3838+- Lookup typically takes 10-50ms
3939+- No external API calls required
4040+4141+## Data Sources
4242+4343+- **Constituency Boundaries**: [dknx01/wahlkreissuche](https://github.com/dknx01/wahlkreissuche) (Open Data)
4444+- **Geocoding**: [OpenStreetMap Nominatim](https://nominatim.openstreetmap.org/) (Open Data)
4545+- **Representative Data**: [Abgeordnetenwatch API](https://www.abgeordnetenwatch.de/api)
4646+4747+## Accuracy
4848+4949+This approach provides constituency-accurate matching (exact Wahlkreis), significantly more precise than PLZ-based heuristics which only provide state-level accuracy.
5050+5151+### Known Limitations:
5252+- Requires valid German address
5353+- Dependent on OSM geocoding quality
5454+- Rate limited to 1 request/second (public API)
5555+5656+## Implementation Details
5757+5858+### Services
5959+6060+- **AddressGeocoder** (`letters/services.py`): Handles geocoding with caching
6161+- **WahlkreisLocator** (`letters/services.py`): Performs point-in-polygon matching
6262+- **ConstituencyLocator** (`letters/services.py`): Integrates both services with PLZ fallback
6363+6464+### Database Models
6565+6666+- **GeocodeCache** (`letters/models.py`): Caches geocoding results to minimize API calls
6767+- **Constituency** (`letters/models.py`): Stores constituency information with external_id mapping to GeoJSON
6868+6969+### Management Commands
7070+7171+- **fetch_wahlkreis_data**: Downloads official Bundestag constituency boundaries
7272+- **query_wahlkreis**: Query constituency by address or postal code
7373+- **query_topics**: Find matching topics for letter text
7474+- **query_representatives**: Find representatives by address and/or topics
7575+7676+### Testing
7777+7878+Run the test suite:
7979+```bash
8080+python manage.py test letters.tests.test_address_matching
8181+python manage.py test letters.tests.test_topic_mapping
8282+python manage.py test letters.tests.test_constituency_suggestions
8383+```
8484+8585+## Internationalization
8686+8787+The constituency matching system works identically in both German and English:
8888+8989+- Addresses can be entered in German format (standard use case)
9090+- UI language (German/English) does not affect geocoding or matching logic
9191+- Representative names, constituency names, and geographic data remain in original German
9292+- All user-facing labels and messages are translated
···11+# Accurate Constituency Matching Implementation Plan
22+33+> **For Claude:** Use `${CLAUDE_PLUGIN_ROOT}/skills/collaboration/executing-plans/SKILL.md` to implement this plan task-by-task.
44+55+**Goal:** Replace PLZ prefix heuristic with accurate address-based constituency matching using OSM Nominatim geocoding and GeoJSON point-in-polygon lookup.
66+77+**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.
88+99+**Tech Stack:** Django 5.x, shapely 2.x, requests, OSM Nominatim API, GeoJSON
1010+1111+---
1212+1313+## Task 1: Database Model for Geocoding Cache
1414+1515+**Files:**
1616+- Create: `website/letters/models.py` (add new model)
1717+- Create: `website/letters/migrations/0012_geocodecache.py` (auto-generated)
1818+- Test: `website/letters/tests.py`
1919+2020+**Step 1: Write the failing test**
2121+2222+Add to `website/letters/tests.py`:
2323+2424+```python
2525+class GeocodeCache Tests(TestCase):
2626+ """Test geocoding cache model."""
2727+2828+ def test_cache_stores_and_retrieves_coordinates(self):
2929+ from .models import GeocodeCache
3030+3131+ cache_entry = GeocodeCache.objects.create(
3232+ address_hash='test_hash_123',
3333+ street='Unter den Linden 77',
3434+ postal_code='10117',
3535+ city='Berlin',
3636+ latitude=52.5170365,
3737+ longitude=13.3888599,
3838+ )
3939+4040+ retrieved = GeocodeCache.objects.get(address_hash='test_hash_123')
4141+ self.assertEqual(retrieved.latitude, 52.5170365)
4242+ self.assertEqual(retrieved.longitude, 13.3888599)
4343+ self.assertEqual(retrieved.street, 'Unter den Linden 77')
4444+```
4545+4646+**Step 2: Run test to verify it fails**
4747+4848+Run: `uv run python manage.py test letters.tests.GeocodeCacheTests -v`
4949+Expected: FAIL with "No module named 'GeocodeCache'"
5050+5151+**Step 3: Add GeocodeCache model**
5252+5353+Add to `website/letters/models.py` after the existing models:
5454+5555+```python
5656+class GeocodeCache(models.Model):
5757+ """Cache geocoding results to minimize API calls."""
5858+5959+ address_hash = models.CharField(
6060+ max_length=64,
6161+ unique=True,
6262+ db_index=True,
6363+ help_text="SHA256 hash of normalized address for fast lookup"
6464+ )
6565+ street = models.CharField(max_length=255, blank=True)
6666+ postal_code = models.CharField(max_length=10, blank=True)
6767+ city = models.CharField(max_length=100, blank=True)
6868+ country = models.CharField(max_length=2, default='DE')
6969+7070+ latitude = models.FloatField(null=True, blank=True)
7171+ longitude = models.FloatField(null=True, blank=True)
7272+7373+ success = models.BooleanField(
7474+ default=True,
7575+ help_text="False if geocoding failed, to avoid repeated failed lookups"
7676+ )
7777+ error_message = models.TextField(blank=True)
7878+7979+ created_at = models.DateTimeField(auto_now_add=True)
8080+ updated_at = models.DateTimeField(auto_now=True)
8181+8282+ class Meta:
8383+ verbose_name = "Geocode Cache Entry"
8484+ verbose_name_plural = "Geocode Cache Entries"
8585+ ordering = ['-created_at']
8686+8787+ def __str__(self):
8888+ if self.latitude and self.longitude:
8989+ return f"{self.city} ({self.latitude}, {self.longitude})"
9090+ return f"{self.city} (failed)"
9191+```
9292+9393+**Step 4: Create migration**
9494+9595+Run: `uv run python manage.py makemigrations letters`
9696+Expected: Migration 0012_geocodecache.py created
9797+9898+**Step 5: Run migration**
9999+100100+Run: `uv run python manage.py migrate`
101101+Expected: "Applying letters.0012_geocodecache... OK"
102102+103103+**Step 6: Run test to verify it passes**
104104+105105+Run: `uv run python manage.py test letters.tests.GeocodeCacheTests -v`
106106+Expected: PASS
107107+108108+**Step 7: Commit**
109109+110110+```bash
111111+git add website/letters/models.py website/letters/migrations/0012_geocodecache.py website/letters/tests.py
112112+git commit -m "feat: add GeocodeCache model for address geocoding results"
113113+```
114114+115115+---
116116+117117+## Task 2: OSM Nominatim API Client
118118+119119+**Files:**
120120+- Modify: `website/letters/services.py` (add AddressGeocoder class)
121121+- Test: `website/letters/tests.py`
122122+123123+**Step 1: Write the failing test**
124124+125125+Add to `website/letters/tests.py`:
126126+127127+```python
128128+from unittest.mock import patch, MagicMock
129129+130130+131131+class AddressGeocoderTests(TestCase):
132132+ """Test OSM Nominatim address geocoding."""
133133+134134+ def test_geocode_returns_coordinates_for_valid_address(self):
135135+ from .services import AddressGeocoder
136136+137137+ # Mock the Nominatim API response
138138+ mock_response = MagicMock()
139139+ mock_response.json.return_value = [{
140140+ 'lat': '52.5170365',
141141+ 'lon': '13.3888599',
142142+ 'display_name': 'Unter den Linden 77, Mitte, Berlin, 10117, Deutschland'
143143+ }]
144144+ mock_response.status_code = 200
145145+146146+ with patch('requests.get', return_value=mock_response):
147147+ result = AddressGeocoder.geocode(
148148+ street='Unter den Linden 77',
149149+ postal_code='10117',
150150+ city='Berlin'
151151+ )
152152+153153+ self.assertIsNotNone(result)
154154+ lat, lng = result
155155+ self.assertAlmostEqual(lat, 52.5170365, places=5)
156156+ self.assertAlmostEqual(lng, 13.3888599, places=5)
157157+158158+ def test_geocode_caches_results(self):
159159+ from .services import AddressGeocoder
160160+ from .models import GeocodeCache
161161+162162+ mock_response = MagicMock()
163163+ mock_response.json.return_value = [{
164164+ 'lat': '52.5170365',
165165+ 'lon': '13.3888599',
166166+ }]
167167+ mock_response.status_code = 200
168168+169169+ with patch('requests.get', return_value=mock_response) as mock_get:
170170+ # First call should hit API
171171+ result1 = AddressGeocoder.geocode(
172172+ street='Unter den Linden 77',
173173+ postal_code='10117',
174174+ city='Berlin'
175175+ )
176176+177177+ # Second call should use cache
178178+ result2 = AddressGeocoder.geocode(
179179+ street='Unter den Linden 77',
180180+ postal_code='10117',
181181+ city='Berlin'
182182+ )
183183+184184+ # API should only be called once
185185+ self.assertEqual(mock_get.call_count, 1)
186186+ self.assertEqual(result1, result2)
187187+188188+ # Verify cache entry exists
189189+ self.assertTrue(
190190+ GeocodeCache.objects.filter(
191191+ city='Berlin',
192192+ postal_code='10117'
193193+ ).exists()
194194+ )
195195+```
196196+197197+**Step 2: Run test to verify it fails**
198198+199199+Run: `uv run python manage.py test letters.tests.AddressGeocoderTests -v`
200200+Expected: FAIL with "No module named 'AddressGeocoder'"
201201+202202+**Step 3: Implement AddressGeocoder service**
203203+204204+Add to `website/letters/services.py` after the existing classes:
205205+206206+```python
207207+import hashlib
208208+import time
209209+from typing import Optional, Tuple
210210+211211+212212+class AddressGeocoder:
213213+ """Geocode German addresses using OSM Nominatim API."""
214214+215215+ NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
216216+ USER_AGENT = "WriteThem.eu/1.0 (https://writethem.eu; contact@writethem.eu)"
217217+ REQUEST_TIMEOUT = 10
218218+ RATE_LIMIT_DELAY = 1.0 # seconds between requests
219219+220220+ _last_request_time = 0
221221+222222+ @classmethod
223223+ def geocode(
224224+ cls,
225225+ street: str,
226226+ postal_code: str,
227227+ city: str,
228228+ country: str = 'DE'
229229+ ) -> Optional[Tuple[float, float]]:
230230+ """
231231+ Geocode a German address to lat/lng coordinates.
232232+233233+ Args:
234234+ street: Street address (e.g., "Unter den Linden 77")
235235+ postal_code: German postal code (e.g., "10117")
236236+ city: City name (e.g., "Berlin")
237237+ country: Country code (default: 'DE')
238238+239239+ Returns:
240240+ Tuple of (latitude, longitude) or None if geocoding fails
241241+ """
242242+ from .models import GeocodeCache
243243+244244+ # Normalize inputs
245245+ street = (street or '').strip()
246246+ postal_code = (postal_code or '').strip()
247247+ city = (city or '').strip()
248248+ country = (country or 'DE').upper()
249249+250250+ if not city:
251251+ logger.warning("City is required for geocoding")
252252+ return None
253253+254254+ # Generate cache key
255255+ address_string = f"{street}|{postal_code}|{city}|{country}".lower()
256256+ address_hash = hashlib.sha256(address_string.encode()).hexdigest()
257257+258258+ # Check cache first
259259+ cached = GeocodeCache.objects.filter(address_hash=address_hash).first()
260260+ if cached:
261261+ if cached.success and cached.latitude and cached.longitude:
262262+ logger.debug(f"Cache hit for {city}: ({cached.latitude}, {cached.longitude})")
263263+ return (cached.latitude, cached.longitude)
264264+ elif not cached.success:
265265+ logger.debug(f"Cache hit for {city}: previous failure")
266266+ return None
267267+268268+ # Rate limiting
269269+ cls._rate_limit()
270270+271271+ # Build query
272272+ query_parts = []
273273+ if street:
274274+ query_parts.append(street)
275275+ if postal_code:
276276+ query_parts.append(postal_code)
277277+ query_parts.append(city)
278278+ query_parts.append(country)
279279+280280+ query = ', '.join(query_parts)
281281+282282+ params = {
283283+ 'q': query,
284284+ 'format': 'json',
285285+ 'limit': 1,
286286+ 'addressdetails': 1,
287287+ 'countrycodes': country.lower(),
288288+ }
289289+290290+ headers = {
291291+ 'User-Agent': cls.USER_AGENT
292292+ }
293293+294294+ try:
295295+ logger.info(f"Geocoding address: {query}")
296296+ response = requests.get(
297297+ cls.NOMINATIM_URL,
298298+ params=params,
299299+ headers=headers,
300300+ timeout=cls.REQUEST_TIMEOUT
301301+ )
302302+ response.raise_for_status()
303303+304304+ results = response.json()
305305+306306+ if not results:
307307+ logger.warning(f"No geocoding results for: {query}")
308308+ cls._cache_failure(address_hash, street, postal_code, city, country, "No results")
309309+ return None
310310+311311+ # Extract coordinates
312312+ result = results[0]
313313+ latitude = float(result['lat'])
314314+ longitude = float(result['lon'])
315315+316316+ # Cache success
317317+ GeocodeCache.objects.update_or_create(
318318+ address_hash=address_hash,
319319+ defaults={
320320+ 'street': street,
321321+ 'postal_code': postal_code,
322322+ 'city': city,
323323+ 'country': country,
324324+ 'latitude': latitude,
325325+ 'longitude': longitude,
326326+ 'success': True,
327327+ 'error_message': '',
328328+ }
329329+ )
330330+331331+ logger.info(f"Geocoded {city} to ({latitude}, {longitude})")
332332+ return (latitude, longitude)
333333+334334+ except requests.RequestException as e:
335335+ error_msg = f"Nominatim API error: {e}"
336336+ logger.error(error_msg)
337337+ cls._cache_failure(address_hash, street, postal_code, city, country, error_msg)
338338+ return None
339339+ except (KeyError, ValueError, TypeError) as e:
340340+ error_msg = f"Invalid geocoding response: {e}"
341341+ logger.error(error_msg)
342342+ cls._cache_failure(address_hash, street, postal_code, city, country, error_msg)
343343+ return None
344344+345345+ @classmethod
346346+ def _rate_limit(cls):
347347+ """Ensure we don't exceed Nominatim rate limits (1 req/sec)."""
348348+ import time
349349+ current_time = time.time()
350350+ elapsed = current_time - cls._last_request_time
351351+352352+ if elapsed < cls.RATE_LIMIT_DELAY:
353353+ time.sleep(cls.RATE_LIMIT_DELAY - elapsed)
354354+355355+ cls._last_request_time = time.time()
356356+357357+ @classmethod
358358+ def _cache_failure(
359359+ cls,
360360+ address_hash: str,
361361+ street: str,
362362+ postal_code: str,
363363+ city: str,
364364+ country: str,
365365+ error_message: str
366366+ ):
367367+ """Cache a failed geocoding attempt to avoid repeated failures."""
368368+ from .models import GeocodeCache
369369+370370+ GeocodeCache.objects.update_or_create(
371371+ address_hash=address_hash,
372372+ defaults={
373373+ 'street': street,
374374+ 'postal_code': postal_code,
375375+ 'city': city,
376376+ 'country': country,
377377+ 'latitude': None,
378378+ 'longitude': None,
379379+ 'success': False,
380380+ 'error_message': error_message,
381381+ }
382382+ )
383383+```
384384+385385+**Step 4: Run test to verify it passes**
386386+387387+Run: `uv run python manage.py test letters.tests.AddressGeocoderTests -v`
388388+Expected: PASS (2 tests)
389389+390390+**Step 5: Commit**
391391+392392+```bash
393393+git add website/letters/services.py website/letters/tests.py
394394+git commit -m "feat: add OSM Nominatim address geocoding service with caching"
395395+```
396396+397397+---
398398+399399+## Task 3: Download and Prepare GeoJSON Data
400400+401401+**Files:**
402402+- Modify: `website/letters/management/commands/fetch_wahlkreis_data.py` (already exists)
403403+- Create: `website/letters/data/wahlkreise.geojson` (downloaded data)
404404+405405+**Step 1: Test existing download command**
406406+407407+Run: `uv run python manage.py fetch_wahlkreis_data --help`
408408+Expected: Shows command help text
409409+410410+**Step 2: Download full Bundestag GeoJSON**
411411+412412+Run: `uv run python manage.py fetch_wahlkreis_data --output=website/letters/data/wahlkreise.geojson --force`
413413+Expected: "Saved Wahlkreis data to website/letters/data/wahlkreise.geojson"
414414+415415+**Step 3: Verify GeoJSON structure**
416416+417417+Run: `uv run python -c "import json; data = json.load(open('website/letters/data/wahlkreise.geojson')); print(f'Loaded {len(data[\"features\"])} constituencies')"`
418418+Expected: "Loaded 299 constituencies" (or similar)
419419+420420+**Step 4: Add GeoJSON to gitignore**
421421+422422+Add to `.gitignore`:
423423+```
424424+# Large GeoJSON data files
425425+website/letters/data/*.geojson
426426+!website/letters/data/wahlkreise_sample.geojson
427427+```
428428+429429+**Step 5: Commit gitignore update**
430430+431431+```bash
432432+git add .gitignore
433433+git commit -m "chore: add GeoJSON files to gitignore"
434434+```
435435+436436+**Step 6: Document download in README**
437437+438438+Add to README.md setup instructions:
439439+```markdown
440440+### Download Constituency Boundaries
441441+442442+Download the Bundestag constituency boundaries:
443443+444444+\`\`\`bash
445445+uv run python manage.py fetch_wahlkreis_data
446446+\`\`\`
447447+448448+This downloads ~2MB of GeoJSON data for accurate constituency matching.
449449+```
450450+451451+**Step 7: Commit documentation**
452452+453453+```bash
454454+git add README.md
455455+git commit -m "docs: add constituency data download instructions"
456456+```
457457+458458+---
459459+460460+## Task 4: WahlkreisLocator Service with Shapely
461461+462462+**Files:**
463463+- Modify: `website/letters/services.py` (add WahlkreisLocator class)
464464+- Test: `website/letters/tests.py`
465465+466466+**Step 1: Write the failing test**
467467+468468+Add to `website/letters/tests.py`:
469469+470470+```python
471471+class WahlkreisLocatorTests(TestCase):
472472+ """Test GeoJSON point-in-polygon constituency lookup."""
473473+474474+ def setUp(self):
475475+ super().setUp()
476476+ # Create test parliament and constituencies
477477+ self.parliament = Parliament.objects.create(
478478+ name='Deutscher Bundestag',
479479+ level='FEDERAL',
480480+ legislative_body='Bundestag',
481481+ region='DE',
482482+ )
483483+ self.term = ParliamentTerm.objects.create(
484484+ parliament=self.parliament,
485485+ name='20. Wahlperiode',
486486+ start_date=date(2021, 10, 26),
487487+ )
488488+ # Berlin-Mitte constituency
489489+ self.constituency_mitte = Constituency.objects.create(
490490+ parliament_term=self.term,
491491+ name='Berlin-Mitte',
492492+ scope='FEDERAL_DISTRICT',
493493+ external_id='75', # Real Wahlkreis ID
494494+ metadata={'state': 'Berlin'},
495495+ )
496496+497497+ def test_find_constituency_for_berlin_coordinates(self):
498498+ from .services import WahlkreisLocator
499499+500500+ # Coordinates for Unter den Linden, Berlin-Mitte
501501+ latitude = 52.5170365
502502+ longitude = 13.3888599
503503+504504+ result = WahlkreisLocator.find_constituency(latitude, longitude)
505505+506506+ self.assertIsNotNone(result)
507507+ self.assertEqual(result.external_id, '75') # Berlin-Mitte
508508+ self.assertEqual(result.scope, 'FEDERAL_DISTRICT')
509509+510510+ def test_returns_none_for_coordinates_outside_germany(self):
511511+ from .services import WahlkreisLocator
512512+513513+ # Coordinates in Paris
514514+ latitude = 48.8566
515515+ longitude = 2.3522
516516+517517+ result = WahlkreisLocator.find_constituency(latitude, longitude)
518518+519519+ self.assertIsNone(result)
520520+```
521521+522522+**Step 2: Run test to verify it fails**
523523+524524+Run: `uv run python manage.py test letters.tests.WahlkreisLocatorTests -v`
525525+Expected: FAIL with "No module named 'WahlkreisLocator'"
526526+527527+**Step 3: Implement WahlkreisLocator service**
528528+529529+Add to `website/letters/services.py`:
530530+531531+```python
532532+from pathlib import Path
533533+from shapely.geometry import Point, shape
534534+from typing import Optional, List, Dict, Any
535535+536536+537537+class WahlkreisLocator:
538538+ """Locate Bundestag constituency from lat/lng using GeoJSON boundaries."""
539539+540540+ _geojson_data: Optional[Dict[str, Any]] = None
541541+ _geometries: Optional[List[tuple]] = None
542542+543543+ GEOJSON_PATH = Path(__file__).parent / 'data' / 'wahlkreise.geojson'
544544+545545+ @classmethod
546546+ def _load_geojson(cls):
547547+ """Load GeoJSON data into memory (called once at startup)."""
548548+ if cls._geometries is not None:
549549+ return
550550+551551+ if not cls.GEOJSON_PATH.exists():
552552+ logger.warning(f"GeoJSON file not found: {cls.GEOJSON_PATH}")
553553+ logger.warning("Run: python manage.py fetch_wahlkreis_data")
554554+ cls._geometries = []
555555+ return
556556+557557+ try:
558558+ with open(cls.GEOJSON_PATH, 'r', encoding='utf-8') as f:
559559+ cls._geojson_data = json.load(f)
560560+561561+ # Pre-process geometries for faster lookup
562562+ cls._geometries = []
563563+ for feature in cls._geojson_data.get('features', []):
564564+ geometry = shape(feature['geometry'])
565565+ properties = feature.get('properties', {})
566566+567567+ # Extract Wahlkreis ID from properties
568568+ wahlkreis_id = properties.get('WKR_NR') or properties.get('id')
569569+ wahlkreis_name = properties.get('WKR_NAME') or properties.get('name')
570570+571571+ if wahlkreis_id:
572572+ cls._geometries.append((
573573+ str(wahlkreis_id),
574574+ wahlkreis_name,
575575+ geometry
576576+ ))
577577+578578+ logger.info(f"Loaded {len(cls._geometries)} constituencies from GeoJSON")
579579+580580+ except Exception as e:
581581+ logger.error(f"Failed to load GeoJSON: {e}")
582582+ cls._geometries = []
583583+584584+ @classmethod
585585+ def find_constituency(
586586+ cls,
587587+ latitude: float,
588588+ longitude: float
589589+ ) -> Optional[Constituency]:
590590+ """
591591+ Find the Bundestag constituency containing the given coordinates.
592592+593593+ Args:
594594+ latitude: Latitude in decimal degrees
595595+ longitude: Longitude in decimal degrees
596596+597597+ Returns:
598598+ Constituency object or None if not found
599599+ """
600600+ cls._load_geojson()
601601+602602+ if not cls._geometries:
603603+ logger.warning("No GeoJSON data loaded")
604604+ return None
605605+606606+ point = Point(longitude, latitude) # Note: shapely uses (x, y) = (lon, lat)
607607+608608+ # Find which polygon contains this point
609609+ for wahlkreis_id, wahlkreis_name, geometry in cls._geometries:
610610+ if geometry.contains(point):
611611+ logger.debug(f"Found constituency: {wahlkreis_name} (ID: {wahlkreis_id})")
612612+613613+ # Look up in database
614614+ constituency = Constituency.objects.filter(
615615+ external_id=wahlkreis_id,
616616+ scope='FEDERAL_DISTRICT'
617617+ ).first()
618618+619619+ if constituency:
620620+ return constituency
621621+ else:
622622+ logger.warning(
623623+ f"Constituency {wahlkreis_id} found in GeoJSON but not in database"
624624+ )
625625+ return None
626626+627627+ logger.debug(f"No constituency found for coordinates ({latitude}, {longitude})")
628628+ return None
629629+630630+ @classmethod
631631+ def clear_cache(cls):
632632+ """Clear cached GeoJSON data (useful for testing)."""
633633+ cls._geojson_data = None
634634+ cls._geometries = None
635635+```
636636+637637+**Step 4: Add shapely to requirements**
638638+639639+Check if shapely is in requirements:
640640+Run: `grep shapely pyproject.toml || grep shapely requirements.txt`
641641+642642+If not found, add to pyproject.toml dependencies:
643643+```toml
644644+dependencies = [
645645+ "django>=5.0",
646646+ "shapely>=2.0",
647647+ # ... other deps
648648+]
649649+```
650650+651651+**Step 5: Install shapely**
652652+653653+Run: `uv sync`
654654+Expected: "Resolved X packages in Yms"
655655+656656+**Step 6: Run test to verify it passes**
657657+658658+Run: `uv run python manage.py test letters.tests.WahlkreisLocatorTests -v`
659659+Expected: PASS (2 tests)
660660+661661+**Step 7: Commit**
662662+663663+```bash
664664+git add website/letters/services.py website/letters/tests.py pyproject.toml
665665+git commit -m "feat: add GeoJSON point-in-polygon constituency lookup"
666666+```
667667+668668+---
669669+670670+## Task 5: Integration - Update ConstituencyLocator
671671+672672+**Files:**
673673+- Modify: `website/letters/services.py` (update ConstituencyLocator class)
674674+- Test: `website/letters/tests.py`
675675+676676+**Step 1: Write integration test**
677677+678678+Add to `website/letters/tests.py`:
679679+680680+```python
681681+class ConstituencyLocatorIntegrationTests(TestCase):
682682+ """Test integrated address โ constituency lookup."""
683683+684684+ def setUp(self):
685685+ super().setUp()
686686+ self.parliament = Parliament.objects.create(
687687+ name='Deutscher Bundestag',
688688+ level='FEDERAL',
689689+ legislative_body='Bundestag',
690690+ region='DE',
691691+ )
692692+ self.term = ParliamentTerm.objects.create(
693693+ parliament=self.parliament,
694694+ name='20. Wahlperiode',
695695+ start_date=date(2021, 10, 26),
696696+ )
697697+ self.constituency_mitte = Constituency.objects.create(
698698+ parliament_term=self.term,
699699+ name='Berlin-Mitte',
700700+ scope='FEDERAL_DISTRICT',
701701+ external_id='75',
702702+ metadata={'state': 'Berlin'},
703703+ )
704704+705705+ @patch('letters.services.AddressGeocoder.geocode')
706706+ def test_locate_uses_address_geocoding(self, mock_geocode):
707707+ from .services import ConstituencyLocator
708708+709709+ # Mock geocoding to return Berlin-Mitte coordinates
710710+ mock_geocode.return_value = (52.5170365, 13.3888599)
711711+712712+ result = ConstituencyLocator.locate_from_address(
713713+ street='Unter den Linden 77',
714714+ postal_code='10117',
715715+ city='Berlin'
716716+ )
717717+718718+ self.assertIsNotNone(result.federal)
719719+ self.assertEqual(result.federal.external_id, '75')
720720+721721+ # Verify geocoder was called
722722+ mock_geocode.assert_called_once_with(
723723+ street='Unter den Linden 77',
724724+ postal_code='10117',
725725+ city='Berlin',
726726+ country='DE'
727727+ )
728728+729729+ def test_locate_falls_back_to_plz_prefix(self):
730730+ from .services import ConstituencyLocator
731731+732732+ # Test with just PLZ (no full address)
733733+ result = ConstituencyLocator.locate('10117')
734734+735735+ # Should return Berlin-based constituency using old heuristic
736736+ self.assertIsNotNone(result.federal)
737737+```
738738+739739+**Step 2: Run test to verify it fails**
740740+741741+Run: `uv run python manage.py test letters.tests.ConstituencyLocatorIntegrationTests -v`
742742+Expected: FAIL with "No method named 'locate_from_address'"
743743+744744+**Step 3: Add locate_from_address method**
745745+746746+Modify `ConstituencyLocator` class in `website/letters/services.py`:
747747+748748+```python
749749+class ConstituencyLocator:
750750+ """Enhanced mapping from addresses/postal codes to constituencies."""
751751+752752+ # ... existing STATE_BY_PLZ_PREFIX dict ...
753753+754754+ @classmethod
755755+ def locate_from_address(
756756+ cls,
757757+ street: str,
758758+ postal_code: str,
759759+ city: str,
760760+ country: str = 'DE'
761761+ ) -> LocatedConstituencies:
762762+ """
763763+ Locate constituency from full address using geocoding.
764764+765765+ This is the preferred method for accurate constituency matching.
766766+ Falls back to PLZ prefix if geocoding fails.
767767+ """
768768+ # Try accurate geocoding first
769769+ coordinates = AddressGeocoder.geocode(street, postal_code, city, country)
770770+771771+ if coordinates:
772772+ latitude, longitude = coordinates
773773+774774+ # Use GeoJSON lookup for federal constituency
775775+ federal_constituency = WahlkreisLocator.find_constituency(latitude, longitude)
776776+777777+ if federal_constituency:
778778+ # Also try to determine state from the federal constituency
779779+ state_name = (federal_constituency.metadata or {}).get('state')
780780+ state_constituency = None
781781+782782+ if state_name:
783783+ normalized_state = normalize_german_state(state_name)
784784+ state_constituency = cls._match_state(normalized_state)
785785+786786+ return LocatedConstituencies(
787787+ federal=federal_constituency,
788788+ state=state_constituency,
789789+ local=None
790790+ )
791791+792792+ # Fallback to PLZ prefix heuristic
793793+ logger.info(f"Falling back to PLZ prefix lookup for {postal_code}")
794794+ return cls.locate(postal_code)
795795+796796+ @classmethod
797797+ def locate(cls, postal_code: str) -> LocatedConstituencies:
798798+ """
799799+ Legacy PLZ prefix-based lookup.
800800+801801+ Use locate_from_address() for accurate results.
802802+ This method kept for backwards compatibility and fallback.
803803+ """
804804+ # ... existing implementation unchanged ...
805805+```
806806+807807+**Step 4: Run test to verify it passes**
808808+809809+Run: `uv run python manage.py test letters.tests.ConstituencyLocatorIntegrationTests -v`
810810+Expected: PASS (2 tests)
811811+812812+**Step 5: Commit**
813813+814814+```bash
815815+git add website/letters/services.py website/letters/tests.py
816816+git commit -m "feat: integrate address geocoding into ConstituencyLocator"
817817+```
818818+819819+---
820820+821821+## Task 6: Update ConstituencySuggestionService to Use Address
822822+823823+**Files:**
824824+- Modify: `website/letters/services.py` (update _resolve_location method)
825825+- Test: `website/letters/tests.py`
826826+827827+**Step 1: Write test for address-based suggestion**
828828+829829+Add to `website/letters/tests.py`:
830830+831831+```python
832832+class SuggestionServiceAddressTests(ParliamentFixtureMixin, TestCase):
833833+ """Test suggestions with full address input."""
834834+835835+ @patch('letters.services.AddressGeocoder.geocode')
836836+ def test_suggestions_with_full_address(self, mock_geocode):
837837+ from .services import ConstituencySuggestionService
838838+839839+ # Mock geocoding
840840+ mock_geocode.return_value = (52.5170365, 13.3888599)
841841+842842+ result = ConstituencySuggestionService.suggest_from_concern(
843843+ 'Mehr Investitionen in den รPNV',
844844+ user_location={
845845+ 'street': 'Unter den Linden 77',
846846+ 'postal_code': '10117',
847847+ 'city': 'Berlin',
848848+ }
849849+ )
850850+851851+ self.assertIsNotNone(result['constituencies'])
852852+ self.assertTrue(len(result['representatives']) > 0)
853853+```
854854+855855+**Step 2: Run test to verify it fails**
856856+857857+Run: `uv run python manage.py test letters.tests.SuggestionServiceAddressTests -v`
858858+Expected: FAIL (address not being used)
859859+860860+**Step 3: Update _resolve_location to handle addresses**
861861+862862+Modify `ConstituencySuggestionService._resolve_location` in `website/letters/services.py`:
863863+864864+```python
865865+@classmethod
866866+def _resolve_location(cls, user_location: Dict[str, str]) -> LocationContext:
867867+ """Resolve user location from various input formats."""
868868+869869+ # Check if full address is provided
870870+ street = (user_location.get('street') or '').strip()
871871+ postal_code = (user_location.get('postal_code') or '').strip()
872872+ city = (user_location.get('city') or '').strip()
873873+874874+ constituencies: List[Constituency] = []
875875+876876+ # Priority 1: Explicitly provided constituency IDs
877877+ provided_constituencies = user_location.get('constituencies')
878878+ if provided_constituencies:
879879+ iterable = provided_constituencies if isinstance(provided_constituencies, (list, tuple, set)) else [provided_constituencies]
880880+ for item in iterable:
881881+ constituency = None
882882+ if isinstance(item, Constituency):
883883+ constituency = item
884884+ else:
885885+ try:
886886+ constituency_id = int(item)
887887+ except (TypeError, ValueError):
888888+ constituency_id = None
889889+ if constituency_id:
890890+ constituency = Constituency.objects.filter(id=constituency_id).first()
891891+ if constituency and all(c.id != constituency.id for c in constituencies):
892892+ constituencies.append(constituency)
893893+894894+ # Priority 2: Full address geocoding
895895+ if not constituencies and city:
896896+ logger.info(f"Using address geocoding for: {city}")
897897+ located = ConstituencyLocator.locate_from_address(street, postal_code, city)
898898+ constituencies.extend(
899899+ constituency
900900+ for constituency in (located.local, located.state, located.federal)
901901+ if constituency
902902+ )
903903+904904+ # Priority 3: PLZ-only fallback
905905+ elif not constituencies and postal_code:
906906+ logger.info(f"Using PLZ fallback for: {postal_code}")
907907+ located = ConstituencyLocator.locate(postal_code)
908908+ constituencies.extend(
909909+ constituency
910910+ for constituency in (located.local, located.state, located.federal)
911911+ if constituency
912912+ )
913913+ else:
914914+ located = LocatedConstituencies(None, None, None)
915915+916916+ # Determine state
917917+ explicit_state = normalize_german_state(user_location.get('state')) if user_location.get('state') else None
918918+ inferred_state = None
919919+ for constituency in constituencies:
920920+ metadata_state = (constituency.metadata or {}).get('state') if constituency.metadata else None
921921+ if metadata_state:
922922+ inferred_state = normalize_german_state(metadata_state)
923923+ if inferred_state:
924924+ break
925925+926926+ state = explicit_state or inferred_state
927927+928928+ return LocationContext(
929929+ postal_code=postal_code or None,
930930+ state=state,
931931+ constituencies=constituencies,
932932+ )
933933+```
934934+935935+**Step 4: Run test to verify it passes**
936936+937937+Run: `uv run python manage.py test letters.tests.SuggestionServiceAddressTests -v`
938938+Expected: PASS
939939+940940+**Step 5: Commit**
941941+942942+```bash
943943+git add website/letters/services.py website/letters/tests.py
944944+git commit -m "feat: support full address in suggestion service"
945945+```
946946+947947+---
948948+949949+## Task 7: Update Profile View to Collect Full Address
950950+951951+**Files:**
952952+- Modify: `website/letters/forms.py` (update verification form)
953953+- Modify: `website/letters/templates/letters/profile.html`
954954+- Test: `website/letters/tests.py`
955955+956956+**Step 1: Write test for address collection**
957957+958958+Add to `website/letters/tests.py`:
959959+960960+```python
961961+class ProfileAddressCollectionTests(TestCase):
962962+ """Test profile form collects full address."""
963963+964964+ def setUp(self):
965965+ self.user = User.objects.create_user(
966966+ username='testuser',
967967+ password='password123',
968968+ email='test@example.com'
969969+ )
970970+971971+ def test_profile_form_has_address_fields(self):
972972+ from .forms import SelfDeclaredVerificationForm
973973+974974+ form = SelfDeclaredVerificationForm()
975975+976976+ self.assertIn('street', form.fields)
977977+ self.assertIn('postal_code', form.fields)
978978+ self.assertIn('city', form.fields)
979979+```
980980+981981+**Step 2: Run test to verify it fails**
982982+983983+Run: `uv run python manage.py test letters.tests.ProfileAddressCollectionTests -v`
984984+Expected: FAIL (fields don't exist)
985985+986986+**Step 3: Add address fields to form**
987987+988988+Modify `website/letters/forms.py` to add address fields to the verification form:
989989+990990+```python
991991+class SelfDeclaredVerificationForm(forms.Form):
992992+ """Form for self-declared constituency verification."""
993993+994994+ street = forms.CharField(
995995+ max_length=255,
996996+ required=False,
997997+ label=_("Street and Number"),
998998+ help_text=_("Optional: Improves constituency matching accuracy"),
999999+ widget=forms.TextInput(attrs={
10001000+ 'placeholder': _('Unter den Linden 77'),
10011001+ 'class': 'form-control'
10021002+ })
10031003+ )
10041004+10051005+ postal_code = forms.CharField(
10061006+ max_length=10,
10071007+ required=True,
10081008+ label=_("Postal Code"),
10091009+ widget=forms.TextInput(attrs={
10101010+ 'placeholder': '10117',
10111011+ 'class': 'form-control'
10121012+ })
10131013+ )
10141014+10151015+ city = forms.CharField(
10161016+ max_length=100,
10171017+ required=True,
10181018+ label=_("City"),
10191019+ widget=forms.TextInput(attrs={
10201020+ 'placeholder': 'Berlin',
10211021+ 'class': 'form-control'
10221022+ })
10231023+ )
10241024+10251025+ federal_constituency = forms.ModelChoiceField(
10261026+ queryset=Constituency.objects.filter(scope='FEDERAL_DISTRICT'),
10271027+ required=False,
10281028+ label=_("Federal Constituency (optional)"),
10291029+ help_text=_("Leave blank for automatic detection"),
10301030+ widget=forms.Select(attrs={'class': 'form-control'})
10311031+ )
10321032+10331033+ state_constituency = forms.ModelChoiceField(
10341034+ queryset=Constituency.objects.filter(scope__in=['STATE_DISTRICT', 'STATE_LIST']),
10351035+ required=False,
10361036+ label=_("State Constituency (optional)"),
10371037+ help_text=_("Leave blank for automatic detection"),
10381038+ widget=forms.Select(attrs={'class': 'form-control'})
10391039+ )
10401040+```
10411041+10421042+**Step 4: Update template to show address fields**
10431043+10441044+Modify `website/letters/templates/letters/profile.html` to show the new fields in the verification form section.
10451045+10461046+**Step 5: Update view to use address for verification**
10471047+10481048+Modify the `complete_verification` or equivalent view in `website/letters/views.py` to use the address fields:
10491049+10501050+```python
10511051+def complete_verification(request):
10521052+ if request.method == 'POST':
10531053+ form = SelfDeclaredVerificationForm(request.POST)
10541054+ if form.is_valid():
10551055+ street = form.cleaned_data.get('street', '')
10561056+ postal_code = form.cleaned_data['postal_code']
10571057+ city = form.cleaned_data['city']
10581058+10591059+ # Use address-based lookup if full address provided
10601060+ if city:
10611061+ located = ConstituencyLocator.locate_from_address(
10621062+ street, postal_code, city
10631063+ )
10641064+ else:
10651065+ located = ConstituencyLocator.locate(postal_code)
10661066+10671067+ federal = form.cleaned_data.get('federal_constituency') or located.federal
10681068+ state = form.cleaned_data.get('state_constituency') or located.state
10691069+10701070+ IdentityVerificationService.self_declare(
10711071+ request.user,
10721072+ federal_constituency=federal,
10731073+ state_constituency=state,
10741074+ )
10751075+10761076+ messages.success(request, _('Your constituency has been saved.'))
10771077+ return redirect('profile')
10781078+ else:
10791079+ form = SelfDeclaredVerificationForm()
10801080+10811081+ return render(request, 'letters/complete_verification.html', {'form': form})
10821082+```
10831083+10841084+**Step 6: Run test to verify it passes**
10851085+10861086+Run: `uv run python manage.py test letters.tests.ProfileAddressCollectionTests -v`
10871087+Expected: PASS
10881088+10891089+**Step 7: Test manually in browser**
10901090+10911091+1. Run dev server: `uv run python manage.py runserver`
10921092+2. Navigate to /profile/verify/
10931093+3. Verify address fields are visible
10941094+4. Submit form with full address
10951095+5. Check that constituency is correctly detected
10961096+10971097+**Step 8: Commit**
10981098+10991099+```bash
11001100+git add website/letters/forms.py website/letters/templates/letters/profile.html website/letters/views.py website/letters/tests.py
11011101+git commit -m "feat: collect full address for accurate constituency matching"
11021102+```
11031103+11041104+---
11051105+11061106+## Task 8: Add Management Command to Test Matching
11071107+11081108+**Files:**
11091109+- Create: `website/letters/management/commands/test_address_matching.py`
11101110+11111111+**Step 1: Create test command**
11121112+11131113+Create `website/letters/management/commands/test_address_matching.py`:
11141114+11151115+```python
11161116+from django.core.management.base import BaseCommand
11171117+from letters.services import AddressGeocoder, WahlkreisLocator, ConstituencyLocator
11181118+11191119+11201120+class Command(BaseCommand):
11211121+ help = "Test address matching with sample German addresses"
11221122+11231123+ TEST_ADDRESSES = [
11241124+ # Berlin
11251125+ ("Unter den Linden 77", "10117", "Berlin"),
11261126+ ("Pariser Platz 1", "10117", "Berlin"),
11271127+11281128+ # Munich
11291129+ ("Marienplatz 8", "80331", "Mรผnchen"),
11301130+ ("Leopoldstraรe 1", "80802", "Mรผnchen"),
11311131+11321132+ # Hamburg
11331133+ ("Rathausmarkt 1", "20095", "Hamburg"),
11341134+ ("Jungfernstieg 1", "20095", "Hamburg"),
11351135+11361136+ # Cologne
11371137+ ("Rathausplatz 2", "50667", "Kรถln"),
11381138+11391139+ # Frankfurt
11401140+ ("Rรถmerberg 27", "60311", "Frankfurt am Main"),
11411141+ ]
11421142+11431143+ def handle(self, *args, **options):
11441144+ self.stdout.write(self.style.SUCCESS("Testing Address Matching\n"))
11451145+11461146+ for street, plz, city in self.TEST_ADDRESSES:
11471147+ self.stdout.write(f"\n{street}, {plz} {city}")
11481148+ self.stdout.write("-" * 60)
11491149+11501150+ # Test geocoding
11511151+ coords = AddressGeocoder.geocode(street, plz, city)
11521152+ if coords:
11531153+ lat, lng = coords
11541154+ self.stdout.write(f" Coordinates: {lat:.6f}, {lng:.6f}")
11551155+11561156+ # Test constituency lookup
11571157+ constituency = WahlkreisLocator.find_constituency(lat, lng)
11581158+ if constituency:
11591159+ self.stdout.write(self.style.SUCCESS(
11601160+ f" โ Constituency: {constituency.name} (ID: {constituency.external_id})"
11611161+ ))
11621162+ else:
11631163+ self.stdout.write(self.style.WARNING(" โ No constituency found"))
11641164+ else:
11651165+ self.stdout.write(self.style.ERROR(" โ Geocoding failed"))
11661166+11671167+ # Small delay to respect rate limits
11681168+ import time
11691169+ time.sleep(1.1)
11701170+11711171+ self.stdout.write("\n" + self.style.SUCCESS("Testing complete!"))
11721172+```
11731173+11741174+**Step 2: Run test command**
11751175+11761176+Run: `uv run python manage.py test_address_matching`
11771177+Expected: Shows results for 8 test addresses
11781178+11791179+**Step 3: Review results and fix any issues**
11801180+11811181+Check that:
11821182+- All addresses geocode successfully
11831183+- Constituencies are found for each address
11841184+- Results match expected Wahlkreise
11851185+11861186+**Step 4: Commit**
11871187+11881188+```bash
11891189+git add website/letters/management/commands/test_address_matching.py
11901190+git commit -m "feat: add management command to test address matching"
11911191+```
11921192+11931193+---
11941194+11951195+## Task 9: Performance Optimization and Monitoring
11961196+11971197+**Files:**
11981198+- Modify: `website/letters/services.py` (add metrics/monitoring)
11991199+- Create: `website/letters/middleware.py` (optional)
12001200+12011201+**Step 1: Add logging for matching performance**
12021202+12031203+Add to `WahlkreisLocator.find_constituency`:
12041204+12051205+```python
12061206+import time
12071207+12081208+@classmethod
12091209+def find_constituency(cls, latitude: float, longitude: float) -> Optional[Constituency]:
12101210+ start_time = time.time()
12111211+12121212+ cls._load_geojson()
12131213+12141214+ # ... existing implementation ...
12151215+12161216+ elapsed = time.time() - start_time
12171217+ logger.info(f"Constituency lookup took {elapsed*1000:.1f}ms")
12181218+12191219+ return result
12201220+```
12211221+12221222+**Step 2: Add cache warming on startup**
12231223+12241224+Add Django app ready hook to pre-load GeoJSON:
12251225+12261226+Modify `website/letters/apps.py`:
12271227+12281228+```python
12291229+from django.apps import AppConfig
12301230+12311231+12321232+class LettersConfig(AppConfig):
12331233+ default_auto_field = 'django.db.models.BigAutoField'
12341234+ name = 'letters'
12351235+12361236+ def ready(self):
12371237+ """Pre-load GeoJSON data on startup."""
12381238+ from .services import WahlkreisLocator
12391239+ WahlkreisLocator._load_geojson()
12401240+```
12411241+12421242+**Step 3: Test performance**
12431243+12441244+Run: `uv run python -m django shell`
12451245+12461246+```python
12471247+from letters.services import WahlkreisLocator
12481248+import time
12491249+12501250+# Test lookup performance
12511251+start = time.time()
12521252+result = WahlkreisLocator.find_constituency(52.5170365, 13.3888599)
12531253+elapsed = time.time() - start
12541254+12551255+print(f"Lookup took {elapsed*1000:.1f}ms")
12561256+print(f"Found: {result.name if result else 'None'}")
12571257+```
12581258+12591259+Expected: < 50ms per lookup
12601260+12611261+**Step 4: Commit**
12621262+12631263+```bash
12641264+git add website/letters/services.py website/letters/apps.py
12651265+git commit -m "perf: optimize constituency lookup with startup cache warming"
12661266+```
12671267+12681268+---
12691269+12701270+## Task 10: Documentation and README
12711271+12721272+**Files:**
12731273+- Modify: `README.md`
12741274+- Create: `docs/matching-algorithm.md`
12751275+12761276+**Step 1: Document matching algorithm**
12771277+12781278+Create `docs/matching-algorithm.md`:
12791279+12801280+```markdown
12811281+# Constituency Matching Algorithm
12821282+12831283+## Overview
12841284+12851285+WriteThem.eu uses a two-stage process to match users to their correct Bundestag constituency:
12861286+12871287+1. **Address Geocoding**: Convert user's address to latitude/longitude coordinates
12881288+2. **Point-in-Polygon Lookup**: Find which constituency polygon contains those coordinates
12891289+12901290+## Stage 1: Address Geocoding
12911291+12921292+We use OpenStreetMap's Nominatim API to convert addresses to coordinates.
12931293+12941294+### Process:
12951295+1. User provides: Street, Postal Code, City
12961296+2. System checks cache (GeocodeCache table) for previous results
12971297+3. If not cached, query Nominatim API with rate limiting (1 req/sec)
12981298+4. Cache result (success or failure) to minimize API calls
12991299+5. Return (latitude, longitude) or None
13001300+13011301+### Fallback:
13021302+If geocoding fails or user only provides postal code, fall back to PLZ prefix heuristic (maps first 2 digits to state).
13031303+13041304+## Stage 2: Point-in-Polygon Lookup
13051305+13061306+We use official Bundestag constituency boundaries (GeoJSON format) with shapely for geometric queries.
13071307+13081308+### Process:
13091309+1. Load GeoJSON with 299 Bundestag constituencies on startup
13101310+2. Create shapely Point from coordinates
13111311+3. Check which constituency Polygon contains the point
13121312+4. Look up Constituency object in database by external_id
13131313+5. Return Constituency or None
13141314+13151315+### Performance:
13161316+- GeoJSON loaded once at startup (~2MB in memory)
13171317+- Lookup typically takes 10-50ms
13181318+- No external API calls required
13191319+13201320+## Data Sources
13211321+13221322+- **Constituency Boundaries**: [dknx01/wahlkreissuche](https://github.com/dknx01/wahlkreissuche) (Open Data)
13231323+- **Geocoding**: [OpenStreetMap Nominatim](https://nominatim.openstreetmap.org/) (Open Data)
13241324+- **Representative Data**: [Abgeordnetenwatch API](https://www.abgeordnetenwatch.de/api)
13251325+13261326+## Accuracy
13271327+13281328+This approach provides constituency-accurate matching (exact Wahlkreis), significantly more precise than PLZ-based heuristics which only provide state-level accuracy.
13291329+13301330+### Known Limitations:
13311331+- Requires valid German address
13321332+- Dependent on OSM geocoding quality
13331333+- Rate limited to 1 request/second (public API)
13341334+```
13351335+13361336+**Step 2: Update README with setup instructions**
13371337+13381338+Add to `README.md`:
13391339+13401340+```markdown
13411341+## Setup: Constituency Matching
13421342+13431343+WriteThem.eu uses accurate address-based constituency matching. Setup requires two steps:
13441344+13451345+### 1. Download Constituency Boundaries
13461346+13471347+```bash
13481348+uv run python manage.py fetch_wahlkreis_data
13491349+```
13501350+13511351+This downloads ~2MB of GeoJSON data containing official Bundestag constituency boundaries.
13521352+13531353+### 2. Test Matching
13541354+13551355+Test the matching system with sample addresses:
13561356+13571357+```bash
13581358+uv run python manage.py test_address_matching
13591359+```
13601360+13611361+You should see successful geocoding and constituency detection for major German cities.
13621362+13631363+### Configuration
13641364+13651365+Set in your environment or settings:
13661366+13671367+```python
13681368+# Optional: Use self-hosted Nominatim (recommended for production)
13691369+NOMINATIM_URL = 'https://your-nominatim-server.com/search'
13701370+13711371+# Optional: Custom GeoJSON path
13721372+CONSTITUENCY_BOUNDARIES_PATH = '/path/to/wahlkreise.geojson'
13731373+```
13741374+13751375+See `docs/matching-algorithm.md` for technical details.
13761376+```
13771377+13781378+**Step 3: Commit**
13791379+13801380+```bash
13811381+git add README.md docs/matching-algorithm.md
13821382+git commit -m "docs: document constituency matching algorithm"
13831383+```
13841384+13851385+---
13861386+13871387+## Plan Complete
13881388+13891389+**Total Implementation Time: ~5-8 hours** (experienced developer, TDD approach)
13901390+13911391+**Testing Checklist:**
13921392+- [ ] All unit tests pass
13931393+- [ ] Integration tests pass
13941394+- [ ] Manual testing with 10+ real addresses
13951395+- [ ] Performance < 100ms end-to-end
13961396+- [ ] Geocoding cache reducing API calls
13971397+13981398+**Next Steps:**
13991399+Run full test suite:
14001400+```bash
14011401+uv run python manage.py test letters
14021402+```
14031403+14041404+Expected: All tests pass (20+ existing tests + ~15 new tests = 35+ total)
14051405+14061406+**Deployment Notes:**
14071407+- Download GeoJSON as part of deployment process
14081408+- Consider self-hosted Nominatim for production (no rate limits)
14091409+- Monitor geocoding cache hit rate
14101410+- Set up alerts for geocoding failures
14111411+14121412+---
14131413+14141414+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
···11+# German + English Internationalization Implementation Plan
22+33+> **For Claude:** Use `${SUPERPOWERS_SKILLS_ROOT}/skills/collaboration/executing-plans/SKILL.md` to implement this plan task-by-task.
44+55+**Goal:** Implement full bilingual support (German + English) using Django's built-in i18n system.
66+77+**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.
88+99+**Tech Stack:** Django 5.2 i18n framework, gettext, .po/.mo translation files, LocaleMiddleware
1010+1111+---
1212+1313+## Task 1: Configure Django i18n Settings
1414+1515+**Files:**
1616+- Modify: `website/writethem/settings.py:104-146`
1717+1818+**Step 1: Write the failing test**
1919+2020+Create: `website/letters/tests/test_i18n.py`
2121+2222+```python
2323+# ABOUTME: Tests for internationalization configuration and functionality.
2424+# ABOUTME: Verifies language switching, URL prefixes, and translation completeness.
2525+2626+from django.test import TestCase
2727+from django.conf import settings
2828+2929+3030+class I18nConfigurationTests(TestCase):
3131+ def test_i18n_enabled(self):
3232+ """Test that USE_I18N is enabled."""
3333+ self.assertTrue(settings.USE_I18N)
3434+3535+ def test_supported_languages(self):
3636+ """Test that German and English are configured."""
3737+ language_codes = [code for code, name in settings.LANGUAGES]
3838+ self.assertIn('de', language_codes)
3939+ self.assertIn('en', language_codes)
4040+4141+ def test_locale_paths_configured(self):
4242+ """Test that LOCALE_PATHS is set."""
4343+ self.assertTrue(len(settings.LOCALE_PATHS) > 0)
4444+```
4545+4646+**Step 2: Run test to verify it fails**
4747+4848+Run: `uv run python manage.py test letters.tests.test_i18n::I18nConfigurationTests -v`
4949+Expected: FAIL with assertion errors (USE_I18N=False, LANGUAGES not configured, LOCALE_PATHS not set)
5050+5151+**Step 3: Update settings.py**
5252+5353+In `website/writethem/settings.py`, replace lines 104-114:
5454+5555+```python
5656+# Internationalization
5757+# https://docs.djangoproject.com/en/5.2/topics/i18n/
5858+5959+LANGUAGE_CODE = 'de'
6060+LANGUAGES = [
6161+ ('de', 'Deutsch'),
6262+ ('en', 'English'),
6363+]
6464+6565+TIME_ZONE = 'Europe/Berlin'
6666+6767+USE_I18N = True
6868+USE_L10N = True
6969+7070+USE_TZ = True
7171+7272+# Locale paths - where Django looks for .po files
7373+LOCALE_PATHS = [
7474+ BASE_DIR / 'locale',
7575+]
7676+```
7777+7878+**Step 4: Add LocaleMiddleware**
7979+8080+In `website/writethem/settings.py`, modify MIDDLEWARE list (lines 43-51):
8181+8282+```python
8383+MIDDLEWARE = [
8484+ 'django.middleware.security.SecurityMiddleware',
8585+ 'django.contrib.sessions.middleware.SessionMiddleware',
8686+ 'django.middleware.locale.LocaleMiddleware', # NEW - handles language detection
8787+ 'django.middleware.common.CommonMiddleware',
8888+ 'django.middleware.csrf.CsrfViewMiddleware',
8989+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
9090+ 'django.contrib.messages.middleware.MessageMiddleware',
9191+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
9292+]
9393+```
9494+9595+**Step 5: Run test to verify it passes**
9696+9797+Run: `uv run python manage.py test letters.tests.test_i18n::I18nConfigurationTests -v`
9898+Expected: PASS (3 tests)
9999+100100+**Step 6: Commit**
101101+102102+```bash
103103+git add website/writethem/settings.py website/letters/tests/test_i18n.py
104104+git commit -m "feat: configure Django i18n with German and English support"
105105+```
106106+107107+---
108108+109109+## Task 2: Configure URL Patterns with Language Prefixes
110110+111111+**Files:**
112112+- Modify: `website/writethem/urls.py`
113113+114114+**Step 1: Write the failing test**
115115+116116+Add to `website/letters/tests/test_i18n.py`:
117117+118118+```python
119119+class I18nURLTests(TestCase):
120120+ def test_german_url_prefix_works(self):
121121+ """Test that German URL prefix is accessible."""
122122+ response = self.client.get('/de/')
123123+ self.assertEqual(response.status_code, 200)
124124+125125+ def test_english_url_prefix_works(self):
126126+ """Test that English URL prefix is accessible."""
127127+ response = self.client.get('/en/')
128128+ self.assertEqual(response.status_code, 200)
129129+130130+ def test_set_language_endpoint_exists(self):
131131+ """Test that language switcher endpoint exists."""
132132+ from django.urls import reverse
133133+ url = reverse('set_language')
134134+ self.assertEqual(url, '/i18n/setlang/')
135135+```
136136+137137+**Step 2: Run test to verify it fails**
138138+139139+Run: `uv run python manage.py test letters.tests.test_i18n::I18nURLTests -v`
140140+Expected: FAIL (URLs not configured with language prefixes)
141141+142142+**Step 3: Update URLs configuration**
143143+144144+Replace entire contents of `website/writethem/urls.py`:
145145+146146+```python
147147+"""
148148+URL configuration for writethem project.
149149+150150+The `urlpatterns` list routes URLs to views. For more information please see:
151151+ https://docs.djangoproject.com/en/5.2/topics/http/urls/
152152+Examples:
153153+Function views
154154+ 1. Add an import: from my_app import views
155155+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
156156+Class-based views
157157+ 1. Add an import: from other_app.views import Home
158158+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
159159+Including another URLconf
160160+ 1. Import the include() function: from django.urls import include, path
161161+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
162162+"""
163163+from django.contrib import admin
164164+from django.urls import path, include
165165+from django.conf import settings
166166+from django.conf.urls.static import static
167167+from django.conf.urls.i18n import i18n_patterns
168168+from django.views.i18n import set_language
169169+170170+urlpatterns = [
171171+ # Language switcher endpoint (no prefix)
172172+ path('i18n/setlang/', set_language, name='set_language'),
173173+]
174174+175175+# All user-facing URLs get language prefix
176176+urlpatterns += i18n_patterns(
177177+ path('admin/', admin.site.urls),
178178+ path('', include('letters.urls')),
179179+ prefix_default_language=True,
180180+)
181181+182182+if settings.DEBUG:
183183+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
184184+```
185185+186186+**Step 4: Run test to verify it passes**
187187+188188+Run: `uv run python manage.py test letters.tests.test_i18n::I18nURLTests -v`
189189+Expected: PASS (3 tests)
190190+191191+**Step 5: Commit**
192192+193193+```bash
194194+git add website/writethem/urls.py
195195+git commit -m "feat: add i18n URL patterns with language prefixes"
196196+```
197197+198198+---
199199+200200+## Task 3: Create Locale Directory Structure
201201+202202+**Files:**
203203+- Create: `website/locale/` directory structure
204204+205205+**Step 1: Create directory structure**
206206+207207+Run:
208208+```bash
209209+cd website
210210+mkdir -p locale/de/LC_MESSAGES
211211+mkdir -p locale/en/LC_MESSAGES
212212+```
213213+214214+**Step 2: Verify directories exist**
215215+216216+Run: `ls -la locale/`
217217+Expected: Shows `de/` and `en/` directories
218218+219219+**Step 3: Create .gitkeep files**
220220+221221+Run:
222222+```bash
223223+touch locale/de/LC_MESSAGES/.gitkeep
224224+touch locale/en/LC_MESSAGES/.gitkeep
225225+```
226226+227227+This ensures git tracks the directory structure even before .po files are created.
228228+229229+**Step 4: Commit**
230230+231231+```bash
232232+git add locale/
233233+git commit -m "feat: create locale directory structure for translations"
234234+```
235235+236236+---
237237+238238+## Task 4: Wrap Base Template Strings
239239+240240+**Files:**
241241+- Modify: `website/letters/templates/letters/base.html`
242242+243243+**Step 1: Review current base template**
244244+245245+Run: `cat website/letters/templates/letters/base.html`
246246+247247+Identify all hardcoded strings that need translation.
248248+249249+**Step 2: Add i18n load tag and wrap strings**
250250+251251+At the top of `website/letters/templates/letters/base.html`, add after the first line:
252252+253253+```django
254254+{% load i18n %}
255255+```
256256+257257+Then wrap all user-facing strings with `{% trans %}` tags. For example:
258258+259259+- Navigation links: `{% trans "Home" %}`, `{% trans "Letters" %}`, `{% trans "Login" %}`, etc.
260260+- Button text: `{% trans "Sign Out" %}`, `{% trans "Sign In" %}`, etc.
261261+- Any other UI text
262262+263263+**Important:** The exact changes depend on the current template content. Wrap every hardcoded user-facing string.
264264+265265+**Step 3: Test template renders without errors**
266266+267267+Run: `uv run python manage.py runserver`
268268+Visit: `http://localhost:8000/de/`
269269+Expected: Page loads without template errors (strings still in English because .po files don't exist yet)
270270+271271+**Step 4: Commit**
272272+273273+```bash
274274+git add website/letters/templates/letters/base.html
275275+git commit -m "feat: wrap base template strings with i18n tags"
276276+```
277277+278278+---
279279+280280+## Task 5: Add Language Switcher Component
281281+282282+**Files:**
283283+- Modify: `website/letters/templates/letters/base.html`
284284+285285+**Step 1: Write the failing test**
286286+287287+Add to `website/letters/tests/test_i18n.py`:
288288+289289+```python
290290+class LanguageSwitcherTests(TestCase):
291291+ def test_language_switcher_present_in_page(self):
292292+ """Test that language switcher form is present."""
293293+ response = self.client.get('/de/')
294294+ self.assertContains(response, 'name="language"')
295295+ self.assertContains(response, 'Deutsch')
296296+ self.assertContains(response, 'English')
297297+298298+ def test_language_switch_changes_language(self):
299299+ """Test that submitting language form changes language."""
300300+ response = self.client.post(
301301+ '/i18n/setlang/',
302302+ {'language': 'en', 'next': '/en/'},
303303+ follow=True
304304+ )
305305+ self.assertEqual(response.status_code, 200)
306306+ # Check cookie was set
307307+ self.assertIn('django_language', response.cookies)
308308+```
309309+310310+**Step 2: Run test to verify it fails**
311311+312312+Run: `uv run python manage.py test letters.tests.test_i18n::LanguageSwitcherTests -v`
313313+Expected: FAIL (language switcher not present)
314314+315315+**Step 3: Add language switcher to base template**
316316+317317+In `website/letters/templates/letters/base.html`, add this component in an appropriate location (e.g., in the header/navigation area):
318318+319319+```django
320320+<div class="language-switcher">
321321+ <form action="{% url 'set_language' %}" method="post">
322322+ {% csrf_token %}
323323+ <input name="next" type="hidden" value="{{ request.get_full_path }}">
324324+ <select name="language" onchange="this.form.submit()" aria-label="{% trans 'Select language' %}">
325325+ {% get_current_language as CURRENT_LANGUAGE %}
326326+ {% get_available_languages as AVAILABLE_LANGUAGES %}
327327+ {% for lang_code, lang_name in AVAILABLE_LANGUAGES %}
328328+ <option value="{{ lang_code }}" {% if lang_code == CURRENT_LANGUAGE %}selected{% endif %}>
329329+ {{ lang_name }}
330330+ </option>
331331+ {% endfor %}
332332+ </select>
333333+ </form>
334334+</div>
335335+```
336336+337337+**Step 4: Run test to verify it passes**
338338+339339+Run: `uv run python manage.py test letters.tests.test_i18n::LanguageSwitcherTests -v`
340340+Expected: PASS (2 tests)
341341+342342+**Step 5: Commit**
343343+344344+```bash
345345+git add website/letters/templates/letters/base.html
346346+git commit -m "feat: add language switcher component to base template"
347347+```
348348+349349+---
350350+351351+## Task 6: Wrap Authentication Template Strings
352352+353353+**Files:**
354354+- Modify: `website/letters/templates/registration/login.html`
355355+- Modify: `website/letters/templates/registration/register.html`
356356+- Modify: `website/letters/templates/registration/password_reset_form.html`
357357+- Modify: `website/letters/templates/registration/password_reset_done.html`
358358+- Modify: `website/letters/templates/registration/password_reset_confirm.html`
359359+- Modify: `website/letters/templates/registration/password_reset_complete.html`
360360+361361+**Step 1: Add i18n load tag to each template**
362362+363363+For each template file listed above, add at the top (after `{% extends %}`):
364364+365365+```django
366366+{% load i18n %}
367367+```
368368+369369+**Step 2: Wrap all strings with trans tags**
370370+371371+For each template, wrap user-facing strings:
372372+- Headings: `<h1>{% trans "Login" %}</h1>`
373373+- Labels: `{% trans "Email" %}`, `{% trans "Password" %}`
374374+- Buttons: `{% trans "Sign In" %}`, `{% trans "Register" %}`, `{% trans "Reset Password" %}`
375375+- Messages: `{% trans "Forgot your password?" %}`, etc.
376376+377377+**Step 3: Test templates render**
378378+379379+Run: `uv run python manage.py runserver`
380380+Visit each auth page:
381381+- `/de/login/`
382382+- `/de/register/`
383383+- `/de/password-reset/`
384384+385385+Expected: Pages load without errors
386386+387387+**Step 4: Commit**
388388+389389+```bash
390390+git add website/letters/templates/registration/
391391+git commit -m "feat: wrap authentication template strings with i18n tags"
392392+```
393393+394394+---
395395+396396+## Task 7: Wrap Letter List and Detail Template Strings
397397+398398+**Files:**
399399+- Modify: `website/letters/templates/letters/letter_list.html`
400400+- Modify: `website/letters/templates/letters/letter_detail.html`
401401+402402+**Step 1: Add i18n load tag**
403403+404404+Add to both templates after `{% extends %}`:
405405+406406+```django
407407+{% load i18n %}
408408+```
409409+410410+**Step 2: Wrap strings in letter_list.html**
411411+412412+Wrap all user-facing strings:
413413+- Headings: `{% trans "Open Letters" %}`
414414+- Buttons: `{% trans "Write Letter" %}`, `{% trans "Filter" %}`, `{% trans "Sort" %}`
415415+- Labels: `{% trans "Topic" %}`, `{% trans "Signatures" %}`
416416+- Empty states: `{% trans "No letters found" %}`
417417+418418+For pluralization (e.g., signature counts), use `{% blocktrans %}`:
419419+420420+```django
421421+{% blocktrans count counter=letter.signatures.count %}
422422+ {{ counter }} signature
423423+{% plural %}
424424+ {{ counter }} signatures
425425+{% endblocktrans %}
426426+```
427427+428428+**Step 3: Wrap strings in letter_detail.html**
429429+430430+Wrap all strings:
431431+- Buttons: `{% trans "Sign Letter" %}`, `{% trans "Remove Signature" %}`, `{% trans "Share" %}`, `{% trans "Report" %}`
432432+- Labels: `{% trans "Recipient" %}`, `{% trans "Published" %}`, `{% trans "Signatures" %}`
433433+- Messages: `{% trans "You have signed this letter" %}`
434434+435435+**Step 4: Test templates render**
436436+437437+Run: `uv run python manage.py runserver`
438438+Visit: `/de/letters/` and any letter detail page
439439+Expected: Pages load without errors
440440+441441+**Step 5: Commit**
442442+443443+```bash
444444+git add website/letters/templates/letters/letter_list.html website/letters/templates/letters/letter_detail.html
445445+git commit -m "feat: wrap letter list and detail template strings with i18n tags"
446446+```
447447+448448+---
449449+450450+## Task 8: Wrap Letter Creation Template Strings
451451+452452+**Files:**
453453+- Modify: `website/letters/templates/letters/letter_form.html`
454454+455455+**Step 1: Add i18n load tag**
456456+457457+```django
458458+{% load i18n %}
459459+```
460460+461461+**Step 2: Wrap all strings**
462462+463463+Wrap:
464464+- Headings: `{% trans "Write an Open Letter" %}`
465465+- Form labels: `{% trans "Title" %}`, `{% trans "Content" %}`, `{% trans "Recipient" %}`
466466+- Help text: `{% trans "Minimum 500 characters" %}`
467467+- Warnings: `{% trans "Once published, letters cannot be edited" %}`
468468+- Buttons: `{% trans "Publish Letter" %}`, `{% trans "Preview" %}`, `{% trans "Cancel" %}`
469469+470470+**Step 3: Update form class with verbose_name**
471471+472472+Modify: `website/letters/forms.py`
473473+474474+Add at the top:
475475+```python
476476+from django.utils.translation import gettext_lazy as _
477477+```
478478+479479+For each form field, add `label` parameter:
480480+```python
481481+title = forms.CharField(
482482+ label=_("Title"),
483483+ max_length=200,
484484+ help_text=_("A clear, concise title for your letter")
485485+)
486486+```
487487+488488+**Step 4: Test template renders**
489489+490490+Visit: `/de/letters/new/`
491491+Expected: Page loads without errors
492492+493493+**Step 5: Commit**
494494+495495+```bash
496496+git add website/letters/templates/letters/letter_form.html website/letters/forms.py
497497+git commit -m "feat: wrap letter creation template and form strings with i18n"
498498+```
499499+500500+---
501501+502502+## Task 9: Wrap Profile and Account Template Strings
503503+504504+**Files:**
505505+- Modify: `website/letters/templates/letters/profile.html`
506506+- Modify: `website/letters/templates/letters/account_delete.html`
507507+- Modify: Any other account-related templates
508508+509509+**Step 1: Add i18n load tag to each template**
510510+511511+```django
512512+{% load i18n %}
513513+```
514514+515515+**Step 2: Wrap all strings**
516516+517517+Profile page:
518518+- Headings: `{% trans "Your Profile" %}`, `{% trans "Authored Letters" %}`, `{% trans "Signed Letters" %}`
519519+- Buttons: `{% trans "Edit Profile" %}`, `{% trans "Delete Account" %}`
520520+- Labels: `{% trans "Email" %}`, `{% trans "Verified" %}`, `{% trans "Unverified" %}`
521521+522522+Account deletion:
523523+- Warnings: `{% trans "This action cannot be undone" %}`
524524+- Buttons: `{% trans "Confirm Deletion" %}`, `{% trans "Cancel" %}`
525525+526526+**Step 3: Test templates render**
527527+528528+Visit profile and account pages
529529+Expected: Pages load without errors
530530+531531+**Step 4: Commit**
532532+533533+```bash
534534+git add website/letters/templates/letters/profile.html website/letters/templates/letters/account_delete.html
535535+git commit -m "feat: wrap profile and account template strings with i18n tags"
536536+```
537537+538538+---
539539+540540+## Task 10: Extract Translation Strings to .po Files
541541+542542+**Files:**
543543+- Create: `website/locale/de/LC_MESSAGES/django.po`
544544+- Create: `website/locale/en/LC_MESSAGES/django.po`
545545+546546+**Step 1: Run makemessages for German**
547547+548548+Run:
549549+```bash
550550+cd website
551551+uv run python manage.py makemessages -l de
552552+```
553553+554554+Expected: Creates/updates `locale/de/LC_MESSAGES/django.po` with all translatable strings
555555+556556+**Step 2: Run makemessages for English**
557557+558558+Run:
559559+```bash
560560+uv run python manage.py makemessages -l en
561561+```
562562+563563+Expected: Creates/updates `locale/en/LC_MESSAGES/django.po`
564564+565565+**Step 3: Verify .po files created**
566566+567567+Run:
568568+```bash
569569+ls -la locale/de/LC_MESSAGES/
570570+ls -la locale/en/LC_MESSAGES/
571571+```
572572+573573+Expected: Both show `django.po` files
574574+575575+**Step 4: Check .po file contents**
576576+577577+Run:
578578+```bash
579579+head -n 30 locale/de/LC_MESSAGES/django.po
580580+```
581581+582582+Expected: Shows header and first few msgid/msgstr pairs
583583+584584+**Step 5: Commit**
585585+586586+```bash
587587+git add locale/
588588+git commit -m "feat: extract translatable strings to .po files"
589589+```
590590+591591+---
592592+593593+## Task 11: Translate German Strings in .po File
594594+595595+**Files:**
596596+- Modify: `website/locale/de/LC_MESSAGES/django.po`
597597+598598+**Step 1: Open German .po file**
599599+600600+Open `website/locale/de/LC_MESSAGES/django.po` for editing
601601+602602+**Step 2: Translate strings systematically**
603603+604604+Go through each `msgid` and add German translation to `msgstr`:
605605+606606+```po
607607+#: letters/templates/letters/base.html:10
608608+msgid "Home"
609609+msgstr "Startseite"
610610+611611+#: letters/templates/letters/base.html:11
612612+msgid "Letters"
613613+msgstr "Briefe"
614614+615615+#: letters/templates/letters/base.html:12
616616+msgid "Sign In"
617617+msgstr "Anmelden"
618618+619619+#: letters/templates/letters/base.html:13
620620+msgid "Sign Out"
621621+msgstr "Abmelden"
622622+623623+#: letters/templates/letters/letter_list.html:5
624624+msgid "Open Letters"
625625+msgstr "Offene Briefe"
626626+627627+#: letters/templates/letters/letter_list.html:8
628628+msgid "Write Letter"
629629+msgstr "Brief Schreiben"
630630+631631+#: letters/templates/letters/letter_detail.html:15
632632+msgid "Sign Letter"
633633+msgstr "Brief Unterschreiben"
634634+635635+#: letters/templates/letters/letter_detail.html:18
636636+msgid "Remove Signature"
637637+msgstr "Unterschrift Entfernen"
638638+639639+#: letters/templates/letters/letter_detail.html:21
640640+msgid "Share"
641641+msgstr "Teilen"
642642+643643+#: letters/templates/letters/letter_form.html:5
644644+msgid "Write an Open Letter"
645645+msgstr "Einen Offenen Brief Schreiben"
646646+647647+#: letters/templates/letters/letter_form.html:10
648648+msgid "Title"
649649+msgstr "Titel"
650650+651651+#: letters/templates/letters/letter_form.html:11
652652+msgid "Content"
653653+msgstr "Inhalt"
654654+655655+#: letters/templates/letters/letter_form.html:12
656656+msgid "Minimum 500 characters"
657657+msgstr "Mindestens 500 Zeichen"
658658+659659+#: letters/templates/letters/letter_form.html:15
660660+msgid "Once published, letters cannot be edited"
661661+msgstr "Nach Verรถffentlichung kรถnnen Briefe nicht mehr bearbeitet werden"
662662+663663+#: letters/templates/letters/letter_form.html:20
664664+msgid "Publish Letter"
665665+msgstr "Brief Verรถffentlichen"
666666+667667+#: letters/templates/registration/login.html:5
668668+msgid "Login"
669669+msgstr "Anmeldung"
670670+671671+#: letters/templates/registration/login.html:10
672672+msgid "Email"
673673+msgstr "E-Mail"
674674+675675+#: letters/templates/registration/login.html:11
676676+msgid "Password"
677677+msgstr "Passwort"
678678+679679+#: letters/templates/registration/login.html:15
680680+msgid "Forgot your password?"
681681+msgstr "Passwort vergessen?"
682682+683683+#: letters/templates/registration/register.html:5
684684+msgid "Register"
685685+msgstr "Registrieren"
686686+```
687687+688688+**Note:** The exact strings will depend on what was extracted in Task 10. Translate ALL msgid entries systematically.
689689+690690+**Step 3: Save the file**
691691+692692+Ensure all translations are complete (no empty `msgstr ""` entries)
693693+694694+**Step 4: Commit**
695695+696696+```bash
697697+git add locale/de/LC_MESSAGES/django.po
698698+git commit -m "feat: add German translations to .po file"
699699+```
700700+701701+---
702702+703703+## Task 12: Populate English .po File
704704+705705+**Files:**
706706+- Modify: `website/locale/en/LC_MESSAGES/django.po`
707707+708708+**Step 1: Open English .po file**
709709+710710+Open `website/locale/en/LC_MESSAGES/django.po` for editing
711711+712712+**Step 2: Add identity translations**
713713+714714+For English, most translations are identity (msgstr = msgid):
715715+716716+```po
717717+#: letters/templates/letters/base.html:10
718718+msgid "Home"
719719+msgstr "Home"
720720+721721+#: letters/templates/letters/base.html:11
722722+msgid "Letters"
723723+msgstr "Letters"
724724+```
725725+726726+Go through all entries and copy msgid to msgstr (they should be identical for English).
727727+728728+**Step 3: Save the file**
729729+730730+**Step 4: Commit**
731731+732732+```bash
733733+git add locale/en/LC_MESSAGES/django.po
734734+git commit -m "feat: add English identity translations to .po file"
735735+```
736736+737737+---
738738+739739+## Task 13: Compile Translation Files
740740+741741+**Files:**
742742+- Create: `website/locale/de/LC_MESSAGES/django.mo`
743743+- Create: `website/locale/en/LC_MESSAGES/django.mo`
744744+745745+**Step 1: Run compilemessages**
746746+747747+Run:
748748+```bash
749749+cd website
750750+uv run python manage.py compilemessages
751751+```
752752+753753+Expected: Creates `django.mo` files for both German and English
754754+755755+**Step 2: Verify .mo files created**
756756+757757+Run:
758758+```bash
759759+ls -la locale/de/LC_MESSAGES/
760760+ls -la locale/en/LC_MESSAGES/
761761+```
762762+763763+Expected: Both show `django.mo` files (binary format)
764764+765765+**Step 3: Test translations work**
766766+767767+Run: `uv run python manage.py runserver`
768768+769769+Visit `/de/` - should show German interface
770770+Visit `/en/` - should show English interface
771771+772772+Use language switcher to toggle between languages.
773773+774774+**Step 4: Add .mo files to .gitignore**
775775+776776+Modify `.gitignore` in repository root, add:
777777+```
778778+# Compiled translation files (generated from .po)
779779+*.mo
780780+```
781781+782782+**Note:** .mo files are generated artifacts and don't need to be tracked in git.
783783+784784+**Step 5: Commit**
785785+786786+```bash
787787+git add .gitignore
788788+git commit -m "chore: add compiled translation files to .gitignore"
789789+```
790790+791791+---
792792+793793+## Task 14: Create Translation Completeness Check Command
794794+795795+**Files:**
796796+- Create: `website/letters/management/commands/check_translations.py`
797797+798798+**Step 1: Write the failing test**
799799+800800+Add to `website/letters/tests/test_i18n.py`:
801801+802802+```python
803803+from django.core.management import call_command
804804+from io import StringIO
805805+806806+807807+class TranslationCompletenessTests(TestCase):
808808+ def test_check_translations_command_exists(self):
809809+ """Test that check_translations command can be called."""
810810+ out = StringIO()
811811+ call_command('check_translations', stdout=out)
812812+ output = out.getvalue()
813813+ self.assertIn('Deutsch', output)
814814+ self.assertIn('English', output)
815815+```
816816+817817+**Step 2: Run test to verify it fails**
818818+819819+Run: `uv run python manage.py test letters.tests.test_i18n::TranslationCompletenessTests -v`
820820+Expected: FAIL (command doesn't exist)
821821+822822+**Step 3: Create management command**
823823+824824+Create: `website/letters/management/commands/check_translations.py`
825825+826826+```python
827827+# ABOUTME: Management command to check translation completeness and report coverage.
828828+# ABOUTME: Analyzes .po files to find untranslated strings and calculate coverage percentage.
829829+830830+from django.core.management.base import BaseCommand
831831+from django.conf import settings
832832+import pathlib
833833+834834+835835+class Command(BaseCommand):
836836+ help = "Check translation completeness for all configured languages"
837837+838838+ def add_arguments(self, parser):
839839+ parser.add_argument(
840840+ '--language',
841841+ type=str,
842842+ help='Check specific language (e.g., "de" or "en")',
843843+ )
844844+845845+ def handle(self, *args, **options):
846846+ locale_paths = settings.LOCALE_PATHS
847847+ languages = settings.LANGUAGES
848848+849849+ target_language = options.get('language')
850850+851851+ if target_language:
852852+ languages_to_check = [(target_language, None)]
853853+ else:
854854+ languages_to_check = languages
855855+856856+ for lang_code, lang_name in languages_to_check:
857857+ self.check_language(locale_paths[0], lang_code, lang_name)
858858+859859+ def check_language(self, locale_path, lang_code, lang_name):
860860+ """Check translation completeness for a single language."""
861861+ po_file = pathlib.Path(locale_path) / lang_code / 'LC_MESSAGES' / 'django.po'
862862+863863+ if not po_file.exists():
864864+ self.stdout.write(self.style.ERROR(
865865+ f"\n{lang_code}: No .po file found at {po_file}"
866866+ ))
867867+ return
868868+869869+ total = 0
870870+ translated = 0
871871+ untranslated = []
872872+873873+ with open(po_file, 'r', encoding='utf-8') as f:
874874+ current_msgid = None
875875+ for line in f:
876876+ line = line.strip()
877877+ if line.startswith('msgid "') and not line.startswith('msgid ""'):
878878+ current_msgid = line[7:-1] # Extract string between quotes
879879+ total += 1
880880+ elif line.startswith('msgstr "'):
881881+ msgstr = line[8:-1]
882882+ if msgstr: # Non-empty translation
883883+ translated += 1
884884+ elif current_msgid:
885885+ untranslated.append(current_msgid)
886886+ current_msgid = None
887887+888888+ if total == 0:
889889+ self.stdout.write(self.style.WARNING(
890890+ f"\n{lang_code}: No translatable strings found"
891891+ ))
892892+ return
893893+894894+ coverage = (translated / total) * 100
895895+ display_name = lang_name if lang_name else lang_code
896896+897897+ self.stdout.write(self.style.SUCCESS(
898898+ f"\n{display_name} ({lang_code}):"
899899+ ))
900900+ self.stdout.write(f" Total strings: {total}")
901901+ self.stdout.write(f" Translated: {translated}")
902902+ self.stdout.write(f" Untranslated: {len(untranslated)}")
903903+ self.stdout.write(f" Coverage: {coverage:.1f}%")
904904+905905+ if untranslated:
906906+ self.stdout.write(self.style.WARNING(
907907+ f"\nMissing translations ({len(untranslated)}):"
908908+ ))
909909+ for msgid in untranslated[:10]: # Show first 10
910910+ self.stdout.write(f" - {msgid}")
911911+ if len(untranslated) > 10:
912912+ self.stdout.write(f" ... and {len(untranslated) - 10} more")
913913+ else:
914914+ self.stdout.write(self.style.SUCCESS(
915915+ "\nAll strings translated!"
916916+ ))
917917+```
918918+919919+**Step 4: Run test to verify it passes**
920920+921921+Run: `uv run python manage.py test letters.tests.test_i18n::TranslationCompletenessTests -v`
922922+Expected: PASS
923923+924924+**Step 5: Test command manually**
925925+926926+Run:
927927+```bash
928928+uv run python manage.py check_translations
929929+```
930930+931931+Expected: Shows coverage report for both German and English
932932+933933+**Step 6: Commit**
934934+935935+```bash
936936+git add website/letters/management/commands/check_translations.py
937937+git commit -m "feat: add check_translations management command"
938938+```
939939+940940+---
941941+942942+## Task 15: Update Documentation
943943+944944+**Files:**
945945+- Modify: `README.md`
946946+- Modify: `docs/matching-algorithm.md` (add i18n section)
947947+948948+**Step 1: Update README with i18n information**
949949+950950+Add a new section to `README.md`:
951951+952952+```markdown
953953+## Internationalization
954954+955955+WriteThem.eu supports German (default) and English.
956956+957957+### Using the Site
958958+959959+- Visit `/de/` for German interface
960960+- Visit `/en/` for English interface
961961+- Use the language switcher in the header to toggle languages
962962+- Language preference is saved in a cookie
963963+964964+### For Developers
965965+966966+**Translation workflow:**
967967+968968+1. Wrap new UI strings with translation functions:
969969+ - Templates: `{% trans "Text" %}` or `{% blocktrans %}`
970970+ - Python: `gettext()` or `gettext_lazy()`
971971+972972+2. Extract strings to .po files:
973973+ ```bash
974974+ cd website
975975+ uv run python manage.py makemessages -l de -l en
976976+ ```
977977+978978+3. Translate strings in `.po` files:
979979+ - Edit `locale/de/LC_MESSAGES/django.po` (German translations)
980980+ - Edit `locale/en/LC_MESSAGES/django.po` (English, mostly identity translations)
981981+982982+4. Compile translations:
983983+ ```bash
984984+ uv run python manage.py compilemessages
985985+ ```
986986+987987+5. Check translation completeness:
988988+ ```bash
989989+ uv run python manage.py check_translations
990990+ ```
991991+992992+**Important:** All code, comments, and translation keys should be in English. Only .po files contain actual translations.
993993+```
994994+995995+**Step 2: Add i18n section to matching-algorithm.md**
996996+997997+Add at the end of `docs/matching-algorithm.md`:
998998+999999+```markdown
10001000+## Internationalization
10011001+10021002+The constituency matching system works identically in both German and English:
10031003+10041004+- Addresses can be entered in German format (standard use case)
10051005+- UI language (German/English) does not affect geocoding or matching logic
10061006+- Representative names, constituency names, and geographic data remain in original German
10071007+- All user-facing labels and messages are translated
10081008+```
10091009+10101010+**Step 3: Commit**
10111011+10121012+```bash
10131013+git add README.md docs/matching-algorithm.md
10141014+git commit -m "docs: add internationalization documentation"
10151015+```
10161016+10171017+---
10181018+10191019+## Task 16: Run Full Test Suite and Verify
10201020+10211021+**Step 1: Run all tests**
10221022+10231023+Run:
10241024+```bash
10251025+cd website
10261026+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
10271027+```
10281028+10291029+Expected: All tests pass (check total count)
10301030+10311031+**Step 2: Check translation completeness**
10321032+10331033+Run:
10341034+```bash
10351035+uv run python manage.py check_translations
10361036+```
10371037+10381038+Expected: 100% coverage for both languages (or report any missing translations)
10391039+10401040+**Step 3: Manual verification checklist**
10411041+10421042+Run: `uv run python manage.py runserver`
10431043+10441044+Test each page in both languages:
10451045+10461046+**German (`/de/`):**
10471047+- [ ] Homepage loads in German
10481048+- [ ] Login page in German
10491049+- [ ] Register page in German
10501050+- [ ] Letter list in German
10511051+- [ ] Letter detail in German
10521052+- [ ] Letter creation form in German
10531053+- [ ] Profile page in German
10541054+- [ ] Language switcher works (toggles to English)
10551055+10561056+**English (`/en/`):**
10571057+- [ ] Homepage loads in English
10581058+- [ ] Login page in English
10591059+- [ ] Register page in English
10601060+- [ ] Letter list in English
10611061+- [ ] Letter detail in English
10621062+- [ ] Letter creation form in English
10631063+- [ ] Profile page in English
10641064+- [ ] Language switcher works (toggles to German)
10651065+10661066+**Step 4: Check for untranslated strings**
10671067+10681068+While testing, look for any English text appearing on German pages (or vice versa). These indicate missed translations.
10691069+10701070+If found, add them to .po files, compile, and test again.
10711071+10721072+**Step 5: Create summary commit**
10731073+10741074+```bash
10751075+git add .
10761076+git commit -m "test: verify i18n implementation with full test suite"
10771077+```
10781078+10791079+---
10801080+10811081+## Verification Checklist
10821082+10831083+Before merging this feature:
10841084+10851085+- [ ] USE_I18N=True in settings
10861086+- [ ] LANGUAGES configured with German and English
10871087+- [ ] LOCALE_PATHS configured
10881088+- [ ] LocaleMiddleware added to MIDDLEWARE
10891089+- [ ] URL patterns use i18n_patterns()
10901090+- [ ] Language switcher present in base template
10911091+- [ ] All templates have `{% load i18n %}`
10921092+- [ ] All UI strings wrapped with `{% trans %}` or `{% blocktrans %}`
10931093+- [ ] German .po file fully translated (100% coverage)
10941094+- [ ] English .po file complete (identity translations)
10951095+- [ ] .mo files compile without errors
10961096+- [ ] check_translations command works
10971097+- [ ] All automated tests pass
10981098+- [ ] Manual testing in both languages successful
10991099+- [ ] Documentation updated
11001100+- [ ] No untranslated strings visible in UI
11011101+11021102+---
11031103+11041104+## Notes for Implementation
11051105+11061106+**Language policy:**
11071107+- All code (variables, functions, classes): English
11081108+- All comments and docstrings: English
11091109+- All translation keys (msgid in .po): English
11101110+- .po files contain actual translations
11111111+11121112+**Testing strategy:**
11131113+- TDD throughout: write test โ verify fail โ implement โ verify pass โ commit
11141114+- Run `uv run python manage.py test` frequently
11151115+- Use `uv run python manage.py runserver` for manual verification
11161116+- Use `check_translations` command to catch missing translations
11171117+11181118+**Common pitfalls:**
11191119+- Forgetting `{% load i18n %}` at top of templates
11201120+- Not using `gettext_lazy` in models/forms (use lazy version for class-level strings)
11211121+- Mixing `{% trans %}` and `{% blocktrans %}` incorrectly (use blocktrans for variables)
11221122+- Not recompiling after editing .po files (run compilemessages)
11231123+11241124+**Skills to reference:**
11251125+- @skills/testing/test-driven-development for TDD workflow
11261126+- @skills/debugging/systematic-debugging if tests fail unexpectedly
11271127+- @skills/collaboration/finishing-a-development-branch when merging back to feat/matching
+1102
docs/plans/2025-10-14-refactor-test-commands.md
···11+# Refactor Test Commands Implementation Plan
22+33+> **For Claude:** Use `${SUPERPOWERS_SKILLS_ROOT}/skills/collaboration/executing-plans/SKILL.md` to implement this plan task-by-task.
44+55+**Goal:** Replace test-like management commands with proper Django tests and create new query commands for interactive debugging.
66+77+**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.
88+99+**Tech Stack:** Django 5.2, Python 3.13, uv, Django test framework
1010+1111+---
1212+1313+## Task 1: Create test_address_matching.py test file
1414+1515+**Files:**
1616+- Create: `website/letters/tests/test_address_matching.py`
1717+- Reference: `website/letters/management/commands/test_matching.py` (for test data and logic)
1818+1919+**Step 1: Write the test file structure with TEST_ADDRESSES fixture**
2020+2121+Create `website/letters/tests/test_address_matching.py`:
2222+2323+```python
2424+# ABOUTME: Test address-based constituency matching with geocoding and point-in-polygon lookup.
2525+# ABOUTME: Covers AddressGeocoder, WahlkreisLocator, and ConstituencyLocator services.
2626+2727+from django.test import TestCase
2828+from unittest.mock import patch, MagicMock
2929+from letters.services import AddressGeocoder, WahlkreisLocator, ConstituencyLocator
3030+from letters.models import GeocodeCache, Representative
3131+3232+3333+# Test addresses covering all German states
3434+TEST_ADDRESSES = [
3535+ {
3636+ 'name': 'Bundestag (Berlin)',
3737+ 'street': 'Platz der Republik 1',
3838+ 'postal_code': '11011',
3939+ 'city': 'Berlin',
4040+ 'expected_state': 'Berlin'
4141+ },
4242+ {
4343+ 'name': 'Hamburg Rathaus',
4444+ 'street': 'Rathausmarkt 1',
4545+ 'postal_code': '20095',
4646+ 'city': 'Hamburg',
4747+ 'expected_state': 'Hamburg'
4848+ },
4949+ {
5050+ 'name': 'Marienplatz Mรผnchen (Bavaria)',
5151+ 'street': 'Marienplatz 1',
5252+ 'postal_code': '80331',
5353+ 'city': 'Mรผnchen',
5454+ 'expected_state': 'Bayern'
5555+ },
5656+ {
5757+ 'name': 'Kรถlner Dom (North Rhine-Westphalia)',
5858+ 'street': 'Domkloster 4',
5959+ 'postal_code': '50667',
6060+ 'city': 'Kรถln',
6161+ 'expected_state': 'Nordrhein-Westfalen'
6262+ },
6363+ {
6464+ 'name': 'Brandenburger Tor (Berlin)',
6565+ 'street': 'Pariser Platz',
6666+ 'postal_code': '10117',
6767+ 'city': 'Berlin',
6868+ 'expected_state': 'Berlin'
6969+ },
7070+]
7171+7272+7373+class AddressGeocodingTests(TestCase):
7474+ """Test address geocoding with OSM Nominatim."""
7575+7676+ def setUp(self):
7777+ self.geocoder = AddressGeocoder()
7878+7979+ def test_geocode_success_with_mocked_api(self):
8080+ """Test successful geocoding with mocked Nominatim response."""
8181+ pass
8282+8383+ def test_geocode_caches_results(self):
8484+ """Test that geocoding results are cached in database."""
8585+ pass
8686+8787+ def test_geocode_returns_cached_results(self):
8888+ """Test that cached geocoding results are reused."""
8989+ pass
9090+9191+ def test_geocode_handles_api_error(self):
9292+ """Test graceful handling of Nominatim API errors."""
9393+ pass
9494+9595+9696+class WahlkreisLocationTests(TestCase):
9797+ """Test point-in-polygon constituency matching."""
9898+9999+ def test_locate_bundestag_coordinates(self):
100100+ """Test that Bundestag coordinates find correct Berlin constituency."""
101101+ pass
102102+103103+ def test_locate_hamburg_coordinates(self):
104104+ """Test that Hamburg coordinates find correct constituency."""
105105+ pass
106106+107107+ def test_coordinates_outside_germany(self):
108108+ """Test that coordinates outside Germany return None."""
109109+ pass
110110+111111+112112+class FullAddressMatchingTests(TestCase):
113113+ """Integration tests for full address โ constituency โ representatives pipeline."""
114114+115115+ @patch('letters.services.AddressGeocoder.geocode')
116116+ def test_address_to_constituency_pipeline(self, mock_geocode):
117117+ """Test full pipeline from address to constituency with mocked geocoding."""
118118+ pass
119119+120120+ def test_plz_fallback_when_geocoding_fails(self):
121121+ """Test PLZ prefix fallback when geocoding fails."""
122122+ pass
123123+124124+125125+# End of file
126126+```
127127+128128+**Step 2: Run test to verify structure loads**
129129+130130+Run: `cd website && uv run python manage.py test letters.tests.test_address_matching`
131131+Expected: All tests should be discovered and skip (no implementations yet)
132132+133133+**Step 3: Commit test file structure**
134134+135135+```bash
136136+git add website/letters/tests/test_address_matching.py
137137+git commit -m "test: add test_address_matching.py structure with fixtures"
138138+```
139139+140140+---
141141+142142+## Task 2: Implement address geocoding tests
143143+144144+**Files:**
145145+- Modify: `website/letters/tests/test_address_matching.py`
146146+147147+**Step 1: Implement test_geocode_success_with_mocked_api**
148148+149149+In `AddressGeocodingTests` class, replace the pass statement:
150150+151151+```python
152152+def test_geocode_success_with_mocked_api(self):
153153+ """Test successful geocoding with mocked Nominatim response."""
154154+ with patch('requests.get') as mock_get:
155155+ # Mock successful Nominatim response
156156+ mock_response = MagicMock()
157157+ mock_response.status_code = 200
158158+ mock_response.json.return_value = [{
159159+ 'lat': '52.5186',
160160+ 'lon': '13.3761'
161161+ }]
162162+ mock_get.return_value = mock_response
163163+164164+ lat, lon, success, error = self.geocoder.geocode(
165165+ 'Platz der Republik 1',
166166+ '11011',
167167+ 'Berlin'
168168+ )
169169+170170+ self.assertTrue(success)
171171+ self.assertIsNone(error)
172172+ self.assertAlmostEqual(lat, 52.5186, places=4)
173173+ self.assertAlmostEqual(lon, 13.3761, places=4)
174174+```
175175+176176+**Step 2: Implement test_geocode_caches_results**
177177+178178+```python
179179+def test_geocode_caches_results(self):
180180+ """Test that geocoding results are cached in database."""
181181+ with patch('requests.get') as mock_get:
182182+ mock_response = MagicMock()
183183+ mock_response.status_code = 200
184184+ mock_response.json.return_value = [{
185185+ 'lat': '52.5186',
186186+ 'lon': '13.3761'
187187+ }]
188188+ mock_get.return_value = mock_response
189189+190190+ # First call should cache
191191+ self.geocoder.geocode('Platz der Republik 1', '11011', 'Berlin')
192192+193193+ # Check cache entry exists
194194+ cache_key = self.geocoder._generate_cache_key(
195195+ 'Platz der Republik 1', '11011', 'Berlin', 'DE'
196196+ )
197197+ cache_entry = GeocodeCache.objects.filter(address_hash=cache_key).first()
198198+ self.assertIsNotNone(cache_entry)
199199+ self.assertTrue(cache_entry.success)
200200+```
201201+202202+**Step 3: Implement test_geocode_returns_cached_results**
203203+204204+```python
205205+def test_geocode_returns_cached_results(self):
206206+ """Test that cached geocoding results are reused."""
207207+ # Create cache entry
208208+ cache_key = self.geocoder._generate_cache_key(
209209+ 'Test Street', '12345', 'Test City', 'DE'
210210+ )
211211+ GeocodeCache.objects.create(
212212+ address_hash=cache_key,
213213+ success=True,
214214+ latitude=52.0,
215215+ longitude=13.0
216216+ )
217217+218218+ # Should return cached result without API call
219219+ with patch('requests.get') as mock_get:
220220+ lat, lon, success, error = self.geocoder.geocode(
221221+ 'Test Street', '12345', 'Test City'
222222+ )
223223+224224+ # Verify no API call was made
225225+ mock_get.assert_not_called()
226226+227227+ # Verify cached results returned
228228+ self.assertTrue(success)
229229+ self.assertEqual(lat, 52.0)
230230+ self.assertEqual(lon, 13.0)
231231+```
232232+233233+**Step 4: Implement test_geocode_handles_api_error**
234234+235235+```python
236236+def test_geocode_handles_api_error(self):
237237+ """Test graceful handling of Nominatim API errors."""
238238+ with patch('requests.get') as mock_get:
239239+ mock_get.side_effect = Exception("API Error")
240240+241241+ lat, lon, success, error = self.geocoder.geocode(
242242+ 'Invalid Street', '99999', 'Nowhere'
243243+ )
244244+245245+ self.assertFalse(success)
246246+ self.assertIsNone(lat)
247247+ self.assertIsNone(lon)
248248+ self.assertIn('API Error', error)
249249+```
250250+251251+**Step 5: Run tests to verify they pass**
252252+253253+Run: `cd website && uv run python manage.py test letters.tests.test_address_matching.AddressGeocodingTests -v`
254254+Expected: 4 tests PASS
255255+256256+**Step 6: Commit geocoding tests**
257257+258258+```bash
259259+git add website/letters/tests/test_address_matching.py
260260+git commit -m "test: implement address geocoding tests with mocking"
261261+```
262262+263263+---
264264+265265+## Task 3: Implement Wahlkreis location tests
266266+267267+**Files:**
268268+- Modify: `website/letters/tests/test_address_matching.py`
269269+270270+**Step 1: Implement test_locate_bundestag_coordinates**
271271+272272+In `WahlkreisLocationTests` class:
273273+274274+```python
275275+def test_locate_bundestag_coordinates(self):
276276+ """Test that Bundestag coordinates find correct Berlin constituency."""
277277+ locator = WahlkreisLocator()
278278+ result = locator.locate(52.5186, 13.3761)
279279+280280+ self.assertIsNotNone(result)
281281+ wkr_nr, wkr_name, land_name = result
282282+ self.assertIsInstance(wkr_nr, int)
283283+ self.assertIn('Berlin', land_name)
284284+```
285285+286286+**Step 2: Implement test_locate_hamburg_coordinates**
287287+288288+```python
289289+def test_locate_hamburg_coordinates(self):
290290+ """Test that Hamburg coordinates find correct constituency."""
291291+ locator = WahlkreisLocator()
292292+ result = locator.locate(53.5511, 9.9937)
293293+294294+ self.assertIsNotNone(result)
295295+ wkr_nr, wkr_name, land_name = result
296296+ self.assertIsInstance(wkr_nr, int)
297297+ self.assertIn('Hamburg', land_name)
298298+```
299299+300300+**Step 3: Implement test_coordinates_outside_germany**
301301+302302+```python
303303+def test_coordinates_outside_germany(self):
304304+ """Test that coordinates outside Germany return None."""
305305+ locator = WahlkreisLocator()
306306+307307+ # Paris coordinates
308308+ result = locator.locate(48.8566, 2.3522)
309309+ self.assertIsNone(result)
310310+311311+ # London coordinates
312312+ result = locator.locate(51.5074, -0.1278)
313313+ self.assertIsNone(result)
314314+```
315315+316316+**Step 4: Run tests to verify they pass**
317317+318318+Run: `cd website && uv run python manage.py test letters.tests.test_address_matching.WahlkreisLocationTests -v`
319319+Expected: 3 tests PASS
320320+321321+**Step 5: Commit Wahlkreis location tests**
322322+323323+```bash
324324+git add website/letters/tests/test_address_matching.py
325325+git commit -m "test: implement Wahlkreis point-in-polygon location tests"
326326+```
327327+328328+---
329329+330330+## Task 4: Implement full address matching integration tests
331331+332332+**Files:**
333333+- Modify: `website/letters/tests/test_address_matching.py`
334334+335335+**Step 1: Implement test_address_to_constituency_pipeline**
336336+337337+In `FullAddressMatchingTests` class:
338338+339339+```python
340340+@patch('letters.services.AddressGeocoder.geocode')
341341+def test_address_to_constituency_pipeline(self, mock_geocode):
342342+ """Test full pipeline from address to constituency with mocked geocoding."""
343343+ # Mock geocoding to return Bundestag coordinates
344344+ mock_geocode.return_value = (52.5186, 13.3761, True, None)
345345+346346+ locator = ConstituencyLocator()
347347+ representatives = locator.locate(
348348+ street='Platz der Republik 1',
349349+ postal_code='11011',
350350+ city='Berlin'
351351+ )
352352+353353+ # Should return representatives (even if list is empty due to no DB data)
354354+ self.assertIsInstance(representatives, list)
355355+ mock_geocode.assert_called_once()
356356+```
357357+358358+**Step 2: Implement test_plz_fallback_when_geocoding_fails**
359359+360360+```python
361361+def test_plz_fallback_when_geocoding_fails(self):
362362+ """Test PLZ prefix fallback when geocoding fails."""
363363+ with patch('letters.services.AddressGeocoder.geocode') as mock_geocode:
364364+ # Mock geocoding failure
365365+ mock_geocode.return_value = (None, None, False, "Geocoding failed")
366366+367367+ locator = ConstituencyLocator()
368368+ representatives = locator.locate(
369369+ postal_code='10115' # Berlin postal code
370370+ )
371371+372372+ # Should still return list (using PLZ fallback)
373373+ self.assertIsInstance(representatives, list)
374374+```
375375+376376+**Step 3: Run tests to verify they pass**
377377+378378+Run: `cd website && uv run python manage.py test letters.tests.test_address_matching.FullAddressMatchingTests -v`
379379+Expected: 2 tests PASS
380380+381381+**Step 4: Run full test suite**
382382+383383+Run: `cd website && uv run python manage.py test letters.tests.test_address_matching -v`
384384+Expected: All 9 tests PASS
385385+386386+**Step 5: Commit integration tests**
387387+388388+```bash
389389+git add website/letters/tests/test_address_matching.py
390390+git commit -m "test: implement full address matching integration tests"
391391+```
392392+393393+---
394394+395395+## Task 5: Create test_topic_mapping.py test file
396396+397397+**Files:**
398398+- Create: `website/letters/tests/test_topic_mapping.py`
399399+- Reference: `website/letters/management/commands/test_topic_mapping.py` (for test data)
400400+401401+**Step 1: Write test file with topic matching tests**
402402+403403+Create `website/letters/tests/test_topic_mapping.py`:
404404+405405+```python
406406+# ABOUTME: Test topic suggestion and matching based on letter content.
407407+# ABOUTME: Covers TopicSuggestionService keyword matching and level suggestion logic.
408408+409409+from django.test import TestCase
410410+from letters.services import TopicSuggestionService
411411+from letters.models import TopicArea
412412+413413+414414+class TopicMatchingTests(TestCase):
415415+ """Test topic keyword matching and scoring."""
416416+417417+ def test_transport_keywords_match_verkehr_topic(self):
418418+ """Test that transport-related keywords match Verkehr topic."""
419419+ concern = "I want to see better train connections between cities"
420420+ topics = TopicSuggestionService.get_topic_suggestions(concern)
421421+422422+ # Should find at least one topic
423423+ self.assertGreater(len(topics), 0)
424424+425425+ # Top topic should be transport-related
426426+ top_topic = topics[0]
427427+ self.assertIn('score', top_topic)
428428+ self.assertGreater(top_topic['score'], 0)
429429+430430+ def test_housing_keywords_match_wohnen_topic(self):
431431+ """Test that housing keywords match Wohnen topic."""
432432+ concern = "We need more affordable housing and rent control"
433433+ topics = TopicSuggestionService.get_topic_suggestions(concern)
434434+435435+ self.assertGreater(len(topics), 0)
436436+437437+ def test_education_keywords_match_bildung_topic(self):
438438+ """Test that education keywords match Bildung topic."""
439439+ concern = "Our school curriculum needs reform"
440440+ topics = TopicSuggestionService.get_topic_suggestions(concern)
441441+442442+ self.assertGreater(len(topics), 0)
443443+444444+ def test_climate_keywords_match_umwelt_topic(self):
445445+ """Test that climate keywords match environment topic."""
446446+ concern = "Climate protection and CO2 emissions must be addressed"
447447+ topics = TopicSuggestionService.get_topic_suggestions(concern)
448448+449449+ self.assertGreater(len(topics), 0)
450450+451451+ def test_no_match_returns_empty_list(self):
452452+ """Test that completely unrelated text returns empty list."""
453453+ concern = "xyzabc nonsense gibberish"
454454+ topics = TopicSuggestionService.get_topic_suggestions(concern)
455455+456456+ # May return empty or very low scores
457457+ if topics:
458458+ self.assertLess(topics[0]['score'], 0.3)
459459+460460+461461+class LevelSuggestionTests(TestCase):
462462+ """Test government level suggestion logic."""
463463+464464+ def test_federal_transport_suggests_federal_level(self):
465465+ """Test that long-distance transport suggests federal level."""
466466+ result = TopicSuggestionService.suggest_representatives_for_concern(
467467+ "Deutsche Bahn is always late",
468468+ limit=5
469469+ )
470470+471471+ self.assertIn('suggested_level', result)
472472+ self.assertIn('explanation', result)
473473+ # Federal issues should suggest Bundestag
474474+ self.assertIn('Bundestag', result['suggested_level'])
475475+476476+ def test_local_bus_suggests_state_or_local(self):
477477+ """Test that local transport suggests state/local level."""
478478+ result = TopicSuggestionService.suggest_representatives_for_concern(
479479+ "Better bus services in my town",
480480+ limit=5
481481+ )
482482+483483+ self.assertIn('suggested_level', result)
484484+ # Local issues should not exclusively suggest federal
485485+ explanation = result['explanation'].lower()
486486+ self.assertTrue('state' in explanation or 'local' in explanation or 'land' in explanation)
487487+488488+489489+# End of file
490490+```
491491+492492+**Step 2: Run tests to verify they work**
493493+494494+Run: `cd website && uv run python manage.py test letters.tests.test_topic_mapping -v`
495495+Expected: Tests PASS (some may be skipped if TopicArea data not loaded)
496496+497497+**Step 3: Commit topic mapping tests**
498498+499499+```bash
500500+git add website/letters/tests/test_topic_mapping.py
501501+git commit -m "test: add topic matching and level suggestion tests"
502502+```
503503+504504+---
505505+506506+## Task 6: Create test_constituency_suggestions.py test file
507507+508508+**Files:**
509509+- Create: `website/letters/tests/test_constituency_suggestions.py`
510510+- Reference: `website/letters/management/commands/test_constituency_suggestion.py`
511511+512512+**Step 1: Write test file for constituency suggestion service**
513513+514514+Create `website/letters/tests/test_constituency_suggestions.py`:
515515+516516+```python
517517+# ABOUTME: Test ConstituencySuggestionService combining topics and geography.
518518+# ABOUTME: Integration tests for letter title/address to representative suggestions.
519519+520520+from django.test import TestCase
521521+from unittest.mock import patch
522522+from letters.services import ConstituencySuggestionService
523523+524524+525525+class ConstituencySuggestionTests(TestCase):
526526+ """Test constituency suggestion combining topic and address matching."""
527527+528528+ @patch('letters.services.AddressGeocoder.geocode')
529529+ def test_suggest_with_title_and_address(self, mock_geocode):
530530+ """Test suggestions work with both title and address."""
531531+ # Mock geocoding
532532+ mock_geocode.return_value = (52.5186, 13.3761, True, None)
533533+534534+ result = ConstituencySuggestionService.suggest_from_concern(
535535+ concern="We need better train connections",
536536+ street="Platz der Republik 1",
537537+ postal_code="11011",
538538+ city="Berlin"
539539+ )
540540+541541+ self.assertIn('matched_topics', result)
542542+ self.assertIn('suggested_level', result)
543543+ self.assertIn('explanation', result)
544544+ self.assertIn('representatives', result)
545545+ self.assertIn('constituencies', result)
546546+547547+ def test_suggest_with_only_title(self):
548548+ """Test suggestions work with only title (no address)."""
549549+ result = ConstituencySuggestionService.suggest_from_concern(
550550+ concern="Climate protection is important"
551551+ )
552552+553553+ self.assertIn('matched_topics', result)
554554+ self.assertIn('suggested_level', result)
555555+ # Without address, should still suggest level and topics
556556+ self.assertIsNotNone(result['suggested_level'])
557557+558558+ def test_suggest_with_only_postal_code(self):
559559+ """Test suggestions work with only postal code."""
560560+ result = ConstituencySuggestionService.suggest_from_concern(
561561+ concern="Local infrastructure problems",
562562+ postal_code="10115"
563563+ )
564564+565565+ self.assertIn('constituencies', result)
566566+ # Should use PLZ fallback
567567+ self.assertIsInstance(result['constituencies'], list)
568568+569569+570570+# End of file
571571+```
572572+573573+**Step 2: Run tests to verify they pass**
574574+575575+Run: `cd website && uv run python manage.py test letters.tests.test_constituency_suggestions -v`
576576+Expected: 3 tests PASS
577577+578578+**Step 3: Commit constituency suggestion tests**
579579+580580+```bash
581581+git add website/letters/tests/test_constituency_suggestions.py
582582+git commit -m "test: add constituency suggestion integration tests"
583583+```
584584+585585+---
586586+587587+## Task 7: Create query_wahlkreis management command
588588+589589+**Files:**
590590+- Create: `website/letters/management/commands/query_wahlkreis.py`
591591+592592+**Step 1: Write query_wahlkreis command**
593593+594594+Create `website/letters/management/commands/query_wahlkreis.py`:
595595+596596+```python
597597+# ABOUTME: Query management command to find constituency by address or postal code.
598598+# ABOUTME: Interactive tool for testing address-based constituency matching.
599599+600600+from django.core.management.base import BaseCommand
601601+from letters.services import AddressGeocoder, WahlkreisLocator, ConstituencyLocator
602602+603603+604604+class Command(BaseCommand):
605605+ help = 'Find constituency (Wahlkreis) by address or postal code'
606606+607607+ def add_arguments(self, parser):
608608+ parser.add_argument(
609609+ '--street',
610610+ type=str,
611611+ help='Street name and number'
612612+ )
613613+ parser.add_argument(
614614+ '--postal-code',
615615+ type=str,
616616+ help='Postal code (PLZ)',
617617+ required=True
618618+ )
619619+ parser.add_argument(
620620+ '--city',
621621+ type=str,
622622+ help='City name'
623623+ )
624624+625625+ def handle(self, *args, **options):
626626+ street = options.get('street')
627627+ postal_code = options['postal_code']
628628+ city = options.get('city')
629629+630630+ try:
631631+ # Try full address geocoding if all parts provided
632632+ if street and city:
633633+ geocoder = AddressGeocoder()
634634+ lat, lon, success, error = geocoder.geocode(street, postal_code, city)
635635+636636+ if not success:
637637+ self.stdout.write(self.style.ERROR(f'Error: Could not geocode address: {error}'))
638638+ return
639639+640640+ locator = WahlkreisLocator()
641641+ result = locator.locate(lat, lon)
642642+643643+ if not result:
644644+ self.stdout.write('No constituency found for these coordinates')
645645+ return
646646+647647+ wkr_nr, wkr_name, land_name = result
648648+ self.stdout.write(f'WK {wkr_nr:03d} - {wkr_name} ({land_name})')
649649+650650+ # Fallback to PLZ prefix lookup
651651+ else:
652652+ from letters.constants import PLZ_TO_STATE
653653+ plz_prefix = postal_code[:2]
654654+655655+ if plz_prefix in PLZ_TO_STATE:
656656+ state = PLZ_TO_STATE[plz_prefix]
657657+ self.stdout.write(f'State: {state} (from postal code prefix)')
658658+ else:
659659+ self.stdout.write('Error: Could not determine state from postal code')
660660+661661+ except Exception as e:
662662+ self.stderr.write(self.style.ERROR(f'Error: {str(e)}'))
663663+ return
664664+```
665665+666666+**Step 2: Test the command manually**
667667+668668+Run: `cd website && uv run python manage.py query_wahlkreis --street "Platz der Republik 1" --postal-code "11011" --city "Berlin"`
669669+Expected: Output showing Berlin constituency
670670+671671+Run: `cd website && uv run python manage.py query_wahlkreis --postal-code "10115"`
672672+Expected: Output showing "State: Berlin (from postal code prefix)"
673673+674674+**Step 3: Commit query_wahlkreis command**
675675+676676+```bash
677677+git add website/letters/management/commands/query_wahlkreis.py
678678+git commit -m "feat: add query_wahlkreis management command"
679679+```
680680+681681+---
682682+683683+## Task 8: Create query_topics management command
684684+685685+**Files:**
686686+- Create: `website/letters/management/commands/query_topics.py`
687687+688688+**Step 1: Write query_topics command**
689689+690690+Create `website/letters/management/commands/query_topics.py`:
691691+692692+```python
693693+# ABOUTME: Query management command to find matching topics for letter text.
694694+# ABOUTME: Interactive tool for testing topic keyword matching and scoring.
695695+696696+from django.core.management.base import BaseCommand
697697+from letters.services import TopicSuggestionService
698698+699699+700700+class Command(BaseCommand):
701701+ help = 'Find matching topics for a letter title or text'
702702+703703+ def add_arguments(self, parser):
704704+ parser.add_argument(
705705+ '--text',
706706+ type=str,
707707+ required=True,
708708+ help='Letter title or text to analyze'
709709+ )
710710+ parser.add_argument(
711711+ '--limit',
712712+ type=int,
713713+ default=5,
714714+ help='Maximum number of topics to return (default: 5)'
715715+ )
716716+717717+ def handle(self, *args, **options):
718718+ text = options['text']
719719+ limit = options['limit']
720720+721721+ try:
722722+ topics = TopicSuggestionService.get_topic_suggestions(text)
723723+724724+ if not topics:
725725+ self.stdout.write('No matching topics found')
726726+ return
727727+728728+ # Limit results
729729+ topics = topics[:limit]
730730+731731+ for topic in topics:
732732+ score = topic.get('match_score', topic.get('score', 0))
733733+ self.stdout.write(
734734+ f"{topic['name']} ({topic['level']}, Score: {score:.2f})"
735735+ )
736736+ if 'description' in topic and topic['description']:
737737+ self.stdout.write(f" {topic['description']}")
738738+739739+ except Exception as e:
740740+ self.stderr.write(self.style.ERROR(f'Error: {str(e)}'))
741741+ return
742742+```
743743+744744+**Step 2: Test the command manually**
745745+746746+Run: `cd website && uv run python manage.py query_topics --text "We need better train connections"`
747747+Expected: Output showing transport-related topics with scores
748748+749749+Run: `cd website && uv run python manage.py query_topics --text "affordable housing" --limit 3`
750750+Expected: Output showing top 3 housing-related topics
751751+752752+**Step 3: Commit query_topics command**
753753+754754+```bash
755755+git add website/letters/management/commands/query_topics.py
756756+git commit -m "feat: add query_topics management command"
757757+```
758758+759759+---
760760+761761+## Task 9: Create query_representatives management command
762762+763763+**Files:**
764764+- Create: `website/letters/management/commands/query_representatives.py`
765765+766766+**Step 1: Write query_representatives command**
767767+768768+Create `website/letters/management/commands/query_representatives.py`:
769769+770770+```python
771771+# ABOUTME: Query management command to find representatives by address and/or topics.
772772+# ABOUTME: Interactive tool for testing representative suggestion logic.
773773+774774+from django.core.management.base import BaseCommand
775775+from letters.services import ConstituencyLocator, TopicSuggestionService, ConstituencySuggestionService
776776+777777+778778+class Command(BaseCommand):
779779+ help = 'Find representatives by address and/or topics'
780780+781781+ def add_arguments(self, parser):
782782+ # Address arguments
783783+ parser.add_argument(
784784+ '--street',
785785+ type=str,
786786+ help='Street name and number'
787787+ )
788788+ parser.add_argument(
789789+ '--postal-code',
790790+ type=str,
791791+ help='Postal code (PLZ)'
792792+ )
793793+ parser.add_argument(
794794+ '--city',
795795+ type=str,
796796+ help='City name'
797797+ )
798798+799799+ # Topic arguments
800800+ parser.add_argument(
801801+ '--topics',
802802+ type=str,
803803+ help='Comma-separated topic keywords (e.g., "Verkehr,Infrastruktur")'
804804+ )
805805+806806+ parser.add_argument(
807807+ '--limit',
808808+ type=int,
809809+ default=10,
810810+ help='Maximum number of representatives to return (default: 10)'
811811+ )
812812+813813+ def handle(self, *args, **options):
814814+ street = options.get('street')
815815+ postal_code = options.get('postal_code')
816816+ city = options.get('city')
817817+ topics_str = options.get('topics')
818818+ limit = options['limit']
819819+820820+ try:
821821+ # Use constituency locator if address provided
822822+ if postal_code or (street and city):
823823+ locator = ConstituencyLocator()
824824+ representatives = locator.locate(
825825+ street=street,
826826+ postal_code=postal_code,
827827+ city=city
828828+ )
829829+830830+ if not representatives:
831831+ self.stdout.write('No representatives found for this location')
832832+ return
833833+834834+ # Filter by topics if provided
835835+ if topics_str:
836836+ topic_keywords = [t.strip() for t in topics_str.split(',')]
837837+ # Simple keyword filter on representative focus areas
838838+ filtered_reps = []
839839+ for rep in representatives:
840840+ # Check if any committee or focus area matches
841841+ rep_text = ' '.join([
842842+ rep.full_name,
843843+ ' '.join([c.name for c in rep.committees.all()]),
844844+ ]).lower()
845845+846846+ if any(keyword.lower() in rep_text for keyword in topic_keywords):
847847+ filtered_reps.append(rep)
848848+849849+ representatives = filtered_reps if filtered_reps else representatives
850850+851851+ # Display results
852852+ for rep in representatives[:limit]:
853853+ constituency = rep.primary_constituency
854854+ constituency_label = constituency.name if constituency else rep.parliament.name
855855+ self.stdout.write(f'{rep.full_name} ({rep.party}) - {constituency_label}')
856856+857857+ # Show committees
858858+ committees = list(rep.committees.all()[:3])
859859+ if committees:
860860+ committee_names = ', '.join([c.name for c in committees])
861861+ self.stdout.write(f' Committees: {committee_names}')
862862+863863+ # Use topic-based search if only topics provided
864864+ elif topics_str:
865865+ self.stdout.write('Topic-based representative search not yet implemented')
866866+ self.stdout.write('Please provide at least a postal code for location-based search')
867867+868868+ else:
869869+ self.stderr.write(self.style.ERROR(
870870+ 'Error: Please provide either an address (--postal-code required) or --topics'
871871+ ))
872872+873873+ except Exception as e:
874874+ self.stderr.write(self.style.ERROR(f'Error: {str(e)}'))
875875+ return
876876+```
877877+878878+**Step 2: Test the command manually**
879879+880880+Run: `cd website && uv run python manage.py query_representatives --postal-code "11011"`
881881+Expected: Output showing Berlin representatives
882882+883883+Run: `cd website && uv run python manage.py query_representatives --street "Platz der Republik 1" --postal-code "11011" --city "Berlin" --limit 5`
884884+Expected: Output showing top 5 representatives for that location
885885+886886+**Step 3: Commit query_representatives command**
887887+888888+```bash
889889+git add website/letters/management/commands/query_representatives.py
890890+git commit -m "feat: add query_representatives management command"
891891+```
892892+893893+---
894894+895895+## Task 10: Run full test suite and verify everything works
896896+897897+**Files:**
898898+- All test files
899899+900900+**Step 1: Run complete test suite**
901901+902902+Run: `cd website && uv run python manage.py test`
903903+Expected: All tests PASS (including new and existing tests)
904904+905905+**Step 2: Test all three query commands manually**
906906+907907+Run: `cd website && uv run python manage.py query_wahlkreis --street "Platz der Republik 1" --postal-code "11011" --city "Berlin"`
908908+Expected: Correct constituency output
909909+910910+Run: `cd website && uv run python manage.py query_topics --text "climate change and renewable energy"`
911911+Expected: Environment-related topics
912912+913913+Run: `cd website && uv run python manage.py query_representatives --postal-code "10115"`
914914+Expected: Berlin representatives
915915+916916+**Step 3: Commit if any fixes needed**
917917+918918+If any issues found and fixed:
919919+```bash
920920+git add .
921921+git commit -m "fix: address test suite issues"
922922+```
923923+924924+---
925925+926926+## Task 11: Delete test_matching.py command
927927+928928+**Files:**
929929+- Delete: `website/letters/management/commands/test_matching.py`
930930+931931+**Step 1: Verify tests cover all test_matching.py functionality**
932932+933933+Compare `test_matching.py` with `test_address_matching.py` to ensure all test cases are covered.
934934+935935+**Step 2: Delete test_matching.py**
936936+937937+Run: `rm website/letters/management/commands/test_matching.py`
938938+939939+**Step 3: Run tests to verify nothing broke**
940940+941941+Run: `cd website && uv run python manage.py test`
942942+Expected: All tests still PASS
943943+944944+**Step 4: Commit deletion**
945945+946946+```bash
947947+git add website/letters/management/commands/test_matching.py
948948+git commit -m "refactor: remove test_matching command (moved to proper tests)"
949949+```
950950+951951+---
952952+953953+## Task 12: Delete test_constituency_suggestion.py command
954954+955955+**Files:**
956956+- Delete: `website/letters/management/commands/test_constituency_suggestion.py`
957957+958958+**Step 1: Verify tests cover functionality**
959959+960960+Compare with `test_constituency_suggestions.py`.
961961+962962+**Step 2: Delete test_constituency_suggestion.py**
963963+964964+Run: `rm website/letters/management/commands/test_constituency_suggestion.py`
965965+966966+**Step 3: Run tests to verify nothing broke**
967967+968968+Run: `cd website && uv run python manage.py test`
969969+Expected: All tests PASS
970970+971971+**Step 4: Commit deletion**
972972+973973+```bash
974974+git add website/letters/management/commands/test_constituency_suggestion.py
975975+git commit -m "refactor: remove test_constituency_suggestion command (moved to proper tests)"
976976+```
977977+978978+---
979979+980980+## Task 13: Delete test_topic_mapping.py command
981981+982982+**Files:**
983983+- Delete: `website/letters/management/commands/test_topic_mapping.py`
984984+985985+**Step 1: Verify tests cover functionality**
986986+987987+Compare with `test_topic_mapping.py`.
988988+989989+**Step 2: Delete test_topic_mapping.py**
990990+991991+Run: `rm website/letters/management/commands/test_topic_mapping.py`
992992+993993+**Step 3: Run tests to verify nothing broke**
994994+995995+Run: `cd website && uv run python manage.py test`
996996+Expected: All tests PASS
997997+998998+**Step 4: Commit deletion**
999999+10001000+```bash
10011001+git add website/letters/management/commands/test_topic_mapping.py
10021002+git commit -m "refactor: remove test_topic_mapping command (moved to proper tests)"
10031003+```
10041004+10051005+---
10061006+10071007+## Task 14: Update documentation
10081008+10091009+**Files:**
10101010+- Modify: `README.md` (if it mentions test commands)
10111011+- Modify: `docs/matching-algorithm.md` (update command references)
10121012+10131013+**Step 1: Check if README mentions test commands**
10141014+10151015+Run: `grep -n "test_matching\|test_constituency\|test_topic" README.md`
10161016+10171017+If found, update to reference new query commands and proper test suite.
10181018+10191019+**Step 2: Update docs/matching-algorithm.md**
10201020+10211021+In `docs/matching-algorithm.md`, find section "Management Commands" (around line 70) and update:
10221022+10231023+```markdown
10241024+### Management Commands
10251025+10261026+- **fetch_wahlkreis_data**: Downloads official Bundestag constituency boundaries
10271027+- **query_wahlkreis**: Query constituency by address or postal code
10281028+- **query_topics**: Find matching topics for letter text
10291029+- **query_representatives**: Find representatives by address and/or topics
10301030+10311031+### Testing
10321032+10331033+Run the test suite:
10341034+```bash
10351035+python manage.py test letters.tests.test_address_matching
10361036+python manage.py test letters.tests.test_topic_mapping
10371037+python manage.py test letters.tests.test_constituency_suggestions
10381038+```
10391039+```
10401040+10411041+**Step 3: Commit documentation updates**
10421042+10431043+```bash
10441044+git add README.md docs/matching-algorithm.md
10451045+git commit -m "docs: update command and testing references"
10461046+```
10471047+10481048+---
10491049+10501050+## Task 15: Final verification and summary
10511051+10521052+**Files:**
10531053+- All modified files
10541054+10551055+**Step 1: Run complete test suite one final time**
10561056+10571057+Run: `cd website && uv run python manage.py test -v`
10581058+Expected: All tests PASS with detailed output
10591059+10601060+**Step 2: Verify query commands work**
10611061+10621062+Test each command with various inputs to ensure they work correctly.
10631063+10641064+**Step 3: Create summary of changes**
10651065+10661066+Review all commits:
10671067+```bash
10681068+git log --oneline
10691069+```
10701070+10711071+**Step 4: Final commit if needed**
10721072+10731073+If any final cleanup needed:
10741074+```bash
10751075+git add .
10761076+git commit -m "chore: final cleanup for test command refactoring"
10771077+```
10781078+10791079+---
10801080+10811081+## Summary
10821082+10831083+**What was accomplished:**
10841084+1. Created three new test files with comprehensive test coverage
10851085+2. Created three new query management commands for interactive debugging
10861086+3. Deleted three old test-like management commands
10871087+4. Updated documentation to reflect new structure
10881088+10891089+**New query commands:**
10901090+- `query_wahlkreis` - Find constituency by address/PLZ
10911091+- `query_topics` - Find matching topics for text
10921092+- `query_representatives` - Find representatives by location/topics
10931093+10941094+**New test files:**
10951095+- `letters/tests/test_address_matching.py` - Address geocoding and matching
10961096+- `letters/tests/test_topic_mapping.py` - Topic keyword matching
10971097+- `letters/tests/test_constituency_suggestions.py` - Integration tests
10981098+10991099+**Testing strategy:**
11001100+- Mocked external API calls (Nominatim) to avoid rate limits
11011101+- Integration tests use real services where possible
11021102+- All edge cases covered (failures, fallbacks, empty results)
+60
docs/plans/matching.md
···11+# Recipient Matching Vision
22+33+## Goal
44+Ensure every letter reaches the most relevant representative by combining precise constituency mapping with topic-awareness and reliable representative metadata.
55+66+## Core Pillars
77+88+1. **Constituency Precision**
99+ - Replace postal-prefix heuristics with official boundary data:
1010+ - Bundestag Wahlkreise (Bundeswahlleiter / BKG GeoJSON)
1111+ - Landtag electoral districts via state open-data portals or OParl feeds
1212+ - EU parliament treated as nationwide constituency
1313+ - Normalise mandate modes:
1414+ - Direktmandat โ voters in that Wahlkreis
1515+ - Landesliste โ voters in the state
1616+ - Bundes/EU list โ national constituencies
1717+ - Centralise the logic in a โconstituency routerโ so each parliamentโs data source is pluggable.
1818+1919+2. **Topic Understanding**
2020+ - Analyse title + body to classify concerns into a canonical taxonomy (reuse committee topics, extend as needed).
2121+ - Infer the responsible level (EU / Bund / Land / Kommune) from topic metadata.
2222+ - Keep the topic model extensible (keyword heuristics today, embeddings or classifiers tomorrow).
2323+2424+3. **Rich Representative Profiles**
2525+ - Build a `RepresentativeProfile` table to store per-source enrichments:
2626+ - Source (ABGEORDNETENWATCH, BUNDESTAG, LANDTAG_*)
2727+ - Normalised fields: focus areas, biography, external links, responsiveness
2828+ - Raw metadata + sync timestamps
2929+ - Importers:
3030+ - Abgeordnetenwatch: `/politicians/{id}` (issues, responsiveness, social links)
3131+ - Bundestag: official vita JSON (`mdbId`) for biography + spokesperson roles
3232+ - Landtage: state-specific data feeds (OParl, CSV, or one-off scrapers)
3333+ - Profiles coexist; the merging service resolves conflicts and picks the best available data.
3434+3535+## Matching Pipeline
3636+1. **Constituency filter**: Use the router and mandate rules to determine eligible reps.
3737+2. **Topic filter**: Narrow to the inferred level and portfolio.
3838+3. **Scoring**: Blend signalsโconstituency proximity, topic match (committee โ topic), activity (votes, questions), responsiveness stats, optional user preferences.
3939+4. **Explanation**: Provide human-readable reasons (โDirect MP for WK 123; sits on Verkehrsausschuss; answered 90% of Abgeordnetenwatch questionsโ).
4040+4141+## Data Sources Reference
4242+4343+| Use Case | Federal | State | EU |
4444+|-------------------------|-------------------------------------|---------------------------------------------|--------------------------------|
4545+| Mandates & committees | Abgeordnetenwatch API | Abgeordnetenwatch, OParl, Landtag portals | EU Parliament REST API |
4646+| Constituency boundaries | Bundeswahlleiter GeoJSON, BKG | Landeswahlleitungen, state GIS datasets | Whole-of-Germany (single geom) |
4747+| Biography / focus | Bundestag vita JSON, Abgeordnetenwatch issues | Landtag bios (open data) | Europarl member profiles |
4848+4949+## Implementation Notes
5050+- Expose `sync_representative_profiles` commands per source; schedule separately from mandate sync.
5151+- Track `source_version`/`hash` to avoid redundant imports.
5252+- View layer consumes a `RepresentativeProfileService` that aggregates focus areas, biography, links, responsiveness.
5353+- Keep a roadmap for future sources (party press, DIP21 votes, Europarl โfilesโ).
5454+5555+## Next Steps
5656+- Implement `RepresentativeProfile` model + importers for Abgeordnetenwatch and Bundestag.
5757+- Integrate boundary datasets and swap the PLZ router.
5858+- Wire the matching pipeline into the letter form suggestions and automated routing.
5959+- Add logging/monitoring for profile freshness and matching success.
6060+
+221
docs/plans/mvp.md
···11+# MVP Vision
22+33+## Mission
44+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.
55+66+## Core Feature Set
77+1. **Accounts & Profiles**
88+ - Email/password registration & login.
99+ - Profile page showing authored letters, signed letters, and verification status.
1010+2. **Representative Directory**
1111+ - EU, Bundestag, Landtag members with photo, party, mandate mode, committee roles, Abgeordnetenwatch links.
1212+ - Exposed via detail view and reusable UI card.
1313+3. **Letter Authoring & Publishing**
1414+ - Draft open letters, auto-suggest recipients based on title + PLZ.
1515+ - Auto-sign on publish; allow editing until first signature.
1616+ - Letter detail page shows full content, representative card, signature stats.
1717+4. **Recommendation Engine**
1818+ - PLZ โ constituency router (direct/state/federal) using official boundary data.
1919+ - Topic analysis highlighting likely responsible level and committee working areas.
2020+ - Explain why a representative is recommended, surface relevant tags, show similar letters.
2121+5. **Signature Flow**
2222+ - One-click signing for logged in users; prompt login otherwise.
2323+ - Badges for verified vs unverified signatures, count constituents distinctly.
2424+ - Social sharing (link copy, optional Twitter/Bluesky share).
2525+6. **Identity Verification (Optional)**
2626+ - Integrate one third-party provider (e.g., Verimi or yesยฎ) via OAuth2/OIDC to pull verified name/address.
2727+ - Store attestation + expiry; map address to constituency for direct mandates.
2828+ - Users without verification can still sign, flagged as โunverified.โ
2929+7. **Signature Threshold & Fulfilment**
3030+ - Configurable threshold per letter or representative type.
3131+ - Admin view showing letters reaching milestones, export letter + supporters for printing/mailing.
3232+8. **Admin & Moderation**
3333+ - Admin dashboard to inspect letters, representatives, signatures, verification status, and signature thresholds.
3434+ - Ability to disable inappropriate letters, resend sync, run exports.
3535+9. **Landing & Discovery**
3636+ - Public homepage summarising mission, stats, featured letters.
3737+ - Browse letters and representatives without login.
3838+10. **Documentation & Transparency**
3939+ - Public โHow it worksโ page, privacy policy, terms.
4040+ - README covering setup, architecture, deployment.
4141+4242+## 1-Month Sprint to 39C3 (December 2024)
4343+4444+### **Week 1-2: Core Functionality** (Days 1-10)
4545+4646+#### Track 1: Accurate Constituency Matching (Days 1-5) โ ๏ธ CRITICAL
4747+**Day 1: OSM Nominatim Integration**
4848+- [ ] Set up OSM Nominatim API client (requests-based, with rate limiting)
4949+- [ ] Add address geocoding service (`AddressGeocoder`)
5050+- [ ] Cache geocoding results in database to minimize API calls
5151+- [ ] Write tests for address โ lat/lng conversion
5252+5353+**Day 2: GeoJSON Point-in-Polygon Lookup**
5454+- [ ] Download full Bundestag Wahlkreis GeoJSON (via existing management command)
5555+- [ ] Build `WahlkreisLocator` using shapely for point-in-polygon
5656+- [ ] Load GeoJSON into memory at startup (or cache in Redis)
5757+- [ ] Test coordinate โ Wahlkreis lookup with sample points
5858+5959+**Day 3: Integration & Service Layer**
6060+- [ ] Replace `ConstituencyLocator` with new address-based lookup
6161+- [ ] Update `LocationContext` to accept full addresses
6262+- [ ] Maintain PLZ prefix fallback for partial data
6363+- [ ] Add comprehensive error handling and logging
6464+6565+**Day 4: Representative Matching Validation**
6666+- [ ] Test matching with 20 real German addresses
6767+- [ ] Verify direct representatives are correctly suggested
6868+- [ ] Test topic + geography combined matching
6969+- [ ] Document matching algorithm for transparency
7070+7171+**Day 5: Performance & Edge Cases**
7272+- [ ] Add caching layer for expensive operations
7373+- [ ] Handle border constituencies and ambiguous addresses
7474+- [ ] Performance test with 100+ concurrent requests
7575+- [ ] Add monitoring/logging for matching accuracy
7676+7777+#### Track 2: UX Polish (Days 3-8)
7878+7979+**Day 3-4: Gov.uk-Inspired Branding**
8080+- [ ] Define color palette (inspired by gov.uk: blues, blacks, whites)
8181+- [ ] Choose typography (gov.uk uses: Transport/Arial for headings, system fonts for body)
8282+- [ ] Create CSS design system with variables
8383+- [ ] Update base template with new styles
8484+- [ ] Design simple wordmark/logo
8585+8686+**Day 5-6: Letter List Improvements**
8787+- [ ] Add sorting controls (newest, most signatures, most verified)
8888+- [ ] Add TopicArea filtering (multi-select chips)
8989+- [ ] Improve letter card design (hierarchy, spacing, affordances)
9090+- [ ] Add empty states with helpful CTAs
9191+- [ ] Mobile responsive improvements
9292+9393+**Day 6-7: Letter Authoring Flow**
9494+- [ ] Add character counter (500 char minimum)
9595+- [ ] Add prominent immutability warning before publish
9696+- [ ] Show representative suggestion reasoning
9797+- [ ] Add preview step before publishing
9898+- [ ] Improve auto-signature confirmation messaging
9999+100100+**Day 7-8: Letter Detail & Sharing**
101101+- [ ] Add prominent "Copy link" button with visual feedback
102102+- [ ] Add social share buttons (Twitter, Bluesky with pre-filled text)
103103+- [ ] Clarify signature removal instructions
104104+- [ ] Improve verified/unverified signature badges
105105+- [ ] Polish report button and modal
106106+107107+#### Track 3: Localization Foundation (Days 6-8)
108108+109109+**Day 6-7: Django i18n Setup**
110110+- [ ] Wrap all strings in `gettext()` / `_()` calls
111111+- [ ] Generate German .po files
112112+- [ ] Add language switcher infrastructure (even if only DE works)
113113+- [ ] Document translation workflow
114114+115115+**Day 8: Content Audit**
116116+- [ ] Audit templates for hardcoded strings
117117+- [ ] Review German tone/voice consistency
118118+- [ ] Ensure error messages are clear and helpful
119119+- [ ] Proofread all user-facing content
120120+121121+#### Track 4: Automated Testing (Days 8-10)
122122+123123+**Day 8: Integration Tests**
124124+- [ ] Test full flow: Register โ Create Letter โ Suggestions โ Publish โ Sign
125125+- [ ] Test with 10 real German addresses
126126+- [ ] Test with 5 different topics
127127+- [ ] Test email flows (registration, password reset)
128128+129129+**Day 9: Matching Tests**
130130+- [ ] Unit tests for geocoding service
131131+- [ ] Unit tests for GeoJSON lookup
132132+- [ ] Integration tests for address โ representative matching
133133+- [ ] Test edge cases (border areas, ambiguous addresses)
134134+135135+**Day 10: System Tests**
136136+- [ ] Browser automation tests (Playwright/Selenium)
137137+- [ ] Mobile responsive tests
138138+- [ ] Performance tests (response times, concurrent users)
139139+- [ ] Create bug fix punch list
140140+141141+### **Week 3-4: Deployment & Polish** (Days 11-20)
142142+143143+#### Track 5: Production Deployment (Days 11-14)
144144+145145+**Day 11-12: VPS Setup**
146146+- [ ] Provision VPS with cloud-init template
147147+- [ ] Configure Gunicorn + Nginx
148148+- [ ] Set up SSL/TLS certificates (Let's Encrypt)
149149+- [ ] Configure static file serving
150150+151151+**Day 13: Production Configuration**
152152+- [ ] Environment-based settings (secrets, database)
153153+- [ ] Configure email backend (SMTP/SendGrid/SES)
154154+- [ ] Set up error tracking (Sentry/Rollbar)
155155+- [ ] Configure logging (structured logs)
156156+157157+**Day 14: Deployment Automation**
158158+- [ ] Create deployment script (simple rsync/git pull based)
159159+- [ ] Test rollback procedure
160160+- [ ] Document deployment process
161161+- [ ] Set up basic monitoring/health checks
162162+163163+#### Track 6: Content & Documentation (Days 15-17)
164164+165165+**Day 15-16: Landing & How It Works**
166166+- [ ] Create compelling homepage (mission, stats, CTA)
167167+- [ ] Write "How It Works" page (transparency about matching)
168168+- [ ] Create FAQ section
169169+- [ ] Add example letters / testimonials
170170+171171+**Day 17: Legal & Privacy**
172172+- [ ] Write basic Privacy Policy (GDPR-compliant)
173173+- [ ] Write Terms of Service
174174+- [ ] Add cookie consent if needed
175175+- [ ] Create Impressum (legal requirement in Germany)
176176+177177+#### Track 7: Final Testing & Launch Prep (Days 18-20)
178178+179179+**Day 18: User Acceptance Testing**
180180+- [ ] Run through entire flow with fresh eyes
181181+- [ ] Test on multiple devices and browsers
182182+- [ ] Verify all links and forms work
183183+- [ ] Check for typos and formatting issues
184184+185185+**Day 19: Performance & Security Audit**
186186+- [ ] Load testing (how many concurrent users can it handle?)
187187+- [ ] Security review (XSS, CSRF, SQL injection protections)
188188+- [ ] Check all forms have proper validation
189189+- [ ] Review admin permissions
190190+191191+**Day 20: Launch Preparation**
192192+- [ ] Create launch checklist
193193+- [ ] Prepare 39C3 demo script
194194+- [ ] Set up analytics/monitoring dashboards
195195+- [ ] Plan initial outreach (Twitter, mailing lists, etc.)
196196+197197+## Completed Features (From Previous Work)
198198+- [x] Account Management (registration, login, password reset, deletion)
199199+- [x] Double opt-in email verification
200200+- [x] TopicArea taxonomy with keyword matching
201201+- [x] Representative metadata sync (photos, committees, focus areas)
202202+- [x] Committee-to-topic automatic mapping
203203+- [x] Self-declared constituency verification
204204+- [x] HTMX-based representative suggestions
205205+- [x] Basic letter authoring and signing flow
206206+207207+## Explicitly Deferred (Post-39C3)
208208+- Third-party identity verification (Verimi, yesยฎ)
209209+- Analytics/feedback systems (basic monitoring only for MVP)
210210+- EU Parliament & Landtag levels (Bundestag only for MVP)
211211+- Draft auto-save functionality
212212+- Advanced admin moderation tools
213213+- Multiple language support (German only for MVP, i18n structure ready)
214214+215215+## Out of Scope for MVP
216216+- Local municipality reps, party-wide campaigns.
217217+- In-browser letter editing with collaboration.
218218+- Advanced analytics or CRM tooling.
219219+- Multiple identity providers (beyond initial integration).
220220+- Expert matching based on representative metadata keywords
221221+- Biography providers, display on representative detail view and extract keywords for matching
+65
docs/plans/verification.md
···11+# Identity Verification Vision
22+33+## Objective
44+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.
55+66+## Core Requirements
77+- **Proof of personhood:** Ensure each signature is tied to a real individual (no throwaway accounts).
88+- **Proof of constituency:** Capture verified address (street, PLZ, city) and map it to the correct Wahlkreis / Landtag district.
99+- **Reusable identity:** Prefer providers where users can consent to sharing existing verified attributes (lower friction than video calls).
1010+- **Evidence retention:** Store cryptographically signed responses or verification references so we can prove the verification later.
1111+- **Expiry / refresh:** Verification should have a validity window (e.g., re-check every 6โ12 months or when user updates address).
1212+1313+## Recommended Providers (Germany)
1414+1515+### Identity Wallets (best reuse experience)
1616+- **Verimi** (OAuth2/OIDC)
1717+ - Users already have a Verimi wallet โ grant consent โ we receive name + address.
1818+ - Supports multiple underlying methods (eID, VideoIdent, bank sources).
1919+- **BundID / BundesIdent** (official government ID)
2020+ - OIDC-based access to Personalausweis attributes via government portal.
2121+ - Gold standard for address proof; onboarding limited to approved use cases.
2222+- **yesยฎ (yes.com)**
2323+ - Bank login to participating institutions; returns bank-verified identity/address via OpenID Connect.
2424+ - No new verification, just consent.
2525+- **Signicat Identity Platform**
2626+ - Aggregator: supports Verimi, yesยฎ, BankID, eIDAS. Useful if expansion beyond Germany is planned.
2727+- **Nect Ident**
2828+ - After an initial automated verification, users can re-share their identity from a wallet.
2929+3030+### Alternative Methods
3131+- **BankIdent / PSD2 providers** (WebID BankIdent, yesยฎ, Deutsche Bank BankIdent)
3232+ - Users log into their bank; returns name/address. High trust, no video.
3333+- **eID solutions (AUTHADA, D-Trust)**
3434+ - NFC-based Personalausweis reading; some provide reusable tokens after first use.
3535+- **VideoIdent (IDnow, WebID VideoIdent, POSTIDENT)**
3636+ - Higher friction; use as fallback when wallet/bank options fail.
3737+3838+## Integration Architecture
3939+1. **Abstraction layer:** Implement a `VerificationProvider` interface with methods like `start_verification(user)` and `handle_callback(payload)`.
4040+2. **Provider adapters:** Build adapters for Verimi, yesยฎ, BundID, etc., each handling OAuth2/OIDC flows, token validation, and attribute extraction.
4141+3. **Verification storage:** Extend `IdentityVerification` to store provider name, verification reference, timestamp, address, and provider response hash/signature.
4242+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.
4343+5. **Expiry handling:** Add `expires_at`โprompt users to re-verify when outdated or on address change.
4444+6. **Audit trail:** Log provider responses; maintain a verification history per user.
4545+7. **Fallback/manual path:** Offer manual verification (moderator-reviewed documents) only if all automated providers fail, clearly flagging such signatures.
4646+4747+## User Flow Blueprint
4848+1. User chooses โVerify identityโ.
4949+2. We present available providers (Verimi, yesยฎ, BundIDโฆ).
5050+3. User authenticates/consents with chosen provider.
5151+4. Provider redirects back / sends webhook with verification result + attributes.
5252+5. We validate response, persist identity data, and map PLZ โ constituency.
5353+6. Signatures now display โVerified constituentโ (and reinforce direct mandates with proof).
5454+5555+## Implementation Priorities
5656+- Start with a wallet provider (Verimi or yesยฎ) for minimal friction.
5757+- Add BundID for maximum trust where accessible.
5858+- Abstract architecture so adding Landtag-specific providers later is straightforward.
5959+- Ensure we can reuse the same verification across multiple letters until it expires.
6060+6161+## Outstanding Questions
6262+- 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?)
6363+- How to handle users without access to any supported provider? (Manual override / postal verification?)
6464+- Data protection & consent: store only whatโs necessary (likely name + address); ensure GDPR-compliant retention policies.
6565+
···11-# Recipient Matching Vision
22-33-## Goal
44-Ensure every letter reaches the most relevant representative by combining precise constituency mapping with topic-awareness and reliable representative metadata.
55-66-## Core Pillars
77-88-1. **Constituency Precision**
99- - Replace postal-prefix heuristics with official boundary data:
1010- - Bundestag Wahlkreise (Bundeswahlleiter / BKG GeoJSON)
1111- - Landtag electoral districts via state open-data portals or OParl feeds
1212- - EU parliament treated as nationwide constituency
1313- - Normalise mandate modes:
1414- - Direktmandat โ voters in that Wahlkreis
1515- - Landesliste โ voters in the state
1616- - Bundes/EU list โ national constituencies
1717- - Centralise the logic in a โconstituency routerโ so each parliamentโs data source is pluggable.
1818-1919-2. **Topic Understanding**
2020- - Analyse title + body to classify concerns into a canonical taxonomy (reuse committee topics, extend as needed).
2121- - Infer the responsible level (EU / Bund / Land / Kommune) from topic metadata.
2222- - Keep the topic model extensible (keyword heuristics today, embeddings or classifiers tomorrow).
2323-2424-3. **Rich Representative Profiles**
2525- - Build a `RepresentativeProfile` table to store per-source enrichments:
2626- - Source (ABGEORDNETENWATCH, BUNDESTAG, LANDTAG_*)
2727- - Normalised fields: focus areas, biography, external links, responsiveness
2828- - Raw metadata + sync timestamps
2929- - Importers:
3030- - Abgeordnetenwatch: `/politicians/{id}` (issues, responsiveness, social links)
3131- - Bundestag: official vita JSON (`mdbId`) for biography + spokesperson roles
3232- - Landtage: state-specific data feeds (OParl, CSV, or one-off scrapers)
3333- - Profiles coexist; the merging service resolves conflicts and picks the best available data.
3434-3535-## Matching Pipeline
3636-1. **Constituency filter**: Use the router and mandate rules to determine eligible reps.
3737-2. **Topic filter**: Narrow to the inferred level and portfolio.
3838-3. **Scoring**: Blend signalsโconstituency proximity, topic match (committee โ topic), activity (votes, questions), responsiveness stats, optional user preferences.
3939-4. **Explanation**: Provide human-readable reasons (โDirect MP for WK 123; sits on Verkehrsausschuss; answered 90% of Abgeordnetenwatch questionsโ).
4040-4141-## Data Sources Reference
4242-4343-| Use Case | Federal | State | EU |
4444-|-------------------------|-------------------------------------|---------------------------------------------|--------------------------------|
4545-| Mandates & committees | Abgeordnetenwatch API | Abgeordnetenwatch, OParl, Landtag portals | EU Parliament REST API |
4646-| Constituency boundaries | Bundeswahlleiter GeoJSON, BKG | Landeswahlleitungen, state GIS datasets | Whole-of-Germany (single geom) |
4747-| Biography / focus | Bundestag vita JSON, Abgeordnetenwatch issues | Landtag bios (open data) | Europarl member profiles |
4848-4949-## Implementation Notes
5050-- Expose `sync_representative_profiles` commands per source; schedule separately from mandate sync.
5151-- Track `source_version`/`hash` to avoid redundant imports.
5252-- View layer consumes a `RepresentativeProfileService` that aggregates focus areas, biography, links, responsiveness.
5353-- Keep a roadmap for future sources (party press, DIP21 votes, Europarl โfilesโ).
5454-5555-## Next Steps
5656-- Implement `RepresentativeProfile` model + importers for Abgeordnetenwatch and Bundestag.
5757-- Integrate boundary datasets and swap the PLZ router.
5858-- Wire the matching pipeline into the letter form suggestions and automated routing.
5959-- Add logging/monitoring for profile freshness and matching success.
6060-
-89
vision/mvp.md
···11-# MVP Vision
22-33-## Mission
44-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.
55-66-## Core Feature Set
77-1. **Accounts & Profiles**
88- - Email/password registration & login.
99- - Profile page showing authored letters, signed letters, and verification status.
1010-2. **Representative Directory**
1111- - EU, Bundestag, Landtag members with photo, party, mandate mode, committee roles, Abgeordnetenwatch links.
1212- - Exposed via detail view and reusable UI card.
1313-3. **Letter Authoring & Publishing**
1414- - Draft open letters, auto-suggest recipients based on title + PLZ.
1515- - Auto-sign on publish; allow editing until first signature.
1616- - Letter detail page shows full content, representative card, signature stats.
1717-4. **Recommendation Engine**
1818- - PLZ โ constituency router (direct/state/federal) using official boundary data.
1919- - Topic analysis highlighting likely responsible level and committee working areas.
2020- - Explain why a representative is recommended, surface relevant tags, show similar letters.
2121-5. **Signature Flow**
2222- - One-click signing for logged in users; prompt login otherwise.
2323- - Badges for verified vs unverified signatures, count constituents distinctly.
2424- - Social sharing (link copy, optional Twitter/Bluesky share).
2525-6. **Identity Verification (Optional)**
2626- - Integrate one third-party provider (e.g., Verimi or yesยฎ) via OAuth2/OIDC to pull verified name/address.
2727- - Store attestation + expiry; map address to constituency for direct mandates.
2828- - Users without verification can still sign, flagged as โunverified.โ
2929-7. **Signature Threshold & Fulfilment**
3030- - Configurable threshold per letter or representative type.
3131- - Admin view showing letters reaching milestones, export letter + supporters for printing/mailing.
3232-8. **Admin & Moderation**
3333- - Admin dashboard to inspect letters, representatives, signatures, verification status, and signature thresholds.
3434- - Ability to disable inappropriate letters, resend sync, run exports.
3535-9. **Landing & Discovery**
3636- - Public homepage summarising mission, stats, featured letters.
3737- - Browse letters and representatives without login.
3838-10. **Documentation & Transparency**
3939- - Public โHow it worksโ page, privacy policy, terms.
4040- - README covering setup, architecture, deployment.
4141-4242-## MVP To-Do List
4343-1. **Constituency & Matching Foundations**
4444- - [ ] Replace PLZ prefix heuristic with Wahlkreis GeoJSON (Bundestag) + state-level boundaries; build router service.
4545- - [x] Expand `TopicArea` taxonomy, add NLP/keyword scoring, and present explanations.
4646- - [x] Enrich representative metadata with committee focus, responsiveness, photos.
4747- - [x] Scope recommendation engine to relevant parliaments using constituency + topic competence.
4848-2. **Account Management**
4949- - [ ] Add account deletion option (removes signatures but keeps letters)
5050- - [ ] Add double opt-in for account creation
5151-3. **UX**
5252- - [ ] Add letter list sorting by signatures / verified signatures / age
5353- - [ ] Add filtering based on TopicArea keywords
5454- - [ ] Remove Kompetenzen info page
5555- - [ ] Rudimentary branding - color scheme, bootstrap
5656-3. **Identity Verification Integration**
5757- - [ ] Build provider abstraction and connect to first reusable ID service.
5858- - [ ] Persist provider response (hash/ID, address) with expiry handling; skip manual verification path.
5959- - [ ] Determine if providers exist offering login-provider functionality
6060- - [x] Support self-declared constituency verification with profile management UI.
6161-3. **Letter Authoring UX**
6262- - [x] Polish HTMX suggestions and representative cards for consistency.
6363- - [ ] Allow draft auto-save and clearer edit states pre-signature.
6464- - [ ] Add share buttons and clearer โcopy linkโ prompt on letter detail.
6565- - [ ] Add minimum letter length of 500 characters.
6666- - [ ] Make it very clear that letters cannot be changed after publication
6767- - [ ] Make it very clear that you can remove your signature from a letter, but not the letter itself
6868-6. **Localization & Accessibility**
6969- - [ ] Complete en/de translation coverage for all templates and forms.
7070- - [ ] Ensure forms, buttons, and suggestions meet accessibility best practices.
7171-7. **Deployment Readiness**
7272- - [ ] Production config (secrets, logging, error tracking, email backend).
7373- - [ ] Deploy to VPS - static media, unicorn, nginx, docker
7474- - [ ] Health checks with Tinylytics
7575- - [ ] Add caching
7676-8. **Feedback & Analytics**
7777- - [ ] Add feedback/contact channel for users.
7878- - [ ] Add simple analytics app. Middleware that keeps track of impressions -> build this so it can easily be moved into a separate repo
7979-9. **Testing & QA**
8080- - [ ] Expand automated test coverage (matching, verification, export workflow).
8181- - [ ] QA checklist for matching accuracy, verification flow, admin exports.
8282-8383-## Out of Scope for MVP
8484-- Local municipality reps, party-wide campaigns.
8585-- In-browser letter editing with collaboration.
8686-- Advanced analytics or CRM tooling.
8787-- Multiple identity providers (beyond initial integration).
8888-- Expert matching based on representative metadata keywords
8989-- Biography providers, display on representative detail view and extract keywords for matching
-65
vision/verification.md
···11-# Identity Verification Vision
22-33-## Objective
44-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.
55-66-## Core Requirements
77-- **Proof of personhood:** Ensure each signature is tied to a real individual (no throwaway accounts).
88-- **Proof of constituency:** Capture verified address (street, PLZ, city) and map it to the correct Wahlkreis / Landtag district.
99-- **Reusable identity:** Prefer providers where users can consent to sharing existing verified attributes (lower friction than video calls).
1010-- **Evidence retention:** Store cryptographically signed responses or verification references so we can prove the verification later.
1111-- **Expiry / refresh:** Verification should have a validity window (e.g., re-check every 6โ12 months or when user updates address).
1212-1313-## Recommended Providers (Germany)
1414-1515-### Identity Wallets (best reuse experience)
1616-- **Verimi** (OAuth2/OIDC)
1717- - Users already have a Verimi wallet โ grant consent โ we receive name + address.
1818- - Supports multiple underlying methods (eID, VideoIdent, bank sources).
1919-- **BundID / BundesIdent** (official government ID)
2020- - OIDC-based access to Personalausweis attributes via government portal.
2121- - Gold standard for address proof; onboarding limited to approved use cases.
2222-- **yesยฎ (yes.com)**
2323- - Bank login to participating institutions; returns bank-verified identity/address via OpenID Connect.
2424- - No new verification, just consent.
2525-- **Signicat Identity Platform**
2626- - Aggregator: supports Verimi, yesยฎ, BankID, eIDAS. Useful if expansion beyond Germany is planned.
2727-- **Nect Ident**
2828- - After an initial automated verification, users can re-share their identity from a wallet.
2929-3030-### Alternative Methods
3131-- **BankIdent / PSD2 providers** (WebID BankIdent, yesยฎ, Deutsche Bank BankIdent)
3232- - Users log into their bank; returns name/address. High trust, no video.
3333-- **eID solutions (AUTHADA, D-Trust)**
3434- - NFC-based Personalausweis reading; some provide reusable tokens after first use.
3535-- **VideoIdent (IDnow, WebID VideoIdent, POSTIDENT)**
3636- - Higher friction; use as fallback when wallet/bank options fail.
3737-3838-## Integration Architecture
3939-1. **Abstraction layer:** Implement a `VerificationProvider` interface with methods like `start_verification(user)` and `handle_callback(payload)`.
4040-2. **Provider adapters:** Build adapters for Verimi, yesยฎ, BundID, etc., each handling OAuth2/OIDC flows, token validation, and attribute extraction.
4141-3. **Verification storage:** Extend `IdentityVerification` to store provider name, verification reference, timestamp, address, and provider response hash/signature.
4242-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.
4343-5. **Expiry handling:** Add `expires_at`โprompt users to re-verify when outdated or on address change.
4444-6. **Audit trail:** Log provider responses; maintain a verification history per user.
4545-7. **Fallback/manual path:** Offer manual verification (moderator-reviewed documents) only if all automated providers fail, clearly flagging such signatures.
4646-4747-## User Flow Blueprint
4848-1. User chooses โVerify identityโ.
4949-2. We present available providers (Verimi, yesยฎ, BundIDโฆ).
5050-3. User authenticates/consents with chosen provider.
5151-4. Provider redirects back / sends webhook with verification result + attributes.
5252-5. We validate response, persist identity data, and map PLZ โ constituency.
5353-6. Signatures now display โVerified constituentโ (and reinforce direct mandates with proof).
5454-5555-## Implementation Priorities
5656-- Start with a wallet provider (Verimi or yesยฎ) for minimal friction.
5757-- Add BundID for maximum trust where accessible.
5858-- Abstract architecture so adding Landtag-specific providers later is straightforward.
5959-- Ensure we can reuse the same verification across multiple letters until it expires.
6060-6161-## Outstanding Questions
6262-- 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?)
6363-- How to handle users without access to any supported provider? (Manual override / postal verification?)
6464-- Data protection & consent: store only whatโs necessary (likely name + address); ensure GDPR-compliant retention policies.
6565-
+18
website/letters/apps.py
···44class LettersConfig(AppConfig):
55 default_auto_field = 'django.db.models.BigAutoField'
66 name = 'letters'
77+88+ def ready(self):
99+ """Pre-load GeoJSON data on startup for improved performance."""
1010+ try:
1111+ from .services import WahlkreisLocator
1212+ import logging
1313+1414+ logger = logging.getLogger(__name__)
1515+ logger.info("Warming WahlkreisLocator cache on startup...")
1616+1717+ # Initialize to load and cache GeoJSON data
1818+ WahlkreisLocator()
1919+2020+ logger.info("WahlkreisLocator cache warmed successfully")
2121+ except Exception as e:
2222+ import logging
2323+ logger = logging.getLogger(__name__)
2424+ logger.warning(f"Failed to warm WahlkreisLocator cache: {e}")
+65-1
website/letters/forms.py
···55from django.utils.translation import gettext_lazy as _
6677from .constants import normalize_german_state
88-from .models import Letter, Representative, Signature, Report, Tag, Constituency
88+from .models import Letter, Representative, Signature, Report, Tag, Constituency, IdentityVerification
9910101111class UserRegisterForm(UserCreationForm):
···241241 )
242242243243 return cleaned_data
244244+245245+246246+class IdentityVerificationForm(forms.Form):
247247+ """Form for collecting full address for identity verification."""
248248+249249+ street_address = forms.CharField(
250250+ max_length=255,
251251+ required=False,
252252+ label=_('Straรe und Hausnummer'),
253253+ widget=forms.TextInput(attrs={
254254+ 'class': 'form-control',
255255+ 'placeholder': _('z.B. Unter den Linden 77')
256256+ })
257257+ )
258258+ postal_code = forms.CharField(
259259+ max_length=20,
260260+ required=False,
261261+ label=_('Postleitzahl'),
262262+ widget=forms.TextInput(attrs={
263263+ 'class': 'form-control',
264264+ 'placeholder': _('z.B. 10117')
265265+ })
266266+ )
267267+ city = forms.CharField(
268268+ max_length=100,
269269+ required=False,
270270+ label=_('Stadt'),
271271+ widget=forms.TextInput(attrs={
272272+ 'class': 'form-control',
273273+ 'placeholder': _('z.B. Berlin')
274274+ })
275275+ )
276276+277277+ def __init__(self, *args, **kwargs):
278278+ self.user = kwargs.pop('user', None)
279279+ super().__init__(*args, **kwargs)
280280+281281+ # Pre-fill with existing address if available
282282+ if self.user and hasattr(self.user, 'identity_verification'):
283283+ verification = getattr(self.user, 'identity_verification', None)
284284+ if verification:
285285+ if verification.street_address:
286286+ self.fields['street_address'].initial = verification.street_address
287287+ if verification.postal_code:
288288+ self.fields['postal_code'].initial = verification.postal_code
289289+ if verification.city:
290290+ self.fields['city'].initial = verification.city
291291+292292+ def clean(self):
293293+ cleaned_data = super().clean()
294294+ street_address = cleaned_data.get('street_address')
295295+ postal_code = cleaned_data.get('postal_code')
296296+ city = cleaned_data.get('city')
297297+298298+ # Check if any field is provided
299299+ has_any = any([street_address, postal_code, city])
300300+ has_all = all([street_address, postal_code, city])
301301+302302+ if has_any and not has_all:
303303+ raise forms.ValidationError(
304304+ _('Bitte geben Sie eine vollstรคndige Adresse ein (Straรe, PLZ und Stadt) oder lassen Sie alle Felder leer.')
305305+ )
306306+307307+ return cleaned_data
···11-"""
22-Management command to test topic-to-constituency mapping.
33-44-Provides examples of how the topic suggestion service works.
55-"""
66-77-from django.core.management.base import BaseCommand
88-from letters.services import TopicSuggestionService
99-1010-1111-class Command(BaseCommand):
1212- help = 'Test topic-to-constituency mapping with example concerns'
1313-1414- def add_arguments(self, parser):
1515- parser.add_argument(
1616- '--concern',
1717- type=str,
1818- help='Custom concern text to test',
1919- )
2020-2121- def handle(self, *args, **options):
2222- custom_concern = options.get('concern')
2323-2424- if custom_concern:
2525- # Test custom concern
2626- self.test_concern(custom_concern)
2727- else:
2828- # Test predefined examples
2929- self.stdout.write(self.style.MIGRATE_HEADING('\nTesting Topic-to-Constituency Mapping\n'))
3030-3131- test_cases = [
3232- "I want to see better train connections between cities",
3333- "We need more affordable housing and rent control",
3434- "Our school curriculum needs reform",
3535- "Climate protection and CO2 emissions must be addressed",
3636- "Better bus services in my town",
3737- "Deutsche Bahn is always late",
3838- "University tuition fees should be abolished",
3939- "We need stronger EU trade agreements",
4040- "Police funding in our state is too low",
4141- "Renewable energy expansion is too slow",
4242- ]
4343-4444- for concern in test_cases:
4545- self.test_concern(concern)
4646- self.stdout.write('') # Blank line
4747-4848- def test_concern(self, concern_text: str):
4949- """Test a single concern and display results."""
5050- self.stdout.write(self.style.SUCCESS(f'Concern: "{concern_text}"'))
5151-5252- # Get topic suggestions only (lightweight)
5353- topics = TopicSuggestionService.get_topic_suggestions(concern_text)
5454-5555- if topics:
5656- self.stdout.write(self.style.WARNING(' Matched Topics:'))
5757- for topic in topics[:3]: # Show top 3
5858- self.stdout.write(f' โข {topic["name"]} ({topic["level"]}) - Score: {topic["match_score"]}')
5959- self.stdout.write(f' {topic["description"]}')
6060- else:
6161- self.stdout.write(self.style.WARNING(' No specific topics matched'))
6262-6363- # Get full suggestions with representatives
6464- result = TopicSuggestionService.suggest_representatives_for_concern(
6565- concern_text,
6666- limit=3
6767- )
6868-6969- self.stdout.write(self.style.WARNING(f' Suggested Level: {result["suggested_level"]}'))
7070- self.stdout.write(self.style.WARNING(f' Explanation: {result["explanation"]}'))
7171-7272- if result['suggested_constituencies']:
7373- self.stdout.write(self.style.WARNING(' Suggested Constituencies:'))
7474- for const in result['suggested_constituencies'][:3]:
7575- self.stdout.write(f' โข {const.name} ({const.level})')
7676-7777- if result['suggested_representatives']:
7878- self.stdout.write(self.style.WARNING(' Suggested Representatives:'))
7979- for rep in result['suggested_representatives']:
8080- party = f' ({rep.party})' if rep.party else ''
8181- constituency = rep.primary_constituency
8282- constituency_label = constituency.name if constituency else rep.parliament.name
8383- self.stdout.write(f' โข {rep.full_name}{party} - {constituency_label}')
8484- else:
8585- self.stdout.write(self.style.WARNING(' (No representatives found - run sync_representatives first)'))
···11+# ABOUTME: Test ConstituencySuggestionService combining topics and geography.
22+# ABOUTME: Integration tests for letter title/address to representative suggestions.
33+44+from django.test import TestCase
55+from unittest.mock import patch
66+from letters.services import ConstituencySuggestionService
77+88+99+class ConstituencySuggestionTests(TestCase):
1010+ """Test constituency suggestion combining topic and address matching."""
1111+1212+ @patch('letters.services.AddressGeocoder.geocode')
1313+ def test_suggest_with_title_and_address(self, mock_geocode):
1414+ """Test suggestions work with both title and address."""
1515+ # Mock geocoding
1616+ mock_geocode.return_value = (52.5186, 13.3761, True, None)
1717+1818+ result = ConstituencySuggestionService.suggest_from_concern(
1919+ concern_text="We need better train connections",
2020+ user_location={
2121+ "street": "Platz der Republik 1",
2222+ "postal_code": "11011",
2323+ "city": "Berlin"
2424+ }
2525+ )
2626+2727+ self.assertIn('matched_topics', result)
2828+ self.assertIn('suggested_level', result)
2929+ self.assertIn('explanation', result)
3030+ self.assertIn('representatives', result)
3131+ self.assertIn('constituencies', result)
3232+3333+ def test_suggest_with_only_title(self):
3434+ """Test suggestions work with only title (no address)."""
3535+ result = ConstituencySuggestionService.suggest_from_concern(
3636+ concern_text="Climate protection is important"
3737+ )
3838+3939+ self.assertIn('matched_topics', result)
4040+ self.assertIn('suggested_level', result)
4141+ # Without address, should still suggest level and topics
4242+ self.assertIsNotNone(result['suggested_level'])
4343+4444+ def test_suggest_with_only_postal_code(self):
4545+ """Test suggestions work with only postal code."""
4646+ result = ConstituencySuggestionService.suggest_from_concern(
4747+ concern_text="Local infrastructure problems",
4848+ user_location={
4949+ "postal_code": "10115"
5050+ }
5151+ )
5252+5353+ self.assertIn('constituencies', result)
5454+ # Should use PLZ fallback
5555+ self.assertIsInstance(result['constituencies'], list)
5656+5757+5858+# End of file
+89
website/letters/tests/test_i18n.py
···11+# ABOUTME: Tests for internationalization configuration and functionality.
22+# ABOUTME: Verifies language switching, URL prefixes, and translation completeness.
33+44+from django.test import TestCase
55+from django.conf import settings
66+from django.core.management import call_command
77+from io import StringIO
88+99+1010+class I18nConfigurationTests(TestCase):
1111+ def test_i18n_enabled(self):
1212+ """Test that USE_I18N is enabled."""
1313+ self.assertTrue(settings.USE_I18N)
1414+1515+ def test_supported_languages(self):
1616+ """Test that German and English are configured."""
1717+ language_codes = [code for code, name in settings.LANGUAGES]
1818+ self.assertIn('de', language_codes)
1919+ self.assertIn('en', language_codes)
2020+2121+ def test_locale_paths_configured(self):
2222+ """Test that LOCALE_PATHS is set."""
2323+ self.assertTrue(len(settings.LOCALE_PATHS) > 0)
2424+2525+2626+class I18nURLTests(TestCase):
2727+ def test_german_url_prefix_works(self):
2828+ """Test that German URL prefix is accessible."""
2929+ response = self.client.get('/de/')
3030+ self.assertEqual(response.status_code, 200)
3131+3232+ def test_english_url_prefix_works(self):
3333+ """Test that English URL prefix is accessible."""
3434+ response = self.client.get('/en/')
3535+ self.assertEqual(response.status_code, 200)
3636+3737+ def test_set_language_endpoint_exists(self):
3838+ """Test that language switcher endpoint exists."""
3939+ from django.urls import reverse
4040+ url = reverse('set_language')
4141+ self.assertEqual(url, '/i18n/setlang/')
4242+4343+4444+class LanguageSwitcherTests(TestCase):
4545+ def test_language_switcher_present_in_page(self):
4646+ """Test that language switcher form is present."""
4747+ response = self.client.get('/de/')
4848+ self.assertContains(response, 'name="language"')
4949+ self.assertContains(response, 'Deutsch')
5050+ self.assertContains(response, 'English')
5151+5252+ def test_language_switch_changes_language(self):
5353+ """Test that submitting language form changes language."""
5454+ response = self.client.post(
5555+ '/i18n/setlang/',
5656+ {'language': 'en', 'next': '/en/'},
5757+ )
5858+ # Check we got a redirect
5959+ self.assertEqual(response.status_code, 302)
6060+ # Check cookie was set
6161+ self.assertIn('django_language', response.cookies)
6262+ self.assertEqual(response.cookies['django_language'].value, 'en')
6363+6464+6565+class LetterFormI18nTests(TestCase):
6666+ def test_letter_form_template_renders(self):
6767+ """Test that letter creation form renders without errors."""
6868+ from django.contrib.auth.models import User
6969+ # Create a user and log them in
7070+ user = User.objects.create_user(username='testuser', password='testpass')
7171+ self.client.login(username='testuser', password='testpass')
7272+7373+ # Test German version
7474+ response = self.client.get('/de/letter/new/')
7575+ self.assertEqual(response.status_code, 200)
7676+7777+ # Test English version
7878+ response = self.client.get('/en/letter/new/')
7979+ self.assertEqual(response.status_code, 200)
8080+8181+8282+class TranslationCompletenessTests(TestCase):
8383+ def test_check_translations_command_exists(self):
8484+ """Test that check_translations command can be called."""
8585+ out = StringIO()
8686+ call_command('check_translations', stdout=out)
8787+ output = out.getvalue()
8888+ self.assertIn('Deutsch', output)
8989+ self.assertIn('English', output)
+101
website/letters/tests/test_topic_mapping.py
···11+# ABOUTME: Test topic suggestion and matching based on letter content.
22+# ABOUTME: Covers TopicSuggestionService keyword matching and level suggestion logic.
33+44+from django.test import TestCase
55+from letters.services import TopicSuggestionService
66+from letters.models import TopicArea
77+88+99+class TopicMatchingTests(TestCase):
1010+ """Test topic keyword matching and scoring."""
1111+1212+ def setUp(self):
1313+ """Check if topic data is available."""
1414+ self.has_topics = TopicArea.objects.exists()
1515+1616+ def test_transport_keywords_match_verkehr_topic(self):
1717+ """Test that transport-related keywords match Verkehr topic."""
1818+ if not self.has_topics:
1919+ self.skipTest("TopicArea data not loaded")
2020+2121+ concern = "I want to see better train connections between cities"
2222+ result = TopicSuggestionService.suggest_representatives_for_concern(concern)
2323+2424+ # Should find at least one topic
2525+ matched_topics = result.get('matched_topics', [])
2626+ self.assertGreater(len(matched_topics), 0)
2727+2828+ def test_housing_keywords_match_wohnen_topic(self):
2929+ """Test that housing keywords match Wohnen topic."""
3030+ if not self.has_topics:
3131+ self.skipTest("TopicArea data not loaded")
3232+3333+ concern = "We need more affordable housing and rent control"
3434+ result = TopicSuggestionService.suggest_representatives_for_concern(concern)
3535+3636+ matched_topics = result.get('matched_topics', [])
3737+ self.assertGreater(len(matched_topics), 0)
3838+3939+ def test_education_keywords_match_bildung_topic(self):
4040+ """Test that education keywords match Bildung topic."""
4141+ if not self.has_topics:
4242+ self.skipTest("TopicArea data not loaded")
4343+4444+ concern = "Our school curriculum needs reform"
4545+ result = TopicSuggestionService.suggest_representatives_for_concern(concern)
4646+4747+ matched_topics = result.get('matched_topics', [])
4848+ self.assertGreater(len(matched_topics), 0)
4949+5050+ def test_climate_keywords_match_umwelt_topic(self):
5151+ """Test that climate keywords match environment topic."""
5252+ if not self.has_topics:
5353+ self.skipTest("TopicArea data not loaded")
5454+5555+ concern = "Climate protection and CO2 emissions must be addressed"
5656+ result = TopicSuggestionService.suggest_representatives_for_concern(concern)
5757+5858+ matched_topics = result.get('matched_topics', [])
5959+ self.assertGreater(len(matched_topics), 0)
6060+6161+ def test_no_match_returns_empty_list(self):
6262+ """Test that completely unrelated text returns empty list."""
6363+ concern = "xyzabc nonsense gibberish"
6464+ result = TopicSuggestionService.suggest_representatives_for_concern(concern)
6565+6666+ matched_topics = result.get('matched_topics', [])
6767+ # Should return empty list for gibberish
6868+ self.assertEqual(len(matched_topics), 0)
6969+7070+7171+class LevelSuggestionTests(TestCase):
7272+ """Test government level suggestion logic."""
7373+7474+ def test_federal_transport_suggests_federal_level(self):
7575+ """Test that long-distance transport suggests federal level."""
7676+ result = TopicSuggestionService.suggest_representatives_for_concern(
7777+ "Deutsche Bahn is always late",
7878+ limit=5
7979+ )
8080+8181+ self.assertIn('suggested_level', result)
8282+ self.assertIn('explanation', result)
8383+ # Federal issues should suggest FEDERAL level
8484+ suggested_level = result['suggested_level']
8585+ self.assertIsNotNone(suggested_level)
8686+ self.assertIn('FEDERAL', suggested_level)
8787+8888+ def test_local_bus_suggests_state_or_local(self):
8989+ """Test that local transport suggests state/local level."""
9090+ result = TopicSuggestionService.suggest_representatives_for_concern(
9191+ "Better bus services in my town",
9292+ limit=5
9393+ )
9494+9595+ self.assertIn('suggested_level', result)
9696+ self.assertIn('explanation', result)
9797+ # Should have an explanation
9898+ self.assertIsNotNone(result['explanation'])
9999+100100+101101+# End of file
+697
website/letters/tests.py
···688688 self.assertNotIn(self.state_rep_list.last_name, content)
689689 self.assertIn(self.federal_expert_rep.last_name, content)
690690691691+ def test_suggest_with_full_address(self):
692692+ """Test that suggestions work with full address (street, postal_code, city)."""
693693+ from unittest.mock import patch, Mock
694694+695695+ # Mock geocoding to return coordinates for Bundestag building
696696+ mock_response = Mock()
697697+ mock_response.status_code = 200
698698+ mock_response.json.return_value = [
699699+ {
700700+ 'lat': '52.5186',
701701+ 'lon': '13.3761',
702702+ 'display_name': 'Berlin, Germany'
703703+ }
704704+ ]
705705+706706+ with patch('requests.get', return_value=mock_response):
707707+ result = ConstituencySuggestionService.suggest_from_concern(
708708+ 'Mehr Verkehr und รPNV in Berlin Mitte',
709709+ user_location={
710710+ 'street': 'Platz der Republik 1',
711711+ 'postal_code': '11011',
712712+ 'city': 'Berlin',
713713+ 'country': 'DE'
714714+ }
715715+ )
716716+717717+ # Should find representatives (direct representatives from Berlin)
718718+ self.assertGreater(len(result['representatives']), 0)
719719+ self.assertIn(self.transport_topic, result['matched_topics'])
720720+721721+ # Should have direct representatives from Berlin
722722+ direct_reps = result.get('direct_representatives', [])
723723+ # At least one Berlin rep should be suggested
724724+ berlin_reps = [
725725+ rep for rep in direct_reps
726726+ if any(
727727+ (c.metadata or {}).get('state') == 'Berlin'
728728+ for c in rep.constituencies.all()
729729+ )
730730+ ]
731731+ self.assertGreater(len(berlin_reps), 0, "Should suggest at least one Berlin representative")
732732+733733+ def test_suggest_with_plz_only_backward_compatibility(self):
734734+ """Test that PLZ-only suggestions still work (backward compatibility)."""
735735+ result = ConstituencySuggestionService.suggest_from_concern(
736736+ 'Klimaschutz ist wichtig',
737737+ user_location={
738738+ 'postal_code': '10115', # Berlin PLZ
739739+ }
740740+ )
741741+742742+ # Should work without crashing
743743+ self.assertIsInstance(result, dict)
744744+ self.assertIn('representatives', result)
745745+ self.assertIn('matched_topics', result)
746746+747747+ # Result should be valid even if empty
748748+ self.assertIsInstance(result['representatives'], list)
749749+691750692751class CompetencyPageTests(TestCase):
693752 """Ensure the competency overview renders topics for visitors."""
···731790 self.assertIn('<li>Zwei</li>', rendered)
732791733792793793+class GeocodeCacheTests(TestCase):
794794+ """Test geocoding cache model."""
795795+796796+ def test_cache_stores_and_retrieves_coordinates(self):
797797+ from .models import GeocodeCache
798798+799799+ cache_entry = GeocodeCache.objects.create(
800800+ address_hash='test_hash_123',
801801+ street='Unter den Linden 77',
802802+ postal_code='10117',
803803+ city='Berlin',
804804+ latitude=52.5170365,
805805+ longitude=13.3888599,
806806+ )
807807+808808+ retrieved = GeocodeCache.objects.get(address_hash='test_hash_123')
809809+ self.assertEqual(retrieved.latitude, 52.5170365)
810810+ self.assertEqual(retrieved.longitude, 13.3888599)
811811+ self.assertEqual(retrieved.street, 'Unter den Linden 77')
812812+813813+814814+class AddressGeocoderTests(TestCase):
815815+ """Test the AddressGeocoder service with OSM Nominatim API."""
816816+817817+ def test_successful_geocoding_with_mocked_api(self):
818818+ """Test successful geocoding with mocked Nominatim API response."""
819819+ from unittest.mock import patch, Mock
820820+ from .services import AddressGeocoder
821821+822822+ mock_response = Mock()
823823+ mock_response.status_code = 200
824824+ mock_response.json.return_value = [
825825+ {
826826+ 'lat': '52.5200066',
827827+ 'lon': '13.404954',
828828+ 'display_name': 'Berlin, Germany'
829829+ }
830830+ ]
831831+832832+ with patch('requests.get', return_value=mock_response) as mock_get:
833833+ geocoder = AddressGeocoder()
834834+ lat, lon, success, error = geocoder.geocode(
835835+ street='Unter den Linden 77',
836836+ postal_code='10117',
837837+ city='Berlin'
838838+ )
839839+840840+ self.assertTrue(success)
841841+ self.assertIsNone(error)
842842+ self.assertAlmostEqual(lat, 52.5200066, places=6)
843843+ self.assertAlmostEqual(lon, 13.404954, places=6)
844844+845845+ # Verify the request was made correctly
846846+ mock_get.assert_called_once()
847847+ call_args = mock_get.call_args
848848+ self.assertIn('nominatim.openstreetmap.org', call_args[0][0])
849849+ self.assertEqual(call_args[1]['headers']['User-Agent'], 'WriteThem.eu/0.1 (civic engagement platform)')
850850+851851+ def test_cache_hit_no_api_call(self):
852852+ """Test that cache hits don't make API calls."""
853853+ from unittest.mock import patch
854854+ from .models import GeocodeCache
855855+ from .services import AddressGeocoder
856856+ import hashlib
857857+858858+ # Create a cache entry
859859+ address_string = 'Unter den Linden 77|10117|Berlin|DE'
860860+ address_hash = hashlib.sha256(address_string.encode('utf-8')).hexdigest()
861861+862862+ GeocodeCache.objects.create(
863863+ address_hash=address_hash,
864864+ street='Unter den Linden 77',
865865+ postal_code='10117',
866866+ city='Berlin',
867867+ country='DE',
868868+ latitude=52.5200066,
869869+ longitude=13.404954,
870870+ success=True
871871+ )
872872+873873+ with patch('requests.get') as mock_get:
874874+ geocoder = AddressGeocoder()
875875+ lat, lon, success, error = geocoder.geocode(
876876+ street='Unter den Linden 77',
877877+ postal_code='10117',
878878+ city='Berlin'
879879+ )
880880+881881+ self.assertTrue(success)
882882+ self.assertIsNone(error)
883883+ self.assertAlmostEqual(lat, 52.5200066, places=6)
884884+ self.assertAlmostEqual(lon, 13.404954, places=6)
885885+886886+ # Verify NO API call was made
887887+ mock_get.assert_not_called()
888888+889889+ def test_failed_geocoding_cached(self):
890890+ """Test that failed geocoding attempts are cached."""
891891+ from unittest.mock import patch, Mock
892892+ from .models import GeocodeCache
893893+ from .services import AddressGeocoder
894894+895895+ mock_response = Mock()
896896+ mock_response.status_code = 200
897897+ mock_response.json.return_value = [] # Empty result = not found
898898+899899+ with patch('requests.get', return_value=mock_response):
900900+ geocoder = AddressGeocoder()
901901+ lat, lon, success, error = geocoder.geocode(
902902+ street='Nonexistent Street 999',
903903+ postal_code='99999',
904904+ city='Nowhere'
905905+ )
906906+907907+ self.assertFalse(success)
908908+ self.assertIsNotNone(error)
909909+ self.assertIsNone(lat)
910910+ self.assertIsNone(lon)
911911+912912+ # Verify the failure was cached
913913+ import hashlib
914914+ address_string = 'Nonexistent Street 999|99999|Nowhere|DE'
915915+ address_hash = hashlib.sha256(address_string.encode('utf-8')).hexdigest()
916916+917917+ cache_entry = GeocodeCache.objects.get(address_hash=address_hash)
918918+ self.assertFalse(cache_entry.success)
919919+ self.assertEqual(cache_entry.error_message, error)
920920+921921+ def test_api_error_handling(self):
922922+ """Test that API errors are handled gracefully."""
923923+ from unittest.mock import patch
924924+ from .services import AddressGeocoder
925925+ import requests
926926+927927+ with patch('requests.get', side_effect=requests.RequestException('API Error')):
928928+ geocoder = AddressGeocoder()
929929+ lat, lon, success, error = geocoder.geocode(
930930+ street='Test Street',
931931+ postal_code='12345',
932932+ city='Test City'
933933+ )
934934+935935+ self.assertFalse(success)
936936+ self.assertIsNotNone(error)
937937+ self.assertIsNone(lat)
938938+ self.assertIsNone(lon)
939939+ self.assertIn('API Error', error)
940940+941941+734942class RepresentativeMetadataExtractionTests(TestCase):
735943736944 def setUp(self):
···7951003 service._ensure_photo_reference(rep)
7961004 rep.refresh_from_db()
7971005 self.assertEqual(rep.photo_path, 'representatives/999.jpg')
10061006+10071007+10081008+class GeoJSONDataTests(TestCase):
10091009+ """Test that official Bundestag constituency GeoJSON data is available and valid."""
10101010+10111011+ def test_geojson_file_exists(self):
10121012+ """Verify the wahlkreise.geojson file exists in the data directory."""
10131013+ from pathlib import Path
10141014+ from django.conf import settings
10151015+10161016+ geojson_path = Path(settings.BASE_DIR) / 'letters' / 'data' / 'wahlkreise.geojson'
10171017+ self.assertTrue(geojson_path.exists(), f"GeoJSON file not found at {geojson_path}")
10181018+10191019+ def test_geojson_is_valid_and_loadable(self):
10201020+ """Verify the GeoJSON file is valid JSON and has expected structure."""
10211021+ import json
10221022+ from pathlib import Path
10231023+ from django.conf import settings
10241024+10251025+ geojson_path = Path(settings.BASE_DIR) / 'letters' / 'data' / 'wahlkreise.geojson'
10261026+10271027+ with open(geojson_path, 'r', encoding='utf-8') as f:
10281028+ data = json.load(f)
10291029+10301030+ self.assertEqual(data['type'], 'FeatureCollection')
10311031+ self.assertIn('features', data)
10321032+ self.assertIsInstance(data['features'], list)
10331033+10341034+ def test_geojson_contains_all_constituencies(self):
10351035+ """Verify the GeoJSON contains all 299 Bundestag constituencies."""
10361036+ import json
10371037+ from pathlib import Path
10381038+ from django.conf import settings
10391039+10401040+ geojson_path = Path(settings.BASE_DIR) / 'letters' / 'data' / 'wahlkreise.geojson'
10411041+10421042+ with open(geojson_path, 'r', encoding='utf-8') as f:
10431043+ data = json.load(f)
10441044+10451045+ self.assertEqual(len(data['features']), 299)
10461046+10471047+ def test_geojson_features_have_required_properties(self):
10481048+ """Verify each feature has required properties: WKR_NR, WKR_NAME, LAND_NR, LAND_NAME."""
10491049+ import json
10501050+ from pathlib import Path
10511051+ from django.conf import settings
10521052+10531053+ geojson_path = Path(settings.BASE_DIR) / 'letters' / 'data' / 'wahlkreise.geojson'
10541054+10551055+ with open(geojson_path, 'r', encoding='utf-8') as f:
10561056+ data = json.load(f)
10571057+10581058+ # Check first feature
10591059+ if len(data['features']) > 0:
10601060+ feature = data['features'][0]
10611061+ self.assertEqual(feature['type'], 'Feature')
10621062+ self.assertIn('properties', feature)
10631063+ self.assertIn('geometry', feature)
10641064+10651065+ properties = feature['properties']
10661066+ self.assertIn('WKR_NR', properties)
10671067+ self.assertIn('WKR_NAME', properties)
10681068+ self.assertIn('LAND_NR', properties)
10691069+ self.assertIn('LAND_NAME', properties)
10701070+10711071+ # Verify geometry type
10721072+ self.assertEqual(feature['geometry']['type'], 'Polygon')
10731073+10741074+ def test_geojson_constituency_numbers_complete(self):
10751075+ """Verify constituency numbers range from 1 to 299 with no gaps."""
10761076+ import json
10771077+ from pathlib import Path
10781078+ from django.conf import settings
10791079+10801080+ geojson_path = Path(settings.BASE_DIR) / 'letters' / 'data' / 'wahlkreise.geojson'
10811081+10821082+ with open(geojson_path, 'r', encoding='utf-8') as f:
10831083+ data = json.load(f)
10841084+10851085+ wkr_numbers = [f['properties']['WKR_NR'] for f in data['features']]
10861086+ expected_numbers = set(range(1, 300))
10871087+ actual_numbers = set(wkr_numbers)
10881088+10891089+ self.assertEqual(actual_numbers, expected_numbers, "Constituency numbers should be 1-299 with no gaps")
10901090+10911091+10921092+class WahlkreisLocatorTests(TestCase):
10931093+ """Test the WahlkreisLocator service for point-in-polygon constituency matching."""
10941094+10951095+ def test_locate_bundestag_building(self):
10961096+ """Test that Bundestag coordinates (52.5186, 13.3761) find a Berlin constituency."""
10971097+ from .services import WahlkreisLocator
10981098+10991099+ locator = WahlkreisLocator()
11001100+ result = locator.locate(52.5186, 13.3761)
11011101+11021102+ self.assertIsNotNone(result, "Bundestag coordinates should find a constituency")
11031103+ wkr_nr, wkr_name, land_name = result
11041104+ self.assertIsInstance(wkr_nr, int)
11051105+ self.assertGreater(wkr_nr, 0)
11061106+ self.assertLessEqual(wkr_nr, 299)
11071107+ self.assertIsInstance(wkr_name, str)
11081108+ self.assertIsInstance(land_name, str)
11091109+ self.assertIn('Berlin', land_name, "Bundestag should be in a Berlin constituency")
11101110+11111111+ def test_locate_hamburg_rathaus(self):
11121112+ """Test that Hamburg Rathaus (53.5511, 9.9937) finds a Hamburg constituency."""
11131113+ from .services import WahlkreisLocator
11141114+11151115+ locator = WahlkreisLocator()
11161116+ result = locator.locate(53.5511, 9.9937)
11171117+11181118+ self.assertIsNotNone(result, "Hamburg Rathaus coordinates should find a constituency")
11191119+ wkr_nr, wkr_name, land_name = result
11201120+ self.assertIsInstance(wkr_nr, int)
11211121+ self.assertGreater(wkr_nr, 0)
11221122+ self.assertLessEqual(wkr_nr, 299)
11231123+ self.assertIn('Hamburg', land_name, "Hamburg Rathaus should be in a Hamburg constituency")
11241124+11251125+ def test_locate_multiple_known_locations(self):
11261126+ """Test multiple German cities to ensure accurate constituency matching."""
11271127+ from .services import WahlkreisLocator
11281128+11291129+ locator = WahlkreisLocator()
11301130+11311131+ # Munich: Marienplatz (48.1374, 11.5755)
11321132+ munich_result = locator.locate(48.1374, 11.5755)
11331133+ self.assertIsNotNone(munich_result, "Munich coordinates should find a constituency")
11341134+ self.assertIn('Bayern', munich_result[2], "Munich should be in Bavaria")
11351135+11361136+ # Cologne: Dom (50.9413, 6.9583)
11371137+ cologne_result = locator.locate(50.9413, 6.9583)
11381138+ self.assertIsNotNone(cologne_result, "Cologne coordinates should find a constituency")
11391139+ self.assertIn('Nordrhein-Westfalen', cologne_result[2], "Cologne should be in NRW")
11401140+11411141+ # Frankfurt: Rรถmer (50.1106, 8.6821)
11421142+ frankfurt_result = locator.locate(50.1106, 8.6821)
11431143+ self.assertIsNotNone(frankfurt_result, "Frankfurt coordinates should find a constituency")
11441144+ self.assertIn('Hessen', frankfurt_result[2], "Frankfurt should be in Hessen")
11451145+11461146+ # Dresden: Frauenkirche (51.0515, 13.7416)
11471147+ dresden_result = locator.locate(51.0515, 13.7416)
11481148+ self.assertIsNotNone(dresden_result, "Dresden coordinates should find a constituency")
11491149+ self.assertIn('Sachsen', dresden_result[2], "Dresden should be in Saxony")
11501150+11511151+ # Stuttgart: Schlossplatz (48.7775, 9.1797)
11521152+ stuttgart_result = locator.locate(48.7775, 9.1797)
11531153+ self.assertIsNotNone(stuttgart_result, "Stuttgart coordinates should find a constituency")
11541154+ self.assertIn('Baden-Wรผrttemberg', stuttgart_result[2], "Stuttgart should be in Baden-Wรผrttemberg")
11551155+11561156+ def test_coordinates_outside_germany(self):
11571157+ """Test that coordinates outside Germany return None."""
11581158+ from .services import WahlkreisLocator
11591159+11601160+ locator = WahlkreisLocator()
11611161+11621162+ # Paris, France (48.8566, 2.3522)
11631163+ paris_result = locator.locate(48.8566, 2.3522)
11641164+ self.assertIsNone(paris_result, "Paris coordinates should not find a German constituency")
11651165+11661166+ # London, UK (51.5074, -0.1278)
11671167+ london_result = locator.locate(51.5074, -0.1278)
11681168+ self.assertIsNone(london_result, "London coordinates should not find a German constituency")
11691169+11701170+ # New York, USA (40.7128, -74.0060)
11711171+ nyc_result = locator.locate(40.7128, -74.0060)
11721172+ self.assertIsNone(nyc_result, "NYC coordinates should not find a German constituency")
11731173+11741174+ def test_geojson_loads_successfully(self):
11751175+ """Test that the service can load the 44MB GeoJSON file without errors."""
11761176+ from .services import WahlkreisLocator
11771177+ import time
11781178+11791179+ start_time = time.time()
11801180+ locator = WahlkreisLocator()
11811181+ load_time = time.time() - start_time
11821182+11831183+ # Verify the service loaded constituencies
11841184+ self.assertIsNotNone(locator.constituencies, "Constituencies should be loaded")
11851185+ self.assertGreater(len(locator.constituencies), 0, "Should have loaded constituencies")
11861186+ self.assertEqual(len(locator.constituencies), 299, "Should have loaded all 299 constituencies")
11871187+11881188+ # Verify loading is reasonably fast (< 2 seconds)
11891189+ self.assertLess(load_time, 2.0, f"GeoJSON loading took {load_time:.2f}s, should be under 2 seconds")
11901190+11911191+11921192+class ConstituencyLocatorIntegrationTests(ParliamentFixtureMixin, TestCase):
11931193+ """Test the integrated ConstituencyLocator with address-based and PLZ fallback."""
11941194+11951195+ def test_locate_by_full_address_returns_constituencies(self):
11961196+ """Test address-based constituency lookup returns Representatives."""
11971197+ from unittest.mock import patch, Mock
11981198+ from .services import ConstituencyLocator
11991199+12001200+ # Mock the geocoding response for Platz der Republik 1, Berlin
12011201+ # This is the Bundestag building location
12021202+ mock_geocode_response = Mock()
12031203+ mock_geocode_response.status_code = 200
12041204+ mock_geocode_response.json.return_value = [
12051205+ {
12061206+ 'lat': '52.5186',
12071207+ 'lon': '13.3761',
12081208+ 'display_name': 'Platz der Republik, Berlin, Germany'
12091209+ }
12101210+ ]
12111211+12121212+ with patch('requests.get', return_value=mock_geocode_response):
12131213+ locator = ConstituencyLocator()
12141214+ result = locator.locate(
12151215+ street='Platz der Republik 1',
12161216+ postal_code='11011',
12171217+ city='Berlin'
12181218+ )
12191219+12201220+ # Result should be a list of Representative objects
12211221+ self.assertIsInstance(result, list)
12221222+ # Should find at least one representative
12231223+ self.assertGreater(len(result), 0)
12241224+12251225+ # All results should be Representative instances
12261226+ from .models import Representative
12271227+ for rep in result:
12281228+ self.assertIsInstance(rep, Representative)
12291229+ # Representatives should be from Berlin
12301230+ rep_states = {
12311231+ (c.metadata or {}).get('state')
12321232+ for c in rep.constituencies.all()
12331233+ if c.metadata
12341234+ }
12351235+ self.assertIn('Berlin', rep_states, f"Representative {rep} should be from Berlin")
12361236+12371237+ def test_locate_by_full_address_with_cache(self):
12381238+ """Test that cached geocoding results are used for constituency lookup."""
12391239+ from .models import GeocodeCache
12401240+ from .services import ConstituencyLocator
12411241+ import hashlib
12421242+12431243+ # Pre-populate cache with Berlin coordinates
12441244+ address_string = 'Unter den Linden 77|10117|Berlin|DE'
12451245+ address_hash = hashlib.sha256(address_string.encode('utf-8')).hexdigest()
12461246+12471247+ GeocodeCache.objects.create(
12481248+ address_hash=address_hash,
12491249+ street='Unter den Linden 77',
12501250+ postal_code='10117',
12511251+ city='Berlin',
12521252+ country='DE',
12531253+ latitude=52.5186,
12541254+ longitude=13.3761,
12551255+ success=True
12561256+ )
12571257+12581258+ # No mocking needed - should use cache
12591259+ locator = ConstituencyLocator()
12601260+ result = locator.locate(
12611261+ street='Unter den Linden 77',
12621262+ postal_code='10117',
12631263+ city='Berlin'
12641264+ )
12651265+12661266+ # Should find representatives
12671267+ self.assertIsInstance(result, list)
12681268+ self.assertGreater(len(result), 0)
12691269+12701270+ def test_locate_by_plz_fallback_when_geocoding_fails(self):
12711271+ """Test PLZ-based fallback when address geocoding fails."""
12721272+ from unittest.mock import patch, Mock
12731273+ from .services import ConstituencyLocator
12741274+12751275+ # Mock geocoding to return failure
12761276+ mock_response = Mock()
12771277+ mock_response.status_code = 200
12781278+ mock_response.json.return_value = [] # Empty = not found
12791279+12801280+ with patch('requests.get', return_value=mock_response):
12811281+ locator = ConstituencyLocator()
12821282+ result = locator.locate(
12831283+ street='Nonexistent Street 999',
12841284+ postal_code='10115', # Berlin PLZ
12851285+ city='Berlin'
12861286+ )
12871287+12881288+ # Should still find representatives using PLZ fallback
12891289+ self.assertIsInstance(result, list)
12901290+ # PLZ fallback might return empty list or representatives depending on data
12911291+ # The important thing is it doesn't crash
12921292+12931293+ def test_locate_by_plz_only_maintains_backward_compatibility(self):
12941294+ """Test that PLZ-only lookup still works (backward compatibility)."""
12951295+ from .services import ConstituencyLocator
12961296+12971297+ locator = ConstituencyLocator()
12981298+ result = locator.locate(postal_code='10115') # Berlin PLZ
12991299+13001300+ # Should work without crashing
13011301+ self.assertIsInstance(result, list)
13021302+ # Result depends on existing data, but should at least not error
13031303+13041304+ def test_locate_without_parameters_returns_empty(self):
13051305+ """Test that calling locate without parameters returns empty list."""
13061306+ from .services import ConstituencyLocator
13071307+13081308+ locator = ConstituencyLocator()
13091309+ result = locator.locate()
13101310+13111311+ self.assertIsInstance(result, list)
13121312+ self.assertEqual(len(result), 0)
13131313+13141314+13151315+class IdentityVerificationFormTests(TestCase):
13161316+ """Test the IdentityVerificationForm for full address collection."""
13171317+13181318+ def setUp(self):
13191319+ self.user = User.objects.create_user(
13201320+ username='testuser',
13211321+ password='password123',
13221322+ email='testuser@example.com',
13231323+ )
13241324+13251325+ def test_form_requires_all_address_fields_together(self):
13261326+ """Test that form validation requires all address fields if any is provided."""
13271327+ from .forms import IdentityVerificationForm
13281328+13291329+ # Only street provided - should fail
13301330+ form = IdentityVerificationForm(
13311331+ data={
13321332+ 'street_address': 'Unter den Linden 77',
13331333+ 'postal_code': '',
13341334+ 'city': '',
13351335+ },
13361336+ user=self.user
13371337+ )
13381338+ self.assertFalse(form.is_valid())
13391339+ self.assertIn('Bitte geben Sie eine vollstรคndige Adresse ein', str(form.errors))
13401340+13411341+ # Only PLZ provided - should fail
13421342+ form = IdentityVerificationForm(
13431343+ data={
13441344+ 'street_address': '',
13451345+ 'postal_code': '10117',
13461346+ 'city': '',
13471347+ },
13481348+ user=self.user
13491349+ )
13501350+ self.assertFalse(form.is_valid())
13511351+ self.assertIn('Bitte geben Sie eine vollstรคndige Adresse ein', str(form.errors))
13521352+13531353+ # Only city provided - should fail
13541354+ form = IdentityVerificationForm(
13551355+ data={
13561356+ 'street_address': '',
13571357+ 'postal_code': '',
13581358+ 'city': 'Berlin',
13591359+ },
13601360+ user=self.user
13611361+ )
13621362+ self.assertFalse(form.is_valid())
13631363+ self.assertIn('Bitte geben Sie eine vollstรคndige Adresse ein', str(form.errors))
13641364+13651365+ def test_form_accepts_all_address_fields(self):
13661366+ """Test that form is valid when all address fields are provided."""
13671367+ from .forms import IdentityVerificationForm
13681368+13691369+ form = IdentityVerificationForm(
13701370+ data={
13711371+ 'street_address': 'Unter den Linden 77',
13721372+ 'postal_code': '10117',
13731373+ 'city': 'Berlin',
13741374+ },
13751375+ user=self.user
13761376+ )
13771377+ self.assertTrue(form.is_valid())
13781378+13791379+ def test_form_accepts_empty_address(self):
13801380+ """Test that form is valid when all address fields are empty."""
13811381+ from .forms import IdentityVerificationForm
13821382+13831383+ form = IdentityVerificationForm(
13841384+ data={
13851385+ 'street_address': '',
13861386+ 'postal_code': '',
13871387+ 'city': '',
13881388+ },
13891389+ user=self.user
13901390+ )
13911391+ self.assertTrue(form.is_valid())
13921392+13931393+ def test_form_prefills_existing_address(self):
13941394+ """Test that form prefills existing address from verification."""
13951395+ from .forms import IdentityVerificationForm
13961396+13971397+ # Create verification with address
13981398+ verification = IdentityVerification.objects.create(
13991399+ user=self.user,
14001400+ status='SELF_DECLARED',
14011401+ street_address='Unter den Linden 77',
14021402+ postal_code='10117',
14031403+ city='Berlin',
14041404+ )
14051405+14061406+ form = IdentityVerificationForm(user=self.user)
14071407+14081408+ self.assertEqual(form.fields['street_address'].initial, 'Unter den Linden 77')
14091409+ self.assertEqual(form.fields['postal_code'].initial, '10117')
14101410+ self.assertEqual(form.fields['city'].initial, 'Berlin')
14111411+14121412+14131413+class ProfileViewAddressTests(TestCase):
14141414+ """Test profile view address form submission."""
14151415+14161416+ def setUp(self):
14171417+ self.user = User.objects.create_user(
14181418+ username='testuser',
14191419+ password='password123',
14201420+ email='testuser@example.com',
14211421+ )
14221422+ self.client.login(username='testuser', password='password123')
14231423+14241424+ def test_profile_view_saves_address(self):
14251425+ """Test that profile view saves address correctly."""
14261426+ response = self.client.post(
14271427+ reverse('profile'),
14281428+ {
14291429+ 'address_form_submit': '1',
14301430+ 'street_address': 'Unter den Linden 77',
14311431+ 'postal_code': '10117',
14321432+ 'city': 'Berlin',
14331433+ },
14341434+ follow=True
14351435+ )
14361436+14371437+ self.assertEqual(response.status_code, 200)
14381438+ self.assertRedirects(response, reverse('profile'))
14391439+14401440+ # Verify address was saved
14411441+ verification = IdentityVerification.objects.get(user=self.user)
14421442+ self.assertEqual(verification.street_address, 'Unter den Linden 77')
14431443+ self.assertEqual(verification.postal_code, '10117')
14441444+ self.assertEqual(verification.city, 'Berlin')
14451445+ self.assertEqual(verification.status, 'SELF_DECLARED')
14461446+ self.assertEqual(verification.verification_type, 'SELF_DECLARED')
14471447+14481448+ def test_profile_view_updates_existing_address(self):
14491449+ """Test that profile view updates existing address."""
14501450+ # Create initial verification
14511451+ verification = IdentityVerification.objects.create(
14521452+ user=self.user,
14531453+ status='SELF_DECLARED',
14541454+ street_address='Old Street 1',
14551455+ postal_code='12345',
14561456+ city='OldCity',
14571457+ )
14581458+14591459+ response = self.client.post(
14601460+ reverse('profile'),
14611461+ {
14621462+ 'address_form_submit': '1',
14631463+ 'street_address': 'Unter den Linden 77',
14641464+ 'postal_code': '10117',
14651465+ 'city': 'Berlin',
14661466+ },
14671467+ follow=True
14681468+ )
14691469+14701470+ self.assertEqual(response.status_code, 200)
14711471+14721472+ # Verify address was updated
14731473+ verification.refresh_from_db()
14741474+ self.assertEqual(verification.street_address, 'Unter den Linden 77')
14751475+ self.assertEqual(verification.postal_code, '10117')
14761476+ self.assertEqual(verification.city, 'Berlin')
14771477+14781478+ def test_profile_view_displays_saved_address(self):
14791479+ """Test that profile view displays saved address."""
14801480+ # Create verification with address
14811481+ verification = IdentityVerification.objects.create(
14821482+ user=self.user,
14831483+ status='SELF_DECLARED',
14841484+ street_address='Unter den Linden 77',
14851485+ postal_code='10117',
14861486+ city='Berlin',
14871487+ )
14881488+14891489+ response = self.client.get(reverse('profile'))
14901490+14911491+ self.assertEqual(response.status_code, 200)
14921492+ self.assertContains(response, 'Unter den Linden 77')
14931493+ self.assertContains(response, '10117')
14941494+ self.assertContains(response, 'Berlin')
+52-13
website/letters/views.py
···2929 ReportForm,
3030 LetterSearchForm,
3131 UserRegisterForm,
3232- SelfDeclaredConstituencyForm
3232+ SelfDeclaredConstituencyForm,
3333+ IdentityVerificationForm
3334)
3435from .services import IdentityVerificationService, ConstituencySuggestionService
3536···314315 verification = None
315316316317 if request.method == 'POST':
317317- constituency_form = SelfDeclaredConstituencyForm(request.POST, user=user)
318318- if constituency_form.is_valid():
319319- IdentityVerificationService.self_declare(
320320- user=user,
321321- federal_constituency=constituency_form.cleaned_data['federal_constituency'],
322322- state_constituency=constituency_form.cleaned_data['state_constituency'],
323323- )
324324- messages.success(
325325- request,
326326- _('Your constituency information has been updated.')
327327- )
328328- return redirect('profile')
318318+ # Check which form was submitted
319319+ if 'address_form_submit' in request.POST:
320320+ address_form = IdentityVerificationForm(request.POST, user=user)
321321+ constituency_form = SelfDeclaredConstituencyForm(user=user)
322322+323323+ if address_form.is_valid():
324324+ street_address = address_form.cleaned_data.get('street_address')
325325+ postal_code = address_form.cleaned_data.get('postal_code')
326326+ city = address_form.cleaned_data.get('city')
327327+328328+ # Only update if all fields are provided
329329+ if street_address and postal_code and city:
330330+ # Get or create verification record
331331+ verification, created = IdentityVerification.objects.get_or_create(
332332+ user=user,
333333+ defaults={
334334+ 'status': 'SELF_DECLARED',
335335+ 'verification_type': 'SELF_DECLARED',
336336+ }
337337+ )
338338+339339+ # Update address fields
340340+ verification.street_address = street_address
341341+ verification.postal_code = postal_code
342342+ verification.city = city
343343+ verification.save()
344344+345345+ messages.success(
346346+ request,
347347+ _('Ihre Adresse wurde gespeichert.')
348348+ )
349349+ return redirect('profile')
350350+ else:
351351+ # Constituency form submission
352352+ constituency_form = SelfDeclaredConstituencyForm(request.POST, user=user)
353353+ address_form = IdentityVerificationForm(user=user)
354354+355355+ if constituency_form.is_valid():
356356+ IdentityVerificationService.self_declare(
357357+ user=user,
358358+ federal_constituency=constituency_form.cleaned_data['federal_constituency'],
359359+ state_constituency=constituency_form.cleaned_data['state_constituency'],
360360+ )
361361+ messages.success(
362362+ request,
363363+ _('Your constituency information has been updated.')
364364+ )
365365+ return redirect('profile')
329366 else:
330367 constituency_form = SelfDeclaredConstituencyForm(user=user)
368368+ address_form = IdentityVerificationForm(user=user)
331369332370 context = {
333371 'user_letters': user_letters,
334372 'user_signatures': user_signatures,
335373 'verification': verification,
336374 'constituency_form': constituency_form,
375375+ 'address_form': address_form,
337376 }
338377339378 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
···66msgstr ""
77"Project-Id-Version: WriteThem.eu\n"
88"Report-Msgid-Bugs-To: \n"
99-"POT-Creation-Date: 2025-10-04 22:57+0200\n"
99+"POT-Creation-Date: 2025-10-15 00:27+0200\n"
1010"PO-Revision-Date: 2025-01-10 12:00+0100\n"
1111"Last-Translator: \n"
1212"Language-Team: German\n"
···1616"Content-Transfer-Encoding: 8bit\n"
1717"Plural-Forms: nplurals=2; plural=(n != 1);\n"
18181919-# Forms
2020-#: letters/forms.py:28
2121-msgid "Postal code (PLZ)"
2222-msgstr "Postleitzahl (PLZ)"
1919+#: letters/admin.py:75
2020+msgid "Mandate Details"
2121+msgstr "Mandatsdetails"
23222424-#: letters/forms.py:29
2525-#, fuzzy
2626-#| msgid "Use your PLZ to narrow down representatives from your constituency."
2727-msgid "Use your PLZ to narrow down representatives from your parliament."
2828-msgstr ""
2929-"Verwenden Sie Ihre PLZ, um Abgeordnete aus Ihrem Wahlkreis einzugrenzen."
2323+#: letters/admin.py:78
2424+msgid "Focus Areas"
2525+msgstr "Schwerpunktbereiche"
30263131-#: letters/forms.py:30
3232-msgid "e.g. 10115"
3333-msgstr "z.B. 10115"
2727+#: letters/admin.py:81 letters/admin.py:95
2828+msgid "Photo"
2929+msgstr "Foto"
34303535-#: letters/forms.py:35
3636-msgid "Comma-separated tags (e.g., \"climate, transport, education\")"
3737-msgstr "Komma-getrennte Schlagwรถrter (z.B. \"Klima, Verkehr, Bildung\")"
3131+#: letters/admin.py:85
3232+msgid "Metadata"
3333+msgstr "Metadaten"
38343939-#: letters/forms.py:36
4040-msgid "climate, transport, education"
4141-msgstr "Klima, Verkehr, Bildung"
3535+#: letters/admin.py:94
3636+msgid "No photo"
3737+msgstr "Kein Foto"
3838+3939+#: letters/admin.py:167
4040+msgid "Topic Areas"
4141+msgstr "Themenbereiche"
4242+4343+#: letters/forms.py:25
4444+msgid ""
4545+"An account with this email already exists. If you registered before, please "
4646+"check your inbox for the activation link or reset your password."
4747+msgstr ""
4848+"Ein Konto mit dieser E-Mail-Adresse existiert bereits. Falls Sie sich zuvor "
4949+"registriert haben, prรผfen Sie Ihren Posteingang auf den Aktivierungslink oder "
5050+"setzen Sie Ihr Passwort zurรผck."
42514343-#: letters/forms.py:43
5252+#: letters/forms.py:37
4453msgid "Title"
4554msgstr "Titel"
46554747-#: letters/forms.py:44
5656+#: letters/forms.py:38
4857msgid "Letter Body"
4958msgstr "Brieftext"
50595151-#: letters/forms.py:45
5252-#, fuzzy
5353-#| msgid "To Representative:"
6060+#: letters/forms.py:39
5461msgid "To Representative"
5555-msgstr "An Abgeordnete:"
6262+msgstr "An Abgeordnete"
56635757-#: letters/forms.py:48
6464+#: letters/forms.py:42
5865msgid "Describe your concern briefly"
5966msgstr "Beschreiben Sie Ihr Anliegen kurz"
60676161-#: letters/forms.py:49
6262-msgid "Write your letter here"
6363-msgstr "Schreiben Sie hier Ihren Brief"
6868+#: letters/forms.py:43
6969+msgid ""
7070+"Write your letter here. Markdown formatting (e.g. **bold**, _italic_) is "
7171+"supported."
7272+msgstr ""
7373+"Schreiben Sie hier Ihren Brief. Markdown-Formatierung (z.B. **fett**, _kursiv_) "
7474+"wird unterstรผtzt."
64756565-#: letters/forms.py:52
7676+#: letters/forms.py:46
6677msgid "Letter title"
6778msgstr "Brieftitel"
68796969-#: letters/forms.py:56
8080+#: letters/forms.py:50
7081msgid "Write your letter here..."
7182msgstr "Schreiben Sie hier Ihren Brief..."
72837373-#: letters/forms.py:156
8484+#: letters/forms.py:110
7485msgid "Comment (optional)"
7586msgstr "Kommentar (optional)"
76877777-#: letters/forms.py:159
8888+#: letters/forms.py:113
7889msgid "Add a personal note to your signature"
7990msgstr "Fรผgen Sie Ihrer Unterschrift eine persรถnliche Notiz hinzu"
80918181-#: letters/forms.py:165
9292+#: letters/forms.py:119
8293msgid "Optional: Add your comment..."
8394msgstr "Optional: Fรผgen Sie Ihren Kommentar hinzu..."
84958585-#: letters/forms.py:177
9696+#: letters/forms.py:131
8697msgid "Reason"
8798msgstr "Grund"
88998989-#: letters/forms.py:178
100100+#: letters/forms.py:132
90101msgid "Description"
91102msgstr "Beschreibung"
921039393-#: letters/forms.py:181
104104+#: letters/forms.py:135
94105msgid "Please provide details about why you are reporting this letter"
95106msgstr "Bitte geben Sie Details an, warum Sie diesen Brief melden"
961079797-#: letters/forms.py:188
108108+#: letters/forms.py:142
98109msgid "Please describe the issue..."
99110msgstr "Bitte beschreiben Sie das Problem..."
100111101101-#: letters/forms.py:198 letters/templates/letters/letter_list.html:19
112112+#: letters/forms.py:152 letters/templates/letters/letter_list.html:19
102113msgid "Search"
103114msgstr "Suchen"
104115105105-#: letters/forms.py:201 letters/templates/letters/letter_list.html:18
116116+#: letters/forms.py:155 letters/templates/letters/letter_list.html:18
106117msgid "Search letters..."
107118msgstr "Briefe suchen..."
108119109109-#: letters/forms.py:207
120120+#: letters/forms.py:161
110121msgid "Tag"
111122msgstr "Schlagwort"
112123113113-#: letters/forms.py:210
124124+#: letters/forms.py:164
114125msgid "Filter by tag..."
115126msgstr "Nach Schlagwort filtern..."
116127128128+#: letters/forms.py:180
129129+msgid "Bundestag constituency"
130130+msgstr "Bundestags-Wahlkreis"
131131+132132+#: letters/forms.py:181
133133+msgid "Pick your Bundestag direct mandate constituency (Wahlkreis)."
134134+msgstr "Wรคhlen Sie Ihren Bundestags-Direktwahlkreis (Wahlkreis)."
135135+136136+#: letters/forms.py:182 letters/forms.py:189
137137+msgid "Select constituency"
138138+msgstr "Wahlkreis auswรคhlen"
139139+140140+#: letters/forms.py:187
141141+msgid "State parliament constituency"
142142+msgstr "Landtags-Wahlkreis"
143143+144144+#: letters/forms.py:188
145145+msgid "Optionally pick your Landtag constituency if applicable."
146146+msgstr "Wรคhlen Sie optional Ihren Landtags-Wahlkreis, falls zutreffend."
147147+148148+#: letters/forms.py:240
149149+msgid "Please select at least one constituency to save your profile."
150150+msgstr "Bitte wรคhlen Sie mindestens einen Wahlkreis aus, um Ihr Profil zu speichern."
151151+152152+#: letters/forms.py:252
153153+msgid "Straรe und Hausnummer"
154154+msgstr "Straรe und Hausnummer"
155155+156156+#: letters/forms.py:255
157157+msgid "z.B. Unter den Linden 77"
158158+msgstr "z.B. Unter den Linden 77"
159159+160160+#: letters/forms.py:261
161161+msgid "Postleitzahl"
162162+msgstr "Postleitzahl"
163163+164164+#: letters/forms.py:264
165165+msgid "z.B. 10117"
166166+msgstr "z.B. 10117"
167167+168168+#: letters/forms.py:270
169169+msgid "Stadt"
170170+msgstr "Stadt"
171171+172172+#: letters/forms.py:273
173173+msgid "z.B. Berlin"
174174+msgstr "z.B. Berlin"
175175+176176+#: letters/forms.py:304
177177+msgid ""
178178+"Bitte geben Sie eine vollstรคndige Adresse ein (Straรe, PLZ und Stadt) oder "
179179+"lassen Sie alle Felder leer."
180180+msgstr ""
181181+"Bitte geben Sie eine vollstรคndige Adresse ein (Straรe, PLZ und Stadt) oder "
182182+"lassen Sie alle Felder leer."
183183+117184# Model choices - Constituency
118118-#: letters/models.py:13
185185+#: letters/models.py:15
119186msgid "European Union"
120187msgstr "Europรคische Union"
121188122122-#: letters/models.py:14
189189+#: letters/models.py:16
123190msgid "Federal"
124191msgstr "Bund"
125192126126-#: letters/models.py:15
193193+#: letters/models.py:17
127194msgid "State"
128195msgstr "Land"
129196130130-#: letters/models.py:16
197197+#: letters/models.py:18
131198msgid "Local"
132199msgstr "Kommune"
133200134134-#: letters/models.py:19
201201+#: letters/models.py:21
135202msgid "Name of the parliament"
136136-msgstr ""
203203+msgstr "Name des Parlaments"
137204138138-#: letters/models.py:23
205205+#: letters/models.py:25
139206msgid "e.g., 'Bundestag', 'Bayerischer Landtag', 'Gemeinderat Mรผnchen'"
140140-msgstr ""
207207+msgstr "z.B. 'Bundestag', 'Bayerischer Landtag', 'Gemeinderat Mรผnchen'"
141208142142-#: letters/models.py:27
209209+#: letters/models.py:29
143210msgid "Geographic identifier (state code, municipality code, etc.)"
144144-msgstr ""
211211+msgstr "Geografische Kennung (Bundeslandkรผrzel, Gemeindeschlรผssel, etc.)"
145212146146-#: letters/models.py:35
213213+#: letters/models.py:37
147214msgid "For hierarchical relationships (e.g., local within state)"
148148-msgstr ""
215215+msgstr "Fรผr hierarchische Beziehungen (z.B. Kommune innerhalb eines Bundeslandes)"
216216+217217+#: letters/models.py:43 letters/models.py:70 letters/models.py:105
218218+#: letters/models.py:165 letters/models.py:372 letters/models.py:421
219219+msgid "Last time this was synced from external API"
220220+msgstr "Zeitpunkt der letzten Synchronisierung von externer API"
149221150150-#: letters/models.py:44
222222+#: letters/models.py:47 letters/templates/letters/committee_detail.html:70
151223msgid "Parliament"
152152-msgstr ""
224224+msgstr "Parlament"
153225154154-#: letters/models.py:45
226226+#: letters/models.py:48
155227msgid "Parliaments"
156156-msgstr ""
157157-158158-#: letters/models.py:80
159159-#, fuzzy
160160-#| msgid "Federal"
161161-msgid "Federal district"
162162-msgstr "Bund"
163163-164164-#: letters/models.py:81
165165-msgid "State district"
166166-msgstr ""
167167-168168-#: letters/models.py:82
169169-msgid "Regional district"
170170-msgstr ""
228228+msgstr "Parlamente"
171229172172-#: letters/models.py:114
230230+#: letters/models.py:84
173231msgid "Federal electoral district"
174174-msgstr ""
232232+msgstr "Bundeswahlkreis"
175233176176-#: letters/models.py:115
234234+#: letters/models.py:85
177235msgid "Bundestag state list"
178178-msgstr ""
236236+msgstr "Bundestag-Landesliste"
179237180180-#: letters/models.py:116
238238+#: letters/models.py:86
181239msgid "Bundestag federal list"
182182-msgstr ""
240240+msgstr "Bundestag-Bundesliste"
183241184184-#: letters/models.py:117
242242+#: letters/models.py:87
185243msgid "State electoral district"
186186-msgstr ""
244244+msgstr "Landeswahlkreis"
187245188188-#: letters/models.py:118
246246+#: letters/models.py:88
189247msgid "State regional list"
190190-msgstr ""
248248+msgstr "Regionale Landesliste"
191249192192-#: letters/models.py:119
250250+#: letters/models.py:89
193251msgid "State wide list"
194194-msgstr ""
252252+msgstr "Landesweite Liste"
195253196196-#: letters/models.py:120
254254+#: letters/models.py:90
197255msgid "EU at large"
198198-msgstr ""
256256+msgstr "EU insgesamt"
199257200200-#: letters/models.py:153
258258+#: letters/models.py:119
201259msgid "Direct mandate"
202202-msgstr ""
260260+msgstr "Direktmandat"
203261204204-#: letters/models.py:154
262262+#: letters/models.py:120
205263msgid "State list mandate"
206206-msgstr ""
264264+msgstr "Landeslisten-Mandat"
207265208208-#: letters/models.py:155
266266+#: letters/models.py:121
209267msgid "State regional list mandate"
210210-msgstr ""
268268+msgstr "Regionales Landeslisten-Mandat"
211269212212-#: letters/models.py:156
270270+#: letters/models.py:122
213271msgid "Federal list mandate"
214214-msgstr ""
272272+msgstr "Bundeslisten-Mandat"
215273216216-#: letters/models.py:157
274274+#: letters/models.py:123
217275msgid "EU list mandate"
218218-msgstr ""
276276+msgstr "EU-Listen-Mandat"
219277220278# Model choices - Letter
221221-#: letters/models.py:422
279279+#: letters/models.py:444
222280msgid "Draft"
223281msgstr "Entwurf"
224282225225-#: letters/models.py:423
283283+#: letters/models.py:445
226284msgid "Published"
227285msgstr "Verรถffentlicht"
228286229229-#: letters/models.py:424
287287+#: letters/models.py:446
230288msgid "Flagged for Review"
231289msgstr "Zur รberprรผfung markiert"
232290233233-#: letters/models.py:425
291291+#: letters/models.py:447
234292msgid "Removed"
235293msgstr "Entfernt"
236294237237-#: letters/services.py:1149
295295+#: letters/models.py:487
296296+msgid "Deleted user"
297297+msgstr "Gelรถschter Benutzer"
298298+299299+#: letters/services.py:2451
238300#, python-format
239301msgid "Detected policy area: %(topic)s."
240240-msgstr ""
302302+msgstr "Erkannter Politikbereich: %(topic)s."
241303242242-#: letters/services.py:1154
243243-#, fuzzy, python-format
244244-#| msgid "Use your PLZ to narrow down representatives from your constituency."
304304+#: letters/services.py:2456
305305+#, python-format
245306msgid "Prioritising representatives for %(constituencies)s."
246246-msgstr ""
247247-"Verwenden Sie Ihre PLZ, um Abgeordnete aus Ihrem Wahlkreis einzugrenzen."
307307+msgstr "Priorisiere Abgeordnete fรผr %(constituencies)s."
248308249249-#: letters/services.py:1159
309309+#: letters/services.py:2461
250310#, python-format
251311msgid "Filtering by state %(state)s."
252252-msgstr ""
312312+msgstr "Filtere nach Bundesland %(state)s."
253313254254-#: letters/services.py:1163
314314+#: letters/services.py:2465
255315#, python-format
256316msgid ""
257317"Postal code %(plz)s had no direct match; showing broader representatives."
258318msgstr ""
319319+"Postleitzahl %(plz)s hatte keine direkte รbereinstimmung; zeige allgemeinere Abgeordnete."
259320260260-#: letters/services.py:1167
261261-#, fuzzy
262262-#| msgid "No external resources available for this representative."
321321+#: letters/services.py:2469
263322msgid "Showing generally relevant representatives."
264264-msgstr "Keine externen Ressourcen fรผr diesen Abgeordneten verfรผgbar."
323323+msgstr "Zeige allgemein relevante Abgeordnete."
324324+325325+#: letters/templates/letters/account_activation_invalid.html:4
326326+#: letters/templates/letters/account_activation_invalid.html:8
327327+msgid "Activation link invalid"
328328+msgstr "Aktivierungslink ungรผltig"
329329+330330+#: letters/templates/letters/account_activation_invalid.html:10
331331+msgid ""
332332+"We could not verify your activation link. It may have already been used or "
333333+"expired."
334334+msgstr ""
335335+"Wir konnten Ihren Aktivierungslink nicht verifizieren. Er wurde mรถglicherweise bereits "
336336+"verwendet oder ist abgelaufen."
337337+338338+#: letters/templates/letters/account_activation_invalid.html:13
339339+msgid ""
340340+"If you still cannot access your account, try registering again or contact "
341341+"support."
342342+msgstr ""
343343+"Falls Sie weiterhin nicht auf Ihr Konto zugreifen kรถnnen, versuchen Sie sich erneut zu "
344344+"registrieren oder kontaktieren Sie den Support."
345345+346346+#: letters/templates/letters/account_activation_invalid.html:15
347347+msgid "Register again"
348348+msgstr "Erneut registrieren"
349349+350350+#: letters/templates/letters/account_activation_sent.html:4
351351+msgid "Activate your account"
352352+msgstr "Aktivieren Sie Ihr Konto"
353353+354354+#: letters/templates/letters/account_activation_sent.html:8
355355+#: letters/templates/letters/password_reset_done.html:8
356356+msgid "Check your inbox"
357357+msgstr "Prรผfen Sie Ihren Posteingang"
358358+359359+#: letters/templates/letters/account_activation_sent.html:10
360360+msgid ""
361361+"We sent you an email with a confirmation link. Please click it to activate "
362362+"your account."
363363+msgstr ""
364364+"Wir haben Ihnen eine E-Mail mit einem Bestรคtigungslink gesendet. Bitte klicken Sie "
365365+"darauf, um Ihr Konto zu aktivieren."
366366+367367+#: letters/templates/letters/account_activation_sent.html:13
368368+msgid ""
369369+"If you do not receive the email within a few minutes, check your spam folder "
370370+"or try registering again."
371371+msgstr ""
372372+"Falls Sie die E-Mail nicht innerhalb weniger Minuten erhalten, prรผfen Sie Ihren "
373373+"Spam-Ordner oder versuchen Sie sich erneut zu registrieren."
374374+375375+#: letters/templates/letters/account_activation_sent.html:15
376376+msgid "Back to homepage"
377377+msgstr "Zurรผck zur Startseite"
378378+379379+#: letters/templates/letters/account_delete_confirm.html:4
380380+#: letters/templates/letters/profile.html:142
381381+msgid "Delete account"
382382+msgstr "Konto lรถschen"
383383+384384+#: letters/templates/letters/account_delete_confirm.html:8
385385+msgid "Delete your account"
386386+msgstr "Lรถschen Sie Ihr Konto"
387387+388388+#: letters/templates/letters/account_delete_confirm.html:10
389389+msgid ""
390390+"Deleting your account will remove your personal data and signatures. Letters "
391391+"you have published stay online but are shown without your name."
392392+msgstr ""
393393+"Das Lรถschen Ihres Kontos entfernt Ihre persรถnlichen Daten und Unterschriften. Von Ihnen "
394394+"verรถffentlichte Briefe bleiben online, werden aber ohne Ihren Namen angezeigt."
395395+396396+#: letters/templates/letters/account_delete_confirm.html:14
397397+msgid "Yes, delete my account"
398398+msgstr "Ja, mein Konto lรถschen"
399399+400400+#: letters/templates/letters/account_delete_confirm.html:15
401401+#: letters/templates/letters/letter_form.html:61
402402+msgid "Cancel"
403403+msgstr "Abbrechen"
265404266405# Navigation
267267-#: letters/templates/letters/base.html:124
406406+#: letters/templates/letters/base.html:140
268407msgid "Letters"
269408msgstr "Briefe"
270409271271-#: letters/templates/letters/base.html:126
272272-#: letters/templates/letters/representative_detail.html:196
410410+#: letters/templates/letters/base.html:141
411411+msgid "Competencies"
412412+msgstr "Kompetenzen"
413413+414414+#: letters/templates/letters/base.html:143
415415+#: letters/templates/letters/representative_detail.html:149
273416msgid "Write Letter"
274417msgstr "Brief schreiben"
275418276276-#: letters/templates/letters/base.html:127
419419+#: letters/templates/letters/base.html:144
277420#: letters/templates/letters/profile.html:4
278421msgid "Profile"
279422msgstr "Profil"
280423281281-#: letters/templates/letters/base.html:128
424424+#: letters/templates/letters/base.html:145
282425msgid "Logout"
283426msgstr "Abmelden"
284427285285-#: letters/templates/letters/base.html:130
286286-#: letters/templates/letters/letter_detail.html:46
287287-#: letters/templates/letters/letter_detail.html:80
428428+#: letters/templates/letters/base.html:147
429429+#: letters/templates/letters/letter_detail.html:47
430430+#: letters/templates/letters/letter_detail.html:81
288431#: letters/templates/letters/login.html:4
289432#: letters/templates/letters/login.html:8
290433#: letters/templates/letters/login.html:33
291434msgid "Login"
292435msgstr "Anmelden"
293436294294-#: letters/templates/letters/base.html:131
437437+#: letters/templates/letters/base.html:148
295438#: letters/templates/letters/register.html:4
296439#: letters/templates/letters/register.html:8
297297-#: letters/templates/letters/register.html:65
440440+#: letters/templates/letters/register.html:66
298441msgid "Register"
299442msgstr "Registrieren"
300443301301-#: letters/templates/letters/base.html:134
444444+#: letters/templates/letters/base.html:151
302445msgid "Admin"
303446msgstr "Admin"
304447448448+#: letters/templates/letters/base.html:157
449449+msgid "Select language"
450450+msgstr "Sprache auswรคhlen"
451451+305452# Footer
306306-#: letters/templates/letters/base.html:154
453453+#: letters/templates/letters/base.html:182
307454msgid "Empowering citizens to write to their representatives"
308455msgstr "Bรผrgern ermรถglichen, an ihre Abgeordneten zu schreiben"
309456457457+#: letters/templates/letters/committee_detail.html:22
458458+msgid "Related Topics"
459459+msgstr "Verwandte Themen"
460460+461461+#: letters/templates/letters/committee_detail.html:36
462462+msgid "Members"
463463+msgstr "Mitglieder"
464464+465465+#: letters/templates/letters/committee_detail.html:46
466466+msgid "Role"
467467+msgstr "Rolle"
468468+469469+#: letters/templates/letters/committee_detail.html:48
470470+#: letters/templates/letters/representative_detail.html:61
471471+msgid "Active"
472472+msgstr "Aktiv"
473473+474474+#: letters/templates/letters/committee_detail.html:51
475475+#: letters/templates/letters/partials/representative_card.html:29
476476+msgid "Since"
477477+msgstr "Seit"
478478+479479+#: letters/templates/letters/committee_detail.html:58
480480+msgid "No members recorded for this committee."
481481+msgstr "Fรผr diesen Ausschuss sind keine Mitglieder verzeichnet."
482482+483483+#: letters/templates/letters/committee_detail.html:67
484484+msgid "Committee Info"
485485+msgstr "Ausschussinformationen"
486486+487487+#: letters/templates/letters/committee_detail.html:71
488488+msgid "Term"
489489+msgstr "Amtszeit"
490490+491491+#: letters/templates/letters/committee_detail.html:74
492492+#: letters/templates/letters/representative_detail.html:105
493493+msgid "View on Abgeordnetenwatch"
494494+msgstr "Auf Abgeordnetenwatch ansehen"
495495+310496#: letters/templates/letters/letter_detail.html:10
311497#: letters/templates/letters/partials/letter_card.html:5
312498msgid "By"
313499msgstr "Von"
314500315315-#: letters/templates/letters/letter_detail.html:11
316316-#: letters/templates/letters/partials/letter_card.html:6
317317-#: letters/templates/letters/profile.html:68
318318-msgid "To"
319319-msgstr "An"
320320-321321-#: letters/templates/letters/letter_detail.html:28
501501+#: letters/templates/letters/letter_detail.html:29
322502#, python-format
323503msgid "Signatures (%(counter)s)"
324504msgid_plural "Signatures (%(counter)s)"
325505msgstr[0] "Unterschrift (%(counter)s)"
326506msgstr[1] "Unterschriften (%(counter)s)"
327507328328-#: letters/templates/letters/letter_detail.html:30
329329-#, fuzzy, python-format
330330-#| msgid "<strong>%(counter)s</strong> constituent of %(constituency)s"
331331-#| msgid_plural "<strong>%(counter)s</strong> constituents of %(constituency)s"
508508+#: letters/templates/letters/letter_detail.html:31
509509+#, python-format
332510msgid "%(counter)s constituent of %(constituency_name)s"
333511msgid_plural "%(counter)s constituents of %(constituency_name)s"
334512msgstr[0] "%(counter)s Wรคhler aus %(constituency_name)s"
335513msgstr[1] "%(counter)s Wรคhler aus %(constituency_name)s"
336514337337-#: letters/templates/letters/letter_detail.html:31
338338-#, fuzzy, python-format
339339-#| msgid "%(counter)s verified"
515515+#: letters/templates/letters/letter_detail.html:32
516516+#, python-format
340517msgid "%(counter)s other verified"
341518msgid_plural "%(counter)s other verified"
342342-msgstr[0] "%(counter)s verifiziert"
343343-msgstr[1] "%(counter)s verifiziert"
519519+msgstr[0] "%(counter)s weitere verifiziert"
520520+msgstr[1] "%(counter)s weitere verifiziert"
344521345345-#: letters/templates/letters/letter_detail.html:32
522522+#: letters/templates/letters/letter_detail.html:33
346523#, python-format
347524msgid "%(counter)s unverified"
348525msgid_plural "%(counter)s unverified"
349526msgstr[0] "%(counter)s nicht verifiziert"
350527msgstr[1] "%(counter)s nicht verifiziert"
351528352352-#: letters/templates/letters/letter_detail.html:40
529529+#: letters/templates/letters/letter_detail.html:41
353530msgid "Sign this letter"
354531msgstr "Brief unterzeichnen"
355532356356-#: letters/templates/letters/letter_detail.html:43
533533+#: letters/templates/letters/letter_detail.html:44
357534msgid "You have signed this letter"
358535msgstr "Sie haben diesen Brief unterzeichnet"
359536360360-#: letters/templates/letters/letter_detail.html:46
361361-#, fuzzy
362362-#| msgid "Login to sign this letter"
537537+#: letters/templates/letters/letter_detail.html:47
363538msgid "to sign this letter"
364364-msgstr "Anmelden um zu unterzeichnen"
539539+msgstr "um diesen Brief zu unterzeichnen"
365540366366-#: letters/templates/letters/letter_detail.html:69
541541+#: letters/templates/letters/letter_detail.html:58
542542+msgid "โ Verified Constituent"
543543+msgstr "โ Verifizierter Wรคhler"
544544+545545+#: letters/templates/letters/letter_detail.html:60
546546+msgid "โ Verified"
547547+msgstr "โ Verifiziert"
548548+549549+#: letters/templates/letters/letter_detail.html:70
367550msgid "No signatures yet. Be the first to sign!"
368551msgstr "Noch keine Unterschriften. Seien Sie der Erste!"
369552370370-#: letters/templates/letters/letter_detail.html:74
371371-#: letters/templates/letters/letter_detail.html:78
553553+#: letters/templates/letters/letter_detail.html:75
554554+#: letters/templates/letters/letter_detail.html:79
372555msgid "Report this letter"
373556msgstr "Brief melden"
374557375375-#: letters/templates/letters/letter_detail.html:75
558558+#: letters/templates/letters/letter_detail.html:76
376559msgid "If you believe this letter violates our guidelines, please report it."
377377-msgstr ""
560560+msgstr "Falls Sie glauben, dass dieser Brief gegen unsere Richtlinien verstรถรt, melden Sie ihn bitte."
378561379379-#: letters/templates/letters/letter_detail.html:80
380380-#, fuzzy
381381-#| msgid "Report this letter"
562562+#: letters/templates/letters/letter_detail.html:81
382563msgid "to report this letter"
383383-msgstr "Brief melden"
564564+msgstr "um diesen Brief zu melden"
384565385385-#: letters/templates/letters/letter_detail.html:85
566566+#: letters/templates/letters/letter_detail.html:86
386567msgid "Back to all letters"
387568msgstr "Zurรผck zu allen Briefen"
388569389570#: letters/templates/letters/letter_form.html:4
390390-#: letters/templates/letters/representative_detail.html:190
571571+#: letters/templates/letters/representative_detail.html:143
391572msgid "Write a Letter"
392573msgstr "Brief schreiben"
393574394575# Letter form
395395-#: letters/templates/letters/letter_form.html:11
576576+#: letters/templates/letters/letter_form.html:10
396577msgid "Write an Open Letter"
397578msgstr "Einen offenen Brief schreiben"
398579399399-#: letters/templates/letters/letter_form.html:13
580580+#: letters/templates/letters/letter_form.html:12
400581msgid ""
401401-"Write an open letter to a German political representative. Your letter will "
402402-"be published publicly and others can sign it."
582582+"Write an open letter to a political representative. Your letter will be "
583583+"published publicly so others can read and sign it."
403584msgstr ""
404404-"Schreiben Sie einen offenen Brief an einen deutschen Abgeordneten. Ihr Brief "
405405-"wird รถffentlich verรถffentlicht und andere kรถnnen ihn unterzeichnen."
585585+"Schreiben Sie einen offenen Brief an einen Abgeordneten. Ihr Brief wird "
586586+"รถffentlich verรถffentlicht, damit andere ihn lesen und unterzeichnen kรถnnen."
587587+588588+#: letters/templates/letters/letter_form.html:16
589589+msgid "Before you write"
590590+msgstr "Bevor Sie schreiben"
591591+592592+#: letters/templates/letters/letter_form.html:18
593593+msgid "Be thoughtful but feel free to be critical."
594594+msgstr "Seien Sie nachdenklich, aber scheuen Sie sich nicht, kritisch zu sein."
595595+596596+#: letters/templates/letters/letter_form.html:19
597597+msgid "Representatives are humans tooโstay respectful."
598598+msgstr "Abgeordnete sind auch Menschen โ bleiben Sie respektvoll."
406599407600#: letters/templates/letters/letter_form.html:20
601601+msgid "Keep your arguments clear and concise."
602602+msgstr "Halten Sie Ihre Argumente klar und prรคgnant."
603603+604604+#: letters/templates/letters/letter_form.html:21
605605+msgid "No insults or hate speech."
606606+msgstr "Keine Beleidigungen oder Hassrede."
607607+608608+#: letters/templates/letters/letter_form.html:22
609609+msgid "Stay within the bounds of the Grundgesetz when making demands."
610610+msgstr "Bleiben Sie bei Forderungen im Rahmen des Grundgesetzes."
611611+612612+#: letters/templates/letters/letter_form.html:30
408613msgid "Title:"
409614msgstr "Titel:"
410615411411-#: letters/templates/letters/letter_form.html:26
616616+#: letters/templates/letters/letter_form.html:36
412617msgid ""
413413-"Describe your concern. We'll suggest the right representatives based on your "
414414-"title."
415415-msgstr ""
416416-"Beschreiben Sie Ihr Anliegen. Wir schlagen Ihnen passende Abgeordnete "
417417-"basierend auf Ihrem Titel vor."
418418-419419-#: letters/templates/letters/letter_form.html:31
420420-msgid "Your postal code (PLZ):"
421421-msgstr "Ihre Postleitzahl (PLZ):"
422422-423423-#: letters/templates/letters/letter_form.html:37
424424-msgid "We'll use this PLZ to highlight representatives from your constituency."
618618+"Describe your concern in a sentence; we'll use it to suggest representatives."
425619msgstr ""
426426-"Wir verwenden diese PLZ, um Abgeordnete aus Ihrem Wahlkreis hervorzuheben."
620620+"Beschreiben Sie Ihr Anliegen in einem Satz; wir verwenden ihn, um Ihnen Abgeordnete "
621621+"vorzuschlagen."
427622428428-#: letters/templates/letters/letter_form.html:45
623623+#: letters/templates/letters/letter_form.html:41
429624msgid "To Representative:"
430625msgstr "An Abgeordnete:"
431626432432-#: letters/templates/letters/letter_form.html:51
433433-msgid "Or select from suggestions on the right โ"
434434-msgstr "Oder wรคhlen Sie aus den Vorschlรคgen rechts โ"
627627+#: letters/templates/letters/letter_form.html:47
628628+msgid ""
629629+"Already know who to address? Pick them here. Otherwise, use the suggestions "
630630+"below."
631631+msgstr ""
632632+"Wissen Sie bereits, wen Sie ansprechen mรถchten? Wรคhlen Sie hier aus. Andernfalls nutzen "
633633+"Sie die Vorschlรคge unten."
435634436436-#: letters/templates/letters/letter_form.html:56
635635+#: letters/templates/letters/letter_form.html:53
437636msgid "Letter Body:"
438637msgstr "Brieftext:"
439638440440-#: letters/templates/letters/letter_form.html:64
441441-msgid "Tags (optional):"
442442-msgstr "Schlagwรถrter (optional):"
443443-444444-#: letters/templates/letters/letter_form.html:71
639639+#: letters/templates/letters/letter_form.html:60
445640msgid "Publish Letter"
446641msgstr "Brief verรถffentlichen"
447642448448-#: letters/templates/letters/letter_form.html:72
449449-msgid "Cancel"
450450-msgstr "Abbrechen"
451451-452452-#: letters/templates/letters/letter_form.html:81
643643+#: letters/templates/letters/letter_form.html:67
453644msgid "Smart Suggestions"
454645msgstr "Intelligente Vorschlรคge"
455646456456-#: letters/templates/letters/letter_form.html:83
457457-#, fuzzy
458458-#| msgid ""
459459-#| "Type your letter title and we'll help you find the right representative "
460460-#| "and show similar letters."
647647+#: letters/templates/letters/letter_form.html:69
461648msgid ""
462462-"Add a title and your PLZ to see tailored representatives, tags, and similar "
463463-"letters."
649649+"Type your title and we'll use your verified profile to suggest "
650650+"representatives, topics, and related letters."
464651msgstr ""
465465-"Geben Sie Ihren Brieftitel ein und wir helfen Ihnen, den richtigen "
466466-"Abgeordneten zu finden und zeigen รคhnliche Briefe."
652652+"Geben Sie Ihren Titel ein und wir verwenden Ihr verifiziertes Profil, um Ihnen "
653653+"Abgeordnete, Themen und verwandte Briefe vorzuschlagen."
467654468468-#: letters/templates/letters/letter_form.html:98
655655+#: letters/templates/letters/letter_form.html:81
469656msgid "Loading..."
470657msgstr "Lรคdt..."
471658472472-#: letters/templates/letters/letter_form.html:100
659659+#: letters/templates/letters/letter_form.html:83
473660msgid "Analyzing your title..."
474661msgstr "Analysiere Ihren Titel..."
475662···510697msgid "Popular tags:"
511698msgstr "Beliebte Schlagwรถrter:"
512699700700+#: letters/templates/letters/letter_list.html:41
701701+msgid "Previous"
702702+msgstr "Zurรผck"
703703+704704+#: letters/templates/letters/letter_list.html:43
705705+#, python-format
706706+msgid "Page %(page)s of %(total)s"
707707+msgstr "Seite %(page)s von %(total)s"
708708+709709+#: letters/templates/letters/letter_list.html:45
710710+msgid "Next"
711711+msgstr "Weiter"
712712+713713+#: letters/templates/letters/letter_list.html:51
714714+msgid "No letters found."
715715+msgstr "Keine Briefe gefunden."
716716+717717+#: letters/templates/letters/letter_list.html:51
718718+msgid "Be the first to write one!"
719719+msgstr "Schreiben Sie den ersten!"
720720+513721# Login page
514722#: letters/templates/letters/login.html:14
515515-#: letters/templates/letters/register.html:14
723723+#: letters/templates/letters/register.html:15
516724msgid "Username:"
517725msgstr "Benutzername:"
518726519727#: letters/templates/letters/login.html:22
520520-#: letters/templates/letters/register.html:46
728728+#: letters/templates/letters/register.html:47
521729msgid "Password:"
522730msgstr "Passwort:"
523731524732#: letters/templates/letters/login.html:37
733733+msgid "Forgot your password?"
734734+msgstr "Passwort vergessen?"
735735+736736+#: letters/templates/letters/login.html:41
525737msgid "Don't have an account?"
526738msgstr "Noch kein Konto?"
527739528528-#: letters/templates/letters/login.html:37
740740+#: letters/templates/letters/login.html:41
529741msgid "Register here"
530742msgstr "Hier registrieren"
531743744744+#: letters/templates/letters/partials/letter_card.html:6
745745+msgid "To"
746746+msgstr "An"
747747+532748# Plurals for signatures
533749#: letters/templates/letters/partials/letter_card.html:20
534534-#: letters/templates/letters/partials/suggestions.html:126
535750#, python-format
536751msgid "%(counter)s signature"
537752msgid_plural "%(counter)s signatures"
···539754msgstr[1] "%(counter)s Unterschriften"
540755541756#: letters/templates/letters/partials/letter_card.html:20
542542-#, fuzzy, python-format
543543-#| msgid "%(counter)s verified"
757757+#, python-format
544758msgid "%(counter)s verified"
545759msgid_plural "%(counter)s verified"
546760msgstr[0] "%(counter)s verifiziert"
547761msgstr[1] "%(counter)s verifiziert"
548762763763+#: letters/templates/letters/partials/representative_card.html:21
764764+#: letters/templates/letters/partials/representative_card.html:23
765765+msgid "Constituency"
766766+msgstr "Wahlkreis"
767767+768768+#: letters/templates/letters/partials/representative_card.html:27
769769+msgid "Mandate"
770770+msgstr "Mandat"
771771+772772+#: letters/templates/letters/partials/representative_card.html:34
773773+msgid "Focus"
774774+msgstr "Schwerpunkt"
775775+776776+#: letters/templates/letters/partials/representative_card.html:34
777777+msgid "self-declared"
778778+msgstr "selbst erklรคrt"
779779+780780+#: letters/templates/letters/partials/representative_card.html:45
781781+msgid "Committees"
782782+msgstr "Ausschรผsse"
783783+784784+#: letters/templates/letters/partials/representative_card.html:57
785785+msgid "Email"
786786+msgstr "E-Mail"
787787+788788+#: letters/templates/letters/partials/representative_card.html:60
789789+msgid "Website"
790790+msgstr "Webseite"
791791+792792+#: letters/templates/letters/partials/representative_card.html:66
793793+msgid "View profile"
794794+msgstr "Profil ansehen"
795795+796796+#: letters/templates/letters/partials/suggestions.html:10
797797+msgid ""
798798+"We couldn't match you to a constituency yet. Update your profile "
799799+"verification to see local representatives."
800800+msgstr ""
801801+"Wir konnten Sie noch keinem Wahlkreis zuordnen. Aktualisieren Sie Ihre Profilverifizierung, "
802802+"um lokale Abgeordnete zu sehen."
803803+549804# Suggestions partial
550550-#: letters/templates/letters/partials/suggestions.html:11
805805+#: letters/templates/letters/partials/suggestions.html:16
551806msgid "Our Interpretation"
552807msgstr "Unsere Interpretation"
553808554554-#: letters/templates/letters/partials/suggestions.html:16
809809+#: letters/templates/letters/partials/suggestions.html:21
555810msgid "Topic:"
556811msgstr "Thema:"
557812558558-#: letters/templates/letters/partials/suggestions.html:23
813813+#: letters/templates/letters/partials/suggestions.html:28
559814msgid "No specific policy area detected. Try adding more keywords."
560815msgstr ""
561816"Kein spezifischer Politikbereich erkannt. Versuchen Sie, mehr "
562817"Schlรผsselwรถrter hinzuzufรผgen."
563818564564-#: letters/templates/letters/partials/suggestions.html:31
565565-msgid "Suggested Representatives"
566566-msgstr "Vorgeschlagene Abgeordnete"
819819+#: letters/templates/letters/partials/suggestions.html:37
820820+msgid "Related Keywords"
821821+msgstr "Verwandte Schlagwรถrter"
822822+823823+#: letters/templates/letters/partials/suggestions.html:51
824824+msgid "Your Direct Representatives"
825825+msgstr "Ihre direkten Abgeordneten"
826826+827827+#: letters/templates/letters/partials/suggestions.html:56
828828+msgid "These representatives directly represent your constituency:"
829829+msgstr "Diese Abgeordneten vertreten Ihren Wahlkreis direkt:"
567830568568-#: letters/templates/letters/partials/suggestions.html:37
831831+#: letters/templates/letters/partials/suggestions.html:67
832832+#: letters/templates/letters/partials/suggestions.html:102
833833+msgid "Select"
834834+msgstr "Auswรคhlen"
835835+836836+#: letters/templates/letters/partials/suggestions.html:81
837837+msgid "Topic Experts"
838838+msgstr "Themenexperten"
839839+840840+#: letters/templates/letters/partials/suggestions.html:86
569841#, python-format
570842msgid ""
571571-"Based on the topic \"%(topic)s\", we suggest contacting representatives from "
572572-"the %(level)s:"
843843+"These representatives are experts on \"%(topic)s\" based on their committee "
844844+"memberships:"
573845msgstr ""
574574-"Basierend auf dem Thema \"%(topic)s\" empfehlen wir, Abgeordnete des "
575575-"%(level)s zu kontaktieren:"
846846+"Diese Abgeordneten sind Experten fรผr \"%(topic)s\" basierend auf ihren "
847847+"Ausschussmitgliedschaften:"
576848577577-#: letters/templates/letters/partials/suggestions.html:51
578578-msgid "Constituency:"
579579-msgstr "Wahlkreis:"
580580-581581-#: letters/templates/letters/partials/suggestions.html:57
849849+#: letters/templates/letters/partials/suggestions.html:95
582850msgid "of"
583851msgstr "von"
584852585585-#: letters/templates/letters/partials/suggestions.html:61
586586-msgid "View profile"
587587-msgstr "Profil ansehen"
853853+#: letters/templates/letters/partials/suggestions.html:116
854854+msgid "Suggested Representatives"
855855+msgstr "Vorgeschlagene Abgeordnete"
588856589589-#: letters/templates/letters/partials/suggestions.html:66
590590-msgid "Select"
591591-msgstr "Auswรคhlen"
592592-593593-#: letters/templates/letters/partials/suggestions.html:73
857857+#: letters/templates/letters/partials/suggestions.html:119
594858msgid ""
595859"No representatives found. Representatives may need to be synced for this "
596860"governmental level."
···598862"Keine Abgeordneten gefunden. Abgeordnete mรผssen mรถglicherweise fรผr diese "
599863"Verwaltungsebene synchronisiert werden."
600864601601-#: letters/templates/letters/partials/suggestions.html:82
602602-#, fuzzy
603603-#| msgid "Suggested Representatives"
604604-msgid "Suggested Tags"
605605-msgstr "Vorgeschlagene Abgeordnete"
865865+#: letters/templates/letters/partials/suggestions.html:148
866866+msgid "Selected:"
867867+msgstr "Ausgewรคhlt:"
868868+869869+#: letters/templates/letters/password_reset_complete.html:4
870870+#: letters/templates/letters/password_reset_complete.html:8
871871+msgid "Password updated"
872872+msgstr "Passwort aktualisiert"
873873+874874+#: letters/templates/letters/password_reset_complete.html:9
875875+msgid "You can now sign in using your new password."
876876+msgstr "Sie kรถnnen sich jetzt mit Ihrem neuen Passwort anmelden."
877877+878878+#: letters/templates/letters/password_reset_complete.html:10
879879+msgid "Go to login"
880880+msgstr "Zur Anmeldung"
881881+882882+#: letters/templates/letters/password_reset_confirm.html:4
883883+#: letters/templates/letters/password_reset_confirm.html:9
884884+msgid "Choose a new password"
885885+msgstr "Wรคhlen Sie ein neues Passwort"
886886+887887+#: letters/templates/letters/password_reset_confirm.html:13
888888+msgid "New password"
889889+msgstr "Neues Passwort"
890890+891891+#: letters/templates/letters/password_reset_confirm.html:20
892892+msgid "Confirm password"
893893+msgstr "Passwort bestรคtigen"
894894+895895+#: letters/templates/letters/password_reset_confirm.html:26
896896+msgid "Update password"
897897+msgstr "Passwort aktualisieren"
606898607607-#: letters/templates/letters/partials/suggestions.html:85
608608-msgid "Click a tag to add it to your letter."
899899+#: letters/templates/letters/password_reset_confirm.html:29
900900+msgid "Reset link invalid"
901901+msgstr "Zurรผcksetzungslink ungรผltig"
902902+903903+#: letters/templates/letters/password_reset_confirm.html:30
904904+msgid "This password reset link is no longer valid. Please request a new one."
905905+msgstr "Dieser Passwort-Zurรผcksetzungslink ist nicht mehr gรผltig. Bitte fordern Sie einen neuen an."
906906+907907+#: letters/templates/letters/password_reset_confirm.html:31
908908+msgid "Request new link"
909909+msgstr "Neuen Link anfordern"
910910+911911+#: letters/templates/letters/password_reset_done.html:4
912912+msgid "Reset email sent"
913913+msgstr "Zurรผcksetzungs-E-Mail gesendet"
914914+915915+#: letters/templates/letters/password_reset_done.html:9
916916+msgid ""
917917+"If an account exists for that email address, we just sent you instructions "
918918+"to choose a new password."
609919msgstr ""
920920+"Falls ein Konto fรผr diese E-Mail-Adresse existiert, haben wir Ihnen gerade Anweisungen "
921921+"zum Festlegen eines neuen Passworts gesendet."
610922611611-#: letters/templates/letters/partials/suggestions.html:101
612612-msgid "Related Keywords"
613613-msgstr "Verwandte Schlagwรถrter"
923923+#: letters/templates/letters/password_reset_done.html:10
924924+msgid "The link will stay valid for a limited time."
925925+msgstr "Der Link bleibt fรผr eine begrenzte Zeit gรผltig."
614926615615-#: letters/templates/letters/partials/suggestions.html:115
616616-msgid "Similar Letters"
617617-msgstr "รhnliche Briefe"
927927+#: letters/templates/letters/password_reset_done.html:11
928928+msgid "Back to login"
929929+msgstr "Zurรผck zur Anmeldung"
618930619619-#: letters/templates/letters/partials/suggestions.html:118
620620-msgid "Others have written about similar topics:"
621621-msgstr "Andere haben รผber รคhnliche Themen geschrieben:"
931931+#: letters/templates/letters/password_reset_form.html:4
932932+msgid "Reset password"
933933+msgstr "Passwort zurรผcksetzen"
622934623623-#: letters/templates/letters/partials/suggestions.html:129
624624-#, fuzzy
625625-#| msgid "To"
626626-msgid "To:"
627627-msgstr "An"
935935+#: letters/templates/letters/password_reset_form.html:8
936936+msgid "Reset your password"
937937+msgstr "Setzen Sie Ihr Passwort zurรผck"
628938629629-#: letters/templates/letters/partials/suggestions.html:131
630630-msgid "by"
939939+#: letters/templates/letters/password_reset_form.html:9
940940+msgid ""
941941+"Enter the email address you used during registration. We will send you a "
942942+"link to create a new password."
631943msgstr ""
944944+"Geben Sie die E-Mail-Adresse ein, die Sie bei der Registrierung verwendet haben. Wir "
945945+"senden Ihnen einen Link zum Erstellen eines neuen Passworts."
632946633633-#: letters/templates/letters/partials/suggestions.html:163
634634-msgid "Selected:"
635635-msgstr "Ausgewรคhlt:"
947947+#: letters/templates/letters/password_reset_form.html:17
948948+msgid "Send reset link"
949949+msgstr "Zurรผcksetzungslink senden"
636950637951# Profile page
638638-#: letters/templates/letters/profile.html:8
952952+#: letters/templates/letters/profile.html:13
639953#, python-format
640954msgid "%(username)s's Profile"
641955msgstr "Profil von %(username)s"
642956643643-#: letters/templates/letters/profile.html:11
644644-msgid "Identity Verification"
645645-msgstr "Identitรคtsverifizierung"
957957+#: letters/templates/letters/profile.html:16
958958+msgid "Identity & Constituency"
959959+msgstr "Identitรคt & Wahlkreis"
646960647647-#: letters/templates/letters/profile.html:15
648648-msgid "Identity Verified"
649649-msgstr "Identitรคt verifiziert"
650650-651651-#: letters/templates/letters/profile.html:17
652652-msgid "Your signatures will be marked as verified constituent signatures."
653653-msgstr ""
654654-"Ihre Unterschriften werden als verifizierte Wรคhlerunterschriften markiert."
961961+#: letters/templates/letters/profile.html:19
962962+msgid "Status:"
963963+msgstr "Status:"
655964656965#: letters/templates/letters/profile.html:21
657657-#: letters/templates/letters/representative_detail.html:23
658658-msgid "Parliament:"
966966+msgid "Type:"
967967+msgstr "Typ:"
968968+969969+#: letters/templates/letters/profile.html:28
970970+msgid ""
971971+"You self-declared your constituency. Representatives will see your "
972972+"signatures as self-declared constituents."
659973msgstr ""
974974+"Sie haben Ihren Wahlkreis selbst angegeben. Abgeordnete werden Ihre Unterschriften als "
975975+"selbst angegebene Wรคhler sehen."
660976661661-#: letters/templates/letters/profile.html:27
662662-msgid "Verification Pending"
663663-msgstr "Verifizierung ausstehend"
977977+#: letters/templates/letters/profile.html:30
978978+msgid "Start third-party verification"
979979+msgstr "Drittanbieter-Verifizierung starten"
980980+981981+#: letters/templates/letters/profile.html:33
982982+msgid ""
983983+"Your identity was verified via a third-party provider. Signatures will "
984984+"appear as verified constituents."
985985+msgstr ""
986986+"Ihre Identitรคt wurde รผber einen Drittanbieter verifiziert. Unterschriften werden als "
987987+"verifizierte Wรคhler angezeigt."
664988665665-#: letters/templates/letters/profile.html:29
989989+#: letters/templates/letters/profile.html:38
666990msgid "Your verification is being processed."
667991msgstr "Ihre Verifizierung wird bearbeitet."
668992669669-#: letters/templates/letters/profile.html:31
993993+#: letters/templates/letters/profile.html:39
670994msgid "Complete Verification (Stub)"
671995msgstr "Verifizierung abschlieรen (Stub)"
672996673673-#: letters/templates/letters/profile.html:35
674674-msgid "Verification Failed"
675675-msgstr "Verifizierung fehlgeschlagen"
997997+#: letters/templates/letters/profile.html:43
998998+msgid "Verification failed. Please try again or contact support."
999999+msgstr "Verifizierung fehlgeschlagen. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support."
6761000677677-#: letters/templates/letters/profile.html:37
678678-msgid "Please try again or contact support."
679679-msgstr "Bitte versuchen Sie es erneut oder kontaktieren Sie den Support."
10011001+#: letters/templates/letters/profile.html:48
10021002+msgid ""
10031003+"You can self-declare your constituency below or start a verification with a "
10041004+"trusted provider. Verified signatures carry more weight."
10051005+msgstr ""
10061006+"Sie kรถnnen Ihren Wahlkreis unten selbst angeben oder eine Verifizierung mit einem "
10071007+"vertrauenswรผrdigen Anbieter starten. Verifizierte Unterschriften haben mehr Gewicht."
6801008681681-#: letters/templates/letters/profile.html:43
10091009+#: letters/templates/letters/profile.html:50
10101010+msgid "Start Third-party Verification"
10111011+msgstr "Drittanbieter-Verifizierung starten"
10121012+10131013+#: letters/templates/letters/profile.html:55
10141014+msgid "Ihre Adresse"
10151015+msgstr "Ihre Adresse"
10161016+10171017+#: letters/templates/letters/profile.html:57
6821018msgid ""
683683-"Verify your identity to prove you're a constituent of the representatives "
684684-"you write to. Verified signatures carry more weight!"
10191019+"Geben Sie Ihre vollstรคndige Adresse ein, um prรคzise Wahlkreis- und "
10201020+"Abgeordnetenempfehlungen zu erhalten."
6851021msgstr ""
686686-"Verifizieren Sie Ihre Identitรคt, um zu beweisen, dass Sie Wรคhler der "
687687-"Abgeordneten sind, an die Sie schreiben. Verifizierte Unterschriften haben "
688688-"mehr Gewicht!"
10221022+"Geben Sie Ihre vollstรคndige Adresse ein, um prรคzise Wahlkreis- und "
10231023+"Abgeordnetenempfehlungen zu erhalten."
6891024690690-#: letters/templates/letters/profile.html:45
691691-msgid "Start Verification"
692692-msgstr "Verifizierung starten"
10251025+#: letters/templates/letters/profile.html:61
10261026+msgid "Gespeicherte Adresse:"
10271027+msgstr "Gespeicherte Adresse:"
10281028+10291029+#: letters/templates/letters/profile.html:83
10301030+msgid "Adresse speichern"
10311031+msgstr "Adresse speichern"
6931032694694-#: letters/templates/letters/profile.html:51
10331033+#: letters/templates/letters/profile.html:88
10341034+msgid "Self-declare your constituency"
10351035+msgstr "Geben Sie Ihren Wahlkreis selbst an"
10361036+10371037+#: letters/templates/letters/profile.html:90
10381038+msgid ""
10391039+"Select the constituencies you live in so we can prioritise the right "
10401040+"representatives."
10411041+msgstr ""
10421042+"Wรคhlen Sie die Wahlkreise aus, in denen Sie leben, damit wir die richtigen "
10431043+"Abgeordneten priorisieren kรถnnen."
10441044+10451045+#: letters/templates/letters/profile.html:109
10461046+msgid "Save constituencies"
10471047+msgstr "Wahlkreise speichern"
10481048+10491049+#: letters/templates/letters/profile.html:114
6951050msgid "Your Letters"
6961051msgstr "Ihre Briefe"
6971052698698-#: letters/templates/letters/profile.html:57
10531053+#: letters/templates/letters/profile.html:120
6991054msgid "You haven't written any letters yet."
7001055msgstr "Sie haben noch keine Briefe geschrieben."
7011056702702-#: letters/templates/letters/profile.html:57
10571057+#: letters/templates/letters/profile.html:120
7031058msgid "Write one now!"
7041059msgstr "Schreiben Sie jetzt einen!"
7051060706706-#: letters/templates/letters/profile.html:62
10611061+#: letters/templates/letters/profile.html:125
7071062msgid "Letters You've Signed"
7081063msgstr "Briefe, die Sie unterzeichnet haben"
7091064710710-# Letter detail page
711711-#: letters/templates/letters/profile.html:68
712712-msgid "Signed on"
713713-msgstr "Unterzeichnet am"
714714-715715-#: letters/templates/letters/profile.html:71
716716-msgid "Your comment:"
717717-msgstr "Ihr Kommentar:"
718718-719719-#: letters/templates/letters/profile.html:76
10651065+#: letters/templates/letters/profile.html:133
7201066msgid "You haven't signed any letters yet."
7211067msgstr "Sie haben noch keine Briefe unterzeichnet."
7221068723723-#: letters/templates/letters/profile.html:76
10691069+#: letters/templates/letters/profile.html:133
7241070msgid "Browse letters"
7251071msgstr "Briefe durchsuchen"
7261072727727-#: letters/templates/letters/register.html:22
728728-#: letters/templates/letters/representative_detail.html:56
10731073+#: letters/templates/letters/profile.html:138
10741074+msgid "Account"
10751075+msgstr "Konto"
10761076+10771077+#: letters/templates/letters/profile.html:140
10781078+msgid ""
10791079+"Need a fresh start? You can delete your account at any time. Your letters "
10801080+"stay visible but without your name."
10811081+msgstr ""
10821082+"Brauchen Sie einen Neuanfang? Sie kรถnnen Ihr Konto jederzeit lรถschen. Ihre Briefe "
10831083+"bleiben sichtbar, aber ohne Ihren Namen."
10841084+10851085+#: letters/templates/letters/register.html:9
10861086+msgid ""
10871087+"After registration we'll send you an email to confirm your address before "
10881088+"you can sign in."
10891089+msgstr ""
10901090+"Nach der Registrierung senden wir Ihnen eine E-Mail, um Ihre Adresse zu bestรคtigen, "
10911091+"bevor Sie sich anmelden kรถnnen."
10921092+10931093+#: letters/templates/letters/register.html:23
7291094msgid "Email:"
7301095msgstr "E-Mail:"
73110967321097# Register page
733733-#: letters/templates/letters/register.html:30
10981098+#: letters/templates/letters/register.html:31
7341099msgid "First Name (optional):"
7351100msgstr "Vorname (optional):"
7361101737737-#: letters/templates/letters/register.html:38
11021102+#: letters/templates/letters/register.html:39
7381103msgid "Last Name (optional):"
7391104msgstr "Nachname (optional):"
7401105741741-#: letters/templates/letters/register.html:54
11061106+#: letters/templates/letters/register.html:55
7421107msgid "Confirm Password:"
7431108msgstr "Passwort bestรคtigen:"
7441109745745-#: letters/templates/letters/register.html:69
11101110+#: letters/templates/letters/register.html:70
7461111msgid "Already have an account?"
7471112msgstr "Bereits ein Konto?"
7481113749749-#: letters/templates/letters/register.html:69
11141114+#: letters/templates/letters/register.html:70
7501115msgid "Login here"
7511116msgstr "Hier anmelden"
7521117753753-# Representative detail
7541118#: letters/templates/letters/representative_detail.html:19
755755-msgid "Party:"
756756-msgstr "Partei:"
757757-758758-#: letters/templates/letters/representative_detail.html:27
759759-msgid "Legislative Body:"
760760-msgstr "Parlament:"
761761-762762-#: letters/templates/letters/representative_detail.html:32
763763-msgid "Role:"
764764-msgstr "Rolle:"
765765-766766-#: letters/templates/letters/representative_detail.html:38
767767-msgid "Term:"
768768-msgstr "Amtszeit:"
769769-770770-#: letters/templates/letters/representative_detail.html:43
771771-msgid "Present"
772772-msgstr "Heute"
773773-774774-#: letters/templates/letters/representative_detail.html:47
775775-msgid "Status:"
776776-msgstr "Status:"
777777-778778-#: letters/templates/letters/representative_detail.html:49
779779-#: letters/templates/letters/representative_detail.html:98
780780-msgid "Active"
781781-msgstr "Aktiv"
11191119+msgid "รber"
11201120+msgstr "รber"
7821121783783-#: letters/templates/letters/representative_detail.html:51
784784-msgid "Inactive"
785785-msgstr "Inaktiv"
786786-787787-#: letters/templates/letters/representative_detail.html:62
788788-msgid "Website:"
789789-msgstr "Webseite:"
790790-791791-#: letters/templates/letters/representative_detail.html:75
11221122+#: letters/templates/letters/representative_detail.html:30
7921123msgid "Committee Memberships"
7931124msgstr "Ausschussmitgliedschaften"
7941125795795-#: letters/templates/letters/representative_detail.html:113
796796-msgid "Policy Competences"
797797-msgstr "Politische Kompetenzen"
798798-799799-#: letters/templates/letters/representative_detail.html:117
800800-msgid ""
801801-"Based on committee memberships, this representative works on the following "
802802-"policy areas:"
803803-msgstr ""
804804-"Basierend auf Ausschussmitgliedschaften arbeitet dieser Abgeordnete in "
805805-"folgenden Politikbereichen:"
806806-807807-#: letters/templates/letters/representative_detail.html:134
11261126+#: letters/templates/letters/representative_detail.html:75
8081127msgid "Open Letters"
8091128msgstr "Offene Briefe"
8101129811811-#: letters/templates/letters/representative_detail.html:142
11301130+#: letters/templates/letters/representative_detail.html:83
8121131msgid "No letters have been written to this representative yet."
8131132msgstr "An diesen Abgeordneten wurden noch keine Briefe geschrieben."
8141133815815-#: letters/templates/letters/representative_detail.html:144
11341134+#: letters/templates/letters/representative_detail.html:85
8161135msgid "Write the First Letter"
8171136msgstr "Ersten Brief schreiben"
8181137819819-#: letters/templates/letters/representative_detail.html:156
11381138+#: letters/templates/letters/representative_detail.html:95
8201139msgid "External Resources"
8211140msgstr "Externe Ressourcen"
8221141823823-#: letters/templates/letters/representative_detail.html:161
11421142+#: letters/templates/letters/representative_detail.html:100
8241143msgid "Abgeordnetenwatch Profile"
8251144msgstr "Abgeordnetenwatch-Profil"
8261145827827-#: letters/templates/letters/representative_detail.html:163
11461146+#: letters/templates/letters/representative_detail.html:102
8281147msgid ""
8291148"View voting record, questions, and detailed profile on Abgeordnetenwatch.de"
8301149msgstr ""
8311150"Abstimmungsverhalten, Fragen und detailliertes Profil auf Abgeordnetenwatch."
8321151"de ansehen"
8331152834834-#: letters/templates/letters/representative_detail.html:166
835835-msgid "View on Abgeordnetenwatch"
836836-msgstr "Auf Abgeordnetenwatch ansehen"
11531153+#: letters/templates/letters/representative_detail.html:112
11541154+msgid "Wikipedia Article"
11551155+msgstr "Wikipedia-Artikel"
8371156838838-#: letters/templates/letters/representative_detail.html:173
839839-msgid "Wikipedia Article (German)"
840840-msgstr "Wikipedia-Artikel (Deutsch)"
841841-842842-#: letters/templates/letters/representative_detail.html:175
11571157+#: letters/templates/letters/representative_detail.html:114
8431158msgid "Read more about this representative on Wikipedia"
8441159msgstr "Mehr รผber diesen Abgeordneten auf Wikipedia lesen"
8451160846846-#: letters/templates/letters/representative_detail.html:178
11611161+#: letters/templates/letters/representative_detail.html:117
8471162msgid "View on Wikipedia"
8481163msgstr "Auf Wikipedia ansehen"
8491164850850-#: letters/templates/letters/representative_detail.html:184
11651165+#: letters/templates/letters/representative_detail.html:123
8511166msgid "No external resources available for this representative."
8521167msgstr "Keine externen Ressourcen fรผr diesen Abgeordneten verfรผgbar."
8531168854854-#: letters/templates/letters/representative_detail.html:192
11691169+#: letters/templates/letters/representative_detail.html:130
11701170+msgid "Kontakt"
11711171+msgstr "Kontakt"
11721172+11731173+#: letters/templates/letters/representative_detail.html:145
8551174#, python-format
8561175msgid "Start a new open letter to %(name)s"
8571176msgstr "Einen neuen offenen Brief an %(name)s beginnen"
8581177859859-#: letters/templates/letters/representative_detail.html:200
11781178+#: letters/templates/letters/representative_detail.html:153
8601179msgid "Login to Write Letter"
8611180msgstr "Anmelden um Brief zu schreiben"
862118111821182+#: letters/views.py:52
11831183+msgid "Confirm your WriteThem.eu account"
11841184+msgstr "Bestรคtigen Sie Ihr WriteThem.eu-Konto"
11851185+8631186# Flash messages
864864-#: letters/views.py:158
11871187+#: letters/views.py:184
8651188msgid "Your letter has been published and your signature has been added!"
866866-msgstr ""
867867-"Ihr Brief wurde verรถffentlicht und Ihre Unterschrift wurde hinzugefรผgt!"
11891189+msgstr "Ihr Brief wurde verรถffentlicht und Ihre Unterschrift wurde hinzugefรผgt!"
8681190869869-#: letters/views.py:171
11911191+#: letters/views.py:197
8701192msgid "You have already signed this letter."
8711193msgstr "Sie haben diesen Brief bereits unterzeichnet."
8721194873873-#: letters/views.py:181
11951195+#: letters/views.py:207
8741196msgid "Your signature has been added!"
8751197msgstr "Ihre Unterschrift wurde hinzugefรผgt!"
8761198877877-#: letters/views.py:199
11991199+#: letters/views.py:225
8781200msgid "Thank you for your report. Our team will review it."
8791201msgstr "Vielen Dank fรผr Ihre Meldung. Unser Team wird sie รผberprรผfen."
8801202881881-#: letters/views.py:226
882882-#, python-format
883883-msgid "Welcome, %(username)s! Your account has been created."
884884-msgstr "Willkommen, %(username)s! Ihr Konto wurde erstellt."
12031203+#: letters/views.py:259
12041204+msgid ""
12051205+"Please confirm your email address. We sent you a link to activate your "
12061206+"account."
12071207+msgstr ""
12081208+"Bitte bestรคtigen Sie Ihre E-Mail-Adresse. Wir haben Ihnen einen Link zum Aktivieren "
12091209+"Ihres Kontos gesendet."
8851210886886-#~ msgid "Previous"
887887-#~ msgstr "Zurรผck"
12111211+#: letters/views.py:289
12121212+msgid "Your account has been activated. You can now log in."
12131213+msgstr "Ihr Konto wurde aktiviert. Sie kรถnnen sich jetzt anmelden."
12141214+12151215+#: letters/views.py:292
12161216+msgid "Your account is already active."
12171217+msgstr "Ihr Konto ist bereits aktiv."
8881218889889-#~ msgid "Next"
890890-#~ msgstr "Weiter"
12191219+#: letters/views.py:347
12201220+msgid "Ihre Adresse wurde gespeichert."
12211221+msgstr "Ihre Adresse wurde gespeichert."
12221222+12231223+#: letters/views.py:363
12241224+msgid "Your constituency information has been updated."
12251225+msgstr "Ihre Wahlkreisinformationen wurden aktualisiert."
12261226+12271227+#: letters/views.py:391
12281228+msgid ""
12291229+"Your account has been deleted. Your published letters remain available to "
12301230+"the public."
12311231+msgstr ""
12321232+"Ihr Konto wurde gelรถscht. Ihre verรถffentlichten Briefe bleiben fรผr die "
12331233+"รffentlichkeit verfรผgbar."
12341234+12351235+# Forms
12361236+#~ msgid "Postal code (PLZ)"
12371237+#~ msgstr "Postleitzahl (PLZ)"
12381238+12391239+#, fuzzy
12401240+#~| msgid "Use your PLZ to narrow down representatives from your constituency."
12411241+#~ msgid "Use your PLZ to narrow down representatives from your parliament."
12421242+#~ msgstr ""
12431243+#~ "Verwenden Sie Ihre PLZ, um Abgeordnete aus Ihrem Wahlkreis einzugrenzen."
12441244+12451245+#~ msgid "Comma-separated tags (e.g., \"climate, transport, education\")"
12461246+#~ msgstr "Komma-getrennte Schlagwรถrter (z.B. \"Klima, Verkehr, Bildung\")"
12471247+12481248+#~ msgid "climate, transport, education"
12491249+#~ msgstr "Klima, Verkehr, Bildung"
12501250+12511251+#~ msgid "Write your letter here"
12521252+#~ msgstr "Schreiben Sie hier Ihren Brief"
12531253+12541254+#, fuzzy
12551255+#~| msgid "Federal"
12561256+#~ msgid "Federal district"
12571257+#~ msgstr "Bund"
12581258+12591259+#~ msgid "Your postal code (PLZ):"
12601260+#~ msgstr "Ihre Postleitzahl (PLZ):"
12611261+12621262+#~ msgid "Or select from suggestions on the right โ"
12631263+#~ msgstr "Oder wรคhlen Sie aus den Vorschlรคgen rechts โ"
12641264+12651265+#~ msgid "Tags (optional):"
12661266+#~ msgstr "Schlagwรถrter (optional):"
89112678921268#, python-format
893893-#~ msgid "Page %(page)s of %(total)s"
894894-#~ msgstr "Seite %(page)s von %(total)s"
12691269+#~ msgid ""
12701270+#~ "Based on the topic \"%(topic)s\", we suggest contacting representatives "
12711271+#~ "from the %(level)s:"
12721272+#~ msgstr ""
12731273+#~ "Basierend auf dem Thema \"%(topic)s\" empfehlen wir, Abgeordnete des "
12741274+#~ "%(level)s zu kontaktieren:"
12751275+12761276+#, fuzzy
12771277+#~| msgid "Suggested Representatives"
12781278+#~ msgid "Suggested Tags"
12791279+#~ msgstr "Vorgeschlagene Abgeordnete"
12801280+12811281+#~ msgid "Similar Letters"
12821282+#~ msgstr "รhnliche Briefe"
12831283+12841284+#~ msgid "Others have written about similar topics:"
12851285+#~ msgstr "Andere haben รผber รคhnliche Themen geschrieben:"
8951286896896-#~ msgid "No letters found."
897897-#~ msgstr "Keine Briefe gefunden."
12871287+#, fuzzy
12881288+#~| msgid "To"
12891289+#~ msgid "To:"
12901290+#~ msgstr "An"
12911291+12921292+#~ msgid "Identity Verification"
12931293+#~ msgstr "Identitรคtsverifizierung"
8981294899899-#~ msgid "Be the first to write one!"
900900-#~ msgstr "Schreiben Sie den ersten!"
12951295+#~ msgid "Your signatures will be marked as verified constituent signatures."
12961296+#~ msgstr ""
12971297+#~ "Ihre Unterschriften werden als verifizierte Wรคhlerunterschriften markiert."
12981298+12991299+#~ msgid "Verification Pending"
13001300+#~ msgstr "Verifizierung ausstehend"
13011301+13021302+#~ msgid "Verification Failed"
13031303+#~ msgstr "Verifizierung fehlgeschlagen"
13041304+13051305+# Letter detail page
13061306+#~ msgid "Signed on"
13071307+#~ msgstr "Unterzeichnet am"
13081308+13091309+#~ msgid "Your comment:"
13101310+#~ msgstr "Ihr Kommentar:"
13111311+13121312+# Representative detail
13131313+#~ msgid "Party:"
13141314+#~ msgstr "Partei:"
13151315+13161316+#~ msgid "Legislative Body:"
13171317+#~ msgstr "Parlament:"
13181318+13191319+#~ msgid "Present"
13201320+#~ msgstr "Heute"
13211321+13221322+#~ msgid "Inactive"
13231323+#~ msgstr "Inaktiv"
13241324+13251325+#~ msgid ""
13261326+#~ "Based on committee memberships, this representative works on the "
13271327+#~ "following policy areas:"
13281328+#~ msgstr ""
13291329+#~ "Basierend auf Ausschussmitgliedschaften arbeitet dieser Abgeordnete in "
13301330+#~ "folgenden Politikbereichen:"
13311331+13321332+#, python-format
13331333+#~ msgid "Welcome, %(username)s! Your account has been created."
13341334+#~ msgstr "Willkommen, %(username)s! Ihr Konto wurde erstellt."
90113359021336#, python-format
9031337#~ msgid "<strong>%(counter)s</strong> other verified"
···91513499161350#~ msgid "Committee Memberships:"
9171351#~ msgstr "Ausschussmitgliedschaften:"
918918-919919-#~ msgid "Policy Areas:"
920920-#~ msgstr "Politikbereiche:"
92113529221353# Services explanations
9231354#~ 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
···11+# SOME DESCRIPTIVE TITLE.
22+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
33+# This file is distributed under the same license as the PACKAGE package.
44+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
55+#
66+#, fuzzy
77+msgid ""
88+msgstr ""
99+"Project-Id-Version: PACKAGE VERSION\n"
1010+"Report-Msgid-Bugs-To: \n"
1111+"POT-Creation-Date: 2025-10-15 00:28+0200\n"
1212+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
1313+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
1414+"Language-Team: LANGUAGE <LL@li.org>\n"
1515+"Language: \n"
1616+"MIME-Version: 1.0\n"
1717+"Content-Type: text/plain; charset=UTF-8\n"
1818+"Content-Transfer-Encoding: 8bit\n"
1919+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
2020+2121+#: letters/admin.py:75
2222+msgid "Mandate Details"
2323+msgstr "Mandate Details"
2424+2525+#: letters/admin.py:78
2626+msgid "Focus Areas"
2727+msgstr "Focus Areas"
2828+2929+#: letters/admin.py:81 letters/admin.py:95
3030+msgid "Photo"
3131+msgstr "Photo"
3232+3333+#: letters/admin.py:85
3434+msgid "Metadata"
3535+msgstr "Metadata"
3636+3737+#: letters/admin.py:94
3838+msgid "No photo"
3939+msgstr "No photo"
4040+4141+#: letters/admin.py:167
4242+msgid "Topic Areas"
4343+msgstr "Topic Areas"
4444+4545+#: letters/forms.py:25
4646+msgid ""
4747+"An account with this email already exists. If you registered before, please "
4848+"check your inbox for the activation link or reset your password."
4949+msgstr ""
5050+"An account with this email already exists. If you registered before, please "
5151+"check your inbox for the activation link or reset your password."
5252+5353+#: letters/forms.py:37
5454+msgid "Title"
5555+msgstr "Title"
5656+5757+#: letters/forms.py:38
5858+msgid "Letter Body"
5959+msgstr "Letter Body"
6060+6161+#: letters/forms.py:39
6262+msgid "To Representative"
6363+msgstr "To Representative"
6464+6565+#: letters/forms.py:42
6666+msgid "Describe your concern briefly"
6767+msgstr "Describe your concern briefly"
6868+6969+#: letters/forms.py:43
7070+msgid ""
7171+"Write your letter here. Markdown formatting (e.g. **bold**, _italic_) is "
7272+"supported."
7373+msgstr ""
7474+"Write your letter here. Markdown formatting (e.g. **bold**, _italic_) is "
7575+"supported."
7676+7777+#: letters/forms.py:46
7878+msgid "Letter title"
7979+msgstr "Letter title"
8080+8181+#: letters/forms.py:50
8282+msgid "Write your letter here..."
8383+msgstr "Write your letter here..."
8484+8585+#: letters/forms.py:110
8686+msgid "Comment (optional)"
8787+msgstr "Comment (optional)"
8888+8989+#: letters/forms.py:113
9090+msgid "Add a personal note to your signature"
9191+msgstr "Add a personal note to your signature"
9292+9393+#: letters/forms.py:119
9494+msgid "Optional: Add your comment..."
9595+msgstr "Optional: Add your comment..."
9696+9797+#: letters/forms.py:131
9898+msgid "Reason"
9999+msgstr "Reason"
100100+101101+#: letters/forms.py:132
102102+msgid "Description"
103103+msgstr "Description"
104104+105105+#: letters/forms.py:135
106106+msgid "Please provide details about why you are reporting this letter"
107107+msgstr "Please provide details about why you are reporting this letter"
108108+109109+#: letters/forms.py:142
110110+msgid "Please describe the issue..."
111111+msgstr "Please describe the issue..."
112112+113113+#: letters/forms.py:152 letters/templates/letters/letter_list.html:19
114114+msgid "Search"
115115+msgstr "Search"
116116+117117+#: letters/forms.py:155 letters/templates/letters/letter_list.html:18
118118+msgid "Search letters..."
119119+msgstr "Search letters..."
120120+121121+#: letters/forms.py:161
122122+msgid "Tag"
123123+msgstr "Tag"
124124+125125+#: letters/forms.py:164
126126+msgid "Filter by tag..."
127127+msgstr "Filter by tag..."
128128+129129+#: letters/forms.py:180
130130+msgid "Bundestag constituency"
131131+msgstr "Bundestag constituency"
132132+133133+#: letters/forms.py:181
134134+msgid "Pick your Bundestag direct mandate constituency (Wahlkreis)."
135135+msgstr "Pick your Bundestag direct mandate constituency (Wahlkreis)."
136136+137137+#: letters/forms.py:182 letters/forms.py:189
138138+msgid "Select constituency"
139139+msgstr "Select constituency"
140140+141141+#: letters/forms.py:187
142142+msgid "State parliament constituency"
143143+msgstr "State parliament constituency"
144144+145145+#: letters/forms.py:188
146146+msgid "Optionally pick your Landtag constituency if applicable."
147147+msgstr "Optionally pick your Landtag constituency if applicable."
148148+149149+#: letters/forms.py:240
150150+msgid "Please select at least one constituency to save your profile."
151151+msgstr "Please select at least one constituency to save your profile."
152152+153153+#: letters/forms.py:252
154154+msgid "Straรe und Hausnummer"
155155+msgstr "Straรe und Hausnummer"
156156+157157+#: letters/forms.py:255
158158+msgid "z.B. Unter den Linden 77"
159159+msgstr "z.B. Unter den Linden 77"
160160+161161+#: letters/forms.py:261
162162+msgid "Postleitzahl"
163163+msgstr "Postleitzahl"
164164+165165+#: letters/forms.py:264
166166+msgid "z.B. 10117"
167167+msgstr "z.B. 10117"
168168+169169+#: letters/forms.py:270
170170+msgid "Stadt"
171171+msgstr "Stadt"
172172+173173+#: letters/forms.py:273
174174+msgid "z.B. Berlin"
175175+msgstr "z.B. Berlin"
176176+177177+#: letters/forms.py:304
178178+msgid ""
179179+"Bitte geben Sie eine vollstรคndige Adresse ein (Straรe, PLZ und Stadt) oder "
180180+"lassen Sie alle Felder leer."
181181+msgstr ""
182182+"Bitte geben Sie eine vollstรคndige Adresse ein (Straรe, PLZ und Stadt) oder "
183183+"lassen Sie alle Felder leer."
184184+185185+#: letters/models.py:15
186186+msgid "European Union"
187187+msgstr "European Union"
188188+189189+#: letters/models.py:16
190190+msgid "Federal"
191191+msgstr "Federal"
192192+193193+#: letters/models.py:17
194194+msgid "State"
195195+msgstr "State"
196196+197197+#: letters/models.py:18
198198+msgid "Local"
199199+msgstr "Local"
200200+201201+#: letters/models.py:21
202202+msgid "Name of the parliament"
203203+msgstr "Name of the parliament"
204204+205205+#: letters/models.py:25
206206+msgid "e.g., 'Bundestag', 'Bayerischer Landtag', 'Gemeinderat Mรผnchen'"
207207+msgstr "e.g., 'Bundestag', 'Bayerischer Landtag', 'Gemeinderat Mรผnchen'"
208208+209209+#: letters/models.py:29
210210+msgid "Geographic identifier (state code, municipality code, etc.)"
211211+msgstr "Geographic identifier (state code, municipality code, etc.)"
212212+213213+#: letters/models.py:37
214214+msgid "For hierarchical relationships (e.g., local within state)"
215215+msgstr "For hierarchical relationships (e.g., local within state)"
216216+217217+#: letters/models.py:43 letters/models.py:70 letters/models.py:105
218218+#: letters/models.py:165 letters/models.py:372 letters/models.py:421
219219+msgid "Last time this was synced from external API"
220220+msgstr "Last time this was synced from external API"
221221+222222+#: letters/models.py:47 letters/templates/letters/committee_detail.html:70
223223+msgid "Parliament"
224224+msgstr "Parliament"
225225+226226+#: letters/models.py:48
227227+msgid "Parliaments"
228228+msgstr "Parliaments"
229229+230230+#: letters/models.py:84
231231+msgid "Federal electoral district"
232232+msgstr "Federal electoral district"
233233+234234+#: letters/models.py:85
235235+msgid "Bundestag state list"
236236+msgstr "Bundestag state list"
237237+238238+#: letters/models.py:86
239239+msgid "Bundestag federal list"
240240+msgstr "Bundestag federal list"
241241+242242+#: letters/models.py:87
243243+msgid "State electoral district"
244244+msgstr "State electoral district"
245245+246246+#: letters/models.py:88
247247+msgid "State regional list"
248248+msgstr "State regional list"
249249+250250+#: letters/models.py:89
251251+msgid "State wide list"
252252+msgstr "State wide list"
253253+254254+#: letters/models.py:90
255255+msgid "EU at large"
256256+msgstr "EU at large"
257257+258258+#: letters/models.py:119
259259+msgid "Direct mandate"
260260+msgstr "Direct mandate"
261261+262262+#: letters/models.py:120
263263+msgid "State list mandate"
264264+msgstr "State list mandate"
265265+266266+#: letters/models.py:121
267267+msgid "State regional list mandate"
268268+msgstr "State regional list mandate"
269269+270270+#: letters/models.py:122
271271+msgid "Federal list mandate"
272272+msgstr "Federal list mandate"
273273+274274+#: letters/models.py:123
275275+msgid "EU list mandate"
276276+msgstr "EU list mandate"
277277+278278+#: letters/models.py:444
279279+msgid "Draft"
280280+msgstr "Draft"
281281+282282+#: letters/models.py:445
283283+msgid "Published"
284284+msgstr "Published"
285285+286286+#: letters/models.py:446
287287+msgid "Flagged for Review"
288288+msgstr "Flagged for Review"
289289+290290+#: letters/models.py:447
291291+msgid "Removed"
292292+msgstr "Removed"
293293+294294+#: letters/models.py:487
295295+msgid "Deleted user"
296296+msgstr "Deleted user"
297297+298298+#: letters/services.py:2451
299299+#, python-format
300300+msgid "Detected policy area: %(topic)s."
301301+msgstr "Detected policy area: %(topic)s."
302302+303303+#: letters/services.py:2456
304304+#, python-format
305305+msgid "Prioritising representatives for %(constituencies)s."
306306+msgstr "Prioritising representatives for %(constituencies)s."
307307+308308+#: letters/services.py:2461
309309+#, python-format
310310+msgid "Filtering by state %(state)s."
311311+msgstr "Filtering by state %(state)s."
312312+313313+#: letters/services.py:2465
314314+#, python-format
315315+msgid ""
316316+"Postal code %(plz)s had no direct match; showing broader representatives."
317317+msgstr ""
318318+"Postal code %(plz)s had no direct match; showing broader representatives."
319319+320320+#: letters/services.py:2469
321321+msgid "Showing generally relevant representatives."
322322+msgstr "Showing generally relevant representatives."
323323+324324+#: letters/templates/letters/account_activation_invalid.html:4
325325+#: letters/templates/letters/account_activation_invalid.html:8
326326+msgid "Activation link invalid"
327327+msgstr "Activation link invalid"
328328+329329+#: letters/templates/letters/account_activation_invalid.html:10
330330+msgid ""
331331+"We could not verify your activation link. It may have already been used or "
332332+"expired."
333333+msgstr ""
334334+"We could not verify your activation link. It may have already been used or "
335335+"expired."
336336+337337+#: letters/templates/letters/account_activation_invalid.html:13
338338+msgid ""
339339+"If you still cannot access your account, try registering again or contact "
340340+"support."
341341+msgstr ""
342342+"If you still cannot access your account, try registering again or contact "
343343+"support."
344344+345345+#: letters/templates/letters/account_activation_invalid.html:15
346346+msgid "Register again"
347347+msgstr "Register again"
348348+349349+#: letters/templates/letters/account_activation_sent.html:4
350350+msgid "Activate your account"
351351+msgstr "Activate your account"
352352+353353+#: letters/templates/letters/account_activation_sent.html:8
354354+#: letters/templates/letters/password_reset_done.html:8
355355+msgid "Check your inbox"
356356+msgstr "Check your inbox"
357357+358358+#: letters/templates/letters/account_activation_sent.html:10
359359+msgid ""
360360+"We sent you an email with a confirmation link. Please click it to activate "
361361+"your account."
362362+msgstr ""
363363+"We sent you an email with a confirmation link. Please click it to activate "
364364+"your account."
365365+366366+#: letters/templates/letters/account_activation_sent.html:13
367367+msgid ""
368368+"If you do not receive the email within a few minutes, check your spam folder "
369369+"or try registering again."
370370+msgstr ""
371371+"If you do not receive the email within a few minutes, check your spam folder "
372372+"or try registering again."
373373+374374+#: letters/templates/letters/account_activation_sent.html:15
375375+msgid "Back to homepage"
376376+msgstr "Back to homepage"
377377+378378+#: letters/templates/letters/account_delete_confirm.html:4
379379+#: letters/templates/letters/profile.html:142
380380+msgid "Delete account"
381381+msgstr "Delete account"
382382+383383+#: letters/templates/letters/account_delete_confirm.html:8
384384+msgid "Delete your account"
385385+msgstr "Delete your account"
386386+387387+#: letters/templates/letters/account_delete_confirm.html:10
388388+msgid ""
389389+"Deleting your account will remove your personal data and signatures. Letters "
390390+"you have published stay online but are shown without your name."
391391+msgstr ""
392392+"Deleting your account will remove your personal data and signatures. Letters "
393393+"you have published stay online but are shown without your name."
394394+395395+#: letters/templates/letters/account_delete_confirm.html:14
396396+msgid "Yes, delete my account"
397397+msgstr "Yes, delete my account"
398398+399399+#: letters/templates/letters/account_delete_confirm.html:15
400400+#: letters/templates/letters/letter_form.html:61
401401+msgid "Cancel"
402402+msgstr "Cancel"
403403+404404+#: letters/templates/letters/base.html:140
405405+msgid "Letters"
406406+msgstr "Letters"
407407+408408+#: letters/templates/letters/base.html:141
409409+msgid "Competencies"
410410+msgstr "Competencies"
411411+412412+#: letters/templates/letters/base.html:143
413413+#: letters/templates/letters/representative_detail.html:149
414414+msgid "Write Letter"
415415+msgstr "Write Letter"
416416+417417+#: letters/templates/letters/base.html:144
418418+#: letters/templates/letters/profile.html:4
419419+msgid "Profile"
420420+msgstr "Profile"
421421+422422+#: letters/templates/letters/base.html:145
423423+msgid "Logout"
424424+msgstr "Logout"
425425+426426+#: letters/templates/letters/base.html:147
427427+#: letters/templates/letters/letter_detail.html:47
428428+#: letters/templates/letters/letter_detail.html:81
429429+#: letters/templates/letters/login.html:4
430430+#: letters/templates/letters/login.html:8
431431+#: letters/templates/letters/login.html:33
432432+msgid "Login"
433433+msgstr "Login"
434434+435435+#: letters/templates/letters/base.html:148
436436+#: letters/templates/letters/register.html:4
437437+#: letters/templates/letters/register.html:8
438438+#: letters/templates/letters/register.html:66
439439+msgid "Register"
440440+msgstr "Register"
441441+442442+#: letters/templates/letters/base.html:151
443443+msgid "Admin"
444444+msgstr "Admin"
445445+446446+#: letters/templates/letters/base.html:157
447447+msgid "Select language"
448448+msgstr "Select language"
449449+450450+#: letters/templates/letters/base.html:182
451451+msgid "Empowering citizens to write to their representatives"
452452+msgstr "Empowering citizens to write to their representatives"
453453+454454+#: letters/templates/letters/committee_detail.html:22
455455+msgid "Related Topics"
456456+msgstr "Related Topics"
457457+458458+#: letters/templates/letters/committee_detail.html:36
459459+msgid "Members"
460460+msgstr "Members"
461461+462462+#: letters/templates/letters/committee_detail.html:46
463463+msgid "Role"
464464+msgstr "Role"
465465+466466+#: letters/templates/letters/committee_detail.html:48
467467+#: letters/templates/letters/representative_detail.html:61
468468+msgid "Active"
469469+msgstr "Active"
470470+471471+#: letters/templates/letters/committee_detail.html:51
472472+#: letters/templates/letters/partials/representative_card.html:29
473473+msgid "Since"
474474+msgstr "Since"
475475+476476+#: letters/templates/letters/committee_detail.html:58
477477+msgid "No members recorded for this committee."
478478+msgstr "No members recorded for this committee."
479479+480480+#: letters/templates/letters/committee_detail.html:67
481481+msgid "Committee Info"
482482+msgstr "Committee Info"
483483+484484+#: letters/templates/letters/committee_detail.html:71
485485+msgid "Term"
486486+msgstr "Term"
487487+488488+#: letters/templates/letters/committee_detail.html:74
489489+#: letters/templates/letters/representative_detail.html:105
490490+msgid "View on Abgeordnetenwatch"
491491+msgstr "View on Abgeordnetenwatch"
492492+493493+#: letters/templates/letters/letter_detail.html:10
494494+#: letters/templates/letters/partials/letter_card.html:5
495495+msgid "By"
496496+msgstr "By"
497497+498498+#: letters/templates/letters/letter_detail.html:29
499499+#, python-format
500500+msgid "Signatures (%(counter)s)"
501501+msgid_plural "Signatures (%(counter)s)"
502502+msgstr[0] "Signatures (%(counter)s)"
503503+msgstr[1] "Signatures (%(counter)s)"
504504+505505+#: letters/templates/letters/letter_detail.html:31
506506+#, python-format
507507+msgid "%(counter)s constituent of %(constituency_name)s"
508508+msgid_plural "%(counter)s constituents of %(constituency_name)s"
509509+msgstr[0] "%(counter)s constituent of %(constituency_name)s"
510510+msgstr[1] "%(counter)s constituents of %(constituency_name)s"
511511+512512+#: letters/templates/letters/letter_detail.html:32
513513+#, python-format
514514+msgid "%(counter)s other verified"
515515+msgid_plural "%(counter)s other verified"
516516+msgstr[0] "%(counter)s other verified"
517517+msgstr[1] "%(counter)s other verified"
518518+519519+#: letters/templates/letters/letter_detail.html:33
520520+#, python-format
521521+msgid "%(counter)s unverified"
522522+msgid_plural "%(counter)s unverified"
523523+msgstr[0] "%(counter)s unverified"
524524+msgstr[1] "%(counter)s unverified"
525525+526526+#: letters/templates/letters/letter_detail.html:41
527527+msgid "Sign this letter"
528528+msgstr "Sign this letter"
529529+530530+#: letters/templates/letters/letter_detail.html:44
531531+msgid "You have signed this letter"
532532+msgstr "You have signed this letter"
533533+534534+#: letters/templates/letters/letter_detail.html:47
535535+msgid "to sign this letter"
536536+msgstr "to sign this letter"
537537+538538+#: letters/templates/letters/letter_detail.html:58
539539+msgid "โ Verified Constituent"
540540+msgstr "โ Verified Constituent"
541541+542542+#: letters/templates/letters/letter_detail.html:60
543543+msgid "โ Verified"
544544+msgstr "โ Verified"
545545+546546+#: letters/templates/letters/letter_detail.html:70
547547+msgid "No signatures yet. Be the first to sign!"
548548+msgstr "No signatures yet. Be the first to sign!"
549549+550550+#: letters/templates/letters/letter_detail.html:75
551551+#: letters/templates/letters/letter_detail.html:79
552552+msgid "Report this letter"
553553+msgstr "Report this letter"
554554+555555+#: letters/templates/letters/letter_detail.html:76
556556+msgid "If you believe this letter violates our guidelines, please report it."
557557+msgstr "If you believe this letter violates our guidelines, please report it."
558558+559559+#: letters/templates/letters/letter_detail.html:81
560560+msgid "to report this letter"
561561+msgstr "to report this letter"
562562+563563+#: letters/templates/letters/letter_detail.html:86
564564+msgid "Back to all letters"
565565+msgstr "Back to all letters"
566566+567567+#: letters/templates/letters/letter_form.html:4
568568+#: letters/templates/letters/representative_detail.html:143
569569+msgid "Write a Letter"
570570+msgstr "Write a Letter"
571571+572572+#: letters/templates/letters/letter_form.html:10
573573+msgid "Write an Open Letter"
574574+msgstr "Write an Open Letter"
575575+576576+#: letters/templates/letters/letter_form.html:12
577577+msgid ""
578578+"Write an open letter to a political representative. Your letter will be "
579579+"published publicly so others can read and sign it."
580580+msgstr ""
581581+"Write an open letter to a political representative. Your letter will be "
582582+"published publicly so others can read and sign it."
583583+584584+#: letters/templates/letters/letter_form.html:16
585585+msgid "Before you write"
586586+msgstr "Before you write"
587587+588588+#: letters/templates/letters/letter_form.html:18
589589+msgid "Be thoughtful but feel free to be critical."
590590+msgstr "Be thoughtful but feel free to be critical."
591591+592592+#: letters/templates/letters/letter_form.html:19
593593+msgid "Representatives are humans tooโstay respectful."
594594+msgstr "Representatives are humans tooโstay respectful."
595595+596596+#: letters/templates/letters/letter_form.html:20
597597+msgid "Keep your arguments clear and concise."
598598+msgstr "Keep your arguments clear and concise."
599599+600600+#: letters/templates/letters/letter_form.html:21
601601+msgid "No insults or hate speech."
602602+msgstr "No insults or hate speech."
603603+604604+#: letters/templates/letters/letter_form.html:22
605605+msgid "Stay within the bounds of the Grundgesetz when making demands."
606606+msgstr "Stay within the bounds of the Grundgesetz when making demands."
607607+608608+#: letters/templates/letters/letter_form.html:30
609609+msgid "Title:"
610610+msgstr "Title:"
611611+612612+#: letters/templates/letters/letter_form.html:36
613613+msgid ""
614614+"Describe your concern in a sentence; we'll use it to suggest representatives."
615615+msgstr ""
616616+"Describe your concern in a sentence; we'll use it to suggest representatives."
617617+618618+#: letters/templates/letters/letter_form.html:41
619619+msgid "To Representative:"
620620+msgstr "To Representative:"
621621+622622+#: letters/templates/letters/letter_form.html:47
623623+msgid ""
624624+"Already know who to address? Pick them here. Otherwise, use the suggestions "
625625+"below."
626626+msgstr ""
627627+"Already know who to address? Pick them here. Otherwise, use the suggestions "
628628+"below."
629629+630630+#: letters/templates/letters/letter_form.html:53
631631+msgid "Letter Body:"
632632+msgstr "Letter Body:"
633633+634634+#: letters/templates/letters/letter_form.html:60
635635+msgid "Publish Letter"
636636+msgstr "Publish Letter"
637637+638638+#: letters/templates/letters/letter_form.html:67
639639+msgid "Smart Suggestions"
640640+msgstr "Smart Suggestions"
641641+642642+#: letters/templates/letters/letter_form.html:69
643643+msgid ""
644644+"Type your title and we'll use your verified profile to suggest "
645645+"representatives, topics, and related letters."
646646+msgstr ""
647647+"Type your title and we'll use your verified profile to suggest "
648648+"representatives, topics, and related letters."
649649+650650+#: letters/templates/letters/letter_form.html:81
651651+msgid "Loading..."
652652+msgstr "Loading..."
653653+654654+#: letters/templates/letters/letter_form.html:83
655655+msgid "Analyzing your title..."
656656+msgstr "Analyzing your title..."
657657+658658+#: letters/templates/letters/letter_list.html:4
659659+msgid "Browse Letters"
660660+msgstr "Browse Letters"
661661+662662+#: letters/templates/letters/letter_list.html:8
663663+msgid "About This"
664664+msgstr "About This"
665665+666666+#: letters/templates/letters/letter_list.html:9
667667+msgid ""
668668+"Make your voice heard, reach out to your representative, participate in "
669669+"democracy."
670670+msgstr ""
671671+"Make your voice heard, reach out to your representative, participate in "
672672+"democracy."
673673+674674+#: letters/templates/letters/letter_list.html:10
675675+msgid "Open letters authored and signed by fellow citizens."
676676+msgstr "Open letters authored and signed by fellow citizens."
677677+678678+#: letters/templates/letters/letter_list.html:11
679679+msgid ""
680680+"Physical letters mailed to representatives when number of verified "
681681+"signatures > 100."
682682+msgstr ""
683683+"Physical letters mailed to representatives when number of verified "
684684+"signatures > 100."
685685+686686+#: letters/templates/letters/letter_list.html:15
687687+msgid "Browse Open Letters"
688688+msgstr "Browse Open Letters"
689689+690690+#: letters/templates/letters/letter_list.html:25
691691+msgid "Popular tags:"
692692+msgstr "Popular tags:"
693693+694694+#: letters/templates/letters/letter_list.html:41
695695+msgid "Previous"
696696+msgstr "Previous"
697697+698698+#: letters/templates/letters/letter_list.html:43
699699+#, python-format
700700+msgid "Page %(page)s of %(total)s"
701701+msgstr "Page %(page)s of %(total)s"
702702+703703+#: letters/templates/letters/letter_list.html:45
704704+msgid "Next"
705705+msgstr "Next"
706706+707707+#: letters/templates/letters/letter_list.html:51
708708+msgid "No letters found."
709709+msgstr "No letters found."
710710+711711+#: letters/templates/letters/letter_list.html:51
712712+msgid "Be the first to write one!"
713713+msgstr "Be the first to write one!"
714714+715715+#: letters/templates/letters/login.html:14
716716+#: letters/templates/letters/register.html:15
717717+msgid "Username:"
718718+msgstr "Username:"
719719+720720+#: letters/templates/letters/login.html:22
721721+#: letters/templates/letters/register.html:47
722722+msgid "Password:"
723723+msgstr "Password:"
724724+725725+#: letters/templates/letters/login.html:37
726726+msgid "Forgot your password?"
727727+msgstr "Forgot your password?"
728728+729729+#: letters/templates/letters/login.html:41
730730+msgid "Don't have an account?"
731731+msgstr "Don't have an account?"
732732+733733+#: letters/templates/letters/login.html:41
734734+msgid "Register here"
735735+msgstr "Register here"
736736+737737+#: letters/templates/letters/partials/letter_card.html:6
738738+msgid "To"
739739+msgstr "To"
740740+741741+#: letters/templates/letters/partials/letter_card.html:20
742742+#, python-format
743743+msgid "%(counter)s signature"
744744+msgid_plural "%(counter)s signatures"
745745+msgstr[0] "%(counter)s signature"
746746+msgstr[1] "%(counter)s signatures"
747747+748748+#: letters/templates/letters/partials/letter_card.html:20
749749+#, python-format
750750+msgid "%(counter)s verified"
751751+msgid_plural "%(counter)s verified"
752752+msgstr[0] "%(counter)s verified"
753753+msgstr[1] "%(counter)s verified"
754754+755755+#: letters/templates/letters/partials/representative_card.html:21
756756+#: letters/templates/letters/partials/representative_card.html:23
757757+msgid "Constituency"
758758+msgstr "Constituency"
759759+760760+#: letters/templates/letters/partials/representative_card.html:27
761761+msgid "Mandate"
762762+msgstr "Mandate"
763763+764764+#: letters/templates/letters/partials/representative_card.html:34
765765+msgid "Focus"
766766+msgstr "Focus"
767767+768768+#: letters/templates/letters/partials/representative_card.html:34
769769+msgid "self-declared"
770770+msgstr "self-declared"
771771+772772+#: letters/templates/letters/partials/representative_card.html:45
773773+msgid "Committees"
774774+msgstr "Committees"
775775+776776+#: letters/templates/letters/partials/representative_card.html:57
777777+msgid "Email"
778778+msgstr "Email"
779779+780780+#: letters/templates/letters/partials/representative_card.html:60
781781+msgid "Website"
782782+msgstr "Website"
783783+784784+#: letters/templates/letters/partials/representative_card.html:66
785785+msgid "View profile"
786786+msgstr "View profile"
787787+788788+#: letters/templates/letters/partials/suggestions.html:10
789789+msgid ""
790790+"We couldn't match you to a constituency yet. Update your profile "
791791+"verification to see local representatives."
792792+msgstr ""
793793+"We couldn't match you to a constituency yet. Update your profile "
794794+"verification to see local representatives."
795795+796796+#: letters/templates/letters/partials/suggestions.html:16
797797+msgid "Our Interpretation"
798798+msgstr "Our Interpretation"
799799+800800+#: letters/templates/letters/partials/suggestions.html:21
801801+msgid "Topic:"
802802+msgstr "Topic:"
803803+804804+#: letters/templates/letters/partials/suggestions.html:28
805805+msgid "No specific policy area detected. Try adding more keywords."
806806+msgstr "No specific policy area detected. Try adding more keywords."
807807+808808+#: letters/templates/letters/partials/suggestions.html:37
809809+msgid "Related Keywords"
810810+msgstr "Related Keywords"
811811+812812+#: letters/templates/letters/partials/suggestions.html:51
813813+msgid "Your Direct Representatives"
814814+msgstr "Your Direct Representatives"
815815+816816+#: letters/templates/letters/partials/suggestions.html:56
817817+msgid "These representatives directly represent your constituency:"
818818+msgstr "These representatives directly represent your constituency:"
819819+820820+#: letters/templates/letters/partials/suggestions.html:67
821821+#: letters/templates/letters/partials/suggestions.html:102
822822+msgid "Select"
823823+msgstr "Select"
824824+825825+#: letters/templates/letters/partials/suggestions.html:81
826826+msgid "Topic Experts"
827827+msgstr "Topic Experts"
828828+829829+#: letters/templates/letters/partials/suggestions.html:86
830830+#, python-format
831831+msgid ""
832832+"These representatives are experts on \"%(topic)s\" based on their committee "
833833+"memberships:"
834834+msgstr ""
835835+"These representatives are experts on \"%(topic)s\" based on their committee "
836836+"memberships:"
837837+838838+#: letters/templates/letters/partials/suggestions.html:95
839839+msgid "of"
840840+msgstr "of"
841841+842842+#: letters/templates/letters/partials/suggestions.html:116
843843+msgid "Suggested Representatives"
844844+msgstr "Suggested Representatives"
845845+846846+#: letters/templates/letters/partials/suggestions.html:119
847847+msgid ""
848848+"No representatives found. Representatives may need to be synced for this "
849849+"governmental level."
850850+msgstr ""
851851+"No representatives found. Representatives may need to be synced for this "
852852+"governmental level."
853853+854854+#: letters/templates/letters/partials/suggestions.html:148
855855+msgid "Selected:"
856856+msgstr "Selected:"
857857+858858+#: letters/templates/letters/password_reset_complete.html:4
859859+#: letters/templates/letters/password_reset_complete.html:8
860860+msgid "Password updated"
861861+msgstr "Password updated"
862862+863863+#: letters/templates/letters/password_reset_complete.html:9
864864+msgid "You can now sign in using your new password."
865865+msgstr "You can now sign in using your new password."
866866+867867+#: letters/templates/letters/password_reset_complete.html:10
868868+msgid "Go to login"
869869+msgstr "Go to login"
870870+871871+#: letters/templates/letters/password_reset_confirm.html:4
872872+#: letters/templates/letters/password_reset_confirm.html:9
873873+msgid "Choose a new password"
874874+msgstr "Choose a new password"
875875+876876+#: letters/templates/letters/password_reset_confirm.html:13
877877+msgid "New password"
878878+msgstr "New password"
879879+880880+#: letters/templates/letters/password_reset_confirm.html:20
881881+msgid "Confirm password"
882882+msgstr "Confirm password"
883883+884884+#: letters/templates/letters/password_reset_confirm.html:26
885885+msgid "Update password"
886886+msgstr "Update password"
887887+888888+#: letters/templates/letters/password_reset_confirm.html:29
889889+msgid "Reset link invalid"
890890+msgstr "Reset link invalid"
891891+892892+#: letters/templates/letters/password_reset_confirm.html:30
893893+msgid "This password reset link is no longer valid. Please request a new one."
894894+msgstr "This password reset link is no longer valid. Please request a new one."
895895+896896+#: letters/templates/letters/password_reset_confirm.html:31
897897+msgid "Request new link"
898898+msgstr "Request new link"
899899+900900+#: letters/templates/letters/password_reset_done.html:4
901901+msgid "Reset email sent"
902902+msgstr "Reset email sent"
903903+904904+#: letters/templates/letters/password_reset_done.html:9
905905+msgid ""
906906+"If an account exists for that email address, we just sent you instructions "
907907+"to choose a new password."
908908+msgstr ""
909909+"If an account exists for that email address, we just sent you instructions "
910910+"to choose a new password."
911911+912912+#: letters/templates/letters/password_reset_done.html:10
913913+msgid "The link will stay valid for a limited time."
914914+msgstr "The link will stay valid for a limited time."
915915+916916+#: letters/templates/letters/password_reset_done.html:11
917917+msgid "Back to login"
918918+msgstr "Back to login"
919919+920920+#: letters/templates/letters/password_reset_form.html:4
921921+msgid "Reset password"
922922+msgstr "Reset password"
923923+924924+#: letters/templates/letters/password_reset_form.html:8
925925+msgid "Reset your password"
926926+msgstr "Reset your password"
927927+928928+#: letters/templates/letters/password_reset_form.html:9
929929+msgid ""
930930+"Enter the email address you used during registration. We will send you a "
931931+"link to create a new password."
932932+msgstr ""
933933+"Enter the email address you used during registration. We will send you a "
934934+"link to create a new password."
935935+936936+#: letters/templates/letters/password_reset_form.html:17
937937+msgid "Send reset link"
938938+msgstr "Send reset link"
939939+940940+#: letters/templates/letters/profile.html:13
941941+#, python-format
942942+msgid "%(username)s's Profile"
943943+msgstr "%(username)s's Profile"
944944+945945+#: letters/templates/letters/profile.html:16
946946+msgid "Identity & Constituency"
947947+msgstr "Identity & Constituency"
948948+949949+#: letters/templates/letters/profile.html:19
950950+msgid "Status:"
951951+msgstr "Status:"
952952+953953+#: letters/templates/letters/profile.html:21
954954+msgid "Type:"
955955+msgstr "Type:"
956956+957957+#: letters/templates/letters/profile.html:28
958958+msgid ""
959959+"You self-declared your constituency. Representatives will see your "
960960+"signatures as self-declared constituents."
961961+msgstr ""
962962+"You self-declared your constituency. Representatives will see your "
963963+"signatures as self-declared constituents."
964964+965965+#: letters/templates/letters/profile.html:30
966966+msgid "Start third-party verification"
967967+msgstr "Start third-party verification"
968968+969969+#: letters/templates/letters/profile.html:33
970970+msgid ""
971971+"Your identity was verified via a third-party provider. Signatures will "
972972+"appear as verified constituents."
973973+msgstr ""
974974+"Your identity was verified via a third-party provider. Signatures will "
975975+"appear as verified constituents."
976976+977977+#: letters/templates/letters/profile.html:38
978978+msgid "Your verification is being processed."
979979+msgstr "Your verification is being processed."
980980+981981+#: letters/templates/letters/profile.html:39
982982+msgid "Complete Verification (Stub)"
983983+msgstr "Complete Verification (Stub)"
984984+985985+#: letters/templates/letters/profile.html:43
986986+msgid "Verification failed. Please try again or contact support."
987987+msgstr "Verification failed. Please try again or contact support."
988988+989989+#: letters/templates/letters/profile.html:48
990990+msgid ""
991991+"You can self-declare your constituency below or start a verification with a "
992992+"trusted provider. Verified signatures carry more weight."
993993+msgstr ""
994994+"You can self-declare your constituency below or start a verification with a "
995995+"trusted provider. Verified signatures carry more weight."
996996+997997+#: letters/templates/letters/profile.html:50
998998+msgid "Start Third-party Verification"
999999+msgstr "Start Third-party Verification"
10001000+10011001+#: letters/templates/letters/profile.html:55
10021002+msgid "Ihre Adresse"
10031003+msgstr "Ihre Adresse"
10041004+10051005+#: letters/templates/letters/profile.html:57
10061006+msgid ""
10071007+"Geben Sie Ihre vollstรคndige Adresse ein, um prรคzise Wahlkreis- und "
10081008+"Abgeordnetenempfehlungen zu erhalten."
10091009+msgstr ""
10101010+"Geben Sie Ihre vollstรคndige Adresse ein, um prรคzise Wahlkreis- und "
10111011+"Abgeordnetenempfehlungen zu erhalten."
10121012+10131013+#: letters/templates/letters/profile.html:61
10141014+msgid "Gespeicherte Adresse:"
10151015+msgstr "Gespeicherte Adresse:"
10161016+10171017+#: letters/templates/letters/profile.html:83
10181018+msgid "Adresse speichern"
10191019+msgstr "Adresse speichern"
10201020+10211021+#: letters/templates/letters/profile.html:88
10221022+msgid "Self-declare your constituency"
10231023+msgstr "Self-declare your constituency"
10241024+10251025+#: letters/templates/letters/profile.html:90
10261026+msgid ""
10271027+"Select the constituencies you live in so we can prioritise the right "
10281028+"representatives."
10291029+msgstr ""
10301030+"Select the constituencies you live in so we can prioritise the right "
10311031+"representatives."
10321032+10331033+#: letters/templates/letters/profile.html:109
10341034+msgid "Save constituencies"
10351035+msgstr "Save constituencies"
10361036+10371037+#: letters/templates/letters/profile.html:114
10381038+msgid "Your Letters"
10391039+msgstr "Your Letters"
10401040+10411041+#: letters/templates/letters/profile.html:120
10421042+msgid "You haven't written any letters yet."
10431043+msgstr "You haven't written any letters yet."
10441044+10451045+#: letters/templates/letters/profile.html:120
10461046+msgid "Write one now!"
10471047+msgstr "Write one now!"
10481048+10491049+#: letters/templates/letters/profile.html:125
10501050+msgid "Letters You've Signed"
10511051+msgstr "Letters You've Signed"
10521052+10531053+#: letters/templates/letters/profile.html:133
10541054+msgid "You haven't signed any letters yet."
10551055+msgstr "You haven't signed any letters yet."
10561056+10571057+#: letters/templates/letters/profile.html:133
10581058+msgid "Browse letters"
10591059+msgstr "Browse letters"
10601060+10611061+#: letters/templates/letters/profile.html:138
10621062+msgid "Account"
10631063+msgstr "Account"
10641064+10651065+#: letters/templates/letters/profile.html:140
10661066+msgid ""
10671067+"Need a fresh start? You can delete your account at any time. Your letters "
10681068+"stay visible but without your name."
10691069+msgstr ""
10701070+"Need a fresh start? You can delete your account at any time. Your letters "
10711071+"stay visible but without your name."
10721072+10731073+#: letters/templates/letters/register.html:9
10741074+msgid ""
10751075+"After registration we'll send you an email to confirm your address before "
10761076+"you can sign in."
10771077+msgstr ""
10781078+"After registration we'll send you an email to confirm your address before "
10791079+"you can sign in."
10801080+10811081+#: letters/templates/letters/register.html:23
10821082+msgid "Email:"
10831083+msgstr "Email:"
10841084+10851085+#: letters/templates/letters/register.html:31
10861086+msgid "First Name (optional):"
10871087+msgstr "First Name (optional):"
10881088+10891089+#: letters/templates/letters/register.html:39
10901090+msgid "Last Name (optional):"
10911091+msgstr "Last Name (optional):"
10921092+10931093+#: letters/templates/letters/register.html:55
10941094+msgid "Confirm Password:"
10951095+msgstr "Confirm Password:"
10961096+10971097+#: letters/templates/letters/register.html:70
10981098+msgid "Already have an account?"
10991099+msgstr "Already have an account?"
11001100+11011101+#: letters/templates/letters/register.html:70
11021102+msgid "Login here"
11031103+msgstr "Login here"
11041104+11051105+#: letters/templates/letters/representative_detail.html:19
11061106+msgid "รber"
11071107+msgstr "รber"
11081108+11091109+#: letters/templates/letters/representative_detail.html:30
11101110+msgid "Committee Memberships"
11111111+msgstr "Committee Memberships"
11121112+11131113+#: letters/templates/letters/representative_detail.html:75
11141114+msgid "Open Letters"
11151115+msgstr "Open Letters"
11161116+11171117+#: letters/templates/letters/representative_detail.html:83
11181118+msgid "No letters have been written to this representative yet."
11191119+msgstr "No letters have been written to this representative yet."
11201120+11211121+#: letters/templates/letters/representative_detail.html:85
11221122+msgid "Write the First Letter"
11231123+msgstr "Write the First Letter"
11241124+11251125+#: letters/templates/letters/representative_detail.html:95
11261126+msgid "External Resources"
11271127+msgstr "External Resources"
11281128+11291129+#: letters/templates/letters/representative_detail.html:100
11301130+msgid "Abgeordnetenwatch Profile"
11311131+msgstr "Abgeordnetenwatch Profile"
11321132+11331133+#: letters/templates/letters/representative_detail.html:102
11341134+msgid ""
11351135+"View voting record, questions, and detailed profile on Abgeordnetenwatch.de"
11361136+msgstr ""
11371137+"View voting record, questions, and detailed profile on Abgeordnetenwatch.de"
11381138+11391139+#: letters/templates/letters/representative_detail.html:112
11401140+msgid "Wikipedia Article"
11411141+msgstr "Wikipedia Article"
11421142+11431143+#: letters/templates/letters/representative_detail.html:114
11441144+msgid "Read more about this representative on Wikipedia"
11451145+msgstr "Read more about this representative on Wikipedia"
11461146+11471147+#: letters/templates/letters/representative_detail.html:117
11481148+msgid "View on Wikipedia"
11491149+msgstr "View on Wikipedia"
11501150+11511151+#: letters/templates/letters/representative_detail.html:123
11521152+msgid "No external resources available for this representative."
11531153+msgstr "No external resources available for this representative."
11541154+11551155+#: letters/templates/letters/representative_detail.html:130
11561156+msgid "Kontakt"
11571157+msgstr "Kontakt"
11581158+11591159+#: letters/templates/letters/representative_detail.html:145
11601160+#, python-format
11611161+msgid "Start a new open letter to %(name)s"
11621162+msgstr "Start a new open letter to %(name)s"
11631163+11641164+#: letters/templates/letters/representative_detail.html:153
11651165+msgid "Login to Write Letter"
11661166+msgstr "Login to Write Letter"
11671167+11681168+#: letters/views.py:52
11691169+msgid "Confirm your WriteThem.eu account"
11701170+msgstr "Confirm your WriteThem.eu account"
11711171+11721172+#: letters/views.py:184
11731173+msgid "Your letter has been published and your signature has been added!"
11741174+msgstr "Your letter has been published and your signature has been added!"
11751175+11761176+#: letters/views.py:197
11771177+msgid "You have already signed this letter."
11781178+msgstr "You have already signed this letter."
11791179+11801180+#: letters/views.py:207
11811181+msgid "Your signature has been added!"
11821182+msgstr "Your signature has been added!"
11831183+11841184+#: letters/views.py:225
11851185+msgid "Thank you for your report. Our team will review it."
11861186+msgstr "Thank you for your report. Our team will review it."
11871187+11881188+#: letters/views.py:259
11891189+msgid ""
11901190+"Please confirm your email address. We sent you a link to activate your "
11911191+"account."
11921192+msgstr ""
11931193+"Please confirm your email address. We sent you a link to activate your "
11941194+"account."
11951195+11961196+#: letters/views.py:289
11971197+msgid "Your account has been activated. You can now log in."
11981198+msgstr "Your account has been activated. You can now log in."
11991199+12001200+#: letters/views.py:292
12011201+msgid "Your account is already active."
12021202+msgstr "Your account is already active."
12031203+12041204+#: letters/views.py:347
12051205+msgid "Ihre Adresse wurde gespeichert."
12061206+msgstr "Ihre Adresse wurde gespeichert."
12071207+12081208+#: letters/views.py:363
12091209+msgid "Your constituency information has been updated."
12101210+msgstr "Your constituency information has been updated."
12111211+12121212+#: letters/views.py:391
12131213+msgid ""
12141214+"Your account has been deleted. Your published letters remain available to "
12151215+"the public."
12161216+msgstr ""
12171217+"Your account has been deleted. Your published letters remain available to "
12181218+"the public."