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 os
import time import time
from django.conf import settings 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): def project_context(request):
""" """
Adds project-specific environment variables to the template context globally. Adds project-specific environment variables to the template context globally.
""" """
return { context = {
"project_description": os.getenv("PROJECT_DESCRIPTION", ""), "project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
# Used for cache-busting static assets # Used for cache-busting static assets
"deployment_timestamp": int(time.time()), "deployment_timestamp": int(time.time()),
"GOOGLE_MAPS_API_KEY": getattr(settings, 'GOOGLE_MAPS_API_KEY', ''), "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): class TenantUserRole(models.Model):
ROLE_CHOICES = [ ROLE_CHOICES = [
('admin', 'Admin'), ('system_admin', 'System Administrator'),
('campaign_manager', 'Campaign Manager'), ('campaign_admin', 'Campaign Administrator'),
('campaign_staff', 'Campaign Staff'), ('campaign_staff', 'Campaign Staff'),
] ]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tenant_roles') 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Grassroots Campaign Manager{% endblock %}</title> <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"> <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"> <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 }}"> <link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% if project_description %} {% if project_description %}
<meta name="description" content="{{ project_description }}"> <meta name="description" content="{{ project_description }}">
{% endif %} {% endif %}
{% if project_image_url %}
{% block head %}{% endblock %} <meta property="og:image" content="{{ project_image_url }}">
{% endif %}
{% block extra_css %}{% endblock %}
</head> </head>
<body> <body class="bg-light">
<nav class="navbar navbar-expand-lg sticky-top"> <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container"> <div class="container">
<a class="navbar-brand d-flex align-items-center" href="/"> <a class="navbar-brand" href="/">Grassroots CM</a>
<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>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/">Dashboard</a> <a class="nav-link" href="/">Home</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/voters/">Voters</a> <a class="nav-link" href="/voters/">Voters</a>
@ -46,9 +37,11 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/events/">Events</a> <a class="nav-link" href="/events/">Events</a>
</li> </li>
{% if can_view_volunteers %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/volunteers/">Volunteers</a> <a class="nav-link" href="/volunteers/">Volunteers</a>
</li> </li>
{% endif %}
</ul> </ul>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<a href="/admin/" class="btn btn-outline-primary btn-sm me-2">Admin Panel</a> <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> <button type="submit" class="btn btn-link nav-link d-inline p-0" style="text-decoration: none;">Logout</button>
</form> </form>
{% else %} {% 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 %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
<main> <div class="container py-4">
{% if messages %} {% if messages %}
<div class="container mt-3">
{% for message in messages %} {% for message in messages %}
<div class="alert alert-{% if message.tags == 'error' %}danger{% else %}{{ message.tags }}{% endif %} alert-dismissible fade show" role="alert"> <div class="alert alert-{{ message.tags }} alert-dismissible fade show">
{{ message }} {{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div> </div>
{% endfor %} {% endfor %}
</div>
{% endif %} {% endif %}
{% block content %}{% endblock %} {% 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 src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script> {% block extra_js %}{% endblock %}
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>
</body> </body>
</html> </html>

View File

@ -2,39 +2,36 @@
{% load static %} {% load static %}
{% block content %} {% block content %}
<div class="container-fluid py-5 px-4"> <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-5 gap-3"> <div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-4 mb-md-5 gap-3">
<div> <div>
<h1 class="h2 fw-bold text-dark mb-1">Door Visit History</h1> <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> <p class="text-muted mb-0">Review completed door-to-door visits and outcomes.</p>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2 w-100 w-md-auto">
<a href="{% url 'door_visits' %}" class="btn btn-outline-primary shadow-sm"> <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> Planned Visits <i class="bi bi-door-open-fill me-1"></i> Door 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
</a> </a>
</div> </div>
</div> </div>
<!-- Filters and Summary --> <!-- Filters and Summary -->
<div class="row g-4 mb-5"> <div class="row g-4 mb-4 mb-md-5">
<!-- Date Filter Card --> <!-- Date Filter Card -->
<div class="col-lg-5"> <div class="col-lg-5">
<div class="card border-0 shadow-sm rounded-4 h-100"> <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> <h5 class="card-title fw-bold mb-3">Filter by Date Range</h5>
<form method="get" class="row g-2"> <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> <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:'' }}"> <input type="date" name="start_date" class="form-control rounded-pill border-light bg-light" value="{{ start_date|default:'' }}">
</div> </div>
<div class="col-sm-5"> <div class="col-6 col-sm-5">
<label class="small text-muted mb-1 ms-1">To</label> <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:'' }}"> <input type="date" name="end_date" class="form-control rounded-pill border-light bg-light" value="{{ end_date|default:'' }}">
</div> </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> <button type="submit" class="btn btn-primary rounded-pill w-100 px-0">Filter</button>
</div> </div>
{% if start_date or end_date %} {% if start_date or end_date %}
@ -52,12 +49,12 @@
<!-- Volunteer Counts Summary --> <!-- Volunteer Counts Summary -->
<div class="col-lg-7"> <div class="col-lg-7">
<div class="card border-0 shadow-sm rounded-4 h-100"> <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> <h5 class="card-title fw-bold mb-3">Visits per Volunteer</h5>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
{% for v_name, count in volunteer_counts %} {% for v_name, count in volunteer_counts %}
<div class="bg-light rounded-pill px-3 py-2 d-flex align-items-center border"> <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> <span class="badge bg-primary rounded-pill">{{ count }}</span>
</div> </div>
{% empty %} {% empty %}
@ -71,9 +68,9 @@
<!-- Households List --> <!-- Households List -->
<div class="card border-0 shadow-sm rounded-4 overflow-hidden"> <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> <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 {{ history.paginator.count }} Households Visited
</span> </span>
</div> </div>
@ -81,31 +78,38 @@
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="bg-light text-muted"> <thead class="bg-light text-muted">
<tr> <tr>
<th class="ps-4 py-3 text-uppercase small ls-1">Household Address</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">Voters Visited</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">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="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> </tr>
</thead> </thead>
<tbody> <tbody>
{% for household in history %} {% for household in history %}
<tr> <tr>
<td class="ps-4"> <td class="ps-3 ps-md-4">
<div class="fw-bold text-dark">{{ household.address_display }}</div> <div class="fw-bold text-dark">{{ household.address_display }}</div>
{% if household.neighborhood %} <div class="d-flex flex-wrap gap-1 mt-1">
<span class="badge bg-light text-primary border border-primary-subtle small mt-1"> {% if household.neighborhood %}
{{ household.neighborhood }} <span class="badge bg-light text-primary border border-primary-subtle small d-md-inline-block">
</span> {{ household.neighborhood }}
{% endif %} </span>
{% if household.district %} {% endif %}
<span class="badge bg-light text-secondary border small mt-1"> {% if household.district %}
District: {{ household.district }} <span class="badge bg-light text-secondary border small d-none d-md-inline-block">
</span> District: {{ household.district }}
{% endif %} </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> <td class="d-none d-md-table-cell">
<div class="d-flex flex-wrap gap-1"> <div class="d-flex flex-wrap gap-1">
{% for voter_name in household.voters_at_address %} {% for voter_name in household.voters_at_address %}
<span class="badge bg-light text-dark border fw-normal"> <span class="badge bg-light text-dark border fw-normal">
@ -115,27 +119,27 @@
</div> </div>
</td> </td>
<td> <td>
<div class="fw-bold text-dark">{{ household.last_visit_date|date:"M d, Y" }}</div> <div class="fw-bold text-dark small">{{ household.last_visit_date|date:"M d, Y" }}</div>
<div class="small text-muted">{{ household.last_visit_date|date:"H:i" }}</div> <div class="small text-muted d-none d-sm-block">{{ household.last_visit_date|date:"H:i" }}</div>
</td> </td>
<td> <td class="d-none d-sm-table-cell">
{% if household.last_volunteer %} {% if household.last_volunteer %}
<div class="d-flex align-items-center"> <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 }} {{ household.last_volunteer.first_name|first }}{{ household.last_volunteer.last_name|first }}
</div> </div>
<span class="fw-medium">{{ household.last_volunteer }}</span> <span class="fw-medium small">{{ household.last_volunteer }}</span>
</div> </div>
{% else %} {% else %}
<span class="text-muted small">N/A</span> <span class="text-muted small">N/A</span>
{% endif %} {% endif %}
</td> </td>
<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 }} {{ household.last_outcome }}
</span> </span>
</td> </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;"> <div class="small text-muted text-wrap" style="max-width: 200px;">
{{ household.notes|default:"-" }} {{ household.notes|default:"-" }}
</div> </div>
@ -148,7 +152,7 @@
<i class="bi bi-calendar-x mb-2" style="font-size: 3rem; opacity: 0.3;"></i> <i class="bi bi-calendar-x mb-2" style="font-size: 3rem; opacity: 0.3;"></i>
</div> </div>
<p class="mb-0 fw-medium">No door visits found for this selection.</p> <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> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -159,31 +163,31 @@
{% if history.paginator.num_pages > 1 %} {% if history.paginator.num_pages > 1 %}
<div class="card-footer bg-white border-0 py-4"> <div class="card-footer bg-white border-0 py-4">
<nav aria-label="Page navigation"> <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 %} {% if history.has_previous %}
<li class="page-item"> <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"> <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> </a>
</li> </li>
<li class="page-item"> <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"> <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> </a>
</li> </li>
{% endif %} {% 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 %} {% if history.has_next %}
<li class="page-item"> <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"> <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> </a>
</li> </li>
<li class="page-item"> <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"> <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> </a>
</li> </li>
{% endif %} {% endif %}
@ -210,5 +214,17 @@
.text-info { .text-info {
color: #055160 !important; 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> </style>
{% endblock %} {% endblock %}

View File

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

View File

@ -128,13 +128,13 @@
<div class="col-md-3"> <div class="col-md-3">
<div class="card border-0 shadow-sm h-100"> <div class="card border-0 shadow-sm h-100">
<div class="card-body p-4"> <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"> <div class="d-flex justify-content-between align-items-end mb-2">
<h4 class="mb-0 fw-bold text-success">{{ metrics.donation_percentage }}%</h4> <h4 class="mb-0 fw-bold text-success">{{ metrics.donation_percentage }}%</h4>
</div> </div>
<div class="progress" style="height: 10px;"> <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 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> </div>
</div> </div>

View File

@ -72,9 +72,19 @@
{{ form.email }} {{ form.email }}
</div> </div>
<div class="col-md-6"> <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 }} {{ form.phone }}
</div> </div>
</div>
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-2"> <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> <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> </a>
</td> </td>
<td>{{ volunteer.email }}</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"> <td class="pe-4">
{% for interest in volunteer.interests.all %} {% for interest in volunteer.interests.all %}
<span class="badge bg-info-subtle text-info border border-info-subtle">{{ interest.name }}</span> <span class="badge bg-info-subtle text-info border border-info-subtle">{{ interest.name }}</span>

