Autosave: 20260201-211719

This commit is contained in:
Flatlogic Bot 2026-02-01 21:17:19 +00:00
parent 442aec63b6
commit 63faa21a4f
23 changed files with 614 additions and 2050 deletions

View File

@ -1,13 +0,0 @@
CSRF_TRUSTED_ORIGINS = [
"https://grassrootscrm.flatlogic.app",
]
CSRF_TRUSTED_ORIGINS += [
origin for origin in [
os.getenv("HOST_FQDN", ""),
os.getenv("CSRF_TRUSTED_ORIGIN", "")
] if origin
]
CSRF_TRUSTED_ORIGINS = [
f"https://{host}" if not host.startswith(("http://", "https://")) else host
for host in CSRF_TRUSTED_ORIGINS
]

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +0,0 @@
from django.http import HttpResponse
from django.utils.safestring import mark_safe
import csv
import io
import logging
import tempfile
import os
from decimal import Decimal
from django.contrib import admin, messages
from django.urls import path, reverse
from django.shortcuts import render, redirect
from django.template.response import TemplateResponse
from .models import (
Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,
VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings,
Interest, Volunteer, VolunteerEvent, ParticipationStatus, format_phone_number
)
from .forms import (
VoterImportForm, EventImportForm, EventParticipationImportForm,
DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm,
VolunteerImportForm
)

View File

@ -1,15 +1,31 @@
import os
import time
from django.conf import settings
from .models import Tenant
from .permissions import can_view_donations, can_edit_voter, get_user_role, can_view_volunteers, can_edit_volunteer, can_view_voters
def project_context(request):
"""
Adds project-specific environment variables to the template context globally.
"""
return {
context = {
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
# Used for cache-busting static assets
"deployment_timestamp": int(time.time()),
"GOOGLE_MAPS_API_KEY": getattr(settings, 'GOOGLE_MAPS_API_KEY', ''),
}
}
if request.user.is_authenticated:
tenant_id = request.session.get('tenant_id')
if tenant_id:
tenant = Tenant.objects.filter(id=tenant_id).first()
if tenant:
context['can_view_donations'] = can_view_donations(request.user, tenant)
context['can_edit_voter'] = can_edit_voter(request.user, tenant)
context['can_view_voters'] = can_view_voters(request.user, tenant)
context['can_view_volunteers'] = can_view_volunteers(request.user, tenant)
context['can_edit_volunteer'] = can_edit_volunteer(request.user, tenant)
context['user_role'] = get_user_role(request.user, tenant)
return context

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-02-01 15:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0038_alter_campaignsettings_timezone'),
]
operations = [
migrations.AlterField(
model_name='tenantuserrole',
name='role',
field=models.CharField(choices=[('system_admin', 'System Administrator'), ('campaign_admin', 'Campaign Administrator'), ('campaign_staff', 'Campaign Staff')], max_length=20),
),
]

View File

@ -31,8 +31,8 @@ class Tenant(models.Model):
class TenantUserRole(models.Model):
ROLE_CHOICES = [
('admin', 'Admin'),
('campaign_manager', 'Campaign Manager'),
('system_admin', 'System Administrator'),
('campaign_admin', 'Campaign Administrator'),
('campaign_staff', 'Campaign Staff'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tenant_roles')

116
core/permissions.py Normal file
View File

@ -0,0 +1,116 @@
from functools import wraps
from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect
from django.contrib import messages
from .models import TenantUserRole
# Allowed roles for staff/admin actions
STAFF_ROLES = [
'admin', 'campaign_manager', 'campaign_staff',
'system_admin', 'campaign_admin'
]
def get_user_role(user, tenant):
if user.is_superuser:
return 'admin'
role_obj = TenantUserRole.objects.filter(user=user, tenant=tenant).first()
if role_obj:
return role_obj.role
return None
def has_role(user, tenant, roles):
if user.is_superuser:
return True
if not tenant:
return False
user_role = get_user_role(user, tenant)
return user_role in roles
def is_block_walker(user):
return user.groups.filter(name='Block Walker').exists()
def can_view_voters(user, tenant):
if user.has_perm("core.view_voter"):
return True
if user.is_superuser:
return True
# If they can edit, they can view
if can_edit_voter(user, tenant):
return True
# All authenticated users with a tenant role can usually view voters in our app
# but we should restrict it if they have NO role and NO permission.
role = get_user_role(user, tenant)
if role: # Any role (even if not in STAFF_ROLES) allows viewing voters?
# Block Walkers don't have a TenantUserRole usually, they have a Group.
return True
return False
def can_view_donations(user, tenant):
if user.has_perm("core.view_donation"):
return True
if user.is_superuser:
return True
role = get_user_role(user, tenant)
if role in STAFF_ROLES:
return True
return False
def can_edit_voter(user, tenant):
if user.has_perm("core.change_voter"):
return True
if user.is_superuser:
return True
role = get_user_role(user, tenant)
if role in STAFF_ROLES:
return True
return False
def can_view_volunteers(user, tenant):
if user.has_perm("core.view_volunteer"):
return True
if user.is_superuser:
return True
role = get_user_role(user, tenant)
if role in STAFF_ROLES:
return True
return False
def can_edit_volunteer(user, tenant):
if user.has_perm("core.change_volunteer"):
return True
if user.is_superuser:
return True
role = get_user_role(user, tenant)
if role in STAFF_ROLES:
return True
return False
def role_required(roles, permission=None):
def decorator(view_func):
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
from .models import Tenant
tenant_id = request.session.get('tenant_id')
if not tenant_id:
if request.user.is_superuser:
return view_func(request, *args, **kwargs)
messages.warning(request, "Please select a campaign first.")
return redirect('index')
tenant = Tenant.objects.filter(id=tenant_id).first()
if not tenant:
messages.warning(request, "Campaign not found.")
return redirect('index')
# Check roles first
if has_role(request.user, tenant, roles):
return view_func(request, *args, **kwargs)
# Check for specific permission if provided
if permission and request.user.has_perm(permission):
return view_func(request, *args, **kwargs)
messages.error(request, "You do not have permission to perform this action.")
return redirect('index')
return _wrapped_view
return decorator

View File

@ -1,41 +1,32 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Grassroots Campaign Manager{% endblock %}</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
{% load static %}
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% if project_description %}
<meta name="description" content="{{ project_description }}">
{% endif %}
{% block head %}{% endblock %}
{% if project_image_url %}
<meta property="og:image" content="{{ project_image_url }}">
{% endif %}
{% block extra_css %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg sticky-top">
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="/">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-person-check-fill me-2" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15.854 5.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 0 1 .708-.708L12.5 7.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
<path d="M1 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
</svg>
Grassroots
</a>
<a class="navbar-brand" href="/">Grassroots CM</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/">Dashboard</a>
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/voters/">Voters</a>
@ -46,9 +37,11 @@
<li class="nav-item">
<a class="nav-link" href="/events/">Events</a>
</li>
{% if can_view_volunteers %}
<li class="nav-item">
<a class="nav-link" href="/volunteers/">Volunteers</a>
</li>
{% endif %}
</ul>
<div class="d-flex align-items-center">
<a href="/admin/" class="btn btn-outline-primary btn-sm me-2">Admin Panel</a>
@ -59,65 +52,28 @@
<button type="submit" class="btn btn-link nav-link d-inline p-0" style="text-decoration: none;">Logout</button>
</form>
{% else %}
<a href="{% url 'login' %}" class="btn btn-primary btn-sm">Login</a>
<a href="{% url 'login' %}" class="btn btn-link nav-link">Login</a>
{% endif %}
</div>
</div>
</div>
</nav>
<main>
<div class="container py-4">
{% if messages %}
<div class="container mt-3">
{% for message in messages %}
<div class="alert alert-{% if message.tags == 'error' %}danger{% else %}{{ message.tags }}{% endif %} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div class="alert alert-{{ message.tags }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
</div>
<footer class="py-5 bg-white border-top mt-5">
<div class="container text-center">
<p class="text-muted mb-0">&copy; 2026 Grassroots Campaign Manager. All rights reserved.</p>
</div>
</footer>
<!-- Bootstrap 5 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
function formatPhoneNumber(value) {
if (!value) return value;
const phoneNumber = value.replace(/[^\d]/g, '');
const phoneNumberLength = phoneNumber.length;
if (phoneNumberLength < 4) return phoneNumber;
if (phoneNumberLength < 7) {
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3)}`;
}
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 10)}`;
}
function phoneNumberFormatter() {
const inputField = this;
const formattedFieldValue = formatPhoneNumber(inputField.value);
inputField.value = formattedFieldValue;
}
const phoneInputs = document.querySelectorAll('input[name="phone"], input[type="tel"]');
phoneInputs.forEach(input => {
input.addEventListener('input', phoneNumberFormatter);
// Also format on load if it has a value
if (input.value) {
input.value = formatPhoneNumber(input.value);
}
});
});
</script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
</html>

