Autosave: 20260126-142846
This commit is contained in:
parent
ac90cc59f4
commit
1dfb7ebbf1
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
169
core/admin.py
169
core/admin.py
@ -16,7 +16,8 @@ from .models import (
|
|||||||
)
|
)
|
||||||
from .forms import (
|
from .forms import (
|
||||||
VoterImportForm, EventImportForm, EventParticipationImportForm,
|
VoterImportForm, EventImportForm, EventParticipationImportForm,
|
||||||
DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm
|
DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm,
|
||||||
|
VolunteerImportForm
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -78,6 +79,14 @@ INTERACTION_MAPPABLE_FIELDS = [
|
|||||||
('notes', 'Notes'),
|
('notes', 'Notes'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
VOLUNTEER_MAPPABLE_FIELDS = [
|
||||||
|
('first_name', 'First Name'),
|
||||||
|
('last_name', 'Last Name'),
|
||||||
|
('email', 'Email'),
|
||||||
|
('phone', 'Phone'),
|
||||||
|
]
|
||||||
|
|
||||||
VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [
|
VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [
|
||||||
('voter_id', 'Voter ID'),
|
('voter_id', 'Voter ID'),
|
||||||
('election_type', 'Election Type (Name)'),
|
('election_type', 'Election Type (Name)'),
|
||||||
@ -565,12 +574,162 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
return render(request, "admin/import_csv.html", context)
|
return render(request, "admin/import_csv.html", context)
|
||||||
|
|
||||||
@admin.register(Volunteer)
|
@admin.register(Volunteer)
|
||||||
class VolunteerAdmin(admin.ModelAdmin):
|
class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||||
list_display = ('name', 'email', 'phone', 'tenant', 'user')
|
list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant', 'user')
|
||||||
list_filter = ('tenant',)
|
list_filter = ('tenant',)
|
||||||
search_fields = ('name', 'email', 'phone')
|
search_fields = ('first_name', 'last_name', 'email', 'phone')
|
||||||
inlines = [VolunteerEventInline, InteractionInline]
|
inlines = [VolunteerEventInline, InteractionInline]
|
||||||
filter_horizontal = ('interests',)
|
filter_horizontal = ('interests',)
|
||||||
|
change_list_template = "admin/volunteer_change_list.html"
|
||||||
|
|
||||||
|
def changelist_view(self, request, extra_context=None):
|
||||||
|
extra_context = extra_context or {}
|
||||||
|
from core.models import Tenant
|
||||||
|
extra_context["tenants"] = Tenant.objects.all()
|
||||||
|
return super().changelist_view(request, extra_context=extra_context)
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
urls = super().get_urls()
|
||||||
|
my_urls = [
|
||||||
|
path('download-errors/', self.admin_site.admin_view(self.download_errors), name='volunteer-download-errors'),
|
||||||
|
path('import-volunteers/', self.admin_site.admin_view(self.import_volunteers), name='import-volunteers'),
|
||||||
|
]
|
||||||
|
return my_urls + urls
|
||||||
|
|
||||||
|
def import_volunteers(self, request):
|
||||||
|
if request.method == "POST":
|
||||||
|
if "_preview" 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 VOLUNTEER_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)
|
||||||
|
total_count = 0
|
||||||
|
create_count = 0
|
||||||
|
update_count = 0
|
||||||
|
preview_data = []
|
||||||
|
for row in reader:
|
||||||
|
total_count += 1
|
||||||
|
email = row.get(mapping.get('email'))
|
||||||
|
exists = Volunteer.objects.filter(tenant=tenant, email=email).exists()
|
||||||
|
if exists:
|
||||||
|
update_count += 1
|
||||||
|
action = 'update'
|
||||||
|
else:
|
||||||
|
create_count += 1
|
||||||
|
action = 'create'
|
||||||
|
if len(preview_data) < 10:
|
||||||
|
preview_data.append({
|
||||||
|
'action': action,
|
||||||
|
'identifier': email,
|
||||||
|
'details': f"{row.get(mapping.get('first_name', '')) or ''} {row.get(mapping.get('last_name', '')) or ''}".strip()
|
||||||
|
})
|
||||||
|
context = self.admin_site.each_context(request)
|
||||||
|
context.update({
|
||||||
|
'title': "Import Preview",
|
||||||
|
'total_count': total_count,
|
||||||
|
'create_count': create_count,
|
||||||
|
'update_count': update_count,
|
||||||
|
'preview_data': preview_data,
|
||||||
|
'mapping': mapping,
|
||||||
|
'file_path': file_path,
|
||||||
|
'tenant_id': tenant_id,
|
||||||
|
'action_url': request.path,
|
||||||
|
'opts': self.model._meta,
|
||||||
|
})
|
||||||
|
return render(request, "admin/import_preview.html", context)
|
||||||
|
except Exception as e:
|
||||||
|
self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR)
|
||||||
|
return redirect("..")
|
||||||
|
|
||||||
|
elif "_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 VOLUNTEER_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
|
||||||
|
failed_rows = []
|
||||||
|
for row in reader:
|
||||||
|
try:
|
||||||
|
email = row.get(mapping.get('email'))
|
||||||
|
if not email:
|
||||||
|
row["Import Error"] = "Missing email"
|
||||||
|
failed_rows.append(row)
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
volunteer_data = {}
|
||||||
|
for field_name, csv_col in mapping.items():
|
||||||
|
if csv_col:
|
||||||
|
val = row.get(csv_col)
|
||||||
|
if val is not None and str(val).strip() != '':
|
||||||
|
if field_name == 'email': continue
|
||||||
|
volunteer_data[field_name] = val
|
||||||
|
Volunteer.objects.update_or_create(
|
||||||
|
tenant=tenant,
|
||||||
|
email=email,
|
||||||
|
defaults=volunteer_data
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error importing volunteer: {e}")
|
||||||
|
row["Import Error"] = str(e)
|
||||||
|
failed_rows.append(row)
|
||||||
|
errors += 1
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
self.message_user(request, f"Successfully imported {count} volunteers.")
|
||||||
|
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows
|
||||||
|
request.session.modified = True
|
||||||
|
if errors > 0:
|
||||||
|
error_url = reverse("admin:volunteer-download-errors")
|
||||||
|
self.message_user(request, mark_safe(f"Failed to import {errors} rows. <a href='{error_url}' download>Download failed records</a>"), 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 = VolunteerImportForm(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 Volunteer Fields",
|
||||||
|
'headers': headers,
|
||||||
|
'model_fields': VOLUNTEER_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 = VolunteerImportForm()
|
||||||
|
context = self.admin_site.each_context(request)
|
||||||
|
context['form'] = form
|
||||||
|
context['title'] = "Import Volunteers"
|
||||||
|
context['opts'] = self.model._meta
|
||||||
|
return render(request, "admin/import_csv.html", context)
|
||||||
|
|
||||||
@admin.register(VolunteerEvent)
|
@admin.register(VolunteerEvent)
|
||||||
class VolunteerEventAdmin(admin.ModelAdmin):
|
class VolunteerEventAdmin(admin.ModelAdmin):
|
||||||
@ -982,7 +1141,7 @@ class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||||
list_display = ('id', 'voter', 'volunteer', 'type', 'date', 'description')
|
list_display = ('id', 'voter', 'volunteer', 'type', 'date', 'description')
|
||||||
list_filter = ('voter__tenant', 'type', 'date', 'volunteer')
|
list_filter = ('voter__tenant', 'type', 'date', 'volunteer')
|
||||||
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description', 'volunteer__name')
|
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description', 'volunteer__first_name', 'volunteer__last_name')
|
||||||
change_list_template = "admin/interaction_change_list.html"
|
change_list_template = "admin/interaction_change_list.html"
|
||||||
|
|
||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
|
|||||||
@ -165,3 +165,12 @@ class VoterLikelihoodImportForm(forms.Form):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
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'})
|
||||||
|
|
||||||
|
class VolunteerImportForm(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'})
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-01-26 05:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0018_event_end_time_event_name_event_start_time'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='volunteer',
|
||||||
|
name='first_name',
|
||||||
|
field=models.CharField(blank=True, max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='volunteer',
|
||||||
|
name='last_name',
|
||||||
|
field=models.CharField(blank=True, max_length=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
17
core/migrations/0020_remove_volunteer_name.py
Normal file
17
core/migrations/0020_remove_volunteer_name.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-01-26 13:59
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0019_volunteer_first_name_volunteer_last_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='volunteer',
|
||||||
|
name='name',
|
||||||
|
),
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
@ -285,7 +285,8 @@ class Event(models.Model):
|
|||||||
class Volunteer(models.Model):
|
class Volunteer(models.Model):
|
||||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='volunteers')
|
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='volunteers')
|
||||||
user = models.OneToOneField(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='volunteer_profile')
|
user = models.OneToOneField(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='volunteer_profile')
|
||||||
name = models.CharField(max_length=255)
|
first_name = models.CharField(max_length=100, blank=True)
|
||||||
|
last_name = models.CharField(max_length=100, blank=True)
|
||||||
email = models.EmailField()
|
email = models.EmailField()
|
||||||
phone = models.CharField(max_length=20, blank=True)
|
phone = models.CharField(max_length=20, blank=True)
|
||||||
interests = models.ManyToManyField(Interest, blank=True, related_name='volunteers')
|
interests = models.ManyToManyField(Interest, blank=True, related_name='volunteers')
|
||||||
@ -297,11 +298,11 @@ class Volunteer(models.Model):
|
|||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return f"{self.first_name} {self.last_name}".strip() or self.email
|
||||||
|
|
||||||
class VolunteerEvent(models.Model):
|
class VolunteerEvent(models.Model):
|
||||||
volunteer = models.ForeignKey(Volunteer, on_delete=models.CASCADE, related_name='event_assignments')
|
volunteer = models.ForeignKey(Volunteer, on_delete=models.CASCADE, related_name="event_assignments")
|
||||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='volunteer_assignments')
|
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="volunteer_assignments")
|
||||||
role = models.CharField(max_length=100)
|
role = models.CharField(max_length=100)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
38
core/templates/admin/volunteer_change_list.html
Normal file
38
core/templates/admin/volunteer_change_list.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{% extends "admin/change_list.html" %}
|
||||||
|
{% load i18n admin_urls static admin_list %}
|
||||||
|
|
||||||
|
{% block object-tools-items %}
|
||||||
|
<li>
|
||||||
|
<a href="import-volunteers/" class="addlink">Import Volunteers</a>
|
||||||
|
</li>
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block search %}
|
||||||
|
{{ block.super }}
|
||||||
|
<div class="tenant-filter-container" style="margin: 10px 0; padding: 15px; background: var(--darkened-bg, #f8f9fa); border: 1px solid var(--border-color, #dee2e6); border-radius: 4px; display: flex; align-items: center; color: var(--body-fg, #333);">
|
||||||
|
<label for="tenant-filter-select" style="font-weight: 600; margin-right: 15px; color: var(--body-fg, #333);">Filter by Tenant:</label>
|
||||||
|
<select id="tenant-filter-select" onchange="filterTenant(this.value)" style="padding: 6px 12px; border-radius: 4px; border: 1px solid var(--border-color, #ced4da); background-color: var(--body-bg, #fff); color: var(--body-fg, #333); min-width: 200px;">
|
||||||
|
<option value="" style="background-color: var(--body-bg); color: var(--body-fg);">-- All Tenants --</option>
|
||||||
|
{% for tenant in tenants %}
|
||||||
|
<option value="{{ tenant.id }}" {% if request.GET.tenant__id__exact == tenant.id|stringformat:"s" %}selected{% endif %} style="background-color: var(--body-bg); color: var(--body-fg);">
|
||||||
|
{{ tenant.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function filterTenant(tenantId) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (tenantId) {
|
||||||
|
url.searchParams.set('tenant__id__exact', tenantId);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('tenant__id__exact');
|
||||||
|
}
|
||||||
|
// Reset to page 1 if filtering
|
||||||
|
url.searchParams.delete('p');
|
||||||
|
window.location.href = url.pathname + url.search;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -2,157 +2,228 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container py-5">
|
<div class="container-fluid py-4">
|
||||||
{% if not selected_tenant %}
|
{% if not selected_tenant %}
|
||||||
<div class="row justify-content-center">
|
<div class="row">
|
||||||
<div class="col-md-8 text-center">
|
<div class="col-12 text-center py-5">
|
||||||
<h1 class="display-4 fw-bold text-emerald mb-4">Welcome to Grassroots</h1>
|
<h1 class="display-4 fw-bold">Welcome to Campaign Manager</h1>
|
||||||
<p class="lead text-slate mb-5">Select a campaign to begin managing your voter database and organizing your efforts.</p>
|
<p class="lead text-muted mb-4">Select a campaign to view the dashboard.</p>
|
||||||
|
<div class="row justify-content-center">
|
||||||
<div class="row g-4">
|
{% for tenant in tenants %}
|
||||||
{% for tenant in tenants %}
|
<div class="col-md-4 mb-3">
|
||||||
<div class="col-md-6">
|
<div class="card shadow-sm border-0 h-100">
|
||||||
<div class="card h-100 border-0 shadow-sm hover-shadow transition">
|
<div class="card-body">
|
||||||
<div class="card-body p-4">
|
<h5 class="card-title fw-bold">{{ tenant.name }}</h5>
|
||||||
<h3 class="h5 fw-bold mb-2">{{ tenant.name }}</h3>
|
<a href="{% url 'select_campaign' tenant.id %}" class="btn btn-primary mt-3">Manage Campaign</a>
|
||||||
<p class="text-muted small mb-4">{{ tenant.description|truncatewords:20 }}</p>
|
|
||||||
<a href="{% url 'select_campaign' tenant.id %}" class="btn btn-emerald w-100">Select Campaign</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="alert alert-info">No campaigns found. Please create one in the admin.</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="row">
|
<div class="row mb-4">
|
||||||
<div class="col-12 mb-4">
|
<div class="col-12">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<h1 class="display-5 fw-bold text-dark">{{ selected_tenant.name }} Dashboard</h1>
|
||||||
<h1 class="h2 fw-bold mb-0">Dashboard: <span class="text-emerald">{{ selected_tenant.name }}</span></h1>
|
<p class="lead text-muted">Overview of voter engagement and field operations.</p>
|
||||||
<a href="{% url 'index' %}" class="btn btn-outline-secondary btn-sm">Switch Campaign</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search Box on its own line -->
|
<!-- Main Stats Row -->
|
||||||
<div class="card border-0 shadow-sm mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="card-body p-3">
|
<div class="col-md-3">
|
||||||
<form action="{% url 'voter_list' %}" method="GET" class="d-flex">
|
<div class="card border-0 shadow-sm h-100 bg-primary text-white">
|
||||||
<input type="text" name="q" class="form-control me-2" placeholder="Quick Search by name, address, or voter ID..." required>
|
<div class="card-body p-4">
|
||||||
<button type="submit" class="btn btn-emerald px-4">Search Registry</button>
|
<h6 class="text-uppercase fw-bold small opacity-75">Active Voters</h6>
|
||||||
</form>
|
<h2 class="mb-0 fw-bold">{{ metrics.total_registered_voters }}</h2>
|
||||||
|
<a href="{% url 'voter_list' %}" class="text-white small text-decoration-none mt-2 d-inline-block">View All →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h6 class="text-uppercase fw-bold small text-muted">Target Voters</h6>
|
||||||
|
<h2 class="mb-0 fw-bold text-dark">{{ metrics.total_target_voters }}</h2>
|
||||||
|
<a href="{% url 'voter_list' %}?is_targeted=true" class="small text-decoration-none mt-2 d-inline-block text-primary">View Targets →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h6 class="text-uppercase fw-bold small text-muted">Supporting</h6>
|
||||||
|
<h2 class="mb-0 fw-bold text-success">{{ metrics.total_supporting }}</h2>
|
||||||
|
<a href="{% url 'voter_list' %}?support=supporting" class="small text-decoration-none mt-2 d-inline-block text-primary">View Supporters →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h6 class="text-uppercase fw-bold small text-muted">Target Households</h6>
|
||||||
|
<h2 class="mb-0 fw-bold text-info">{{ metrics.total_target_households }}</h2>
|
||||||
|
<a href="{% url 'voter_list' %}?is_targeted=true&has_address=true" class="small text-decoration-none mt-2 d-inline-block text-primary">View Map →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Secondary Stats Row -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body p-4 text-center">
|
||||||
|
<div class="bg-warning bg-opacity-10 p-3 rounded-circle d-inline-block mb-3">
|
||||||
|
<i class="bi bi-door-open-fill text-warning fs-3"></i>
|
||||||
</div>
|
</div>
|
||||||
|
<h6 class="text-uppercase fw-bold small text-muted mb-1">Door Visits</h6>
|
||||||
|
<h3 class="mb-0 fw-bold">{{ metrics.total_door_visits }}</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Metrics Cards -->
|
<div class="col-md-3">
|
||||||
<div class="row g-4 mb-4">
|
<div class="card border-0 shadow-sm h-100">
|
||||||
<div class="col-md-4 col-lg-3">
|
<div class="card-body p-4 text-center">
|
||||||
<a href="{% url 'voter_list' %}" class="text-decoration-none">
|
<div class="bg-danger bg-opacity-10 p-3 rounded-circle d-inline-block mb-3">
|
||||||
<div class="card border-0 shadow-sm h-100 hover-lift transition">
|
<i class="bi bi-signpost-2-fill text-danger fs-3"></i>
|
||||||
<div class="card-body text-center p-3">
|
</div>
|
||||||
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Registered Voters</h6>
|
<h6 class="text-uppercase fw-bold small text-muted mb-1">Signs</h6>
|
||||||
<p class="display-6 fw-bold mb-0 text-dark">{{ metrics.total_registered_voters }}</p>
|
<h3 class="mb-0 fw-bold">{{ metrics.total_signs }}</h3>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 col-lg-3">
|
|
||||||
<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">
|
|
||||||
<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">
|
|
||||||
<a href="{% url 'voter_list' %}?is_targeted=true&has_address=true" class="text-decoration-none">
|
|
||||||
<div class="card border-0 shadow-sm h-100 hover-lift transition">
|
|
||||||
<div class="card-body text-center p-3">
|
|
||||||
<h6 class="card-title text-muted small mb-2 text-uppercase fw-bold">Target Households</h6>
|
|
||||||
<p class="display-6 fw-bold mb-0 text-info">{{ metrics.total_target_households }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4 col-lg-3">
|
|
||||||
<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">
|
|
||||||
<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">
|
|
||||||
<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">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>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-md-3">
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm h-100">
|
||||||
<div class="card-body d-flex justify-content-between align-items-center">
|
<div class="card-body p-4 text-center">
|
||||||
<div>
|
<div class="bg-primary bg-opacity-10 p-3 rounded-circle d-inline-block mb-3">
|
||||||
<h5 class="fw-bold mb-1">Voter Management</h5>
|
<i class="bi bi-window-sidebar text-primary fs-3"></i>
|
||||||
<p class="text-muted mb-0">Access the full registry to manage individual voter profiles.</p>
|
</div>
|
||||||
</div>
|
<h6 class="text-uppercase fw-bold small text-muted mb-1">Window Stickers</h6>
|
||||||
<a href="{% url 'voter_list' %}" class="btn btn-emerald px-4">View Registry</a>
|
<h3 class="mb-0 fw-bold">{{ metrics.total_window_stickers }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h6 class="text-uppercase fw-bold small text-muted mb-1">Donation Goal</h6>
|
||||||
|
<div class="d-flex justify-content-between align-items-end mb-2">
|
||||||
|
<h4 class="mb-0 fw-bold">${{ metrics.total_donations|floatformat:0 }}</h4>
|
||||||
|
<small class="text-muted">of ${{ metrics.donation_goal|floatformat:0 }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 10px;">
|
||||||
|
<div class="progress-bar bg-success" role="progressbar" style="width: {{ metrics.donation_percentage }}%" aria-valuenow="{{ metrics.donation_percentage }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end mt-1">
|
||||||
|
<small class="fw-bold text-success">{{ metrics.donation_percentage }}%</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Row: Other Metrics & Lists -->
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0 fw-bold">Recent Interactions</h5>
|
||||||
|
<a href="/admin/core/interaction/" class="btn btn-sm btn-outline-primary rounded-pill px-3">View All</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="bg-light text-muted small text-uppercase">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 border-0">Voter</th>
|
||||||
|
<th class="py-3 border-0">Type</th>
|
||||||
|
<th class="py-3 border-0">Date</th>
|
||||||
|
<th class="py-3 border-0">Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for interaction in recent_interactions %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 border-0">
|
||||||
|
<div class="fw-bold">{{ interaction.voter }}</div>
|
||||||
|
<div class="small text-muted">{{ interaction.volunteer|default:"Staff" }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 border-0">
|
||||||
|
<span class="badge rounded-pill bg-light text-dark fw-normal border">
|
||||||
|
{{ interaction.type }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 border-0 text-muted small">
|
||||||
|
{{ interaction.date|date:"M d, Y" }}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 border-0 text-muted small">
|
||||||
|
{{ interaction.description|truncatechars:50 }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center py-5 text-muted">No recent interactions found.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-white border-0 py-3">
|
||||||
|
<h5 class="mb-0 fw-bold">Upcoming Events</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for event in upcoming_events %}
|
||||||
|
<div class="list-group-item px-4 py-3 border-0 border-bottom">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<h6 class="mb-0 fw-bold text-dark">{{ event.name|default:event.event_type }}</h6>
|
||||||
|
<span class="badge rounded-pill bg-primary bg-opacity-10 text-primary small">
|
||||||
|
{{ event.event_type }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="small fw-medium text-dark">
|
||||||
|
<i class="bi bi-calendar-event me-1 text-muted"></i>{{ event.date|date:"M d, Y" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
No upcoming events.
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Campaign Overview -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-0 py-3">
|
||||||
|
<h5 class="mb-0 fw-bold">Field Operations</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between mb-3">
|
||||||
|
<span class="text-muted">Total Volunteers</span>
|
||||||
|
<span class="fw-bold">{{ metrics.volunteers_count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mb-3">
|
||||||
|
<span class="text-muted">Total Interactions</span>
|
||||||
|
<span class="fw-bold">{{ metrics.interactions_count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mb-0">
|
||||||
|
<span class="text-muted">Total Events</span>
|
||||||
|
<span class="fw-bold">{{ metrics.events_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
@ -5,9 +5,10 @@ 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, CampaignSettings
|
from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer
|
||||||
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm
|
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm
|
||||||
import logging
|
import logging
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -20,6 +21,8 @@ def index(request):
|
|||||||
selected_tenant_id = request.session.get('tenant_id')
|
selected_tenant_id = request.session.get('tenant_id')
|
||||||
selected_tenant = None
|
selected_tenant = None
|
||||||
metrics = {}
|
metrics = {}
|
||||||
|
recent_interactions = []
|
||||||
|
upcoming_events = []
|
||||||
|
|
||||||
if selected_tenant_id:
|
if selected_tenant_id:
|
||||||
selected_tenant = Tenant.objects.filter(id=selected_tenant_id).first()
|
selected_tenant = Tenant.objects.filter(id=selected_tenant_id).first()
|
||||||
@ -46,12 +49,20 @@ def index(request):
|
|||||||
'total_donations': float(total_donations),
|
'total_donations': float(total_donations),
|
||||||
'donation_goal': float(donation_goal),
|
'donation_goal': float(donation_goal),
|
||||||
'donation_percentage': donation_percentage,
|
'donation_percentage': donation_percentage,
|
||||||
|
'volunteers_count': Volunteer.objects.filter(tenant=selected_tenant).count(),
|
||||||
|
'interactions_count': Interaction.objects.filter(voter__tenant=selected_tenant).count(),
|
||||||
|
'events_count': Event.objects.filter(tenant=selected_tenant).count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recent_interactions = Interaction.objects.filter(voter__tenant=selected_tenant).order_by('-date')[:5]
|
||||||
|
upcoming_events = Event.objects.filter(tenant=selected_tenant, date__gte=timezone.now().date()).order_by('date')[:5]
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'tenants': tenants,
|
'tenants': tenants,
|
||||||
'selected_tenant': selected_tenant,
|
'selected_tenant': selected_tenant,
|
||||||
'metrics': metrics,
|
'metrics': metrics,
|
||||||
|
'recent_interactions': recent_interactions,
|
||||||
|
'upcoming_events': upcoming_events,
|
||||||
}
|
}
|
||||||
return render(request, 'core/index.html', context)
|
return render(request, 'core/index.html', context)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user