This commit is contained in:
Flatlogic Bot 2026-01-25 00:39:13 +00:00
parent 0ef73ff181
commit 443505ace5
20 changed files with 426 additions and 129 deletions

View File

@ -1,8 +1,49 @@
from django.contrib import admin
import csv
import io
import logging
import tempfile
import os
from django.contrib import admin, messages
from django.urls import path
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
)
from .forms import VoterImportForm, EventImportForm
logger = logging.getLogger(__name__)
VOTER_MAPPABLE_FIELDS = [
('voter_id', 'Voter ID'),
('first_name', 'First Name'),
('last_name', 'Last Name'),
('nickname', 'Nickname'),
('birthdate', 'Birthdate'),
('address_street', 'Street Address'),
('city', 'City'),
('state', 'State'),
('zip_code', 'Zip Code'),
('county', 'County'),
('phone', 'Phone'),
('email', 'Email'),
('district', 'District'),
('precinct', 'Precinct'),
('registration_date', 'Registration Date'),
('is_targeted', 'Is Targeted'),
('candidate_support', 'Candidate Support'),
('yard_sign', 'Yard Sign'),
('window_sticker', 'Window Sticker'),
('latitude', 'Latitude'),
('longitude', 'Longitude'),
]
EVENT_MAPPABLE_FIELDS = [
('date', 'Date'),
('event_type', 'Event Type (Name)'),
('description', 'Description'),
]
class TenantUserRoleInline(admin.TabularInline):
model = TenantUserRole
@ -66,15 +107,222 @@ class VoterLikelihoodInline(admin.TabularInline):
@admin.register(Voter)
class VoterAdmin(admin.ModelAdmin):
list_display = ('first_name', 'last_name', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state')
list_display = ('first_name', 'last_name', 'nickname', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state')
list_filter = ('tenant', 'candidate_support', 'is_targeted', 'yard_sign', 'district', 'city', 'state')
search_fields = ('first_name', 'last_name', 'voter_id', 'address', 'city', 'state', 'zip_code', 'county')
search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'zip_code', 'county')
inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline]
change_list_template = "admin/voter_change_list.html"
def get_urls(self):
urls = super().get_urls()
my_urls = [
path('import-voters/', self.admin_site.admin_view(self.import_voters), name='import-voters'),
]
return my_urls + urls
def import_voters(self, request):
if request.method == "POST":
if "_import" in request.POST:
file_path = request.POST.get('file_path')
tenant_id = request.POST.get('tenant')
tenant = Tenant.objects.get(id=tenant_id)
mapping = {}
for field_name, _ in VOTER_MAPPABLE_FIELDS:
mapping[field_name] = request.POST.get(f'map_{field_name}')
try:
with open(file_path, 'r', encoding='UTF-8') as f:
reader = csv.DictReader(f)
count = 0
errors = 0
for row in reader:
try:
voter_data = {}
for field_name, csv_col in mapping.items():
if csv_col:
val = row.get(csv_col)
if val is not None:
if field_name == 'is_targeted':
val = str(val).lower() in ['true', '1', 'yes']
voter_data[field_name] = val
voter_id = voter_data.pop('voter_id', '')
if 'candidate_support' in voter_data:
if voter_data['candidate_support'] not in dict(Voter.SUPPORT_CHOICES):
voter_data['candidate_support'] = 'unknown'
if 'yard_sign' in voter_data:
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'
for d_field in ['registration_date', 'birthdate', 'latitude', 'longitude']:
if d_field in voter_data and not voter_data[d_field]:
del voter_data[d_field]
Voter.objects.update_or_create(
tenant=tenant,
voter_id=voter_id,
defaults=voter_data
)
count += 1
except Exception as e:
logger.error(f"Error importing voter row: {e}")
errors += 1
if os.path.exists(file_path):
os.remove(file_path)
self.message_user(request, f"Successfully imported {count} voters.")
if errors > 0:
self.message_user(request, f"Failed to import {errors} rows.", level=messages.WARNING)
return redirect("..")
except Exception as e:
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
return redirect("..")
else:
form = VoterImportForm(request.POST, request.FILES)
if form.is_valid():
csv_file = request.FILES['file']
tenant = form.cleaned_data['tenant']
if not csv_file.name.endswith('.csv'):
self.message_user(request, "Please upload a CSV file.", level=messages.ERROR)
return redirect("..")
with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:
for chunk in csv_file.chunks():
tmp.write(chunk)
file_path = tmp.name
with open(file_path, 'r', encoding='UTF-8') as f:
reader = csv.reader(f)
headers = next(reader)
context = self.admin_site.each_context(request)
context.update({
'title': "Map Voter Fields",
'headers': headers,
'model_fields': VOTER_MAPPABLE_FIELDS,
'tenant_id': tenant.id,
'file_path': file_path,
'action_url': request.path,
'opts': self.model._meta,
})
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(admin.ModelAdmin):
list_display = ('event_type', 'date', 'tenant')
list_filter = ('tenant', 'date', 'event_type')
change_list_template = "admin/event_change_list.html"
def get_urls(self):
urls = super().get_urls()
my_urls = [
path('import-events/', self.admin_site.admin_view(self.import_events), name='import-events'),
]
return my_urls + urls
def import_events(self, request):
if request.method == "POST":
if "_import" in request.POST:
file_path = request.POST.get('file_path')
tenant_id = request.POST.get('tenant')
tenant = Tenant.objects.get(id=tenant_id)
mapping = {}
for field_name, _ in EVENT_MAPPABLE_FIELDS:
mapping[field_name] = request.POST.get(f'map_{field_name}')
try:
with open(file_path, 'r', encoding='UTF-8') as f:
reader = csv.DictReader(f)
count = 0
errors = 0
for row in reader:
try:
date = row.get(mapping.get('date')) if mapping.get('date') else None
event_type_name = row.get(mapping.get('event_type')) if mapping.get('event_type') else None
description = row.get(mapping.get('description')) if mapping.get('description') else ''
if not date or not event_type_name:
errors += 1
continue
event_type, _ = EventType.objects.get_or_create(
tenant=tenant,
name=event_type_name
)
Event.objects.create(
tenant=tenant,
date=date,
event_type=event_type,
description=description
)
count += 1
except Exception as e:
logger.error(f"Error importing event row: {e}")
errors += 1
if os.path.exists(file_path):
os.remove(file_path)
self.message_user(request, f"Successfully imported {count} events.")
if errors > 0:
self.message_user(request, f"Failed to import {errors} rows.", level=messages.WARNING)
return redirect("..")
except Exception as e:
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
return redirect("..")
else:
form = EventImportForm(request.POST, request.FILES)
if form.is_valid():
csv_file = request.FILES['file']
tenant = form.cleaned_data['tenant']
if not csv_file.name.endswith('.csv'):
self.message_user(request, "Please upload a CSV file.", level=messages.ERROR)
return redirect("..")
with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:
for chunk in csv_file.chunks():
tmp.write(chunk)
file_path = tmp.name
with open(file_path, 'r', encoding='UTF-8') as f:
reader = csv.reader(f)
headers = next(reader)
context = self.admin_site.each_context(request)
context.update({
'title': "Map Event Fields",
'headers': headers,
'model_fields': EVENT_MAPPABLE_FIELDS,
'tenant_id': tenant.id,
'file_path': file_path,
'action_url': request.path,
'opts': self.model._meta,
})
return render(request, "admin/import_mapping.html", context)
else:
form = EventImportForm()
context = self.admin_site.each_context(request)
context['form'] = form
context['title'] = "Import Events"
context['opts'] = self.model._meta
return render(request, "admin/import_csv.html", context)
@admin.register(EventParticipation)
class EventParticipationAdmin(admin.ModelAdmin):
@ -84,4 +332,4 @@ class EventParticipationAdmin(admin.ModelAdmin):
@admin.register(CampaignSettings)
class CampaignSettingsAdmin(admin.ModelAdmin):
list_display = ('tenant', 'donation_goal')
list_filter = ('tenant',)
list_filter = ('tenant',)

