37769-vm/core/views.py
2026-05-30 08:01:02 +00:00

2778 lines
120 KiB
Python

import os
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm
from django.utils.dateparse import parse_date
from datetime import datetime, time, timedelta
import base64
import re
import urllib.parse
import urllib.request
import csv
import io
import json
from django.http import JsonResponse, HttpResponse
from django.urls import reverse
from django.shortcuts import render, redirect, get_object_or_404
from django.db import transaction
from django.db.models import Q, Sum, Value, DecimalField
from django.contrib import messages
from django.core.paginator import Paginator
from django.conf import settings
from django.db.models.functions import Coalesce
from .models import format_phone_number, Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest, VolunteerRole, ScheduledCall, BulkTask
from .filter_helper import get_filtered_voter_queryset, get_phone_search_filters
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm, ScheduledCallForm, UserUpdateForm, EventParticipationImportForm, ParticipantMappingForm
from django.core.mail import get_connection, EmailMessage
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, get_user_role, STAFF_ROLES, can_access_call_queue
logger = logging.getLogger(__name__)
from .task_runners import start_bulk_sms_task
def _robust_decode(content):
if not content: return ""
for enc in ["utf-8-sig", "utf-8", "iso-8859-1", "windows-1252"]:
try: return content.decode(enc)
except UnicodeDecodeError: continue
return content.decode("utf-8", errors="replace")
def _handle_uploaded_file(uploaded_file):
"""
Handles uploaded CSV files, saves them to a temporary file, and extracts headers.
Returns (headers, temp_file_path) or (None, None) if an error occurs.
"""
import tempfile
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp:
for chunk in uploaded_file.chunks():
tmp.write(chunk)
file_path = tmp.name
with open(file_path, "r", encoding="utf-8-sig", errors="replace") as f:
# Read first line for headers
content = f.readline()
io_string = io.StringIO(content)
try:
dialect = csv.Sniffer().sniff(content[:1024])
io_string.seek(0)
reader = csv.reader(io_string, dialect)
except:
io_string.seek(0)
reader = csv.reader(io_string)
headers = [header.strip() for header in next(reader)]
return headers, file_path
except Exception as e:
logger.error(f"Error processing uploaded file: {e}")
return None, None
except Exception as e:
logger.error(f"Error processing uploaded file: {e}")
return None, None
def index(request):
"""
Main landing page for Grassroots Campaign Manager.
Displays a list of campaigns if the user is logged in but hasn't selected one.
"""
tenants = Tenant.objects.all()
selected_tenant_id = request.session.get('tenant_id')
selected_tenant = None
metrics = {}
recent_interactions = []
upcoming_events = []
if selected_tenant_id:
selected_tenant = Tenant.objects.filter(id=selected_tenant_id).first()
if selected_tenant:
voters = selected_tenant.voters.filter(is_inactive=False)
total_donations = Donation.objects.filter(voter__tenant=selected_tenant).aggregate(total=Sum('amount'))['total'] or 0
# Get or create settings for the tenant
campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=selected_tenant)
donation_goal = campaign_settings.donation_goal
donation_percentage = 0
if donation_goal > 0:
donation_percentage = float(round((total_donations / donation_goal) * 100, 1))
metrics = {
'total_registered_voters': voters.count(),
'total_target_voters': voters.filter(is_targeted=True).count(),
'total_supporting': voters.filter(candidate_support='supporting').count(),
'total_target_households': voters.filter(is_targeted=True).exclude(address='').values('address_street').distinct().count(),
'total_door_visits': voters.filter(door_visit=True).exclude(address='').values('address_street').distinct().count(),
'total_target_door_visit_households': voters.filter(target_door_visit=True).exclude(address='').values('address_street').distinct().count(),
'total_has_signs': voters.filter(yard_sign__in=['has', 'has_large']).exclude(address='').values('address_street').distinct().count(),
'total_wants_signs': voters.filter(yard_sign__in=['wants', 'wants_large']).exclude(address='').values('address_street').distinct().count(),
'total_has_window_stickers': voters.filter(window_sticker='has').count(),
'total_wants_window_stickers': voters.filter(window_sticker='wants').count(),
'total_donations': float(total_donations),
'donation_goal': float(donation_goal),
'donation_percentage': donation_percentage,
'volunteers_count': Volunteer.objects.filter(tenant=selected_tenant).count(),
"total_to_be_called": voters.exclude(Q(phone="") & Q(secondary_phone="")).filter(call_queue_status__in=["to_be_called", "in_call_queue"]).count(),
"total_called": voters.exclude(Q(phone="") & Q(secondary_phone="")).filter(call_queue_status="called").count(),
"total_event_attendees": EventParticipation.objects.filter(event__tenant=selected_tenant).count(),
'interactions_count': Interaction.objects.filter(voter__tenant=selected_tenant).count(),
'events_count': Event.objects.filter(tenant=selected_tenant).count(),
'pending_calls_count': ScheduledCall.objects.filter(tenant=selected_tenant, status='pending').count(),
}
recent_interactions = Interaction.objects.filter(voter__tenant=selected_tenant).order_by('-date')[:5]
upcoming_events = Event.objects.filter(tenant=selected_tenant, date__gte=timezone.now().date()).order_by('date')[:5]
context = {
'tenants': tenants,
'selected_tenant': selected_tenant,
'metrics': metrics,
'recent_interactions': recent_interactions,
'upcoming_events': upcoming_events,
}
return render(request, 'core/index.html', context)
def select_campaign(request, tenant_id):
"""
Sets the selected campaign in the session.
"""
tenant = get_object_or_404(Tenant, id=tenant_id)
request.session['tenant_id'] = 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.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
query = request.GET.get("q")
voters = Voter.objects.filter(tenant=tenant, is_inactive=False).order_by("last_name", "first_name")
# Filtering based on dashboard metrics
if request.GET.get("is_targeted") == "true":
voters = voters.filter(is_targeted=True)
if request.GET.get("support") == "supporting":
voters = voters.filter(candidate_support="supporting")
if request.GET.get("has_address") == "true":
voters = voters.exclude(address__isnull=True).exclude(address="")
if request.GET.get("visited") == "true":
voters = voters.filter(door_visit=True)
if request.GET.get("yard_sign") == "true":
voters = voters.filter(yard_sign__in=['wants', 'has', 'wants_large', 'has_large'])
if request.GET.get("window_sticker") == "true":
voters = voters.filter(Q(window_sticker="wants") | Q(window_sticker="has"))
if request.GET.get("has_donations") == "true":
voters = voters.filter(donations__isnull=False).distinct()
if query:
query = query.strip()
search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(voter_id__iexact=query) | get_phone_search_filters(query)
if "," in query:
parts = [p.strip() for p in query.split(",")]
if len(parts) >= 2:
last_part = parts[0]
first_part = parts[1]
search_filter |= Q(last_name__icontains=last_part, first_name__icontains=first_part)
elif " " in query:
parts = query.split()
if len(parts) >= 2:
first_part = parts[0]
last_part = " ".join(parts[1:])
search_filter |= Q(first_name__icontains=first_part, last_name__icontains=last_part)
voters = voters.filter(search_filter).order_by("last_name", "first_name")
paginator = Paginator(voters, 50)
page_number = request.GET.get('page')
voters_page = paginator.get_page(page_number)
context = {
"voters": voters_page,
"query": query,
"selected_tenant": tenant,
"call_form": ScheduledCallForm(tenant=tenant),
}
return render(request, "core/voter_list.html", context)
@role_required(["admin", "campaign_manager", "campaign_staff", "system_admin", "campaign_admin"], permission="core.add_voter")
def voter_add(request):
"""
Add a new voter to the campaign.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
if request.method == "POST":
form = VoterForm(request.POST, user=request.user, tenant=tenant)
if form.is_valid():
voter = form.save(commit=False)
voter.tenant = tenant
voter.save()
messages.success(request, f"Voter {voter.first_name} {voter.last_name} added successfully.")
return redirect("voter_detail", voter_id=voter.id)
else:
for field, errors in form.errors.items():
for error in errors:
messages.error(request, f"Error in {field}: {error}")
else:
form = VoterForm(user=request.user, tenant=tenant)
context = {
"form": form,
"selected_tenant": tenant,
"is_create": True,
}
return render(request, "core/voter_add.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.
"""
selected_tenant_id = request.session.get('tenant_id')
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect('index')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
other_voters_at_address = []
if voter.address_street and voter.city:
other_voters_at_address = Voter.objects.filter(
tenant=tenant,
address_street=voter.address_street,
city=voter.city,
state=voter.state,
zip_code=voter.zip_code
).exclude(id=voter.id)
context = {
'voter': voter,
"other_voters_at_address": other_voters_at_address,
'selected_tenant': tenant,
'voting_records': voter.voting_records.all().order_by('-election_date'),
'donations': voter.donations.all().order_by('-date'),
'interactions': voter.interactions.all().order_by('-date'),
'event_participations': voter.event_participations.all().order_by('-event__date'),
'likelihoods': voter.likelihoods.all(),
'voter_form': VoterForm(instance=voter, user=request.user, tenant=tenant),
'interaction_form': InteractionForm(tenant=tenant),
'donation_form': DonationForm(tenant=tenant),
'likelihood_form': VoterLikelihoodForm(tenant=tenant),
'event_participation_form': EventParticipationForm(tenant=tenant),
'call_form': ScheduledCallForm(tenant=tenant),
}
return render(request, 'core/voter_detail.html', context)
@role_required(["admin", "campaign_manager", "campaign_staff", "system_admin", "campaign_admin"], permission="core.change_voter")
def voter_edit(request, voter_id):
"""
Update voter core demographics.
"""
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
if request.method == 'POST':
# Log incoming coordinate data for debugging
lat_raw = request.POST.get('latitude')
lon_raw = request.POST.get('longitude')
logger.info(f"Voter Edit POST: lat={lat_raw}, lon={lon_raw}")
form = VoterForm(request.POST, instance=voter, user=request.user, tenant=tenant)
if form.is_valid():
# If coordinates were provided in POST, ensure they are applied to the instance
# This handles cases where readonly or other widget settings might interfere
voter = form.save(commit=False);
if lat_raw:
try:
voter.latitude = lat_raw
except: pass
if lon_raw:
try:
voter.longitude = lon_raw
except: pass
voter.save()
messages.success(request, "Voter profile updated successfully.")
else:
logger.warning(f"Voter Edit Form Invalid: {form.errors}")
for field, errors in form.errors.items():
for error in errors:
messages.error(request, f"Error in {field}: {error}")
return redirect('voter_detail', voter_id=voter.id)
def add_interaction(request, voter_id):
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
if request.method == 'POST':
form = InteractionForm(request.POST, tenant=tenant)
if form.is_valid():
interaction = form.save(commit=False)
interaction.voter = voter
interaction.save()
messages.success(request, "Interaction added.")
return redirect(reverse('voter_detail', kwargs={'voter_id': voter.id}) + '?active_tab=interactions')
def edit_interaction(request, interaction_id):
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
interaction = get_object_or_404(Interaction, id=interaction_id, voter__tenant=tenant)
if request.method == 'POST':
form = InteractionForm(request.POST, instance=interaction, tenant=tenant)
if form.is_valid():
form.save()
messages.success(request, "Interaction updated.")
return redirect(reverse('voter_detail', kwargs={'voter_id': interaction.voter.id}) + '?active_tab=interactions')
def delete_interaction(request, interaction_id):
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
interaction = get_object_or_404(Interaction, id=interaction_id, voter__tenant=tenant)
voter_id = interaction.voter.id
if request.method == 'POST':
interaction.delete()
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)
voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
if request.method == 'POST':
form = DonationForm(request.POST, tenant=tenant)
if form.is_valid():
donation = form.save(commit=False)
donation.voter = voter
donation.save()
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)
donation = get_object_or_404(Donation, id=donation_id, voter__tenant=tenant)
if request.method == 'POST':
form = DonationForm(request.POST, instance=donation, tenant=tenant)
if form.is_valid():
form.save()
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)
donation = get_object_or_404(Donation, id=donation_id, voter__tenant=tenant)
voter_id = donation.voter.id
if request.method == 'POST':
donation.delete()
messages.success(request, "Donation deleted.")
return redirect(reverse('voter_detail', kwargs={'voter_id': voter_id}) + '?active_tab=donations')
def add_likelihood(request, voter_id):
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
if request.method == 'POST':
form = VoterLikelihoodForm(request.POST, tenant=tenant)
if form.is_valid():
likelihood = form.save(commit=False)
likelihood.voter = voter
# Handle potential duplicate election_type
VoterLikelihood.objects.filter(voter=voter, election_type=likelihood.election_type).delete()
likelihood.save()
messages.success(request, "Likelihood updated.")
return redirect('voter_detail', voter_id=voter.id)
def edit_likelihood(request, likelihood_id):
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
likelihood = get_object_or_404(VoterLikelihood, id=likelihood_id, voter__tenant=tenant)
if request.method == 'POST':
form = VoterLikelihoodForm(request.POST, instance=likelihood, tenant=tenant)
if form.is_valid():
election_type = form.cleaned_data['election_type']
if VoterLikelihood.objects.filter(voter=likelihood.voter, election_type=election_type).exclude(id=likelihood.id).exists():
VoterLikelihood.objects.filter(voter=likelihood.voter, election_type=election_type).exclude(id=likelihood.id).delete()
form.save()
messages.success(request, "Likelihood updated.")
return redirect('voter_detail', voter_id=likelihood.voter.id)
def delete_likelihood(request, likelihood_id):
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
likelihood = get_object_or_404(VoterLikelihood, id=likelihood_id, voter__tenant=tenant)
voter_id = likelihood.voter.id
if request.method == 'POST':
likelihood.delete()
messages.success(request, "Likelihood record deleted.")
return redirect('voter_detail', voter_id=voter_id)
def add_event_participation(request, voter_id):
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
if request.method == 'POST':
form = EventParticipationForm(request.POST, tenant=tenant)
if form.is_valid():
participation = form.save(commit=False)
participation.voter = voter
# Avoid duplicate participation
if not EventParticipation.objects.filter(voter=voter, event=participation.event).exists():
participation.save()
messages.success(request, "Event participation added.")
else:
messages.warning(request, "Voter is already participating in this event.")
return redirect(reverse('voter_detail', kwargs={'voter_id': voter.id}) + '?active_tab=events')
def edit_event_participation(request, participation_id):
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant)
if request.method == 'POST':
form = EventParticipationForm(request.POST, instance=participation, tenant=tenant)
if form.is_valid():
event = form.cleaned_data['event']
if EventParticipation.objects.filter(voter=participation.voter, event=event).exclude(id=participation.id).exists():
messages.warning(request, "Voter is already participating in that event.")
else:
form.save()
messages.success(request, "Event participation updated.")
return redirect(reverse('voter_detail', kwargs={'voter_id': participation.voter.id}) + '?active_tab=events')
def delete_event_participation(request, participation_id):
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant)
voter_id = participation.voter.id
if request.method == 'POST':
participation.delete()
messages.success(request, "Event participation removed.")
return redirect(reverse('voter_detail', kwargs={'voter_id': voter_id}) + '?active_tab=events')
def voter_geocode(request, voter_id):
"""
Manually trigger geocoding for a voter, potentially using values from the request.
"""
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
if request.method == 'POST':
street = request.POST.get('address_street', voter.address_street)
city = request.POST.get('city', voter.city)
state = request.POST.get('state', voter.state)
zip_code = request.POST.get('zip_code', voter.zip_code)
parts = [street, city, state, zip_code]
full_address = ", ".join([p for p in parts if p])
# Use a temporary instance to avoid saving until the user clicks "Save" in the modal
temp_voter = Voter(
address_street=street,
city=city,
state=state,
zip_code=zip_code,
address=full_address
)
success, error_msg = temp_voter.geocode_address()
if success:
return JsonResponse({
'success': True,
'latitude': str(temp_voter.latitude),
'longitude': str(temp_voter.longitude),
'address': full_address
})
else:
return JsonResponse({
'success': False,
'error': f"Geocoding failed: {error_msg or 'No results found.'}"
})
return JsonResponse({'success': False, 'error': 'Invalid request method.'})
@role_required(["admin", "campaign_manager", "campaign_staff", "system_admin", "campaign_admin"], permission="core.view_bulktask")
def bulk_task_list(request):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
bulk_tasks = BulkTask.objects.filter(tenant=tenant).order_by("-created_at")
paginator = Paginator(bulk_tasks, 20)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
return render(request, "core/bulk_task_list.html", {
"bulk_tasks": page_obj,
"selected_tenant": tenant,
})
@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.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voters = Voter.objects.filter(tenant=tenant, is_inactive=False).order_by("last_name", "first_name")
form = AdvancedVoterSearchForm(request.GET)
if form.is_valid():
data = form.cleaned_data
if data.get('first_name'):
voters = voters.filter(first_name__icontains=data['first_name'])
if data.get('last_name'):
voters = voters.filter(last_name__icontains=data['last_name'])
if data.get('address'):
voters = voters.filter(Q(address__icontains=data['address']) | Q(address_street__icontains=data['address']))
if data.get('voter_id'):
voters = voters.filter(voter_id__iexact=data['voter_id'])
if data.get('birth_month'):
voters = voters.filter(birthdate__month=data['birth_month'])
if data.get('city'):
voters = voters.filter(city__icontains=data['city'])
if data.get('zip_code'):
voters = voters.filter(zip_code__icontains=data['zip_code'])
if data.get('neighborhood'):
voters = voters.filter(neighborhood__icontains=data['neighborhood'])
if data.get('district'):
voters = voters.filter(district=data['district'])
if data.get('precinct'):
voters = voters.filter(precinct=data['precinct'])
if data.get('email'):
voters = voters.filter(email__icontains=data['email'])
if data.get('phone'):
voters = voters.filter(get_phone_search_filters(data['phone']))
if data.get('phone_type'):
voters = voters.filter(phone_type=data['phone_type'])
if data.get('is_targeted'):
voters = voters.filter(is_targeted=(data['is_targeted'] == 'True'))
if data.get('target_door_visit'):
voters = voters.filter(target_door_visit=(data['target_door_visit'] == 'True'))
if data.get('door_visit'):
voters = voters.filter(door_visit=(data['door_visit'] == 'True'))
if data.get('ever_had_yard_sign'):
voters = voters.filter(ever_had_yard_sign=(data['ever_had_yard_sign'] == 'True'))
if data.get('ever_had_large_sign'):
voters = voters.filter(ever_had_large_sign=(data['ever_had_large_sign'] == 'True'))
if data.get('voted'):
voters = voters.filter(voted=(data['voted'] == 'True'))
if data.get('candidate_support'):
voters = voters.filter(candidate_support=data['candidate_support'])
if data.get('yard_sign'):
voters = voters.filter(yard_sign=data['yard_sign'])
if data.get('window_sticker'):
voters = voters.filter(window_sticker=data['window_sticker'])
if data.get('call_queue_status'):
voters = voters.filter(call_queue_status=data['call_queue_status'])
# Add donation amount filters
min_total_donation = data.get('min_total_donation')
max_total_donation = data.get('max_total_donation')
if min_total_donation is not None or max_total_donation is not None:
voters = voters.annotate(total_donation_amount=Coalesce(Sum('donations__amount'), Value(0), output_field=DecimalField()))
if min_total_donation is not None:
voters = voters.filter(total_donation_amount__gte=min_total_donation)
if max_total_donation is not None:
voters = voters.filter(total_donation_amount__lte=max_total_donation)
paginator = Paginator(voters, 50)
page_number = request.GET.get('page')
voters_page = paginator.get_page(page_number)
context = {
'form': form,
'voters': voters_page,
'selected_tenant': tenant,
'call_form': ScheduledCallForm(tenant=tenant),
}
return render(request, "core/voter_advanced_search.html", context)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
def export_voters_csv(request):
"""
Exports selected or filtered voters to a CSV file.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
if request.method != 'POST':
return redirect('voter_advanced_search')
action = request.POST.get('action')
select_all_results = request.POST.get('select_all_results') == 'true' or action == 'export_all'
if select_all_results:
voters, _ = get_filtered_voter_queryset(request, tenant, data_source="POST")
else:
voter_ids = request.POST.getlist('selected_voters')
voters = Voter.objects.filter(tenant=tenant, is_inactive=False, id__in=voter_ids)
voters = voters.order_by('last_name', 'first_name')
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="voters_export_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv"'
writer = csv.writer(response)
writer.writerow([
'Voter ID', 'First Name', 'Last Name', 'Nickname', 'Birthdate',
'Address', 'City', 'State', 'Zip Code', 'Neighborhood', 'Phone', 'Phone Type', 'Secondary Phone', 'Secondary Phone Type', 'Email',
'District', 'Precinct', 'Is Targeted', 'Support', 'Yard Sign', 'Window Sticker', 'Call Queue Status', 'Notes'
])
for voter in voters:
writer.writerow([
voter.voter_id, voter.first_name, voter.last_name, voter.nickname, voter.birthdate,
voter.address, voter.city, voter.state, voter.zip_code, voter.neighborhood, voter.phone, voter.get_phone_type_display(), voter.secondary_phone, voter.get_secondary_phone_type_display(), voter.email,
voter.district, voter.precinct, 'Yes' if voter.is_targeted else 'No',
voter.get_candidate_support_display(), voter.get_yard_sign_display(), voter.get_window_sticker_display(), voter.get_call_queue_status_display(), voter.notes
])
return response
def voter_delete(request, voter_id):
"""
Delete a voter profile.
"""
selected_tenant_id = request.session.get('tenant_id')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
if request.method == 'POST':
voter.delete()
messages.success(request, "Voter profile deleted successfully.")
return redirect('voter_list')
return redirect('voter_detail', voter_id=voter.id)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
def bulk_send_sms(request):
"""
Sends bulk SMS to selected voters using Twilio API in a background thread.
"""
if request.method != 'POST':
return redirect('voter_advanced_search')
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
message_body = request.POST.get('message_body')
if not message_body:
messages.error(request, "Message body cannot be empty.")
return redirect('voter_advanced_search')
select_all_results = request.POST.get('select_all_results') == 'true'
voter_ids = request.POST.getlist('selected_voters')
search_filters = {}
if select_all_results:
for key, value in request.POST.items():
if key.startswith("filter_") and value:
search_filters[key.replace("filter_", "")] = value
start_bulk_sms_task(tenant, message_body, voter_ids, select_all_results, search_filters, object_type='voter')
messages.success(request, "Bulk SMS process started in the background. You can continue working while it processes.")
return redirect('voter_advanced_search')
def event_list(request):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
events = Event.objects.filter(tenant=tenant).order_by('-date')
context = {
'tenant': tenant,
'events': events,
'selected_tenant': tenant,
}
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:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
event = get_object_or_404(Event, id=event_id, tenant=tenant)
participations = event.participations.all().select_related('voter', 'participation_status').order_by('voter__last_name', 'voter__first_name')
# Get assigned volunteers
volunteers = event.volunteer_assignments.all().select_related('volunteer').order_by('volunteer__last_name', 'volunteer__first_name')
# Form for adding a new participant
add_form = EventParticipantAddForm(tenant=tenant)
# Form for adding a new volunteer
default_role = event.default_volunteer_role
if not default_role and event.event_type:
default_role = event.event_type.default_volunteer_role
add_volunteer_form = VolunteerEventAddForm(tenant=tenant, initial={'role_type': default_role})
participation_statuses = ParticipationStatus.objects.filter(tenant=tenant, is_active=True)
context = {
'tenant': tenant,
'selected_tenant': tenant,
'event': event,
'participations': participations,
'volunteers': volunteers,
'campaign_settings': getattr(tenant, 'settings', None),
'add_form': add_form,
'add_volunteer_form': add_volunteer_form,
'participation_statuses': participation_statuses,
}
return render(request, 'core/event_detail.html', context)
def event_add_participant(request, event_id):
tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=tenant_id)
event = get_object_or_404(Event, id=event_id, tenant=tenant)
if request.method == 'POST':
form = EventParticipantAddForm(request.POST, tenant=tenant)
if form.is_valid():
participation = form.save(commit=False)
participation.event = event
if not EventParticipation.objects.filter(event=event, voter=participation.voter).exists():
participation.save()
messages.success(request, f"{participation.voter} added to event.")
else:
messages.warning(request, "Voter is already a participant.")
else:
messages.error(request, "Error adding participant.")
return redirect('event_detail', event_id=event.id)
def event_edit_participant(request, participation_id):
tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=tenant_id)
participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant)
if request.method == 'POST':
status_id = request.POST.get('participation_status')
if status_id:
status = get_object_or_404(ParticipationStatus, id=status_id, tenant=tenant)
participation.participation_status = status
participation.save()
messages.success(request, f"Participation updated for {participation.voter}.")
else:
messages.error(request, "Invalid status.")
return redirect('event_detail', event_id=participation.event.id)
def event_delete_participant(request, participation_id):
tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=tenant_id)
participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant)
event_id = participation.event.id
voter_name = str(participation.voter)
participation.delete()
messages.success(request, f"{voter_name} removed from event.")
return redirect('event_detail', event_id=event_id)
def voter_search_json(request):
"""
JSON endpoint for voter search, used by autocomplete/search UI.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
return JsonResponse({"results": []})
query = request.GET.get("q", "").strip()
if len(query) < 2:
return JsonResponse({"results": []})
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voters = Voter.objects.filter(tenant=tenant, is_inactive=False)
search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(voter_id__iexact=query) | get_phone_search_filters(query)
if "," in query:
parts = [p.strip() for p in query.split(",") ]
if len(parts) >= 2:
search_filter |= Q(last_name__icontains=parts[0], first_name__icontains=parts[1])
results = voters.filter(search_filter).order_by("last_name", "first_name")[:20]
data = []
for v in results:
data.append({
"id": v.id,
"text": f"{v.last_name}, {v.first_name} ({v.voter_id})",
"address": v.address,
"phone": v.phone
})
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:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
volunteers = Volunteer.objects.filter(tenant=tenant).order_by('last_name', 'first_name')
# Simple search
query = request.GET.get("q")
if query:
volunteers = volunteers.filter(
Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(email__icontains=query) | get_phone_search_filters(query, secondary=False)
)
# Interest filter
interest_id = request.GET.get("interest")
if interest_id:
volunteers = volunteers.filter(interests__id=interest_id).distinct()
interests = Interest.objects.filter(tenant=tenant).order_by('name')
paginator = Paginator(volunteers, 50)
page_number = request.GET.get('page')
volunteers_page = paginator.get_page(page_number)
context = {
'tenant': tenant,
'selected_tenant': tenant,
'volunteers': volunteers_page,
'query': query,
'interests': interests,
'selected_interest': interest_id,
}
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:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
if request.method == 'POST':
form = VolunteerForm(request.POST, tenant=tenant)
if form.is_valid():
volunteer = form.save(commit=False)
volunteer.tenant = tenant
volunteer.save()
form.save_m2m() # Save interests
messages.success(request, f"Volunteer {volunteer} added successfully.")
return redirect('volunteer_detail', volunteer_id=volunteer.id)
else:
form = VolunteerForm(tenant=tenant)
context = {
'form': form,
'tenant': tenant,
'selected_tenant': tenant,
'is_create': True,
}
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:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant)
if request.method == 'POST':
form = VolunteerForm(request.POST, instance=volunteer, tenant=tenant)
if form.is_valid():
form.save()
messages.success(request, f"Volunteer {volunteer} updated successfully.")
return redirect('volunteer_detail', volunteer_id=volunteer.id)
else:
form = VolunteerForm(instance=volunteer, tenant=tenant)
assignments = volunteer.event_assignments.all().select_related('event')
assign_form = VolunteerEventForm(tenant=tenant)
context = {
'volunteer': volunteer,
'form': form,
'assignments': assignments,
'assign_form': assign_form,
'tenant': tenant,
'selected_tenant': tenant,
}
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:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant)
if request.method == 'POST':
volunteer.delete()
messages.success(request, "Volunteer deleted.")
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:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant)
if request.method == 'POST':
form = VolunteerEventForm(request.POST, tenant=tenant)
if form.is_valid():
assignment = form.save(commit=False)
assignment.volunteer = volunteer
assignment.save()
messages.success(request, f"Assigned to {assignment.event}.")
else:
messages.error(request, "Error assigning to event.")
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:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
assignment = get_object_or_404(VolunteerEvent, id=assignment_id, volunteer__tenant=tenant)
volunteer_id = assignment.volunteer.id
assignment.delete()
messages.success(request, "Assignment removed.")
return redirect('volunteer_detail', volunteer_id=volunteer_id)
def interest_add(request):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
return JsonResponse({'success': False, 'error': 'No campaign selected.'})
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
if request.method == 'POST':
name = request.POST.get('name', '').strip()
if name:
interest, created = Interest.objects.get_or_create(tenant=tenant, name=name)
if created:
return JsonResponse({'success': True, 'id': interest.id, 'name': interest.name})
else:
return JsonResponse({'success': False, 'error': 'Interest already exists.'})
return JsonResponse({'success': False, 'error': 'Invalid request.'})
def interest_delete(request, interest_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
return JsonResponse({'success': False, 'error': 'No campaign selected.'})
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
interest = get_object_or_404(Interest, id=interest_id, tenant=tenant)
if request.method == 'POST':
interest.delete()
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:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
if request.method == "POST":
form = EventForm(request.POST, tenant=tenant)
if form.is_valid():
event = form.save(commit=False)
event.tenant = tenant
event.save()
messages.success(request, "Event created successfully.")
return redirect("event_detail", event_id=event.id)
else:
form = EventForm(tenant=tenant)
context = {
"form": form,
"tenant": tenant,
"selected_tenant": tenant,
"is_create": True,
}
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:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
event = get_object_or_404(Event, id=event_id, tenant=tenant)
if request.method == 'POST':
form = EventForm(request.POST, instance=event, tenant=tenant)
if form.is_valid():
form.save()
messages.success(request, "Event updated successfully.")
return redirect('event_detail', event_id=event.id)
else:
form = EventForm(instance=event, tenant=tenant)
context = {
'form': form,
'event': event,
'tenant': tenant,
'selected_tenant': tenant,
}
return render(request, 'core/event_edit.html', context)
@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event')
def import_participants(request, event_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
event = get_object_or_404(Event, id=event_id, tenant=tenant)
if request.method == 'POST':
form = EventParticipationImportForm(request.POST, request.FILES, event=event)
if form.is_valid():
uploaded_file = form.cleaned_data['file']
if not uploaded_file.name.lower().endswith('.csv'):
messages.error(request, "Only CSV files are supported. Please save your file as CSV and try again.")
return redirect('event_detail', event_id=event.id)
headers, file_path = _handle_uploaded_file(uploaded_file)
if headers and file_path:
request.session['imported_participants_data'] = {
'event_id': event.id,
'headers': headers,
'file_path': file_path,
'file_name': uploaded_file.name
}
messages.info(request, f"File '{uploaded_file.name}' uploaded successfully. Now map the fields.")
return redirect('import_participants_map_fields', event_id=event.id)
else:
messages.error(request, "Could not read headers from the uploaded file. Please ensure it's a valid CSV.")
else:
messages.error(request, "No file was uploaded or an error occurred with the form.")
# For debugging, you might want to log form.errors
logger.error(f"EventParticipationImportForm errors: {form.errors}")
return redirect('event_detail', event_id=event.id)
@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event')
def import_participants_map_fields(request, event_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
event = get_object_or_404(Event, id=event_id, tenant=tenant)
imported_data = request.session.get('imported_participants_data')
if not imported_data or imported_data['event_id'] != event.id:
messages.error(request, "No data found to map. Please upload a file first.")
return redirect('event_detail', event_id=event.id)
headers = imported_data['headers']
file_name = imported_data['file_name']
if request.method == 'POST':
form = ParticipantMappingForm(request.POST, headers=headers, tenant=tenant)
if form.is_valid():
email_column = form.cleaned_data['email_column']
name_column = form.cleaned_data['name_column'] # Retrieve name column
phone_column = form.cleaned_data['phone_column'] # Retrieve phone column
participation_status_column = form.cleaned_data['participation_status_column']
default_participation_status = form.cleaned_data['default_participation_status'] # Retrieve default status
# Store mapping in session and proceed to processing
request.session['imported_participants_data']['email_column'] = email_column
request.session['imported_participants_data']['name_column'] = name_column # Store name column
request.session['imported_participants_data']['phone_column'] = phone_column # Store phone column
request.session['imported_participants_data']['participation_status_column'] = participation_status_column
request.session['imported_participants_data']['default_participation_status_id'] = default_participation_status.id if default_participation_status else None
request.session.modified = True # Ensure session is saved
logger.debug(f"Session after mapping: {request.session.get('imported_participants_data')}") # Added debug logging
return redirect('process_participants_import', event_id=event.id)
else:
logger.error(f"ParticipantMappingForm errors: {form.errors}") # Added logging
messages.error(request, "Please correct the mapping errors.")
else:
form = ParticipantMappingForm(headers=headers, tenant=tenant)
context = {
'event': event,
'form': form,
'file_name': file_name,
'headers': headers,
}
return render(request, 'core/event_participant_map_fields.html', context)
@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event')
def process_participants_import(request, event_id):
logger.debug(f"Session at start of process_participants_import: {request.session.get('imported_participants_data')}") # Added debug logging
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
event = get_object_or_404(Event, id=event_id, tenant=tenant)
imported_data = request.session.get('imported_participants_data')
if not imported_data or imported_data['event_id'] != event.id:
messages.error(request, "No data found to process. Please upload and map a file first.")
return redirect('event_detail', event_id=event.id)
headers = imported_data["headers"]
file_path = imported_data.get("file_path")
if not file_path or not os.path.exists(file_path):
messages.error(request, "No data found to process. Please upload and map a file first.")
return redirect("event_detail", event_id=event.id)
data_rows = []
try:
with open(file_path, "r", encoding="utf-8-sig", errors="replace") as f:
# Try to determine the CSV dialect
sample = f.read(2048)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample)
reader = csv.reader(f, dialect)
except Exception:
reader = csv.reader(f)
# Skip the header row as it is already stored in session
next(reader, None)
for row in reader:
if any(row): # Skip empty rows
data_rows.append(row)
except Exception as e:
logger.error(f"Error reading import file: {e}")
messages.error(request, f"Error reading the uploaded file: {e}")
return redirect("event_detail", event_id=event.id)
# Safely get column names from session, handle cases where they might be missing
email_column = imported_data.get('email_column')
name_column = imported_data.get('name_column') # Retrieve name column
phone_column = imported_data.get('phone_column') # Retrieve phone column
participation_status_column = imported_data.get('participation_status_column')
default_participation_status_id = imported_data.get('default_participation_status_id')
logger.debug(f"process_participants_import - name_column from session: {name_column}") # DEBUG LOGGING
logger.debug(f"process_participants_import - phone_column from session: {phone_column}") # DEBUG LOGGING
# Validate that required columns are present
if not email_column:
messages.error(request, "Email column mapping is missing. Please go back and map the fields.")
return redirect('import_participants_map_fields', event_id=event.id)
matched_count = 0
unmatched_participants = []
# Get all active participation statuses for the tenant
participation_statuses_map = {status.name.lower(): status for status in ParticipationStatus.objects.filter(tenant=tenant, is_active=True)}
default_status_obj = None
if default_participation_status_id:
default_status_obj = get_object_or_404(ParticipationStatus, id=default_participation_status_id, tenant=tenant)
voters_by_email = {v.email.lower(): v for v in Voter.objects.filter(tenant=tenant).exclude(email="")}
existing_participations = set(EventParticipation.objects.filter(event=event).values_list("voter_id", flat=True))
with transaction.atomic():
for row_index, row in enumerate(data_rows):
row_dict = dict(zip(headers, row))
email = row_dict.get(email_column)
phone = row_dict.get(phone_column) if phone_column else None
# DEBUG LOGGING: Log the value of the name column for each row
if name_column:
logger.debug(f"process_participants_import - Row {row_index}: name_column='{name_column}', name_value='{row_dict.get(name_column)}'")
if phone_column:
logger.debug(f"process_participants_import - Row {row_index}: phone_column='{phone_column}', phone_value='{phone}'")
participation_status_name = row_dict.get(participation_status_column)
if not email:
logger.warning(f"Row {row_index+2}: Skipping due to missing email.")
continue
voter = voters_by_email.get(email.lower()) if email else None
if voter:
# If phone is mapped and present, and not already associated with voter, update it
if phone and voter.phone != phone and voter.secondary_phone != phone:
voter.phone = phone
voter.phone_type = 'cell'
voter.save()
# Match found, add as participant if not already existing
status = participation_statuses_map.get(participation_status_name.lower()) if participation_status_name else default_status_obj
if not status:
status, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Unknown', is_active=True) # Fallback to unknown if no default and no match
if voter.id not in existing_participations:
EventParticipation.objects.create(
event=event,
voter=voter,
participation_status=status
)
matched_count += 1
else:
logger.info(f"Voter {voter.email} is already a participant in event {event.name}. Skipping.")
else:
# No match found, add to unmatched list
unmatched_participants.append({
'row_data': row_dict,
'original_row_index': row_index, # Keep original index for reference if needed
})
# Cleanup temporary file
if file_path and os.path.exists(file_path):
try:
os.remove(file_path)
except Exception as e:
logger.error(f"Error removing temporary import file {file_path}: {e}")
if unmatched_participants:
# Store unmatched data in session for manual matching
request.session['unmatched_participants_data'] = {
'event_id': event.id,
'unmatched_rows': unmatched_participants,
'file_name': imported_data['file_name'],
'email_column': email_column,
'name_column': name_column, # Pass name column to unmatched data
'phone_column': phone_column, # Pass phone column to unmatched data
'participation_status_column': participation_status_column,
'default_participation_status_id': default_participation_status_id,
}
messages.warning(request, f"{len(unmatched_participants)} participants could not be automatically matched. Please match them manually.")
return redirect('match_participants', event_id=event.id)
else:
messages.success(request, f"Successfully imported {matched_count} participants for event {event.name}.")
del request.session['imported_participants_data'] # Clean up session
return redirect('event_detail', event_id=event.id)
@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event')
def match_participants(request, event_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
event = get_object_or_404(Event, id=event_id, tenant=tenant)
unmatched_data = request.session.get('unmatched_participants_data')
if not unmatched_data or unmatched_data['event_id'] != event.id:
messages.error(request, "No unmatched participant data found. Please try importing again.")
return redirect('event_detail', event_id=event.id)
unmatched_rows = unmatched_data['unmatched_rows']
file_name = unmatched_data['file_name']
email_column = unmatched_data['email_column']
name_column = unmatched_data['name_column'] # Retrieve name column
phone_column = unmatched_data.get('phone_column') # Retrieve phone column
participation_status_column = unmatched_data['participation_status_column']
default_participation_status_id = unmatched_data.get('default_participation_status_id')
logger.debug(f"match_participants context: email_column={email_column}, name_column={name_column}, phone_column={phone_column}, participation_status_column={participation_status_column}") # DEBUG LOGGING
# DEBUG LOGGING: Log the value of the name column for each unmatched row
for index, row_data in enumerate(unmatched_rows):
name_value = row_data.get('row_data', {}).get(name_column)
phone_value = row_data.get('row_data', {}).get(phone_column)
logger.debug(f"match_participants - Unmatched row {index}: name_column='{name_column}', name_value='{name_value}', phone_column='{phone_column}', phone_value='{phone_value}'")
if request.method == 'POST':
matched_count = 0
current_unmatched_rows = [] # To store rows that are still unmatched after POST
default_status_obj = None
if default_participation_status_id:
default_status_obj = get_object_or_404(ParticipationStatus, id=default_participation_status_id, tenant=tenant)
for index, row_data in enumerate(unmatched_rows):
original_row_index = row_data['original_row_index']
posted_voter_id = request.POST.get(f'voter_match_{original_row_index}')
if posted_voter_id:
# Manual match provided
voter = get_object_or_404(Voter, id=posted_voter_id, tenant=tenant)
# Update voter's email
voter_email_from_file = row_data['row_data'].get(email_column)
if voter_email_from_file and voter.email != voter_email_from_file:
voter.email = voter_email_from_file
voter.save()
# Update voter's phone if mapped and different
voter_phone_from_file = row_data['row_data'].get(phone_column)
if voter_phone_from_file and voter.phone != voter_phone_from_file and voter.secondary_phone != voter_phone_from_file:
voter.phone = voter_phone_from_file
voter.phone_type = 'cell'
voter.save()
# Add as participant if not already existing
participation_status_name = row_data['row_data'].get(participation_status_column)
status = None
if participation_status_name:
status = ParticipationStatus.objects.filter(tenant=tenant, name__iexact=participation_status_name).first()
if not status:
status = default_status_obj
if not status:
status, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Unknown', is_active=True)
if not EventParticipation.objects.filter(event=event, voter=voter).exists():
EventParticipation.objects.create(
event=event,
voter=voter,
participation_status=status
)
matched_count += 1
else:
messages.warning(request, f"Voter {voter.email} is already a participant in event {event.name}. Skipping manual match for this voter.")
else:
# Still unmatched, keep for re-display
current_unmatched_rows.append(row_data)
if matched_count > 0:
messages.success(request, f"Successfully matched {matched_count} participants.")
if current_unmatched_rows:
request.session['unmatched_participants_data']['unmatched_rows'] = current_unmatched_rows
messages.warning(request, f"{len(current_unmatched_rows)} participants still need manual matching.")
return redirect('match_participants', event_id=event.id)
else:
messages.success(request, "All participants have been matched.")
del request.session['unmatched_participants_data'] # Clean up session
del request.session['imported_participants_data'] # Also clean up this
return redirect('event_detail', event_id=event.id)
context = {
'event': event,
'unmatched_rows': unmatched_rows,
'file_name': file_name,
'email_column': email_column,
'name_column': name_column, # Pass name column to template
'phone_column': phone_column, # Pass phone column to template
'participation_status_column': participation_status_column,
}
return render(request, 'core/event_participant_matching.html', context)
def volunteer_search_json(request):
"""
JSON endpoint for volunteer search, used by autocomplete/search UI.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
return JsonResponse({"results": []})
query = request.GET.get("q", "").strip()
if len(query) < 2:
return JsonResponse({"results": []})
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
volunteers = Volunteer.objects.filter(tenant=tenant)
search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(email__icontains=query) | get_phone_search_filters(query, secondary=False) | get_phone_search_filters(query, secondary=False)
results = volunteers.filter(search_filter).order_by("last_name", "first_name")[:20]
data = []
for v in results:
data.append({
"id": v.id,
"text": f"{v.first_name} {v.last_name} ({v.email})",
"phone": v.phone
})
return JsonResponse({"results": data})
def event_add_volunteer(request, event_id):
tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=tenant_id)
event = get_object_or_404(Event, id=event_id, tenant=tenant)
if request.method == 'POST':
form = VolunteerEventAddForm(request.POST, tenant=tenant)
if form.is_valid():
assignment = form.save(commit=False)
assignment.event = event
if not VolunteerEvent.objects.filter(event=event, volunteer=assignment.volunteer).exists():
assignment.save()
messages.success(request, f"{assignment.volunteer} added as volunteer.")
else:
messages.warning(request, "Volunteer is already assigned to this event.")
else:
messages.error(request, "Error adding volunteer.")
return redirect('event_detail', event_id=event.id)
def event_remove_volunteer(request, assignment_id):
tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=tenant_id)
assignment = get_object_or_404(VolunteerEvent, id=assignment_id, event__tenant=tenant)
event_id = assignment.event.id
volunteer_name = str(assignment.volunteer)
assignment.delete()
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'], permission='core.view_volunteer')
def volunteer_bulk_send_sms(request):
"""
Sends bulk SMS to selected volunteers using Twilio API in a background thread.
"""
if request.method != 'POST':
return redirect('volunteer_list')
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
message_body = request.POST.get('message_body')
if not message_body:
messages.error(request, "Message body cannot be empty.")
return redirect('volunteer_list')
volunteer_ids = request.POST.getlist('selected_volunteers')
start_bulk_sms_task(tenant, message_body, volunteer_ids, False, None, object_type='volunteer')
messages.success(request, "Bulk SMS process started in the background.")
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.
Optimized to handle large datasets more efficiently.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
city_filter = request.GET.get("city", "").strip()
district_filter = request.GET.get('district', '').strip()
neighborhood_filter = request.GET.get('neighborhood', '').strip()
address_filter = request.GET.get('address', '').strip()
voters = Voter.objects.filter(tenant=tenant, is_inactive=False, door_visit=False, target_door_visit=True)
if city_filter:
voters = voters.filter(city__icontains=city_filter)
if district_filter:
voters = voters.filter(district=district_filter)
if neighborhood_filter:
voters = voters.filter(neighborhood__icontains=neighborhood_filter)
if address_filter:
voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter))
# Optimization: Use iterator and lighter data structure
# We only fetch needed fields. Compound index helps here.
voters_iterator = voters.values(
'id', 'first_name', 'last_name', 'address_street', 'city', 'state',
'zip_code', 'neighborhood', 'district', 'latitude', 'longitude', 'phone'
).iterator(chunk_size=2000)
households_dict = {}
for v in voters_iterator:
street = (v['address_street'] or "").strip()
city = (v['city'] or "").strip()
state = (v['state'] or "").strip()
zip_code = (v['zip_code'] or "").strip()
key = (street.lower(), city.lower(), state.lower(), zip_code.lower())
if key not in households_dict:
street_number = ""
street_name = street
match_street = re.match(r'^(\d+)\s+(.*)$', street)
if match_street:
street_number = match_street.group(1)
street_name = match_street.group(2)
try:
street_number_sort = int(street_number)
except (ValueError, TypeError):
street_number_sort = 0
households_dict[key] = {
'address_street': street,
'city': city,
'state': state,
'zip_code': zip_code,
'neighborhood': (v['neighborhood'] or "").strip(),
'district': (v['district'] or "").strip(),
'latitude': float(v['latitude']) if v['latitude'] else None,
'longitude': float(v['longitude']) if v['longitude'] else None,
'street_name_sort': street_name.lower(),
'street_number_sort': street_number_sort,
'target_voters': [],
'voters_json': []
}
else:
if not households_dict[key]['neighborhood'] and v['neighborhood']:
households_dict[key]['neighborhood'] = v['neighborhood'].strip()
if not households_dict[key]['district'] and v['district']:
households_dict[key]['district'] = v['district'].strip()
households_dict[key]['target_voters'].append(v)
phone_display = format_phone_number(v['phone']) if v['phone'] else "<No Phone>"
households_dict[key]['voters_json'].append({'id': v['id'], 'name': f"{v['first_name']} {v['last_name']} - {phone_display}" })
households_list = list(households_dict.values())
del households_dict # Free memory
households_list.sort(key=lambda x: (
not bool(x['neighborhood']),
(x['neighborhood'] or '').lower(),
x['street_name_sort'],
x['street_number_sort']
))
# Optimization: Map data should only be for visible results or limited
# We still keep the limit of 3000 for map
map_data = []
# Always try to show up to 3000 markers instead of showing 0 if count > 3000
for h in households_list[:3000]:
if h['latitude'] and h['longitude']:
map_data.append({
'lat': h['latitude'],
'lng': h['longitude'],
'address_street': h['address_street'],
'city': h['city'],
'state': h['state'],
'zip_code': h['zip_code'],
'address': f"{h['address_street']}, {h['city']}",
'voters': ", ".join([f"{v['first_name']} {v['last_name']}" for v in h['target_voters']])
})
for h in households_list:
h['voters_json_str'] = json.dumps(h['voters_json'])
paginator = Paginator(households_list, 50)
page_number = request.GET.get('page')
households_page = paginator.get_page(page_number)
context = {
'selected_tenant': tenant,
'households': households_page,
'district_filter': district_filter,
'neighborhood_filter': neighborhood_filter,
'address_filter': address_filter,
'city_filter': city_filter,
'map_data_json': json.dumps(map_data),
'map_limit_reached': len(households_list) > 3000,
'GOOGLE_MAPS_API_KEY': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''),
}
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,
and create interaction records.
Can also render a standalone page for logging a visit.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
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", request.GET.get("next_query_string", ""))
source = request.POST.get("source", request.GET.get("source", ""))
redirect_url = reverse("door_visits")
# Build redirect URL
redirect_params = []
if next_qs:
redirect_params.append(next_qs)
if source == "map":
redirect_params.append("open_map=1")
if redirect_params:
redirect_url += "?" + "&".join(redirect_params)
# Get address components from POST or GET
address_street = request.POST.get("address_street", request.GET.get("address_street"))
city = request.POST.get("city", request.GET.get("city"))
state = request.POST.get("state", request.GET.get("state"))
zip_code = request.POST.get("zip_code", request.GET.get("zip_code"))
if not address_street:
messages.warning(request, "No address provided.")
return redirect(redirect_url)
# Find targeted voters at this exact address
voters = Voter.objects.filter(
tenant=tenant,
address_street__iexact=address_street,
city__iexact=city,
state__iexact=state,
zip_code__iexact=zip_code,
target_door_visit=True
)
if not voters.exists() and request.method == "POST":
messages.warning(request, f"No targeted voters found at {address_street}.")
return redirect(redirect_url)
voter_choices = [(v.id, f'{v.first_name} {v.last_name} - {format_phone_number(v.phone) if v.phone else "<No Phone>"}') for v in voters]
# Get the volunteer linked to the current user
volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first()
if request.method == "POST":
form = DoorVisitLogForm(request.POST, voter_choices=voter_choices)
if form.is_valid():
outcome = form.cleaned_data["outcome"]
notes = form.cleaned_data["notes"]
yard_sign_status = form.cleaned_data["yard_sign_status"]
candidate_support = form.cleaned_data["candidate_support"]
follow_up = form.cleaned_data["follow_up"]
follow_up_voter_id = form.cleaned_data.get("follow_up_voter")
call_notes = form.cleaned_data["call_notes"]
# Determine date/time in campaign timezone
campaign_tz_name = campaign_settings.timezone or "America/Chicago"
try:
tz = zoneinfo.ZoneInfo(campaign_tz_name)
except:
tz = zoneinfo.ZoneInfo("America/Chicago")
interaction_date = timezone.now().astimezone(tz)
# Get or create InteractionType
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Door Visit")
# Get default caller for follow-ups
default_caller = None
if follow_up:
default_caller = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first()
for voter in voters:
# 1) Update voter flags
voter.door_visit = True
# 2) Update Yard Sign status if not "no_change":
if yard_sign_status != 'no_change':
voter.yard_sign = yard_sign_status
# 3) Update support status if Supporting or Not Supporting
if candidate_support in ["supporting", "not_supporting"]:
voter.candidate_support = candidate_support
voter.save()
# 4) Create interaction
Interaction.objects.create(
voter=voter,
volunteer=volunteer,
type=interaction_type,
date=interaction_date,
description=outcome,
notes=notes
)
# 5) Create ScheduledCall if follow_up is checked and this is the selected voter
if follow_up and follow_up_voter_id and str(voter.id) == follow_up_voter_id:
ScheduledCall.objects.create(
tenant=tenant,
voter=voter,
volunteer=default_caller,
comments=call_notes,
status="pending"
)
if follow_up:
messages.success(request, f"Door visit logged and follow-up call scheduled for {address_street}.")
else:
messages.success(request, f"Door visit logged for {address_street}.")
return redirect(redirect_url)
else:
messages.error(request, f"There was an error in the visit log form: {form.errors.as_text()}")
return redirect(redirect_url)
else:
# GET request: render standalone page
form = DoorVisitLogForm(voter_choices=voter_choices)
context = {
'selected_tenant': tenant,
'visit_form': form,
'address_street': address_street,
'city': city,
'state': state,
'zip_code': zip_code,
'voters': voters,
'next_query_string': next_qs,
'source': source,
'redirect_url': redirect_url,
}
return render(request, 'core/log_door_visit.html', context)
def door_visit_history(request):
"""
Shows a distinct list of Door visit interactions for addresses.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
# 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( voter__tenant=tenant,
type__name="Door Visit"
).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 (before filtering by volunteer)
volunteer_id = request.GET.get("volunteer")
volunteer_counts_dict = {} # (name, id) -> count
seen_households_counts = set()
for interaction in interactions.order_by("-date"):
v = interaction.voter
addr = v.address.strip() if v.address else f"{v.address_street}, {v.city}, {v.state} {v.zip_code}".strip(", ")
if not addr: continue
key = addr.lower()
if key not in seen_households_counts:
seen_households_counts.add(key)
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"
v_id = str(v_obj.id) if v_obj else "None"
volunteer_counts_dict[(v_name, v_id)] = volunteer_counts_dict.get((v_name, v_id), 0) + 1
# Apply volunteer filter for the table
if volunteer_id:
if volunteer_id == "None":
interactions = interactions.filter(volunteer__isnull=True)
else:
interactions = interactions.filter(volunteer_id=volunteer_id)
visited_households = {}
for interaction in interactions.order_by("-date"):
v = interaction.voter
addr = v.address.strip() if v.address else f"{v.address_street}, {v.city}, {v.state} {v.zip_code}".strip(", ")
if not addr:
continue
key = addr.lower()
if key not in visited_households:
# Parse street name and number for sorting
street_number = ""
street_name = v.address_street or ""
match = re.search(r'^(\d+)\s+(.*)$', street_name)
if match:
street_number = match.group(1)
street_name = match.group(2)
try:
street_number_sort = int(street_number)
except ValueError:
street_number_sort = 0
visited_households[key] = {
'address_display': addr,
'address_street': v.address_street,
'city': v.city,
'state': v.state,
'zip_code': v.zip_code,
'neighborhood': v.neighborhood,
'district': v.district,
'latitude': float(v.latitude) if v.latitude else None,
'longitude': float(v.longitude) if v.longitude else None,
'street_name_sort': street_name.lower(),
'street_number_sort': street_number_sort,
'last_visit_date': interaction.date,
'last_volunteer': interaction.volunteer,
'last_outcome': interaction.description,
'notes': interaction.notes,
'target_voters': [],
'voters_at_address': []
}
phone_display = format_phone_number(v.phone) if v.phone else "<No Phone>"
visited_households[key]["voters_at_address"].append((v.id, f"{v.first_name} {v.last_name} - {phone_display}"))
visited_households[key]['target_voters'].append(v)
# Sort volunteer counts by total (descending)
sorted_volunteer_counts = [
{"name": k[0], "id": k[1], "count": v}
for k, v in sorted(volunteer_counts_dict.items(), key=lambda x: x[1], reverse=True)
]
history_list = list(visited_households.values())
history_list.sort(key=lambda x: x["last_visit_date"], reverse=True)
paginator = Paginator(history_list, 50)
page_number = request.GET.get("page")
history_page = paginator.get_page(page_number)
context = {
"selected_tenant": tenant,
"history": history_page,
"start_date": start_date, "end_date": end_date,
"volunteer_counts": sorted_volunteer_counts, "selected_volunteer_id": volunteer_id,
}
return render(request, "core/door_visit_history.html", context)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_scheduledcall')
def schedule_call(request, voter_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
if request.method == 'POST':
form = ScheduledCallForm(request.POST, tenant=tenant)
if form.is_valid():
call = form.save(commit=False)
call.tenant = tenant
call.voter = voter
call.save()
messages.success(request, f"Call for {voter} added to queue.")
else:
messages.error(request, "Error scheduling call.")
referer = request.META.get('HTTP_REFERER')
if referer:
return redirect(referer)
return redirect('voter_detail', voter_id=voter.id)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_scheduledcall')
def bulk_schedule_calls(request):
if request.method != 'POST':
return redirect('voter_advanced_search')
selected_tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voter_ids = request.POST.getlist('selected_voters')
volunteer_id = request.POST.get('volunteer')
comments = request.POST.get('comments', '')
volunteer = None
if volunteer_id:
volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant)
else:
# Fallback to default caller if not specified in POST but available
volunteer = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first()
voters = Voter.objects.filter(tenant=tenant, id__in=voter_ids)
count = 0
for voter in voters:
ScheduledCall.objects.create(
tenant=tenant,
voter=voter,
volunteer=volunteer,
comments=comments
)
count += 1
messages.success(request, f"{count} calls added to queue.")
return redirect(request.META.get('HTTP_REFERER', 'voter_advanced_search'))
@login_required
def call_queue(request):
if not can_access_call_queue(request.user):
messages.error(request, "You do not have permission to access the call queue.")
return redirect('index')
selected_tenant_id = request.session.get('tenant_id')
if not selected_tenant_id:
messages.warning(request, 'Please select a campaign first.')
return redirect('index')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
# Determine if user is staff
user_role = get_user_role(request.user, tenant)
is_staff = request.user.is_superuser or user_role in STAFF_ROLES or request.user.groups.filter(name='Editor').exists()
# Get volunteer profile
try:
current_volunteer = request.user.volunteer_profile
except:
current_volunteer = None
calls = ScheduledCall.objects.filter(tenant=tenant, status='pending').order_by('created_at')
volunteers = []
selected_volunteer_id = request.GET.get('volunteer_filter')
if is_staff:
volunteers = Volunteer.objects.filter(tenant=tenant, interests__name="Phone Banking").order_by("last_name", "first_name")
# Default to current user's volunteer if no filter selected
if not selected_volunteer_id and current_volunteer:
selected_volunteer_id = str(current_volunteer.id)
if selected_volunteer_id and selected_volunteer_id != 'all':
calls = calls.filter(volunteer_id=selected_volunteer_id)
else:
# Non-staff: only show assigned calls
if current_volunteer:
calls = calls.filter(volunteer=current_volunteer)
selected_volunteer_id = str(current_volunteer.id)
else:
calls = calls.none()
paginator = Paginator(calls, 50)
page_number = request.GET.get('page')
calls_page = paginator.get_page(page_number)
context = {
'selected_tenant': tenant,
'calls': calls_page,
'volunteers': volunteers,
'campaign_settings': getattr(tenant, 'settings', None),
'selected_volunteer_id': selected_volunteer_id,
'is_staff': is_staff,
}
return render(request, 'core/call_queue.html', context)
@login_required
def complete_call(request, call_id):
if not can_access_call_queue(request.user):
messages.error(request, "You do not have permission to perform this action.")
return redirect('index')
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
call = get_object_or_404(ScheduledCall, id=call_id, tenant=tenant)
if request.method == 'POST':
# Get notes from post data taken during the call
call_notes = request.POST.get('call_notes', '')
call_outcome = request.POST.get('call_outcome', 'Called Voter')
candidate_support = request.POST.get('candidate_support')
yard_sign = request.POST.get('yard_sign')
# Update candidate support if provided
if candidate_support:
voter = call.voter
if voter.candidate_support != candidate_support:
voter.candidate_support = candidate_support
voter.save(update_fields=['candidate_support'])
# Update yard sign status if provided and not 'no_change'
if yard_sign and yard_sign != 'no_change':
voter = call.voter
if voter.yard_sign != yard_sign:
voter.yard_sign = yard_sign
voter.save(update_fields=['yard_sign'])
# Create interaction for the completed call
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Phone Call")
# Determine date/time in campaign timezone
campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant)
campaign_tz_name = campaign_settings.timezone or 'America/Chicago'
try:
tz = zoneinfo.ZoneInfo(campaign_tz_name)
except:
tz = zoneinfo.ZoneInfo('America/Chicago')
interaction_date = timezone.now().astimezone(tz);
Interaction.objects.create(
voter=call.voter,
volunteer=call.volunteer,
type=interaction_type,
date=interaction_date,
description=call_outcome,
notes=call_notes
)
call.status = 'completed';
call.save()
if call_outcome == 'No Answer No Voice Mail':
voter = call.voter
# If no other pending calls, reset to 'to_be_called' instead of 'called'
if not ScheduledCall.objects.filter(voter=voter, status='pending').exists():
Voter.objects.filter(pk=voter.pk).update(call_queue_status='to_be_called')
messages.success(request, f"Call for {call.voter} marked as completed and interaction logged.")
return redirect('call_queue')
@login_required
def delete_call(request, call_id):
if not can_access_call_queue(request.user):
messages.error(request, "You do not have permission to perform this action.")
return redirect('index')
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
call = get_object_or_404(ScheduledCall, id=call_id, tenant=tenant)
if request.method == 'POST':
call.delete()
messages.success(request, "Call removed from queue.")
return redirect('call_queue')
@login_required
def profile(request):
try:
volunteer = request.user.volunteer_profile
except:
volunteer = None
if request.method == 'POST':
u_form = UserUpdateForm(request.POST, instance=request.user)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
def neighborhood_counts(request):
"""
Shows household counts by neighborhood after applying filters from door visits.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
city_filter = request.GET.get("city", "").strip()
district_filter = request.GET.get('district', '').strip()
neighborhood_filter = request.GET.get('neighborhood', '').strip()
address_filter = request.GET.get('address', '').strip()
voters = Voter.objects.filter(tenant=tenant, is_inactive=False, door_visit=False, target_door_visit=True)
if city_filter:
voters = voters.filter(city__icontains=city_filter)
if district_filter:
voters = voters.filter(district=district_filter)
if neighborhood_filter:
voters = voters.filter(neighborhood__icontains=neighborhood_filter)
if address_filter:
voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter))
household_qs = voters.values('neighborhood', 'address_street', 'city', 'state', 'zip_code').distinct()
neighborhood_counts_dict = {}
for h in household_qs:
nb = h['neighborhood']
neighborhood_counts_dict[nb] = neighborhood_counts_dict.get(nb, 0) + 1
neighborhood_list = [
{'neighborhood': nb, 'display_name': nb or "Unknown", 'count': count}
for nb, count in neighborhood_counts_dict.items()
]
neighborhood_list.sort(key=lambda x: x['count'], reverse=True)
context = {
'selected_tenant': tenant,
'neighborhoods': neighborhood_list,
'city_filter': city_filter,
'district_filter': district_filter,
'neighborhood_filter': neighborhood_filter,
'address_filter': address_filter,
}
return render(request, 'core/neighborhood_counts.html', context)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
def yard_sign_voters(request):
"""
Manage yard sign requests. Groups voters who want a yard sign by household.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
city_filter = request.GET.get("city", "").strip()
district_filter = request.GET.get('district', '').strip()
neighborhood_filter = request.GET.get('neighborhood', '').strip()
address_filter = request.GET.get('address', '').strip()
sign_type_filter = request.GET.get('sign_type', '').strip()
# Initial queryset: voters who want a yard sign for this tenant
voters = Voter.objects.filter(tenant=tenant, is_inactive=False, yard_sign__in=['wants', 'wants_large'])
if city_filter:
voters = voters.filter(city__icontains=city_filter)
if district_filter:
voters = voters.filter(district=district_filter)
if neighborhood_filter:
voters = voters.filter(neighborhood__icontains=neighborhood_filter)
if address_filter:
voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter))
if sign_type_filter == 'yard':
voters = voters.filter(yard_sign='wants')
elif sign_type_filter == 'large':
voters = voters.filter(yard_sign='wants_large')
# Grouping by household (unique address)
households_dict = {}
for voter in voters:
# Normalize address components for robust grouping
street = (voter.address_street or "").strip()
city = (voter.city or "").strip()
state = (voter.state or "").strip()
zip_code = (voter.zip_code or "").strip()
key = (street.lower(), city.lower(), state.lower(), zip_code.lower())
if key not in households_dict:
street_number = ""
street_name = street
match = re.match(r'^(\d+)\s+(.*)$', street)
if match:
street_number = match.group(1)
street_name = match.group(2)
try:
street_number_sort = int(street_number)
except (ValueError, TypeError):
street_number_sort = 0
households_dict[key] = {
'address_street': street,
'city': city,
'state': state,
'zip_code': zip_code,
'neighborhood': (voter.neighborhood or "").strip(),
'district': (voter.district or "").strip(),
'latitude': float(voter.latitude) if voter.latitude else None,
'longitude': float(voter.longitude) if voter.longitude else None,
'street_name_sort': street_name.lower(),
'street_number_sort': street_number_sort,
'voters_who_want_sign': [],
'sign_types': set(),
'has_large_request': False,
}
else:
# Pick a non-empty neighborhood/district if the current one is empty
if not households_dict[key]['neighborhood'] and voter.neighborhood:
households_dict[key]['neighborhood'] = voter.neighborhood.strip()
if not households_dict[key]['district'] and voter.district:
households_dict[key]['district'] = voter.district.strip()
households_dict[key]['voters_who_want_sign'].append(voter)
households_dict[key]['sign_types'].add('Large Sign' if voter.yard_sign == 'wants_large' else 'Yard Sign')
if voter.yard_sign == 'wants_large':
households_dict[key]['has_large_request'] = True
households_list = list(households_dict.values())
for h in households_list:
h['sign_types_display'] = ", ".join(sorted(list(h['sign_types'])))
# Sort by neighborhood presence, then neighborhood name, then street name, then street number
households_list.sort(key=lambda x: (
not bool(x['neighborhood']),
(x['neighborhood'] or '').lower(),
x['street_name_sort'],
x['street_number_sort']
))
# Prepare data for Google Map
# Limit map markers to 3000 for performance
map_data = [
{
"lat": h["latitude"],
"lng": h["longitude"],
"address": f"{h.get('address_street', '')}, {h.get('city', '')}, {h.get('state', '')}",
"voters": [{"id": v.id, "name": f"{v.first_name} {v.last_name}"} for v in h["voters_who_want_sign"]],
"notes": ", ".join([f"{v.first_name}: {v.notes}" for v in h["voters_who_want_sign"] if v.notes]),
"voter_ids": [v.id for v in h["voters_who_want_sign"]],
"is_large": h["has_large_request"]
}
for h in households_list[:3000] if h["latitude"] and h["longitude"]
]
paginator = Paginator(households_list, 50)
page_number = request.GET.get('page')
households_page = paginator.get_page(page_number)
context = {
'selected_tenant': tenant,
'households': households_page,
'district_filter': district_filter,
'neighborhood_filter': neighborhood_filter,
'address_filter': address_filter,
"city_filter": city_filter,
"sign_type_filter": sign_type_filter,
'map_data_json': json.dumps(map_data),
'GOOGLE_MAPS_API_KEY': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''),
'map_limit_reached': len(households_list) > 3000,
}
return render(request, 'core/yard_sign_voters.html', context)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
def view_signs(request):
"""
View voters who already have a yard sign. Groups them by household.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
city_filter = request.GET.get("city", "").strip()
district_filter = request.GET.get('district', '').strip()
neighborhood_filter = request.GET.get('neighborhood', '').strip()
address_filter = request.GET.get('address', '').strip()
sign_type_filter = request.GET.get('sign_type', '').strip()
# Initial queryset: voters who have a yard sign for this tenant
voters = Voter.objects.filter(tenant=tenant, is_inactive=False, yard_sign__in=['has', 'has_large'])
if city_filter:
voters = voters.filter(city__icontains=city_filter)
if district_filter:
voters = voters.filter(district=district_filter)
if neighborhood_filter:
voters = voters.filter(neighborhood__icontains=neighborhood_filter)
if address_filter:
voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter))
if sign_type_filter == 'yard':
voters = voters.filter(yard_sign='has')
elif sign_type_filter == 'large':
voters = voters.filter(yard_sign='has_large')
# Grouping by household (unique address)
households_dict = {}
for voter in voters:
# Normalize address components for robust grouping
street = (voter.address_street or "").strip()
city = (voter.city or "").strip()
state = (voter.state or "").strip()
zip_code = (voter.zip_code or "").strip()
key = (street.lower(), city.lower(), state.lower(), zip_code.lower())
if key not in households_dict:
street_number = ""
street_name = street
match = re.match(r'^(\d+)\s+(.*)$', street)
if match:
street_number = match.group(1)
street_name = match.group(2)
try:
street_number_sort = int(street_number)
except (ValueError, TypeError):
street_number_sort = 0
households_dict[key] = {
'address_street': street,
'city': city,
'state': state,
'zip_code': zip_code,
'neighborhood': (voter.neighborhood or "").strip(),
'district': (voter.district or "").strip(),
'latitude': float(voter.latitude) if voter.latitude else None,
'longitude': float(voter.longitude) if voter.longitude else None,
'street_name_sort': street_name.lower(),
'street_number_sort': street_number_sort,
'voters_with_sign': [],
'sign_types': set(),
'has_large': False,
}
else:
# Pick a non-empty neighborhood/district if the current one is empty
if not households_dict[key]['neighborhood'] and voter.neighborhood:
households_dict[key]['neighborhood'] = voter.neighborhood.strip()
if not households_dict[key]['district'] and voter.district:
households_dict[key]['district'] = voter.district.strip()
households_dict[key]['voters_with_sign'].append(voter)
households_dict[key]['sign_types'].add('Large Sign' if voter.yard_sign == 'has_large' else 'Yard Sign')
if voter.yard_sign == 'has_large':
households_dict[key]['has_large'] = True
households_list = list(households_dict.values())
for h in households_list:
h['sign_types_display'] = ", ".join(sorted(list(h['sign_types'])))
# Sort
households_list.sort(key=lambda x: (
not bool(x['neighborhood']),
(x['neighborhood'] or '').lower(),
x['street_name_sort'],
x['street_number_sort']
))
# Prepare data for Google Map
map_data = [
{
'lat': h['latitude'],
'lng': h['longitude'],
'address': f"{h['address_street']}, {h['city']}, {h['state']}",
'voters': [{"id": v.id, "name": f"{v.first_name} {v.last_name}"} for v in h['voters_with_sign']],
'is_large': h['has_large']
}
for h in households_list[:3000] if h['latitude'] and h['longitude']
]
paginator = Paginator(households_list, 50)
page_number = request.GET.get('page')
households_page = paginator.get_page(page_number)
context = {
'selected_tenant': tenant,
'households': households_page,
'district_filter': district_filter,
'neighborhood_filter': neighborhood_filter,
'address_filter': address_filter,
"city_filter": city_filter,
"sign_type_filter": sign_type_filter,
'map_data_json': json.dumps(map_data),
'GOOGLE_MAPS_API_KEY': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''),
'map_limit_reached': len(households_list) > 3000,
}
return render(request, 'core/view_signs.html', context)
def get_tenant_email_connection(campaign_settings):
use_tls = campaign_settings.smtp_use_tls
use_ssl = campaign_settings.smtp_use_ssl
# EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive
if use_tls and use_ssl:
if campaign_settings.smtp_port == 465:
use_tls = False
else:
use_ssl = False
return get_connection(
host=campaign_settings.smtp_host,
port=campaign_settings.smtp_port,
username=campaign_settings.smtp_username,
password=campaign_settings.smtp_password,
use_tls=use_tls,
use_ssl=use_ssl,
)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
def volunteer_bulk_send_email(request):
selected_tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
campaign_settings = CampaignSettings.objects.get(tenant=tenant)
if request.method == 'POST':
volunteer_ids = request.POST.getlist('selected_volunteers')
subject = request.POST.get('subject')
body = request.POST.get('body')
is_html = request.POST.get("is_html") == "on"
volunteers = Volunteer.objects.filter(id__in=volunteer_ids, tenant=tenant).exclude(email='')
if not volunteers.exists():
messages.warning(request, "No volunteers with email addresses selected.")
return redirect('volunteer_list')
connection = get_tenant_email_connection(campaign_settings)
if not connection:
messages.error(request, "SMTP settings are not configured. Please check Campaign Settings.")
return redirect('volunteer_list')
from_email = campaign_settings.email_from_address or settings.DEFAULT_FROM_EMAIL
if campaign_settings.email_from_name:
from_email = f"{campaign_settings.email_from_name} <{from_email}>"
sent_count = 0
error_count = 0
for volunteer in volunteers:
try:
email = EmailMessage(
subject,
body,
from_email,
[volunteer.email],
connection=connection,
)
if is_html:
email.content_subtype = "html"
email.send()
sent_count += 1
except Exception as e:
logger.error(f"Error sending bulk email to volunteer {volunteer.email}: {e}")
error_count += 1
if sent_count > 0:
messages.success(request, f"Successfully sent {sent_count} emails.")
if error_count > 0:
messages.error(request, f"Failed to send {error_count} emails.")
return redirect('volunteer_list')
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
def voter_bulk_send_email(request):
selected_tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
campaign_settings = CampaignSettings.objects.get(tenant=tenant)
if request.method == 'POST':
select_all_results = request.POST.get('select_all_results') == 'true'
subject = request.POST.get('subject')
body = request.POST.get('body')
is_html = request.POST.get("is_html") == "on"
if select_all_results:
voters, _ = get_filtered_voter_queryset(request, tenant, data_source="POST")
voters = voters.exclude(email='')
else:
voter_ids = request.POST.getlist('selected_voters')
voters = Voter.objects.filter(id__in=voter_ids, tenant=tenant).exclude(email='')
if not voters.exists():
messages.warning(request, "No voters with email addresses selected.")
return redirect('voter_advanced_search')
connection = get_tenant_email_connection(campaign_settings)
if not connection:
messages.error(request, "SMTP settings are not configured. Please check Campaign Settings.")
return redirect('voter_advanced_search')
from_email = campaign_settings.email_from_address or settings.DEFAULT_FROM_EMAIL
if campaign_settings.email_from_name:
from_email = f"{campaign_settings.email_from_name} <{from_email}>"
email_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name='Email')
sent_count = 0
error_count = 0
for voter in voters:
try:
email = EmailMessage(
subject,
body,
from_email,
[voter.email],
connection=connection,
)
if is_html:
email.content_subtype = "html"
email.send()
sent_count += 1
# Log interaction
Interaction.objects.create(
voter=voter,
type=email_type,
date=timezone.now(),
description=subject,
notes=body
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error sending bulk email to voter {voter.email}: {e}")
error_count += 1
if sent_count > 0:
messages.success(request, f"Successfully sent {sent_count} emails.")
if error_count > 0:
messages.error(request, f"Failed to send {error_count} emails.")
return redirect('voter_advanced_search')
def mark_yard_sign_delivered(request):
"""
Action to mark yard signs as delivered for one or more voters.
"""
is_ajax = request.headers.get('x-requested-with') == 'XMLHttpRequest' or request.POST.get('is_ajax') == 'true'
if request.method == "POST":
voter_ids = request.POST.getlist('voter_ids')
selected_tenant_id = request.session.get("tenant_id")
if voter_ids and selected_tenant_id:
voters_to_update = Voter.objects.filter(
id__in=voter_ids,
tenant_id=selected_tenant_id,
yard_sign__in=['wants', 'wants_large']
)
updated_count = 0
for v in voters_to_update:
if v.yard_sign == 'wants':
v.yard_sign = 'has'
elif v.yard_sign == 'wants_large':
v.yard_sign = 'has_large'
v.save(update_fields=['yard_sign'])
updated_count += 1
if updated_count > 0:
msg = f"Successfully marked sign(s) as delivered for {updated_count} voter(s)."
if is_ajax:
return JsonResponse({'success': True, 'message': msg, 'updated_count': updated_count})
messages.success(request, msg)
else:
msg = "No voter records were updated."
if is_ajax:
return JsonResponse({'success': False, 'message': msg})
messages.warning(request, msg)
else:
msg = "Invalid request: No voters selected."
if is_ajax:
return JsonResponse({'success': False, 'message': msg})
messages.error(request, msg)
if is_ajax:
return JsonResponse({'success': False, 'message': 'Invalid request method.'})
return redirect(request.META.get('HTTP_REFERER', 'yard_sign_voters'))
@login_required
def populate_call_queue(request):
if not can_access_call_queue(request.user):
messages.error(request, "You do not have permission to perform this action.")
return redirect("index")
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
# Determine if user is staff to allow assigning to others
user_role = get_user_role(request.user, tenant)
is_staff = request.user.is_superuser or user_role in STAFF_ROLES or request.user.groups.filter(name="Editor").exists()
if request.method == "POST":
# Default to current user's volunteer profile
volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first()
# If staff, they can override the volunteer
if is_staff:
target_volunteer_id = request.POST.get("volunteer_id")
if target_volunteer_id:
volunteer = Volunteer.objects.filter(id=target_volunteer_id, tenant=tenant).first()
if not volunteer:
messages.error(request, "No volunteer profile found for the assignment.")
return redirect("call_queue")
num_voters_raw = request.POST.get("num_voters", "5")
try:
num_voters = int(num_voters_raw)
except ValueError:
num_voters = 5
district = request.POST.get("district", "").strip()
include_door_visits = request.POST.get("include_door_visits") == "on"
# Build queryset
voters = Voter.objects.filter(
tenant=tenant,
call_queue_status="to_be_called",
is_inactive=False
).exclude(
Q(phone="") & Q(secondary_phone="")
)
if district:
voters = voters.filter(district__iexact=district)
if not include_door_visits:
voters = voters.filter(door_visit=False)
# Select the top N voters
voters_to_add = voters[:num_voters]
added_count = 0
with transaction.atomic():
for voter in voters_to_add:
ScheduledCall.objects.create(
tenant=tenant,
voter=voter,
volunteer=volunteer,
status="pending"
)
added_count += 1
if added_count > 0:
messages.success(request, f"Successfully added {added_count} voters to {volunteer}'s call queue.")
else:
messages.info(request, "No voters found matching your criteria.")
return redirect("call_queue")
@login_required
def export_call_queue(request):
if not can_access_call_queue(request.user):
messages.error(request, "You do not have permission to access the call queue.")
return redirect('index')
selected_tenant_id = request.session.get('tenant_id')
if not selected_tenant_id:
messages.warning(request, 'Please select a campaign first.')
return redirect('index')
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
# Determine if user is staff
user_role = get_user_role(request.user, tenant)
is_staff = request.user.is_superuser or user_role in STAFF_ROLES or request.user.groups.filter(name='Editor').exists()
# Get volunteer profile
try:
current_volunteer = request.user.volunteer_profile
except:
current_volunteer = None
calls = ScheduledCall.objects.filter(tenant=tenant, status='pending').order_by('created_at')
selected_volunteer_id = request.GET.get('volunteer_filter')
if is_staff:
if selected_volunteer_id and selected_volunteer_id != 'all':
calls = calls.filter(volunteer_id=selected_volunteer_id)
else:
# Non-staff: only export assigned calls
if current_volunteer:
calls = calls.filter(volunteer=current_volunteer)
else:
calls = calls.none()
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="call_queue_export_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv"'
writer = csv.writer(response)
writer.writerow([
'Voter ID', 'First Name', 'Last Name', 'Phone', 'Secondary Phone',
'Address', 'City', 'Neighborhood', 'Assigned Volunteer', 'Comments', 'Created At'
])
for call in calls.select_related('voter', 'volunteer'):
writer.writerow([
call.voter.voter_id,
call.voter.first_name,
call.voter.last_name,
call.voter.phone,
call.voter.secondary_phone,
call.voter.address,
call.voter.city,
call.voter.neighborhood,
str(call.volunteer) if call.volunteer else 'Unassigned',
call.comments,
call.created_at.strftime('%Y-%m-%d %H:%M:%S')
])
return response