Autosave: 20260203-043854

This commit is contained in:
Flatlogic Bot 2026-02-03 04:38:54 +00:00
parent 63faa21a4f
commit c3568101a3
21 changed files with 678 additions and 47 deletions

View File

@ -790,6 +790,7 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
@admin.register(Volunteer)
class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant', 'user')
ordering = ("last_name", "first_name")
list_filter = ('tenant',)
fields = ('tenant', 'user', 'first_name', 'last_name', 'email', 'phone', 'notes', 'interests')
search_fields = ('first_name', 'last_name', 'email', 'phone')

View File

@ -1,5 +1,5 @@
from django import forms
from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType, Tenant, ParticipationStatus, Volunteer, VolunteerEvent
from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType, Tenant, ParticipationStatus, Volunteer, VolunteerEvent, VolunteerRole, ScheduledCall
class VoterForm(forms.ModelForm):
class Meta:
@ -256,7 +256,7 @@ class VolunteerImportForm(forms.Form):
class VolunteerForm(forms.ModelForm):
class Meta:
model = Volunteer
fields = ['first_name', 'last_name', 'email', 'phone', 'notes', 'interests']
fields = ['first_name', 'last_name', 'email', 'phone', 'is_default_caller', 'notes', 'interests']
widgets = {'notes': forms.Textarea(attrs={'rows': 3})}
def __init__(self, *args, tenant=None, **kwargs):
@ -265,10 +265,11 @@ class VolunteerForm(forms.ModelForm):
from .models import Interest
self.fields['interests'].queryset = Interest.objects.filter(tenant=tenant)
for field in self.fields.values():
field.widget.attrs.update({'class': 'form-control'})
if not isinstance(field.widget, forms.CheckboxInput):
field.widget.attrs.update({'class': 'form-control'})
else:
field.widget.attrs.update({'class': 'form-check-input'})
# self.fields['interests'].widget = forms.SelectMultiple()
# Re-apply class for checkbox
self.fields['interests'].widget.attrs.update({'class': 'form-select tom-select'})
class VolunteerEventForm(forms.ModelForm):
@ -336,3 +337,22 @@ class DoorVisitLogForm(forms.Form):
widget=forms.Select(attrs={"class": "form-select"}),
label="Candidate Support"
)
class ScheduledCallForm(forms.ModelForm):
class Meta:
model = ScheduledCall
fields = ['volunteer', 'comments']
widgets = {
'comments': forms.Textarea(attrs={'rows': 3}),
}
def __init__(self, *args, tenant=None, **kwargs):
super().__init__(*args, **kwargs)
if tenant:
self.fields['volunteer'].queryset = Volunteer.objects.filter(tenant=tenant)
default_caller = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first()
if default_caller:
self.initial['volunteer'] = default_caller
for field in self.fields.values():
field.widget.attrs.update({'class': 'form-control'})
self.fields['volunteer'].widget.attrs.update({'class': 'form-select'})

View File

@ -0,0 +1,32 @@
# Generated by Django 5.2.7 on 2026-02-03 01:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0039_alter_tenantuserrole_role'),
]
operations = [
migrations.AddField(
model_name='volunteer',
name='is_default_caller',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='ScheduledCall',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('comments', models.TextField(blank=True)),
('status', models.CharField(choices=[('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_calls', to='core.tenant')),
('volunteer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_calls', to='core.volunteer')),
('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_calls', to='core.voter')),
],
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-02-03 03:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0040_volunteer_is_default_caller_scheduledcall'),
]
operations = [
migrations.AlterModelOptions(
name='volunteer',
options={'ordering': ('last_name', 'first_name')},
),
]

View File

