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 first_name
string last_name string last_name
text address text address
string address_street
string city
string state
string zip_code
string county
decimal latitude
decimal longitude
string phone string phone
string email string email
string district string district
string precinct string precinct
date registration_date date registration_date
boolean is_targeted
string candidate_support string candidate_support
string yard_sign string yard_sign
datetime created_at datetime created_at
@ -96,7 +104,6 @@ erDiagram
VotingRecord { VotingRecord {
int id PK int id PK
int voter_id FK int voter_id FK
string participation_type
date election_date date election_date
string election_description string election_description
string primary_party string primary_party
@ -120,7 +127,6 @@ erDiagram
Donation { Donation {
int id PK int id PK
int voter_id FK int voter_id FK
string participation_type
date date date date
int method_id FK int method_id FK
decimal amount decimal amount
@ -129,7 +135,6 @@ erDiagram
Interaction { Interaction {
int id PK int id PK
int voter_id FK int voter_id FK
string participation_type
int type_id FK int type_id FK
date date date date
string description string description
@ -139,7 +144,6 @@ erDiagram
VoterLikelihood { VoterLikelihood {
int id PK int id PK
int voter_id FK int voter_id FK
string participation_type
int election_type_id FK int election_type_id FK
string likelihood string likelihood
} }

View File

@ -62,9 +62,9 @@ class VoterLikelihoodInline(admin.TabularInline):
@admin.register(Voter) @admin.register(Voter)
class VoterAdmin(admin.ModelAdmin): class VoterAdmin(admin.ModelAdmin):
list_display = ('first_name', 'last_name', 'voter_id', 'tenant', 'district', 'candidate_support') list_display = ('first_name', 'last_name', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state')
list_filter = ('tenant', 'candidate_support', 'yard_sign', 'district') list_filter = ('tenant', 'candidate_support', 'is_targeted', 'yard_sign', 'district', 'city', 'state')
search_fields = ('first_name', 'last_name', 'voter_id') search_fields = ('first_name', 'last_name', 'voter_id', 'address', 'city', 'state', 'zip_code', 'county')
inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline] inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline]
@admin.register(Event) @admin.register(Event)
@ -75,4 +75,4 @@ class EventAdmin(admin.ModelAdmin):
@admin.register(EventParticipation) @admin.register(EventParticipation)
class EventParticipationAdmin(admin.ModelAdmin): class EventParticipationAdmin(admin.ModelAdmin):
list_display = ('voter', 'event', 'participation_type') list_display = ('voter', 'event', 'participation_type')
list_filter = ('event__tenant', 'event', 'participation_type') list_filter = ('event__tenant', 'event', 'participation_type')

View File

@ -5,23 +5,32 @@ class VoterForm(forms.ModelForm):
class Meta: class Meta:
model = Voter model = Voter
fields = [ fields = [
'first_name', 'last_name', 'address', 'phone', 'email', 'first_name', 'last_name', 'address_street', 'city', 'state',
'voter_id', 'district', 'precinct', 'registration_date', 'zip_code', 'county', 'latitude', 'longitude',
'candidate_support', 'yard_sign' 'phone', 'email', 'voter_id', 'district', 'precinct',
'registration_date', 'is_targeted', 'candidate_support', 'yard_sign'
] ]
widgets = { widgets = {
'registration_date': forms.DateInput(attrs={'type': 'date'}), '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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
for field in self.fields.values(): for name, field in self.fields.items():
field.widget.attrs.update({'class': 'form-control'}) 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['candidate_support'].widget.attrs.update({'class': 'form-select'})
self.fields['yard_sign'].widget.attrs.update({'class': 'form-select'}) self.fields['yard_sign'].widget.attrs.update({'class': 'form-select'})
class InteractionForm(forms.ModelForm): class InteractionForm(forms.ModelForm):
# ... (rest of the file remains the same)
class Meta: class Meta:
model = Interaction model = Interaction
fields = ['type', 'date', 'description', 'notes'] fields = ['type', 'date', 'description', 'notes']
@ -109,4 +118,4 @@ class EventTypeForm(forms.ModelForm):
for field in self.fields.values(): for field in self.fields.values():
if not isinstance(field.widget, forms.CheckboxInput): if not isinstance(field.widget, forms.CheckboxInput):
field.widget.attrs.update({'class': 'form-control'}) 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'})

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.db import models
from django.utils.text import slugify from django.utils.text import slugify
from django.contrib.auth.models import User 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): class Tenant(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
@ -93,16 +99,72 @@ class Voter(models.Model):
first_name = models.CharField(max_length=100) first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100)
address = models.TextField(blank=True) 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) phone = models.CharField(max_length=20, blank=True)
email = models.EmailField(blank=True) email = models.EmailField(blank=True)
district = models.CharField(max_length=100, blank=True) district = models.CharField(max_length=100, blank=True)
precinct = models.CharField(max_length=100, blank=True) precinct = models.CharField(max_length=100, blank=True)
registration_date = models.DateField(null=True, 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') 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') yard_sign = models.CharField(max_length=20, choices=YARD_SIGN_CHOICES, default='none')
created_at = models.DateTimeField(auto_now_add=True) 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): def __str__(self):
return f"{self.first_name} {self.last_name}" return f"{self.first_name} {self.last_name}"
@ -171,4 +233,4 @@ class VoterLikelihood(models.Model):
unique_together = ('voter', 'election_type') unique_together = ('voter', 'election_type')
def __str__(self): 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()}"

View File

@ -2,6 +2,11 @@
{% load static %} {% load static %}
{% block content %} {% 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"> <div class="container py-5">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4"> <nav aria-label="breadcrumb" class="mb-4">
@ -38,6 +43,9 @@
</div> </div>
</div> </div>
<div class="col-md-auto text-md-end mt-3 mt-md-0"> <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' %} {% if voter.candidate_support == 'supporting' %}
<div class="h4 text-success mb-0"><i class="bi bi-check-circle-fill me-2"></i>Supporting</div> <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' %} {% elif voter.candidate_support == 'not_supporting' %}
@ -53,6 +61,22 @@
<div class="row"> <div class="row">
<!-- Left Column: Quick Stats & Likelihood --> <!-- Left Column: Quick Stats & Likelihood -->
<div class="col-lg-4"> <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 border-0 shadow-sm mb-4">
<div class="card-header bg-white py-3"> <div class="card-header bg-white py-3">
<h5 class="card-title mb-0">Contact Information</h5> <h5 class="card-title mb-0">Contact Information</h5>
@ -333,7 +357,7 @@
<h5 class="modal-title" id="editVoterModalLabel">Edit Voter Profile</h5> <h5 class="modal-title" id="editVoterModalLabel">Edit Voter Profile</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form action="{% url 'voter_edit' voter.id %}" method="POST"> <form id="editVoterForm" action="{% url 'voter_edit' voter.id %}" method="POST">
{% csrf_token %} {% csrf_token %}
<div class="modal-body p-4"> <div class="modal-body p-4">
<div class="row"> <div class="row">
@ -346,8 +370,38 @@
{{ voter_form.last_name }} {{ voter_form.last_name }}
</div> </div>
<div class="col-12 mb-3"> <div class="col-12 mb-3">
<label class="form-label fw-medium">{{ voter_form.address.label }}</label> <label class="form-label fw-medium">Address Street</label>
{{ voter_form.address }} {{ 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>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label fw-medium">{{ voter_form.phone.label }}</label> <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> <label class="form-label fw-medium">{{ voter_form.registration_date.label }}</label>
{{ voter_form.registration_date }} {{ voter_form.registration_date }}
</div> </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"> <div class="col-md-6 mb-3">
<label class="form-label fw-medium">{{ voter_form.candidate_support.label }}</label> <label class="form-label fw-medium">{{ voter_form.candidate_support.label }}</label>
{{ voter_form.candidate_support }} {{ voter_form.candidate_support }}
@ -816,6 +878,79 @@
tab.show(); 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> </script>
{% endblock %} {% endblock %}

View File

@ -7,6 +7,7 @@ urlpatterns = [
path('voters/', views.voter_list, name='voter_list'), path('voters/', views.voter_list, name='voter_list'),
path('voters/<int:voter_id>/', views.voter_detail, name='voter_detail'), 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>/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('voters/<int:voter_id>/interaction/add/', views.add_interaction, name='add_interaction'),
path('interaction/<int:interaction_id>/edit/', views.edit_interaction, name='edit_interaction'), path('interaction/<int:interaction_id>/edit/', views.edit_interaction, name='edit_interaction'),
@ -29,4 +30,4 @@ urlpatterns = [
path('maintenance/event-types/add/', views.event_type_add, name='event_type_add'), path('maintenance/event-types/add/', views.event_type_add, name='event_type_add'),
path('maintenance/event-types/<int:type_id>/edit/', views.event_type_edit, name='event_type_edit'), path('maintenance/event-types/<int:type_id>/edit/', views.event_type_edit, name='event_type_edit'),
path('maintenance/event-types/<int:type_id>/delete/', views.event_type_delete, name='event_type_delete'), path('maintenance/event-types/<int:type_id>/delete/', views.event_type_delete, name='event_type_delete'),
] ]

View File

@ -1,3 +1,4 @@
from django.http import JsonResponse
from django.urls import reverse from django.urls import reverse
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.db.models import Q from django.db.models import Q
@ -331,4 +332,40 @@ def event_type_delete(request, type_id):
messages.success(request, "Event type deleted.") messages.success(request, "Event type deleted.")
except Exception as e: except Exception as e:
messages.error(request, f"Cannot delete event type: {e}") messages.error(request, f"Cannot delete event type: {e}")
return redirect('event_type_list') 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 Django==5.2.7
mysqlclient==2.2.7 mysqlclient==2.2.7
python-dotenv==1.1.1 python-dotenv==1.1.1
geopy==2.4.1