View File

@ -2,39 +2,36 @@
{% load static %}
{% block content %}
<div class="container-fluid py-5 px-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
<div class="container-fluid py-4 py-md-5 px-3 px-md-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-4 mb-md-5 gap-3">
<div>
<h1 class="h2 fw-bold text-dark mb-1">Door Visit History</h1>
<p class="text-muted mb-0">Review completed door-to-door visits and outcomes.</p>
</div>
<div class="d-flex gap-2">
<a href="{% url 'door_visits' %}" class="btn btn-outline-primary shadow-sm">
<i class="bi bi-door-open-fill me-1"></i> Planned Visits
</a>
<a href="{% url 'voter_list' %}" class="btn btn-primary shadow-sm">
<i class="bi bi-person-lines-fill me-1"></i> Voter Registry
<div class="d-flex gap-2 w-100 w-md-auto">
<a href="{% url 'door_visits' %}" class="btn btn-outline-primary shadow-sm flex-grow-1 flex-md-grow-0 py-1 py-md-2">
<i class="bi bi-door-open-fill me-1"></i> Door Visits
</a>
</div>
</div>
<!-- Filters and Summary -->
<div class="row g-4 mb-5">
<div class="row g-4 mb-4 mb-md-5">
<!-- Date Filter Card -->
<div class="col-lg-5">
<div class="card border-0 shadow-sm rounded-4 h-100">
<div class="card-body p-4">
<div class="card-body p-3 p-md-4">
<h5 class="card-title fw-bold mb-3">Filter by Date Range</h5>
<form method="get" class="row g-2">
<div class="col-sm-5">
<div class="col-6 col-sm-5">
<label class="small text-muted mb-1 ms-1">From</label>
<input type="date" name="start_date" class="form-control rounded-pill border-light bg-light" value="{{ start_date|default:'' }}">
</div>
<div class="col-sm-5">
<div class="col-6 col-sm-5">
<label class="small text-muted mb-1 ms-1">To</label>
<input type="date" name="end_date" class="form-control rounded-pill border-light bg-light" value="{{ end_date|default:'' }}">
</div>
<div class="col-sm-2 d-flex align-items-end">
<div class="col-12 col-sm-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary rounded-pill w-100 px-0">Filter</button>
</div>
{% if start_date or end_date %}
@ -52,12 +49,12 @@
<!-- Volunteer Counts Summary -->
<div class="col-lg-7">
<div class="card border-0 shadow-sm rounded-4 h-100">
<div class="card-body p-4">
<div class="card-body p-3 p-md-4">
<h5 class="card-title fw-bold mb-3">Visits per Volunteer</h5>
<div class="d-flex flex-wrap gap-2">
{% for v_name, count in volunteer_counts %}
<div class="bg-light rounded-pill px-3 py-2 d-flex align-items-center border">
<span class="fw-medium text-dark me-2">{{ v_name }}</span>
<span class="fw-medium text-dark me-2 small">{{ v_name }}</span>
<span class="badge bg-primary rounded-pill">{{ count }}</span>
</div>
{% empty %}
@ -71,9 +68,9 @@
<!-- Households List -->
<div class="card border-0 shadow-sm rounded-4 overflow-hidden">
<div class="card-header bg-white py-3 border-bottom d-flex justify-content-between align-items-center">
<div class="card-header bg-white py-3 border-bottom d-flex flex-column flex-sm-row justify-content-between align-items-sm-center gap-2">
<h5 class="card-title mb-0 fw-bold">Visited Households</h5>
<span class="badge bg-success-subtle text-success px-3 py-2 rounded-pill">
<span class="badge bg-success-subtle text-success px-3 py-2 rounded-pill align-self-start align-self-sm-center">
{{ history.paginator.count }} Households Visited
</span>
</div>
@ -81,31 +78,38 @@
<table class="table table-hover align-middle mb-0">
<thead class="bg-light text-muted">
<tr>
<th class="ps-4 py-3 text-uppercase small ls-1">Household Address</th>
<th class="py-3 text-uppercase small ls-1">Voters Visited</th>
<th class="ps-3 ps-md-4 py-3 text-uppercase small ls-1">Household Address</th>
<th class="py-3 text-uppercase small ls-1 d-none d-md-table-cell">Voters Visited</th>
<th class="py-3 text-uppercase small ls-1">Last Visit</th>
<th class="py-3 text-uppercase small ls-1">Volunteer</th>
<th class="py-3 text-uppercase small ls-1 d-none d-sm-table-cell">Volunteer</th>
<th class="py-3 text-uppercase small ls-1">Outcome</th>
<th class="pe-4 py-3 text-uppercase small ls-1">Comments</th>
<th class="pe-3 pe-md-4 py-3 text-uppercase small ls-1 d-none d-lg-table-cell">Comments</th>
</tr>
</thead>
<tbody>
{% for household in history %}
<tr>
<td class="ps-4">
<td class="ps-3 ps-md-4">
<div class="fw-bold text-dark">{{ household.address_display }}</div>
{% if household.neighborhood %}
<span class="badge bg-light text-primary border border-primary-subtle small mt-1">
{{ household.neighborhood }}
</span>
{% endif %}
{% if household.district %}
<span class="badge bg-light text-secondary border small mt-1">
District: {{ household.district }}
</span>
{% endif %}
<div class="d-flex flex-wrap gap-1 mt-1">
{% if household.neighborhood %}
<span class="badge bg-light text-primary border border-primary-subtle small d-md-inline-block">
{{ household.neighborhood }}
</span>
{% endif %}
{% if household.district %}
<span class="badge bg-light text-secondary border small d-none d-md-inline-block">
District: {{ household.district }}
</span>
{% endif %}
<div class="d-md-none small text-muted w-100">
{% with voters=household.voters_at_address %}
{{ voters|join:", " }}
{% endwith %}
</div>
</div>
</td>
<td>
<td class="d-none d-md-table-cell">
<div class="d-flex flex-wrap gap-1">
{% for voter_name in household.voters_at_address %}
<span class="badge bg-light text-dark border fw-normal">
@ -115,27 +119,27 @@
</div>
</td>
<td>
<div class="fw-bold text-dark">{{ household.last_visit_date|date:"M d, Y" }}</div>
<div class="small text-muted">{{ household.last_visit_date|date:"H:i" }}</div>
<div class="fw-bold text-dark small">{{ household.last_visit_date|date:"M d, Y" }}</div>
<div class="small text-muted d-none d-sm-block">{{ household.last_visit_date|date:"H:i" }}</div>
</td>
<td>
<td class="d-none d-sm-table-cell">
{% if household.last_volunteer %}
<div class="d-flex align-items-center">
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-flex align-items-center justify-content-center me-2 d-none d-md-flex" style="width: 32px; height: 32px;">
{{ household.last_volunteer.first_name|first }}{{ household.last_volunteer.last_name|first }}
</div>
<span class="fw-medium">{{ household.last_volunteer }}</span>
<span class="fw-medium small">{{ household.last_volunteer }}</span>
</div>
{% else %}
<span class="text-muted small">N/A</span>
{% endif %}
</td>
<td>
<span class="badge {% if 'Spoke' in household.last_outcome %}bg-success{% elif 'Literature' in household.last_outcome %}bg-info{% else %}bg-secondary{% endif %} bg-opacity-10 {% if 'Spoke' in household.last_outcome %}text-success{% elif 'Literature' in household.last_outcome %}text-info{% else %}text-secondary{% endif %} px-2 py-1">
<span class="badge {% if 'Spoke' in household.last_outcome %}bg-success{% elif 'Literature' in household.last_outcome %}bg-info{% else %}bg-secondary{% endif %} bg-opacity-10 {% if 'Spoke' in household.last_outcome %}text-success{% elif 'Literature' in household.last_outcome %}text-info{% else %}text-secondary{% endif %} px-2 py-1 small">
{{ household.last_outcome }}
</span>
</td>
<td class="pe-4">
<td class="pe-3 pe-md-4 d-none d-lg-table-cell">
<div class="small text-muted text-wrap" style="max-width: 200px;">
{{ household.notes|default:"-" }}
</div>
@ -148,7 +152,7 @@
<i class="bi bi-calendar-x mb-2" style="font-size: 3rem; opacity: 0.3;"></i>
</div>
<p class="mb-0 fw-medium">No door visits found for this selection.</p>
<p class="small text-muted">Try clearing your filters or visit the <a href="{% url 'door_visits' %}">Planned Visits</a> page to log more visits.</p>
<p class="small text-muted">Try clearing your filters or visit the <a href="{% url 'door_visits' %}">Door Visits</a> page to log more visits.</p>
</td>
</tr>
{% endfor %}
@ -159,31 +163,31 @@
{% if history.paginator.num_pages > 1 %}
<div class="card-footer bg-white border-0 py-4">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0">
<ul class="pagination pagination-sm justify-content-center mb-0">
{% if history.has_previous %}
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page=1{% if start_date %}&start_date={{ start_date }}{% endif %}{% if end_date %}&end_date={{ end_date }}{% endif %}" aria-label="First">
<i class="bi bi-chevron-double-left small"></i>
<i class="bi bi-chevron-double-left"></i>
</a>
</li>
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ history.previous_page_number }}{% if start_date %}&start_date={{ start_date }}{% endif %}{% if end_date %}&end_date={{ end_date }}{% endif %}" aria-label="Previous">
<i class="bi bi-chevron-left small"></i>
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% endif %}
<li class="page-item active mx-2"><span class="page-link rounded-pill px-3 border-0">Page {{ history.number }} of {{ history.paginator.num_pages }}</span></li>
<li class="page-item active mx-1 mx-md-2"><span class="page-link rounded-pill px-3 border-0">Page {{ history.number }} of {{ history.paginator.num_pages }}</span></li>
{% if history.has_next %}
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ history.next_page_number }}{% if start_date %}&start_date={{ start_date }}{% endif %}{% if end_date %}&end_date={{ end_date }}{% endif %}" aria-label="Next">
<i class="bi bi-chevron-right small"></i>
<i class="bi bi-chevron-right"></i>
</a>
</li>
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ history.paginator.num_pages }}{% if start_date %}&start_date={{ start_date }}{% endif %}{% if end_date %}&end_date={{ end_date }}{% endif %}" aria-label="Last">
<i class="bi bi-chevron-double-right small"></i>
<i class="bi bi-chevron-double-right"></i>
</a>
</li>
{% endif %}
@ -210,5 +214,17 @@
.text-info {
color: #055160 !important;
}
@media (max-width: 576px) {
.container-fluid {
padding-left: 0.75rem !important;
padding-right: 0.75rem !important;
}
.card-body {
padding: 1rem !important;
}
h1.h2 {
font-size: 1.5rem;
}
}
</style>
{% endblock %}