@ -228,10 +228,10 @@ class Voter(models.Model):
err = None # Clear previous error if fallback works
if lat and lon:
self.latitude = lat
# Truncate longitude to 12 characters as requested
# Truncate coordinates to 12 characters as requested
self.latitude = Decimal(str(lat)[:12])
self.longitude = Decimal(str(lon)[:12])
logger.info(f"Geocoding success: {lat}, {self.longitude}")
logger.info(f"Geocoding success: {self.latitude}, {self.longitude}")
return True, None
logger.warning(f"Geocoding failed for {self.address}: {err}")
@ -242,7 +242,9 @@ class Voter(models.Model):
self.phone = format_phone_number(self.phone)
self.secondary_phone = format_phone_number(self.secondary_phone)
# Ensure longitude is truncated to 12 characters before saving
# Ensure coordinates are truncated to 12 characters before saving
if self.latitude:
self.latitude = Decimal(str(self.latitude)[:12])
if self.longitude:
self.longitude = Decimal(str(self.longitude)[:12])
@ -317,12 +319,22 @@ class Event(models.Model):
class Meta:
unique_together = ('tenant', 'name')
def save(self, *args, **kwargs):
# Ensure coordinates are truncated to 12 characters before saving
if self.latitude:
self.latitude = Decimal(str(self.latitude)[:12])
if self.longitude:
self.longitude = Decimal(str(self.longitude)[:12])
super().save(*args, **kwargs)
def __str__(self):
if self.name:
return f"{self.name} ({self.date})"
return f"{self.event_type} on {self.date}"
class Volunteer(models.Model):
class Meta:
ordering = ("last_name", "first_name")
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='volunteers')
user = models.OneToOneField(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='volunteer_profile')
first_name = models.CharField(max_length=100, blank=True)
@ -331,11 +343,17 @@ class Volunteer(models.Model):
phone = models.CharField(max_length=20, blank=True)
interests = models.ManyToManyField(Interest, blank=True, related_name='volunteers')
assigned_events = models.ManyToManyField(Event, through='VolunteerEvent', related_name='assigned_volunteers')
is_default_caller = models.BooleanField(default=False)
notes = models.TextField(blank=True)
def save(self, *args, **kwargs):
# Auto-format phone number
self.phone = format_phone_number(self.phone)
if self.is_default_caller:
# Only one default caller per tenant
Volunteer.objects.filter(tenant=self.tenant, is_default_caller=True).exclude(pk=self.pk).update(is_default_caller=False)
super().save(*args, **kwargs)
def __str__(self):
@ -392,6 +410,24 @@ class VoterLikelihood(models.Model):
def __str__(self):
return f"{self.voter} - {self.election_type}: {self.get_likelihood_display()}"
class ScheduledCall(models.Model):
STATUS_CHOICES = [
('pending', 'Pending'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
]
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='scheduled_calls')
voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='scheduled_calls')
volunteer = models.ForeignKey(Volunteer, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_calls')
comments = models.TextField(blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"Call for {self.voter} assigned to {self.volunteer}"
class CampaignSettings(models.Model):
tenant = models.OneToOneField(Tenant, on_delete=models.CASCADE, related_name='settings')
donation_goal = models.DecimalField(max_digits=12, decimal_places=2, default=170000.00)

View File

@ -31,6 +31,9 @@
<li class="nav-item">
<a class="nav-link" href="/voters/">Voters</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/call-queue/">Call Queue</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/door-visits/">Door Visits</a>
</li>
@ -76,4 +79,4 @@
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
</html>

View File

@ -0,0 +1,158 @@
{% extends "base.html" %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Call Queue</h1>
<div class="d-flex gap-2">
<span class="badge bg-primary d-flex align-items-center px-3">{{ calls.paginator.count }} Pending Calls</span>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="bg-light">
<tr>
<th class="ps-4">Voter</th>
<th>Phone</th>
<th>Assigned Volunteer</th>
<th>Comments</th>
<th>Scheduled</th>
<th class="pe-4 text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for call in calls %}
<tr>
<td class="ps-4">
<a href="{% url 'voter_detail' call.voter.id %}" class="fw-semibold text-primary text-decoration-none d-block">
{{ call.voter.first_name }} {{ call.voter.last_name }}
</a>
</td>
<td>
{% if call.voter.phone %}
<div class="d-flex align-items-center">
<a href="tel:{{ call.voter.phone }}" class="text-decoration-none text-dark fw-medium me-2">{{ call.voter.phone }}</a>
<a href="tel:{{ call.voter.phone }}" class="text-primary me-2" title="Call"><i class="bi bi-telephone" style="font-size: 0.85rem;"></i></a>
</div>
{% else %}
-
{% endif %}
</td>
<td>
{% if call.volunteer %}
<a href="{% url 'volunteer_detail' call.volunteer.id %}" class="text-decoration-none text-dark">
{{ call.volunteer }}
</a>
{% else %}
<span class="text-muted small">Unassigned</span>
{% endif %}
</td>
<td><small class="text-muted">{{ call.comments|default:"-" }}</small></td>
<td><small class="text-muted" title="{{ call.created_at }}">{{ call.created_at|timesince }} ago</small></td>
<td class="pe-4 text-end">
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#callNotesModal"
data-call-id="{{ call.id }}" data-voter-name="{{ call.voter.first_name }} {{ call.voter.last_name }}">
<i class="bi bi-telephone me-1"></i>Make Call
</button>
<form action="{% url 'delete_call' call.id %}" method="POST" class="d-inline ms-1">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('Remove this call from queue?')">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<p class="mb-0">The call queue is currently empty.</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if calls.paginator.num_pages > 1 %}
<div class="card-footer bg-white border-0 py-3">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0">
{% if calls.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ calls.previous_page_number }}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% endif %}
<li class="page-item active"><span class="page-link">{{ calls.number }} of {{ calls.paginator.num_pages }}</span></li>
{% if calls.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ calls.next_page_number }}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
</div>
<!-- Call Notes Modal -->
<div class="modal fade" id="callNotesModal" tabindex="-1" aria-labelledby="callNotesModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-0 shadow">
<form id="completeCallForm" method="POST">
{% csrf_token %}
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="callNotesModalLabel">Call Notes for <span id="modalVoterName"></span></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="call_notes" class="form-label fw-bold">Call Results & Notes</label>
<textarea class="form-control" id="call_notes" name="call_notes" rows="4" placeholder="Enter notes from the call..."></textarea>
<div class="form-text">These notes will be saved to the voter's interaction history.</div>
</div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary px-4" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success px-4">Save Call & Complete</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const callNotesModal = document.getElementById('callNotesModal');
if (callNotesModal) {
callNotesModal.addEventListener('show.bs.modal', function(event) {
const button = event.relatedTarget;
const callId = button.getAttribute('data-call-id');
const voterName = button.getAttribute('data-voter-name');
const modalVoterName = callNotesModal.querySelector('#modalVoterName');
const form = callNotesModal.querySelector('#completeCallForm');
modalVoterName.textContent = voterName;
// Construct the URL dynamically. The placeholder '0' will be replaced by the actual callId.
let baseUrl = "{% url 'complete_call' 0 %}";
form.action = baseUrl.replace('0', callId);
// Clear the notes textarea each time the modal opens
callNotesModal.querySelector('#call_notes').value = '';
});
// Focus on the textarea when the modal is shown
callNotesModal.addEventListener('shown.bs.modal', function () {
callNotesModal.querySelector('#call_notes').focus();
});
}
});
</script>
{% endblock %}

