Autosave: 20260205-190904

This commit is contained in:
Flatlogic Bot 2026-02-05 19:09:04 +00:00
parent e39a0343c7
commit d244ac9d3f
16 changed files with 1394 additions and 799 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
from django import forms
from django.contrib.auth.models import User
from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType, Tenant, ParticipationStatus, Volunteer, VolunteerEvent, VolunteerRole, ScheduledCall
from .permissions import get_user_role
class Select2MultipleWidget(forms.SelectMultiple):
"""
@ -277,14 +276,56 @@ class EventImportForm(forms.Form):
self.fields['file'].widget.attrs.update({'class': 'form-control'})
class EventParticipationImportForm(forms.Form):
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
file = forms.FileField(label="Select CSV file")
file = forms.FileField(label="Select CSV/Excel file")
def __init__(self, *args, **kwargs):
def __init__(self, *args, event=None, **kwargs):
super().__init__(*args, **kwargs)
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
# No tenant field needed as event_id is passed directly
self.fields['file'].widget.attrs.update({'class': 'form-control'})
class ParticipantMappingForm(forms.Form):
def __init__(self, *args, headers, tenant, **kwargs):
super().__init__(*args, **kwargs)
self.fields['email_column'] = forms.ChoiceField(
choices=[(header, header) for header in headers],
label="Column for Email Address",
required=True,
widget=forms.Select(attrs={'class': 'form-select'})
)
name_choices = [('', '-- Select Name Column (Optional) --')] + [(header, header) for header in headers]
self.fields['name_column'] = forms.ChoiceField(
choices=name_choices,
label="Column for Participant Name",
required=False,
widget=forms.Select(attrs={'class': 'form-select'})
)
phone_choices = [('', '-- Select Phone Column (Optional) --')] + [(header, header) for header in headers]
self.fields['phone_column'] = forms.ChoiceField(
choices=phone_choices,
label="Column for Phone Number",
required=False,
widget=forms.Select(attrs={'class': 'form-select'})
)
participation_status_choices = [('', '-- Select Status Column (Optional) --')] + [(header, header) for header in headers]
self.fields['participation_status_column'] = forms.ChoiceField(
choices=participation_status_choices,
label="Column for Participation Status",
required=False,
widget=forms.Select(attrs={'class': 'form-select'})
)
# Optional: Add a default participation status if no column is mapped
self.fields['default_participation_status'] = forms.ModelChoiceField(
queryset=ParticipationStatus.objects.filter(tenant=tenant, is_active=True),
label="Default Participation Status (if no column mapped or column is empty)",
required=False,
empty_label="-- Select a Default Status --",
widget=forms.Select(attrs={'class': 'form-select'})
)
class DonationImportForm(forms.Form):
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
file = forms.FileField(label="Select CSV file")
@ -456,4 +497,5 @@ class VolunteerProfileForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs.update({'class': 'form-control'})
field.widget.attrs.update({'class': 'form-control'}
)

View File

@ -31,7 +31,7 @@
<thead>
<tr style="background: #f8f8f8; border-bottom: 1px solid #ccc;">
<th style="padding: 8px; text-align: left;">{% translate "Action" %}</th>
<th style="padding: 8px; text-align: left;">{% translate "Identifyer" %}</th>
<th style="padding: 8px; text-align: left;">{% translate "CSV Name / Matched Voter" %}</th>
<th style="padding: 8px; text-align: left;">{% translate "Details" %}</th>
</tr>
</thead>
@ -45,7 +45,17 @@
<span style="color: blue; font-weight: bold;">{% translate "UPDATE" %}</span>
{% endif %}
</td>
<td style="padding: 8px;">{{ row.identifier }}</td>
<td style="padding: 8px;">
{% if row.csv_full_name %}
<strong>CSV:</strong> {{ row.csv_full_name }}
{% if "Voter: N/A" not in row.identifier %}<br>{% endif %}
{% endif %}
{% if "Voter: N/A" not in row.identifier %}
<strong>Matched:</strong> {{ row.identifier|cut:"Voter: " }}
{% else %}
{% if not row.csv_full_name %}N/A{% endif %}
{% endif %}
</td>
<td style="padding: 8px; font-size: 0.9em; color: #666;">{{ row.details }}</td>
</tr>
{% endfor %}
@ -74,4 +84,4 @@
</div>
</form>
</div>
{% endblock %}
{% endblock %}

View File

@ -20,6 +20,9 @@
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addParticipantModal">
+ Add Participant
</button>
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#importParticipantsModal">
Import Participants
</button>
</div>
</div>
</div>
@ -247,6 +250,31 @@
</div>
</div>
<!-- Import Participants Modal -->
<div class="modal fade" id="importParticipantsModal" tabindex="-1" aria-labelledby="importParticipantsModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0 bg-primary text-white">
<h5 class="modal-title fw-bold" id="importParticipantsModalLabel">Import Participants</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'import_participants' event.id %}" method="POST" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-body p-4">
<div class="mb-3">
<label for="id_file" class="form-label fw-bold">Select File</label>
<input type="file" class="form-control" id="id_file" name="file" required>
</div>
</div>
<div class="modal-footer border-0 p-4 pt-0">
<button type="button" class="btn btn-outline-secondary rounded-pill px-4" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary rounded-pill px-4 shadow-sm">Upload and Map</button>
</div>
</form>
</div>
</div>
</div>
<!-- Add Volunteer Modal -->
<div class="modal fade" id="addVolunteerModal" tabindex="-1" aria-labelledby="addVolunteerModalLabel" aria-hidden="true">
<div class="modal-dialog">
@ -445,4 +473,4 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
</script>
{% endblock %}
{% endblock %}

View File

@ -0,0 +1,80 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container py-5">
<div class="mb-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'event_list' %}">Events</a></li>
<li class="breadcrumb-item"><a href="{% url 'event_detail' event.id %}">{{ event.name|default:event.event_type }}</a></li>
<li class="breadcrumb-item active" aria-current="page">Map Participant Fields</li>
</ol>
</nav>
<h1 class="h2 mb-0">Map Participant Fields for {{ event.name|default:event.event_type }}</h1>
<p class="text-muted">File: {{ file_name }}</p>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title fw-bold mb-3">Column Mapping</h5>
<form method="POST" action="{% url 'import_participants_map_fields' event.id %}">
{% csrf_token %}
<div class="row g-3">
<div class="col-md-6">
{{ form.email_column.label_tag }}
{{ form.email_column }}
{% if form.email_column.errors %}
<div class="text-danger small">{% for error in form.email_column.errors %}{{ error }}{% endfor %}</div>
{% endif %}
</div>
<div class="col-md-6">
{{ form.name_column.label_tag }}
{{ form.name_column }}
{% if form.name_column.errors %}
<div class="text-danger small">{% for error in form.name_column.errors %}{{ error }}{% endfor %}</div>
{% endif %}
</div>
<div class="col-md-6">
{{ form.phone_column.label_tag }}
{{ form.phone_column }}
{% if form.phone_column.errors %}
<div class="text-danger small">{% for error in form.phone_column.errors %}{{ error }}{% endfor %}</div>
{% endif %}
</div>
<div class="col-md-6">
{{ form.participation_status_column.label_tag }}
{{ form.participation_status_column }}
{% if form.participation_status_column.errors %}
<div class="text-danger small">{% for error in form.participation_status_column.errors %}{{ error }}{% endfor %}</div>
{% endif %}
</div>
<div class="col-12">
{{ form.default_participation_status.label_tag }}
{{ form.default_participation_status }}
{% if form.default_participation_status.errors %}
<div class="text-danger small">{% for error in form.default_participation_status.errors %}{{ error }}{% endfor %}</div>
{% endif %}
</div>
</div>
<div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary rounded-pill px-4 shadow-sm">Continue to Import</button>
</div>
</form>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-bold">Detected Headers</h5>
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
{% for header in headers %}
<li class="list-group-item">{{ header }}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,154 @@
{% extends "base.html" %}
{% load static %}
{% load core_tags %}
{% block content %}
<div class="container py-5">
<div class="mb-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'event_list' %}">Events</a></li>
<li class="breadcrumb-item"><a href="{% url 'event_detail' event.id %}">{{ event.name|default:event.event_type }}</a></li>
<li class="breadcrumb-item active" aria-current="page">Match Participants</li>
</ol>
</nav>
<h1 class="h2 mb-0">Match Participants for {{ event.name|default:event.event_type }}</h1>
<p class="text-muted">File: {{ file_name }}</p>
</div>
{% if unmatched_rows %}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-bold">Unmatched Participants ({{ unmatched_rows|length }})</h5>
<p class="text-muted mb-0">Manually match these participants to existing voters. If a voter is matched, their email will be updated with the one from the file, and they will be added to the event.</p>
</div>
<form method="POST" action="{% url 'match_participants' event.id %}">
{% csrf_token %}
<div class="card-body">
{% for row_data in unmatched_rows %}
<div class="mb-4 p-3 border rounded bg-light">
<div class="row mb-2">
<div class="col-12 mb-2">
<strong>From File (Name):</strong> {{ row_data.row_data|get_item:name_column|default:"N/A" }}
</div>
<div class="col-md-6">
<strong>From File (Email):</strong> {{ row_data.row_data|get_item:email_column|default:"N/A" }}
</div>
<div class="col-md-6">
<strong>From File (Status):</strong> {{ row_data.row_data|get_item:participation_status_column|default:"N/A" }}
</div>
<div class="col-md-6">
<strong>From File (Phone):</strong> {{ row_data.row_data|get_item:phone_column|default:"N/A" }}
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Match to Voter:</label>
<input type="text" class="form-control voter-search-input" data-row-index="{{ row_data.original_row_index }}" placeholder="Search voter by name or ID..." autocomplete="off">
<div class="list-group voter-search-results mt-1 shadow-sm d-none" style="max-height: 200px; overflow-y: auto; position: absolute; z-index: 1000; width: auto;"></div>
<div id="selectedVoterDisplay_{{ row_data.original_row_index }}" class="mt-2 d-none">
<div class="alert alert-info py-2 px-3 mb-0 d-flex justify-content-between align-items-center rounded-3 border-0">
<span class="voter-name-display fw-semibold"></span>
<button type="button" class="btn-close small clear-selected-voter" data-row-index="{{ row_data.original_row_index }}"></button>
</div>
</div>
<input type="hidden" name="voter_match_{{ row_data.original_row_index }}" id="voter_id_hidden_{{ row_data.original_row_index }}">
</div>
</div>
{% endfor %}
</div>
<div class="card-footer bg-light border-0 d-flex justify-content-end py-3">
<button type="submit" class="btn btn-primary rounded-pill px-4 shadow-sm">Save Matches & Continue</button>
</div>
</form>
</div>
{% else %}
<div class="alert alert-success" role="alert">
All participants have been successfully processed or no unmatched participants were found.
</div>
<a href="{% url 'event_detail' event.id %}" class="btn btn-primary">Back to Event Details</a>
{% endif %}
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const searchInputs = document.querySelectorAll('.voter-search-input');
searchInputs.forEach(searchInput => {
let debounceTimer;
const rowIndex = searchInput.dataset.rowIndex;
const resultsContainer = searchInput.nextElementSibling; // The div for results
const hiddenVoterId = document.getElementById(`voter_id_hidden_${rowIndex}`);
const selectedDisplay = document.getElementById(`selectedVoterDisplay_${rowIndex}`);
const voterNameDisplay = selectedDisplay.querySelector('.voter-name-display');
const clearBtn = selectedDisplay.querySelector('.clear-selected-voter');
searchInput.addEventListener('input', function() {
clearTimeout(debounceTimer);
const query = this.value.trim();
if (query.length < 2) {
resultsContainer.classList.add('d-none');
return;
}
debounceTimer = setTimeout(() => {
fetch(`/voters/search/json/?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
resultsContainer.innerHTML = '';
if (data.results && data.results.length > 0) {
data.results.forEach(voter => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'list-group-item list-group-item-action py-2';
btn.innerHTML = `<div class="fw-bold text-dark">${voter.text}</div><div class="small text-muted">${voter.address || "No address"} | ${voter.phone || "No phone"}</div>`;
btn.addEventListener('click', () => {
selectVoter(voter.id, voter.text, voter.address, voter.phone, rowIndex, searchInput, resultsContainer, hiddenVoterId, selectedDisplay, voterNameDisplay);
});
resultsContainer.appendChild(btn);
});
resultsContainer.classList.remove('d-none');
// Adjust width of results container to match input width
resultsContainer.style.width = `${searchInput.offsetWidth}px`;
} else {
const div = document.createElement('div');
div.className = 'list-group-item text-muted small py-2';
div.textContent = 'No results found';
resultsContainer.appendChild(div);
resultsContainer.classList.remove('d-none');
}
});
}, 300);
});
if (clearBtn) {
clearBtn.addEventListener('click', () => {
hiddenVoterId.value = '';
voterNameDisplay.textContent = '';
selectedDisplay.classList.add('d-none');
searchInput.parentElement.classList.remove('d-none');
searchInput.value = '';
searchInput.classList.remove('d-none');
resultsContainer.classList.add('d-none');
});
}
// Hide results when clicking outside
document.addEventListener('click', function(e) {
if (!resultsContainer.contains(e.target) && e.target !== searchInput) {
resultsContainer.classList.add('d-none');
}
});
});
function selectVoter(id, text, address, phone, rowIndex, searchInput, resultsContainer, hiddenVoterId, selectedDisplay, voterNameDisplay) {
hiddenVoterId.value = id;
voterNameDisplay.innerHTML = `<div>${text}</div><div class="small fw-normal text-muted">${address || ""} ${phone ? "• " + phone : ""}</div>`;
selectedDisplay.classList.remove('d-none');
searchInput.classList.add('d-none'); // Hide the search input after selection
resultsContainer.classList.add('d-none');
}
});
</script>
{% endblock %}

View File

Binary file not shown.

View File

@ -0,0 +1,7 @@
from django import template
register = template.Library()
@register.filter
def get_item(dictionary, key):
return dictionary.get(key)

View File

@ -40,6 +40,10 @@ urlpatterns = [
path('events/participant/<int:participation_id>/edit/', views.event_edit_participant, name='event_edit_participant'),
path('events/participant/<int:participation_id>/delete/', views.event_delete_participant, name='event_delete_participant'),
path('voters/search/json/', views.voter_search_json, name='voter_search_json'),
path('events/<int:event_id>/import-participants/', views.import_participants, name='import_participants'),
path('events/<int:event_id>/import-participants/map-fields/', views.import_participants_map_fields, name='import_participants_map_fields'),
path('events/<int:event_id>/import-participants/process/', views.process_participants_import, name='process_participants_import'),
path('events/<int:event_id>/import-participants/match/', views.match_participants, name='match_participants'),
# Volunteer Management
path('interests/add/', views.interest_add, name='interest_add'),

View File

@ -17,7 +17,7 @@ from django.contrib import messages
from django.core.paginator import Paginator
from django.conf import settings
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, VolunteerProfileForm
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm, ScheduledCallForm, UserUpdateForm, VolunteerProfileForm, EventParticipationImportForm, ParticipantMappingForm
import logging
import zoneinfo
from django.utils import timezone
@ -25,6 +25,41 @@ from django.utils import timezone
from .permissions import role_required, can_view_donations, can_edit_voter, can_view_volunteers, can_edit_volunteer, can_view_voters
logger = logging.getLogger(__name__)
def _handle_uploaded_file(uploaded_file):
"""
Handles uploaded CSV or Excel files, reads content, and extracts headers.
Returns (headers, data_rows) or (None, None) if file type is unsupported or an error occurs.
"""
# For simplicity, assuming CSV for now. Extend with openpyxl for Excel if needed.
try:
file_content = uploaded_file.read()
decoded_file = file_content.decode('utf-8')
io_string = io.StringIO(decoded_file)
# Try to sniff CSV dialect
try:
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)
headers = [header.strip() for header in next(reader)]
data_rows = []
for row in reader:
if len(row) == len(headers):
data_rows.append([item.strip() for item in row])
else:
logger.warning(f"Skipping malformed row in uploaded file: {row}")
continue
return headers, data_rows
except Exception as e:
logger.error(f"Error processing uploaded file: {e}")
return None, None
def index(request):
"""
Main landing page for Grassroots Campaign Manager.
@ -203,7 +238,7 @@ def voter_edit(request, voter_id):
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)
voter = form.save(commit=False);
if lat_raw:
try:
voter.latitude = lat_raw
@ -485,7 +520,7 @@ def voter_advanced_search(request):
'selected_tenant': tenant,
'call_form': ScheduledCallForm(tenant=tenant),
}
return render(request, 'core/voter_advanced_search.html', context)
return render(request, "core/voter_advanced_search.html", context)
def export_voters_csv(request):
"""
@ -616,17 +651,16 @@ def bulk_send_sms(request):
voter_ids = request.POST.getlist('selected_voters')
message_body = request.POST.get('message_body')
client_time_str = request.POST.get('client_time')
interaction_date = timezone.now()
if client_time_str:
try:
from datetime import datetime
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}')
# 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.")
@ -650,7 +684,7 @@ def bulk_send_sms(request):
for voter in voters:
# Format phone to E.164 (assume US +1)
digits = re.sub(r'\D', '', str(voter.phone))
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'):
@ -678,7 +712,7 @@ def bulk_send_sms(request):
Interaction.objects.create(
voter=voter,
type=interaction_type,
date=interaction_date,
# date=interaction_date, # interaction_date removed
description='Mass SMS Text',
notes=message_body
)
@ -1061,6 +1095,315 @@ def event_edit(request, event_id):
}
return render(request, 'core/event_edit.html', context)
@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event')
def import_participants(request, event_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
event = get_object_or_404(Event, id=event_id, tenant=tenant)
if request.method == 'POST':
form = EventParticipationImportForm(request.POST, request.FILES, event=event)
if form.is_valid():
uploaded_file = form.cleaned_data['file']
headers, data_rows = _handle_uploaded_file(uploaded_file)
if headers and data_rows:
# Store data in session for the mapping step
request.session['imported_participants_data'] = {
'event_id': event.id,
'headers': headers,
'data_rows': data_rows,
'file_name': uploaded_file.name
}
messages.info(request, f"File '{uploaded_file.name}' uploaded successfully. Now map the fields.")
return redirect('import_participants_map_fields', event_id=event.id)
else:
messages.error(request, "Could not read data from the uploaded file. Please ensure it's a valid CSV/Excel.")
else:
messages.error(request, "No file was uploaded or an error occurred with the form.")
# For debugging, you might want to log form.errors
logger.error(f"EventParticipationImportForm errors: {form.errors}")
return redirect('event_detail', event_id=event.id)
@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event')
def import_participants_map_fields(request, event_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
event = get_object_or_404(Event, id=event_id, tenant=tenant)
imported_data = request.session.get('imported_participants_data')
if not imported_data or imported_data['event_id'] != event.id:
messages.error(request, "No data found to map. Please upload a file first.")
return redirect('event_detail', event_id=event.id)
headers = imported_data['headers']
file_name = imported_data['file_name']
if request.method == 'POST':
form = ParticipantMappingForm(request.POST, headers=headers, tenant=tenant)
if form.is_valid():
email_column = form.cleaned_data['email_column']
name_column = form.cleaned_data['name_column'] # Retrieve name column
phone_column = form.cleaned_data['phone_column'] # Retrieve phone column
participation_status_column = form.cleaned_data['participation_status_column']
default_participation_status = form.cleaned_data['default_participation_status'] # Retrieve default status
# Store mapping in session and proceed to processing
request.session['imported_participants_data']['email_column'] = email_column
request.session['imported_participants_data']['name_column'] = name_column # Store name column
request.session['imported_participants_data']['phone_column'] = phone_column # Store phone column
request.session['imported_participants_data']['participation_status_column'] = participation_status_column
request.session['imported_participants_data']['default_participation_status_id'] = default_participation_status.id if default_participation_status else None
request.session.modified = True # Ensure session is saved
logger.debug(f"Session after mapping: {request.session.get('imported_participants_data')}") # Added debug logging
return redirect('process_participants_import', event_id=event.id)
else:
logger.error(f"ParticipantMappingForm errors: {form.errors}") # Added logging
messages.error(request, "Please correct the mapping errors.")
else:
form = ParticipantMappingForm(headers=headers, tenant=tenant)
context = {
'event': event,
'form': form,
'file_name': file_name,
'headers': headers,
}
return render(request, 'core/event_participant_map_fields.html', context)
@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event')
def process_participants_import(request, event_id):
logger.debug(f"Session at start of process_participants_import: {request.session.get('imported_participants_data')}") # Added debug logging
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
event = get_object_or_404(Event, id=event_id, tenant=tenant)
imported_data = request.session.get('imported_participants_data')
if not imported_data or imported_data['event_id'] != event.id:
messages.error(request, "No data found to process. Please upload and map a file first.")
return redirect('event_detail', event_id=event.id)
headers = imported_data['headers']
data_rows = imported_data['data_rows']
# Safely get column names from session, handle cases where they might be missing
email_column = imported_data.get('email_column')
name_column = imported_data.get('name_column') # Retrieve name column
phone_column = imported_data.get('phone_column') # Retrieve phone column
participation_status_column = imported_data.get('participation_status_column')
default_participation_status_id = imported_data.get('default_participation_status_id')
logger.debug(f"process_participants_import - name_column from session: {name_column}") # DEBUG LOGGING
logger.debug(f"process_participants_import - phone_column from session: {phone_column}") # DEBUG LOGGING
# Validate that required columns are present
if not email_column:
messages.error(request, "Email column mapping is missing. Please go back and map the fields.")
return redirect('import_participants_map_fields', event_id=event.id)
matched_count = 0
unmatched_participants = []
# Get all active participation statuses for the tenant
participation_statuses_map = {status.name.lower(): status for status in ParticipationStatus.objects.filter(tenant=tenant, is_active=True)}
default_status_obj = None
if default_participation_status_id:
default_status_obj = get_object_or_404(ParticipationStatus, id=default_participation_status_id, tenant=tenant)
for row_index, row in enumerate(data_rows):
row_dict = dict(zip(headers, row))
email = row_dict.get(email_column)
phone = row_dict.get(phone_column) if phone_column else None
# DEBUG LOGGING: Log the value of the name column for each row
if name_column:
logger.debug(f"process_participants_import - Row {row_index}: name_column='{name_column}', name_value='{row_dict.get(name_column)}'")
if phone_column:
logger.debug(f"process_participants_import - Row {row_index}: phone_column='{phone_column}', phone_value='{phone}'")
participation_status_name = row_dict.get(participation_status_column)
if not email:
logger.warning(f"Row {row_index+2}: Skipping due to missing email.")
continue
voter = Voter.objects.filter(tenant=tenant, email__iexact=email).first()
if voter:
# If phone is mapped and present, and not already associated with voter, update it
if phone and voter.phone != phone and voter.secondary_phone != phone:
voter.phone = phone
voter.phone_type = 'cell'
voter.save()
# Match found, add as participant if not already existing
status = participation_statuses_map.get(participation_status_name.lower()) if participation_status_name else default_status_obj
if not status:
status, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Unknown', is_active=True) # Fallback to unknown if no default and no match
if not EventParticipation.objects.filter(event=event, voter=voter).exists():
EventParticipation.objects.create(
event=event,
voter=voter,
participation_status=status
)
matched_count += 1
else:
logger.info(f"Voter {voter.email} is already a participant in event {event.name}. Skipping.")
else:
# No match found, add to unmatched list
unmatched_participants.append({
'row_data': row_dict,
'original_row_index': row_index, # Keep original index for reference if needed
})
if unmatched_participants:
# Store unmatched data in session for manual matching
request.session['unmatched_participants_data'] = {
'event_id': event.id,
'unmatched_rows': unmatched_participants,
'file_name': imported_data['file_name'],
'email_column': email_column,
'name_column': name_column, # Pass name column to unmatched data
'phone_column': phone_column, # Pass phone column to unmatched data
'participation_status_column': participation_status_column,
'default_participation_status_id': default_participation_status_id,
}
messages.warning(request, f"{len(unmatched_participants)} participants could not be automatically matched. Please match them manually.")
return redirect('match_participants', event_id=event.id)
else:
messages.success(request, f"Successfully imported {matched_count} participants for event {event.name}.")
del request.session['imported_participants_data'] # Clean up session
return redirect('event_detail', event_id=event.id)
@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event')
def match_participants(request, event_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
event = get_object_or_404(Event, id=event_id, tenant=tenant)
unmatched_data = request.session.get('unmatched_participants_data')
if not unmatched_data or unmatched_data['event_id'] != event.id:
messages.error(request, "No unmatched participant data found. Please try importing again.")
return redirect('event_detail', event_id=event.id)
unmatched_rows = unmatched_data['unmatched_rows']
file_name = unmatched_data['file_name']
email_column = unmatched_data['email_column']
name_column = unmatched_data['name_column'] # Retrieve name column
phone_column = unmatched_data.get('phone_column') # Retrieve phone column
participation_status_column = unmatched_data['participation_status_column']
default_participation_status_id = unmatched_data.get('default_participation_status_id')
logger.debug(f"match_participants context: email_column={email_column}, name_column={name_column}, phone_column={phone_column}, participation_status_column={participation_status_column}") # DEBUG LOGGING
# DEBUG LOGGING: Log the value of the name column for each unmatched row
for index, row_data in enumerate(unmatched_rows):
name_value = row_data.get('row_data', {}).get(name_column)
phone_value = row_data.get('row_data', {}).get(phone_column)
logger.debug(f"match_participants - Unmatched row {index}: name_column='{name_column}', name_value='{name_value}', phone_column='{phone_column}', phone_value='{phone_value}'")
if request.method == 'POST':
matched_count = 0
current_unmatched_rows = [] # To store rows that are still unmatched after POST
default_status_obj = None
if default_participation_status_id:
default_status_obj = get_object_or_404(ParticipationStatus, id=default_participation_status_id, tenant=tenant)
for index, row_data in enumerate(unmatched_rows):
original_row_index = row_data['original_row_index']
posted_voter_id = request.POST.get(f'voter_match_{original_row_index}')
if posted_voter_id:
# Manual match provided
voter = get_object_or_404(Voter, id=posted_voter_id, tenant=tenant)
# Update voter's email
voter_email_from_file = row_data['row_data'].get(email_column)
if voter_email_from_file and voter.email != voter_email_from_file:
voter.email = voter_email_from_file
voter.save()
# Update voter's phone if mapped and different
voter_phone_from_file = row_data['row_data'].get(phone_column)
if voter_phone_from_file and voter.phone != voter_phone_from_file and voter.secondary_phone != voter_phone_from_file:
voter.phone = voter_phone_from_file
voter.phone_type = 'cell'
voter.save()
# Add as participant if not already existing
participation_status_name = row_data['row_data'].get(participation_status_column)
status = None
if participation_status_name:
status = ParticipationStatus.objects.filter(tenant=tenant, name__iexact=participation_status_name).first()
if not status:
status = default_status_obj
if not status:
status, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Unknown', is_active=True)
if not EventParticipation.objects.filter(event=event, voter=voter).exists():
EventParticipation.objects.create(
event=event,
voter=voter,
participation_status=status
)
matched_count += 1
else:
messages.warning(request, f"Voter {voter.email} is already a participant in event {event.name}. Skipping manual match for this voter.")
else:
# Still unmatched, keep for re-display
current_unmatched_rows.append(row_data)
if matched_count > 0:
messages.success(request, f"Successfully matched {matched_count} participants.")
if current_unmatched_rows:
request.session['unmatched_participants_data']['unmatched_rows'] = current_unmatched_rows
messages.warning(request, f"{len(current_unmatched_rows)} participants still need manual matching.")
return redirect('match_participants', event_id=event.id)
else:
messages.success(request, "All participants have been matched.")
del request.session['unmatched_participants_data'] # Clean up session
del request.session['imported_participants_data'] # Also clean up this
return redirect('event_detail', event_id=event.id)
context = {
'event': event,
'unmatched_rows': unmatched_rows,
'file_name': file_name,
'email_column': email_column,
'name_column': name_column, # Pass name column to template
'phone_column': phone_column, # Pass phone column to template
'participation_status_column': participation_status_column,
}
return render(request, 'core/event_participant_matching.html', context)
def volunteer_search_json(request):
"""
JSON endpoint for volunteer search, used by autocomplete/search UI.
@ -1169,7 +1512,7 @@ def volunteer_bulk_send_sms(request):
for volunteer in volunteers:
# Format phone to E.164 (assume US +1)
digits = re.sub(r'\D', '', str(volunteer.phone))
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'):
@ -1203,7 +1546,6 @@ def volunteer_bulk_send_sms(request):
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.
@ -1229,7 +1571,7 @@ def door_visits(request):
if neighborhood_filter:
voters = voters.filter(neighborhood__icontains=neighborhood_filter)
if address_filter:
voters = voters.filter(Q(address_street__icontains=address_filter) | Q(address__icontains=address_filter))
voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter))
# Grouping by household (unique address)
households_dict = {}
@ -1349,7 +1691,7 @@ def log_door_visit(request):
except:
tz = zoneinfo.ZoneInfo("America/Chicago")
interaction_date = timezone.now().astimezone(tz)
interaction_date = timezone.now().astimezone(tz);
# Get or create InteractionType
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Door Visit")
@ -1393,7 +1735,7 @@ def log_door_visit(request):
volunteer=volunteer,
type=interaction_type,
date=interaction_date,
description=outcome,
description="Outcome",
notes=notes
)
@ -1472,22 +1814,23 @@ def door_visit_history(request):
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,
"last_visit_date": interaction.date,
"last_outcome": interaction.description,
"last_volunteer": interaction.volunteer,
"notes": interaction.notes,
"voters_at_address": set(),
"latest_interaction": interaction
'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_at_address"].add((v.id, f"{v.first_name} {v.last_name}"))
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)
@ -1495,7 +1838,7 @@ def door_visit_history(request):
history_list = list(visited_households.values())
history_list.sort(key=lambda x: x["last_visit_date"], reverse=True)
paginator = Paginator(history_list, 50)
paginator = Paginator(history_list, 50);
page_number = request.GET.get("page")
history_page = paginator.get_page(page_number)
@ -1510,6 +1853,10 @@ def door_visit_history(request):
@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)
@ -1586,6 +1933,10 @@ def call_queue(request):
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_scheduledcall')
def complete_call(request, call_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
call = get_object_or_404(ScheduledCall, id=call_id, tenant=tenant)
@ -1604,7 +1955,7 @@ def complete_call(request, call_id):
except:
tz = zoneinfo.ZoneInfo('America/Chicago')
interaction_date = timezone.now().astimezone(tz)
interaction_date = timezone.now().astimezone(tz);
Interaction.objects.create(
voter=call.voter,
@ -1615,7 +1966,7 @@ def complete_call(request, call_id):
notes=call_notes
)
call.status = 'completed'
call.status = 'completed';
call.save()
messages.success(request, f"Call for {call.voter} marked as completed and interaction logged.")