View File

@ -1,16 +1,17 @@
from django import forms
from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType
from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType, Tenant
class VoterForm(forms.ModelForm):
class Meta:
model = Voter
fields = [
'first_name', 'last_name', 'address_street', 'city', 'state',
'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state',
'zip_code', 'county', 'latitude', 'longitude',
'phone', 'email', 'voter_id', 'district', 'precinct',
'registration_date', 'is_targeted', 'candidate_support', 'yard_sign', 'window_sticker'
]
widgets = {
'birthdate': forms.DateInput(attrs={'type': 'date'}),
'registration_date': forms.DateInput(attrs={'type': 'date'}),
'latitude': forms.TextInput(attrs={'class': 'form-control bg-light'}),
'longitude': forms.TextInput(attrs={'class': 'form-control bg-light'}),
@ -109,8 +110,19 @@ class EventForm(forms.ModelForm):
self.fields['event_type'].widget.attrs.update({'class': 'form-select'})
class VoterImportForm(forms.Form):
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
file = forms.FileField(label="Select CSV file")
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'})
class EventImportForm(forms.Form):
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
file = forms.FileField(label="Select CSV file")
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'})

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-01-25 00:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0010_alter_voter_window_sticker_campaignsettings'),
]
operations = [
migrations.AddField(
model_name='voter',
name='birthdate',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='voter',
name='nickname',
field=models.CharField(blank=True, max_length=100),
),
]

