Autosave: 20260128-130611

This commit is contained in:
Flatlogic Bot 2026-01-28 13:06:12 +00:00
parent 2e087bcd88
commit 4056b17780
16 changed files with 449 additions and 158 deletions

View File

@ -1,3 +1,6 @@
from decimal import Decimal
from datetime import datetime, date
from django.db import transaction
from django.http import HttpResponse from django.http import HttpResponse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
import csv import csv
@ -10,6 +13,7 @@ from django.urls import path, reverse
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from .models import ( from .models import (
format_phone_number,
Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter, Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,
VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings, VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings,
Interest, Volunteer, VolunteerEvent, ParticipationStatus Interest, Volunteer, VolunteerEvent, ParticipationStatus
@ -227,51 +231,58 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
def import_voters(self, request): def import_voters(self, request):
if request.method == "POST": if request.method == "POST":
if "_preview" in request.POST: if "_preview" in request.POST:
file_path = request.POST.get('file_path') file_path = request.POST.get("file_path")
tenant_id = request.POST.get('tenant') tenant_id = request.POST.get("tenant")
tenant = Tenant.objects.get(id=tenant_id) tenant = Tenant.objects.get(id=tenant_id)
mapping = {} mapping = {}
for field_name, _ in VOTER_MAPPABLE_FIELDS: for field_name, _ in VOTER_MAPPABLE_FIELDS:
mapping[field_name] = request.POST.get(f'map_{field_name}') mapping[field_name] = request.POST.get(f"map_{field_name}")
try: try:
with open(file_path, 'r', encoding='UTF-8') as f: with open(file_path, "r", encoding="UTF-8") as f:
# Optimization: Fast count and partial preview
total_count = sum(1 for line in f) - 1
f.seek(0)
reader = csv.DictReader(f) reader = csv.DictReader(f)
total_count = 0 preview_rows = []
create_count = 0 voter_ids_for_preview = []
update_count = 0 for i, row in enumerate(reader):
preview_data = [] if i < 10:
for row in reader: preview_rows.append(row)
total_count += 1 v_id = row.get(mapping.get("voter_id"))
voter_id = row.get(mapping.get('voter_id')) if v_id:
exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists() voter_ids_for_preview.append(v_id)
if exists:
update_count += 1
action = 'update'
else: else:
create_count += 1 break
action = 'create'
if len(preview_data) < 10: existing_preview_ids = set(Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids_for_preview).values_list("voter_id", flat=True))
preview_data.append({
'action': action, preview_data = []
'identifier': voter_id, for row in preview_rows:
'details': f"{row.get(mapping.get('first_name', '')) or ''} {row.get(mapping.get('last_name', '')) or ''}".strip() v_id = row.get(mapping.get("voter_id"))
}) action = "update" if v_id in existing_preview_ids else "create"
preview_data.append({
"action": action,
"identifier": v_id,
"details": f"{row.get(mapping.get('first_name', '')) or ''} {row.get(mapping.get('last_name', '')) or ''}".strip()
})
update_count = "N/A"
create_count = "N/A"
context = self.admin_site.each_context(request) context = self.admin_site.each_context(request)
context.update({ context.update({
'title': "Import Preview", "title": "Import Preview",
'total_count': total_count, "total_count": total_count,
'create_count': create_count, "create_count": create_count,
'update_count': update_count, "update_count": update_count,
'preview_data': preview_data, "preview_data": preview_data,
'mapping': mapping, "mapping": mapping,
'file_path': file_path, "file_path": file_path,
'tenant_id': tenant_id, "tenant_id": tenant_id,
'action_url': request.path, "action_url": request.path,
'opts': self.model._meta, "opts": self.model._meta,
}) })
return render(request, "admin/import_preview.html", context) return render(request, "admin/import_preview.html", context)
except Exception as e: except Exception as e:
@ -279,133 +290,191 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
return redirect("..") return redirect("..")
elif "_import" in request.POST: elif "_import" in request.POST:
file_path = request.POST.get('file_path') file_path = request.POST.get("file_path")
tenant_id = request.POST.get('tenant') tenant_id = request.POST.get("tenant")
tenant = Tenant.objects.get(id=tenant_id) tenant = Tenant.objects.get(id=tenant_id)
mapping = {} mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTER_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")}
for field_name, _ in VOTER_MAPPABLE_FIELDS:
mapping[field_name] = request.POST.get(f'map_{field_name}')
try: try:
with open(file_path, 'r', encoding='UTF-8') as f: count = 0
reader = csv.DictReader(f) errors = 0
count = 0 failed_rows = []
errors = 0 batch_size = 500 # Optimized batch size
failed_rows = []
# Pre-calculate choice dicts and sets
support_choices = dict(Voter.SUPPORT_CHOICES)
yard_sign_choices = dict(Voter.YARD_SIGN_CHOICES)
window_sticker_choices = dict(Voter.WINDOW_STICKER_CHOICES)
phone_type_choices = dict(Voter.PHONE_TYPE_CHOICES)
phone_type_reverse = {v.lower(): k for k, v in phone_type_choices.items()}
# Fields to fetch for change detection
valid_fields = {f.name for f in Voter._meta.get_fields()}
mapped_fields = {f for f in mapping.keys() if f in valid_fields}
fetch_fields = list(mapped_fields | {"voter_id", "address", "phone", "longitude"})
update_fields = list(mapped_fields | {"address", "phone"})
if "voter_id" in update_fields: update_fields.remove("voter_id")
def chunk_reader(reader, size):
chunk = []
for row in reader: for row in reader:
try: chunk.append(row)
voter_data = {} if len(chunk) == size:
voter_id = '' yield chunk
for field_name, csv_col in mapping.items(): chunk = []
if csv_col: if chunk:
val = row.get(csv_col) yield chunk
if val is not None and str(val).strip() != '':
if field_name == 'voter_id':
voter_id = val
continue
if field_name == 'is_targeted': with open(file_path, "r", encoding="UTF-8") as f:
val = str(val).lower() in ['true', '1', 'yes'] reader = csv.DictReader(f)
voter_data[field_name] = val v_id_col = mapping.get("voter_id")
if not v_id_col:
raise ValueError("Voter ID mapping is missing")
if 'candidate_support' in voter_data: for chunk_index, chunk in enumerate(chunk_reader(reader, batch_size)):
if voter_data['candidate_support'] not in dict(Voter.SUPPORT_CHOICES): with transaction.atomic():
voter_data['candidate_support'] = 'unknown' voter_ids = [row.get(v_id_col) for row in chunk if row.get(v_id_col)]
if 'yard_sign' in voter_data: existing_voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids).only(*fetch_fields)}
if voter_data['yard_sign'] not in dict(Voter.YARD_SIGN_CHOICES):
voter_data['yard_sign'] = 'none'
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, created = Voter.objects.get_or_create( to_create = []
tenant=tenant, to_update = []
voter_id=voter_id, processed_in_batch = set()
)
for key, value in voter_data.items():
setattr(voter, key, value)
# Flag that coordinates were provided in the import to avoid geocoding for row in chunk:
if "latitude" in voter_data and "longitude" in voter_data: try:
voter._coords_provided_in_import = True voter_id = row.get(v_id_col)
if not voter_id or voter_id in processed_in_batch:
continue
processed_in_batch.add(voter_id)
voter.save() voter = existing_voters.get(voter_id)
count += 1 created = False
except Exception as e: if not voter:
logger.error(f"Error importing: {e}") voter = Voter(tenant=tenant, voter_id=voter_id)
row["Import Error"] = str(e) created = True
failed_rows.append(row)
errors += 1 changed = created
for field_name, csv_col in mapping.items():
if field_name == "voter_id": continue
val = row.get(csv_col)
if val is None or str(val).strip() == "": continue
# Type-specific conversions
if field_name == "is_targeted":
val = str(val).lower() in ["true", "1", "yes"]
elif field_name in ["birthdate", "registration_date"]:
try:
if isinstance(val, str):
val = datetime.strptime(val.strip(), "%Y-%m-%d").date()
except:
pass
elif field_name == "candidate_support":
if val not in support_choices: val = "unknown"
elif field_name == "yard_sign":
if val not in yard_sign_choices: val = "none"
elif field_name == "window_sticker":
if val not in window_sticker_choices: val = "none"
elif field_name == "phone_type":
val_lower = str(val).lower()
if val_lower in phone_type_choices:
val = val_lower
elif val_lower in phone_type_reverse:
val = phone_type_reverse[val_lower]
else:
val = "cell"
if getattr(voter, field_name) != val:
setattr(voter, field_name, val)
changed = True
# Special fields
old_phone = voter.phone
voter.phone = format_phone_number(voter.phone)
if voter.phone != old_phone:
changed = True
if voter.longitude:
try:
new_lon = Decimal(str(voter.longitude)[:12])
if voter.longitude != new_lon:
voter.longitude = new_lon
changed = True
except:
pass
old_address = voter.address
parts = [voter.address_street, voter.city, voter.state, voter.zip_code]
voter.address = ", ".join([p for p in parts if p])
if voter.address != old_address:
changed = True
if not changed:
continue
if created:
to_create.append(voter)
else:
to_update.append(voter)
count += 1
except Exception as e:
logger.error(f"Error importing row: {e}")
row["Import Error"] = str(e)
failed_rows.append(row)
errors += 1
if to_create:
Voter.objects.bulk_create(to_create)
if to_update:
Voter.objects.bulk_update(to_update, update_fields, batch_size=250)
logger.info(f"Voter import progress: Processed batch {chunk_index + 1}. Total successes: {count}")
if os.path.exists(file_path): if os.path.exists(file_path):
os.remove(file_path) os.remove(file_path)
self.message_user(request, f"Successfully imported {count} voters.") self.message_user(request, f"Successfully imported {count} voters.")
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows
request.session.modified = True request.session.modified = True
logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}")
if errors > 0: if errors > 0:
error_url = reverse("admin:voter-download-errors") error_url = reverse("admin:voter-download-errors")
self.message_user(request, mark_safe(f"Failed to import {errors} rows. <a href='{error_url}' download>Download failed records</a>"), level=messages.WARNING) self.message_user(request, mark_safe(f"Failed to import {errors} rows. <a href='{error_url}' download>Download failed records</a>"), level=messages.WARNING)
return redirect("..") return redirect("..")
except Exception as e: except Exception as e:
logger.exception("Voter import failed")
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
return redirect("..") return redirect("..")
else: else:
form = VoterImportForm(request.POST, request.FILES) form = VoterImportForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
csv_file = request.FILES['file'] csv_file = request.FILES["file"]
tenant = form.cleaned_data['tenant'] tenant = form.cleaned_data["tenant"]
if not csv_file.name.endswith('.csv'): if not csv_file.name.endswith(".csv"):
self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) self.message_user(request, "Please upload a CSV file.", level=messages.ERROR)
return redirect("..") return redirect("..")
with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp:
for chunk in csv_file.chunks(): for chunk in csv_file.chunks():
tmp.write(chunk) tmp.write(chunk)
file_path = tmp.name file_path = tmp.name
with open(file_path, 'r', encoding='UTF-8') as f: with open(file_path, "r", encoding="UTF-8") as f:
reader = csv.reader(f) reader = csv.reader(f)
headers = next(reader) headers = next(reader)
context = self.admin_site.each_context(request) context = self.admin_site.each_context(request)
context.update({ context.update({
'title': "Map Voter Fields", "title": "Map Voter Fields",
'headers': headers, "headers": headers,
'model_fields': VOTER_MAPPABLE_FIELDS, "model_fields": VOTER_MAPPABLE_FIELDS,
'tenant_id': tenant.id, "tenant_id": tenant.id,
'file_path': file_path, "file_path": file_path,
'action_url': request.path, "action_url": request.path,
'opts': self.model._meta, "opts": self.model._meta,
}) })
return render(request, "admin/import_mapping.html", context) return render(request, "admin/import_mapping.html", context)
else:
form = VoterImportForm()
context = self.admin_site.each_context(request)
context['form'] = form
context['title'] = "Import Voters"
context['opts'] = self.model._meta
return render(request, "admin/import_csv.html", context)
@admin.register(Event)
class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_display = ('id', 'name', 'event_type', 'date', 'start_time', 'end_time', 'tenant') list_display = ('id', 'name', 'event_type', 'date', 'start_time', 'end_time', 'tenant')
list_filter = ('tenant', 'date', 'event_type') list_filter = ('tenant', 'date', 'event_type')