View File

@ -2,43 +2,42 @@
{% load static %}
{% block content %}
<div class="container-fluid py-5 px-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
<div class="container-fluid py-4 py-md-5 px-3 px-md-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-4 mb-md-5 gap-3">
<div>
<h1 class="h2 fw-bold text-dark mb-1">Door Visits</h1>
<p class="text-muted mb-0">Manage and track your door-to-door campaign progress.</p>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-primary shadow-sm" data-bs-toggle="modal" data-bs-target="#mapModal">
<div class="d-flex flex-column flex-sm-row gap-2">
<button type="button" class="btn btn-outline-primary shadow-sm py-1 px-4 flex-grow-1" data-bs-toggle="modal" data-bs-target="#mapModal">
<i class="bi bi-map-fill me-1"></i> View Map
</button>
<a href="{% url 'door_visit_history' %}" class="btn btn-outline-success shadow-sm me-2"><i class="bi bi-clock-history me-1"></i> Visit History</a>
<a href="{% url 'voter_list' %}" class="btn btn-primary shadow-sm">
<i class="bi bi-person-lines-fill me-1"></i> Voter Registry
<a href="{% url 'door_visit_history' %}" class="btn btn-outline-success shadow-sm py-1 px-4 flex-grow-1">
<i class="bi bi-clock-history me-1"></i> Visit History
</a>
</div>
</div>
<!-- Filters Card -->
<div class="card border-0 shadow-sm rounded-4 mb-5 overflow-hidden">
<div class="card border-0 shadow-sm rounded-4 mb-4 mb-md-5 overflow-hidden">
<div class="card-header bg-white py-3 border-0">
<h5 class="card-title mb-0 fw-bold text-primary">Filters</h5>
</div>
<div class="card-body p-4">
<div class="card-body p-3 p-md-4">
<form method="GET" action="." class="row g-3 align-items-end">
<div class="col-md-3">
<div class="col-12 col-md-3">
<label class="form-label small fw-bold text-uppercase text-muted">District</label>
<input type="text" name="district" class="form-control rounded-3" placeholder="Filter by district..." value="{{ district_filter }}">
</div>
<div class="col-md-3">
<div class="col-12 col-md-3">
<label class="form-label small fw-bold text-uppercase text-muted">Neighborhood</label>
<input type="text" name="neighborhood" class="form-control rounded-3" placeholder="Filter by neighborhood..." value="{{ neighborhood_filter }}">
</div>
<div class="col-md-4">
<div class="col-12 col-md-4">
<label class="form-label small fw-bold text-uppercase text-muted">Address Search</label>
<input type="text" name="address" class="form-control rounded-3" placeholder="Filter by street address..." value="{{ address_filter }}">
</div>
<div class="col-md-2 d-flex gap-2">
<div class="col-12 col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary w-100 rounded-3">Filter</button>
<a href="." class="btn btn-light w-100 rounded-3">Reset</a>
</div>
@ -48,9 +47,9 @@
<!-- Households List -->
<div class="card border-0 shadow-sm rounded-4 overflow-hidden">
<div class="card-header bg-white py-3 border-bottom d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0 fw-bold">Unvisited Targeted Households</h5>
<span class="badge bg-primary-subtle text-primary px-3 py-2 rounded-pill">
<div class="card-header bg-white py-3 border-bottom d-flex flex-column flex-sm-row justify-content-between align-items-sm-center gap-2">
<h5 class="card-title mb-0 fw-bold text-dark">Unvisited Targeted Households</h5>
<span class="badge bg-primary-subtle text-primary px-3 py-2 rounded-pill w-auto align-self-start align-self-sm-center">
{{ households.paginator.count }} Households Found
</span>
</div>
@ -61,15 +60,14 @@
<th class="ps-4 py-3 text-uppercase small ls-1">Action</th>
<th class="py-3 text-uppercase small ls-1">Household Address</th>
<th class="py-3 text-uppercase small ls-1">Targeted Voters</th>
<th class="py-3 text-uppercase small ls-1">Neighborhood</th>
<th class="pe-4 py-3 text-uppercase small ls-1">District</th>
<th class="pe-4 py-3 text-uppercase small ls-1 d-none d-md-table-cell">Neighborhood</th>
</tr>
</thead>
<tbody>
{% for household in households %}
<tr>
<td class="ps-4">
<button type="button" class="btn btn-sm btn-primary px-3 shadow-sm"
<button type="button" class="btn btn-sm btn-primary px-3 shadow-sm py-2"
data-bs-toggle="modal" data-bs-target="#logVisitModal"
data-address="{{ household.address_street }}"
data-city="{{ household.city }}"
@ -80,7 +78,14 @@
</td>
<td>
<div class="fw-bold text-dark">{{ household.address_street }}</div>
<div class="small text-muted">{{ household.city }}, {{ household.state }} {{ household.zip_code }}</div>
<div class="small text-muted">{{ household.city }}</div>
<div class="d-md-none mt-1">
{% if household.neighborhood %}
<span class="badge border border-primary-subtle bg-primary-subtle text-primary fw-medium px-2 py-1 small">
{{ household.neighborhood }}
</span>
{% endif %}
</div>
</td>
<td>
<div class="d-flex flex-wrap gap-1">
@ -91,7 +96,7 @@
{% endfor %}
</div>
</td>
<td>
<td class="pe-4 d-none d-md-table-cell">
{% if household.neighborhood %}
<span class="badge border border-primary-subtle bg-primary-subtle text-primary fw-medium px-2 py-1">
{{ household.neighborhood }}
@ -100,15 +105,10 @@
<span class="text-muted italic small">Not assigned</span>
{% endif %}
</td>
<td class="pe-4">
<span class="badge bg-light text-dark border fw-medium">
{{ household.district|default:"-" }}
</span>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center py-5">
<td colspan="4" class="text-center py-5">
<div class="text-muted mb-2">
<i class="bi bi-house-dash mb-2" style="font-size: 3rem; opacity: 0.3;"></i>
</div>
@ -138,7 +138,7 @@
</li>
{% endif %}
<li class="page-item active mx-2"><span class="page-link rounded-pill px-3 border-0">Page {{ households.number }} of {{ households.paginator.num_pages }}</span></li>
<li class="page-item active mx-1 mx-sm-2"><span class="page-link rounded-pill px-2 px-sm-3 border-0 small">Page {{ households.number }} of {{ households.paginator.num_pages }}</span></li>
{% if households.has_next %}
<li class="page-item">
@ -161,9 +161,9 @@
<!-- Map Modal -->
<div class="modal fade" id="mapModal" tabindex="-1" aria-labelledby="mapModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen p-4">
<div class="modal-dialog modal-fullscreen p-2 p-md-4">
<div class="modal-content rounded-4 border-0 shadow">
<div class="modal-header border-0 bg-primary text-white p-4">
<div class="modal-header border-0 bg-primary text-white p-3 p-md-4">
<h5 class="modal-title d-flex align-items-center" id="mapModalLabel">
<i class="bi bi-map-fill me-2"></i> Targeted Households Map
</h5>
@ -179,17 +179,17 @@
<!-- Log Visit Modal -->
<div class="modal fade" id="logVisitModal" tabindex="-1" aria-labelledby="logVisitModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content rounded-4 border-0 shadow-lg">
<div class="modal-content rounded-4 border-0 shadow-lg mx-2 mx-md-0">
<div class="modal-header bg-light border-0 py-3">
<h5 class="modal-title fw-bold text-dark" id="logVisitModalLabel">Log Door Visit</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'log_door_visit' %}" method="POST">
{% csrf_token %}
<div class="modal-body p-4">
<div class="bg-primary-subtle p-3 rounded-3 mb-4 border border-primary-subtle">
<div class="modal-body p-3 p-md-4">
<div class="bg-primary-subtle p-3 rounded-3 mb-3 mb-md-4 border border-primary-subtle">
<div class="small text-uppercase fw-bold text-primary mb-1">Household Address</div>
<div id="modalAddressDisplay" class="h5 mb-0 fw-bold text-dark"></div>
<div id="modalAddressDisplay" class="h6 h5-md mb-0 fw-bold text-dark"></div>
</div>
<input type="hidden" name="address_street" id="modal_address_street">
@ -202,9 +202,9 @@
<label class="form-label fw-bold text-primary small text-uppercase">Visit Outcome</label>
<div class="row g-2">
{% for radio in visit_form.outcome %}
<div class="col-md-4">
<div class="col-6 col-md-4">
{{ radio.tag }}
<label class="btn btn-outline-primary w-100 h-100 d-flex align-items-center justify-content-center text-center py-2 px-3" for="{{ radio.id_for_label }}">
<label class="btn btn-outline-primary w-100 h-100 d-flex align-items-center justify-content-center text-center py-2 px-2 small" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
@ -218,11 +218,11 @@
</div>
<div class="row g-3">
<div class="col-md-6">
<div class="col-12 col-md-6">
<label class="form-label fw-bold text-primary small text-uppercase">Support Status</label>
{{ visit_form.candidate_support }}
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="col-12 col-md-6 d-flex align-items-end">
<div class="form-check mb-2">
{{ visit_form.wants_yard_sign }}
<label class="form-check-label fw-bold text-dark" for="{{ visit_form.wants_yard_sign.id_for_label }}">
@ -363,5 +363,11 @@
.ls-1 {
letter-spacing: 1px;
}
@media (max-width: 767.98px) {
.h5-md { font-size: 1.25rem; }
.table-responsive {
border: 0;
}
}
</style>
{% endblock %}

