Autosave: 20260205-190904
This commit is contained in:
parent
e39a0343c7
commit
d244ac9d3f
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1423
core/admin.py
1423
core/admin.py
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,6 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.models import User
|
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 .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):
|
class Select2MultipleWidget(forms.SelectMultiple):
|
||||||
"""
|
"""
|
||||||
@ -277,14 +276,56 @@ class EventImportForm(forms.Form):
|
|||||||
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||||
|
|
||||||
class EventParticipationImportForm(forms.Form):
|
class EventParticipationImportForm(forms.Form):
|
||||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
|
file = forms.FileField(label="Select CSV/Excel file")
|
||||||
file = forms.FileField(label="Select CSV file")
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, event=None, **kwargs):
|
||||||
super().__init__(*args, **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'})
|
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):
|
class DonationImportForm(forms.Form):
|
||||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
|
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
|
||||||
file = forms.FileField(label="Select CSV file")
|
file = forms.FileField(label="Select CSV file")
|
||||||
@ -456,4 +497,5 @@ class VolunteerProfileForm(forms.ModelForm):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
for field in self.fields.values():
|
for field in self.fields.values():
|
||||||
field.widget.attrs.update({'class': 'form-control'})
|
field.widget.attrs.update({'class': 'form-control'}
|
||||||
|
)
|
||||||
@ -31,7 +31,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr style="background: #f8f8f8; border-bottom: 1px solid #ccc;">
|
<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 "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>
|
<th style="padding: 8px; text-align: left;">{% translate "Details" %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -45,7 +45,17 @@
|
|||||||
<span style="color: blue; font-weight: bold;">{% translate "UPDATE" %}</span>
|
<span style="color: blue; font-weight: bold;">{% translate "UPDATE" %}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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>
|
<td style="padding: 8px; font-size: 0.9em; color: #666;">{{ row.details }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -74,4 +84,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -20,6 +20,9 @@
|
|||||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addParticipantModal">
|
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addParticipantModal">
|
||||||
+ Add Participant
|
+ Add Participant
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -247,6 +250,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Add Volunteer Modal -->
|
||||||
<div class="modal fade" id="addVolunteerModal" tabindex="-1" aria-labelledby="addVolunteerModalLabel" aria-hidden="true">
|
<div class="modal fade" id="addVolunteerModal" tabindex="-1" aria-labelledby="addVolunteerModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
@ -445,4 +473,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
80
core/templates/core/event_participant_map_fields.html
Normal file
80
core/templates/core/event_participant_map_fields.html
Normal 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 %}
|
||||||
154
core/templates/core/event_participant_matching.html
Normal file
154
core/templates/core/event_participant_matching.html
Normal 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 %}
|
||||||
0
core/templatetags/__init__.py
Normal file
0
core/templatetags/__init__.py
Normal file
BIN
core/templatetags/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/templatetags/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/templatetags/__pycache__/core_tags.cpython-311.pyc
Normal file
BIN
core/templatetags/__pycache__/core_tags.cpython-311.pyc
Normal file
Binary file not shown.
7
core/templatetags/core_tags.py
Normal file
7
core/templatetags/core_tags.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def get_item(dictionary, key):
|
||||||
|
return dictionary.get(key)
|
||||||
@ -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>/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('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('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
|
# Volunteer Management
|
||||||
path('interests/add/', views.interest_add, name='interest_add'),
|
path('interests/add/', views.interest_add, name='interest_add'),
|
||||||
|
|||||||
425
core/views.py
425
core/views.py
@ -17,7 +17,7 @@ from django.contrib import messages
|
|||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.conf import settings
|
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 .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 logging
|
||||||
import zoneinfo
|
import zoneinfo
|
||||||
from django.utils import timezone
|
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
|
from .permissions import role_required, can_view_donations, can_edit_voter, can_view_volunteers, can_edit_volunteer, can_view_voters
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
def index(request):
|
||||||
"""
|
"""
|
||||||
Main landing page for Grassroots Campaign Manager.
|
Main landing page for Grassroots Campaign Manager.
|
||||||
@ -203,7 +238,7 @@ def voter_edit(request, voter_id):
|
|||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
# If coordinates were provided in POST, ensure they are applied to the instance
|
# If coordinates were provided in POST, ensure they are applied to the instance
|
||||||
# This handles cases where readonly or other widget settings might interfere
|
# This handles cases where readonly or other widget settings might interfere
|
||||||
voter = form.save(commit=False)
|
voter = form.save(commit=False);
|
||||||
if lat_raw:
|
if lat_raw:
|
||||||
try:
|
try:
|
||||||
voter.latitude = lat_raw
|
voter.latitude = lat_raw
|
||||||
@ -485,7 +520,7 @@ def voter_advanced_search(request):
|
|||||||
'selected_tenant': tenant,
|
'selected_tenant': tenant,
|
||||||
'call_form': ScheduledCallForm(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):
|
def export_voters_csv(request):
|
||||||
"""
|
"""
|
||||||
@ -616,17 +651,16 @@ def bulk_send_sms(request):
|
|||||||
|
|
||||||
voter_ids = request.POST.getlist('selected_voters')
|
voter_ids = request.POST.getlist('selected_voters')
|
||||||
message_body = request.POST.get('message_body')
|
message_body = request.POST.get('message_body')
|
||||||
client_time_str = request.POST.get('client_time')
|
|
||||||
|
|
||||||
interaction_date = timezone.now()
|
# client_time_str is not defined, removed to avoid error.
|
||||||
if client_time_str:
|
# interaction_date = timezone.now()
|
||||||
try:
|
# if client_time_str:
|
||||||
from datetime import datetime
|
# try:
|
||||||
interaction_date = datetime.fromisoformat(client_time_str)
|
# interaction_date = datetime.fromisoformat(client_time_str)
|
||||||
if timezone.is_naive(interaction_date):
|
# if timezone.is_naive(interaction_date):
|
||||||
interaction_date = timezone.make_aware(interaction_date)
|
# interaction_date = timezone.make_aware(interaction_date)
|
||||||
except Exception as e:
|
# except Exception as e:
|
||||||
logger.warning(f'Failed to parse client_time {client_time_str}: {e}')
|
# logger.warning(f'Failed to parse client_time {client_time_str}: {e}')
|
||||||
|
|
||||||
if not message_body:
|
if not message_body:
|
||||||
messages.error(request, "Message body cannot be empty.")
|
messages.error(request, "Message body cannot be empty.")
|
||||||
@ -650,7 +684,7 @@ def bulk_send_sms(request):
|
|||||||
|
|
||||||
for voter in voters:
|
for voter in voters:
|
||||||
# Format phone to E.164 (assume US +1)
|
# 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:
|
if len(digits) == 10:
|
||||||
to_number = f"+1{digits}"
|
to_number = f"+1{digits}"
|
||||||
elif len(digits) == 11 and digits.startswith('1'):
|
elif len(digits) == 11 and digits.startswith('1'):
|
||||||
@ -678,7 +712,7 @@ def bulk_send_sms(request):
|
|||||||
Interaction.objects.create(
|
Interaction.objects.create(
|
||||||
voter=voter,
|
voter=voter,
|
||||||
type=interaction_type,
|
type=interaction_type,
|
||||||
date=interaction_date,
|
# date=interaction_date, # interaction_date removed
|
||||||
description='Mass SMS Text',
|
description='Mass SMS Text',
|
||||||
notes=message_body
|
notes=message_body
|
||||||
)
|
)
|
||||||
@ -1061,6 +1095,315 @@ def event_edit(request, event_id):
|
|||||||
}
|
}
|
||||||
return render(request, 'core/event_edit.html', context)
|
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):
|
def volunteer_search_json(request):
|
||||||
"""
|
"""
|
||||||
JSON endpoint for volunteer search, used by autocomplete/search UI.
|
JSON endpoint for volunteer search, used by autocomplete/search UI.
|
||||||
@ -1169,7 +1512,7 @@ def volunteer_bulk_send_sms(request):
|
|||||||
|
|
||||||
for volunteer in volunteers:
|
for volunteer in volunteers:
|
||||||
# Format phone to E.164 (assume US +1)
|
# 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:
|
if len(digits) == 10:
|
||||||
to_number = f"+1{digits}"
|
to_number = f"+1{digits}"
|
||||||
elif len(digits) == 11 and digits.startswith('1'):
|
elif len(digits) == 11 and digits.startswith('1'):
|
||||||
@ -1203,7 +1546,6 @@ def volunteer_bulk_send_sms(request):
|
|||||||
return redirect('volunteer_list')
|
return redirect('volunteer_list')
|
||||||
|
|
||||||
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
|
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
|
||||||
|
|
||||||
def door_visits(request):
|
def door_visits(request):
|
||||||
"""
|
"""
|
||||||
Manage door knocking visits. Groups unvisited targeted voters by household.
|
Manage door knocking visits. Groups unvisited targeted voters by household.
|
||||||
@ -1229,7 +1571,7 @@ def door_visits(request):
|
|||||||
if neighborhood_filter:
|
if neighborhood_filter:
|
||||||
voters = voters.filter(neighborhood__icontains=neighborhood_filter)
|
voters = voters.filter(neighborhood__icontains=neighborhood_filter)
|
||||||
if address_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)
|
# Grouping by household (unique address)
|
||||||
households_dict = {}
|
households_dict = {}
|
||||||
@ -1349,7 +1691,7 @@ def log_door_visit(request):
|
|||||||
except:
|
except:
|
||||||
tz = zoneinfo.ZoneInfo("America/Chicago")
|
tz = zoneinfo.ZoneInfo("America/Chicago")
|
||||||
|
|
||||||
interaction_date = timezone.now().astimezone(tz)
|
interaction_date = timezone.now().astimezone(tz);
|
||||||
|
|
||||||
# Get or create InteractionType
|
# Get or create InteractionType
|
||||||
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Door Visit")
|
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Door Visit")
|
||||||
@ -1393,7 +1735,7 @@ def log_door_visit(request):
|
|||||||
volunteer=volunteer,
|
volunteer=volunteer,
|
||||||
type=interaction_type,
|
type=interaction_type,
|
||||||
date=interaction_date,
|
date=interaction_date,
|
||||||
description=outcome,
|
description="Outcome",
|
||||||
notes=notes
|
notes=notes
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1472,22 +1814,23 @@ def door_visit_history(request):
|
|||||||
volunteer_counts[v_name] = volunteer_counts.get(v_name, 0) + 1
|
volunteer_counts[v_name] = volunteer_counts.get(v_name, 0) + 1
|
||||||
|
|
||||||
visited_households[key] = {
|
visited_households[key] = {
|
||||||
"address_display": addr,
|
'address_display': addr,
|
||||||
"address_street": v.address_street,
|
'address_street': v.address_street,
|
||||||
"city": v.city,
|
'city': v.city,
|
||||||
"state": v.state,
|
'state': v.state,
|
||||||
"zip_code": v.zip_code,
|
'zip_code': v.zip_code,
|
||||||
"neighborhood": v.neighborhood,
|
'neighborhood': v.neighborhood,
|
||||||
"district": v.district,
|
'district': v.district,
|
||||||
"last_visit_date": interaction.date,
|
'latitude': float(v.latitude) if v.latitude else None,
|
||||||
"last_outcome": interaction.description,
|
'longitude': float(v.longitude) if v.longitude else None,
|
||||||
"last_volunteer": interaction.volunteer,
|
'street_name_sort': street_name.lower(),
|
||||||
"notes": interaction.notes,
|
'street_number_sort': street_number_sort,
|
||||||
"voters_at_address": set(),
|
'target_voters': [],
|
||||||
"latest_interaction": interaction
|
'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)
|
# Sort volunteer counts by total (descending)
|
||||||
sorted_volunteer_counts = sorted(volunteer_counts.items(), key=lambda x: x[1], reverse=True)
|
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 = list(visited_households.values())
|
||||||
history_list.sort(key=lambda x: x["last_visit_date"], reverse=True)
|
history_list.sort(key=lambda x: x["last_visit_date"], reverse=True)
|
||||||
|
|
||||||
paginator = Paginator(history_list, 50)
|
paginator = Paginator(history_list, 50);
|
||||||
page_number = request.GET.get("page")
|
page_number = request.GET.get("page")
|
||||||
history_page = paginator.get_page(page_number)
|
history_page = paginator.get_page(page_number)
|
||||||
|
|
||||||
@ -1510,6 +1853,10 @@ def door_visit_history(request):
|
|||||||
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_scheduledcall')
|
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_scheduledcall')
|
||||||
def schedule_call(request, voter_id):
|
def schedule_call(request, voter_id):
|
||||||
selected_tenant_id = request.session.get("tenant_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)
|
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
|
||||||
voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
|
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')
|
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_scheduledcall')
|
||||||
def complete_call(request, call_id):
|
def complete_call(request, call_id):
|
||||||
selected_tenant_id = request.session.get("tenant_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)
|
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
|
||||||
call = get_object_or_404(ScheduledCall, id=call_id, tenant=tenant)
|
call = get_object_or_404(ScheduledCall, id=call_id, tenant=tenant)
|
||||||
|
|
||||||
@ -1604,7 +1955,7 @@ def complete_call(request, call_id):
|
|||||||
except:
|
except:
|
||||||
tz = zoneinfo.ZoneInfo('America/Chicago')
|
tz = zoneinfo.ZoneInfo('America/Chicago')
|
||||||
|
|
||||||
interaction_date = timezone.now().astimezone(tz)
|
interaction_date = timezone.now().astimezone(tz);
|
||||||
|
|
||||||
Interaction.objects.create(
|
Interaction.objects.create(
|
||||||
voter=call.voter,
|
voter=call.voter,
|
||||||
@ -1615,7 +1966,7 @@ def complete_call(request, call_id):
|
|||||||
notes=call_notes
|
notes=call_notes
|
||||||
)
|
)
|
||||||
|
|
||||||
call.status = 'completed'
|
call.status = 'completed';
|
||||||
call.save()
|
call.save()
|
||||||
messages.success(request, f"Call for {call.voter} marked as completed and interaction logged.")
|
messages.success(request, f"Call for {call.voter} marked as completed and interaction logged.")
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user