Autosave: 20260125-000522

This commit is contained in:
Flatlogic Bot 2026-01-25 00:05:22 +00:00
parent e4aeae1b74
commit 0ef73ff181
19 changed files with 372 additions and 97 deletions

View File

@ -1,18 +1,22 @@
from django.contrib import admin from django.contrib import admin
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 VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings
) )
class TenantUserRoleInline(admin.TabularInline): class TenantUserRoleInline(admin.TabularInline):
model = TenantUserRole model = TenantUserRole
extra = 1 extra = 1
class CampaignSettingsInline(admin.StackedInline):
model = CampaignSettings
can_delete = False
@admin.register(Tenant) @admin.register(Tenant)
class TenantAdmin(admin.ModelAdmin): class TenantAdmin(admin.ModelAdmin):
list_display = ('name', 'slug', 'created_at') list_display = ('name', 'slug', 'created_at')
search_fields = ('name',) search_fields = ('name',)
inlines = [TenantUserRoleInline] inlines = [TenantUserRoleInline, CampaignSettingsInline]
@admin.register(TenantUserRole) @admin.register(TenantUserRole)
class TenantUserRoleAdmin(admin.ModelAdmin): class TenantUserRoleAdmin(admin.ModelAdmin):
@ -75,4 +79,9 @@ class EventAdmin(admin.ModelAdmin):
@admin.register(EventParticipation) @admin.register(EventParticipation)
class EventParticipationAdmin(admin.ModelAdmin): class EventParticipationAdmin(admin.ModelAdmin):
list_display = ('voter', 'event', 'participation_type') list_display = ('voter', 'event', 'participation_type')
list_filter = ('event__tenant', 'event', 'participation_type') list_filter = ('event__tenant', 'event', 'participation_type')
@admin.register(CampaignSettings)
class CampaignSettingsAdmin(admin.ModelAdmin):
list_display = ('tenant', 'donation_goal')
list_filter = ('tenant',)

View File

@ -8,7 +8,7 @@ class VoterForm(forms.ModelForm):
'first_name', 'last_name', 'address_street', 'city', 'state', 'first_name', 'last_name', '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' 'registration_date', 'is_targeted', 'candidate_support', 'yard_sign', 'window_sticker'
] ]
widgets = { widgets = {
'registration_date': forms.DateInput(attrs={'type': 'date'}), 'registration_date': forms.DateInput(attrs={'type': 'date'}),
@ -28,6 +28,7 @@ class VoterForm(forms.ModelForm):
self.fields['candidate_support'].widget.attrs.update({'class': 'form-select'}) self.fields['candidate_support'].widget.attrs.update({'class': 'form-select'})
self.fields['yard_sign'].widget.attrs.update({'class': 'form-select'}) self.fields['yard_sign'].widget.attrs.update({'class': 'form-select'})
self.fields['window_sticker'].widget.attrs.update({'class': 'form-select'})
class InteractionForm(forms.ModelForm): class InteractionForm(forms.ModelForm):
class Meta: class Meta:
@ -106,3 +107,10 @@ class EventForm(forms.ModelForm):
for field in self.fields.values(): for field in self.fields.values():
field.widget.attrs.update({'class': 'form-control'}) field.widget.attrs.update({'class': 'form-control'})
self.fields['event_type'].widget.attrs.update({'class': 'form-select'}) self.fields['event_type'].widget.attrs.update({'class': 'form-select'})
class VoterImportForm(forms.Form):
file = forms.FileField(label="Select CSV file")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['file'].widget.attrs.update({'class': 'form-control'})

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-01-24 23:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_alter_voter_latitude_alter_voter_longitude'),
]
operations = [
migrations.AddField(
model_name='voter',
name='window_sticker',
field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker')], default='none', max_length=20),
),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 5.2.7 on 2026-01-24 23:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_voter_window_sticker'),
]
operations = [
migrations.AlterField(
model_name='voter',
name='window_sticker',
field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker')], default='none', max_length=20, verbose_name='Window Sticker Status'),
),
migrations.CreateModel(
name='CampaignSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('donation_goal', models.DecimalField(decimal_places=2, default=170000.0, max_digits=12)),
('tenant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='core.tenant')),
],
options={
'verbose_name': 'Campaign Settings',
'verbose_name_plural': 'Campaign Settings',
},
),
]

View File

@ -95,6 +95,11 @@ class Voter(models.Model):
('wants', 'Wants a yard sign'), ('wants', 'Wants a yard sign'),
('has', 'Has a yard sign'), ('has', 'Has a yard sign'),
] ]
WINDOW_STICKER_CHOICES = [
('none', 'None'),
('wants', 'Wants Sticker'),
('has', 'Has Sticker'),
]
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)
@ -116,6 +121,7 @@ class Voter(models.Model):
is_targeted = models.BooleanField(default=False) is_targeted = models.BooleanField(default=False)
candidate_support = models.CharField(max_length=20, choices=SUPPORT_CHOICES, default='unknown') candidate_support = models.CharField(max_length=20, choices=SUPPORT_CHOICES, default='unknown')
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')
window_sticker = models.CharField(max_length=20, choices=WINDOW_STICKER_CHOICES, default='none', verbose_name='Window Sticker Status')
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@ -286,4 +292,14 @@ class VoterLikelihood(models.Model):
unique_together = ('voter', 'election_type') unique_together = ('voter', 'election_type')
def __str__(self): def __str__(self):
return f"{self.voter} - {self.election_type}: {self.get_likelihood_display()}" return f"{self.voter} - {self.election_type}: {self.get_likelihood_display()}"
class CampaignSettings(models.Model):
tenant = models.OneToOneField(Tenant, on_delete=models.CASCADE, related_name='settings')
donation_goal = models.DecimalField(max_digits=12, decimal_places=2, default=170000.00)
class Meta:
verbose_name = 'Campaign Settings'
verbose_name_plural = 'Campaign Settings'
def __str__(self):
return f'Settings for {self.tenant.name}'