View File

@ -128,13 +128,13 @@
<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>
{% if can_view_donations %}<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 text-success">{{ metrics.donation_percentage }}%</h4>
</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>{% endif %}
</div>
</div>
</div>

View File

@ -72,9 +72,19 @@
{{ form.email }}
</div>
<div class="col-md-6">
<label for="{{ form.phone.id_for_label }}" class="form-label small text-muted text-uppercase fw-bold">Phone Number</label>
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center">
<label for="{{ form.phone.id_for_label }}" class="form-label small text-muted text-uppercase fw-bold mb-1">Phone Number</label>
{% if volunteer.phone %}
<div class="mb-1">
<a href="tel:{{ volunteer.phone }}" class="text-primary me-2" title="Call"><i class="bi bi-telephone"></i></a>
<a href="sms:{{ volunteer.phone }}" class="text-secondary" title="Text"><i class="bi bi-chat-dots"></i></a>
</div>
{% endif %}
</div>
{{ form.phone }}
</div>
</div>
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-2">
<label for="{{ form.interests.id_for_label }}" class="form-label small text-muted text-uppercase fw-bold mb-0">Interests</label>

View File

@ -66,7 +66,17 @@
</a>
</td>
<td>{{ volunteer.email }}</td>
<td>{{ volunteer.phone|default:"-" }}</td>
<td>
{% if volunteer.phone %}
<div class="d-flex align-items-center">
<a href="tel:{{ volunteer.phone }}" class="text-decoration-none text-dark fw-medium me-2">{{ volunteer.phone }}</a>
<a href="tel:{{ volunteer.phone }}" class="text-primary me-2" title="Call"><i class="bi bi-telephone" style="font-size: 0.85rem;"></i></a>
<a href="sms:{{ volunteer.phone }}" class="text-secondary" title="Text"><i class="bi bi-chat-dots" style="font-size: 0.85rem;"></i></a>
</div>
{% else %}
-
{% endif %}
</td>
<td class="pe-4">
{% for interest in volunteer.interests.all %}
<span class="badge bg-info-subtle text-info border border-info-subtle">{{ interest.name }}</span>

