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.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'}
|
||||
)
|
||||
@ -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 %}
|
||||
@ -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 %}
|
||||
|
||||
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>/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'),
|
||||
|
||||
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.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.")
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user