22
core/admin.py.tmp Normal file
View File

@ -0,0 +1,22 @@
from django.http import HttpResponse
from django.utils.safestring import mark_safe
import csv
import io
import logging
import tempfile
import os
from decimal import Decimal
from django.contrib import admin, messages
from django.urls import path, reverse
from django.shortcuts import render, redirect
from django.template.response import TemplateResponse
from .models import (
Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,
VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings,
Interest, Volunteer, VolunteerEvent, ParticipationStatus, format_phone_number
)
from .forms import (
VoterImportForm, EventImportForm, EventParticipationImportForm,
DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm,
VolunteerImportForm
)

View File

@ -8,13 +8,14 @@ class VoterForm(forms.ModelForm):
'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', 'phone_type', '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', 'notes'
] ]
widgets = { widgets = {
'birthdate': forms.DateInput(attrs={'type': 'date'}), 'birthdate': forms.DateInput(attrs={'type': 'date'}),
'registration_date': forms.DateInput(attrs={'type': 'date'}), 'registration_date': forms.DateInput(attrs={'type': 'date'}),
'latitude': forms.TextInput(attrs={'class': 'form-control bg-light'}), 'latitude': forms.TextInput(attrs={'class': 'form-control bg-light'}),
'longitude': forms.TextInput(attrs={'class': 'form-control bg-light'}), 'longitude': forms.TextInput(attrs={'class': 'form-control bg-light'}),
'notes': forms.Textarea(attrs={'rows': 3}),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-01-26 17:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0021_voter_phone_type'),
]
operations = [
migrations.AddField(
model_name='voter',
name='notes',
field=models.TextField(blank=True),
),
]

View File

@ -0,0 +1,83 @@
# Generated by Django 5.2.7 on 2026-01-28 04:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0022_voter_notes'),
]
operations = [
migrations.AlterField(
model_name='voter',
name='address_street',
field=models.CharField(blank=True, db_index=True, max_length=255),
),
migrations.AlterField(
model_name='voter',
name='birthdate',
field=models.DateField(blank=True, db_index=True, null=True),
),
migrations.AlterField(
model_name='voter',
name='candidate_support',
field=models.CharField(choices=[('unknown', 'Unknown'), ('supporting', 'Supporting'), ('not_supporting', 'Not Supporting')], db_index=True, default='unknown', max_length=20),
),
migrations.AlterField(
model_name='voter',
name='city',
field=models.CharField(blank=True, db_index=True, max_length=100),
),
migrations.AlterField(
model_name='voter',
name='district',
field=models.CharField(blank=True, db_index=True, max_length=100),
),
migrations.AlterField(
model_name='voter',
name='first_name',
field=models.CharField(db_index=True, max_length=100),
),
migrations.AlterField(
model_name='voter',
name='is_targeted',
field=models.BooleanField(db_index=True, default=False),
),
migrations.AlterField(
model_name='voter',
name='last_name',
field=models.CharField(db_index=True, max_length=100),
),
migrations.AlterField(
model_name='voter',
name='precinct',
field=models.CharField(blank=True, db_index=True, max_length=100),
),
migrations.AlterField(
model_name='voter',
name='state',
field=models.CharField(blank=True, db_index=True, max_length=2),
),
migrations.AlterField(
model_name='voter',
name='voter_id',
field=models.CharField(blank=True, db_index=True, max_length=50),
),
migrations.AlterField(
model_name='voter',
name='window_sticker',
field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker')], db_index=True, default='none', max_length=20, verbose_name='Window Sticker Status'),
),
migrations.AlterField(
model_name='voter',
name='yard_sign',
field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants a yard sign'), ('has', 'Has a yard sign')], db_index=True, default='none', max_length=20),
),
migrations.AlterField(
model_name='voter',
name='zip_code',
field=models.CharField(blank=True, db_index=True, max_length=20),
),
]

