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 @@
+ {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} + {% block content %}{% endblock %}
diff --git a/core/templates/core/index.html b/core/templates/core/index.html index 7be5adc..db73f03 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -33,28 +33,84 @@

Dashboard: {{ selected_tenant.name }}

- Switch Campaign -
-
- -
-
-
-
Voters Registry
-

{{ selected_tenant.voters.count }}

- View Registry +
+
+ + +
+ Switch Campaign
-
+ +
+
+
+
+
Registered Voters
+

{{ metrics.total_registered_voters }}

+
+
+
+
+
+
+
Target Voters
+

{{ metrics.total_target_voters }}

+
+
+
+
+
+
+
Supporting
+

{{ metrics.total_supporting }}

+
+
+
+
+
+
+
Voter Addresses
+

{{ metrics.total_voter_addresses }}

+
+
+
+
+
+
+
Door Visits
+

{{ metrics.total_door_visits }}

+
+
+
+
+
+
+
Signs (Wants/Has)
+

{{ metrics.total_signs }}

+
+
+
+
+
+
+
Total Donations
+

${{ metrics.total_donations|floatformat:2 }}

+
+
+
+
+ +
-
-
Quick Voter Search
-
- - -
+
+
+
Voter Management
+

Access the full registry to manage individual voter profiles.