View File

@ -31,14 +31,18 @@
{% else %} {% else %}
<div class="row"> <div class="row">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h2 fw-bold">Dashboard: <span class="text-emerald">{{ selected_tenant.name }}</span></h1> <h1 class="h2 fw-bold mb-0">Dashboard: <span class="text-emerald">{{ selected_tenant.name }}</span></h1>
<div class="d-flex align-items-center"> <a href="{% url 'index' %}" class="btn btn-outline-secondary btn-sm">Switch Campaign</a>
<form action="{% url 'voter_list' %}" method="GET" class="d-flex me-3"> </div>
<input type="text" name="q" class="form-control form-control-sm me-2" placeholder="Quick Search..." required>
<button type="submit" class="btn btn-emerald btn-sm px-3">Search</button> <!-- Search Box on its own line -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
<form action="{% url 'voter_list' %}" method="GET" class="d-flex">
<input type="text" name="q" class="form-control me-2" placeholder="Quick Search by name, address, or voter ID..." required>
<button type="submit" class="btn btn-emerald px-4">Search Registry</button>
</form> </form>
<a href="{% url 'index' %}" class="btn btn-outline-secondary btn-sm">Switch Campaign</a>
</div> </div>
</div> </div>
</div> </div>
@ -46,60 +50,84 @@
<!-- Metrics Cards --> <!-- Metrics Cards -->
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-md-4 col-lg-3"> <div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm h-100"> <a href="{% url 'voter_list' %}" class="text-decoration-none">
<div class="card-body text-center p-3"> <div class="card border-0 shadow-sm h-100 hover-lift transition">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Registered Voters</h6> <div class="card-body text-center p-3">
<p class="display-6 fw-bold mb-0 text-dark">{{ metrics.total_registered_voters }}</p> <h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Registered Voters</h6>
<p class="display-6 fw-bold mb-0 text-dark">{{ metrics.total_registered_voters }}</p>
</div>
</div> </div>
</div> </a>
</div> </div>
<div class="col-md-4 col-lg-3"> <div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm h-100"> <a href="{% url 'voter_list' %}?is_targeted=true" class="text-decoration-none">
<div class="card-body text-center p-3"> <div class="card border-0 shadow-sm h-100 hover-lift transition">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Target Voters</h6> <div class="card-body text-center p-3">
<p class="display-6 fw-bold mb-0 text-primary">{{ metrics.total_target_voters }}</p> <h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Target Voters</h6>
<p class="display-6 fw-bold mb-0 text-primary">{{ metrics.total_target_voters }}</p>
</div>
</div> </div>
</div> </a>
</div> </div>
<div class="col-md-4 col-lg-3"> <div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm h-100"> <a href="{% url 'voter_list' %}?support=supporting" class="text-decoration-none">
<div class="card-body text-center p-3"> <div class="card border-0 shadow-sm h-100 hover-lift transition">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Supporting</h6> <div class="card-body text-center p-3">
<p class="display-6 fw-bold mb-0 text-success">{{ metrics.total_supporting }}</p> <h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Supporting</h6>
<p class="display-6 fw-bold mb-0 text-success">{{ metrics.total_supporting }}</p>
</div>
</div> </div>
</div> </a>
</div> </div>
<div class="col-md-4 col-lg-3"> <div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm h-100"> <a href="{% url 'voter_list' %}?has_address=true" class="text-decoration-none">
<div class="card-body text-center p-3"> <div class="card border-0 shadow-sm h-100 hover-lift transition">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Voter Addresses</h6> <div class="card-body text-center p-3">
<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">Voter Addresses</h6>
<p class="display-6 fw-bold mb-0 text-info">{{ metrics.total_voter_addresses }}</p>
</div>
</div> </div>
</div> </a>
</div> </div>
<div class="col-md-4 col-lg-3"> <div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm h-100"> <a href="{% url 'voter_list' %}?visited=true" class="text-decoration-none">
<div class="card-body text-center p-3"> <div class="card border-0 shadow-sm h-100 hover-lift transition">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Door Visits</h6> <div class="card-body text-center p-3">
<p class="display-6 fw-bold mb-0 text-warning">{{ metrics.total_door_visits }}</p> <h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Door Visits</h6>
<p class="display-6 fw-bold mb-0 text-warning">{{ metrics.total_door_visits }}</p>
</div>
</div> </div>
</div> </a>
</div> </div>
<div class="col-md-4 col-lg-3"> <div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm h-100"> <a href="{% url 'voter_list' %}?yard_sign=true" class="text-decoration-none">
<div class="card-body text-center p-3"> <div class="card border-0 shadow-sm h-100 hover-lift transition">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Signs (Wants/Has)</h6> <div class="card-body text-center p-3">
<p class="display-6 fw-bold mb-0 text-danger">{{ metrics.total_signs }}</p> <h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Signs (Wants/Has)</h6>
<p class="display-6 fw-bold mb-0 text-danger">{{ metrics.total_signs }}</p>
</div>
</div> </div>
</div> </a>
</div> </div>
<div class="col-md-4 col-lg-3"> <div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm h-100"> <a href="{% url 'voter_list' %}?window_sticker=true" class="text-decoration-none">
<div class="card-body text-center p-3"> <div class="card border-0 shadow-sm h-100 hover-lift transition">
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Total Donations</h6> <div class="card-body text-center p-3">
<p class="display-6 fw-bold mb-0 text-emerald">${{ metrics.total_donations|floatformat:2 }}</p> <h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Window Stickers (Wants/Has)</h6>
<p class="display-6 fw-bold mb-0 text-indigo">{{ metrics.total_window_stickers }}</p>
</div>
</div> </div>
</div> </a>
</div>
<div class="col-md-4 col-lg-3">
<a href="{% url 'voter_list' %}?has_donations=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">Donation Goal</h6>
<p class="display-6 fw-bold mb-0 text-emerald">{{ metrics.donation_percentage }}%</p>
</div>
</div>
</a>
</div> </div>
</div> </div>
@ -117,4 +145,14 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endblock %}
<style>
.hover-lift {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.hover-lift:hover {
transform: translateY(-5px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
</style>
{% endblock %}

View File

@ -137,7 +137,7 @@
<h5 class="card-title mb-0">Campaign Assets</h5> <h5 class="card-title mb-0">Campaign Assets</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center mb-3">
<div class="flex-grow-1"> <div class="flex-grow-1">
<span class="text-muted">Yard Sign Status</span> <span class="text-muted">Yard Sign Status</span>
</div> </div>
@ -151,6 +151,20 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<span class="text-muted">Window Sticker Status</span>
</div>
<div>
{% if voter.window_sticker == 'has' %}
<span class="badge bg-warning text-dark">Has Sticker</span>
{% elif voter.window_sticker == 'wants' %}
<span class="badge bg-info">Wants Sticker</span>
{% else %}
<span class="badge bg-light text-dark border">None</span>
{% endif %}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -441,6 +455,10 @@
<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">
<label class="form-label fw-medium">{{ voter_form.window_sticker.label }}</label>
{{ voter_form.window_sticker }}
</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">
@ -960,5 +978,4 @@
} }
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,38 @@
{% 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

@ -4,7 +4,12 @@
<div class="container py-5"> <div class="container py-5">
<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>
<a href="/admin/core/voter/add/" class="btn btn-primary btn-sm">+ Add New Voter</a> <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> </div>
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
@ -25,48 +30,44 @@
<table class="table table-hover mb-0 align-middle"> <table class="table table-hover mb-0 align-middle">
<thead class="bg-light"> <thead class="bg-light">
<tr> <tr>
<th class="ps-4">Voter ID</th> <th class="ps-4">Name</th>
<th>Name</th>
<th>District</th> <th>District</th>
<th>Support</th> <th>Phone</th>
<th>Yard Sign</th> <th>Target Voter</th>
<th class="text-end pe-4">Actions</th> <th class="pe-4">Supporter</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for voter in voters %} {% for voter in voters %}
<tr> <tr>
<td class="ps-4"><code>{{ voter.voter_id|default:"N/A" }}</code></td> <td class="ps-4">
<td> <a href="{% url 'voter_detail' voter.id %}" class="fw-semibold text-primary text-decoration-none d-block">
<div class="fw-semibold text-dark">{{ voter.first_name }} {{ voter.last_name }}</div> {{ voter.first_name }} {{ voter.last_name }}
<div class="small text-muted">{{ voter.address|truncatechars:40 }}</div> </a>
<div class="small text-muted">{{ voter.address|default:"No address provided" }}</div>
</td> </td>
<td><span class="badge bg-light text-dark border">{{ voter.district|default:"-" }}</span></td> <td><span class="badge bg-light text-dark border">{{ voter.district|default:"-" }}</span></td>
<td>{{ voter.phone|default:"-" }}</td>
<td> <td>
{% if voter.is_targeted %}
<span class="badge bg-primary">Yes</span>
{% else %}
<span class="badge bg-secondary-subtle text-secondary">No</span>
{% endif %}
</td>
<td class="pe-4">
{% if voter.candidate_support == 'supporting' %} {% if voter.candidate_support == 'supporting' %}
<span class="badge bg-success-subtle text-success">Supporting</span> <span class="badge bg-success">Supporting</span>
{% elif voter.candidate_support == 'not_supporting' %} {% elif voter.candidate_support == 'not_supporting' %}
<span class="badge bg-danger-subtle text-danger">Not Supporting</span> <span class="badge bg-danger">Not Supporting</span>
{% else %} {% else %}
<span class="badge bg-secondary-subtle text-secondary">Unknown</span> <span class="badge bg-secondary">Unknown</span>
{% endif %} {% endif %}
</td> </td>
<td>
{% if voter.yard_sign == 'has' %}
<span class="badge bg-warning-subtle text-warning-emphasis">Has Sign</span>
{% elif voter.yard_sign == 'wants' %}
<span class="badge bg-info-subtle text-info-emphasis">Wants Sign</span>
{% else %}
<span class="text-muted small">None</span>
{% endif %}
</td>
<td class="text-end pe-4">
<a href="{% url 'voter_detail' voter.id %}" class="btn btn-sm btn-outline-primary">View 360&deg;</a>
</td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="6" class="text-center py-5 text-muted"> <td colspan="5" class="text-center py-5 text-muted">
<p class="mb-0">No voters found matching your search.</p> <p class="mb-0">No voters found matching your search.</p>
</td> </td>
</tr> </tr>
@ -76,4 +77,4 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -5,6 +5,7 @@ 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'),

View File

@ -1,10 +1,12 @@
import csv
import io
from django.http import JsonResponse from django.http import JsonResponse
from django.urls import reverse from django.urls import reverse
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.db.models import Q, Sum from django.db.models import Q, Sum
from django.contrib import messages from django.contrib import messages
from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -23,14 +25,27 @@ def index(request):
selected_tenant = Tenant.objects.filter(id=selected_tenant_id).first() selected_tenant = Tenant.objects.filter(id=selected_tenant_id).first()
if selected_tenant: if selected_tenant:
voters = selected_tenant.voters.all() voters = selected_tenant.voters.all()
total_donations = Donation.objects.filter(voter__tenant=selected_tenant).aggregate(total=Sum('amount'))['total'] or 0
# Get or create settings for the tenant
campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=selected_tenant)
donation_goal = campaign_settings.donation_goal
donation_percentage = 0
if donation_goal > 0:
donation_percentage = float(round((total_donations / donation_goal) * 100, 1))
metrics = { metrics = {
"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_voter_addresses': voters.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_donations": Donation.objects.filter(voter__tenant=selected_tenant).aggregate(total=Sum("amount"))["total"] or 0, 'total_window_stickers': voters.filter(Q(window_sticker='wants') | Q(window_sticker='has')).count(),
'total_donations': float(total_donations),
'donation_goal': float(donation_goal),
'donation_percentage': donation_percentage,
} }
context = { context = {
@ -53,15 +68,31 @@ def voter_list(request):
""" """
List and search voters. Restricted to selected tenant. List and search voters. Restricted to selected tenant.
""" """
selected_tenant_id = request.session.get('tenant_id') selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id: if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.") messages.warning(request, "Please select a campaign first.")
return redirect('index') return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id) tenant = get_object_or_404(Tenant, id=selected_tenant_id)
query = request.GET.get('q') query = request.GET.get("q")
voters = Voter.objects.filter(tenant=tenant) voters = Voter.objects.filter(tenant=tenant).order_by("last_name", "first_name")
# Filtering based on dashboard metrics
if request.GET.get("is_targeted") == "true":
voters = voters.filter(is_targeted=True)
if request.GET.get("support") == "supporting":
voters = voters.filter(candidate_support="supporting")
if request.GET.get("has_address") == "true":
voters = voters.exclude(address__isnull=True).exclude(address="")
if request.GET.get("visited") == "true":
voters = voters.filter(interactions__type__name="Door Visit").distinct()
if request.GET.get("yard_sign") == "true":
voters = voters.filter(Q(yard_sign="wants") | Q(yard_sign="has"))
if request.GET.get("window_sticker") == "true":
voters = voters.filter(Q(window_sticker="wants") | Q(window_sticker="has"))
if request.GET.get("has_donations") == "true":
voters = voters.filter(donations__isnull=False).distinct()
if query: if query:
query = query.strip() query = query.strip()
search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(voter_id__icontains=query) search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(voter_id__icontains=query)
@ -79,14 +110,77 @@ def voter_list(request):
last_part = " ".join(parts[1:]) last_part = " ".join(parts[1:])
search_filter |= Q(first_name__icontains=first_part, last_name__icontains=last_part) search_filter |= Q(first_name__icontains=first_part, last_name__icontains=last_part)
voters = voters.filter(search_filter) voters = voters.filter(search_filter).order_by("last_name", "first_name")
context = { context = {
'voters': voters, "voters": voters,
'query': query, "query": query,
'selected_tenant': tenant "selected_tenant": tenant
} }
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):
""" """

4
core/views.py.tmp Normal file
View File

@ -0,0 +1,4 @@
if request.GET.get("yard_sign") == "true":
voters = voters.filter(Q(yard_sign="wants") | Q(yard_sign="has"))
if request.GET.get("window_sticker") == "true":
voters = voters.filter(Q(window_sticker="wants") | Q(window_sticker="has"))