Autosave: 20260126-175038
This commit is contained in:
parent
e0d1690e97
commit
2e087bcd88
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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}")
|
||||
|
||||
@ -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'})
|
||||
18
core/migrations/0021_voter_phone_type.py
Normal file
18
core/migrations/0021_voter_phone_type.py
Normal 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),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@ -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
|
||||
|
||||
@ -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 -->
|
||||
|
||||
197
core/templates/core/voter_advanced_search.html
Normal file
197
core/templates/core/voter_advanced_search.html
Normal 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 %}
|
||||
@ -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>
|
||||
|
||||
@ -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'),
|
||||
|
||||
136
core/views.py
136
core/views.py
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user