This commit is contained in:
Flatlogic Bot 2026-02-01 06:28:41 +00:00
parent c5d42d341f
commit 442aec63b6
9 changed files with 192 additions and 45 deletions

View File

@ -69,6 +69,7 @@ MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'core.middleware.LoginRequiredMiddleware', 'core.middleware.LoginRequiredMiddleware',
'core.middleware.TimezoneMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes. # Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
# 'django.middleware.clickjacking.XFrameOptionsMiddleware', # 'django.middleware.clickjacking.XFrameOptionsMiddleware',

View File

@ -1,6 +1,9 @@
import zoneinfo
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from django.conf import settings from django.conf import settings
from django.utils import timezone
from core.models import CampaignSettings, Tenant
class LoginRequiredMiddleware: class LoginRequiredMiddleware:
def __init__(self, get_response): def __init__(self, get_response):
@ -11,7 +14,6 @@ class LoginRequiredMiddleware:
path = request.path_info path = request.path_info
# Allow access to login, logout, admin, and any other exempted paths # Allow access to login, logout, admin, and any other exempted paths
# We use try/except in case URLs are not defined yet
try: try:
login_url = reverse('login') login_url = reverse('login')
logout_url = reverse('logout') logout_url = reverse('logout')
@ -33,3 +35,34 @@ class LoginRequiredMiddleware:
response = self.get_response(request) response = self.get_response(request)
return response return response
class TimezoneMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
tzname = None
# 1. Try to get tenant from session
tenant_id = request.session.get("tenant_id")
if tenant_id:
try:
campaign_settings = CampaignSettings.objects.get(tenant_id=tenant_id)
tzname = campaign_settings.timezone
except CampaignSettings.DoesNotExist:
pass
# 2. If not found and user is authenticated, maybe they are in admin?
# In admin, we might not have tenant_id in session if they went directly there.
# But this is a multi-tenant app, usually they select a campaign first.
# If they are superuser in admin, we might want to default to something or let them see UTC.
if tzname:
try:
timezone.activate(zoneinfo.ZoneInfo(tzname))
except:
timezone.deactivate()
else:
timezone.deactivate()
return self.get_response(request)

View File

@ -18,6 +18,57 @@
</div> </div>
</div> </div>
<!-- Filters and Summary -->
<div class="row g-4 mb-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">
<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">
<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">
<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">
<button type="submit" class="btn btn-primary rounded-pill w-100 px-0">Filter</button>
</div>
{% if start_date or end_date %}
<div class="col-12 mt-1">
<a href="{% url 'door_visit_history' %}" class="text-secondary small ms-1">
<i class="bi bi-x-circle me-1"></i>Clear range
</a>
</div>
{% endif %}
</form>
</div>
</div>
</div>
<!-- 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">
<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="badge bg-primary rounded-pill">{{ count }}</span>
</div>
{% empty %}
<p class="text-muted small mb-0">No volunteer data available for this selection.</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<!-- 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 justify-content-between align-items-center">
@ -33,8 +84,9 @@
<th class="ps-4 py-3 text-uppercase small ls-1">Household Address</th> <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="py-3 text-uppercase small ls-1">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">Outcome</th> <th class="py-3 text-uppercase small ls-1">Outcome</th>
<th class="pe-4 py-3 text-uppercase small ls-1">Interactions</th> <th class="pe-4 py-3 text-uppercase small ls-1">Comments</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -66,25 +118,37 @@
<div class="fw-bold text-dark">{{ household.last_visit_date|date:"M d, Y" }}</div> <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="small text-muted">{{ household.last_visit_date|date:"H:i" }}</div>
</td> </td>
<td>
{% 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;">
{{ household.last_volunteer.first_name|first }}{{ household.last_volunteer.last_name|first }}
</div>
<span class="fw-medium">{{ household.last_volunteer }}</span>
</div>
{% else %}
<span class="text-muted small">N/A</span>
{% endif %}
</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">
{{ household.last_outcome }} {{ household.last_outcome }}
</span> </span>
</td> </td>
<td class="pe-4"> <td class="pe-4">
<span class="badge rounded-pill bg-light text-dark border"> <div class="small text-muted text-wrap" style="max-width: 200px;">
{{ household.interaction_count }} Visit{{ household.interaction_count|pluralize }} {{ household.notes|default:"-" }}
</span> </div>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5" class="text-center py-5"> <td colspan="6" class="text-center py-5">
<div class="text-muted mb-2"> <div class="text-muted mb-2">
<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 logged yet.</p> <p class="mb-0 fw-medium">No door visits found for this selection.</p>
<p class="small text-muted">Visit the <a href="{% url 'door_visits' %}">Planned Visits</a> page to start logging visits.</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>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -98,12 +162,12 @@
<ul class="pagination justify-content-center mb-0"> <ul class="pagination 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" 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 small"></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 }}" 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 small"></i>
</a> </a>
</li> </li>
@ -113,12 +177,12 @@
{% 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 }}" 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 small"></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 }}" 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 small"></i>
</a> </a>
</li> </li>

View File

@ -196,6 +196,7 @@
<input type="hidden" name="city" id="modal_city"> <input type="hidden" name="city" id="modal_city">
<input type="hidden" name="state" id="modal_state"> <input type="hidden" name="state" id="modal_state">
<input type="hidden" name="zip_code" id="modal_zip_code"> <input type="hidden" name="zip_code" id="modal_zip_code">
<input type="hidden" name="next_query_string" value="{{ request.GET.urlencode }}">
<div class="mb-4"> <div class="mb-4">
<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>

View File

