Autosave: 20260124-205044

This commit is contained in:
Flatlogic Bot 2026-01-24 20:50:44 +00:00
parent 14bf8d7295
commit 7686b1143d
17 changed files with 337 additions and 22 deletions

12
ERD.md
View File

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

View File

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

View File

@ -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():
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']

View File

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

View File

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

View File

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

View File

@ -2,6 +2,11 @@
{% 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>
<div class="container py-5">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
@ -38,6 +43,9 @@
</div>
</div>
<div class="col-md-auto text-md-end mt-3 mt-md-0">
{% if voter.is_targeted %}
<div class="badge bg-primary mb-2 px-3 py-2 text-uppercase fw-bold" style="font-size: 0.75rem;"><i class="bi bi-bullseye me-2"></i>Targeted Voter</div>
{% endif %}
{% if voter.candidate_support == 'supporting' %}
<div class="h4 text-success mb-0"><i class="bi bi-check-circle-fill me-2"></i>Supporting</div>
{% elif voter.candidate_support == 'not_supporting' %}
@ -53,6 +61,22 @@
<div class="row">
<!-- Left Column: Quick Stats & Likelihood -->
<div class="col-lg-4">
<!-- Map Card -->
<div class="card border-0 shadow-sm mb-4 overflow-hidden">
<div class="card-header bg-white py-3">
<h5 class="card-title mb-0">Location Map</h5>
</div>
<div id="voterMap" style="height: 250px; background-color: #f8f9fa;">
{% if not voter.latitude or not voter.longitude %}
<div class="d-flex align-items-center justify-content-center h-100 flex-column text-muted p-4 text-center">
<i class="bi bi-map mb-2" style="font-size: 2rem;"></i>
<p class="small mb-0">No coordinates available for this address.</p>
<p class="small mb-0">Edit profile to trigger auto-geocoding.</p>
</div>
{% endif %}
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white py-3">
<h5 class="card-title mb-0">Contact Information</h5>
@ -333,7 +357,7 @@
<h5 class="modal-title" id="editVoterModalLabel">Edit Voter Profile</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'voter_edit' voter.id %}" method="POST">
<form id="editVoterForm" action="{% url 'voter_edit' voter.id %}" method="POST">
{% csrf_token %}
<div class="modal-body p-4">
<div class="row">
@ -346,8 +370,38 @@
{{ voter_form.last_name }}
</div>
<div class="col-12 mb-3">
<label class="form-label fw-medium">{{ voter_form.address.label }}</label>
{{ voter_form.address }}
<label class="form-label fw-medium">Address Street</label>
{{ voter_form.address_street }}
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">City</label>
{{ voter_form.city }}
</div>
<div class="col-md-3 mb-3">
<label class="form-label fw-medium">State</label>
{{ voter_form.state }}
</div>
<div class="col-md-3 mb-3">
<label class="form-label fw-medium">Zip Code</label>
{{ voter_form.zip_code }}
</div>
<div class="col-12 mb-4">
<button type="button" id="manualGeocodeBtn" class="btn btn-outline-primary w-100 fw-bold py-2 shadow-sm">
<i class="bi bi-geo-alt-fill me-2"></i>Manual Geocode Address
</button>
<div id="geocodeStatus" class="small mt-2 text-center" style="display: none;"></div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">County</label>
{{ voter_form.county }}
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">Latitude</label>
{{ voter_form.latitude }}
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">Longitude</label>
{{ voter_form.longitude }}
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">{{ voter_form.phone.label }}</label>
@ -373,6 +427,14 @@
<label class="form-label fw-medium">{{ voter_form.registration_date.label }}</label>
{{ voter_form.registration_date }}
</div>
<div class="col-md-6 mb-3 d-flex align-items-center">
<div class="form-check">
{{ voter_form.is_targeted }}
<label class="form-check-label fw-medium" for="{{ voter_form.is_targeted.id_for_label }}">
Targeted Voter
</label>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">{{ voter_form.candidate_support.label }}</label>
{{ voter_form.candidate_support }}
@ -816,6 +878,79 @@
tab.show();
}
}
{% 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();
} catch (e) {
console.error("Leaflet map initialization failed:", e);
}
{% endif %}
// Manual Geocode Logic
const geocodeBtn = document.getElementById('manualGeocodeBtn');
const statusDiv = document.getElementById('geocodeStatus');
if (geocodeBtn) {
geocodeBtn.addEventListener('click', function() {
const street = document.querySelector('[name="address_street"]').value;
const city = document.querySelector('[name="city"]').value;
const state = document.querySelector('[name="state"]').value;
const zip = document.querySelector('[name="zip_code"]').value;
if (!street && !city) {
statusDiv.innerHTML = '<span class="text-danger">Please enter at least a street or city.</span>';
statusDiv.style.display = 'block';
return;
}
geocodeBtn.disabled = true;
geocodeBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Geocoding...';
statusDiv.style.display = 'none';
const formData = new FormData();
formData.append('address_street', street);
formData.append('city', city);
formData.append('state', state);
formData.append('zip_code', zip);
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
fetch('{% url "voter_geocode" voter.id %}', {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.querySelector('[name="latitude"]').value = data.latitude;
document.querySelector('[name="longitude"]').value = data.longitude;
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>';
}
})
.catch(error => {
console.error('Error:', error);
statusDiv.innerHTML = '<span class="text-danger">An error occurred during geocoding.</span>';
})
.finally(() => {
geocodeBtn.disabled = false;
geocodeBtn.innerHTML = '<i class="bi bi-geo-alt-fill me-2"></i>Manual Geocode Address';
statusDiv.style.display = 'block';
});
});
}
});
</script>
{% endblock %}

View File

@ -7,6 +7,7 @@ urlpatterns = [
path('voters/', views.voter_list, name='voter_list'),
path('voters/<int:voter_id>/', views.voter_detail, name='voter_detail'),
path('voters/<int:voter_id>/edit/', views.voter_edit, name='voter_edit'),
path('voters/<int:voter_id>/geocode/', views.voter_geocode, name='voter_geocode'),
path('voters/<int:voter_id>/interaction/add/', views.add_interaction, name='add_interaction'),
path('interaction/<int:interaction_id>/edit/', views.edit_interaction, name='edit_interaction'),

View File

@ -1,3 +1,4 @@
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
@ -332,3 +333,39 @@ def event_type_delete(request, type_id):
except Exception as e:
messages.error(request, f"Cannot delete event type: {e}")
return redirect('event_type_list')
def voter_geocode(request, voter_id):
"""
Manually trigger geocoding for a voter, potentially using values from the request.
"""
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
if request.method == 'POST':
street = request.POST.get('address_street', voter.address_street)
city = request.POST.get('city', voter.city)
state = request.POST.get('state', voter.state)
zip_code = request.POST.get('zip_code', voter.zip_code)
parts = [street, city, state, zip_code]
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()
if temp_voter.latitude and temp_voter.longitude:
return JsonResponse({
'success': True,
'latitude': str(temp_voter.latitude),
'longitude': str(temp_voter.longitude),
'address': full_address
})
else:
return JsonResponse({
'success': False,
'error': 'Geocoding failed. Please check the address.'
})
return JsonResponse({'success': False, 'error': 'Invalid request method.'})

View File

@ -1,3 +1,4 @@
Django==5.2.7
mysqlclient==2.2.7
python-dotenv==1.1.1
geopy==2.4.1