View File

@ -133,30 +133,31 @@ class Voter(models.Model):
] ]
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, db_index=True)
first_name = models.CharField(max_length=100) first_name = models.CharField(max_length=100, db_index=True)
last_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100, db_index=True)
nickname = models.CharField(max_length=100, blank=True) nickname = models.CharField(max_length=100, blank=True)
birthdate = models.DateField(null=True, blank=True) birthdate = models.DateField(null=True, blank=True, db_index=True)
address = models.TextField(blank=True) address = models.TextField(blank=True)
address_street = models.CharField(max_length=255, blank=True) address_street = models.CharField(max_length=255, blank=True, db_index=True)
city = models.CharField(max_length=100, blank=True) city = models.CharField(max_length=100, blank=True, db_index=True)
state = models.CharField(max_length=2, blank=True) state = models.CharField(max_length=2, blank=True, db_index=True)
prior_state = models.CharField(max_length=2, blank=True) prior_state = models.CharField(max_length=2, blank=True)
zip_code = models.CharField(max_length=20, blank=True) zip_code = models.CharField(max_length=20, blank=True, db_index=True)
county = models.CharField(max_length=100, blank=True) county = models.CharField(max_length=100, blank=True)
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') 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, db_index=True)
precinct = models.CharField(max_length=100, blank=True) precinct = models.CharField(max_length=100, blank=True, db_index=True)
registration_date = models.DateField(null=True, blank=True) registration_date = models.DateField(null=True, blank=True)
is_targeted = models.BooleanField(default=False) is_targeted = models.BooleanField(default=False, db_index=True)
candidate_support = models.CharField(max_length=20, choices=SUPPORT_CHOICES, default='unknown') candidate_support = models.CharField(max_length=20, choices=SUPPORT_CHOICES, default='unknown', db_index=True)
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', db_index=True)
window_sticker = models.CharField(max_length=20, choices=WINDOW_STICKER_CHOICES, default='none', verbose_name='Window Sticker Status') window_sticker = models.CharField(max_length=20, choices=WINDOW_STICKER_CHOICES, default='none', verbose_name='Window Sticker Status', db_index=True)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)