View File

@ -105,6 +105,8 @@ class Voter(models.Model):
voter_id = models.CharField(max_length=50, blank=True)
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
nickname = models.CharField(max_length=100, blank=True)
birthdate = models.DateField(null=True, blank=True)
address = models.TextField(blank=True)
address_street = models.CharField(max_length=255, blank=True)
city = models.CharField(max_length=100, blank=True)
@ -302,4 +304,4 @@ class CampaignSettings(models.Model):
verbose_name_plural = 'Campaign Settings'
def __str__(self):
return f'Settings for {self.tenant.name}'
return f'Settings for {self.tenant.name}'

View File

@ -0,0 +1,7 @@
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
<li>
<a href="import-events/" class="addlink">Import Events</a>
</li>
{{ block.super }}
{% endblock %}

View File

@ -0,0 +1,42 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {{ title }}
</div>
{% endblock %}
{% block content %}
<div id="content-main">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<fieldset class="module aligned">
<div class="description">
<p>Upload a CSV file to import {{ opts.verbose_name_plural }}.</p>
{% if title == "Import Voters" %}
<p>Expected columns (header mandatory): <strong>voter_id, first_name, last_name, address_street, city, state, zip_code, county, phone, email, district, precinct, registration_date, is_targeted, candidate_support, yard_sign, window_sticker</strong></p>
{% else %}
<p>Expected columns (header mandatory): <strong>date, event_type, description</strong></p>
{% endif %}
</div>
{% for field in form %}
<div class="form-row">
{{ field.errors }}
<label class="required" for="{{ field.id_for_label }}">{{ field.label }}:</label>
{{ field }}
{% if field.help_text %}
<div class="help">{{ field.help_text|safe }}</div>
{% endif %}
</div>
{% endfor %}
</fieldset>
<div class="submit-row">
<input type="submit" value="Upload" class="default" name="_save">
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,48 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {% translate 'Import Mapping' %}
</div>
{% endblock %}
{% block content %}
<div id="content-main">
<form method="post" action="{{ action_url }}">
{% csrf_token %}
<input type="hidden" name="file_path" value="{{ file_path }}">
<input type="hidden" name="tenant" value="{{ tenant_id }}">
<fieldset class="module aligned">
<h2>{% translate "Map CSV Columns to Model Fields" %}</h2>
<div class="description">
Select which CSV column matches each model field. Leave blank to skip.
</div>
{% for field_name, verbose_name in model_fields %}
<div class="form-row">
<div>
<label for="id_map_{{ field_name }}">{{ verbose_name }}:</label>
<select name="map_{{ field_name }}" id="id_map_{{ field_name }}">
<option value="">-- {% translate "Skip" %} --</option>
{% for header in headers %}
<option value="{{ header }}" {% if header|lower == field_name|lower or header|lower == verbose_name|lower %}selected{% endif %}>
{{ header }}
</option>
{% endfor %}
</select>
</div>
</div>
{% endfor %}
</fieldset>
<div class="submit-row">
<input type="submit" value="{% translate 'Import' %}" class="default" name="_import">
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
<li>
<a href="import-voters/" class="addlink">Import Voters</a>
</li>
{{ block.super }}
{% endblock %}

View File

@ -80,11 +80,11 @@
</a>
</div>
<div class="col-md-4 col-lg-3">
<a href="{% url 'voter_list' %}?has_address=true" class="text-decoration-none">
<a href="{% url 'voter_list' %}?is_targeted=true&has_address=true" class="text-decoration-none">
<div class="card border-0 shadow-sm h-100 hover-lift transition">
<div class="card-body text-center p-3">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Voter Addresses</h6>
<p class="display-6 fw-bold mb-0 text-info">{{ metrics.total_voter_addresses }}</p>
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Target Households</h6>
<p class="display-6 fw-bold mb-0 text-info">{{ metrics.total_target_households }}</p>
</div>
</div>
</a>
@ -155,4 +155,4 @@
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
</style>
{% endblock %}
{% endblock %}

