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 @@