Autosave: 20260203-043854
This commit is contained in:
parent
63faa21a4f
commit
c3568101a3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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')
|
||||
|
||||
@ -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'})
|
||||
|
||||
@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
17
core/migrations/0041_alter_volunteer_options.py
Normal file
17
core/migrations/0041_alter_volunteer_options.py
Normal 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')},
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@ -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)
|
||||
|
||||
@ -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>
|
||||
158
core/templates/core/call_queue.html
Normal file
158
core/templates/core/call_queue.html
Normal 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">«</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">»</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 %}
|
||||
@ -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">
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 () {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 %}
|
||||
@ -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'),
|
||||
]
|
||||
147
core/views.py
147
core/views.py
@ -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')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user