Autosave: 20260201-211719
This commit is contained in:
parent
442aec63b6
commit
63faa21a4f
@ -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.
Binary file not shown.
BIN
core/__pycache__/permissions.cpython-311.pyc
Normal file
BIN
core/__pycache__/permissions.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
1816
core/admin.py.bak
1816
core/admin.py.bak
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
)
|
||||
@ -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
|
||||
|
||||
18
core/migrations/0039_alter_tenantuserrole_role.py
Normal file
18
core/migrations/0039_alter_tenantuserrole_role.py
Normal 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),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@ -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
116
core/permissions.py
Normal 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
|
||||
@ -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">© 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>
|
||||
|
||||
@ -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 %}
|
||||
@ -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 %}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
@ -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 {
|
||||
|
||||
@ -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">««</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">«</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">»</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">»»</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 %}
|
||||
@ -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.
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
Loading…
x
Reference in New Issue
Block a user