View File

@ -1,10 +1,16 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% block content %} {% block content %}
<div class="container py-5"> <div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Advanced Voter Search</h1> <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>
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
@ -86,6 +92,7 @@
<div class="card border-0 shadow-sm"> <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"> <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> <h5 class="mb-0 fw-bold">Search Results ({{ voters.paginator.count }})</h5>
{% if can_edit_voter %}
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div id="bulk-actions" class="d-none me-2"> <div id="bulk-actions" class="d-none me-2">
<button type="submit" name="action" value="export_selected" class="btn btn-primary btn-sm"> <button type="submit" name="action" value="export_selected" class="btn btn-primary btn-sm">
@ -101,6 +108,7 @@
</button> </button>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0 align-middle"> <table class="table table-hover mb-0 align-middle">
@ -130,15 +138,25 @@
</td> </td>
<td><span class="badge bg-light text-dark border">{{ voter.district|default:"-" }}</span></td> <td><span class="badge bg-light text-dark border">{{ voter.district|default:"-" }}</span></td>
<td> <td>
{{ voter.phone|default:"-" }}
{% if voter.phone %} {% 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 %} {% endif %}
{% if voter.secondary_phone %} {% if voter.secondary_phone %}
<div class="mt-2"> <div class="mt-2">
{{ voter.secondary_phone }} <div class="d-flex align-items-center mb-1">
<div class="small text-muted">{{ voter.get_secondary_phone_type_display }}</div> <a href="tel:{{ voter.secondary_phone }}" class="text-decoration-none text-muted fw-medium me-2">{{ voter.secondary_phone }}</a>
</div> <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 %} {% endif %}
</td> </td>
<td> <td>
@ -208,6 +226,23 @@
</form> </form>
</div> </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 --> <!-- SMS Modal -->
<div class="modal fade" id="smsModal" tabindex="-1" aria-labelledby="smsModalLabel" aria-hidden="true"> <div class="modal fade" id="smsModal" tabindex="-1" aria-labelledby="smsModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
@ -238,7 +273,58 @@
</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> <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() { document.addEventListener('DOMContentLoaded', function() {
const selectAll = document.getElementById('select-all'); const selectAll = document.getElementById('select-all');
const checkboxes = document.querySelectorAll('.voter-checkbox'); const checkboxes = document.querySelectorAll('.voter-checkbox');
@ -246,10 +332,12 @@ document.addEventListener('DOMContentLoaded', function() {
function updateBulkActionsVisibility() { function updateBulkActionsVisibility() {
const checkedCount = document.querySelectorAll('.voter-checkbox:checked').length; const checkedCount = document.querySelectorAll('.voter-checkbox:checked').length;
if (checkedCount > 0) { if (bulkActions) {
bulkActions.classList.remove('d-none'); if (checkedCount > 0) {
} else { bulkActions.classList.remove('d-none');
bulkActions.classList.add('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> </script>
{% endblock %} {% endblock %}

View File

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

View File

@ -1,12 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% block content %} {% block content %}
<div class="container py-5"> <div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Voter Registry</h1> <h1 class="h2">Voter Registry</h1>
<div class="d-flex gap-2"> <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> <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> <a href="/admin/core/voter/add/" class="btn btn-primary btn-sm">+ Add New Voter</a>
{% endif %}
</div> </div>
</div> </div>
@ -45,10 +51,22 @@
<div class="small text-muted">{{ voter.address|default:"No address provided" }}</div> <div class="small text-muted">{{ voter.address|default:"No address provided" }}</div>
</td> </td>
<td><span class="badge bg-light text-dark border">{{ voter.district|default:"-" }}</span></td> <td><span class="badge bg-light text-dark border">{{ voter.district|default:"-" }}</span></td>
<td> <td>
{{ voter.phone|default:"-" }} {% 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 %} {% 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 %} {% endif %}
</td> </td>
<td> <td>
@ -85,12 +103,12 @@
<ul class="pagination justify-content-center mb-0"> <ul class="pagination justify-content-center mb-0">
{% if voters.has_previous %} {% if voters.has_previous %}
<li class="page-item"> <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> <span class="page-link">&laquo;&laquo;</span>
</a> </a>
</li> </li>
<li class="page-item"> <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> <span aria-hidden="true">&laquo;</span>
</a> </a>
</li> </li>
@ -100,12 +118,12 @@
{% if voters.has_next %} {% if voters.has_next %}
<li class="page-item"> <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> <span aria-hidden="true">&raquo;</span>
</a> </a>
</li> </li>
<li class="page-item"> <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> <span aria-hidden="true">&raquo;&raquo;</span>
</a> </a>
</li> </li>
@ -116,4 +134,94 @@
{% endif %} {% endif %}
</div> </div>
</div> </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> 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 %} {% endblock %}

View File

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