diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 1d14269..ccb9d81 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index 6f72c17..def34ca 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 7b0eccf..d5fb453 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 363abb5..57229ea 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index c8bcb73..e1e7dd1 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 35068b0..a3fd971 100644 --- a/core/admin.py +++ b/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 ( 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',) \ No newline at end of file + list_filter = ('tenant',) diff --git a/core/forms.py b/core/forms.py index a025ab4..836d8bd 100644 --- a/core/forms.py +++ b/core/forms.py @@ -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'}) \ No newline at end of file diff --git a/core/migrations/0011_voter_birthdate_voter_nickname.py b/core/migrations/0011_voter_birthdate_voter_nickname.py new file mode 100644 index 0000000..ce248cd --- /dev/null +++ b/core/migrations/0011_voter_birthdate_voter_nickname.py @@ -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), + ), + ] diff --git a/core/migrations/__pycache__/0011_voter_birthdate_voter_nickname.cpython-311.pyc b/core/migrations/__pycache__/0011_voter_birthdate_voter_nickname.cpython-311.pyc new file mode 100644 index 0000000..7fe2c12 Binary files /dev/null and b/core/migrations/__pycache__/0011_voter_birthdate_voter_nickname.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 9034959..023259f 100644 --- a/core/models.py +++ b/core/models.py @@ -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}' \ No newline at end of file diff --git a/core/templates/admin/event_change_list.html b/core/templates/admin/event_change_list.html new file mode 100644 index 0000000..df48ce9 --- /dev/null +++ b/core/templates/admin/event_change_list.html @@ -0,0 +1,7 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +
  • + Import Events +
  • + {{ block.super }} +{% endblock %} diff --git a/core/templates/admin/import_csv.html b/core/templates/admin/import_csv.html new file mode 100644 index 0000000..ccd6b51 --- /dev/null +++ b/core/templates/admin/import_csv.html @@ -0,0 +1,42 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    + {% csrf_token %} +
    +
    +

    Upload a CSV file to import {{ opts.verbose_name_plural }}.

    + {% if title == "Import Voters" %} +

    Expected columns (header mandatory): 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

    + {% else %} +

    Expected columns (header mandatory): date, event_type, description

    + {% endif %} +
    + {% for field in form %} +
    + {{ field.errors }} + + {{ field }} + {% if field.help_text %} +
    {{ field.help_text|safe }}
    + {% endif %} +
    + {% endfor %} +
    +
    + +
    +
    +
    +{% endblock %} diff --git a/core/templates/admin/import_mapping.html b/core/templates/admin/import_mapping.html new file mode 100644 index 0000000..c07c7a1 --- /dev/null +++ b/core/templates/admin/import_mapping.html @@ -0,0 +1,48 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    + {% csrf_token %} + + + +
    +

    {% translate "Map CSV Columns to Model Fields" %}

    +
    + Select which CSV column matches each model field. Leave blank to skip. +
    + + {% for field_name, verbose_name in model_fields %} +
    +
    + + +
    +
    + {% endfor %} +
    + +
    + +
    +
    +
    +{% endblock %} diff --git a/core/templates/admin/voter_change_list.html b/core/templates/admin/voter_change_list.html new file mode 100644 index 0000000..eb36f95 --- /dev/null +++ b/core/templates/admin/voter_change_list.html @@ -0,0 +1,7 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +
  • + Import Voters +
  • + {{ block.super }} +{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index 91ce1a5..f689bb2 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -80,11 +80,11 @@
    - +
    -
    Voter Addresses
    -

    {{ metrics.total_voter_addresses }}

    +
    Target Households
    +

    {{ metrics.total_target_households }}

    @@ -155,4 +155,4 @@ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; } -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/voter_detail.html b/core/templates/core/voter_detail.html index f1895cf..e54d851 100644 --- a/core/templates/core/voter_detail.html +++ b/core/templates/core/voter_detail.html @@ -11,7 +11,7 @@ @@ -21,12 +21,12 @@
    - {{ voter.first_name|first }}{{ voter.last_name|first }} + {% if voter.nickname %}{{ voter.nickname|first }}{% else %}{{ voter.first_name|first }}{% endif %}{{ voter.last_name|first }}
    -

    {{ voter.first_name }} {{ voter.last_name }}

    +

    {% if voter.nickname %}{{ voter.nickname }}{% else %}{{ voter.first_name }}{% endif %} {{ voter.last_name }}

    @@ -89,6 +89,10 @@ {{ voter.phone|default:"N/A" }} +
  • + + {{ voter.birthdate|date:"M d, Y"|default:"N/A" }} +
  • {{ voter.registration_date|date:"M d, Y"|default:"Unknown" }} @@ -373,15 +377,19 @@ {% csrf_token %}