diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc
index c8bcb91..de0615d 100644
Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ
diff --git a/config/settings.py b/config/settings.py
index 291d043..e1e7409 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -180,3 +180,4 @@ if EMAIL_USE_SSL:
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+GOOGLE_MAPS_API_KEY = os.getenv("GOOGLE_MAPS_API_KEY", "AIzaSyAluZTEjH-RSiGJUHnfrSqWbcAXCGzGOq4")
diff --git a/core/__pycache__/context_processors.cpython-311.pyc b/core/__pycache__/context_processors.cpython-311.pyc
index 3df58b8..3282f43 100644
Binary files a/core/__pycache__/context_processors.cpython-311.pyc and b/core/__pycache__/context_processors.cpython-311.pyc differ
diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc
index 486ed09..c88bd47 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 4c2240d..ccc4975 100644
Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ
diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc
index d807a73..935de85 100644
Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ
diff --git a/core/context_processors.py b/core/context_processors.py
index 0bf87c3..dd5df10 100644
--- a/core/context_processors.py
+++ b/core/context_processors.py
@@ -1,5 +1,6 @@
import os
import time
+from django.conf import settings
def project_context(request):
"""
@@ -10,4 +11,5 @@ def project_context(request):
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
# Used for cache-busting static assets
"deployment_timestamp": int(time.time()),
- }
+ "GOOGLE_MAPS_API_KEY": getattr(settings, 'GOOGLE_MAPS_API_KEY', ''),
+ }
\ No newline at end of file
diff --git a/core/forms.py b/core/forms.py
index 472917b..6ce5422 100644
--- a/core/forms.py
+++ b/core/forms.py
@@ -12,8 +12,8 @@ class VoterForm(forms.ModelForm):
]
widgets = {
'registration_date': forms.DateInput(attrs={'type': 'date'}),
- 'latitude': forms.TextInput(attrs={'readonly': 'readonly', 'class': 'form-control bg-light'}),
- 'longitude': forms.TextInput(attrs={'readonly': 'readonly', 'class': 'form-control bg-light'}),
+ 'latitude': forms.TextInput(attrs={'class': 'form-control bg-light'}),
+ 'longitude': forms.TextInput(attrs={'class': 'form-control bg-light'}),
}
def __init__(self, *args, **kwargs):
diff --git a/core/migrations/0008_alter_voter_latitude_alter_voter_longitude.py b/core/migrations/0008_alter_voter_latitude_alter_voter_longitude.py
new file mode 100644
index 0000000..c55e947
--- /dev/null
+++ b/core/migrations/0008_alter_voter_latitude_alter_voter_longitude.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2.7 on 2026-01-24 21:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0007_voter_address_street_voter_city_voter_county_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='voter',
+ name='latitude',
+ field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True),
+ ),
+ migrations.AlterField(
+ model_name='voter',
+ name='longitude',
+ field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True),
+ ),
+ ]
diff --git a/core/migrations/__pycache__/0008_alter_voter_latitude_alter_voter_longitude.cpython-311.pyc b/core/migrations/__pycache__/0008_alter_voter_latitude_alter_voter_longitude.cpython-311.pyc
new file mode 100644
index 0000000..dc44a34
Binary files /dev/null and b/core/migrations/__pycache__/0008_alter_voter_latitude_alter_voter_longitude.cpython-311.pyc differ
diff --git a/core/models.py b/core/models.py
index bcb006b..7a0450d 100644
--- a/core/models.py
+++ b/core/models.py
@@ -1,6 +1,8 @@
+from decimal import Decimal
from django.db import models
from django.utils.text import slugify
from django.contrib.auth.models import User
+from django.conf import settings
import urllib.request
import urllib.parse
import json
@@ -104,8 +106,8 @@ class Voter(models.Model):
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)
+ latitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True)
+ longitude = models.DecimalField(max_digits=12, decimal_places=9, 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)
@@ -117,27 +119,69 @@ class Voter(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
- def geocode_address(self):
+ def geocode_address(self, use_fallback=True):
+ """
+ Attempts to geocode the voter's address using Google Maps API.
+ Returns (success, error_message).
+ """
if not self.address:
- return
+ return False, "No address provided."
- 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}")
+ api_key = getattr(settings, 'GOOGLE_MAPS_API_KEY', None)
+ if not api_key:
+ return False, "Google Maps API Key not configured."
+
+ def _fetch(addr):
+ try:
+ query = urllib.parse.quote(addr)
+ url = f"https://maps.googleapis.com/maps/api/geocode/json?address={query}&key={api_key}"
+ req = urllib.request.Request(url)
+ with urllib.request.urlopen(req, timeout=10) as response:
+ data = json.loads(response.read().decode())
+ if data.get('status') == 'OK':
+ result = data['results'][0]
+ return result['geometry']['location']['lat'], result['geometry']['location']['lng'], None
+ elif data.get('status') == 'ZERO_RESULTS':
+ return None, None, "No results found."
+ elif data.get('status') == 'OVER_QUERY_LIMIT':
+ return None, None, "Query limit exceeded."
+ elif data.get('status') == 'REQUEST_DENIED':
+ return None, None, f"Request denied: {data.get('error_message', 'No message')}"
+ elif data.get('status') == 'INVALID_REQUEST':
+ return None, None, "Invalid request."
+ else:
+ return None, None, f"Google Maps Error: {data.get('status')}"
+ except Exception as e:
+ return None, None, str(e)
+
+ logger.info(f"Geocoding with Google Maps: {self.address}")
+ lat, lon, err = _fetch(self.address)
+
+ if not lat and use_fallback:
+ # Try fallback: City, State, Zip
+ fallback_parts = [self.city, self.state, self.zip_code]
+ fallback_addr = ", ".join([p for p in fallback_parts if p])
+ if fallback_addr and fallback_addr != self.address:
+ logger.info(f"Geocoding fallback: {fallback_addr}")
+ lat, lon, fallback_err = _fetch(fallback_addr)
+ if lat:
+ err = None # Clear previous error if fallback works
+
+ if lat and lon:
+ self.latitude = lat
+ # Truncate longitude to 12 characters as requested
+ self.longitude = Decimal(str(lon)[:12])
+ logger.info(f"Geocoding success: {lat}, {self.longitude}")
+ return True, None
+
+ logger.warning(f"Geocoding failed for {self.address}: {err}")
+ return False, err
def save(self, *args, **kwargs):
+ # Ensure longitude is truncated to 12 characters before saving
+ if self.longitude:
+ self.longitude = Decimal(str(self.longitude)[:12])
+
# 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])
@@ -146,21 +190,30 @@ class Voter(models.Model):
should_geocode = False
if not self.pk:
# New record
- should_geocode = True
+ # Only auto-geocode if coordinates were not already provided
+ if self.latitude is None or self.longitude is None:
+ 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):
+ # Detect if address components changed
+ address_changed = (self.address_street != orig.address_street or
+ self.city != orig.city or
+ self.state != orig.state or
+ self.zip_code != orig.zip_code)
+
+ # Detect if coordinates were changed in this transaction (e.g., from a form)
+ coords_provided = (self.latitude != orig.latitude or self.longitude != orig.longitude)
+
+ # Auto-geocode if address changed AND coordinates were NOT manually updated
+ if address_changed and not coords_provided:
should_geocode = True
- # If coordinates are missing
- if self.latitude is None or self.longitude is None:
+ # Auto-geocode if coordinates are still missing and were not just provided
+ if (self.latitude is None or self.longitude is None) and not coords_provided:
should_geocode = True
if should_geocode and self.address:
+ # We don't want to block save if geocoding fails, so we just call it
self.geocode_address()
super().save(*args, **kwargs)
diff --git a/core/templates/base.html b/core/templates/base.html
index a1fe79a..1fed896 100644
--- a/core/templates/base.html
+++ b/core/templates/base.html
@@ -55,6 +55,17 @@
{{ metrics.total_registered_voters }}
+{{ metrics.total_target_voters }}
+{{ metrics.total_supporting }}
+{{ metrics.total_voter_addresses }}
+{{ metrics.total_door_visits }}
+{{ metrics.total_signs }}
+${{ metrics.total_donations|floatformat:2 }}
+Access the full registry to manage individual voter profiles.
+