.9
This commit is contained in:
parent
0ef73ff181
commit
443505ace5
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
254
core/admin.py
254
core/admin.py
@ -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 (
|
from .models import (
|
||||||
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
|
||||||
)
|
)
|
||||||
|
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):
|
class TenantUserRoleInline(admin.TabularInline):
|
||||||
model = TenantUserRole
|
model = TenantUserRole
|
||||||
@ -66,15 +107,222 @@ class VoterLikelihoodInline(admin.TabularInline):
|
|||||||
|
|
||||||
@admin.register(Voter)
|
@admin.register(Voter)
|
||||||
class VoterAdmin(admin.ModelAdmin):
|
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')
|
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]
|
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)
|
@admin.register(Event)
|
||||||
class EventAdmin(admin.ModelAdmin):
|
class EventAdmin(admin.ModelAdmin):
|
||||||
list_display = ('event_type', 'date', 'tenant')
|
list_display = ('event_type', 'date', 'tenant')
|
||||||
list_filter = ('tenant', 'date', 'event_type')
|
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)
|
@admin.register(EventParticipation)
|
||||||
class EventParticipationAdmin(admin.ModelAdmin):
|
class EventParticipationAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
from django import forms
|
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 VoterForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Voter
|
model = Voter
|
||||||
fields = [
|
fields = [
|
||||||
'first_name', 'last_name', 'address_street', 'city', 'state',
|
'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state',
|
||||||
'zip_code', 'county', 'latitude', 'longitude',
|
'zip_code', 'county', 'latitude', 'longitude',
|
||||||
'phone', 'email', 'voter_id', 'district', 'precinct',
|
'phone', '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 = {
|
||||||
|
'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'}),
|
||||||
@ -109,8 +110,19 @@ class EventForm(forms.ModelForm):
|
|||||||
self.fields['event_type'].widget.attrs.update({'class': 'form-select'})
|
self.fields['event_type'].widget.attrs.update({'class': 'form-select'})
|
||||||
|
|
||||||
class VoterImportForm(forms.Form):
|
class VoterImportForm(forms.Form):
|
||||||
|
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
|
||||||
file = forms.FileField(label="Select CSV file")
|
file = forms.FileField(label="Select CSV file")
|
||||||
|
|
||||||
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['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'})
|
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||||
23
core/migrations/0011_voter_birthdate_voter_nickname.py
Normal file
23
core/migrations/0011_voter_birthdate_voter_nickname.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
Binary file not shown.
@ -105,6 +105,8 @@ class Voter(models.Model):
|
|||||||
voter_id = models.CharField(max_length=50, blank=True)
|
voter_id = models.CharField(max_length=50, blank=True)
|
||||||
first_name = models.CharField(max_length=100)
|
first_name = models.CharField(max_length=100)
|
||||||
last_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 = models.TextField(blank=True)
|
||||||
address_street = models.CharField(max_length=255, blank=True)
|
address_street = models.CharField(max_length=255, blank=True)
|
||||||
city = models.CharField(max_length=100, blank=True)
|
city = models.CharField(max_length=100, blank=True)
|
||||||
|
|||||||
7
core/templates/admin/event_change_list.html
Normal file
7
core/templates/admin/event_change_list.html
Normal 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 %}
|
||||||
42
core/templates/admin/import_csv.html
Normal file
42
core/templates/admin/import_csv.html
Normal 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>
|
||||||
|
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||||
|
› {{ 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 %}
|
||||||
48
core/templates/admin/import_mapping.html
Normal file
48
core/templates/admin/import_mapping.html
Normal 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>
|
||||||
|
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||||
|
› {% 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 %}
|
||||||
7
core/templates/admin/voter_change_list.html
Normal file
7
core/templates/admin/voter_change_list.html
Normal 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 %}
|
||||||
@ -80,11 +80,11 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 col-lg-3">
|
<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 border-0 shadow-sm h-100 hover-lift transition">
|
||||||
<div class="card-body text-center p-3">
|
<div class="card-body text-center p-3">
|
||||||
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Voter Addresses</h6>
|
<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_voter_addresses }}</p>
|
<p class="display-6 fw-bold mb-0 text-info">{{ metrics.total_target_households }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
<ol class="breadcrumb">
|
<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 '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"><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>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@ -21,12 +21,12 @@
|
|||||||
<div class="row align-items-center">
|
<div class="row align-items-center">
|
||||||
<div class="col-md-auto mb-3 mb-md-0">
|
<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;">
|
<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>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="d-flex align-items-center">
|
<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">
|
<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
|
<i class="bi bi-pencil me-1"></i>Edit
|
||||||
</button>
|
</button>
|
||||||
@ -89,6 +89,10 @@
|
|||||||
<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>
|
||||||
</li>
|
</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>
|
<li>
|
||||||
<label class="small text-muted d-block">Registration Date</label>
|
<label class="small text-muted d-block">Registration Date</label>
|
||||||
<span class="fw-semibold">{{ voter.registration_date|date:"M d, Y"|default:"Unknown" }}</span>
|
<span class="fw-semibold">{{ voter.registration_date|date:"M d, Y"|default:"Unknown" }}</span>
|
||||||
@ -373,15 +377,19 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="modal-body p-4">
|
<div class="modal-body p-4">
|
||||||
<div class="row">
|
<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>
|
<label class="form-label fw-medium">{{ voter_form.first_name.label }}</label>
|
||||||
{{ voter_form.first_name }}
|
{{ voter_form.first_name }}
|
||||||
</div>
|
</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>
|
<label class="form-label fw-medium">{{ voter_form.last_name.label }}</label>
|
||||||
{{ voter_form.last_name }}
|
{{ voter_form.last_name }}
|
||||||
</div>
|
</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>
|
<label class="form-label fw-medium">Address Street</label>
|
||||||
{{ voter_form.address_street }}
|
{{ voter_form.address_street }}
|
||||||
</div>
|
</div>
|
||||||
@ -435,11 +443,15 @@
|
|||||||
<label class="form-label fw-medium">{{ voter_form.precinct.label }}</label>
|
<label class="form-label fw-medium">{{ voter_form.precinct.label }}</label>
|
||||||
{{ voter_form.precinct }}
|
{{ voter_form.precinct }}
|
||||||
</div>
|
</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">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label fw-medium">{{ voter_form.registration_date.label }}</label>
|
<label class="form-label fw-medium">{{ voter_form.registration_date.label }}</label>
|
||||||
{{ voter_form.registration_date }}
|
{{ voter_form.registration_date }}
|
||||||
</div>
|
</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">
|
<div class="form-check">
|
||||||
{{ voter_form.is_targeted }}
|
{{ voter_form.is_targeted }}
|
||||||
<label class="form-check-label fw-medium" for="{{ voter_form.is_targeted.id_for_label }}">
|
<label class="form-check-label fw-medium" for="{{ voter_form.is_targeted.id_for_label }}">
|
||||||
@ -447,15 +459,15 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<label class="form-label fw-medium">{{ voter_form.candidate_support.label }}</label>
|
||||||
{{ voter_form.candidate_support }}
|
{{ voter_form.candidate_support }}
|
||||||
</div>
|
</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>
|
<label class="form-label fw-medium">{{ voter_form.yard_sign.label }}</label>
|
||||||
{{ voter_form.yard_sign }}
|
{{ voter_form.yard_sign }}
|
||||||
</div>
|
</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>
|
<label class="form-label fw-medium">{{ voter_form.window_sticker.label }}</label>
|
||||||
{{ voter_form.window_sticker }}
|
{{ voter_form.window_sticker }}
|
||||||
</div>
|
</div>
|
||||||
@ -906,10 +918,10 @@
|
|||||||
const marker = new google.maps.Marker({
|
const marker = new google.maps.Marker({
|
||||||
position: position,
|
position: position,
|
||||||
map: map,
|
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({
|
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", () => {
|
marker.addListener("click", () => {
|
||||||
infowindow.open(map, marker);
|
infowindow.open(map, marker);
|
||||||
|
|||||||
@ -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 %}
|
|
||||||
@ -5,9 +5,6 @@
|
|||||||
<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_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>
|
<a href="/admin/core/voter/add/" class="btn btn-primary btn-sm">+ Add New Voter</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,7 +5,6 @@ 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/import/', views.voter_import, name='voter_import'),
|
|
||||||
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'),
|
||||||
|
|||||||
@ -39,7 +39,7 @@ def index(request):
|
|||||||
'total_registered_voters': voters.count(),
|
'total_registered_voters': voters.count(),
|
||||||
'total_target_voters': voters.filter(is_targeted=True).count(),
|
'total_target_voters': voters.filter(is_targeted=True).count(),
|
||||||
'total_supporting': voters.filter(candidate_support='supporting').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_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_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(),
|
'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)
|
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):
|
def voter_detail(request, voter_id):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user