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)
|
@admin.register(Volunteer)
|
||||||
class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||||
list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant', 'user')
|
list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant', 'user')
|
||||||
|
ordering = ("last_name", "first_name")
|
||||||
list_filter = ('tenant',)
|
list_filter = ('tenant',)
|
||||||
fields = ('tenant', 'user', 'first_name', 'last_name', 'email', 'phone', 'notes', 'interests')
|
fields = ('tenant', 'user', 'first_name', 'last_name', 'email', 'phone', 'notes', 'interests')
|
||||||
search_fields = ('first_name', 'last_name', 'email', 'phone')
|
search_fields = ('first_name', 'last_name', 'email', 'phone')
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from django import forms
|
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 VoterForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -256,7 +256,7 @@ class VolunteerImportForm(forms.Form):
|
|||||||
class VolunteerForm(forms.ModelForm):
|
class VolunteerForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Volunteer
|
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})}
|
widgets = {'notes': forms.Textarea(attrs={'rows': 3})}
|
||||||
|
|
||||||
def __init__(self, *args, tenant=None, **kwargs):
|
def __init__(self, *args, tenant=None, **kwargs):
|
||||||
@ -265,10 +265,11 @@ class VolunteerForm(forms.ModelForm):
|
|||||||
from .models import Interest
|
from .models import Interest
|
||||||
self.fields['interests'].queryset = Interest.objects.filter(tenant=tenant)
|
self.fields['interests'].queryset = Interest.objects.filter(tenant=tenant)
|
||||||
for field in self.fields.values():
|
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'})
|
self.fields['interests'].widget.attrs.update({'class': 'form-select tom-select'})
|
||||||
|
|
||||||
class VolunteerEventForm(forms.ModelForm):
|
class VolunteerEventForm(forms.ModelForm):
|
||||||
@ -336,3 +337,22 @@ class DoorVisitLogForm(forms.Form):
|
|||||||
widget=forms.Select(attrs={"class": "form-select"}),
|
widget=forms.Select(attrs={"class": "form-select"}),
|
||||||
label="Candidate Support"
|
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
|
err = None # Clear previous error if fallback works
|
||||||
|
|
||||||
if lat and lon:
|
if lat and lon:
|
||||||
self.latitude = lat
|
# Truncate coordinates to 12 characters as requested
|
||||||
# Truncate longitude to 12 characters as requested
|
self.latitude = Decimal(str(lat)[:12])
|
||||||
self.longitude = Decimal(str(lon)[: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
|
return True, None
|
||||||
|
|
||||||
logger.warning(f"Geocoding failed for {self.address}: {err}")
|
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.phone = format_phone_number(self.phone)
|
||||||
self.secondary_phone = format_phone_number(self.secondary_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:
|
if self.longitude:
|
||||||
self.longitude = Decimal(str(self.longitude)[:12])
|
self.longitude = Decimal(str(self.longitude)[:12])
|
||||||
|
|
||||||
@ -317,12 +319,22 @@ class Event(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('tenant', 'name')
|
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):
|
def __str__(self):
|
||||||
if self.name:
|
if self.name:
|
||||||
return f"{self.name} ({self.date})"
|
return f"{self.name} ({self.date})"
|
||||||
return f"{self.event_type} on {self.date}"
|
return f"{self.event_type} on {self.date}"
|
||||||
|
|
||||||
class Volunteer(models.Model):
|
class Volunteer(models.Model):
|
||||||
|
class Meta:
|
||||||
|
ordering = ("last_name", "first_name")
|
||||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='volunteers')
|
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')
|
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)
|
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)
|
phone = models.CharField(max_length=20, blank=True)
|
||||||
interests = models.ManyToManyField(Interest, blank=True, related_name='volunteers')
|
interests = models.ManyToManyField(Interest, blank=True, related_name='volunteers')
|
||||||
assigned_events = models.ManyToManyField(Event, through='VolunteerEvent', related_name='assigned_volunteers')
|
assigned_events = models.ManyToManyField(Event, through='VolunteerEvent', related_name='assigned_volunteers')
|
||||||
|
is_default_caller = models.BooleanField(default=False)
|
||||||
notes = models.TextField(blank=True)
|
notes = models.TextField(blank=True)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
# Auto-format phone number
|
# Auto-format phone number
|
||||||
self.phone = format_phone_number(self.phone)
|
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -392,6 +410,24 @@ class VoterLikelihood(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.voter} - {self.election_type}: {self.get_likelihood_display()}"
|
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):
|
class CampaignSettings(models.Model):
|
||||||
tenant = models.OneToOneField(Tenant, on_delete=models.CASCADE, related_name='settings')
|
tenant = models.OneToOneField(Tenant, on_delete=models.CASCADE, related_name='settings')
|
||||||
donation_goal = models.DecimalField(max_digits=12, decimal_places=2, default=170000.00)
|
donation_goal = models.DecimalField(max_digits=12, decimal_places=2, default=170000.00)
|
||||||
|
|||||||
@ -31,6 +31,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/voters/">Voters</a>
|
<a class="nav-link" href="/voters/">Voters</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/call-queue/">Call Queue</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/door-visits/">Door Visits</a>
|
<a class="nav-link" href="/door-visits/">Door Visits</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
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;
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||||
}
|
}
|
||||||
.ts-control.focus {
|
.ts-control.focus {
|
||||||
border-color: #86b7fe !important;
|
border-color: #059669 !important;
|
||||||
outline: 0 !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>
|
</style>
|
||||||
{% endblock %}
|
{% 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>
|
<label for="{{ form.email.id_for_label }}" class="form-label small text-muted text-uppercase fw-bold">Email Address</label>
|
||||||
{{ form.email }}
|
{{ form.email }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<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>
|
<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>
|
</div>
|
||||||
{{ form.phone }}
|
{{ form.phone }}
|
||||||
</div>
|
</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>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
@ -141,7 +148,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="small text-muted">{{ assignment.event.date|date:"M d, Y" }}</div>
|
<div class="small text-muted">{{ assignment.event.date|date:"M d, Y" }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ assignment.role }}</td>
|
<td>{{ assignment.role_type|default:"Assigned" }}</td>
|
||||||
<td class="pe-4 text-end">
|
<td class="pe-4 text-end">
|
||||||
<form action="{% url 'volunteer_remove_event' assignment.id %}" method="POST" onsubmit="return confirm('Remove this assignment?')">
|
<form action="{% url 'volunteer_remove_event' assignment.id %}" method="POST" onsubmit="return confirm('Remove this assignment?')">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@ -229,9 +236,9 @@
|
|||||||
{{ assign_form.event }}
|
{{ assign_form.event }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-0">
|
<div class="mb-0">
|
||||||
<label for="{{ assign_form.role.id_for_label }}" class="form-label fw-bold">Role/Task</label>
|
<label for="{{ assign_form.role_type.id_for_label }}" class="form-label fw-bold">Role/Task</label>
|
||||||
{{ assign_form.role }}
|
{{ assign_form.role_type }}
|
||||||
<div class="form-text">e.g., Door knocker, Registration desk, Driver</div>
|
<div class="form-text">e.g., Phone Banker, Door knocker, Registration desk</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer border-0 p-4 pt-0">
|
<div class="modal-footer border-0 p-4 pt-0">
|
||||||
|
|||||||
@ -49,8 +49,8 @@
|
|||||||
<input type="checkbox" class="form-check-input" id="select-all">
|
<input type="checkbox" class="form-check-input" id="select-all">
|
||||||
</th>
|
</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Email</th>
|
|
||||||
<th>Phone</th>
|
<th>Phone</th>
|
||||||
|
<th>Email</th>
|
||||||
<th class="pe-4">Interests</th>
|
<th class="pe-4">Interests</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -61,11 +61,17 @@
|
|||||||
<input type="checkbox" name="selected_volunteers" value="{{ volunteer.id }}" class="form-check-input volunteer-checkbox">
|
<input type="checkbox" name="selected_volunteers" value="{{ volunteer.id }}" class="form-check-input volunteer-checkbox">
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{% url 'volunteer_detail' volunteer.id %}" class="fw-semibold text-primary text-decoration-none d-block">
|
<div class="d-flex align-items-center">
|
||||||
{{ volunteer.first_name }} {{ volunteer.last_name }}
|
<a href="{% url 'volunteer_detail' volunteer.id %}" class="fw-semibold text-primary text-decoration-none me-2">
|
||||||
</a>
|
{{ 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>
|
||||||
<td>{{ volunteer.email }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
{% if volunteer.phone %}
|
{% if volunteer.phone %}
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
@ -77,6 +83,7 @@
|
|||||||
-
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ volunteer.email }}</td>
|
||||||
<td class="pe-4">
|
<td class="pe-4">
|
||||||
{% for interest in volunteer.interests.all %}
|
{% for interest in volunteer.interests.all %}
|
||||||
<span class="badge bg-info-subtle text-info border border-info-subtle">{{ interest.name }}</span>
|
<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() {
|
function updateBulkActionsVisibility() {
|
||||||
const checkedCount = document.querySelectorAll('.volunteer-checkbox:checked').length;
|
const checkedCount = document.querySelectorAll('.volunteer-checkbox:checked').length;
|
||||||
if (checkedCount > 0) {
|
if (bulkActions) {
|
||||||
bulkActions.classList.remove('d-none');
|
if (checkedCount > 0) {
|
||||||
} else {
|
bulkActions.classList.remove('d-none');
|
||||||
bulkActions.classList.add('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">
|
<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
|
<i class="bi bi-file-earmark-spreadsheet me-1"></i> Export Selected
|
||||||
</button>
|
</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">
|
<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
|
<i class="bi bi-chat-left-text me-1"></i> Send Bulk SMS
|
||||||
</button>
|
</button>
|
||||||
@ -122,6 +125,7 @@
|
|||||||
<th>Phone</th>
|
<th>Phone</th>
|
||||||
<th>Target Voter</th>
|
<th>Target Voter</th>
|
||||||
<th class="pe-4">Supporter</th>
|
<th class="pe-4">Supporter</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -175,10 +179,19 @@
|
|||||||
<span class="badge bg-secondary">Unknown</span>
|
<span class="badge bg-secondary">Unknown</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<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>
|
<p class="mb-0">No voters found matching your search criteria.</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -255,7 +268,7 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="modal-body py-4">
|
<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>
|
<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">
|
<input type="hidden" name="client_time" id="client_time">
|
||||||
<!-- Voter IDs will be injected here -->
|
<!-- Voter IDs will be injected here -->
|
||||||
</div>
|
</div>
|
||||||
@ -273,6 +286,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Google Maps JS -->
|
||||||
{% if GOOGLE_MAPS_API_KEY %}
|
{% if GOOGLE_MAPS_API_KEY %}
|
||||||
<script src="https://maps.googleapis.com/maps/api/js?key={{ GOOGLE_MAPS_API_KEY }}"></script>
|
<script src="https://maps.googleapis.com/maps/api/js?key={{ GOOGLE_MAPS_API_KEY }}"></script>
|
||||||
@ -281,7 +355,7 @@
|
|||||||
<script>
|
<script>
|
||||||
var map;
|
var map;
|
||||||
var markers = [];
|
var markers = [];
|
||||||
var mapData = {{ map_data_json|safe }};
|
var mapData = {{ map_data_json|safe|default:"[]" }};
|
||||||
|
|
||||||
function initMap() {
|
function initMap() {
|
||||||
if (!window.google || !window.google.maps) {
|
if (!window.google || !window.google.maps) {
|
||||||
@ -361,7 +435,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const smsModal = document.getElementById('smsModal');
|
const smsModal = document.getElementById('smsModal');
|
||||||
if (smsModal) {
|
if (smsModal) {
|
||||||
smsModal.addEventListener('show.bs.modal', function () {
|
smsModal.addEventListener('show.bs.modal', function () {
|
||||||
const container = document.getElementById('selected-voters-container');
|
const container = document.getElementById('selected-voters-sms-container');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
checkboxes.forEach(cb => {
|
checkboxes.forEach(cb => {
|
||||||
if (cb.checked) {
|
if (cb.checked) {
|
||||||
@ -376,7 +450,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const clientTimeInput = document.getElementById("client_time");
|
const clientTimeInput = document.getElementById("client_time");
|
||||||
if (clientTimeInput) {
|
if (clientTimeInput) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// Format as YYYY-MM-DDTHH:mm:ss
|
|
||||||
const offset = now.getTimezoneOffset() * 60000;
|
const offset = now.getTimezoneOffset() * 60000;
|
||||||
const localISOTime = (new Date(now - offset)).toISOString().slice(0, 19);
|
const localISOTime = (new Date(now - offset)).toISOString().slice(0, 19);
|
||||||
clientTimeInput.value = localISOTime;
|
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');
|
var mapModal = document.getElementById('mapModal');
|
||||||
if (mapModal) {
|
if (mapModal) {
|
||||||
mapModal.addEventListener('shown.bs.modal', function () {
|
mapModal.addEventListener('shown.bs.modal', function () {
|
||||||
|
|||||||
@ -79,8 +79,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card border-0 shadow-sm mb-4">
|
<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>
|
<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>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<ul class="list-unstyled mb-0">
|
<ul class="list-unstyled mb-0">
|
||||||
@ -120,6 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<li class="mb-3">
|
||||||
<label class="small text-muted d-block">Birthdate</label>
|
<label class="small text-muted d-block">Birthdate</label>
|
||||||
<span class="fw-semibold">{{ voter.birthdate|date:"M d, Y"|default:"N/A" }}</span>
|
<span class="fw-semibold">{{ voter.birthdate|date:"M d, Y"|default:"N/A" }}</span>
|
||||||
</li>
|
</li>
|
||||||
@ -271,7 +275,9 @@
|
|||||||
<td><span class="badge bg-light text-dark border">{{ interaction.type.name }}</span></td>
|
<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>{% if interaction.volunteer %}{{ interaction.volunteer }}{% else %}<span class="text-muted small">-</span>{% endif %}</td>
|
||||||
<td>{{ interaction.description }}</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 %}
|
{% if can_edit_voter %}
|
||||||
<td class="pe-4 text-end">
|
<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 }}">
|
<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>
|
<span class="badge bg-info-subtle text-info border border-info-subtle">{{ participation.participation_status.name }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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 %}
|
{% if can_edit_voter %}
|
||||||
<td class="pe-4 text-end">
|
<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 }}">
|
<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 %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% 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>
|
<style>
|
||||||
.nav-tabs .nav-link {
|
.nav-tabs .nav-link {
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
@ -1053,6 +1090,12 @@
|
|||||||
.bg-success-subtle { background-color: #d1fae5; }
|
.bg-success-subtle { background-color: #d1fae5; }
|
||||||
.bg-danger-subtle { background-color: #fee2e2; }
|
.bg-danger-subtle { background-color: #fee2e2; }
|
||||||
.bg-info-subtle { background-color: #e0f2fe; }
|
.bg-info-subtle { background-color: #e0f2fe; }
|
||||||
|
|
||||||
|
/* Ensure tooltips are readable */
|
||||||
|
.tooltip-inner {
|
||||||
|
max-width: 300px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<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 %}
|
{% if voter.latitude and voter.longitude %}
|
||||||
// Initialize Map
|
// Initialize Map
|
||||||
try {
|
try {
|
||||||
@ -1130,7 +1179,7 @@
|
|||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
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);
|
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>';
|
statusDiv.innerHTML = '<span class="text-success fw-bold"><i class="bi bi-check-circle me-1"></i>Coordinates updated!</span>';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -38,7 +38,8 @@
|
|||||||
<th>District</th>
|
<th>District</th>
|
||||||
<th>Phone</th>
|
<th>Phone</th>
|
||||||
<th>Target Voter</th>
|
<th>Target Voter</th>
|
||||||
<th class="pe-4">Supporter</th>
|
<th>Supporter</th>
|
||||||
|
<th class="pe-4 text-end">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -76,7 +77,7 @@
|
|||||||
<span class="badge bg-secondary-subtle text-secondary">No</span>
|
<span class="badge bg-secondary-subtle text-secondary">No</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="pe-4">
|
<td>
|
||||||
{% if voter.candidate_support == 'supporting' %}
|
{% if voter.candidate_support == 'supporting' %}
|
||||||
<span class="badge bg-success">Supporting</span>
|
<span class="badge bg-success">Supporting</span>
|
||||||
{% elif voter.candidate_support == 'not_supporting' %}
|
{% elif voter.candidate_support == 'not_supporting' %}
|
||||||
@ -85,10 +86,19 @@
|
|||||||
<span class="badge bg-secondary">Unknown</span>
|
<span class="badge bg-secondary">Unknown</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<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>
|
<p class="mb-0">No voters found matching your search.</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -152,6 +162,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Google Maps JS -->
|
||||||
{% if GOOGLE_MAPS_API_KEY %}
|
{% if GOOGLE_MAPS_API_KEY %}
|
||||||
<script src="https://maps.googleapis.com/maps/api/js?key={{ GOOGLE_MAPS_API_KEY }}"></script>
|
<script src="https://maps.googleapis.com/maps/api/js?key={{ GOOGLE_MAPS_API_KEY }}"></script>
|
||||||
@ -160,7 +199,7 @@
|
|||||||
<script>
|
<script>
|
||||||
var map;
|
var map;
|
||||||
var markers = [];
|
var markers = [];
|
||||||
var mapData = {{ map_data_json|safe }};
|
var mapData = {{ map_data_json|safe|default:"[]" }};
|
||||||
|
|
||||||
function initMap() {
|
function initMap() {
|
||||||
if (!window.google || !window.google.maps) {
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -12,6 +12,8 @@ urlpatterns = [
|
|||||||
path('voters/<int:voter_id>/edit/', views.voter_edit, name='voter_edit'),
|
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>/delete/', views.voter_delete, name='voter_delete'),
|
||||||
path('voters/<int:voter_id>/geocode/', views.voter_geocode, name='voter_geocode'),
|
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('voters/<int:voter_id>/interaction/add/', views.add_interaction, name='add_interaction'),
|
||||||
path('interaction/<int:interaction_id>/edit/', views.edit_interaction, name='edit_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/', views.door_visits, name='door_visits'),
|
||||||
path('door-visits/log/', views.log_door_visit, name='log_door_visit'),
|
path('door-visits/log/', views.log_door_visit, name='log_door_visit'),
|
||||||
path('door-visits/history/', views.door_visit_history, name='door_visit_history'),
|
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.contrib import messages
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest, VolunteerRole
|
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
|
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm, ScheduledCallForm
|
||||||
import logging
|
import logging
|
||||||
import zoneinfo
|
import zoneinfo
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -63,6 +63,7 @@ def index(request):
|
|||||||
'volunteers_count': Volunteer.objects.filter(tenant=selected_tenant).count(),
|
'volunteers_count': Volunteer.objects.filter(tenant=selected_tenant).count(),
|
||||||
'interactions_count': Interaction.objects.filter(voter__tenant=selected_tenant).count(),
|
'interactions_count': Interaction.objects.filter(voter__tenant=selected_tenant).count(),
|
||||||
'events_count': Event.objects.filter(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]
|
recent_interactions = Interaction.objects.filter(voter__tenant=selected_tenant).order_by('-date')[:5]
|
||||||
@ -144,7 +145,8 @@ def voter_list(request):
|
|||||||
context = {
|
context = {
|
||||||
"voters": voters_page,
|
"voters": voters_page,
|
||||||
"query": query,
|
"query": query,
|
||||||
"selected_tenant": tenant
|
"selected_tenant": tenant,
|
||||||
|
"call_form": ScheduledCallForm(tenant=tenant),
|
||||||
}
|
}
|
||||||
return render(request, "core/voter_list.html", context)
|
return render(request, "core/voter_list.html", context)
|
||||||
|
|
||||||
@ -176,6 +178,7 @@ def voter_detail(request, voter_id):
|
|||||||
'donation_form': DonationForm(tenant=tenant),
|
'donation_form': DonationForm(tenant=tenant),
|
||||||
'likelihood_form': VoterLikelihoodForm(tenant=tenant),
|
'likelihood_form': VoterLikelihoodForm(tenant=tenant),
|
||||||
'event_participation_form': EventParticipationForm(tenant=tenant),
|
'event_participation_form': EventParticipationForm(tenant=tenant),
|
||||||
|
'call_form': ScheduledCallForm(tenant=tenant),
|
||||||
}
|
}
|
||||||
return render(request, 'core/voter_detail.html', context)
|
return render(request, 'core/voter_detail.html', context)
|
||||||
|
|
||||||
@ -452,9 +455,9 @@ def voter_advanced_search(request):
|
|||||||
if data.get('zip_code'):
|
if data.get('zip_code'):
|
||||||
voters = voters.filter(zip_code__icontains=data['zip_code'])
|
voters = voters.filter(zip_code__icontains=data['zip_code'])
|
||||||
if data.get('district'):
|
if data.get('district'):
|
||||||
voters = voters.filter(district__icontains=data['district'])
|
voters = voters.filter(district=data['district'])
|
||||||
if data.get('precinct'):
|
if data.get('precinct'):
|
||||||
voters = voters.filter(precinct__icontains=data['precinct'])
|
voters = voters.filter(precinct=data['precinct'])
|
||||||
if data.get('phone_type'):
|
if data.get('phone_type'):
|
||||||
voters = voters.filter(phone_type=data['phone_type'])
|
voters = voters.filter(phone_type=data['phone_type'])
|
||||||
if data.get('is_targeted'):
|
if data.get('is_targeted'):
|
||||||
@ -475,6 +478,7 @@ def voter_advanced_search(request):
|
|||||||
'form': form,
|
'form': form,
|
||||||
'voters': voters_page,
|
'voters': voters_page,
|
||||||
'selected_tenant': tenant,
|
'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)
|
||||||
|
|
||||||
@ -525,9 +529,9 @@ def export_voters_csv(request):
|
|||||||
if data.get('zip_code'):
|
if data.get('zip_code'):
|
||||||
voters = voters.filter(zip_code__icontains=data['zip_code'])
|
voters = voters.filter(zip_code__icontains=data['zip_code'])
|
||||||
if data.get('district'):
|
if data.get('district'):
|
||||||
voters = voters.filter(district__icontains=data['district'])
|
voters = voters.filter(district=data['district'])
|
||||||
if data.get('precinct'):
|
if data.get('precinct'):
|
||||||
voters = voters.filter(precinct__icontains=data['precinct'])
|
voters = voters.filter(precinct=data['precinct'])
|
||||||
if data.get('phone_type'):
|
if data.get('phone_type'):
|
||||||
voters = voters.filter(phone_type=data['phone_type'])
|
voters = voters.filter(phone_type=data['phone_type'])
|
||||||
if data.get('is_targeted'):
|
if data.get('is_targeted'):
|
||||||
@ -1213,7 +1217,7 @@ def door_visits(request):
|
|||||||
|
|
||||||
# Apply filters if provided
|
# Apply filters if provided
|
||||||
if district_filter:
|
if district_filter:
|
||||||
voters = voters.filter(district__icontains=district_filter)
|
voters = voters.filter(district=district_filter)
|
||||||
if neighborhood_filter:
|
if neighborhood_filter:
|
||||||
voters = voters.filter(neighborhood__icontains=neighborhood_filter)
|
voters = voters.filter(neighborhood__icontains=neighborhood_filter)
|
||||||
if address_filter:
|
if address_filter:
|
||||||
@ -1469,3 +1473,130 @@ def door_visit_history(request):
|
|||||||
"volunteer_counts": sorted_volunteer_counts,
|
"volunteer_counts": sorted_volunteer_counts,
|
||||||
}
|
}
|
||||||
return render(request, "core/door_visit_history.html", context)
|
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