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'),
|
('zip_code', 'Zip Code'),
|
||||||
('county', 'County'),
|
('county', 'County'),
|
||||||
('phone', 'Phone'),
|
('phone', 'Phone'),
|
||||||
|
('phone_type', 'Phone Type'),
|
||||||
('email', 'Email'),
|
('email', 'Email'),
|
||||||
('district', 'District'),
|
('district', 'District'),
|
||||||
('precinct', 'Precinct'),
|
('precinct', 'Precinct'),
|
||||||
@ -203,7 +204,7 @@ class VolunteerEventInline(admin.TabularInline):
|
|||||||
@admin.register(Voter)
|
@admin.register(Voter)
|
||||||
class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
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_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')
|
search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'prior_state', 'zip_code', 'county')
|
||||||
inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline]
|
inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline]
|
||||||
readonly_fields = ('address',)
|
readonly_fields = ('address',)
|
||||||
@ -317,12 +318,34 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
if 'window_sticker' in voter_data:
|
if 'window_sticker' in voter_data:
|
||||||
if voter_data['window_sticker'] not in dict(Voter.WINDOW_STICKER_CHOICES):
|
if voter_data['window_sticker'] not in dict(Voter.WINDOW_STICKER_CHOICES):
|
||||||
voter_data['window_sticker'] = 'none'
|
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,
|
tenant=tenant,
|
||||||
voter_id=voter_id,
|
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
|
count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error importing: {e}")
|
logger.error(f"Error importing: {e}")
|
||||||
|
|||||||
@ -7,7 +7,7 @@ class VoterForm(forms.ModelForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state', 'prior_state',
|
'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state', 'prior_state',
|
||||||
'zip_code', 'county', 'latitude', 'longitude',
|
'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'
|
'registration_date', 'is_targeted', 'candidate_support', 'yard_sign', 'window_sticker'
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
@ -30,6 +30,55 @@ class VoterForm(forms.ModelForm):
|
|||||||
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'})
|
||||||
self.fields['window_sticker'].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 InteractionForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -173,4 +222,4 @@ class VolunteerImportForm(forms.Form):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
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'),
|
('wants', 'Wants Sticker'),
|
||||||
('has', 'Has 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')
|
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='voters')
|
||||||
voter_id = models.CharField(max_length=50, blank=True)
|
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)
|
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)
|
longitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True)
|
||||||
phone = models.CharField(max_length=20, 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)
|
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)
|
||||||
@ -239,9 +245,12 @@ class Voter(models.Model):
|
|||||||
self.state != orig.state or
|
self.state != orig.state or
|
||||||
self.zip_code != orig.zip_code)
|
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)
|
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
|
# Auto-geocode if address changed AND coordinates were NOT manually updated
|
||||||
if address_changed and not coords_provided:
|
if address_changed and not coords_provided:
|
||||||
should_geocode = True
|
should_geocode = True
|
||||||
|
|||||||
@ -30,7 +30,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="col-12 col-md-8 col-lg-6">
|
||||||
<form action="{% url 'voter_list' %}" method="GET" class="w-100">
|
<form action="{% url 'voter_list' %}" method="GET" class="w-100">
|
||||||
<div class="input-group shadow-sm">
|
<div class="input-group shadow-sm">
|
||||||
@ -42,6 +42,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Main Stats Row -->
|
<!-- 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">
|
<li class="mb-3">
|
||||||
<label class="small text-muted d-block">Phone</label>
|
<label class="small text-muted d-block">Phone</label>
|
||||||
<span class="fw-semibold">{{ voter.phone|default:"N/A" }}</span>
|
<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>
|
||||||
<li class="mb-3">
|
<li class="mb-3">
|
||||||
<label class="small text-muted d-block">Birthdate</label>
|
<label class="small text-muted d-block">Birthdate</label>
|
||||||
@ -436,6 +439,10 @@
|
|||||||
{{ voter_form.phone }}
|
{{ voter_form.phone }}
|
||||||
</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_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>
|
<label class="form-label fw-medium">{{ voter_form.email.label }}</label>
|
||||||
{{ voter_form.email }}
|
{{ voter_form.email }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,6 +5,8 @@ urlpatterns = [
|
|||||||
path('', views.index, name='index'),
|
path('', views.index, name='index'),
|
||||||
path('select-campaign/<int:tenant_id>/', views.select_campaign, name='select_campaign'),
|
path('select-campaign/<int:tenant_id>/', views.select_campaign, name='select_campaign'),
|
||||||
path('voters/', views.voter_list, name='voter_list'),
|
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>/', 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>/geocode/', views.voter_geocode, name='voter_geocode'),
|
||||||
|
|||||||
136
core/views.py
136
core/views.py
@ -1,12 +1,12 @@
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse, HttpResponse
|
||||||
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, Sum
|
from django.db.models import Q, Sum
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer
|
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
|
import logging
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@ -399,3 +399,135 @@ def voter_geocode(request, voter_id):
|
|||||||
})
|
})
|
||||||
|
|
||||||
return JsonResponse({'success': False, 'error': 'Invalid request method.'})
|
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