View File

@ -1,10 +1,16 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Advanced Voter Search</h1>
<a href="{% url 'voter_list' %}" class="btn btn-outline-secondary btn-sm">Back to Registry</a>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#mapModal">
<i class="bi bi-map-fill me-1"></i> View Map
</button>
<a href="{% url 'voter_list' %}" class="btn btn-outline-secondary btn-sm">Back to Registry</a>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
@ -86,6 +92,7 @@
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-3 border-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-bold">Search Results ({{ voters.paginator.count }})</h5>
{% if can_edit_voter %}
<div class="d-flex align-items-center">
<div id="bulk-actions" class="d-none me-2">
<button type="submit" name="action" value="export_selected" class="btn btn-primary btn-sm">
@ -101,6 +108,7 @@
</button>
</div>
</div>
{% endif %}
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
@ -130,15 +138,25 @@
</td>
<td><span class="badge bg-light text-dark border">{{ voter.district|default:"-" }}</span></td>
<td>
{{ voter.phone|default:"-" }}
{% if voter.phone %}
<div class="small text-muted">{{ voter.get_phone_type_display }}</div>
<div class="d-flex align-items-center mb-1">
<a href="tel:{{ voter.phone }}" class="text-decoration-none text-dark fw-medium me-2">{{ voter.phone }}</a>
<a href="tel:{{ voter.phone }}" class="text-primary me-2" title="Call"><i class="bi bi-telephone" style="font-size: 0.85rem;"></i></a>
<a href="sms:{{ voter.phone }}" class="text-secondary" title="Text"><i class="bi bi-chat-dots" style="font-size: 0.85rem;"></i></a>
</div>
<div class="small text-muted">{{ voter.get_phone_type_display }}</div>
{% else %}
-
{% endif %}
{% if voter.secondary_phone %}
<div class="mt-2">
{{ voter.secondary_phone }}
<div class="small text-muted">{{ voter.get_secondary_phone_type_display }}</div>
</div>
<div class="mt-2">
<div class="d-flex align-items-center mb-1">
<a href="tel:{{ voter.secondary_phone }}" class="text-decoration-none text-muted fw-medium me-2">{{ voter.secondary_phone }}</a>
<a href="tel:{{ voter.secondary_phone }}" class="text-primary me-2" title="Call"><i class="bi bi-telephone" style="font-size: 0.75rem;"></i></a>
<a href="sms:{{ voter.secondary_phone }}" class="text-secondary" title="Text"><i class="bi bi-chat-dots" style="font-size: 0.75rem;"></i></a>
</div>
<div class="small text-muted">{{ voter.get_secondary_phone_type_display }}</div>
</div>
{% endif %}
</td>
<td>
@ -208,6 +226,23 @@
</form>
</div>
<!-- Map Modal -->
<div class="modal fade" id="mapModal" tabindex="-1" aria-labelledby="mapModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen p-2 p-md-4">
<div class="modal-content rounded-4 border-0 shadow">
<div class="modal-header border-0 bg-primary text-white p-3 p-md-4">
<h5 class="modal-title d-flex align-items-center" id="mapModalLabel">
<i class="bi bi-map-fill me-2"></i> Search Results Map
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<div id="map" style="width: 100%; height: 100%; min-height: 500px;"></div>
</div>
</div>
</div>
</div>
<!-- SMS Modal -->
<div class="modal fade" id="smsModal" tabindex="-1" aria-labelledby="smsModalLabel" aria-hidden="true">
<div class="modal-dialog">
@ -238,7 +273,58 @@
</div>
</div>
<!-- Google Maps JS -->
{% if GOOGLE_MAPS_API_KEY %}
<script src="https://maps.googleapis.com/maps/api/js?key={{ GOOGLE_MAPS_API_KEY }}"></script>
{% endif %}
<script>
var map;
var markers = [];
var mapData = {{ map_data_json|safe }};
function initMap() {
if (!window.google || !window.google.maps) {
console.error("Google Maps API not loaded");
return;
}
var mapOptions = {
zoom: 12,
center: { lat: 41.8781, lng: -87.6298 }, // Default to Chicago
mapTypeControl: true,
streetViewControl: true,
fullscreenControl: true
};
map = new google.maps.Map(document.getElementById('map'), mapOptions);
var bounds = new google.maps.LatLngBounds();
var infowindow = new google.maps.InfoWindow();
mapData.forEach(function(item) {
if (item.lat && item.lng) {
var position = { lat: parseFloat(item.lat), lng: parseFloat(item.lng) };
var marker = new google.maps.Marker({
position: position,
map: map,
title: item.voters
});
marker.addListener('click', function() {
infowindow.setContent('<strong>' + item.voters + '</strong><br>' + item.address);
infowindow.open(map, marker);
});
markers.push(marker);
bounds.extend(position);
}
});
if (markers.length > 0) {
map.fitBounds(bounds);
}
}
document.addEventListener('DOMContentLoaded', function() {
const selectAll = document.getElementById('select-all');
const checkboxes = document.querySelectorAll('.voter-checkbox');
@ -246,10 +332,12 @@ document.addEventListener('DOMContentLoaded', function() {
function updateBulkActionsVisibility() {
const checkedCount = document.querySelectorAll('.voter-checkbox:checked').length;
if (checkedCount > 0) {
bulkActions.classList.remove('d-none');
} else {
bulkActions.classList.add('d-none');
if (bulkActions) {
if (checkedCount > 0) {
bulkActions.classList.remove('d-none');
} else {
bulkActions.classList.add('d-none');
}
}
}
@ -295,6 +383,24 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
}
var mapModal = document.getElementById('mapModal');
if (mapModal) {
mapModal.addEventListener('shown.bs.modal', function () {
if (!map) {
initMap();
} else {
google.maps.event.trigger(map, 'resize');
if (markers.length > 0) {
var bounds = new google.maps.LatLngBounds();
markers.forEach(function(marker) {
bounds.extend(marker.getPosition());
});
map.fitBounds(bounds);
}
}
});
}
});
</script>
{% endblock %}

View File

@ -27,9 +27,11 @@
<div class="col-md">
<div class="d-flex align-items-center">
<h1 class="h3 mb-1 me-3">{% if voter.nickname %}{{ voter.nickname }}{% else %}{{ voter.first_name }}{% endif %} {{ voter.last_name }}</h1>
{% if can_edit_voter %}
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#editVoterModal">
<i class="bi bi-pencil me-1"></i>Edit
</button>
{% endif %}
</div>
<p class="text-muted mb-0">
<i class="bi bi-geo-alt me-1"></i> {{ voter.address|default:"No address on file" }}
@ -88,19 +90,36 @@
</li>
<li class="mb-3">
<label class="small text-muted d-block">Phone</label>
<span class="fw-semibold">{{ voter.phone|default:"N/A" }}</span>
{% if voter.phone %}
<span class="badge bg-light text-dark border ms-1">{{ voter.get_phone_type_display }}</span>
<div class="d-flex align-items-center">
<span class="fw-semibold me-2">{{ voter.phone }}</span>
<a href="tel:{{ voter.phone }}" class="btn btn-sm btn-outline-primary py-0 px-2 me-1" title="Call">
<i class="bi bi-telephone"></i>
</a>
<a href="sms:{{ voter.phone }}" class="btn btn-sm btn-outline-secondary py-0 px-2 me-2" title="Text">
<i class="bi bi-chat-dots"></i>
</a>
<span class="badge bg-light text-dark border">{{ voter.get_phone_type_display }}</span>
</div>
{% else %}
<span class="fw-semibold">N/A</span>
{% endif %}
</li>
{% if voter.secondary_phone %}
<li class="mb-3">
<label class="small text-muted d-block">Secondary Phone</label>
<span class="fw-semibold">{{ voter.secondary_phone }}</span>
<span class="badge bg-light text-dark border ms-1">{{ voter.get_secondary_phone_type_display }}</span>
<div class="d-flex align-items-center">
<span class="fw-semibold me-2">{{ voter.secondary_phone }}</span>
<a href="tel:{{ voter.secondary_phone }}" class="btn btn-sm btn-outline-primary py-0 px-2 me-1" title="Call">
<i class="bi bi-telephone"></i>
</a>
<a href="sms:{{ voter.secondary_phone }}" class="btn btn-sm btn-outline-secondary py-0 px-2 me-2" title="Text">
<i class="bi bi-chat-dots"></i>
</a>
<span class="badge bg-light text-dark border">{{ voter.get_secondary_phone_type_display }}</span>
</div>
</li>
{% endif %}
<li class="mb-3">
<label class="small text-muted d-block">Birthdate</label>
<span class="fw-semibold">{{ voter.birthdate|date:"M d, Y"|default:"N/A" }}</span>
</li>
@ -119,9 +138,11 @@
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Likelihood to Vote</h5>
{% if can_edit_voter %}
<button class="btn btn-sm btn-link text-primary p-0" data-bs-toggle="modal" data-bs-target="#addLikelihoodModal">
<i class="bi bi-plus-circle"></i>
</button>
{% endif %}
</div>
<div class="card-body">
{% for likelihood in likelihoods %}
@ -136,6 +157,7 @@
<span class="badge bg-secondary ms-2">Not Likely</span>
{% endif %}
</div>
{% if can_edit_voter %}
<div class="text-nowrap ms-2">
<button class="btn btn-sm btn-link text-primary p-0 me-2" data-bs-toggle="modal" data-bs-target="#editLikelihoodModal{{ likelihood.id }}">
<i class="bi bi-pencil small"></i>
@ -144,6 +166,7 @@
<i class="bi bi-trash small"></i>
</button>
</div>
{% endif %}
</div>
{% empty %}
<p class="text-muted mb-0 italic">No likelihood data available.</p>
@ -207,9 +230,11 @@
<li class="nav-item" role="presentation">
<button class="nav-link border-0 py-3 px-4" id="events-tab" data-bs-toggle="tab" data-bs-target="#events" type="button" role="tab">Events</button>
</li>
{% if can_view_donations %}
<li class="nav-item" role="presentation">
<button class="nav-link border-0 py-3 px-4" id="donations-tab" data-bs-toggle="tab" data-bs-target="#donations" type="button" role="tab">Donations</button>
</li>
{% endif %}
<li class="nav-item" role="presentation">
<button class="nav-link border-0 py-3 px-4" id="voting-tab" data-bs-toggle="tab" data-bs-target="#voting" type="button" role="tab">Voting History</button>
</li>
@ -234,7 +259,9 @@
<th>Volunteer</th>
<th>Description</th>
<th>Notes</th>
{% if can_edit_voter %}
<th class="pe-4 text-end">Actions</th>
{% endif %}
</tr>
</thead>
<tbody>
@ -245,6 +272,7 @@
<td>{% if interaction.volunteer %}{{ interaction.volunteer }}{% else %}<span class="text-muted small">-</span>{% endif %}</td>
<td>{{ interaction.description }}</td>
<td class="small text-muted">{{ interaction.notes|truncatechars:30 }}</td>
{% if can_edit_voter %}
<td class="pe-4 text-end">
<button class="btn btn-sm btn-link text-primary p-0 me-2" data-bs-toggle="modal" data-bs-target="#editInteractionModal{{ interaction.id }}">
<i class="bi bi-pencil"></i>
@ -253,9 +281,10 @@
<i class="bi bi-trash"></i>
</button>
</td>
{% endif %}
</tr>
{% empty %}
<tr><td colspan="5" class="text-center py-4 text-muted">No interactions recorded.</td></tr>
<tr><td colspan="6" class="text-center py-4 text-muted">No interactions recorded.</td></tr>
{% endfor %}
</tbody>
</table>
@ -281,7 +310,9 @@
<th>Event Type</th>
<th>Status</th>
<th>Description</th>
{% if can_edit_voter %}
<th class="pe-4 text-end">Actions</th>
{% endif %}
</tr>
</thead>
<tbody>
@ -300,6 +331,7 @@
{% endif %}
</td>
<td class="small text-muted">{{ participation.event.description|truncatechars:60 }}</td>
{% if can_edit_voter %}
<td class="pe-4 text-end">
<button class="btn btn-sm btn-link text-primary p-0 me-2" data-bs-toggle="modal" data-bs-target="#editEventParticipationModal{{ participation.id }}">
<i class="bi bi-pencil"></i>
@ -308,9 +340,10 @@
<i class="bi bi-trash"></i>
</button>
</td>
{% endif %}
</tr>
{% empty %}
<tr><td colspan="5" class="text-center py-4 text-muted">No event participations found.</td></tr>
<tr><td colspan="6" class="text-center py-4 text-muted">No event participations found.</td></tr>
{% endfor %}
</tbody>
</table>
@ -319,6 +352,7 @@
</div>
<!-- Donations Tab -->
{% if can_view_donations %}
<div class="tab-pane fade" id="donations" role="tabpanel">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
@ -360,6 +394,7 @@
</div>
</div>
</div>
{% endif %}
<!-- Voting History Tab -->
<div class="tab-pane fade" id="voting" role="tabpanel">
@ -393,6 +428,7 @@
</div>
</div>
{% if can_edit_voter %}
<!-- Edit Voter Modal -->
<div class="modal fade" id="editVoterModal" tabindex="-1" aria-labelledby="editVoterModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
@ -563,6 +599,7 @@
</div>
</div>
</div>
{% endif %}
<!-- Add Interaction Modal -->
<div class="modal fade" id="addInteractionModal" tabindex="-1" aria-hidden="true">
@ -605,6 +642,7 @@
</div>
</div>
{% if can_edit_voter %}
<!-- Edit Interaction Modals -->
{% for interaction in interactions %}
<div class="modal fade" id="editInteractionModal{{ interaction.id }}" tabindex="-1" aria-hidden="true">
@ -683,7 +721,9 @@
</div>
</div>
{% endfor %}
{% endif %}
{% if can_view_donations %}
<!-- Add Donation Modal -->
<div class="modal fade" id="addDonationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
@ -788,6 +828,7 @@
</div>
</div>
{% endfor %}
{% endif %}
<!-- Add Likelihood Modal -->
<div class="modal fade" id="addLikelihoodModal" tabindex="-1" aria-hidden="true">
@ -818,6 +859,7 @@
</div>
</div>
{% if can_edit_voter %}
<!-- Edit Likelihood Modals -->
{% for likelihood in likelihoods %}
<div class="modal fade" id="editLikelihoodModal{{ likelihood.id }}" tabindex="-1" aria-hidden="true">
@ -883,6 +925,7 @@
</div>
</div>
{% endfor %}
{% endif %}
<!-- Add Event Participation Modal -->
<div class="modal fade" id="addEventParticipationModal" tabindex="-1" aria-hidden="true">
@ -913,6 +956,7 @@
</div>
</div>
{% if can_edit_voter %}
<!-- Edit Event Participation Modals -->
{% for participation in event_participations %}
<div class="modal fade" id="editEventParticipationModal{{ participation.id }}" tabindex="-1" aria-hidden="true">
@ -978,6 +1022,7 @@
</div>
</div>
{% endfor %}
{% endif %}
<style>
.nav-tabs .nav-link {

View File

@ -1,12 +1,18 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<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">
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#mapModal">
<i class="bi bi-map-fill me-1"></i> View Map
</button>
<a href="{% url 'voter_advanced_search' %}" class="btn btn-outline-primary btn-sm">Advanced Search</a>
{% if can_edit_voter %}
<a href="/admin/core/voter/add/" class="btn btn-primary btn-sm">+ Add New Voter</a>
{% endif %}
</div>
</div>
@ -45,10 +51,22 @@
<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>
{% if voter.phone %}
<div class="d-flex align-items-center mb-1">
<a href="tel:{{ voter.phone }}" class="text-decoration-none text-dark fw-medium me-2">{{ voter.phone }}</a>
<a href="tel:{{ voter.phone }}" class="text-primary me-2" title="Call"><i class="bi bi-telephone" style="font-size: 0.85rem;"></i></a>
<a href="sms:{{ voter.phone }}" class="text-secondary" title="Text"><i class="bi bi-chat-dots" style="font-size: 0.85rem;"></i></a>
</div>
{% else %}
-
{% endif %}
{% if voter.secondary_phone %}
<br><small class="text-muted">{{ voter.secondary_phone }}</small>
<div class="d-flex align-items-center small text-muted">
<a href="tel:{{ voter.secondary_phone }}" class="text-decoration-none text-muted me-2">{{ voter.secondary_phone }}</a>
<a href="tel:{{ voter.secondary_phone }}" class="text-primary me-2" title="Call"><i class="bi bi-telephone" style="font-size: 0.75rem;"></i></a>
<a href="sms:{{ voter.secondary_phone }}" class="text-secondary" title="Text"><i class="bi bi-chat-dots" style="font-size: 0.75rem;"></i></a>
</div>
{% endif %}
</td>
<td>
@ -85,12 +103,12 @@
<ul class="pagination justify-content-center mb-0">
{% if voters.has_previous %}
<li class="page-item">
<a class="page-item" href="?page=1{% if query %}&q={{ query }}{% endif %}" aria-label="First">
<a class="page-item" href="?page=1{% if query %}&q={{ query }}{% endif %}{% for key, value in request.GET.items %}{% if key != 'page' and key != 'q' %}&{{ key }}={{ value }}{% endif %}{% endfor %}" aria-label="First">
<span class="page-link">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ voters.previous_page_number }}{% if query %}&q={{ query }}{% endif %}" aria-label="Previous">
<a class="page-link" href="?page={{ voters.previous_page_number }}{% if query %}&q={{ query }}{% endif %}{% for key, value in request.GET.items %}{% if key != 'page' and key != 'q' %}&{{ key }}={{ value }}{% endif %}{% endfor %}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
@ -100,12 +118,12 @@
{% if voters.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ voters.next_page_number }}{% if query %}&q={{ query }}{% endif %}" aria-label="Next">
<a class="page-link" href="?page={{ voters.next_page_number }}{% if query %}&q={{ query }}{% endif %}{% for key, value in request.GET.items %}{% if key != 'page' and key != 'q' %}&{{ key }}={{ value }}{% endif %}{% endfor %}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ voters.paginator.num_pages }}{% if query %}&q={{ query }}{% endif %}" aria-label="Last">
<a class="page-link" href="?page={{ voters.paginator.num_pages }}{% if query %}&q={{ query }}{% endif %}{% for key, value in request.GET.items %}{% if key != 'page' and key != 'q' %}&{{ key }}={{ value }}{% endif %}{% endfor %}" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
@ -116,4 +134,94 @@
{% endif %}
</div>
</div>
{% endblock %}
<!-- Map Modal -->
<div class="modal fade" id="mapModal" tabindex="-1" aria-labelledby="mapModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen p-2 p-md-4">
<div class="modal-content rounded-4 border-0 shadow">
<div class="modal-header border-0 bg-primary text-white p-3 p-md-4">
<h5 class="modal-title d-flex align-items-center" id="mapModalLabel">
<i class="bi bi-map-fill me-2"></i> Voter Map
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<div id="map" style="width: 100%; height: 100%; min-height: 500px;"></div>
</div>
</div>
</div>
</div>
<!-- Google Maps JS -->
{% if GOOGLE_MAPS_API_KEY %}
<script src="https://maps.googleapis.com/maps/api/js?key={{ GOOGLE_MAPS_API_KEY }}"></script>
{% endif %}
<script>
var map;
var markers = [];
var mapData = {{ map_data_json|safe }};
function initMap() {
if (!window.google || !window.google.maps) {
console.error("Google Maps API not loaded");
return;
}
var mapOptions = {
zoom: 12,
center: { lat: 41.8781, lng: -87.6298 }, // Default to Chicago
mapTypeControl: true,
streetViewControl: true,
fullscreenControl: true
};
map = new google.maps.Map(document.getElementById('map'), mapOptions);
var bounds = new google.maps.LatLngBounds();
var infowindow = new google.maps.InfoWindow();
mapData.forEach(function(item) {
if (item.lat && item.lng) {
var position = { lat: parseFloat(item.lat), lng: parseFloat(item.lng) };
var marker = new google.maps.Marker({
position: position,
map: map,
title: item.voters
});
marker.addListener('click', function() {
infowindow.setContent('<strong>' + item.voters + '</strong><br>' + item.address);
infowindow.open(map, marker);
});
markers.push(marker);
bounds.extend(position);
}
});
if (markers.length > 0) {
map.fitBounds(bounds);
}
}
document.addEventListener('DOMContentLoaded', function() {
var mapModal = document.getElementById('mapModal');
if (mapModal) {
mapModal.addEventListener('shown.bs.modal', function () {
if (!map) {
initMap();
} else {
google.maps.event.trigger(map, 'resize');
if (markers.length > 0) {
var bounds = new google.maps.LatLngBounds();
markers.forEach(function(marker) {
bounds.extend(marker.getPosition());
});
map.fitBounds(bounds);
}
}
});
}
});
</script>
{% endblock %}

