···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}
+4-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/.
89910## Architecture
1011- **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/`).
1112- **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.
1213- **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.
1414+- **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.
1515+- **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.
1416- **Identity & signatures**: `IdentityVerificationService` (stub) attaches address information to users; signature counts and verification badges are derived from the associated verification records.
1517- **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`).
1618- **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.
+83
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+```
···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.
+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
+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)