diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 79533be..82d9cc3 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index 1bb1da0..ef9dbe1 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 2a861dd..9a15037 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 254fb70..f61391b 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index f3fd53f..2acb99d 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 22fa994..58dc59c 100644 --- a/core/admin.py +++ b/core/admin.py @@ -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') diff --git a/core/forms.py b/core/forms.py index 4705bbf..1b46fd8 100644 --- a/core/forms.py +++ b/core/forms.py @@ -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'}) diff --git a/core/migrations/0040_volunteer_is_default_caller_scheduledcall.py b/core/migrations/0040_volunteer_is_default_caller_scheduledcall.py new file mode 100644 index 0000000..4e41184 --- /dev/null +++ b/core/migrations/0040_volunteer_is_default_caller_scheduledcall.py @@ -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')), + ], + ), + ] diff --git a/core/migrations/0041_alter_volunteer_options.py b/core/migrations/0041_alter_volunteer_options.py new file mode 100644 index 0000000..ffe8c4d --- /dev/null +++ b/core/migrations/0041_alter_volunteer_options.py @@ -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')}, + ), + ] diff --git a/core/migrations/__pycache__/0040_volunteer_is_default_caller_scheduledcall.cpython-311.pyc b/core/migrations/__pycache__/0040_volunteer_is_default_caller_scheduledcall.cpython-311.pyc new file mode 100644 index 0000000..9dee99e Binary files /dev/null and b/core/migrations/__pycache__/0040_volunteer_is_default_caller_scheduledcall.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0041_alter_volunteer_options.cpython-311.pyc b/core/migrations/__pycache__/0041_alter_volunteer_options.cpython-311.pyc new file mode 100644 index 0000000..f6bf406 Binary files /dev/null and b/core/migrations/__pycache__/0041_alter_volunteer_options.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index ea51444..4723cea 100644 --- a/core/models.py +++ b/core/models.py @@ -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) diff --git a/core/templates/base.html b/core/templates/base.html index 47fb9c8..fd94cde 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -31,6 +31,9 @@ + @@ -76,4 +79,4 @@ {% block extra_js %}{% endblock %} - + \ No newline at end of file diff --git a/core/templates/core/call_queue.html b/core/templates/core/call_queue.html new file mode 100644 index 0000000..d28c9fb --- /dev/null +++ b/core/templates/core/call_queue.html @@ -0,0 +1,158 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Call Queue

+
+ {{ calls.paginator.count }} Pending Calls +
+
+ +
+
+ + + + + + + + + + + + + {% for call in calls %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
VoterPhoneAssigned VolunteerCommentsScheduledActions
+ + {{ call.voter.first_name }} {{ call.voter.last_name }} + + + {% if call.voter.phone %} + + {% else %} + - + {% endif %} + + {% if call.volunteer %} + + {{ call.volunteer }} + + {% else %} + Unassigned + {% endif %} + {{ call.comments|default:"-" }}{{ call.created_at|timesince }} ago + +
+ {% csrf_token %} + +
+
+

The call queue is currently empty.

+
+
+ + {% if calls.paginator.num_pages > 1 %} + + {% endif %} +
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/volunteer_detail.html b/core/templates/core/volunteer_detail.html index 67bda37..a57e425 100644 --- a/core/templates/core/volunteer_detail.html +++ b/core/templates/core/volunteer_detail.html @@ -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; } {% endblock %} @@ -71,7 +71,6 @@ {{ form.email }} -
@@ -84,6 +83,14 @@
{{ form.phone }}
+
+
+ {{ form.is_default_caller }} + +
If enabled, this volunteer will be the default assigned person for new call queue entries.
+
@@ -141,7 +148,7 @@
{{ assignment.event.date|date:"M d, Y" }}
- {{ assignment.role }} + {{ assignment.role_type|default:"Assigned" }}
{% csrf_token %} @@ -229,9 +236,9 @@ {{ assign_form.event }}
- - {{ assign_form.role }} -
e.g., Door knocker, Registration desk, Driver
+ + {{ assign_form.role_type }} +
e.g., Phone Banker, Door knocker, Registration desk