diff --git a/ERD.md b/ERD.md index 92f991e..a5aac2c 100644 --- a/ERD.md +++ b/ERD.md @@ -83,11 +83,19 @@ erDiagram string first_name string last_name text address + string address_street + string city + string state + string zip_code + string county + decimal latitude + decimal longitude string phone string email string district string precinct date registration_date + boolean is_targeted string candidate_support string yard_sign datetime created_at @@ -96,7 +104,6 @@ erDiagram VotingRecord { int id PK int voter_id FK - string participation_type date election_date string election_description string primary_party @@ -120,7 +127,6 @@ erDiagram Donation { int id PK int voter_id FK - string participation_type date date int method_id FK decimal amount @@ -129,7 +135,6 @@ erDiagram Interaction { int id PK int voter_id FK - string participation_type int type_id FK date date string description @@ -139,7 +144,6 @@ erDiagram VoterLikelihood { int id PK int voter_id FK - string participation_type int election_type_id FK string likelihood } diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 66bd230..c6c1cfd 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index 3b4f52c..486ed09 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 223fb5d..4c2240d 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index a3e9c96..5a7894e 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index fd154aa..d807a73 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index c79005e..5b46c21 100644 --- a/core/admin.py +++ b/core/admin.py @@ -62,9 +62,9 @@ class VoterLikelihoodInline(admin.TabularInline): @admin.register(Voter) class VoterAdmin(admin.ModelAdmin): - list_display = ('first_name', 'last_name', 'voter_id', 'tenant', 'district', 'candidate_support') - list_filter = ('tenant', 'candidate_support', 'yard_sign', 'district') - search_fields = ('first_name', 'last_name', 'voter_id') + list_display = ('first_name', 'last_name', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state') + list_filter = ('tenant', 'candidate_support', 'is_targeted', 'yard_sign', 'district', 'city', 'state') + search_fields = ('first_name', 'last_name', 'voter_id', 'address', 'city', 'state', 'zip_code', 'county') inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline] @admin.register(Event) @@ -75,4 +75,4 @@ class EventAdmin(admin.ModelAdmin): @admin.register(EventParticipation) class EventParticipationAdmin(admin.ModelAdmin): list_display = ('voter', 'event', 'participation_type') - list_filter = ('event__tenant', 'event', 'participation_type') + list_filter = ('event__tenant', 'event', 'participation_type') \ No newline at end of file diff --git a/core/forms.py b/core/forms.py index 2ec45de..472917b 100644 --- a/core/forms.py +++ b/core/forms.py @@ -5,23 +5,32 @@ class VoterForm(forms.ModelForm): class Meta: model = Voter fields = [ - 'first_name', 'last_name', 'address', 'phone', 'email', - 'voter_id', 'district', 'precinct', 'registration_date', - 'candidate_support', 'yard_sign' + 'first_name', 'last_name', 'address_street', 'city', 'state', + 'zip_code', 'county', 'latitude', 'longitude', + 'phone', 'email', 'voter_id', 'district', 'precinct', + 'registration_date', 'is_targeted', 'candidate_support', 'yard_sign' ] widgets = { 'registration_date': forms.DateInput(attrs={'type': 'date'}), - 'address': forms.Textarea(attrs={'rows': 2}), + 'latitude': forms.TextInput(attrs={'readonly': 'readonly', 'class': 'form-control bg-light'}), + 'longitude': forms.TextInput(attrs={'readonly': 'readonly', 'class': 'form-control bg-light'}), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - for field in self.fields.values(): - field.widget.attrs.update({'class': 'form-control'}) + for name, field in self.fields.items(): + if name in ['latitude', 'longitude']: + continue + if isinstance(field.widget, forms.CheckboxInput): + field.widget.attrs.update({'class': 'form-check-input'}) + else: + field.widget.attrs.update({'class': 'form-control'}) + self.fields['candidate_support'].widget.attrs.update({'class': 'form-select'}) self.fields['yard_sign'].widget.attrs.update({'class': 'form-select'}) class InteractionForm(forms.ModelForm): + # ... (rest of the file remains the same) class Meta: model = Interaction fields = ['type', 'date', 'description', 'notes'] @@ -109,4 +118,4 @@ class EventTypeForm(forms.ModelForm): for field in self.fields.values(): if not isinstance(field.widget, forms.CheckboxInput): field.widget.attrs.update({'class': 'form-control'}) - self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'}) + self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'}) \ No newline at end of file diff --git a/core/migrations/0006_voter_is_targeted.py b/core/migrations/0006_voter_is_targeted.py new file mode 100644 index 0000000..6018e1e --- /dev/null +++ b/core/migrations/0006_voter_is_targeted.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-24 16:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_eventparticipation_participation_type'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='is_targeted', + field=models.BooleanField(default=False), + ), + ] diff --git a/core/migrations/0007_voter_address_street_voter_city_voter_county_and_more.py b/core/migrations/0007_voter_address_street_voter_city_voter_county_and_more.py new file mode 100644 index 0000000..0671104 --- /dev/null +++ b/core/migrations/0007_voter_address_street_voter_city_voter_county_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.7 on 2026-01-24 16:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_voter_is_targeted'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='address_street', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='voter', + name='city', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='voter', + name='county', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='voter', + name='latitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + migrations.AddField( + model_name='voter', + name='longitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + migrations.AddField( + model_name='voter', + name='state', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='voter', + name='zip_code', + field=models.CharField(blank=True, max_length=20), + ), + ] diff --git a/core/migrations/__pycache__/0006_voter_is_targeted.cpython-311.pyc b/core/migrations/__pycache__/0006_voter_is_targeted.cpython-311.pyc new file mode 100644 index 0000000..89db3c4 Binary files /dev/null and b/core/migrations/__pycache__/0006_voter_is_targeted.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0007_voter_address_street_voter_city_voter_county_and_more.cpython-311.pyc b/core/migrations/__pycache__/0007_voter_address_street_voter_city_voter_county_and_more.cpython-311.pyc new file mode 100644 index 0000000..469c1a7 Binary files /dev/null and b/core/migrations/__pycache__/0007_voter_address_street_voter_city_voter_county_and_more.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index be81b57..bcb006b 100644 --- a/core/models.py +++ b/core/models.py @@ -1,6 +1,12 @@ from django.db import models from django.utils.text import slugify from django.contrib.auth.models import User +import urllib.request +import urllib.parse +import json +import logging + +logger = logging.getLogger(__name__) class Tenant(models.Model): name = models.CharField(max_length=255) @@ -93,16 +99,72 @@ class Voter(models.Model): first_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100) address = models.TextField(blank=True) + address_street = models.CharField(max_length=255, blank=True) + city = models.CharField(max_length=100, blank=True) + state = models.CharField(max_length=100, blank=True) + zip_code = models.CharField(max_length=20, blank=True) + county = models.CharField(max_length=100, blank=True) + latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) phone = models.CharField(max_length=20, blank=True) email = models.EmailField(blank=True) district = models.CharField(max_length=100, blank=True) precinct = models.CharField(max_length=100, blank=True) registration_date = models.DateField(null=True, blank=True) + is_targeted = models.BooleanField(default=False) candidate_support = models.CharField(max_length=20, choices=SUPPORT_CHOICES, default='unknown') yard_sign = models.CharField(max_length=20, choices=YARD_SIGN_CHOICES, default='none') created_at = models.DateTimeField(auto_now_add=True) + def geocode_address(self): + if not self.address: + return + + logger.info(f"Geocoding address: {self.address}") + try: + query = urllib.parse.quote(self.address) + url = f"https://nominatim.openstreetmap.org/search?q={query}&format=json&limit=1" + req = urllib.request.Request(url, headers={'User-Agent': 'FlatlogicVoterApp/1.1 (Campaign Management System; contact: info@example.com)'}) + with urllib.request.urlopen(req, timeout=10) as response: + data = json.loads(response.read().decode()) + if data: + self.latitude = data[0]['lat'] + self.longitude = data[0]['lon'] + logger.info(f"Geocoding success: {self.latitude}, {self.longitude}") + else: + logger.warning(f"Geocoding returned no results for: {self.address}") + except Exception as e: + logger.error(f"Geocoding error for {self.address}: {e}") + + def save(self, *args, **kwargs): + # Auto concatenation: address street, city, state, zip + parts = [self.address_street, self.city, self.state, self.zip_code] + self.address = ", ".join([p for p in parts if p]) + + # Change detection + should_geocode = False + if not self.pk: + # New record + should_geocode = True + else: + orig = Voter.objects.get(pk=self.pk) + # If any address component changed + if (self.address_street != orig.address_street or + self.city != orig.city or + self.state != orig.state or + self.zip_code != orig.zip_code): + should_geocode = True + + # If coordinates are missing + if self.latitude is None or self.longitude is None: + should_geocode = True + + if should_geocode and self.address: + self.geocode_address() + + super().save(*args, **kwargs) + def __str__(self): return f"{self.first_name} {self.last_name}" @@ -171,4 +233,4 @@ class VoterLikelihood(models.Model): unique_together = ('voter', 'election_type') def __str__(self): - return f"{self.voter} - {self.election_type}: {self.get_likelihood_display()}" + return f"{self.voter} - {self.election_type}: {self.get_likelihood_display()}" \ No newline at end of file diff --git a/core/templates/core/voter_detail.html b/core/templates/core/voter_detail.html index 6432bb7..6a94a8a 100644 --- a/core/templates/core/voter_detail.html +++ b/core/templates/core/voter_detail.html @@ -2,6 +2,11 @@ {% load static %} {% block content %} + + + + +
+ {% if voter.is_targeted %} +
Targeted Voter
+ {% endif %} {% if voter.candidate_support == 'supporting' %}
Supporting
{% elif voter.candidate_support == 'not_supporting' %} @@ -53,6 +61,22 @@
+ +
+
+
Location Map
+
+
+ {% if not voter.latitude or not voter.longitude %} +
+ +

No coordinates available for this address.

+

Edit profile to trigger auto-geocoding.

+
+ {% endif %} +
+
+
Contact Information
@@ -333,7 +357,7 @@
-
+ {% csrf_token %}