@ -1,3 +1,5 @@
from django.utils.dateparse import parse_date
from datetime import datetime, time, timedelta
import base64 import base64
import re import re
import urllib.parse import urllib.parse
@ -1277,6 +1279,12 @@ def log_door_visit(request):
tenant = get_object_or_404(Tenant, id=selected_tenant_id) tenant = get_object_or_404(Tenant, id=selected_tenant_id)
campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant) campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant)
# Capture query string for redirecting back with filters
next_qs = request.POST.get('next_query_string', '')
redirect_url = reverse('door_visits')
if next_qs:
redirect_url += f"?{next_qs}"
# Get the volunteer linked to the current user # Get the volunteer linked to the current user
volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first() volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first()
@ -1317,7 +1325,7 @@ def log_door_visit(request):
if not voters.exists(): if not voters.exists():
messages.warning(request, f"No targeted voters found at {address_street}.") messages.warning(request, f"No targeted voters found at {address_street}.")
return redirect('door_visits') return redirect(redirect_url)
for voter in voters: for voter in voters:
# 1) Update voter flags # 1) Update voter flags
@ -1347,8 +1355,7 @@ def log_door_visit(request):
else: else:
messages.error(request, "There was an error in the visit log form.") messages.error(request, "There was an error in the visit log form.")
return redirect('door_visits') return redirect(redirect_url)
def door_visit_history(request): def door_visit_history(request):
""" """
@ -1360,17 +1367,39 @@ def door_visit_history(request):
return redirect("index") return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id) tenant = get_object_or_404(Tenant, id=selected_tenant_id)
# Get all "Door Visit" interactions for this tenant, ordered by date desc # Date filter
start_date = request.GET.get("start_date")
end_date = request.GET.get("end_date")
# Get all "Door Visit" interactions for this tenant
interactions = Interaction.objects.filter( interactions = Interaction.objects.filter(
voter__tenant=tenant, voter__tenant=tenant,
type__name="Door Visit" type__name="Door Visit"
).select_related('voter', 'volunteer').order_by('-date') ).select_related("voter", "volunteer")
if start_date or end_date:
try:
if start_date:
d = parse_date(start_date)
if d:
start_dt = timezone.make_aware(datetime.combine(d, time.min))
interactions = interactions.filter(date__gte=start_dt)
if end_date:
d = parse_date(end_date)
if d:
# Use lt with next day to capture everything on the end_date
end_dt = timezone.make_aware(datetime.combine(d + timedelta(days=1), time.min))
interactions = interactions.filter(date__lt=end_dt)
except Exception as e:
logger.error(f"Error filtering door visit history by date: {e}")
# Summary of counts per volunteer
# Grouping by household (unique address) # Grouping by household (unique address)
visited_households = {} visited_households = {}
for interaction in interactions: volunteer_counts = {}
for interaction in interactions.order_by("-date"):
v = interaction.voter v = interaction.voter
# Use concatenated address if available, otherwise build it
addr = v.address.strip() if v.address else f"{v.address_street}, {v.city}, {v.state} {v.zip_code}".strip(", ") addr = v.address.strip() if v.address else f"{v.address_street}, {v.city}, {v.state} {v.zip_code}".strip(", ")
if not addr: if not addr:
continue continue
@ -1378,38 +1407,43 @@ def door_visit_history(request):
key = addr.lower() key = addr.lower()
if key not in visited_households: if key not in visited_households:
# Calculate volunteer summary - only once per household
v_obj = interaction.volunteer
v_name = f"{v_obj.first_name} {v_obj.last_name}".strip() or v_obj.email if v_obj else "N/A"
volunteer_counts[v_name] = volunteer_counts.get(v_name, 0) + 1
visited_households[key] = { visited_households[key] = {
'address_display': addr, "address_display": addr,
'address_street': v.address_street, "address_street": v.address_street,
'city': v.city, "city": v.city,
'state': v.state, "state": v.state,
'zip_code': v.zip_code, "zip_code": v.zip_code,
'neighborhood': v.neighborhood, "neighborhood": v.neighborhood,
'district': v.district, "district": v.district,
'last_visit_date': interaction.date, "last_visit_date": interaction.date,
'last_outcome': interaction.description, "last_outcome": interaction.description,
'voters_at_address': set(), "last_volunteer": interaction.volunteer,
'interaction_count': 0, "notes": interaction.notes,
'latest_interaction': interaction "voters_at_address": set(),
"latest_interaction": interaction
} }
visited_households[key]['voters_at_address'].add(f"{v.first_name} {v.last_name}") visited_households[key]["voters_at_address"].add(f"{v.first_name} {v.last_name}")
visited_households[key]['interaction_count'] += 1
if interaction.date > visited_households[key]['last_visit_date']: # Sort volunteer counts by total (descending)
visited_households[key]['last_visit_date'] = interaction.date sorted_volunteer_counts = sorted(volunteer_counts.items(), key=lambda x: x[1], reverse=True)
visited_households[key]['last_outcome'] = interaction.description
visited_households[key]['latest_interaction'] = interaction
history_list = list(visited_households.values()) history_list = list(visited_households.values())
history_list.sort(key=lambda x: x['last_visit_date'], reverse=True) history_list.sort(key=lambda x: x["last_visit_date"], reverse=True)
paginator = Paginator(history_list, 50) paginator = Paginator(history_list, 50)
page_number = request.GET.get('page') page_number = request.GET.get("page")
history_page = paginator.get_page(page_number) history_page = paginator.get_page(page_number)
context = { context = {
'selected_tenant': tenant, "selected_tenant": tenant,
'history': history_page, "history": history_page,
"start_date": start_date, "end_date": end_date,
"volunteer_counts": sorted_volunteer_counts,
} }
return render(request, 'core/door_visit_history.html', context) return render(request, "core/door_visit_history.html", context)

14
core_view_fix.tmp Normal file
View File

@ -0,0 +1,14 @@
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):
"""