Autosave: 20260124-224650

This commit is contained in:
Flatlogic Bot 2026-01-24 22:46:50 +00:00
parent 7686b1143d
commit 14a93b6b2b
16 changed files with 334 additions and 72 deletions

View File

@ -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")

View File

@ -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', ''),
}

View File

@ -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):

View File

@ -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),
),
]

View File

@ -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)

View File

@ -55,6 +55,17 @@
</nav>
<main>
{% if messages %}
<div class="container mt-3">
{% for message in messages %}
<div class="alert alert-{% if message.tags == 'error' %}danger{% else %}{{ message.tags }}{% endif %} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>

View File

@ -33,28 +33,84 @@
<div class="col-12 mb-4">
<div class="d-flex justify-content-between align-items-center">
<h1 class="h2 fw-bold">Dashboard: <span class="text-emerald">{{ selected_tenant.name }}</span></h1>
<a href="{% url 'index' %}" class="btn btn-outline-secondary btn-sm">Switch Campaign</a>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<h5 class="card-title text-muted">Voters Registry</h5>
<p class="card-text display-6 fw-bold">{{ selected_tenant.voters.count }}</p>
<a href="{% url 'voter_list' %}" class="btn btn-sm btn-emerald mt-2">View Registry</a>
<div class="d-flex align-items-center">
<form action="{% url 'voter_list' %}" method="GET" class="d-flex me-3">
<input type="text" name="q" class="form-control form-control-sm me-2" placeholder="Quick Search..." required>
<button type="submit" class="btn btn-emerald btn-sm px-3">Search</button>
</form>
<a href="{% url 'index' %}" class="btn btn-outline-secondary btn-sm">Switch Campaign</a>
</div>
</div>
</div>
<div class="col-md-8 mb-4">
<!-- Metrics Cards -->
<div class="row g-4 mb-4">
<div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center p-3">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Registered Voters</h6>
<p class="display-6 fw-bold mb-0 text-dark">{{ metrics.total_registered_voters }}</p>
</div>
</div>
</div>
<div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center p-3">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Target Voters</h6>
<p class="display-6 fw-bold mb-0 text-primary">{{ metrics.total_target_voters }}</p>
</div>
</div>
</div>
<div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center p-3">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Supporting</h6>
<p class="display-6 fw-bold mb-0 text-success">{{ metrics.total_supporting }}</p>
</div>
</div>
</div>
<div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center p-3">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Voter Addresses</h6>
<p class="display-6 fw-bold mb-0 text-info">{{ metrics.total_voter_addresses }}</p>
</div>
</div>
</div>
<div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center p-3">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Door Visits</h6>
<p class="display-6 fw-bold mb-0 text-warning">{{ metrics.total_door_visits }}</p>
</div>
</div>
</div>
<div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center p-3">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Signs (Wants/Has)</h6>
<p class="display-6 fw-bold mb-0 text-danger">{{ metrics.total_signs }}</p>
</div>
</div>
</div>
<div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center p-3">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Total Donations</h6>
<p class="display-6 fw-bold mb-0 text-emerald">${{ metrics.total_donations|floatformat:2 }}</p>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h5 class="card-title mb-4">Quick Voter Search</h5>
<form action="{% url 'voter_list' %}" method="GET" class="d-flex">
<input type="text" name="q" class="form-control me-2" placeholder="Search by name or voter ID..." required>
<button type="submit" class="btn btn-emerald px-4">Search</button>
</form>
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h5 class="fw-bold mb-1">Voter Management</h5>
<p class="text-muted mb-0">Access the full registry to manage individual voter profiles.</p>
</div>
<a href="{% url 'voter_list' %}" class="btn btn-emerald px-4">View Registry</a>
</div>
</div>
</div>

View File

@ -2,10 +2,8 @@
{% load static %}
{% block content %}
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<!-- Google Maps JS -->
<script src="https://maps.googleapis.com/maps/api/js?key={{ GOOGLE_MAPS_API_KEY }}"></script>
<div class="container py-5">
<!-- Breadcrumb -->
@ -157,7 +155,7 @@
</div>
</div>
<!-- Right Column: Timeline & Detailed Records -->
<!-- Right Column: Detailed Records -->
<div class="col-lg-8">
<!-- Nav Tabs -->
<ul class="nav nav-tabs border-0 mb-4" id="voterTabs" role="tablist">
@ -391,15 +389,15 @@
</button>
<div id="geocodeStatus" class="small mt-2 text-center" style="display: none;"></div>
</div>
<div class="col-md-4 mb-3">
<div class="col-md-12 mb-3">
<label class="form-label fw-medium">County</label>
{{ voter_form.county }}
</div>
<div class="col-md-4 mb-3">
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">Latitude</label>
{{ voter_form.latitude }}
</div>
<div class="col-md-4 mb-3">
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">Longitude</label>
{{ voter_form.longitude }}
</div>
@ -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: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
L.marker([{{ voter.latitude }}, {{ voter.longitude }}]).addTo(map)
.bindPopup('{{ voter.first_name }} {{ voter.last_name }}<br>{{ 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: "<strong>{{ voter.first_name }} {{ voter.last_name }}</strong><br>{{ 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 = '<span class="text-success fw-bold"><i class="bi bi-check-circle me-1"></i>Coordinates updated!</span>';
} else {
statusDiv.innerHTML = '<span class="text-danger">' + (data.error || 'Geocoding failed.') + '</span>';
@ -954,3 +961,4 @@
});
</script>
{% endblock %}

View File

@ -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.'})

64
test_manual_save_v2.py Normal file
View File

@ -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).")