Autosave: 20260126-142846

This commit is contained in:
Flatlogic Bot 2026-01-26 14:28:46 +00:00
parent ac90cc59f4
commit 1dfb7ebbf1
14 changed files with 476 additions and 147 deletions

View File

@ -16,7 +16,8 @@ from .models import (
)
from .forms import (
VoterImportForm, EventImportForm, EventParticipationImportForm,
DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm
DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm,
VolunteerImportForm
)
logger = logging.getLogger(__name__)
@ -78,6 +79,14 @@ INTERACTION_MAPPABLE_FIELDS = [
('notes', 'Notes'),
]
VOLUNTEER_MAPPABLE_FIELDS = [
('first_name', 'First Name'),
('last_name', 'Last Name'),
('email', 'Email'),
('phone', 'Phone'),
]
VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [
('voter_id', 'Voter ID'),
('election_type', 'Election Type (Name)'),
@ -565,12 +574,162 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
return render(request, "admin/import_csv.html", context)
@admin.register(Volunteer)
class VolunteerAdmin(admin.ModelAdmin):
list_display = ('name', 'email', 'phone', 'tenant', 'user')
class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant', 'user')
list_filter = ('tenant',)
search_fields = ('name', 'email', 'phone')
search_fields = ('first_name', 'last_name', 'email', 'phone')
inlines = [VolunteerEventInline, InteractionInline]
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)
class VolunteerEventAdmin(admin.ModelAdmin):
@ -982,7 +1141,7 @@ class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_display = ('id', 'voter', 'volunteer', 'type', 'date', 'description')
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"
def get_urls(self):
@ -1376,4 +1535,4 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
@admin.register(CampaignSettings)
class CampaignSettingsAdmin(admin.ModelAdmin):
list_display = ('tenant', 'donation_goal')
list_filter = ('tenant',)
list_filter = ('tenant',)

View File

@ -164,4 +164,13 @@ class VoterLikelihoodImportForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
self.fields['file'].widget.attrs.update({'class': 'form-control'})
self.fields['file'].widget.attrs.update({'class': 'form-control'})
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'})

View File

@ -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),
),
]

View 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',
),
]

View File

@ -285,7 +285,8 @@ class Event(models.Model):
class Volunteer(models.Model):
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')
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()
phone = models.CharField(max_length=20, blank=True)
interests = models.ManyToManyField(Interest, blank=True, related_name='volunteers')
@ -297,11 +298,11 @@ class Volunteer(models.Model):
super().save(*args, **kwargs)
def __str__(self):
return self.name
return f"{self.first_name} {self.last_name}".strip() or self.email
class VolunteerEvent(models.Model):
volunteer = models.ForeignKey(Volunteer, on_delete=models.CASCADE, related_name='event_assignments')
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='volunteer_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")
role = models.CharField(max_length=100)
def __str__(self):

View 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 %}

View File

@ -2,157 +2,228 @@
{% load static %}
{% block content %}
<div class="container py-5">
<div class="container-fluid py-4">
{% if not selected_tenant %}
<div class="row justify-content-center">
<div class="col-md-8 text-center">
<h1 class="display-4 fw-bold text-emerald mb-4">Welcome to Grassroots</h1>
<p class="lead text-slate mb-5">Select a campaign to begin managing your voter database and organizing your efforts.</p>
<div class="row g-4">
{% for tenant in tenants %}
<div class="col-md-6">
<div class="card h-100 border-0 shadow-sm hover-shadow transition">
<div class="card-body p-4">
<h3 class="h5 fw-bold mb-2">{{ tenant.name }}</h3>
<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 class="row">
<div class="col-12 text-center py-5">
<h1 class="display-4 fw-bold">Welcome to Campaign Manager</h1>
<p class="lead text-muted mb-4">Select a campaign to view the dashboard.</p>
<div class="row justify-content-center">
{% for tenant in tenants %}
<div class="col-md-4 mb-3">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<h5 class="card-title fw-bold">{{ tenant.name }}</h5>
<a href="{% url 'select_campaign' tenant.id %}" class="btn btn-primary mt-3">Manage Campaign</a>
</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>
{% endfor %}
</div>
</div>
</div>
{% else %}
<div class="row">
<div class="col-12 mb-4">
<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 class="row mb-4">
<div class="col-12">
<h1 class="display-5 fw-bold text-dark">{{ selected_tenant.name }} Dashboard</h1>
<p class="lead text-muted">Overview of voter engagement and field operations.</p>
</div>
</div>
<!-- Main Stats Row -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-0 shadow-sm h-100 bg-primary text-white">
<div class="card-body p-4">
<h6 class="text-uppercase fw-bold small opacity-75">Active Voters</h6>
<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 &rarr;</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 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 &rarr;</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 &rarr;</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 &rarr;</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>
<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>
<!-- Metrics Cards -->
<div class="row g-4 mb-4">
<div class="col-md-4 col-lg-3">
<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">
<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 class="col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-4 text-center">
<div class="bg-danger bg-opacity-10 p-3 rounded-circle d-inline-block mb-3">
<i class="bi bi-signpost-2-fill text-danger fs-3"></i>
</div>
<h6 class="text-uppercase fw-bold small text-muted mb-1">Signs</h6>
<h3 class="mb-0 fw-bold">{{ metrics.total_signs }}</h3>
</div>
</div>
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h5 class="fw-bold mb-1">Voter Management</h5>
<p class="text-muted mb-0">Access the full registry to manage individual voter profiles.</p>
</div>
<a href="{% url 'voter_list' %}" class="btn btn-emerald px-4">View Registry</a>
</div>
<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-primary bg-opacity-10 p-3 rounded-circle d-inline-block mb-3">
<i class="bi bi-window-sidebar text-primary fs-3"></i>
</div>
<h6 class="text-uppercase fw-bold small text-muted mb-1">Window Stickers</h6>
<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>
<!-- 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 %}
</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 %}

View File

@ -5,9 +5,10 @@ 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, 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
import logging
from django.utils import timezone
logger = logging.getLogger(__name__)
@ -20,6 +21,8 @@ def index(request):
selected_tenant_id = request.session.get('tenant_id')
selected_tenant = None
metrics = {}
recent_interactions = []
upcoming_events = []
if selected_tenant_id:
selected_tenant = Tenant.objects.filter(id=selected_tenant_id).first()
@ -46,12 +49,20 @@ def index(request):
'total_donations': float(total_donations),
'donation_goal': float(donation_goal),
'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 = {
'tenants': tenants,
'selected_tenant': selected_tenant,
'metrics': metrics,
'recent_interactions': recent_interactions,
'upcoming_events': upcoming_events,
}
return render(request, 'core/index.html', context)
@ -387,4 +398,4 @@ def voter_geocode(request, voter_id):
'error': f"Geocoding failed: {error_msg or 'No results found.'}"
})
return JsonResponse({'success': False, 'error': 'Invalid request method.'})
return JsonResponse({'success': False, 'error': 'Invalid request method.'})