View File

@ -78,23 +78,25 @@
{% csrf_token %} {% csrf_token %}
<!-- Hidden inputs to pass search filters for "Export All" scenario --> <!-- Hidden inputs to pass search filters for "Export All" scenario -->
{% for key, value in request.GET.items %} {% for key, value in request.GET.items %}
{% if key != 'csrfmiddlewaretoken' %} {% if key != 'csrfmiddlewaretoken' and key != 'page' %}
<input type="hidden" name="filter_{{ key }}" value="{{ value }}"> <input type="hidden" name="filter_{{ key }}" value="{{ value }}">
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<div class="card border-0 shadow-sm"> <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"> <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> <h5 class="mb-0 fw-bold">Search Results ({{ voters.paginator.count }})</h5>
<div id="bulk-actions" class="d-none"> <div class="d-flex align-items-center">
<button type="submit" name="action" value="export_selected" class="btn btn-success btn-sm me-2"> <div id="bulk-actions" class="d-none me-2">
<i class="bi bi-file-earmark-spreadsheet me-1"></i> Export Selected <button type="submit" name="action" value="export_selected" class="btn btn-success btn-sm">
</button> <i class="bi bi-file-earmark-spreadsheet me-1"></i> Export Selected
</div> </button>
<div> </div>
<button type="submit" name="action" value="export_all" class="btn btn-outline-success btn-sm"> <div>
<i class="bi bi-file-earmark-spreadsheet me-1"></i> Export All Results <button type="submit" name="action" value="export_all" class="btn btn-outline-success btn-sm">
</button> <i class="bi bi-file-earmark-spreadsheet me-1"></i> Export All Results
</button>
</div>
</div> </div>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
@ -157,6 +159,42 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% if voters.paginator.num_pages > 1 %}
<div class="card-footer bg-white border-0 py-3">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0">
{% if voters.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ voters.previous_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% endif %}
<li class="page-item active"><span class="page-link">{{ voters.number }} of {{ voters.paginator.num_pages }}</span></li>
{% if voters.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ voters.next_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ voters.paginator.num_pages }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div> </div>
</form> </form>
</div> </div>