View File

@ -20,6 +20,7 @@ import logging
import zoneinfo
from django.utils import timezone
from .permissions import role_required, can_view_donations, can_edit_voter, can_view_volunteers, can_edit_volunteer, can_view_voters
logger = logging.getLogger(__name__)
def index(request):
@ -86,6 +87,7 @@ def select_campaign(request, tenant_id):
messages.success(request, f"You are now managing: {tenant.name}")
return redirect('index')
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
def voter_list(request):
"""
List and search voters. Restricted to selected tenant.
@ -147,6 +149,7 @@ def voter_list(request):
return render(request, "core/voter_list.html", context)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
def voter_detail(request, voter_id):
"""
360-degree view of a voter.
@ -250,6 +253,7 @@ def delete_interaction(request, interaction_id):
messages.success(request, "Interaction deleted.")
return redirect(reverse('voter_detail', kwargs={'voter_id': voter_id}) + '?active_tab=interactions')
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_donation')
def add_donation(request, voter_id):
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
@ -264,6 +268,7 @@ def add_donation(request, voter_id):
messages.success(request, "Donation recorded.")
return redirect(reverse('voter_detail', kwargs={'voter_id': voter.id}) + '?active_tab=donations')
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_donation')
def edit_donation(request, donation_id):
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
@ -276,6 +281,7 @@ def edit_donation(request, donation_id):
messages.success(request, "Donation updated.")
return redirect(reverse('voter_detail', kwargs={'voter_id': donation.voter.id}) + '?active_tab=donations')
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.delete_donation')
def delete_donation(request, donation_id):
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
@ -417,6 +423,7 @@ def voter_geocode(request, voter_id):
return JsonResponse({'success': False, 'error': 'Invalid request method.'})
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
def voter_advanced_search(request):
"""
Advanced search for voters with multiple filters.
@ -569,6 +576,7 @@ def voter_delete(request, voter_id):
return redirect('voter_detail', voter_id=voter.id)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
def bulk_send_sms(request):
"""
Sends bulk SMS to selected voters using Twilio API.
@ -672,6 +680,7 @@ def bulk_send_sms(request):
messages.success(request, f"Bulk SMS process completed: {success_count} successful, {fail_count} failed/skipped.")
return redirect('voter_advanced_search')
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_event')
def event_list(request):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
@ -689,6 +698,7 @@ def event_list(request):
}
return render(request, 'core/event_list.html', context)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_event')
def event_detail(request, event_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
@ -807,6 +817,7 @@ def voter_search_json(request):
return JsonResponse({"results": data})
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_volunteer')
def volunteer_list(request):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
@ -844,6 +855,7 @@ def volunteer_list(request):
}
return render(request, 'core/volunteer_list.html', context)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_volunteer')
def volunteer_add(request):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
@ -871,6 +883,7 @@ def volunteer_add(request):
}
return render(request, 'core/volunteer_detail.html', context)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_volunteer')
def volunteer_detail(request, volunteer_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
@ -902,6 +915,7 @@ def volunteer_detail(request, volunteer_id):
}
return render(request, 'core/volunteer_detail.html', context)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.delete_volunteer')
def volunteer_delete(request, volunteer_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
@ -916,6 +930,7 @@ def volunteer_delete(request, volunteer_id):
return redirect('volunteer_list')
return redirect('volunteer_detail', volunteer_id=volunteer_id)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_volunteer')
def volunteer_assign_event(request, volunteer_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
@ -936,6 +951,7 @@ def volunteer_assign_event(request, volunteer_id):
return redirect('volunteer_detail', volunteer_id=volunteer.id)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_volunteer')
def volunteer_remove_event(request, assignment_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
@ -977,6 +993,7 @@ def interest_delete(request, interest_id):
return JsonResponse({'success': True})
return JsonResponse({'success': False, 'error': 'Invalid request.'})
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_event')
def event_create(request):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
@ -1005,6 +1022,7 @@ def event_create(request):
}
return render(request, "core/event_edit.html", context)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_event')
def event_edit(request, event_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
@ -1091,6 +1109,7 @@ def event_remove_volunteer(request, assignment_id):
messages.success(request, f"{volunteer_name} removed from event volunteers.")
return redirect('event_detail', event_id=event_id)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
def volunteer_bulk_send_sms(request):
"""
Sends bulk SMS to selected volunteers using Twilio API.
@ -1172,6 +1191,7 @@ def volunteer_bulk_send_sms(request):
messages.success(request, f"Bulk SMS process completed: {success_count} successful, {fail_count} failed/skipped.")
return redirect('volunteer_list')
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
def door_visits(request):
"""
Manage door knocking visits. Groups unvisited targeted voters by household.
@ -1267,6 +1287,7 @@ def door_visits(request):
}
return render(request, 'core/door_visits.html', context)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
def log_door_visit(request):
"""
Mark all targeted voters at a specific address as visited, update their flags,
@ -1357,6 +1378,7 @@ def log_door_visit(request):
return redirect(redirect_url)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
def door_visit_history(request):
"""
Shows a distinct list of Door visit interactions for addresses.

View File

@ -1,14 +0,0 @@
context = {
'selected_tenant': tenant,
'households': households_page,
'district_filter': district_filter,
'neighborhood_filter': neighborhood_filter,
'address_filter': address_filter,
'map_data_json': json.dumps(map_data),
'google_maps_api_key': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''),
'visit_form': DoorVisitLogForm(),
}
return render(request, 'core/door_visits.html', context)
def log_door_visit(request):
"""