From 01c62eb11d6ae80fc594c5a85f2ef31425a3af6c Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 8 Feb 2026 00:11:53 +0000 Subject: [PATCH] Revert "Autosave: 20260207-233919" This reverts commit d3537b642704f6b5beb49fa78103cde48170eb77. --- core/__pycache__/urls.cpython-311.pyc | Bin 7458 -> 7287 bytes core/__pycache__/views.cpython-311.pyc | Bin 33106 -> 114516 bytes core/templates/core/profile.html | 78 +- core/urls.py | 52 +- core/views.py | 2486 +++++++++++++++++++----- 5 files changed, 2045 insertions(+), 571 deletions(-) diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f4038b715508aab38034c7b1e449f1a0c530b457..d28eeba16bcbe54172312090659aad03428d2a43 100644 GIT binary patch delta 2361 zcmZ{l%~MlX7{-(MrUoJMVfac&0Od;*17aXZK*aE6Pzjg;`HCUA;?+P95*1n+7hSM$ z;mqlRnQo}lnP$~V#|?`v=*~rFdd|#QICj%N;dG``x4q|_drKT`a`WUod4A`;_ny2b zd42VR|MZV``ze$5_~8CV{mt?7={?&QR&_n0Jb!awb6DnE)b+Ho8H=aY)VepInX3gK zMAgKgS4pU8@B1?T7A?qj+6v6)W}<&ig+mRA)Ozf=w&4m2+eKj`*g0#AWJ$wm6VQv| z=}1#jeXJzahpALT0>{3fW~r!o9PP9mk3TaFm6tqlbSh7k^>oKZY%{IK)eUjc=oh;O zeOKaiT>gAmgWRWW=#P>va}^4gRw1{&-R$C{H*@yiEoKkOmU$fS%buo^8Euu7&1gbN z$JFFbGY{S*%7W&mi=y(D_diBnUu($6Kp zWk3@YsGQCQwSifCZM{5cGO-h8v-zE=4u5>SGo8U79}I%#Qp-EAy$J4A>_uKK>_5ZM zN}!cOD?7onKY50WF61J#V;E1&V^|=tKw*JddCa~rS&OvEj@nlv)X|g+VDJ<0Q}DBs zLDX2)kQLM^LE$3zeumcf;DIR&VFFC{oB!}D43h*VDNJTrxd1A3pe@%-+XF{~7^VqKQc@;=%zWWe^NAfK0bJ4nu}&JWj3JsO5Ty`hUO{x~gjk20IGn(s z5Kt&6tV;k_bfE9hNBie+^f`uY0^1a}nNI*cI?#U@p#uweMf%hQa zhF*)ZtjV-$zF}b|lQn4hOX%N1S)afM1-|!b3ttoQkXbMHY4cq-3u0Ijd+vQJt!d9| z>pnzah{6!N!GUHA3mP>(X;3yIYmB1#T9<81nsKzNnF(obqJvu3?T|E+Mj_9El{WO^ z!SHb>-;&5}1M%fEw08>6CNZoLSfjAU?nq<`6*=2%VQHpOpJryHxr_2tE?4%kG)ZGt{Xr#d z9K<8MCH0P6lgPS(h_2Oo@&Sfb0;?2OSxO=s2GV<2N&6yrF^wTZAVVR;o=D`WMD{%U zm9(}!kNWG1FFcdcb7Qvnee1&haSRg#CMZm>ZHerlCv~pum(mPbWJ`*MYRhR29v~2) z5Mb9Wvg0Ixi^NVHep`>>9)Wul?y(z^2pWXg>w`zyPMskzLt%!ENMck!iSt$D+ofDB XhHg>~-4wdnm;hcB^XKNU_OJ3U+Pv{P delta 2382 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 H=8FDbYIfFY diff --git a/core/templates/core/profile.html b/core/templates/core/profile.html index 8d242cc..b7f4016 100644 --- a/core/templates/core/profile.html +++ b/core/templates/core/profile.html @@ -1,11 +1,73 @@ -{% extends 'base.html' %} - -{% block title %}Profile{% endblock %} +{% extends "base.html" %} +{% load static %} {% block content %} -
-

User Profile

-

This is a placeholder for the user profile page.

-

Selected Tenant: {{ selected_tenant.name }}

+
+
+
+
+
+
+
+ +
+
+

My Profile

+

Manage your account information

+
+
+
+
+
+ {% csrf_token %} + +
User Information
+
+
+ + {{ u_form.first_name }} + {% if u_form.first_name.errors %} +
{{ u_form.first_name.errors }}
+ {% endif %} +
+
+ + {{ u_form.last_name }} + {% if u_form.last_name.errors %} +
{{ u_form.last_name.errors }}
+ {% endif %} +
+
+ + {{ u_form.email }} + {% if u_form.email.errors %} +
{{ u_form.email.errors }}
+ {% endif %} +
+
+ + {% if v_form %} +
+
Volunteer Details
+
+
+ + {{ v_form.phone }} + {% if v_form.phone.errors %} +
{{ v_form.phone.errors }}
+ {% endif %} +
+
+ {% endif %} + +
+ + Change Password +
+
+
+
+
+
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/core/urls.py b/core/urls.py index cb58d21..0c592a0 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,43 +1,41 @@ from django.urls import path - from . import views urlpatterns = [ - path('', views.dashboard, name='dashboard'), + path('', views.index, name='index'), path('select-campaign//', views.select_campaign, name='select_campaign'), path('voters/', views.voter_list, name='voter_list'), path('voters/advanced-search/', views.voter_advanced_search, name='voter_advanced_search'), path('voters/export-csv/', views.export_voters_csv, name='export_voters_csv'), path('voters/bulk-sms/', views.bulk_send_sms, name='bulk_send_sms'), path('voters//', views.voter_detail, name='voter_detail'), - path('voters//edit/', views.voter_update, name='voter_edit'), # Changed to voter_update + path('voters//edit/', views.voter_edit, name='voter_edit'), path('voters//delete/', views.voter_delete, name='voter_delete'), path('voters//geocode/', views.voter_geocode, name='voter_geocode'), - path('voters//schedule-call/', views.create_scheduled_call, name='schedule_call'), # Changed to create_scheduled_call + path('voters//schedule-call/', views.schedule_call, name='schedule_call'), path('voters/bulk-schedule-calls/', views.bulk_schedule_calls, name='bulk_schedule_calls'), - path('voters//interaction/add/', views.interaction_create, name='add_interaction'), # Changed to interaction_create - path('interaction//edit/', views.interaction_update, name='edit_interaction'), # Changed to pk and interaction_update - path('interaction//delete/', views.interaction_delete, name='delete_interaction'), # Changed to pk and interaction_delete + path('voters//interaction/add/', views.add_interaction, name='add_interaction'), + path('interaction//edit/', views.edit_interaction, name='edit_interaction'), + path('interaction//delete/', views.delete_interaction, name='delete_interaction'), - # Assuming donation, likelihood, event-participation views are correctly named in views.py and use 'pk' - path('voters//donation/add/', views.donation_create, name='add_donation'), - path('donation//edit/', views.donation_update, name='edit_donation'), - path('donation//delete/', views.donation_delete, name='delete_donation'), + path('voters//donation/add/', views.add_donation, name='add_donation'), + path('donation//edit/', views.edit_donation, name='edit_donation'), + path('donation//delete/', views.delete_donation, name='delete_donation'), - path('voters//likelihood/add/', views.likelihood_create, name='add_likelihood'), - path('likelihood//edit/', views.likelihood_update, name='edit_likelihood'), - path('likelihood//delete/', views.likelihood_delete, name='delete_likelihood'), + path('voters//likelihood/add/', views.add_likelihood, name='add_likelihood'), + path('likelihood//edit/', views.edit_likelihood, name='edit_likelihood'), + path('likelihood//delete/', views.delete_likelihood, name='delete_likelihood'), - path('voters//event-participation/add/', views.event_participation_create, name='add_event_participation'), - path('event-participation//edit/', views.event_participation_update, name='edit_event_participation'), - path('event-participation//delete/', views.event_participation_delete, name='delete_event_participation'), + path('voters//event-participation/add/', views.add_event_participation, name='add_event_participation'), + path('event-participation//edit/', views.edit_event_participation, name='edit_event_participation'), + path('event-participation//delete/', views.delete_event_participation, name='delete_event_participation'), # Event Detail and Participant Management path('events/', views.event_list, name='event_list'), - path('events//', views.event_detail, name='event_detail'), # Changed to pk + path('events//', views.event_detail, name='event_detail'), path('events/add/', views.event_create, name='event_create'), - path('events//edit/', views.event_update, name='event_edit'), # Changed to pk and event_update + path('events//edit/', views.event_edit, name='event_edit'), path('events//participant/add/', views.event_add_participant, name='event_add_participant'), path('events/participant//edit/', views.event_edit_participant, name='event_edit_participant'), path('events/participant//delete/', views.event_delete_participant, name='event_delete_participant'), @@ -51,24 +49,24 @@ urlpatterns = [ path('interests/add/', views.interest_add, name='interest_add'), path('interests//delete/', views.interest_delete, name='interest_delete'), path('volunteers/', views.volunteer_list, name='volunteer_list'), - path('volunteers/add/', views.volunteer_create, name='volunteer_add'), # Changed to volunteer_create - path('volunteers//', views.volunteer_detail, name='volunteer_detail'), # Changed to pk - path('volunteers//delete/', views.volunteer_delete, name='volunteer_delete'), # Changed to pk + path('volunteers/add/', views.volunteer_add, name='volunteer_add'), + path('volunteers//', views.volunteer_detail, name='volunteer_detail'), + path('volunteers//delete/', views.volunteer_delete, name='volunteer_delete'), path('volunteers//assign-event/', views.volunteer_assign_event, name='volunteer_assign_event'), path('volunteers/assignment//remove/', views.volunteer_remove_event, name='volunteer_remove_event'), path('volunteers/search/json/', views.volunteer_search_json, name='volunteer_search_json'), path('volunteers/bulk-sms/', views.volunteer_bulk_send_sms, name='volunteer_bulk_send_sms'), - path('events//volunteer/add/', views.volunteer_event_create, name='event_add_volunteer'), # Changed to volunteer_event_create - path('events/volunteer//delete/', views.volunteer_event_delete, name='event_remove_volunteer'), # Changed to pk and volunteer_event_delete + path('events//volunteer/add/', views.event_add_volunteer, name='event_add_volunteer'), + path('events/volunteer//delete/', views.event_remove_volunteer, name='event_remove_volunteer'), # Door Visits path('door-visits/', views.door_visits, name='door_visits'), - path('door-visits/log/', views.create_interaction_for_voter, name='log_door_visit'), # Changed to create_interaction_for_voter + path('door-visits/log/', views.log_door_visit, name='log_door_visit'), path('door-visits/history/', views.door_visit_history, name='door_visit_history'), # Call Queue path('call-queue/', views.call_queue, name='call_queue'), - path('call-queue//complete/', views.complete_call, name='complete_call'), # Changed to pk - path('call-queue//delete/', views.scheduled_call_delete, name='delete_call'), # Changed to pk and scheduled_call_delete + path('call-queue//complete/', views.complete_call, name='complete_call'), + path('call-queue//delete/', views.delete_call, name='delete_call'), path('profile/', views.profile, name='profile'), ] \ No newline at end of file diff --git a/core/views.py b/core/views.py index 93c67b0..a44313c 100644 --- a/core/views.py +++ b/core/views.py @@ -1,616 +1,2030 @@ -from django.shortcuts import render, get_object_or_404, redirect from django.contrib.auth.decorators import login_required -from django.db.models import Count, Case, When, IntegerField, Sum, F, DecimalField -from django.db.models.functions import Coalesce -from django.http import JsonResponse, HttpResponseForbidden, HttpResponseBadRequest, HttpResponse -from django.core.paginator import Paginator -from django.forms import modelformset_factory -from .models import Tenant, Voter, Interaction, Event, EventParticipation, Volunteer, VolunteerEvent, CampaignSettings, ParticipationStatus, VoterLikelihood, ScheduledCall, VolunteerRole, EventType, TenantUserRole -from .forms import VoterForm, EventForm, VolunteerForm, ScheduledCallForm, InteractionForm -from datetime import datetime, date, time, timedelta -from django.utils import timezone -import pytz +from django.contrib.auth.forms import PasswordChangeForm +from django.utils.dateparse import parse_date +from datetime import datetime, time, timedelta +import base64 import re -from django.conf import settings -from django.template.defaultfilters import date as date_filter +import urllib.parse +import urllib.request import csv - -# Import necessary modules for Twilio -from twilio.rest import Client -from twilio.base.exceptions import TwilioRestException +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.models import Q, Sum, Value +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 Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest, VolunteerRole, ScheduledCall +from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm, ScheduledCallForm, UserUpdateForm, EventParticipationImportForm, ParticipantMappingForm 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 logger = logging.getLogger(__name__) -# Helper function to get the current tenant -def get_current_tenant(request): - if request.user.is_authenticated: - tenant_role = TenantUserRole.objects.filter(user=request.user).first() - if tenant_role: - return tenant_role.tenant - return None - -def get_tenant_campaign_settings(tenant): - if tenant: +def _handle_uploaded_file(uploaded_file): + """ + Handles uploaded CSV or Excel files, reads content, and extracts headers. + Returns (headers, data_rows) or (None, None) if file type is unsupported or an error occurs. + """ + # For simplicity, assuming CSV for now. Extend with openpyxl for Excel if needed. + try: + file_content = uploaded_file.read() + decoded_file = file_content.decode('utf-8') + io_string = io.StringIO(decoded_file) + + # Try to sniff CSV dialect try: - return CampaignSettings.objects.get(tenant=tenant) - except CampaignSettings.DoesNotExist: - pass - return None + dialect = csv.Sniffer().sniff(io_string.read(1024)) + io_string.seek(0) # Rewind after sniffing + reader = csv.reader(io_string, dialect) + except csv.Error: + # Not a CSV or sniffing failed, assume comma-separated + io_string.seek(0) + reader = csv.reader(io_string) -@login_required -def dashboard(request): - user_tenants = Tenant.objects.filter(user_roles__user=request.user) - - if not user_tenants.exists(): - return redirect('admin:index') - - selected_tenant_id = request.session.get('selected_tenant_id') + headers = [header.strip() for header in next(reader)] + data_rows = [] + for row in reader: + if len(row) == len(headers): + data_rows.append([item.strip() for item in row]) + else: + logger.warning(f"Skipping malformed row in uploaded file: {row}") + continue + + return headers, data_rows + 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 = get_object_or_404(Tenant, pk=selected_tenant_id) - if selected_tenant not in user_tenants: - # If the selected tenant is not among the user's tenants, reset session and show selection - del request.session['selected_tenant_id'] - return redirect('dashboard') - else: - # If no tenant is selected, and there's only one available, select it automatically - if user_tenants.count() == 1: - selected_tenant = user_tenants.first() - request.session['selected_tenant_id'] = selected_tenant.id - else: - # Otherwise, prompt the user to select a tenant - return render(request, 'core/index.html', {'tenants': user_tenants, 'selected_tenant': None}) + selected_tenant = Tenant.objects.filter(id=selected_tenant_id).first() + if selected_tenant: + voters = selected_tenant.voters.all() + 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').distinct().count(), + 'total_door_visits': voters.filter(door_visit=True).exclude(address='').values('address').distinct().count(), + 'total_signs': voters.filter(Q(yard_sign='wants') | Q(yard_sign='has')).count(), + 'total_window_stickers': voters.filter(Q(window_sticker='wants') | Q(window_sticker='has')).count(), + 'total_donations': float(total_donations), + 'donation_goal': float(donation_goal), + 'donation_percentage': donation_percentage, + 'volunteers_count': Volunteer.objects.filter(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] - campaign_settings = get_tenant_campaign_settings(selected_tenant) - # Total Voters - total_voters = Voter.objects.filter(tenant=selected_tenant).count() - - # Total Interactions - total_interactions = Interaction.objects.filter(voter__tenant=selected_tenant).count() - - return render(request, 'core/index.html', { - 'total_voters': total_voters, - 'total_interactions': total_interactions, - 'campaign_settings': campaign_settings, + context = { + 'tenants': tenants, 'selected_tenant': selected_tenant, - 'tenants': user_tenants, # Pass all tenants for potential switching - }) + 'metrics': metrics, + 'recent_interactions': recent_interactions, + 'upcoming_events': upcoming_events, + } + return render(request, 'core/index.html', context) -@login_required def select_campaign(request, tenant_id): - # Ensure the tenant exists and the user has access to it - tenant = get_object_or_404(Tenant, pk=tenant_id, user_roles__user=request.user) - request.session['selected_tenant_id'] = tenant.id - return redirect('dashboard') + """ + 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') -# Placeholder views for other functions -@login_required +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') def voter_list(request): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') + """ + 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") - voters = Voter.objects.filter(tenant=tenant) - return render(request, 'core/voter_list.html', {'voters': voters}) + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + query = request.GET.get("q") + voters = Voter.objects.filter(tenant=tenant).order_by("last_name", "first_name") -@login_required -def voter_detail(request, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') + # 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(Q(yard_sign="wants") | Q(yard_sign="has")) + 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() - voter = get_object_or_404(Voter, pk=pk, tenant=tenant) - return render(request, 'core/voter_detail.html', {'voter': voter}) + if query: + query = query.strip() + search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(voter_id__iexact=query) -@login_required -def voter_create(request): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') + 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.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) + + + context = { + 'voter': voter, + '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': - form = VoterForm(request.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(): - voter = form.save(commit=False) - voter.tenant = tenant + # 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() - return redirect('voter_detail', pk=voter.pk) - else: - form = VoterForm() - return render(request, 'core/voter_form.html', {'form': form}) + 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) -@login_required -def voter_update(request, voter_id): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - voter = get_object_or_404(Voter, pk=voter_id, tenant=tenant) if request.method == 'POST': - form = VoterForm(request.POST, instance=voter) - if form.is_valid(): - form.save() - return redirect('voter_detail', pk=voter.pk) - else: - form = VoterForm(instance=voter) - return render(request, 'core/voter_form.html', {'form': form, 'voter': voter}) - -@login_required -def voter_delete(request, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - voter = get_object_or_404(Voter, pk=pk, tenant=tenant) - if request.method == 'POST': - voter.delete() - return redirect('voter_list') - return render(request, 'core/voter_confirm_delete.html', {'voter': voter}) - -@login_required -def interaction_list(request): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - interactions = Interaction.objects.filter(voter__tenant=tenant) - return render(request, 'core/interaction_list.html', {'interactions': interactions}) - -@login_required -def interaction_detail(request, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - interaction = get_object_or_404(Interaction, pk=pk, voter__tenant=tenant) - return render(request, 'core/interaction_detail.html', {'interaction': interaction}) - -@login_required -def interaction_create(request, voter_id): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - voter = get_object_or_404(Voter, pk=voter_id, tenant=tenant) - if request.method == 'POST': - form = InteractionForm(request.POST) + form = InteractionForm(request.POST, tenant=tenant) if form.is_valid(): interaction = form.save(commit=False) interaction.voter = voter - # Assign volunteer if relevant or leave null interaction.save() - return redirect('voter_detail', pk=voter.pk) - else: - form = InteractionForm() - return render(request, 'core/interaction_form.html', {'form': form, 'voter': voter}) + messages.success(request, "Interaction added.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter.id}) + '?active_tab=interactions') -@login_required -def interaction_update(request, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - interaction = get_object_or_404(Interaction, pk=pk, voter__tenant=tenant) +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) + form = InteractionForm(request.POST, instance=interaction, tenant=tenant) if form.is_valid(): form.save() - return redirect('voter_detail', pk=interaction.voter.pk) - else: - form = InteractionForm(instance=interaction) - return render(request, 'core/interaction_form.html', {'form': form, 'interaction': interaction}) + messages.success(request, "Interaction updated.") + return redirect(reverse('voter_detail', kwargs={'voter_id': interaction.voter.id}) + '?active_tab=interactions') -@login_required -def interaction_delete(request, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - interaction = get_object_or_404(Interaction, pk=pk, voter__tenant=tenant) +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() - return redirect('voter_detail', pk=interaction.voter.pk) - return render(request, 'core/interaction_confirm_delete.html', {'interaction': interaction}) + messages.success(request, "Interaction deleted.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter_id}) + '?active_tab=interactions') - -@login_required -def donation_list(request): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - donations = Interaction.objects.filter(voter__tenant=tenant) - return render(request, 'core/donation_list.html', {'donations': donations}) - -@login_required -def donation_detail(request, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - donation = get_object_or_404(Interaction, pk=pk, voter__tenant=tenant) - return render(request, 'core/donation_detail.html', {'donation': donation}) - -@login_required -def donation_create(request, voter_pk): - return HttpResponse("Placeholder for donation_create") - -@login_required -def donation_update(request, voter_pk, pk): - return HttpResponse("Placeholder for donation_update") - -@login_required -def donation_delete(request, voter_pk, pk): - return HttpResponse("Placeholder for donation_delete") - -@login_required -def likelihood_create(request, voter_pk): - return HttpResponse("Placeholder for likelihood_create") - -@login_required -def likelihood_update(request, voter_pk, pk): - return HttpResponse("Placeholder for likelihood_update") - -@login_required -def likelihood_delete(request, voter_pk, pk): - return HttpResponse("Placeholder for likelihood_delete") - -@login_required -def event_list(request): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - events = Event.objects.filter(tenant=tenant) - return render(request, 'core/event_list.html', {'events': events}) - -@login_required -def event_detail(request, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - event = get_object_or_404(Event, pk=pk, tenant=tenant) - return render(request, 'core/event_detail.html', {'event': event}) - -@login_required -def event_create(request): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') +@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 = EventForm(request.POST) + form = DonationForm(request.POST, tenant=tenant) if form.is_valid(): - event = form.save(commit=False) - event.tenant = tenant - event.save() - return redirect('event_detail', pk=event.pk) - else: - form = EventForm() - return render(request, 'core/event_form.html', {'form': form}) + 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') -@login_required -def event_update(request, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - event = get_object_or_404(Event, pk=pk, tenant=tenant) +@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 = EventForm(request.POST, instance=event) + form = DonationForm(request.POST, instance=donation, tenant=tenant) if form.is_valid(): form.save() - return redirect('event_detail', pk=event.pk) - else: - form = EventForm(instance=event) - return render(request, 'core/event_form.html', {'form': form, 'event': event}) + messages.success(request, "Donation updated.") + return redirect(reverse('voter_detail', kwargs={'voter_id': donation.voter.id}) + '?active_tab=donations') -@login_required -def event_delete(request, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - event = get_object_or_404(Event, pk=pk, tenant=tenant) +@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': - event.delete() - return redirect('event_list') - return render(request, 'core/event_confirm_delete.html', {'event': event}) + donation.delete() + messages.success(request, "Donation deleted.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter_id}) + '?active_tab=donations') -@login_required -def event_add_participant(request, pk): - return HttpResponse("Placeholder for event_add_participant") +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) -@login_required -def event_edit_participant(request, event_pk, pk): - return HttpResponse("Placeholder for event_edit_participant") + 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) -@login_required -def event_delete_participant(request, event_pk, pk): - return HttpResponse("Placeholder for event_delete_participant") +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) -@login_required -def event_participation_create(request, voter_id): - return HttpResponse("Placeholder for event_participation_create") +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) -@login_required -def event_participation_update(request, pk): - return HttpResponse("Placeholder for event_participation_update") +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) -@login_required -def event_participation_delete(request, pk): - return HttpResponse("Placeholder for event_participation_delete") + 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') -@login_required +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_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).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('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_type'): + voters = voters.filter(phone_type=data['phone_type']) + if data.get('is_targeted'): + voters = voters.filter(is_targeted=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']) + + # 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: + # Annotate each voter with their total donation amount, treating no donations as 0 + voters = voters.annotate(total_donation_amount=Coalesce(Sum('donations__amount'), Value(0))) + + 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) + +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') + voters = Voter.objects.filter(tenant=tenant) + + if action == 'export_selected': + voter_ids = request.POST.getlist('selected_voters') + voters = voters.filter(id__in=voter_ids) + else: # export_all + # Re-apply filters from hidden inputs + # These are passed as filter_fieldname + filters = {} + for key, value in request.POST.items(): + if key.startswith('filter_') and value: + field_name = key.replace('filter_', '') + filters[field_name] = value + + # We can use the AdvancedVoterSearchForm to validate and apply filters + # but we need to pass data without the prefix + form = AdvancedVoterSearchForm(filters) + 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('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_type'): + voters = voters.filter(phone_type=data['phone_type']) + if data.get('is_targeted'): + voters = voters.filter(is_targeted=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']) + + # Add donation amount filters for export + 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))) + + 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) + + 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', 'Phone', 'Phone Type', 'Secondary Phone', 'Secondary Phone Type', 'Email', + 'District', 'Precinct', 'Is Targeted', 'Support', 'Yard Sign', 'Window Sticker', '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.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.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. + """ + 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) + settings = getattr(tenant, 'settings', None) + if not settings: + messages.error(request, "Campaign settings not found.") + return redirect('voter_advanced_search') + + account_sid = settings.twilio_account_sid + auth_token = settings.twilio_auth_token + from_number = settings.twilio_from_number + + if not account_sid or not auth_token or not from_number: + messages.error(request, "Twilio configuration is incomplete in Campaign Settings.") + return redirect('voter_advanced_search') + + voter_ids = request.POST.getlist('selected_voters') + message_body = request.POST.get('message_body') + + # client_time_str is not defined, removed to avoid error. + # interaction_date = timezone.now() + # if client_time_str: + # try: + # interaction_date = datetime.fromisoformat(client_time_str) + # if timezone.is_naive(interaction_date): + # interaction_date = timezone.make_aware(interaction_date) + # except Exception as e: + # logger.warning(f'Failed to parse client_time {client_time_str}: {e}') + + if not message_body: + messages.error(request, "Message body cannot be empty.") + return redirect('voter_advanced_search') + + voters = Voter.objects.filter(tenant=tenant, id__in=voter_ids, phone_type='cell').exclude(phone='') + + if not voters.exists(): + messages.warning(request, "No voters with a valid cell phone number were selected.") + return redirect('voter_advanced_search') + + success_count = 0 + fail_count = 0 + + auth_str = f"{account_sid}:{auth_token}" + auth_header = base64.b64encode(auth_str.encode()).decode() + url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json" + + # Get or create interaction type for SMS + interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="SMS Text") + + for voter in voters: + # Format phone to E.164 (assume US +1) + digits = re.sub(r'\\D', '', str(voter.phone)) + if len(digits) == 10: + to_number = f"+1{digits}" + elif len(digits) == 11 and digits.startswith('1'): + to_number = f"+{digits}" + else: + # Skip invalid phone numbers + fail_count += 1 + continue + + data_dict = { + 'To': to_number, + 'From': from_number, + 'Body': message_body + } + data = urllib.parse.urlencode(data_dict).encode() + + req = urllib.request.Request(url, data=data, method='POST') + req.add_header("Authorization", f"Basic {auth_header}") + + try: + with urllib.request.urlopen(req, timeout=10) as response: + if response.status in [200, 201]: + success_count += 1 + # Log interaction + Interaction.objects.create( + voter=voter, + # volunteer=volunteer, # volunteer is not defined here + type=interaction_type, + # date=interaction_date, # interaction_date removed + description='Mass SMS Text', + notes=message_body + ) + else: + fail_count += 1 + except Exception as e: + logger.error(f"Error sending SMS to {voter.phone}: {e}") + fail_count += 1 + + messages.success(request, f"Bulk SMS process completed: {success_count} successful, {fail_count} failed/skipped.") + return redirect('voter_advanced_search') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_event') +def event_list(request): + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + 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, + '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) + + search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(voter_id__iexact=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): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin: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") - volunteers = Volunteer.objects.filter(tenant=tenant) - return render(request, 'core/volunteer_list.html', {'volunteers': volunteers}) + 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) + ) -@login_required -def volunteer_detail(request, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - volunteer = get_object_or_404(Volunteer, pk=pk, tenant=tenant) - return render(request, 'core/volunteer_detail.html', {'volunteer': volunteer}) + # Interest filter + interest_id = request.GET.get("interest") + if interest_id: + volunteers = volunteers.filter(interests__id=interest_id).distinct() -@login_required -def volunteer_create(request): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') + 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) + form = VolunteerForm(request.POST, tenant=tenant) if form.is_valid(): volunteer = form.save(commit=False) volunteer.tenant = tenant volunteer.save() - return redirect('volunteer_detail', pk=volunteer.pk) + form.save_m2m() # Save interests + messages.success(request, f"Volunteer {volunteer} added successfully.") + return redirect('volunteer_detail', volunteer_id=volunteer.id) else: - form = VolunteerForm() - return render(request, 'core/volunteer_form.html', {'form': form}) - -@login_required -def volunteer_edit(request, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - volunteer = get_object_or_404(Volunteer, pk=pk, tenant=tenant) - if request.method == 'POST': - form = VolunteerForm(request.POST, instance=volunteer) - if form.is_valid(): - form.save() - return redirect('volunteer_detail', pk=volunteer.pk) - else: - form = VolunteerForm(instance=volunteer) - return render(request, 'core/volunteer_form.html', {'form': form, 'voter': volunteer.pk}) - -@login_required -def volunteer_delete(request, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - volunteer = get_object_or_404(Volunteer, pk=pk, tenant=tenant) - if request.method == 'POST': - volunteer.delete() - return redirect('volunteer_list') - return render(request, 'core/volunteer_confirm_delete.html', {'volunteer': volunteer}) - -@login_required -def voter_geocode(request, voter_pk): - return HttpResponse("Placeholder for voter_geocode") - -@login_required -def export_voters_csv(request): - return HttpResponse("Placeholder for export_voters_csv") - -@login_required -def create_scheduled_call(request, voter_id): - return HttpResponse("Placeholder for create_scheduled_call") - -@login_required -def scheduled_call_list(request): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - scheduled_calls = ScheduledCall.objects.filter(tenant=tenant) - return render(request, 'core/scheduled_call_list.html', {'scheduled_calls': scheduled_calls}) - -@login_required -def scheduled_call_detail(request, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - scheduled_call = get_object_or_404(ScheduledCall, pk=pk, tenant=tenant) - return render(request, 'core/scheduled_call_detail.html', {'scheduled_call': scheduled_call}) - -@login_required -def scheduled_call_create(request, voter_pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - voter = get_object_or_404(Voter, pk=voter_pk, tenant=tenant) - if request.method == 'POST': - form = ScheduledCallForm(request.POST) - if form.is_valid(): - scheduled_call = form.save(commit=False) - scheduled_call.voter = voter - scheduled_call.tenant = tenant - scheduled_call.save() - return redirect('scheduled_call_detail', pk=scheduled_call.pk) - else: - form = ScheduledCallForm(initial={'voter': voter}) - return render(request, 'core/scheduled_call_form.html', {'form': form, 'voter': voter}) - -@login_required -def scheduled_call_edit(request, voter_pk, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - scheduled_call = get_object_or_404(ScheduledCall, pk=pk, voter__pk=voter_pk, tenant=tenant) - if request.method == 'POST': - form = ScheduledCallForm(request.POST, instance=scheduled_call) - if form.is_valid(): - form.save() - return redirect('scheduled_call_detail', pk=scheduled_call.pk) - else: - form = ScheduledCallForm(instance=scheduled_call) - return render(request, 'core/scheduled_call_form.html', {'form': form, 'voter': scheduled_call.voter}) - -@login_required -def scheduled_call_delete(request, voter_pk, pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - scheduled_call = get_object_or_404(ScheduledCall, pk=pk, voter__pk=voter_pk, tenant=tenant) - if request.method == 'POST': - scheduled_call.delete() - return redirect('scheduled_call_list') - return render(request, 'core/scheduled_call_confirm_delete.html', {'scheduled_call': scheduled_call}) - -@login_required -def bulk_schedule_calls(request): - return HttpResponse("Placeholder for bulk_schedule_calls") - -@login_required -def voter_search_json(request): - return HttpResponse("Placeholder for voter_search_json") - -@login_required -def import_participants(request): - return HttpResponse("Placeholder for import_participants") - -@login_required -def import_participants_map_fields(request): - return HttpResponse("Placeholder for import_participants_map_fields") - -@login_required -def process_participants_import(request): - return HttpResponse("Placeholder for process_participants_import") - -@login_required -def match_participants(request): - return HttpResponse("Placeholder for match_participants") - -@login_required -def interest_add(request): - return HttpResponse("Placeholder for interest_add") - -@login_required -def interest_delete(request): - return HttpResponse("Placeholder for interest_delete") - -@login_required -def volunteer_add(request): - return HttpResponse("Placeholder for volunteer_add") - -@login_required -def volunteer_assign_event(request, volunteer_pk): - return HttpResponse("Placeholder for volunteer_assign_event") - -@login_required -def volunteer_remove_event(request, volunteer_pk, event_pk): - return HttpResponse("Placeholder for volunteer_remove_event") - -@login_required -def volunteer_search_json(request): - return HttpResponse("Placeholder for volunteer_search_json") - -@login_required -def bulk_send_sms(request): - return HttpResponse("Placeholder for bulk_send_sms") - -@login_required -def create_interaction_for_voter(request, voter_pk): - return HttpResponse("Placeholder for create_interaction_for_voter") - -@login_required -def complete_call(request, call_pk): - return HttpResponse("Placeholder for complete_call") - -@login_required -def door_visits(request): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - # Get voters for the current tenant that have door_visit set to True - door_to_door_voters = Voter.objects.filter(tenant=tenant, door_visit=True) - - # Dictionary to store visited households with the latest visit date - visited_households = {} - - for voter in door_to_door_voters: - # Construct a unique key for the household (e.g., street address and city) - household_key = f"{voter.address_street.lower().strip()}-{voter.city.lower().strip()}" - - # Find the latest interaction for this voter - latest_interaction = Interaction.objects.filter(voter=voter).order_by('-date').first() - - # Update visited_households with the latest interaction date for the household - if household_key not in visited_households or (latest_interaction and latest_interaction.date > visited_households[household_key]['last_visit_date']): - visited_households[household_key] = { - 'voter': voter, - 'last_visit_date': latest_interaction.date if latest_interaction else None, - 'voters_in_household': [] - } + form = VolunteerForm(tenant=tenant) - # Ensure 'last_visit_date' is always present in the dictionary for comparison - if 'last_visit_date' not in visited_households[household_key]: - visited_households[household_key]['last_visit_date'] = None - - visited_households[household_key]['voters_in_household'].append(voter) - - # Sort households by the last visit date, with None dates appearing last - sorted_households = sorted(visited_households.values(), key=lambda x: x['last_visit_date'] if x['last_visit_date'] is not None else datetime.min.replace(tzinfo=pytz.UTC), reverse=True) - - # Render the door_visits.html template with the sorted household data - return render(request, 'core/door_visits.html', {'households': sorted_households}) - -@login_required -def door_visit_history(request, voter_pk): - tenant = get_current_tenant(request) - if not tenant: - return redirect('admin:index') - - voter = get_object_or_404(Voter, pk=voter_pk, tenant=tenant) - interactions = Interaction.objects.filter(voter=voter).order_by('-date') context = { - 'voter': voter, - 'interactions': interactions + 'form': form, + 'tenant': tenant, + 'selected_tenant': tenant, + 'is_create': True, } - return render(request, 'core/door_visit_history.html', context) + return render(request, 'core/volunteer_detail.html', context) -@login_required -def voter_advanced_search(request): - return HttpResponse("Placeholder for voter_advanced_search") +@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) + -@login_required -def volunteer_event_create(request, event_id): - return HttpResponse("Placeholder for volunteer_event_create") + context = { + 'volunteer': volunteer, + 'form': form, + 'assignments': assignments, + 'assign_form': assign_form, + 'tenant': tenant, + 'selected_tenant': tenant, + } + return render(request, 'core/volunteer_detail.html', context) -@login_required -def volunteer_event_delete(request, pk): - return HttpResponse("Placeholder for volunteer_event_delete") +@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) -@login_required +@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'] + + headers, data_rows = _handle_uploaded_file(uploaded_file) + + if headers and data_rows: + # Store data in session for the mapping step + request.session['imported_participants_data'] = { + 'event_id': event.id, + 'headers': headers, + 'data_rows': data_rows, + '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 data from the uploaded file. Please ensure it's a valid CSV/Excel.") + 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'] + data_rows = imported_data['data_rows'] + + # 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) + + 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 = Voter.objects.filter(tenant=tenant, email__iexact=email).first() + + 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 not EventParticipation.objects.filter(event=event, voter=voter).exists(): + 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 + }) + + 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) + + 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. + """ + 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) + settings = getattr(tenant, 'settings', None) + if not settings: + messages.error(request, "Campaign settings not found.") + return redirect('volunteer_list') + + account_sid = settings.twilio_account_sid + auth_token = settings.twilio_auth_token + from_number = settings.twilio_from_number + + if not account_sid or not auth_token or not from_number: + messages.error(request, "Twilio configuration is incomplete in Campaign Settings.") + return redirect('volunteer_list') + + volunteer_ids = request.POST.getlist('selected_volunteers') + message_body = request.POST.get('message_body') + + if not message_body: + messages.error(request, "Message body cannot be empty.") + return redirect('volunteer_list') + + volunteers = Volunteer.objects.filter(tenant=tenant, id__in=volunteer_ids).exclude(phone='') + + if not volunteers.exists(): + messages.warning(request, "No volunteers with a valid phone number were selected.") + return redirect('volunteer_list') + + success_count = 0 + fail_count = 0 + + auth_str = f"{account_sid}:{auth_token}" + auth_header = base64.b64encode(auth_str.encode()).decode() + url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json" + + for volunteer in volunteers: + # Format phone to E.164 (assume US +1) + digits = re.sub(r'\\D', '', str(volunteer.phone)) + if len(digits) == 10: + to_number = f"+1{digits}" + elif len(digits) == 11 and digits.startswith('1'): + to_number = f"+{digits}" + else: + # Skip invalid phone numbers + fail_count += 1 + continue + + data_dict = { + 'To': to_number, + 'From': from_number, + 'Body': message_body + } + data = urllib.parse.urlencode(data_dict).encode() + + req = urllib.request.Request(url, data=data, method='POST') + req.add_header("Authorization", f"Basic {auth_header}") + + try: + with urllib.request.urlopen(req, timeout=10) as response: + if response.status in [200, 201]: + success_count += 1 + else: + fail_count += 1 + except Exception as e: + logger.error(f"Error sending SMS to volunteer {volunteer.phone}: {e}") + fail_count += 1 + + messages.success(request, f"Bulk SMS process completed: {success_count} successful, {fail_count} failed/skipped.") + return redirect('volunteer_list') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def door_visits(request): + """ + Manage door knocking visits. Groups unvisited targeted voters by household. + """ + 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) + + # Filters from GET parameters + district_filter = request.GET.get('district', '').strip() + neighborhood_filter = request.GET.get('neighborhood', '').strip() + address_filter = request.GET.get('address', '').strip() + + # Initial queryset: unvisited targeted voters for this tenant + voters = Voter.objects.filter(tenant=tenant, door_visit=False, is_targeted=True) + + # Apply filters if provided + 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)) + + # Grouping by household (unique address) + households_dict = {} + for voter in voters: + # Key for grouping is the unique address components + key = (voter.address_street, voter.city, voter.state, voter.zip_code) + if key not in households_dict: + # Parse street name and number for sorting + street_number = "" + street_name = voter.address_street + match = re.match(r'^(\d+)\s+(.*)$', voter.address_street) + 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 + + households_dict[key] = { + 'address_street': voter.address_street, + 'city': voter.city, + 'state': voter.state, + 'zip_code': voter.zip_code, + 'neighborhood': voter.neighborhood, + 'district': voter.district, + '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, + 'target_voters': [], + 'voters_json': [] + } + households_dict[key]['target_voters'].append(voter) + households_dict[key]['voters_json'].append({'id': voter.id, 'name': f"{voter.first_name} {voter.last_name}"}) + + households_list = list(households_dict.values()) + for h in households_list: + h['voters_json_str'] = json.dumps(h['voters_json']) + + households_list.sort(key=lambda x: ( + (x['neighborhood'] or '').lower(), + x['street_name_sort'], + x['street_number_sort'] + )) + + # Prepare data for Google Map (all filtered households with coordinates) + map_data = [ + { + 'lat': h['latitude'], + 'lng': h['longitude'], + 'address': f"{h['address_street']}, {h['city']}, {h['state']}", + 'voters': ", ".join([f"{v.first_name} {v.last_name}" for v in h['target_voters']]) + } + for h in households_list 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, + 'map_data_json': json.dumps(map_data), + 'google_maps_api_key': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''), + 'visit_form': DoorVisitLogForm(), + } + return render(request, 'core/door_visits.html', context) + +@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. + """ + 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", "") + redirect_url = reverse("door_visits") + if next_qs: + redirect_url += f"?{next_qs}" + + # 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) + if form.is_valid(): + address_street = request.POST.get("address_street") + city = request.POST.get("city") + state = request.POST.get("state") + zip_code = request.POST.get("zip_code") + + outcome = form.cleaned_data["outcome"] + notes = form.cleaned_data["notes"] + wants_yard_sign = form.cleaned_data["wants_yard_sign"] + 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") + + # Find targeted voters at this exact address + voters = Voter.objects.filter( + tenant=tenant, + address_street=address_street, + city=city, + state=state, + zip_code=zip_code, + is_targeted=True + ) + + if not voters.exists(): + messages.warning(request, f"No targeted voters found at {address_street}.") + return redirect(redirect_url) + + # 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) If "Wants a Yard Sign" checkbox is selected + if wants_yard_sign: + voter.yard_sign = "wants" + + # 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}.") + else: + messages.error(request, "There was an error in the visit log form.") + + return redirect(redirect_url) + +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 + # Grouping by household (unique address) + visited_households = {} + volunteer_counts = {} + + 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: + # Calculate volunteer summary - only once per household + v_obj = interaction.volunteer + v_name = f"{v_obj.first_name} {v_obj.last_name}".strip() or v_obj.email if v_obj else "N/A" + volunteer_counts[v_name] = volunteer_counts.get(v_name, 0) + 1 + + visited_households[key] = { + '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, + 'target_voters': [], + 'voters_json': [] + } + + visited_households[key]["voters_json"].append({'id': v.id, 'name': f"{v.first_name} {v.last_name}"}) + visited_households[key]['target_voters'].append(v) + + # Sort volunteer counts by total (descending) + sorted_volunteer_counts = sorted(volunteer_counts.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, + } + 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')) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_scheduledcall') def call_queue(request): - return HttpResponse("Placeholder for call_queue") + 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) + calls = ScheduledCall.objects.filter(tenant=tenant, status='pending').order_by('created_at') + + paginator = Paginator(calls, 50) + page_number = request.GET.get('page') + calls_page = paginator.get_page(page_number) + + context = { + 'selected_tenant': tenant, + 'calls': calls_page, + } + return render(request, 'core/call_queue.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_scheduledcall') +def complete_call(request, call_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) + 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', '') + + # 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="Called Voter", + notes=call_notes + ) + + call.status = 'completed'; + call.save() + messages.success(request, f"Call for {call.voter} marked as completed and interaction logged.") + + return redirect('call_queue') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.delete_scheduledcall') +def delete_call(request, call_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) + 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): - return HttpResponse("Placeholder for profile") + try: + volunteer = request.user.volunteer_profile + except: + volunteer = None -@login_required -def volunteer_bulk_send_sms(request): - return HttpResponse("Placeholder for volunteer_bulk_send_sms") \ No newline at end of file + if request.method == 'POST': + u_form = UserUpdateForm(request.POST, instance=request.user)