Autosave: 20260126-175038

This commit is contained in:
Flatlogic Bot 2026-01-26 17:50:38 +00:00
parent e0d1690e97
commit 2e087bcd88
15 changed files with 451 additions and 9 deletions

View File

@ -35,6 +35,7 @@ VOTER_MAPPABLE_FIELDS = [
('zip_code', 'Zip Code'),
('county', 'County'),
('phone', 'Phone'),
('phone_type', 'Phone Type'),
('email', 'Email'),
('district', 'District'),
('precinct', 'Precinct'),
@ -203,7 +204,7 @@ class VolunteerEventInline(admin.TabularInline):
@admin.register(Voter)
class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_display = ('first_name', 'last_name', 'nickname', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state', 'prior_state')
list_filter = ('tenant', 'candidate_support', 'is_targeted', 'yard_sign', 'district', 'city', 'state', 'prior_state')
list_filter = ('tenant', 'candidate_support', 'is_targeted', 'phone_type', 'yard_sign', 'district', 'city', 'state', 'prior_state')
search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'prior_state', 'zip_code', 'county')
inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline]
readonly_fields = ('address',)
@ -317,12 +318,34 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if 'window_sticker' in voter_data:
if voter_data['window_sticker'] not in dict(Voter.WINDOW_STICKER_CHOICES):
voter_data['window_sticker'] = 'none'
if 'phone_type' in voter_data:
pt_val = str(voter_data['phone_type']).lower()
pt_choices = dict(Voter.PHONE_TYPE_CHOICES)
if pt_val not in pt_choices:
# Try to match by display name
found = False
for k, v in pt_choices.items():
if v.lower() == pt_val:
voter_data['phone_type'] = k
found = True
break
if not found:
voter_data['phone_type'] = 'cell'
else:
voter_data['phone_type'] = pt_val
Voter.objects.update_or_create(
voter, created = Voter.objects.get_or_create(
tenant=tenant,
voter_id=voter_id,
defaults=voter_data
)
for key, value in voter_data.items():
setattr(voter, key, value)
# Flag that coordinates were provided in the import to avoid geocoding
if "latitude" in voter_data and "longitude" in voter_data:
voter._coords_provided_in_import = True
voter.save()
count += 1
except Exception as e:
logger.error(f"Error importing: {e}")

View File

@ -7,7 +7,7 @@ class VoterForm(forms.ModelForm):
fields = [
'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state', 'prior_state',
'zip_code', 'county', 'latitude', 'longitude',
'phone', 'email', 'voter_id', 'district', 'precinct',
'phone', 'phone_type', 'email', 'voter_id', 'district', 'precinct',
'registration_date', 'is_targeted', 'candidate_support', 'yard_sign', 'window_sticker'
]
widgets = {
@ -30,6 +30,55 @@ class VoterForm(forms.ModelForm):
self.fields['candidate_support'].widget.attrs.update({'class': 'form-select'})
self.fields['yard_sign'].widget.attrs.update({'class': 'form-select'})
self.fields['window_sticker'].widget.attrs.update({'class': 'form-select'})
self.fields['phone_type'].widget.attrs.update({'class': 'form-select'})
class AdvancedVoterSearchForm(forms.Form):
MONTH_CHOICES = [
('', 'Any Month'),
(1, 'January'), (2, 'February'), (3, 'March'), (4, 'April'),
(5, 'May'), (6, 'June'), (7, 'July'), (8, 'August'),
(9, 'September'), (10, 'October'), (11, 'November'), (12, 'December')
]
first_name = forms.CharField(required=False)
last_name = forms.CharField(required=False)
voter_id = forms.CharField(required=False, label="Voter ID")
birth_month = forms.ChoiceField(choices=MONTH_CHOICES, required=False, label="Birth Month")
city = forms.CharField(required=False)
zip_code = forms.CharField(required=False)
district = forms.CharField(required=False)
precinct = forms.CharField(required=False)
phone_type = forms.ChoiceField(
choices=[('', 'Any')] + Voter.PHONE_TYPE_CHOICES,
required=False
)
is_targeted = forms.BooleanField(required=False, label="Targeted Only")
candidate_support = forms.ChoiceField(
choices=[('', 'Any')] + Voter.SUPPORT_CHOICES,
required=False
)
yard_sign = forms.ChoiceField(
choices=[('', 'Any')] + Voter.YARD_SIGN_CHOICES,
required=False
)
window_sticker = forms.ChoiceField(
choices=[('', 'Any')] + Voter.WINDOW_STICKER_CHOICES,
required=False
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
if isinstance(field.widget, forms.CheckboxInput):
field.widget.attrs.update({'class': 'form-check-input'})
else:
field.widget.attrs.update({'class': 'form-control'})
self.fields['birth_month'].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['window_sticker'].widget.attrs.update({'class': 'form-select'})
self.fields['phone_type'].widget.attrs.update({'class': 'form-select'})
class InteractionForm(forms.ModelForm):
class Meta:
@ -173,4 +222,4 @@ class VolunteerImportForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
self.fields['file'].widget.attrs.update({'class': 'form-control'})
self.fields['file'].widget.attrs.update({'class': 'form-control'})

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-01-26 16:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0020_remove_volunteer_name'),
]
operations = [
migrations.AddField(
model_name='voter',
name='phone_type',
field=models.CharField(choices=[('home', 'Home Phone'), ('cell', 'Cell Phone'), ('work', 'Work Phone')], default='cell', max_length=10),
),
]

View File

@ -126,6 +126,11 @@ class Voter(models.Model):
('wants', 'Wants Sticker'),
('has', 'Has Sticker'),
]
PHONE_TYPE_CHOICES = [
('home', 'Home Phone'),
('cell', 'Cell Phone'),
('work', 'Work Phone'),
]
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='voters')
voter_id = models.CharField(max_length=50, blank=True)
@ -143,6 +148,7 @@ class Voter(models.Model):
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)
phone_type = models.CharField(max_length=10, choices=PHONE_TYPE_CHOICES, default='cell')
email = models.EmailField(blank=True)
district = models.CharField(max_length=100, blank=True)
precinct = models.CharField(max_length=100, blank=True)
@ -239,9 +245,12 @@ class Voter(models.Model):
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)
# If specifically provided in import, treat as provided even if same as DB
if getattr(self, "_coords_provided_in_import", False):
coords_provided = True
# Auto-geocode if address changed AND coordinates were NOT manually updated
if address_changed and not coords_provided:
should_geocode = True