View File

@ -11,7 +11,7 @@
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'index' %}" class="text-decoration-none">Dashboard</a></li>
<li class="breadcrumb-item"><a href="{% url 'voter_list' %}" class="text-decoration-none">Voters</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ voter.first_name }} {{ voter.last_name }}</li>
<li class="breadcrumb-item active" aria-current="page">{% if voter.nickname %}{{ voter.nickname }}{% else %}{{ voter.first_name }}{% endif %} {{ voter.last_name }}</li>
</ol>
</nav>
@ -21,12 +21,12 @@
<div class="row align-items-center">
<div class="col-md-auto mb-3 mb-md-0">
<div class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center shadow-sm" style="width: 80px; height: 80px; font-size: 2rem;">
{{ voter.first_name|first }}{{ voter.last_name|first }}
{% if voter.nickname %}{{ voter.nickname|first }}{% else %}{{ voter.first_name|first }}{% endif %}{{ voter.last_name|first }}
</div>
</div>
<div class="col-md">
<div class="d-flex align-items-center">
<h1 class="h3 mb-1 me-3">{{ voter.first_name }} {{ voter.last_name }}</h1>
<h1 class="h3 mb-1 me-3">{% if voter.nickname %}{{ voter.nickname }}{% else %}{{ voter.first_name }}{% endif %} {{ voter.last_name }}</h1>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#editVoterModal">
<i class="bi bi-pencil me-1"></i>Edit
</button>
@ -89,6 +89,10 @@
<label class="small text-muted d-block">Phone</label>
<span class="fw-semibold">{{ voter.phone|default:"N/A" }}</span>
</li>
<li class="mb-3">
<label class="small text-muted d-block">Birthdate</label>
<span class="fw-semibold">{{ voter.birthdate|date:"M d, Y"|default:"N/A" }}</span>
</li>
<li>
<label class="small text-muted d-block">Registration Date</label>
<span class="fw-semibold">{{ voter.registration_date|date:"M d, Y"|default:"Unknown" }}</span>
@ -373,15 +377,19 @@
{% csrf_token %}
<div class="modal-body p-4">
<div class="row">
<div class="col-md-6 mb-3">
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ voter_form.first_name.label }}</label>
{{ voter_form.first_name }}
</div>
<div class="col-md-6 mb-3">
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ voter_form.last_name.label }}</label>
{{ voter_form.last_name }}
</div>
<div class="col-12 mb-3">
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ voter_form.nickname.label }}</label>
{{ voter_form.nickname }}
</div>
<div class="col-md-12 mb-3">
<label class="form-label fw-medium">Address Street</label>
{{ voter_form.address_street }}
</div>
@ -435,11 +443,15 @@
<label class="form-label fw-medium">{{ voter_form.precinct.label }}</label>
{{ voter_form.precinct }}
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">{{ voter_form.birthdate.label }}</label>
{{ voter_form.birthdate }}
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">{{ voter_form.registration_date.label }}</label>
{{ voter_form.registration_date }}
</div>
<div class="col-md-6 mb-3 d-flex align-items-center">
<div class="col-md-12 mb-3 d-flex align-items-center">
<div class="form-check">
{{ voter_form.is_targeted }}
<label class="form-check-label fw-medium" for="{{ voter_form.is_targeted.id_for_label }}">
@ -447,15 +459,15 @@
</label>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ voter_form.candidate_support.label }}</label>
{{ voter_form.candidate_support }}
</div>
<div class="col-md-6 mb-3">
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ voter_form.yard_sign.label }}</label>
{{ voter_form.yard_sign }}
</div>
<div class="col-md-6 mb-3">
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ voter_form.window_sticker.label }}</label>
{{ voter_form.window_sticker }}
</div>
@ -906,10 +918,10 @@
const marker = new google.maps.Marker({
position: position,
map: map,
title: "{{ voter.first_name }} {{ voter.last_name }}",
title: "{% if voter.nickname %}{{ voter.nickname }}{% else %}{{ voter.first_name }}{% endif %} {{ voter.last_name }}",
});
const infowindow = new google.maps.InfoWindow({
content: "<strong>{{ voter.first_name }} {{ voter.last_name }}</strong><br>{{ voter.address_street }}",
content: "<strong>{% if voter.nickname %}{{ voter.nickname }}{% else %}{{ voter.first_name }}{% endif %} {{ voter.last_name }}</strong><br>{{ voter.address_street }}",
});
marker.addListener("click", () => {
infowindow.open(map, marker);

View File

@ -1,38 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h5 class="card-title mb-0">Import Voters for {{ selected_tenant.name }}</h5>
</div>
<div class="card-body">
<div class="alert alert-info mb-4">
<h6 class="alert-heading"><i class="bi bi-info-circle me-2"></i>CSV Format Instructions</h6>
<p class="mb-2 small text-muted">Please ensure your CSV file follows this column order:</p>
<code class="d-block bg-light p-2 rounded mb-3 small">
First Name, Last Name, Street Address, City, State, Zip, Phone, Email, Voter ID, District, Precinct
</code>
<p class="mb-0 small text-muted">The first row (header) will be automatically skipped.</p>
</div>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-4">
{{ form.as_p }}
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-upload me-2"></i>Start Import
</button>
<a href="{% url 'voter_list' %}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -5,9 +5,6 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Voter Registry</h1>
<div class="d-flex gap-2">
<a href="{% url 'voter_import' %}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-upload me-1"></i> Import Voters
</a>
<a href="/admin/core/voter/add/" class="btn btn-primary btn-sm">+ Add New Voter</a>
</div>
</div>

View File

@ -5,7 +5,6 @@ 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/import/', views.voter_import, name='voter_import'),
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

@ -39,7 +39,7 @@ def index(request):
'total_registered_voters': voters.count(),
'total_target_voters': voters.filter(is_targeted=True).count(),
'total_supporting': voters.filter(candidate_support='supporting').count(),
'total_voter_addresses': voters.values('address').distinct().count(),
'total_target_households': voters.filter(is_targeted=True).exclude(address='').values('address').distinct().count(),
'total_door_visits': Interaction.objects.filter(voter__tenant=selected_tenant, type__name='Door Visit').count(),
'total_signs': voters.filter(Q(yard_sign='wants') | Q(yard_sign='has')).count(),
'total_window_stickers': voters.filter(Q(window_sticker='wants') | Q(window_sticker='has')).count(),
@ -119,68 +119,6 @@ def voter_list(request):
}
return render(request, "core/voter_list.html", context)
def voter_import(request):
"""
Import voters from 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':
form = VoterImportForm(request.POST, request.FILES)
if form.is_valid():
csv_file = request.FILES['file']
if not csv_file.name.endswith('.csv'):
messages.error(request, 'Please upload a CSV file.')
return redirect('voter_import')
data_set = csv_file.read().decode('UTF-8')
io_string = io.StringIO(data_set)
next(io_string) # skip header
count = 0
errors = 0
for row in csv.reader(io_string, delimiter=',', quotechar='"'):
try:
# CSV Format: first_name, last_name, address_street, city, state, zip_code, phone, email, voter_id, district, precinct
# Ensure row has enough columns
if len(row) < 2: continue
voter, created = Voter.objects.update_or_create(
tenant=tenant,
voter_id=row[8] if len(row) > 8 else '',
defaults={
'first_name': row[0],
'last_name': row[1],
'address_street': row[2] if len(row) > 2 else '',
'city': row[3] if len(row) > 3 else '',
'state': row[4] if len(row) > 4 else '',
'zip_code': row[5] if len(row) > 5 else '',
'phone': row[6] if len(row) > 6 else '',
'email': row[7] if len(row) > 7 else '',
'district': row[9] if len(row) > 9 else '',
'precinct': row[10] if len(row) > 10 else '',
}
)
count += 1
except Exception as e:
logger.error(f"Error importing row {row}: {e}")
errors += 1
if count > 0:
messages.success(request, f"Successfully imported {count} voters.")
if errors > 0:
messages.warning(request, f"Failed to import {errors} rows. Check logs for details.")
return redirect('voter_list')
else:
form = VoterImportForm()
return render(request, 'core/voter_import.html', {'form': form, 'selected_tenant': tenant})
def voter_detail(request, voter_id):
"""
@ -449,4 +387,4 @@ def voter_geocode(request, voter_id):
'error': f"Geocoding failed: {error_msg or 'No results found.'}"
})
return JsonResponse({'success': False, 'error': 'Invalid request method.'})
return JsonResponse({'success': False, 'error': 'Invalid request method.'})