Autosave: 20260125-000522
This commit is contained in:
parent
e4aeae1b74
commit
0ef73ff181
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,18 +1,22 @@
|
||||
from django.contrib import admin
|
||||
from .models import (
|
||||
Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,
|
||||
VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood
|
||||
VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings
|
||||
)
|
||||
|
||||
class TenantUserRoleInline(admin.TabularInline):
|
||||
model = TenantUserRole
|
||||
extra = 1
|
||||
|
||||
class CampaignSettingsInline(admin.StackedInline):
|
||||
model = CampaignSettings
|
||||
can_delete = False
|
||||
|
||||
@admin.register(Tenant)
|
||||
class TenantAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'slug', 'created_at')
|
||||
search_fields = ('name',)
|
||||
inlines = [TenantUserRoleInline]
|
||||
inlines = [TenantUserRoleInline, CampaignSettingsInline]
|
||||
|
||||
@admin.register(TenantUserRole)
|
||||
class TenantUserRoleAdmin(admin.ModelAdmin):
|
||||
@ -76,3 +80,8 @@ class EventAdmin(admin.ModelAdmin):
|
||||
class EventParticipationAdmin(admin.ModelAdmin):
|
||||
list_display = ('voter', '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',)
|
||||
@ -8,7 +8,7 @@ class VoterForm(forms.ModelForm):
|
||||
'first_name', 'last_name', 'address_street', 'city', 'state',
|
||||
'zip_code', 'county', 'latitude', 'longitude',
|
||||
'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 = {
|
||||
'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['yard_sign'].widget.attrs.update({'class': 'form-select'})
|
||||
self.fields['window_sticker'].widget.attrs.update({'class': 'form-select'})
|
||||
|
||||
class InteractionForm(forms.ModelForm):
|
||||
class Meta:
|
||||
@ -106,3 +107,10 @@ class EventForm(forms.ModelForm):
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
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'})
|
||||
18
core/migrations/0009_voter_window_sticker.py
Normal file
18
core/migrations/0009_voter_window_sticker.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@ -95,6 +95,11 @@ class Voter(models.Model):
|
||||
('wants', 'Wants 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')
|
||||
voter_id = models.CharField(max_length=50, blank=True)
|
||||
@ -116,6 +121,7 @@ class Voter(models.Model):
|
||||
is_targeted = models.BooleanField(default=False)
|
||||
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')
|
||||
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)
|
||||
|
||||
@ -287,3 +293,13 @@ class VoterLikelihood(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
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}'
|
||||
|
||||
@ -31,75 +31,103 @@
|
||||
{% else %}
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h2 fw-bold">Dashboard: <span class="text-emerald">{{ selected_tenant.name }}</span></h1>
|
||||
<div class="d-flex align-items-center">
|
||||
<form action="{% url 'voter_list' %}" method="GET" class="d-flex me-3">
|
||||
<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>
|
||||
</form>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h2 fw-bold mb-0">Dashboard: <span class="text-emerald">{{ selected_tenant.name }}</span></h1>
|
||||
<a href="{% url 'index' %}" class="btn btn-outline-secondary btn-sm">Switch Campaign</a>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Cards -->
|
||||
<div class="row g-4 mb-4">
|
||||
<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 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">Registered Voters</h6>
|
||||
<p class="display-6 fw-bold mb-0 text-dark">{{ metrics.total_registered_voters }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<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 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">Target Voters</h6>
|
||||
<p class="display-6 fw-bold mb-0 text-primary">{{ metrics.total_target_voters }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<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 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">Supporting</h6>
|
||||
<p class="display-6 fw-bold mb-0 text-success">{{ metrics.total_supporting }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<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 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">Door Visits</h6>
|
||||
<p class="display-6 fw-bold mb-0 text-warning">{{ metrics.total_door_visits }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<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 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">Signs (Wants/Has)</h6>
|
||||
<p class="display-6 fw-bold mb-0 text-danger">{{ metrics.total_signs }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<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 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">Total Donations</h6>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@ -117,4 +145,14 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
@ -137,7 +137,7 @@
|
||||
<h5 class="card-title mb-0">Campaign Assets</h5>
|
||||
</div>
|
||||
<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">
|
||||
<span class="text-muted">Yard Sign Status</span>
|
||||
</div>
|
||||
@ -151,6 +151,20 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
@ -441,6 +455,10 @@
|
||||
<label class="form-label fw-medium">{{ voter_form.yard_sign.label }}</label>
|
||||
{{ voter_form.yard_sign }}
|
||||
</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 class="modal-footer border-0 p-4 pt-0">
|
||||
@ -961,4 +979,3 @@
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
38
core/templates/core/voter_import.html
Normal file
38
core/templates/core/voter_import.html
Normal 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 %}
|
||||
@ -4,8 +4,13 @@
|
||||
<div class="container py-5">
|
||||
<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>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
@ -25,48 +30,44 @@
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">Voter ID</th>
|
||||
<th>Name</th>
|
||||
<th class="ps-4">Name</th>
|
||||
<th>District</th>
|
||||
<th>Support</th>
|
||||
<th>Yard Sign</th>
|
||||
<th class="text-end pe-4">Actions</th>
|
||||
<th>Phone</th>
|
||||
<th>Target Voter</th>
|
||||
<th class="pe-4">Supporter</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for voter in voters %}
|
||||
<tr>
|
||||
<td class="ps-4"><code>{{ voter.voter_id|default:"N/A" }}</code></td>
|
||||
<td>
|
||||
<div class="fw-semibold text-dark">{{ voter.first_name }} {{ voter.last_name }}</div>
|
||||
<div class="small text-muted">{{ voter.address|truncatechars:40 }}</div>
|
||||
<td class="ps-4">
|
||||
<a href="{% url 'voter_detail' voter.id %}" class="fw-semibold text-primary text-decoration-none d-block">
|
||||
{{ voter.first_name }} {{ voter.last_name }}
|
||||
</a>
|
||||
<div class="small text-muted">{{ voter.address|default:"No address provided" }}</div>
|
||||
</td>
|
||||
<td><span class="badge bg-light text-dark border">{{ voter.district|default:"-" }}</span></td>
|
||||
<td>{{ voter.phone|default:"-" }}</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' %}
|
||||
<span class="badge bg-success-subtle text-success">Supporting</span>
|
||||
<span class="badge bg-success">Supporting</span>
|
||||
{% 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 %}
|
||||
<span class="badge bg-secondary-subtle text-secondary">Unknown</span>
|
||||
<span class="badge bg-secondary">Unknown</span>
|
||||
{% endif %}
|
||||
</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°</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -5,6 +5,7 @@ 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'),
|
||||
|
||||
130
core/views.py
130
core/views.py
@ -1,10 +1,12 @@
|
||||
import csv
|
||||
import io
|
||||
from django.http import JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.db.models import Q, Sum
|
||||
from django.contrib import messages
|
||||
from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType
|
||||
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm
|
||||
from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings
|
||||
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -23,14 +25,27 @@ def index(request):
|
||||
selected_tenant = Tenant.objects.filter(id=selected_tenant_id).first()
|
||||
if selected_tenant:
|
||||
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 = {
|
||||
"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_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_donations": Donation.objects.filter(voter__tenant=selected_tenant).aggregate(total=Sum("amount"))["total"] or 0,
|
||||
'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_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(),
|
||||
'total_donations': float(total_donations),
|
||||
'donation_goal': float(donation_goal),
|
||||
'donation_percentage': donation_percentage,
|
||||
}
|
||||
|
||||
context = {
|
||||
@ -53,14 +68,30 @@ def voter_list(request):
|
||||
"""
|
||||
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:
|
||||
messages.warning(request, "Please select a campaign first.")
|
||||
return redirect('index')
|
||||
return redirect("index")
|
||||
|
||||
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
|
||||
query = request.GET.get('q')
|
||||
voters = Voter.objects.filter(tenant=tenant)
|
||||
query = request.GET.get("q")
|
||||
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:
|
||||
query = query.strip()
|
||||
@ -79,14 +110,77 @@ def voter_list(request):
|
||||
last_part = " ".join(parts[1:])
|
||||
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 = {
|
||||
'voters': voters,
|
||||
'query': query,
|
||||
'selected_tenant': tenant
|
||||
"voters": voters,
|
||||
"query": query,
|
||||
"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):
|
||||
"""
|
||||
|
||||
4
core/views.py.tmp
Normal file
4
core/views.py.tmp
Normal 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"))
|
||||
Loading…
x
Reference in New Issue
Block a user