View File

@ -23,9 +23,9 @@
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
.ts-control.focus {
border-color: #86b7fe !important;
border-color: #059669 !important;
outline: 0 !important;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25) !important;
box-shadow: 0 0 0 0.25rem rgba(5, 150, 105, 0.1) !important;
}
</style>
{% endblock %}
@ -71,7 +71,6 @@
<label for="{{ form.email.id_for_label }}" class="form-label small text-muted text-uppercase fw-bold">Email Address</label>
{{ form.email }}
</div>
<div class="col-md-6">
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center">
<label for="{{ form.phone.id_for_label }}" class="form-label small text-muted text-uppercase fw-bold mb-1">Phone Number</label>
@ -84,6 +83,14 @@
</div>
{{ form.phone }}
</div>
<div class="col-12">
<div class="form-check form-switch py-2">
{{ form.is_default_caller }}
<label class="form-check-label fw-bold text-muted text-uppercase small" for="{{ form.is_default_caller.id_for_label }}">
Default Caller for Queue
</label>
<div class="form-text">If enabled, this volunteer will be the default assigned person for new call queue entries.</div>
</div>
</div>
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-2">
@ -141,7 +148,7 @@
</a>
<div class="small text-muted">{{ assignment.event.date|date:"M d, Y" }}</div>
</td>
<td>{{ assignment.role }}</td>
<td>{{ assignment.role_type|default:"Assigned" }}</td>
<td class="pe-4 text-end">
<form action="{% url 'volunteer_remove_event' assignment.id %}" method="POST" onsubmit="return confirm('Remove this assignment?')">
{% csrf_token %}
@ -229,9 +236,9 @@
{{ assign_form.event }}
</div>
<div class="mb-0">
<label for="{{ assign_form.role.id_for_label }}" class="form-label fw-bold">Role/Task</label>
{{ assign_form.role }}
<div class="form-text">e.g., Door knocker, Registration desk, Driver</div>
<label for="{{ assign_form.role_type.id_for_label }}" class="form-label fw-bold">Role/Task</label>
{{ assign_form.role_type }}
<div class="form-text">e.g., Phone Banker, Door knocker, Registration desk</div>
</div>
</div>
<div class="modal-footer border-0 p-4 pt-0">