View File

@ -178,6 +178,15 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white py-3">
<h5 class="card-title mb-0">Notes</h5>
</div>
<div class="card-body">
<p class="mb-0 text-muted" style="white-space: pre-wrap;">{{ voter.notes|default:"No notes available." }}</p>
</div>
</div>
</div> </div>
<!-- Right Column: Detailed Records --> <!-- Right Column: Detailed Records -->
@ -486,6 +495,10 @@
<label class="form-label fw-medium">{{ voter_form.window_sticker.label }}</label> <label class="form-label fw-medium">{{ voter_form.window_sticker.label }}</label>
{{ voter_form.window_sticker }} {{ voter_form.window_sticker }}
</div> </div>
<div class="col-md-12 mb-3">
<label class="form-label fw-medium">Notes</label>
{{ voter_form.notes }}
</div>
</div> </div>
</div> </div>
<div class="modal-footer border-0 p-4 pt-0"> <div class="modal-footer border-0 p-4 pt-0">

View File

@ -5,6 +5,7 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Voter Registry</h1> <h1 class="h2">Voter Registry</h1>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'voter_advanced_search' %}" class="btn btn-outline-primary btn-sm">Advanced Search</a>
<a href="/admin/core/voter/add/" class="btn btn-primary btn-sm">+ Add New Voter</a> <a href="/admin/core/voter/add/" class="btn btn-primary btn-sm">+ Add New Voter</a>
</div> </div>
</div> </div>
@ -72,6 +73,42 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% if voters.paginator.num_pages > 1 %}
<div class="card-footer bg-white border-0 py-3">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0">
{% if voters.has_previous %}
<li class="page-item">
<a class="page-item" href="?page=1{% if query %}&q={{ query }}{% endif %}" aria-label="First">
<span class="page-link">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ voters.previous_page_number }}{% if query %}&q={{ query }}{% endif %}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% endif %}
<li class="page-item active"><span class="page-link">{{ voters.number }} of {{ voters.paginator.num_pages }}</span></li>
{% if voters.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ voters.next_page_number }}{% if query %}&q={{ query }}{% endif %}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ voters.paginator.num_pages }}{% if query %}&q={{ query }}{% endif %}" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -5,6 +5,7 @@ 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 django.core.paginator import Paginator
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, AdvancedVoterSearchForm from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm
import logging import logging
@ -123,8 +124,12 @@ def voter_list(request):
voters = voters.filter(search_filter).order_by("last_name", "first_name") voters = voters.filter(search_filter).order_by("last_name", "first_name")
paginator = Paginator(voters, 50)
page_number = request.GET.get('page')
voters_page = paginator.get_page(page_number)
context = { context = {
"voters": voters, "voters": voters_page,
"query": query, "query": query,
"selected_tenant": tenant "selected_tenant": tenant
} }
@ -442,9 +447,13 @@ def voter_advanced_search(request):
if data.get('window_sticker'): if data.get('window_sticker'):
voters = voters.filter(window_sticker=data['window_sticker']) voters = voters.filter(window_sticker=data['window_sticker'])
paginator = Paginator(voters, 50)
page_number = request.GET.get('page')
voters_page = paginator.get_page(page_number)
context = { context = {
'form': form, 'form': form,
'voters': voters, 'voters': voters_page,
'selected_tenant': tenant, 'selected_tenant': tenant,
} }
return render(request, 'core/voter_advanced_search.html', context) return render(request, 'core/voter_advanced_search.html', context)
@ -519,7 +528,7 @@ def export_voters_csv(request):
writer.writerow([ writer.writerow([
'Voter ID', 'First Name', 'Last Name', 'Nickname', 'Birthdate', 'Voter ID', 'First Name', 'Last Name', 'Nickname', 'Birthdate',
'Address', 'City', 'State', 'Zip Code', 'Phone', 'Phone Type', 'Email', 'Address', 'City', 'State', 'Zip Code', 'Phone', 'Phone Type', 'Email',
'District', 'Precinct', 'Is Targeted', 'Support', 'Yard Sign', 'Window Sticker' 'District', 'Precinct', 'Is Targeted', 'Support', 'Yard Sign', 'Window Sticker', 'Notes'
]) ])
for voter in voters: for voter in voters:
@ -527,7 +536,7 @@ def export_voters_csv(request):
voter.voter_id, voter.first_name, voter.last_name, voter.nickname, voter.birthdate, 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.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.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() voter.get_candidate_support_display(), voter.get_yard_sign_display(), voter.get_window_sticker_display(), voter.notes
]) ])
return response return response