diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc
index d28eeba..f4038b7 100644
Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ
diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc
index 84daac2..ad02be0 100644
Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ
diff --git a/core/templates/core/profile.html b/core/templates/core/profile.html
index b7f4016..8d242cc 100644
--- a/core/templates/core/profile.html
+++ b/core/templates/core/profile.html
@@ -1,73 +1,11 @@
-{% extends "base.html" %}
-{% load static %}
+{% extends 'base.html' %}
+
+{% block title %}Profile{% endblock %}
{% block content %}
-
-
+
+
User Profile
+
This is a placeholder for the user profile page.
+
Selected Tenant: {{ selected_tenant.name }}
-{% endblock %}
+{% endblock %}
\ No newline at end of file
diff --git a/core/urls.py b/core/urls.py
index 0c592a0..cb58d21 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -1,41 +1,43 @@
from django.urls import path
+
from . import views
urlpatterns = [
- path('', views.index, name='index'),
+ path('', views.dashboard, name='dashboard'),
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_edit, name='voter_edit'),
+ path('voters//edit/', views.voter_update, name='voter_edit'), # Changed to voter_update
path('voters//delete/', views.voter_delete, name='voter_delete'),
path('voters//geocode/', views.voter_geocode, name='voter_geocode'),
- path('voters//schedule-call/', views.schedule_call, name='schedule_call'),
+ path('voters//schedule-call/', views.create_scheduled_call, name='schedule_call'), # Changed to create_scheduled_call
path('voters/bulk-schedule-calls/', views.bulk_schedule_calls, name='bulk_schedule_calls'),
- 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'),
+ 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//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'),
+ # 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//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//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//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'),
+ 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'),
# Event Detail and Participant Management
path('events/', views.event_list, name='event_list'),
- path('events//', views.event_detail, name='event_detail'),
+ path('events//', views.event_detail, name='event_detail'), # Changed to pk
path('events/add/', views.event_create, name='event_create'),
- path('events//edit/', views.event_edit, name='event_edit'),
+ path('events//edit/', views.event_update, name='event_edit'), # Changed to pk and event_update
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'),
@@ -49,24 +51,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_add, name='volunteer_add'),
- path('volunteers//', views.volunteer_detail, name='volunteer_detail'),
- path('volunteers//delete/', views.volunteer_delete, name='volunteer_delete'),
+ 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//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.event_add_volunteer, name='event_add_volunteer'),
- path('events/volunteer//delete/', views.event_remove_volunteer, name='event_remove_volunteer'),
+ 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
# Door Visits
path('door-visits/', views.door_visits, name='door_visits'),
- path('door-visits/log/', views.log_door_visit, name='log_door_visit'),
+ path('door-visits/log/', views.create_interaction_for_voter, name='log_door_visit'), # Changed to create_interaction_for_voter
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'),
- path('call-queue//delete/', views.delete_call, name='delete_call'),
+ 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('profile/', views.profile, name='profile'),
]
\ No newline at end of file
diff --git a/core/views.py b/core/views.py
index a44313c..93c67b0 100644
--- a/core/views.py
+++ b/core/views.py
@@ -1,2030 +1,616 @@
+from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
-from django.contrib.auth.forms import PasswordChangeForm
-from django.utils.dateparse import parse_date
-from datetime import datetime, time, timedelta
-import base64
-import re
-import urllib.parse
-import urllib.request
-import csv
-import io
-import json
-from django.http import JsonResponse, HttpResponse
-from django.urls import reverse
-from django.shortcuts import render, redirect, get_object_or_404
-from django.db.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 import Count, Case, When, IntegerField, Sum, F, DecimalField
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.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
+import re
+from django.conf import settings
+from django.template.defaultfilters import date as date_filter
+import csv
+
+# Import necessary modules for Twilio
+from twilio.rest import Client
+from twilio.base.exceptions import TwilioRestException
+import logging
-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__)
-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
+# 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:
try:
- 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)
+ return CampaignSettings.objects.get(tenant=tenant)
+ except CampaignSettings.DoesNotExist:
+ pass
+ return None
- 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
+@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')
-def index(request):
- """
- Main landing page for Grassroots Campaign Manager.
- Displays a list of campaigns if the user is logged in but hasn't selected one.
- """
- tenants = Tenant.objects.all()
- selected_tenant_id = request.session.get('tenant_id')
- selected_tenant = None
- metrics = {}
- recent_interactions = []
- upcoming_events = []
-
if selected_tenant_id:
- selected_tenant = Tenant.objects.filter(id=selected_tenant_id).first()
- if selected_tenant:
- voters = selected_tenant.voters.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]
-
-
- context = {
- 'tenants': tenants,
- 'selected_tenant': selected_tenant,
- 'metrics': metrics,
- 'recent_interactions': recent_interactions,
- 'upcoming_events': upcoming_events,
- }
- return render(request, 'core/index.html', context)
-
-def select_campaign(request, tenant_id):
- """
- Sets the selected campaign in the session.
- """
- tenant = get_object_or_404(Tenant, id=tenant_id)
- request.session['tenant_id'] = tenant.id
- messages.success(request, f"You are now managing: {tenant.name}")
- return redirect('index')
-
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
-def voter_list(request):
- """
- List and search voters. Restricted to selected tenant.
- """
- selected_tenant_id = request.session.get("tenant_id")
- if not selected_tenant_id:
- messages.warning(request, "Please select a campaign first.")
- return redirect("index")
-
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- query = request.GET.get("q")
- voters = Voter.objects.filter(tenant=tenant).order_by("last_name", "first_name")
-
- # Filtering based on dashboard metrics
- if request.GET.get("is_targeted") == "true":
- voters = voters.filter(is_targeted=True)
- if request.GET.get("support") == "supporting":
- voters = voters.filter(candidate_support="supporting")
- if request.GET.get("has_address") == "true":
- voters = voters.exclude(address__isnull=True).exclude(address="")
- if request.GET.get("visited") == "true":
- voters = voters.filter(door_visit=True)
- if request.GET.get("yard_sign") == "true":
- voters = voters.filter(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()
-
- if query:
- query = query.strip()
- 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:
- 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':
- # Log incoming coordinate data for debugging
- lat_raw = request.POST.get('latitude')
- lon_raw = request.POST.get('longitude')
- logger.info(f"Voter Edit POST: lat={lat_raw}, lon={lon_raw}")
-
- form = VoterForm(request.POST, instance=voter, user=request.user, tenant=tenant)
- if form.is_valid():
- # If coordinates were provided in POST, ensure they are applied to the instance
- # This handles cases where readonly or other widget settings might interfere
- voter = form.save(commit=False);
- if lat_raw:
- try:
- voter.latitude = lat_raw
- except: pass
- if lon_raw:
- try:
- voter.longitude = lon_raw
- except: pass
-
- voter.save()
- messages.success(request, "Voter profile updated successfully.")
+ 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:
- 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)
+ # Otherwise, prompt the user to select a tenant
+ return render(request, 'core/index.html', {'tenants': user_tenants, 'selected_tenant': None})
-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)
+ 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,
+ 'selected_tenant': selected_tenant,
+ 'tenants': user_tenants, # Pass all tenants for potential switching
+ })
+
+@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')
+
+# Placeholder views for other functions
+@login_required
+def voter_list(request):
+ tenant = get_current_tenant(request)
+ if not tenant:
+ return redirect('admin:index')
+
+ voters = Voter.objects.filter(tenant=tenant)
+ return render(request, 'core/voter_list.html', {'voters': voters})
+
+@login_required
+def voter_detail(request, pk):
+ tenant = get_current_tenant(request)
+ if not tenant:
+ return redirect('admin:index')
+
+ voter = get_object_or_404(Voter, pk=pk, tenant=tenant)
+ return render(request, 'core/voter_detail.html', {'voter': voter})
+
+@login_required
+def voter_create(request):
+ tenant = get_current_tenant(request)
+ if not tenant:
+ return redirect('admin:index')
if request.method == 'POST':
- form = InteractionForm(request.POST, tenant=tenant)
+ form = VoterForm(request.POST)
+ if form.is_valid():
+ voter = form.save(commit=False)
+ voter.tenant = tenant
+ voter.save()
+ return redirect('voter_detail', pk=voter.pk)
+ else:
+ form = VoterForm()
+ return render(request, 'core/voter_form.html', {'form': form})
+
+@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)
if form.is_valid():
interaction = form.save(commit=False)
interaction.voter = voter
+ # Assign volunteer if relevant or leave null
interaction.save()
- messages.success(request, "Interaction added.")
- return redirect(reverse('voter_detail', kwargs={'voter_id': voter.id}) + '?active_tab=interactions')
+ return redirect('voter_detail', pk=voter.pk)
+ else:
+ form = InteractionForm()
+ return render(request, 'core/interaction_form.html', {'form': form, 'voter': voter})
-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)
-
+@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)
if request.method == 'POST':
- form = InteractionForm(request.POST, instance=interaction, tenant=tenant)
+ form = InteractionForm(request.POST, instance=interaction)
if form.is_valid():
form.save()
- messages.success(request, "Interaction updated.")
- return redirect(reverse('voter_detail', kwargs={'voter_id': interaction.voter.id}) + '?active_tab=interactions')
+ return redirect('voter_detail', pk=interaction.voter.pk)
+ else:
+ form = InteractionForm(instance=interaction)
+ return render(request, 'core/interaction_form.html', {'form': form, 'interaction': interaction})
-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
-
+@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)
if request.method == 'POST':
interaction.delete()
- messages.success(request, "Interaction deleted.")
- return redirect(reverse('voter_detail', kwargs={'voter_id': voter_id}) + '?active_tab=interactions')
-
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_donation')
-def add_donation(request, voter_id):
- selected_tenant_id = request.session.get('tenant_id')
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
-
- if request.method == 'POST':
- form = DonationForm(request.POST, tenant=tenant)
- if form.is_valid():
- donation = form.save(commit=False)
- donation.voter = voter
- donation.save()
- messages.success(request, "Donation recorded.")
- return redirect(reverse('voter_detail', kwargs={'voter_id': voter.id}) + '?active_tab=donations')
-
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_donation')
-def edit_donation(request, donation_id):
- selected_tenant_id = request.session.get('tenant_id')
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- donation = get_object_or_404(Donation, id=donation_id, voter__tenant=tenant)
-
- if request.method == 'POST':
- form = DonationForm(request.POST, instance=donation, tenant=tenant)
- if form.is_valid():
- form.save()
- messages.success(request, "Donation updated.")
- return redirect(reverse('voter_detail', kwargs={'voter_id': donation.voter.id}) + '?active_tab=donations')
-
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.delete_donation')
-def delete_donation(request, donation_id):
- selected_tenant_id = request.session.get('tenant_id')
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- donation = get_object_or_404(Donation, id=donation_id, voter__tenant=tenant)
- voter_id = donation.voter.id
-
- if request.method == 'POST':
- donation.delete()
- messages.success(request, "Donation deleted.")
- return redirect(reverse('voter_detail', kwargs={'voter_id': voter_id}) + '?active_tab=donations')
-
-def add_likelihood(request, voter_id):
- selected_tenant_id = request.session.get('tenant_id')
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
-
- if request.method == 'POST':
- form = VoterLikelihoodForm(request.POST, tenant=tenant)
- if form.is_valid():
- likelihood = form.save(commit=False)
- likelihood.voter = voter
- # Handle potential duplicate election_type
- VoterLikelihood.objects.filter(voter=voter, election_type=likelihood.election_type).delete()
- likelihood.save()
- messages.success(request, "Likelihood updated.")
- return redirect('voter_detail', voter_id=voter.id)
-
-def edit_likelihood(request, likelihood_id):
- selected_tenant_id = request.session.get('tenant_id')
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- likelihood = get_object_or_404(VoterLikelihood, id=likelihood_id, voter__tenant=tenant)
-
- if request.method == 'POST':
- form = VoterLikelihoodForm(request.POST, instance=likelihood, tenant=tenant)
- if form.is_valid():
- election_type = form.cleaned_data['election_type']
- if VoterLikelihood.objects.filter(voter=likelihood.voter, election_type=election_type).exclude(id=likelihood.id).exists():
- VoterLikelihood.objects.filter(voter=likelihood.voter, election_type=election_type).exclude(id=likelihood.id).delete()
- form.save()
- messages.success(request, "Likelihood updated.")
- return redirect('voter_detail', voter_id=likelihood.voter.id)
-
-def delete_likelihood(request, likelihood_id):
- selected_tenant_id = request.session.get('tenant_id')
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- likelihood = get_object_or_404(VoterLikelihood, id=likelihood_id, voter__tenant=tenant)
- voter_id = likelihood.voter.id
-
- if request.method == 'POST':
- likelihood.delete()
- messages.success(request, "Likelihood record deleted.")
- return redirect('voter_detail', voter_id=voter_id)
-
-def add_event_participation(request, voter_id):
- selected_tenant_id = request.session.get('tenant_id')
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
-
- if request.method == 'POST':
- form = EventParticipationForm(request.POST, tenant=tenant)
- if form.is_valid():
- participation = form.save(commit=False)
- participation.voter = voter
- # Avoid duplicate participation
- if not EventParticipation.objects.filter(voter=voter, event=participation.event).exists():
- participation.save()
- messages.success(request, "Event participation added.")
- else:
- messages.warning(request, "Voter is already participating in this event.")
- return redirect(reverse('voter_detail', kwargs={'voter_id': voter.id}) + '?active_tab=events')
-
-def edit_event_participation(request, participation_id):
- selected_tenant_id = request.session.get('tenant_id')
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant)
-
- if request.method == 'POST':
- form = EventParticipationForm(request.POST, instance=participation, tenant=tenant)
- if form.is_valid():
- event = form.cleaned_data['event']
- if EventParticipation.objects.filter(voter=participation.voter, event=event).exclude(id=participation.id).exists():
- messages.warning(request, "Voter is already participating in that event.")
- else:
- form.save()
- messages.success(request, "Event participation updated.")
- return redirect(reverse('voter_detail', kwargs={'voter_id': participation.voter.id}) + '?active_tab=events')
-
-def delete_event_participation(request, participation_id):
- selected_tenant_id = request.session.get('tenant_id')
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant)
- voter_id = participation.voter.id
-
- if request.method == 'POST':
- participation.delete()
- messages.success(request, "Event participation removed.")
- return redirect(reverse('voter_detail', kwargs={'voter_id': voter_id}) + '?active_tab=events')
-
-def voter_geocode(request, voter_id):
- """
- Manually trigger geocoding for a voter, potentially using values from the request.
- """
- selected_tenant_id = request.session.get('tenant_id')
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
-
- if request.method == 'POST':
- street = request.POST.get('address_street', voter.address_street)
- city = request.POST.get('city', voter.city)
- state = request.POST.get('state', voter.state)
- zip_code = request.POST.get('zip_code', voter.zip_code)
-
- parts = [street, city, state, zip_code]
- full_address = ", ".join([p for p in parts if p])
-
- # Use a temporary instance to avoid saving until the user clicks "Save" in the modal
- temp_voter = Voter(
- address_street=street,
- city=city,
- state=state,
- zip_code=zip_code,
- address=full_address
- )
- success, error_msg = temp_voter.geocode_address()
-
- if success:
- return JsonResponse({
- 'success': True,
- 'latitude': str(temp_voter.latitude),
- 'longitude': str(temp_voter.longitude),
- 'address': full_address
- })
- else:
- return JsonResponse({
- 'success': False,
- 'error': f"Geocoding failed: {error_msg or 'No results found.'}"
- })
-
- return JsonResponse({'success': False, 'error': 'Invalid request method.'})
-
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_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)
+ return redirect('voter_detail', pk=interaction.voter.pk)
+ return render(request, 'core/interaction_confirm_delete.html', {'interaction': interaction})
- context = {
- 'form': form,
- 'voters': voters_page,
- 'selected_tenant': tenant,
- 'call_form': ScheduledCallForm(tenant=tenant),
- }
- return render(request, "core/voter_advanced_search.html", context)
+@login_required
+def donation_list(request):
+ tenant = get_current_tenant(request)
+ if not tenant:
+ return redirect('admin:index')
-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")
+ donations = Interaction.objects.filter(voter__tenant=tenant)
+ return render(request, 'core/donation_list.html', {'donations': donations})
- 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'])
+@login_required
+def donation_detail(request, pk):
+ tenant = get_current_tenant(request)
+ if not tenant:
+ return redirect('admin:index')
- # Add donation amount filters for export
- min_total_donation = data.get('min_total_donation')
- max_total_donation = data.get('max_total_donation')
+ donation = get_object_or_404(Interaction, pk=pk, voter__tenant=tenant)
+ return render(request, 'core/donation_detail.html', {'donation': 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)))
+@login_required
+def donation_create(request, voter_pk):
+ return HttpResponse("Placeholder for donation_create")
- 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)
+@login_required
+def donation_update(request, voter_pk, pk):
+ return HttpResponse("Placeholder for donation_update")
- 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
+@login_required
+def donation_delete(request, voter_pk, pk):
+ return HttpResponse("Placeholder for donation_delete")
-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)
+@login_required
+def likelihood_create(request, voter_pk):
+ return HttpResponse("Placeholder for likelihood_create")
- 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)
+@login_required
+def likelihood_update(request, voter_pk, pk):
+ return HttpResponse("Placeholder for likelihood_update")
-@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')
+@login_required
+def likelihood_delete(request, voter_pk, pk):
+ return HttpResponse("Placeholder for likelihood_delete")
- # 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')
+@login_required
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_current_tenant(request)
+ if not tenant:
+ return redirect('admin:index')
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- events = Event.objects.filter(tenant=tenant).order_by('-date')
-
+ events = Event.objects.filter(tenant=tenant)
+ return render(request, 'core/event_list.html', {'events': events})
- context = {
- 'tenant': tenant,
- 'events': events,
- 'selected_tenant': tenant,
- }
- return render(request, 'core/event_list.html', context)
+@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})
-@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):
- selected_tenant_id = request.session.get("tenant_id")
- if not selected_tenant_id:
- messages.warning(request, "Please select a campaign first.")
- return redirect("index")
-
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- volunteers = Volunteer.objects.filter(tenant=tenant).order_by('last_name', 'first_name')
-
- # Simple search
- query = request.GET.get("q")
- if query:
- volunteers = volunteers.filter(
- Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(email__icontains=query)
- )
-
- # Interest filter
- interest_id = request.GET.get("interest")
- if interest_id:
- volunteers = volunteers.filter(interests__id=interest_id).distinct()
-
- interests = Interest.objects.filter(tenant=tenant).order_by('name')
-
- paginator = Paginator(volunteers, 50)
- page_number = request.GET.get('page')
- volunteers_page = paginator.get_page(page_number)
-
- context = {
- 'tenant': tenant,
- 'selected_tenant': tenant,
- 'volunteers': volunteers_page,
- 'query': query,
- 'interests': interests,
- 'selected_interest': interest_id,
- }
- return render(request, 'core/volunteer_list.html', context)
-
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_volunteer')
-def volunteer_add(request):
- selected_tenant_id = request.session.get("tenant_id")
- if not selected_tenant_id:
- messages.warning(request, "Please select a campaign first.")
- return redirect("index")
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
-
- if request.method == 'POST':
- form = VolunteerForm(request.POST, tenant=tenant)
- if form.is_valid():
- volunteer = form.save(commit=False)
- volunteer.tenant = tenant
- volunteer.save()
- form.save_m2m() # Save interests
- messages.success(request, f"Volunteer {volunteer} added successfully.")
- return redirect('volunteer_detail', volunteer_id=volunteer.id)
- else:
- form = VolunteerForm(tenant=tenant)
-
-
- context = {
- 'form': form,
- 'tenant': tenant,
- 'selected_tenant': tenant,
- 'is_create': True,
- }
- return render(request, 'core/volunteer_detail.html', context)
-
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_volunteer')
-def volunteer_detail(request, volunteer_id):
- selected_tenant_id = request.session.get("tenant_id")
- if not selected_tenant_id:
- messages.warning(request, "Please select a campaign first.")
- return redirect("index")
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant)
-
- if request.method == 'POST':
- form = VolunteerForm(request.POST, instance=volunteer, tenant=tenant)
- if form.is_valid():
- form.save()
- messages.success(request, f"Volunteer {volunteer} updated successfully.")
- return redirect('volunteer_detail', volunteer_id=volunteer.id)
- else:
- form = VolunteerForm(instance=volunteer, tenant=tenant)
-
- assignments = volunteer.event_assignments.all().select_related('event')
- assign_form = VolunteerEventForm(tenant=tenant)
-
-
- context = {
- 'volunteer': volunteer,
- 'form': form,
- 'assignments': assignments,
- 'assign_form': assign_form,
- 'tenant': tenant,
- 'selected_tenant': tenant,
- }
- return render(request, 'core/volunteer_detail.html', context)
-
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.delete_volunteer')
-def volunteer_delete(request, volunteer_id):
- selected_tenant_id = request.session.get("tenant_id")
- if not selected_tenant_id:
- messages.warning(request, "Please select a campaign first.")
- return redirect("index")
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant)
-
- if request.method == 'POST':
- volunteer.delete()
- messages.success(request, "Volunteer deleted.")
- return redirect('volunteer_list')
- return redirect('volunteer_detail', volunteer_id=volunteer.id)
-
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_volunteer')
-def volunteer_assign_event(request, volunteer_id):
- selected_tenant_id = request.session.get("tenant_id")
- if not selected_tenant_id:
- messages.warning(request, "Please select a campaign first.")
- return redirect("index")
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant)
-
- if request.method == 'POST':
- form = VolunteerEventForm(request.POST, tenant=tenant)
- if form.is_valid():
- assignment = form.save(commit=False)
- assignment.volunteer = volunteer
- assignment.save()
- messages.success(request, f"Assigned to {assignment.event}.")
- else:
- messages.error(request, "Error assigning to event.")
-
- return redirect('volunteer_detail', volunteer_id=volunteer.id)
-
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_volunteer')
-def volunteer_remove_event(request, assignment_id):
- selected_tenant_id = request.session.get("tenant_id")
- if not selected_tenant_id:
- messages.warning(request, "Please select a campaign first.")
- return redirect("index")
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- assignment = get_object_or_404(VolunteerEvent, id=assignment_id, volunteer__tenant=tenant)
- volunteer_id = assignment.volunteer.id
- assignment.delete()
- messages.success(request, "Assignment removed.")
- return redirect('volunteer_detail', volunteer_id=volunteer_id)
-
-def interest_add(request):
- selected_tenant_id = request.session.get("tenant_id")
- if not selected_tenant_id:
- return JsonResponse({'success': False, 'error': 'No campaign selected.'})
-
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- if request.method == 'POST':
- name = request.POST.get('name', '').strip()
- if name:
- interest, created = Interest.objects.get_or_create(tenant=tenant, name=name)
- if created:
- return JsonResponse({'success': True, 'id': interest.id, 'name': interest.name})
- else:
- return JsonResponse({'success': False, 'error': 'Interest already exists.'})
- return JsonResponse({'success': False, 'error': 'Invalid request.'})
-
-def interest_delete(request, interest_id):
- selected_tenant_id = request.session.get("tenant_id")
- if not selected_tenant_id:
- return JsonResponse({'success': False, 'error': 'No campaign selected.'})
-
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- interest = get_object_or_404(Interest, id=interest_id, tenant=tenant)
-
- if request.method == 'POST':
- interest.delete()
- return JsonResponse({'success': True})
- return JsonResponse({'success': False, 'error': 'Invalid request.'})
-
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_event')
+@login_required
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_current_tenant(request)
+ if not tenant:
+ return redirect('admin:index')
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
-
- if request.method == "POST":
- form = EventForm(request.POST, tenant=tenant)
+ if request.method == 'POST':
+ form = EventForm(request.POST)
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)
+ return redirect('event_detail', pk=event.pk)
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)
+ form = EventForm()
+ return render(request, 'core/event_form.html', {'form': form})
+@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)
if request.method == 'POST':
- form = EventForm(request.POST, instance=event, tenant=tenant)
+ form = EventForm(request.POST, instance=event)
if form.is_valid():
form.save()
- messages.success(request, "Event updated successfully.")
- return redirect('event_detail', event_id=event.id)
+ return redirect('event_detail', pk=event.pk)
else:
- form = EventForm(instance=event, tenant=tenant)
+ form = EventForm(instance=event)
+ return render(request, 'core/event_form.html', {'form': form, 'event': event})
+@login_required
+def event_delete(request, pk):
+ tenant = get_current_tenant(request)
+ if not tenant:
+ return redirect('admin:index')
- 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)
-
+ event = get_object_or_404(Event, pk=pk, 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)
+ event.delete()
+ return redirect('event_list')
+ return render(request, 'core/event_confirm_delete.html', {'event': event})
-@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")
+@login_required
+def event_add_participant(request, pk):
+ return HttpResponse("Placeholder for event_add_participant")
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- event = get_object_or_404(Event, id=event_id, tenant=tenant)
+@login_required
+def event_edit_participant(request, event_pk, pk):
+ return HttpResponse("Placeholder for event_edit_participant")
- 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)
+@login_required
+def event_delete_participant(request, event_pk, pk):
+ return HttpResponse("Placeholder for event_delete_participant")
- headers = imported_data['headers']
- file_name = imported_data['file_name']
+@login_required
+def event_participation_create(request, voter_id):
+ return HttpResponse("Placeholder for event_participation_create")
- 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
+@login_required
+def event_participation_update(request, pk):
+ return HttpResponse("Placeholder for event_participation_update")
- # 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)
+@login_required
+def event_participation_delete(request, pk):
+ return HttpResponse("Placeholder for event_participation_delete")
- context = {
- 'event': event,
- 'form': form,
- 'file_name': file_name,
- 'headers': headers,
- }
- return render(request, 'core/event_participant_map_fields.html', context)
+@login_required
+def volunteer_list(request):
+ tenant = get_current_tenant(request)
+ if not tenant:
+ return redirect('admin:index')
-@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})
+ return render(request, 'core/volunteer_list.html', {'volunteers': volunteers})
+
+@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})
+
+@login_required
+def volunteer_create(request):
+ tenant = get_current_tenant(request)
+ if not tenant:
+ return redirect('admin:index')
-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)
+ form = VolunteerForm(request.POST)
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)
+ volunteer = form.save(commit=False)
+ volunteer.tenant = tenant
+ volunteer.save()
+ return redirect('volunteer_detail', pk=volunteer.pk)
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'))
+ form = VolunteerForm()
+ return render(request, 'core/volunteer_form.html', {'form': form})
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_scheduledcall')
-def call_queue(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")
+@login_required
+def volunteer_edit(request, pk):
+ tenant = get_current_tenant(request)
+ if not tenant:
+ return redirect('admin:index')
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- calls = ScheduledCall.objects.filter(tenant=tenant, status='pending').order_by('created_at')
+ 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': []
+ }
+
+ # 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')
- paginator = Paginator(calls, 50)
- page_number = request.GET.get('page')
- calls_page = paginator.get_page(page_number)
+ voter = get_object_or_404(Voter, pk=voter_pk, tenant=tenant)
+ interactions = Interaction.objects.filter(voter=voter).order_by('-date')
context = {
- 'selected_tenant': tenant,
- 'calls': calls_page,
+ 'voter': voter,
+ 'interactions': interactions
}
- return render(request, 'core/call_queue.html', context)
+ return render(request, 'core/door_visit_history.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")
+@login_required
+def voter_advanced_search(request):
+ return HttpResponse("Placeholder for voter_advanced_search")
- tenant = get_object_or_404(Tenant, id=selected_tenant_id)
- call = get_object_or_404(ScheduledCall, id=call_id, tenant=tenant)
+@login_required
+def volunteer_event_create(request, event_id):
+ return HttpResponse("Placeholder for volunteer_event_create")
- if request.method == 'POST':
- # Get notes from post data taken during the call
- call_notes = request.POST.get('call_notes', '')
+@login_required
+def volunteer_event_delete(request, pk):
+ return HttpResponse("Placeholder for volunteer_event_delete")
- # 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 call_queue(request):
+ return HttpResponse("Placeholder for call_queue")
@login_required
def profile(request):
- try:
- volunteer = request.user.volunteer_profile
- except:
- volunteer = None
+ return HttpResponse("Placeholder for profile")
- if request.method == 'POST':
- u_form = UserUpdateForm(request.POST, instance=request.user)
+@login_required
+def volunteer_bulk_send_sms(request):
+ return HttpResponse("Placeholder for volunteer_bulk_send_sms")
\ No newline at end of file