View File

@ -49,8 +49,8 @@
<input type="checkbox" class="form-check-input" id="select-all">
</th>
<th>Name</th>
<th>Email</th>
<th>Phone</th>
<th>Email</th>
<th class="pe-4">Interests</th>
</tr>
</thead>
@ -61,11 +61,17 @@
<input type="checkbox" name="selected_volunteers" value="{{ volunteer.id }}" class="form-check-input volunteer-checkbox">
</td>
<td>
<a href="{% url 'volunteer_detail' volunteer.id %}" class="fw-semibold text-primary text-decoration-none d-block">
{{ volunteer.first_name }} {{ volunteer.last_name }}
</a>
<div class="d-flex align-items-center">
<a href="{% url 'volunteer_detail' volunteer.id %}" class="fw-semibold text-primary text-decoration-none me-2">
{{ volunteer.first_name }} {{ volunteer.last_name }}
</a>
{% if volunteer.is_default_caller %}
<span class="badge bg-success" title="Default caller for queue entries">
<i class="bi bi-telephone-fill me-1"></i>Default Caller
</span>
{% endif %}
</div>
</td>
<td>{{ volunteer.email }}</td>
<td>
{% if volunteer.phone %}
<div class="d-flex align-items-center">
@ -77,6 +83,7 @@
-
{% endif %}
</td>
<td>{{ volunteer.email }}</td>
<td class="pe-4">
{% for interest in volunteer.interests.all %}
<span class="badge bg-info-subtle text-info border border-info-subtle">{{ interest.name }}</span>
@ -172,10 +179,12 @@ document.addEventListener('DOMContentLoaded', function() {
function updateBulkActionsVisibility() {
const checkedCount = document.querySelectorAll('.volunteer-checkbox:checked').length;
if (checkedCount > 0) {
bulkActions.classList.remove('d-none');
} else {
bulkActions.classList.add('d-none');
if (bulkActions) {
if (checkedCount > 0) {
bulkActions.classList.remove('d-none');
} else {
bulkActions.classList.add('d-none');
}
}
}

View File

@ -98,6 +98,9 @@
<button type="submit" name="action" value="export_selected" class="btn btn-primary btn-sm">
<i class="bi bi-file-earmark-spreadsheet me-1"></i> Export Selected
</button>
<button type="button" class="btn btn-primary btn-sm ms-2" data-bs-toggle="modal" data-bs-target="#bulkCallModal">
<i class="bi bi-telephone-plus me-1"></i> Call Selected
</button>
<button type="button" class="btn btn-primary btn-sm ms-2" data-bs-toggle="modal" data-bs-target="#smsModal">
<i class="bi bi-chat-left-text me-1"></i> Send Bulk SMS
</button>
@ -122,6 +125,7 @@
<th>Phone</th>
<th>Target Voter</th>
<th class="pe-4">Supporter</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@ -175,10 +179,19 @@
<span class="badge bg-secondary">Unknown</span>
{% endif %}
</td>
<td class="pe-4 text-end">
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal"
data-bs-target="#scheduleCallModal"
data-voter-id="{{ voter.id }}"
data-voter-name="{{ voter.first_name }} {{ voter.last_name }}">
<i class="bi bi-telephone-plus"></i>
</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<td colspan="7" class="text-center py-5 text-muted">
<p class="mb-0">No voters found matching your search criteria.</p>
</td>
</tr>
@ -255,7 +268,7 @@
{% csrf_token %}
<div class="modal-body py-4">
<p class="small text-muted mb-3">Messages will only be sent to selected voters who have a <strong>Cell Phone</strong> entered. Others will be skipped.</p>
<div id="selected-voters-container">
<div id="selected-voters-sms-container">
<input type="hidden" name="client_time" id="client_time">
<!-- Voter IDs will be injected here -->
</div>
@ -273,6 +286,67 @@
</div>
</div>
<!-- Bulk Call Modal -->
<div class="modal fade" id="bulkCallModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold">Bulk Schedule Calls</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'bulk_schedule_calls' %}" method="POST" id="bulk-call-form">
{% csrf_token %}
<div class="modal-body py-4">
<div id="selected-voters-call-container">
<!-- Voter IDs will be injected here -->
</div>
<div class="mb-3">
<label class="form-label small fw-bold text-muted">Volunteer to call back</label>
{{ call_form.volunteer }}
</div>
<div class="mb-0">
<label class="form-label small fw-bold text-muted">Comments</label>
{{ call_form.comments }}
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-4">Add to Call Queue</button>
</div>
</form>
</div>
</div>
</div>
<!-- Individual Schedule Call Modal -->
<div class="modal fade" id="scheduleCallModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0 bg-light">
<h5 class="modal-title">Schedule Call: <span id="voterNameLabel"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="scheduleCallForm" action="" method="POST">
{% csrf_token %}
<div class="modal-body p-4">
<div class="mb-3">
<label class="form-label fw-medium">Volunteer to call back</label>
{{ call_form.volunteer }}
</div>
<div class="mb-0">
<label class="form-label fw-medium">Comments</label>
{{ call_form.comments }}
</div>
</div>
<div class="modal-footer border-0 p-4 pt-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-4">Add to Call Queue</button>
</div>
</form>
</div>
</div>
</div>
<!-- Google Maps JS -->
{% if GOOGLE_MAPS_API_KEY %}
<script src="https://maps.googleapis.com/maps/api/js?key={{ GOOGLE_MAPS_API_KEY }}"></script>
@ -281,7 +355,7 @@
<script>
var map;
var markers = [];
var mapData = {{ map_data_json|safe }};
var mapData = {{ map_data_json|safe|default:"[]" }};
function initMap() {
if (!window.google || !window.google.maps) {
@ -361,7 +435,7 @@ document.addEventListener('DOMContentLoaded', function() {
const smsModal = document.getElementById('smsModal');
if (smsModal) {
smsModal.addEventListener('show.bs.modal', function () {
const container = document.getElementById('selected-voters-container');
const container = document.getElementById('selected-voters-sms-container');
container.innerHTML = '';
checkboxes.forEach(cb => {
if (cb.checked) {
@ -376,7 +450,6 @@ document.addEventListener('DOMContentLoaded', function() {
const clientTimeInput = document.getElementById("client_time");
if (clientTimeInput) {
const now = new Date();
// Format as YYYY-MM-DDTHH:mm:ss
const offset = now.getTimezoneOffset() * 60000;
const localISOTime = (new Date(now - offset)).toISOString().slice(0, 19);
clientTimeInput.value = localISOTime;
@ -384,6 +457,39 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
const bulkCallModal = document.getElementById('bulkCallModal');
if (bulkCallModal) {
bulkCallModal.addEventListener('show.bs.modal', function () {
const container = document.getElementById('selected-voters-call-container');
container.innerHTML = '';
checkboxes.forEach(cb => {
if (cb.checked) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_voters';
input.value = cb.value;
container.appendChild(input);
}
});
});
}
// Individual Schedule Call Modal dynamic content
var scheduleCallModal = document.getElementById('scheduleCallModal');
if (scheduleCallModal) {
scheduleCallModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
var voterId = button.getAttribute('data-voter-id');
var voterName = button.getAttribute('data-voter-name');
var modalTitle = scheduleCallModal.querySelector('#voterNameLabel');
var form = scheduleCallModal.querySelector('#scheduleCallForm');
modalTitle.textContent = voterName;
form.action = '/voters/' + voterId + '/schedule-call/';
});
}
var mapModal = document.getElementById('mapModal');
if (mapModal) {
mapModal.addEventListener('shown.bs.modal', function () {

View File

@ -79,8 +79,11 @@
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white py-3">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Contact Information</h5>
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#scheduleCallModal">
<i class="bi bi-telephone-plus me-1"></i>Call Voter
</button>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0">
@ -120,6 +123,7 @@
</div>
</li>
{% endif %}
<li class="mb-3">
<label class="small text-muted d-block">Birthdate</label>
<span class="fw-semibold">{{ voter.birthdate|date:"M d, Y"|default:"N/A" }}</span>
</li>
@ -271,7 +275,9 @@
<td><span class="badge bg-light text-dark border">{{ interaction.type.name }}</span></td>
<td>{% if interaction.volunteer %}{{ interaction.volunteer }}{% else %}<span class="text-muted small">-</span>{% endif %}</td>
<td>{{ interaction.description }}</td>
<td class="small text-muted">{{ interaction.notes|truncatechars:30 }}</td>
<td class="small text-muted" {% if interaction.notes %}data-bs-toggle="tooltip" data-bs-placement="top" title="{{ interaction.notes }}"{% endif %}>
{{ interaction.notes|truncatechars:30 }}
</td>
{% if can_edit_voter %}
<td class="pe-4 text-end">
<button class="btn btn-sm btn-link text-primary p-0 me-2" data-bs-toggle="modal" data-bs-target="#editInteractionModal{{ interaction.id }}">
@ -330,7 +336,9 @@
<span class="badge bg-info-subtle text-info border border-info-subtle">{{ participation.participation_status.name }}</span>
{% endif %}
</td>
<td class="small text-muted">{{ participation.event.description|truncatechars:60 }}</td>
<td class="small text-muted" {% if participation.event.description %}data-bs-toggle="tooltip" data-bs-placement="top" title="{{ participation.event.description }}"{% endif %}>
{{ participation.event.description|truncatechars:60 }}
</td>
{% if can_edit_voter %}
<td class="pe-4 text-end">
<button class="btn btn-sm btn-link text-primary p-0 me-2" data-bs-toggle="modal" data-bs-target="#editEventParticipationModal{{ participation.id }}">
@ -1024,6 +1032,35 @@
{% endfor %}
{% endif %}
<!-- Schedule Call Modal -->
<div class="modal fade" id="scheduleCallModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-0">
<div class="modal-header border-0 bg-light">
<h5 class="modal-title">Schedule Call</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'schedule_call' voter.id %}" method="POST">
{% csrf_token %}
<div class="modal-body p-4">
<div class="mb-3">
<label class="form-label fw-medium">Volunteer to call back</label>
{{ call_form.volunteer }}
</div>
<div class="mb-0">
<label class="form-label fw-medium">Comments</label>
{{ call_form.comments }}
</div>
</div>
<div class="modal-footer border-0 p-4 pt-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-4">Add to Call Queue</button>
</div>
</form>
</div>
</div>
</div>
<style>
.nav-tabs .nav-link {
color: #6c757d;
@ -1053,6 +1090,12 @@
.bg-success-subtle { background-color: #d1fae5; }
.bg-danger-subtle { background-color: #fee2e2; }
.bg-info-subtle { background-color: #e0f2fe; }
/* Ensure tooltips are readable */
.tooltip-inner {
max-width: 300px;
text-align: left;
}
</style>
<script>
@ -1067,6 +1110,12 @@
}
}
// Initialize tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
{% if voter.latitude and voter.longitude %}
// Initialize Map
try {
@ -1130,7 +1179,7 @@
.then(response => response.json())
.then(data => {
if (data.success) {
document.querySelector('[name="latitude"]').value = data.latitude;
document.querySelector('[name="latitude"]').value = String(data.latitude).substring(0, 12);
document.querySelector('[name="longitude"]').value = String(data.longitude).substring(0, 12);
statusDiv.innerHTML = '<span class="text-success fw-bold"><i class="bi bi-check-circle me-1"></i>Coordinates updated!</span>';
} else {

View File

@ -38,7 +38,8 @@
<th>District</th>
<th>Phone</th>
<th>Target Voter</th>
<th class="pe-4">Supporter</th>
<th>Supporter</th>
<th class="pe-4 text-end">Actions</th>
</tr>
</thead>
<tbody>
@ -76,7 +77,7 @@
<span class="badge bg-secondary-subtle text-secondary">No</span>
{% endif %}
</td>
<td class="pe-4">
<td>
{% if voter.candidate_support == 'supporting' %}
<span class="badge bg-success">Supporting</span>
{% elif voter.candidate_support == 'not_supporting' %}
@ -85,10 +86,19 @@
<span class="badge bg-secondary">Unknown</span>
{% endif %}
</td>
<td class="pe-4 text-end">
<button class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal"
data-bs-target="#scheduleCallModal"
data-voter-id="{{ voter.id }}"
data-voter-name="{{ voter.first_name }} {{ voter.last_name }}">
<i class="bi bi-telephone-plus me-1"></i>Call Voter
</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center py-5 text-muted">
<td colspan="6" class="text-center py-5 text-muted">
<p class="mb-0">No voters found matching your search.</p>
</td>
</tr>
@ -152,6 +162,35 @@
</div>
</div>
<!-- Schedule Call Modal -->
<div class="modal fade" id="scheduleCallModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0 bg-light">
<h5 class="modal-title">Schedule Call: <span id="voterNameLabel"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="scheduleCallForm" action="" method="POST">
{% csrf_token %}
<div class="modal-body p-4">
<div class="mb-3">
<label class="form-label fw-medium">Volunteer to call back</label>
{{ call_form.volunteer }}
</div>
<div class="mb-0">
<label class="form-label fw-medium">Comments</label>
{{ call_form.comments }}
</div>
</div>
<div class="modal-footer border-0 p-4 pt-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-4">Add to Call Queue</button>
</div>
</form>
</div>
</div>
</div>
<!-- Google Maps JS -->
{% if GOOGLE_MAPS_API_KEY %}
<script src="https://maps.googleapis.com/maps/api/js?key={{ GOOGLE_MAPS_API_KEY }}"></script>
@ -160,7 +199,7 @@
<script>
var map;
var markers = [];
var mapData = {{ map_data_json|safe }};
var mapData = {{ map_data_json|safe|default:"[]" }};
function initMap() {
if (!window.google || !window.google.maps) {
@ -222,6 +261,22 @@
}
});
}
// Schedule Call Modal dynamic content
var scheduleCallModal = document.getElementById('scheduleCallModal');
if (scheduleCallModal) {
scheduleCallModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
var voterId = button.getAttribute('data-voter-id');
var voterName = button.getAttribute('data-voter-name');
var modalTitle = scheduleCallModal.querySelector('#voterNameLabel');
var form = scheduleCallModal.querySelector('#scheduleCallForm');
modalTitle.textContent = voterName;
form.action = '/voters/' + voterId + '/schedule-call/';
});
}
});
</script>
{% endblock %}

View File

@ -12,6 +12,8 @@ urlpatterns = [
path('voters/<int:voter_id>/edit/', views.voter_edit, name='voter_edit'),
path('voters/<int:voter_id>/delete/', views.voter_delete, name='voter_delete'),
path('voters/<int:voter_id>/geocode/', views.voter_geocode, name='voter_geocode'),
path('voters/<int:voter_id>/schedule-call/', views.schedule_call, name='schedule_call'),
path('voters/bulk-schedule-calls/', views.bulk_schedule_calls, name='bulk_schedule_calls'),
path('voters/<int:voter_id>/interaction/add/', views.add_interaction, name='add_interaction'),
path('interaction/<int:interaction_id>/edit/', views.edit_interaction, name='edit_interaction'),
@ -57,4 +59,9 @@ urlpatterns = [
path('door-visits/', views.door_visits, name='door_visits'),
path('door-visits/log/', views.log_door_visit, name='log_door_visit'),
path('door-visits/history/', views.door_visit_history, name='door_visit_history'),
# Call Queue
path('call-queue/', views.call_queue, name='call_queue'),
path('call-queue/<int:call_id>/complete/', views.complete_call, name='complete_call'),
path('call-queue/<int:call_id>/delete/', views.delete_call, name='delete_call'),
]

View File

@ -14,8 +14,8 @@ from django.db.models import Q, Sum
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
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm
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
import logging
import zoneinfo
from django.utils import timezone
@ -63,6 +63,7 @@ def index(request):
'volunteers_count': Volunteer.objects.filter(tenant=selected_tenant).count(),
'interactions_count': Interaction.objects.filter(voter__tenant=selected_tenant).count(),
'events_count': Event.objects.filter(tenant=selected_tenant).count(),
'pending_calls_count': ScheduledCall.objects.filter(tenant=selected_tenant, status='pending').count(),
}
recent_interactions = Interaction.objects.filter(voter__tenant=selected_tenant).order_by('-date')[:5]
@ -144,7 +145,8 @@ def voter_list(request):
context = {
"voters": voters_page,
"query": query,
"selected_tenant": tenant
"selected_tenant": tenant,
"call_form": ScheduledCallForm(tenant=tenant),
}
return render(request, "core/voter_list.html", context)
@ -176,6 +178,7 @@ def voter_detail(request, voter_id):
'donation_form': DonationForm(tenant=tenant),
'likelihood_form': VoterLikelihoodForm(tenant=tenant),
'event_participation_form': EventParticipationForm(tenant=tenant),
'call_form': ScheduledCallForm(tenant=tenant),
}
return render(request, 'core/voter_detail.html', context)
@ -452,9 +455,9 @@ def voter_advanced_search(request):
if data.get('zip_code'):
voters = voters.filter(zip_code__icontains=data['zip_code'])
if data.get('district'):
voters = voters.filter(district__icontains=data['district'])
voters = voters.filter(district=data['district'])
if data.get('precinct'):
voters = voters.filter(precinct__icontains=data['precinct'])
voters = voters.filter(precinct=data['precinct'])
if data.get('phone_type'):
voters = voters.filter(phone_type=data['phone_type'])
if data.get('is_targeted'):
@ -475,6 +478,7 @@ def voter_advanced_search(request):
'form': form,
'voters': voters_page,
'selected_tenant': tenant,
'call_form': ScheduledCallForm(tenant=tenant),
}
return render(request, 'core/voter_advanced_search.html', context)
@ -525,9 +529,9 @@ def export_voters_csv(request):
if data.get('zip_code'):
voters = voters.filter(zip_code__icontains=data['zip_code'])
if data.get('district'):
voters = voters.filter(district__icontains=data['district'])
voters = voters.filter(district=data['district'])
if data.get('precinct'):
voters = voters.filter(precinct__icontains=data['precinct'])
voters = voters.filter(precinct=data['precinct'])
if data.get('phone_type'):
voters = voters.filter(phone_type=data['phone_type'])
if data.get('is_targeted'):
@ -1213,7 +1217,7 @@ def door_visits(request):
# Apply filters if provided
if district_filter:
voters = voters.filter(district__icontains=district_filter)
voters = voters.filter(district=district_filter)
if neighborhood_filter:
voters = voters.filter(neighborhood__icontains=neighborhood_filter)
if address_filter:
@ -1469,3 +1473,130 @@ def door_visit_history(request):
"volunteer_counts": sorted_volunteer_counts,
}
return render(request, "core/door_visit_history.html", context)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
def schedule_call(request, voter_id):
selected_tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
if request.method == 'POST':
form = ScheduledCallForm(request.POST, tenant=tenant)
if form.is_valid():
call = form.save(commit=False)
call.tenant = tenant
call.voter = voter
call.save()
messages.success(request, f"Call for {voter} added to queue.")
else:
messages.error(request, "Error scheduling call.")
referer = request.META.get('HTTP_REFERER')
if referer:
return redirect(referer)
return redirect('voter_detail', voter_id=voter.id)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
def bulk_schedule_calls(request):
if request.method != 'POST':
return redirect('voter_advanced_search')
selected_tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voter_ids = request.POST.getlist('selected_voters')
volunteer_id = request.POST.get('volunteer')
comments = request.POST.get('comments', '')
volunteer = None
if volunteer_id:
volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant)
else:
# Fallback to default caller if not specified in POST but available
volunteer = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first()
voters = Voter.objects.filter(tenant=tenant, id__in=voter_ids)
count = 0
for voter in voters:
ScheduledCall.objects.create(
tenant=tenant,
voter=voter,
volunteer=volunteer,
comments=comments
)
count += 1
messages.success(request, f"{count} calls added to queue.")
return redirect(request.META.get('HTTP_REFERER', 'voter_advanced_search'))
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
def call_queue(request):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
calls = ScheduledCall.objects.filter(tenant=tenant, status='pending').order_by('created_at')
paginator = Paginator(calls, 50)
page_number = request.GET.get('page')
calls_page = paginator.get_page(page_number)
context = {
'selected_tenant': tenant,
'calls': calls_page,
}
return render(request, 'core/call_queue.html', context)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
def complete_call(request, call_id):
selected_tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
call = get_object_or_404(ScheduledCall, id=call_id, tenant=tenant)
if request.method == 'POST':
# Get notes from post data taken during the call
call_notes = request.POST.get('call_notes', '')
# Create interaction for the completed call
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Phone Call")
# Determine date/time in campaign timezone
campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant)
campaign_tz_name = campaign_settings.timezone or 'America/Chicago'
try:
tz = zoneinfo.ZoneInfo(campaign_tz_name)
except:
tz = zoneinfo.ZoneInfo('America/Chicago')
interaction_date = timezone.now().astimezone(tz)
Interaction.objects.create(
voter=call.voter,
volunteer=call.volunteer,
type=interaction_type,
date=interaction_date,
description="Called Voter",
notes=call_notes
)
call.status = 'completed'
call.save()
messages.success(request, f"Call for {call.voter} marked as completed and interaction logged.")
return redirect('call_queue')
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
def delete_call(request, call_id):
selected_tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
call = get_object_or_404(ScheduledCall, id=call_id, tenant=tenant)
if request.method == 'POST':
call.delete()
messages.success(request, "Call removed from queue.")
return redirect('call_queue')