View File

@ -30,7 +30,7 @@
</div>
</div>
<div class="row mb-4">
<div class="row mb-4 align-items-center">
<div class="col-12 col-md-8 col-lg-6">
<form action="{% url 'voter_list' %}" method="GET" class="w-100">
<div class="input-group shadow-sm">
@ -42,6 +42,11 @@
</div>
</form>
</div>
<div class="col-12 col-md-4 col-lg-3 mt-2 mt-md-0">
<a href="{% url 'voter_advanced_search' %}" class="btn btn-outline-primary shadow-sm w-100">
<i class="bi bi-sliders me-2"></i>Advanced Search
</a>
</div>
</div>
<!-- Main Stats Row -->

View File

@ -0,0 +1,197 @@
{% extends "base.html" %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Advanced Voter Search</h1>
<a href="{% url 'voter_list' %}" class="btn btn-outline-secondary btn-sm">Back to Registry</a>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<form action="." method="GET" class="row g-3">
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">First Name</label>
{{ form.first_name }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Last Name</label>
{{ form.last_name }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Voter ID</label>
{{ form.voter_id }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Birth Month</label>
{{ form.birth_month }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">City</label>
{{ form.city }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Zip Code</label>
{{ form.zip_code }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">District</label>
{{ form.district }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Precinct</label>
{{ form.precinct }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Phone Type</label>
{{ form.phone_type }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Candidate Support</label>
{{ form.candidate_support }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Yard Sign</label>
{{ form.yard_sign }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Window Sticker</label>
{{ form.window_sticker }}
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check mb-2">
{{ form.is_targeted }}
<label class="form-check-label ms-1" for="{{ form.is_targeted.id_for_label }}">
Targeted Only
</label>
</div>
</div>
<div class="col-12 text-end">
<a href="{% url 'voter_advanced_search' %}" class="btn btn-light me-2">Clear Filters</a>
<button type="submit" class="btn btn-primary px-4">Search Voters</button>
</div>
</form>
</div>
</div>
<form id="bulk-action-form" action="{% url 'export_voters_csv' %}" method="POST">
{% csrf_token %}
<!-- Hidden inputs to pass search filters for "Export All" scenario -->
{% for key, value in request.GET.items %}
{% if key != 'csrfmiddlewaretoken' %}
<input type="hidden" name="filter_{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-3 border-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-bold">Search Results ({{ voters.count }})</h5>
<div id="bulk-actions" class="d-none">
<button type="submit" name="action" value="export_selected" class="btn btn-success btn-sm me-2">
<i class="bi bi-file-earmark-spreadsheet me-1"></i> Export Selected
</button>
</div>
<div>
<button type="submit" name="action" value="export_all" class="btn btn-outline-success btn-sm">
<i class="bi bi-file-earmark-spreadsheet me-1"></i> Export All Results
</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="bg-light">
<tr>
<th class="ps-4" style="width: 40px;">
<input type="checkbox" class="form-check-input" id="select-all">
</th>
<th>Name</th>
<th>District</th>
<th>Phone</th>
<th>Target Voter</th>
<th class="pe-4">Supporter</th>
</tr>
</thead>
<tbody>
{% for voter in voters %}
<tr>
<td class="ps-4">
<input type="checkbox" name="selected_voters" value="{{ voter.id }}" class="form-check-input voter-checkbox">
</td>
<td>
<a href="{% url 'voter_detail' voter.id %}" class="fw-semibold text-primary text-decoration-none d-block">
{{ voter.first_name }} {{ voter.last_name }}
</a>
<div class="small text-muted">{{ voter.address|default:"No address provided" }}</div>
</td>
<td><span class="badge bg-light text-dark border">{{ voter.district|default:"-" }}</span></td>
<td>
{{ voter.phone|default:"-" }}
{% if voter.phone %}
<div class="small text-muted">{{ voter.get_phone_type_display }}</div>
{% endif %}
</td>
<td>
{% if voter.is_targeted %}
<span class="badge bg-primary">Yes</span>
{% else %}
<span class="badge bg-secondary-subtle text-secondary">No</span>
{% endif %}
</td>
<td class="pe-4">
{% if voter.candidate_support == 'supporting' %}
<span class="badge bg-success">Supporting</span>
{% elif voter.candidate_support == 'not_supporting' %}
<span class="badge bg-danger">Not Supporting</span>
{% else %}
<span class="badge bg-secondary">Unknown</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<p class="mb-0">No voters found matching your search criteria.</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</form>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const selectAll = document.getElementById('select-all');
const checkboxes = document.querySelectorAll('.voter-checkbox');
const bulkActions = document.getElementById('bulk-actions');
function updateBulkActionsVisibility() {
const checkedCount = document.querySelectorAll('.voter-checkbox:checked').length;
if (checkedCount > 0) {
bulkActions.classList.remove('d-none');
} else {
bulkActions.classList.add('d-none');
}
}
if (selectAll) {
selectAll.addEventListener('change', function() {
checkboxes.forEach(cb => {
cb.checked = selectAll.checked;
});
updateBulkActionsVisibility();
});
}
checkboxes.forEach(cb => {
cb.addEventListener('change', function() {
const allChecked = Array.from(checkboxes).every(c => c.checked);
selectAll.checked = allChecked;
updateBulkActionsVisibility();
});
});
});
</script>
{% endblock %}

View File

@ -88,6 +88,9 @@
<li class="mb-3">
<label class="small text-muted d-block">Phone</label>
<span class="fw-semibold">{{ voter.phone|default:"N/A" }}</span>
{% if voter.phone %}
<span class="badge bg-light text-dark border ms-1">{{ voter.get_phone_type_display }}</span>
{% endif %}
</li>
<li class="mb-3">
<label class="small text-muted d-block">Birthdate</label>
@ -436,6 +439,10 @@
{{ voter_form.phone }}
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">{{ voter_form.phone_type.label }}</label>
{{ voter_form.phone_type }}
</div>
<div class="col-md-12 mb-3">
<label class="form-label fw-medium">{{ voter_form.email.label }}</label>
{{ voter_form.email }}
</div>

View File

@ -5,6 +5,8 @@ urlpatterns = [
path('', views.index, name='index'),
path('select-campaign/<int:tenant_id>/', views.select_campaign, name='select_campaign'),
path('voters/', views.voter_list, name='voter_list'),
path('voters/advanced-search/', views.voter_advanced_search, name='voter_advanced_search'),
path('voters/export-csv/', views.export_voters_csv, name='export_voters_csv'),
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'),

View File

@ -1,12 +1,12 @@
import csv
import io
from django.http import JsonResponse
from django.http import JsonResponse, HttpResponse
from django.urls import reverse
from django.shortcuts import render, redirect, get_object_or_404
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, CampaignSettings, Volunteer
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm
import logging
from django.utils import timezone
@ -399,3 +399,135 @@ def voter_geocode(request, voter_id):
})
return JsonResponse({'success': False, 'error': 'Invalid request method.'})
def voter_advanced_search(request):
"""
Advanced search for voters with multiple filters.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voters = Voter.objects.filter(tenant=tenant).order_by("last_name", "first_name")
form = AdvancedVoterSearchForm(request.GET)
if form.is_valid():
data = form.cleaned_data
if data.get('first_name'):
voters = voters.filter(first_name__icontains=data['first_name'])
if data.get('last_name'):
voters = voters.filter(last_name__icontains=data['last_name'])
if data.get('voter_id'):
voters = voters.filter(voter_id__icontains=data['voter_id'])
if data.get('birth_month'):
voters = voters.filter(birthdate__month=data['birth_month'])
if data.get('city'):
voters = voters.filter(city__icontains=data['city'])
if data.get('zip_code'):
voters = voters.filter(zip_code__icontains=data['zip_code'])
if data.get('district'):
voters = voters.filter(district__icontains=data['district'])
if data.get('precinct'):
voters = voters.filter(precinct__icontains=data['precinct'])
if data.get('phone_type'):
voters = voters.filter(phone_type=data['phone_type'])
if data.get('is_targeted'):
voters = voters.filter(is_targeted=True)
if data.get('candidate_support'):
voters = voters.filter(candidate_support=data['candidate_support'])
if data.get('yard_sign'):
voters = voters.filter(yard_sign=data['yard_sign'])
if data.get('window_sticker'):
voters = voters.filter(window_sticker=data['window_sticker'])
context = {
'form': form,
'voters': voters,
'selected_tenant': tenant,
}
return render(request, 'core/voter_advanced_search.html', context)
def export_voters_csv(request):
"""
Exports selected or filtered voters to a CSV file.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
if request.method != 'POST':
return redirect('voter_advanced_search')
action = request.POST.get('action')
voters = Voter.objects.filter(tenant=tenant)
if action == 'export_selected':
voter_ids = request.POST.getlist('selected_voters')
voters = voters.filter(id__in=voter_ids)
else: # export_all
# Re-apply filters from hidden inputs
# These are passed as filter_fieldname
filters = {}
for key, value in request.POST.items():
if key.startswith('filter_') and value:
field_name = key.replace('filter_', '')
filters[field_name] = value
# We can use the AdvancedVoterSearchForm to validate and apply filters
# but we need to pass data without the prefix
form = AdvancedVoterSearchForm(filters)
if form.is_valid():
data = form.cleaned_data
if data.get('first_name'):
voters = voters.filter(first_name__icontains=data['first_name'])
if data.get('last_name'):
voters = voters.filter(last_name__icontains=data['last_name'])
if data.get('voter_id'):
voters = voters.filter(voter_id__icontains=data['voter_id'])
if data.get('birth_month'):
voters = voters.filter(birthdate__month=data['birth_month'])
if data.get('city'):
voters = voters.filter(city__icontains=data['city'])
if data.get('zip_code'):
voters = voters.filter(zip_code__icontains=data['zip_code'])
if data.get('district'):
voters = voters.filter(district__icontains=data['district'])
if data.get('precinct'):
voters = voters.filter(precinct__icontains=data['precinct'])
if data.get('phone_type'):
voters = voters.filter(phone_type=data['phone_type'])
if data.get('is_targeted'):
voters = voters.filter(is_targeted=True)
if data.get('candidate_support'):
voters = voters.filter(candidate_support=data['candidate_support'])
if data.get('yard_sign'):
voters = voters.filter(yard_sign=data['yard_sign'])
if data.get('window_sticker'):
voters = voters.filter(window_sticker=data['window_sticker'])
voters = voters.order_by('last_name', 'first_name')
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="voters_export_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv"'
writer = csv.writer(response)
writer.writerow([
'Voter ID', 'First Name', 'Last Name', 'Nickname', 'Birthdate',
'Address', 'City', 'State', 'Zip Code', 'Phone', 'Phone Type', 'Email',
'District', 'Precinct', 'Is Targeted', 'Support', 'Yard Sign', 'Window Sticker'
])
for voter in voters:
writer.writerow([
voter.voter_id, voter.first_name, voter.last_name, voter.nickname, voter.birthdate,
voter.address, voter.city, voter.state, voter.zip_code, voter.phone, voter.get_phone_type_display(), voter.email,
voter.district, voter.precinct, 'Yes' if voter.is_targeted else 'No',
voter.get_candidate_support_display(), voter.get_yard_sign_display(), voter.get_window_sticker_display()
])
return response