+
+ View Registry
diff --git a/core/templates/core/voter_detail.html b/core/templates/core/voter_detail.html index 6a94a8a..3a3412a 100644 --- a/core/templates/core/voter_detail.html +++ b/core/templates/core/voter_detail.html @@ -2,10 +2,8 @@ {% load static %} {% block content %} - - - - + +
@@ -157,7 +155,7 @@
- +
-
+
{{ voter_form.county }}
-
+
{{ voter_form.latitude }}
-
+
{{ voter_form.longitude }}
@@ -882,16 +880,25 @@ {% if voter.latitude and voter.longitude %} // Initialize Map try { - var map = L.map('voterMap').setView([{{ voter.latitude }}, {{ voter.longitude }}], 15); - L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 19, - attribution: '© OpenStreetMap' - }).addTo(map); - L.marker([{{ voter.latitude }}, {{ voter.longitude }}]).addTo(map) - .bindPopup('{{ voter.first_name }} {{ voter.last_name }}
{{ voter.address_street }}') - .openPopup(); + const position = { lat: {{ voter.latitude }}, lng: {{ voter.longitude }} }; + const map = new google.maps.Map(document.getElementById("voterMap"), { + zoom: 15, + center: position, + }); + const marker = new google.maps.Marker({ + position: position, + map: map, + title: "{{ voter.first_name }} {{ voter.last_name }}", + }); + const infowindow = new google.maps.InfoWindow({ + content: "{{ voter.first_name }} {{ voter.last_name }}
{{ voter.address_street }}", + }); + marker.addListener("click", () => { + infowindow.open(map, marker); + }); + infowindow.open(map, marker); } catch (e) { - console.error("Leaflet map initialization failed:", e); + console.error("Google Maps initialization failed:", e); } {% endif %} @@ -934,7 +941,7 @@ .then(data => { if (data.success) { document.querySelector('[name="latitude"]').value = data.latitude; - document.querySelector('[name="longitude"]').value = data.longitude; + document.querySelector('[name="longitude"]').value = String(data.longitude).substring(0, 12); statusDiv.innerHTML = 'Coordinates updated!'; } else { statusDiv.innerHTML = '' + (data.error || 'Geocoding failed.') + ''; @@ -954,3 +961,4 @@ }); {% endblock %} + diff --git a/core/views.py b/core/views.py index 0641876..80e7a0c 100644 --- a/core/views.py +++ b/core/views.py @@ -1,10 +1,13 @@ from django.http import JsonResponse from django.urls import reverse from django.shortcuts import render, redirect, get_object_or_404 -from django.db.models import Q +from django.db.models import Q, Sum from django.contrib import messages from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, EventTypeForm +import logging + +logger = logging.getLogger(__name__) def index(request): """ @@ -14,12 +17,26 @@ def index(request): tenants = Tenant.objects.all() selected_tenant_id = request.session.get('tenant_id') selected_tenant = None + metrics = {} + if selected_tenant_id: selected_tenant = Tenant.objects.filter(id=selected_tenant_id).first() + if selected_tenant: + voters = selected_tenant.voters.all() + metrics = { + "total_registered_voters": voters.count(), + "total_target_voters": voters.filter(is_targeted=True).count(), + "total_supporting": voters.filter(candidate_support="supporting").count(), + "total_voter_addresses": voters.values("address").distinct().count(), + "total_door_visits": Interaction.objects.filter(voter__tenant=selected_tenant, type__name="Door Visit").count(), + "total_signs": voters.filter(Q(yard_sign="wants") | Q(yard_sign="has")).count(), + "total_donations": Donation.objects.filter(voter__tenant=selected_tenant).aggregate(total=Sum("amount"))["total"] or 0, + } context = { 'tenants': tenants, 'selected_tenant': selected_tenant, + 'metrics': metrics, } return render(request, 'core/index.html', context) @@ -108,11 +125,32 @@ def voter_edit(request, voter_id): voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) if request.method == 'POST': + # Log incoming coordinate data for debugging + lat_raw = request.POST.get('latitude') + lon_raw = request.POST.get('longitude') + logger.info(f"Voter Edit POST: lat={lat_raw}, lon={lon_raw}") + form = VoterForm(request.POST, instance=voter) if form.is_valid(): - form.save() + # If coordinates were provided in POST, ensure they are applied to the instance + # This handles cases where readonly or other widget settings might interfere + voter = form.save(commit=False) + if lat_raw: + try: + voter.latitude = lat_raw + except: pass + if lon_raw: + try: + voter.longitude = lon_raw + except: pass + + voter.save() messages.success(request, "Voter profile updated successfully.") - return redirect('voter_detail', voter_id=voter.id) + else: + logger.warning(f"Voter Edit Form Invalid: {form.errors}") + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f"Error in {field}: {error}") return redirect('voter_detail', voter_id=voter.id) def add_interaction(request, voter_id): @@ -352,10 +390,16 @@ def voter_geocode(request, voter_id): full_address = ", ".join([p for p in parts if p]) # Use a temporary instance to avoid saving until the user clicks "Save" in the modal - temp_voter = Voter(address=full_address) - temp_voter.geocode_address() + temp_voter = Voter( + address_street=street, + city=city, + state=state, + zip_code=zip_code, + address=full_address + ) + success, error_msg = temp_voter.geocode_address() - if temp_voter.latitude and temp_voter.longitude: + if success: return JsonResponse({ 'success': True, 'latitude': str(temp_voter.latitude), @@ -365,7 +409,7 @@ def voter_geocode(request, voter_id): else: return JsonResponse({ 'success': False, - 'error': 'Geocoding failed. Please check the address.' + 'error': f"Geocoding failed: {error_msg or 'No results found.'}" }) return JsonResponse({'success': False, 'error': 'Invalid request method.'}) diff --git a/test_manual_save_v2.py b/test_manual_save_v2.py new file mode 100644 index 0000000..ce510c7 --- /dev/null +++ b/test_manual_save_v2.py @@ -0,0 +1,64 @@ +import os +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +django.setup() + +from core.models import Voter, Tenant +from decimal import Decimal + +# Ensure we have a tenant +tenant, _ = Tenant.objects.get_or_create(name="Test Tenant", slug="test-tenant") + +# 1. Create a voter with no coordinates +voter = Voter.objects.create( + tenant=tenant, + first_name="Manual", + last_name="Test", + address_street="1600 Amphitheatre Parkway", + city="Mountain View", + state="CA", + zip_code="94043" +) + +print(f"Initial voter: {voter.first_name} {voter.last_name}") +print(f"Coordinates: {voter.latitude}, {voter.longitude}") + +# 2. Simulate manual geocode (updating latitude/longitude before save) +manual_lat = Decimal("37.422476400") +manual_lon = Decimal("-122.084249900") + +# Simulate what happens in voter_edit view +# The form instance will have the new coordinates from request.POST +voter.latitude = manual_lat +voter.longitude = manual_lon + +print(f"Updating coordinates manually to: {voter.latitude}, {voter.longitude}") +voter.save() + +# 3. Reload from DB and verify +voter.refresh_from_db() +print(f"After save, coordinates in DB: {voter.latitude}, {voter.longitude}") + +if voter.latitude == manual_lat and voter.longitude == manual_lon: + print("SUCCESS: Manual coordinates preserved.") +else: + print("FAILURE: Manual coordinates overwritten or not saved.") + +# 4. Now test if changing address but NOT coordinates triggers geocode (which might overwrite) +voter.address_street = "1 Infinite Loop" +voter.city = "Cupertino" +# Note: we are NOT changing latitude/longitude here, so coords_provided should be False in save() +print(f"Changing address to {voter.address_street} but keeping coordinates.") +voter.save() + +voter.refresh_from_db() +print(f"After address change, coordinates in DB: {voter.latitude}, {voter.longitude}") +# It should have updated coordinates to Apple's HQ if geocoding worked, +# OR at least it shouldn't be the old Google HQ coordinates if it triggered geocode. +# Actually, it SHOULD have triggered geocode. + +if voter.latitude != manual_lat: + print("SUCCESS: Auto-geocode triggered for address change.") +else: + print("INFO: Auto-geocode might not have triggered or returned same coordinates (unlikely).")