Autosave: 20260201-034149
This commit is contained in:
parent
f7bc2da356
commit
77709c3744
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -16,7 +16,7 @@ from .models import (
|
|||||||
format_phone_number,
|
format_phone_number,
|
||||||
Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,
|
Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,
|
||||||
VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings,
|
VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings,
|
||||||
Interest, Volunteer, VolunteerEvent, ParticipationStatus
|
Interest, Volunteer, VolunteerEvent, ParticipationStatus, VolunteerRole
|
||||||
)
|
)
|
||||||
from .forms import (
|
from .forms import (
|
||||||
VoterImportForm, EventImportForm, EventParticipationImportForm,
|
VoterImportForm, EventImportForm, EventParticipationImportForm,
|
||||||
@ -179,6 +179,12 @@ class DonationMethodAdmin(admin.ModelAdmin):
|
|||||||
list_filter = ('tenant', 'is_active')
|
list_filter = ('tenant', 'is_active')
|
||||||
search_fields = ('name',)
|
search_fields = ('name',)
|
||||||
|
|
||||||
|
@admin.register(VolunteerRole)
|
||||||
|
class VolunteerRoleAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "tenant", "is_active")
|
||||||
|
list_filter = ("tenant", "is_active")
|
||||||
|
search_fields = ("name",)
|
||||||
|
|
||||||
@admin.register(ElectionType)
|
@admin.register(ElectionType)
|
||||||
class ElectionTypeAdmin(admin.ModelAdmin):
|
class ElectionTypeAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'tenant', 'is_active')
|
list_display = ('name', 'tenant', 'is_active')
|
||||||
@ -187,9 +193,10 @@ class ElectionTypeAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(EventType)
|
@admin.register(EventType)
|
||||||
class EventTypeAdmin(admin.ModelAdmin):
|
class EventTypeAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'tenant', 'is_active')
|
list_display = ('name', 'tenant', 'is_active', 'default_volunteer_role')
|
||||||
list_filter = ('tenant', 'is_active')
|
list_filter = ('tenant', 'is_active')
|
||||||
search_fields = ('name',)
|
search_fields = ('name',)
|
||||||
|
filter_horizontal = ('available_roles',)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ParticipationStatus)
|
@admin.register(ParticipationStatus)
|
||||||
@ -223,6 +230,7 @@ class DonationInline(admin.TabularInline):
|
|||||||
class InteractionInline(admin.TabularInline):
|
class InteractionInline(admin.TabularInline):
|
||||||
model = Interaction
|
model = Interaction
|
||||||
extra = 1
|
extra = 1
|
||||||
|
autocomplete_fields = ['voter', 'type', 'volunteer']
|
||||||
|
|
||||||
class VoterLikelihoodInline(admin.TabularInline):
|
class VoterLikelihoodInline(admin.TabularInline):
|
||||||
model = VoterLikelihood
|
model = VoterLikelihood
|
||||||
@ -913,8 +921,8 @@ class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(VolunteerEvent)
|
@admin.register(VolunteerEvent)
|
||||||
class VolunteerEventAdmin(admin.ModelAdmin):
|
class VolunteerEventAdmin(admin.ModelAdmin):
|
||||||
list_display = ('volunteer', 'event', 'role')
|
list_display = ('volunteer', 'event', 'role_type')
|
||||||
list_filter = ('event__tenant', 'event', 'role')
|
list_filter = ('event__tenant', 'event', 'role_type')
|
||||||
autocomplete_fields = ["volunteer", "event"]
|
autocomplete_fields = ["volunteer", "event"]
|
||||||
|
|
||||||
@admin.register(EventParticipation)
|
@admin.register(EventParticipation)
|
||||||
@ -1798,9 +1806,9 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(CampaignSettings)
|
@admin.register(CampaignSettings)
|
||||||
class CampaignSettingsAdmin(admin.ModelAdmin):
|
class CampaignSettingsAdmin(admin.ModelAdmin):
|
||||||
list_display = ('tenant', 'donation_goal', 'twilio_from_number')
|
list_display = ('tenant', 'donation_goal', 'twilio_from_number', 'timezone')
|
||||||
list_filter = ('tenant',)
|
list_filter = ('tenant',)
|
||||||
fields = ('tenant', 'donation_goal', 'twilio_account_sid', 'twilio_auth_token', 'twilio_from_number')
|
fields = ('tenant', 'donation_goal', 'twilio_account_sid', 'twilio_auth_token', 'twilio_from_number', 'timezone')
|
||||||
|
|
||||||
@admin.register(VotingRecord)
|
@admin.register(VotingRecord)
|
||||||
class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||||
|
|||||||
@ -6,9 +6,9 @@ class VoterForm(forms.ModelForm):
|
|||||||
model = Voter
|
model = Voter
|
||||||
fields = [
|
fields = [
|
||||||
'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state', 'prior_state',
|
'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state', 'prior_state',
|
||||||
'zip_code', 'county', 'latitude', 'longitude',
|
'zip_code', 'county', 'neighborhood', 'latitude', 'longitude',
|
||||||
'phone', 'phone_type', 'secondary_phone', 'secondary_phone_type', 'email', 'voter_id', 'district', 'precinct',
|
'phone', 'phone_type', 'secondary_phone', 'secondary_phone_type', 'email', 'voter_id', 'district', 'precinct',
|
||||||
'registration_date', 'is_targeted', 'candidate_support', 'yard_sign', 'window_sticker', 'notes'
|
'registration_date', 'is_targeted', 'door_visit', 'candidate_support', 'yard_sign', 'window_sticker', 'notes'
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'birthdate': forms.DateInput(attrs={'type': 'date'}),
|
'birthdate': forms.DateInput(attrs={'type': 'date'}),
|
||||||
@ -48,6 +48,7 @@ class AdvancedVoterSearchForm(forms.Form):
|
|||||||
birth_month = forms.ChoiceField(choices=MONTH_CHOICES, required=False, label="Birth Month")
|
birth_month = forms.ChoiceField(choices=MONTH_CHOICES, required=False, label="Birth Month")
|
||||||
city = forms.CharField(required=False)
|
city = forms.CharField(required=False)
|
||||||
zip_code = forms.CharField(required=False)
|
zip_code = forms.CharField(required=False)
|
||||||
|
neighborhood = forms.CharField(required=False)
|
||||||
district = forms.CharField(required=False)
|
district = forms.CharField(required=False)
|
||||||
precinct = forms.CharField(required=False)
|
precinct = forms.CharField(required=False)
|
||||||
phone_type = forms.ChoiceField(
|
phone_type = forms.ChoiceField(
|
||||||
@ -55,6 +56,7 @@ class AdvancedVoterSearchForm(forms.Form):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
is_targeted = forms.BooleanField(required=False, label="Targeted Only")
|
is_targeted = forms.BooleanField(required=False, label="Targeted Only")
|
||||||
|
door_visit = forms.BooleanField(required=False, label="Visited Only")
|
||||||
candidate_support = forms.ChoiceField(
|
candidate_support = forms.ChoiceField(
|
||||||
choices=[('', 'Any')] + Voter.SUPPORT_CHOICES,
|
choices=[('', 'Any')] + Voter.SUPPORT_CHOICES,
|
||||||
required=False
|
required=False
|
||||||
@ -170,7 +172,7 @@ class EventParticipantAddForm(forms.ModelForm):
|
|||||||
class EventForm(forms.ModelForm):
|
class EventForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Event
|
model = Event
|
||||||
fields = ['name', 'date', 'start_time', 'end_time', 'event_type', 'description', 'location_name', 'address', 'city', 'state', 'zip_code', 'latitude', 'longitude']
|
fields = ['name', 'date', 'start_time', 'end_time', 'event_type', 'default_volunteer_role', 'description', 'location_name', 'address', 'city', 'state', 'zip_code', 'latitude', 'longitude']
|
||||||
widgets = {
|
widgets = {
|
||||||
'date': forms.DateInput(attrs={'type': 'date'}),
|
'date': forms.DateInput(attrs={'type': 'date'}),
|
||||||
'start_time': forms.TimeInput(attrs={'type': 'time'}),
|
'start_time': forms.TimeInput(attrs={'type': 'time'}),
|
||||||
@ -182,9 +184,11 @@ class EventForm(forms.ModelForm):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
if tenant:
|
if tenant:
|
||||||
self.fields['event_type'].queryset = EventType.objects.filter(tenant=tenant, is_active=True)
|
self.fields['event_type'].queryset = EventType.objects.filter(tenant=tenant, is_active=True)
|
||||||
|
self.fields['default_volunteer_role'].queryset = VolunteerRole.objects.filter(tenant=tenant, is_active=True)
|
||||||
for field in self.fields.values():
|
for field in self.fields.values():
|
||||||
field.widget.attrs.update({'class': 'form-control'})
|
field.widget.attrs.update({'class': 'form-control'})
|
||||||
self.fields['event_type'].widget.attrs.update({'class': 'form-select'})
|
self.fields['event_type'].widget.attrs.update({'class': 'form-select'})
|
||||||
|
self.fields['default_volunteer_role'].widget.attrs.update({'class': 'form-select'})
|
||||||
|
|
||||||
class VoterImportForm(forms.Form):
|
class VoterImportForm(forms.Form):
|
||||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
|
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
|
||||||
@ -270,7 +274,7 @@ class VolunteerForm(forms.ModelForm):
|
|||||||
class VolunteerEventForm(forms.ModelForm):
|
class VolunteerEventForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VolunteerEvent
|
model = VolunteerEvent
|
||||||
fields = ['event', 'role']
|
fields = ['event', 'role_type']
|
||||||
|
|
||||||
def __init__(self, *args, tenant=None, **kwargs):
|
def __init__(self, *args, tenant=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@ -283,7 +287,7 @@ class VolunteerEventForm(forms.ModelForm):
|
|||||||
class VolunteerEventAddForm(forms.ModelForm):
|
class VolunteerEventAddForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VolunteerEvent
|
model = VolunteerEvent
|
||||||
fields = ['volunteer', 'role']
|
fields = ['volunteer', 'role_type']
|
||||||
|
|
||||||
def __init__(self, *args, tenant=None, **kwargs):
|
def __init__(self, *args, tenant=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@ -304,4 +308,31 @@ class VotingRecordImportForm(forms.Form):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
||||||
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||||
|
class DoorVisitLogForm(forms.Form):
|
||||||
|
OUTCOME_CHOICES = [
|
||||||
|
("No Answer Left Literature", "No Answer Left Literature"),
|
||||||
|
("Spoke to voter", "Spoke to voter"),
|
||||||
|
("No Access to House", "No Access to House"),
|
||||||
|
]
|
||||||
|
outcome = forms.ChoiceField(
|
||||||
|
choices=OUTCOME_CHOICES,
|
||||||
|
widget=forms.RadioSelect(attrs={"class": "form-check-input"}),
|
||||||
|
label="Outcome"
|
||||||
|
)
|
||||||
|
notes = forms.CharField(
|
||||||
|
widget=forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||||
|
required=False,
|
||||||
|
label="Notes"
|
||||||
|
)
|
||||||
|
wants_yard_sign = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||||
|
label="Wants a Yard Sign"
|
||||||
|
)
|
||||||
|
candidate_support = forms.ChoiceField(
|
||||||
|
choices=Voter.SUPPORT_CHOICES,
|
||||||
|
initial="unknown",
|
||||||
|
widget=forms.Select(attrs={"class": "form-select"}),
|
||||||
|
label="Candidate Support"
|
||||||
|
)
|
||||||
|
|||||||
@ -0,0 +1,41 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-01-31 13:00
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0030_event_location_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='VolunteerRole',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteer_roles', to='core.tenant')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('tenant', 'name')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='event',
|
||||||
|
name='default_volunteer_role',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_events', to='core.volunteerrole'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='eventtype',
|
||||||
|
name='available_roles',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='event_types', to='core.volunteerrole'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='volunteerevent',
|
||||||
|
name='role_type',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='volunteer_assignments', to='core.volunteerrole'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0032_alter_volunteerevent_role.py
Normal file
18
core/migrations/0032_alter_volunteerevent_role.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-02-01 00:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0031_volunteerrole_event_default_volunteer_role_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='volunteerevent',
|
||||||
|
name='role',
|
||||||
|
field=models.CharField(blank=True, max_length=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
17
core/migrations/0033_remove_volunteerevent_role.py
Normal file
17
core/migrations/0033_remove_volunteerevent_role.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-02-01 00:54
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0032_alter_volunteerevent_role'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='volunteerevent',
|
||||||
|
name='role',
|
||||||
|
),
|
||||||
|
]
|
||||||
19
core/migrations/0034_eventtype_default_volunteer_role.py
Normal file
19
core/migrations/0034_eventtype_default_volunteer_role.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-02-01 01:02
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0033_remove_volunteerevent_role'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='eventtype',
|
||||||
|
name='default_volunteer_role',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_event_types', to='core.volunteerrole'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-02-01 01:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0034_eventtype_default_volunteer_role'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='interaction',
|
||||||
|
name='door_visit',
|
||||||
|
field=models.BooleanField(db_index=True, default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='interaction',
|
||||||
|
name='neighborhood',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='volunteer',
|
||||||
|
name='door_visit',
|
||||||
|
field=models.BooleanField(db_index=True, default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='volunteer',
|
||||||
|
name='neighborhood',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='voter',
|
||||||
|
name='door_visit',
|
||||||
|
field=models.BooleanField(db_index=True, default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='voter',
|
||||||
|
name='neighborhood',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-02-01 01:55
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0035_interaction_door_visit_interaction_neighborhood_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='interaction',
|
||||||
|
name='door_visit',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='interaction',
|
||||||
|
name='neighborhood',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='volunteer',
|
||||||
|
name='door_visit',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='volunteer',
|
||||||
|
name='neighborhood',
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0037_campaignsettings_timezone.py
Normal file
18
core/migrations/0037_campaignsettings_timezone.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-02-01 03:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0036_remove_interaction_door_visit_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='campaignsettings',
|
||||||
|
name='timezone',
|
||||||
|
field=models.CharField(default='America/Chicago', max_length=50),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0038_alter_campaignsettings_timezone.py
Normal file
18
core/migrations/0038_alter_campaignsettings_timezone.py
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,3 +1,4 @@
|
|||||||
|
import zoneinfo
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
import json
|
import json
|
||||||
@ -77,8 +78,20 @@ class ElectionType(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
class EventType(models.Model):
|
class ParticipationStatus(models.Model):
|
||||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='event_types')
|
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='participation_statuses')
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('tenant', 'name')
|
||||||
|
verbose_name_plural = 'Participation Statuses'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class VolunteerRole(models.Model):
|
||||||
|
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='volunteer_roles')
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
@ -88,14 +101,15 @@ class EventType(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
class ParticipationStatus(models.Model):
|
class EventType(models.Model):
|
||||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='participation_statuses')
|
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='event_types')
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
|
available_roles = models.ManyToManyField(VolunteerRole, blank=True, related_name='event_types')
|
||||||
|
default_volunteer_role = models.ForeignKey(VolunteerRole, on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_event_types")
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('tenant', 'name')
|
unique_together = ('tenant', 'name')
|
||||||
verbose_name_plural = 'Participation Statuses'
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@ -160,6 +174,8 @@ class Voter(models.Model):
|
|||||||
yard_sign = models.CharField(max_length=20, choices=YARD_SIGN_CHOICES, default='none', db_index=True)
|
yard_sign = models.CharField(max_length=20, choices=YARD_SIGN_CHOICES, default='none', db_index=True)
|
||||||
window_sticker = models.CharField(max_length=20, choices=WINDOW_STICKER_CHOICES, default='none', verbose_name='Window Sticker Status', db_index=True)
|
window_sticker = models.CharField(max_length=20, choices=WINDOW_STICKER_CHOICES, default='none', verbose_name='Window Sticker Status', db_index=True)
|
||||||
notes = models.TextField(blank=True)
|
notes = models.TextField(blank=True)
|
||||||
|
door_visit = models.BooleanField(default=False, db_index=True)
|
||||||
|
neighborhood = models.CharField(max_length=100, blank=True, db_index=True)
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
@ -288,6 +304,7 @@ class Event(models.Model):
|
|||||||
start_time = models.TimeField(null=True, blank=True)
|
start_time = models.TimeField(null=True, blank=True)
|
||||||
end_time = models.TimeField(null=True, blank=True)
|
end_time = models.TimeField(null=True, blank=True)
|
||||||
event_type = models.ForeignKey(EventType, on_delete=models.PROTECT, null=True)
|
event_type = models.ForeignKey(EventType, on_delete=models.PROTECT, null=True)
|
||||||
|
default_volunteer_role = models.ForeignKey(VolunteerRole, on_delete=models.SET_NULL, null=True, blank=True, related_name='default_for_events')
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
location_name = models.CharField(max_length=255, blank=True)
|
location_name = models.CharField(max_length=255, blank=True)
|
||||||
address = models.CharField(max_length=255, blank=True)
|
address = models.CharField(max_length=255, blank=True)
|
||||||
@ -327,10 +344,10 @@ class Volunteer(models.Model):
|
|||||||
class VolunteerEvent(models.Model):
|
class VolunteerEvent(models.Model):
|
||||||
volunteer = models.ForeignKey(Volunteer, on_delete=models.CASCADE, related_name="event_assignments")
|
volunteer = models.ForeignKey(Volunteer, on_delete=models.CASCADE, related_name="event_assignments")
|
||||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="volunteer_assignments")
|
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="volunteer_assignments")
|
||||||
role = models.CharField(max_length=100)
|
role_type = models.ForeignKey(VolunteerRole, on_delete=models.SET_NULL, null=True, blank=True, related_name="volunteer_assignments")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.volunteer} at {self.event} as {self.role}"
|
return f"{self.volunteer} at {self.event} as {self.role_type or 'Assigned'}"
|
||||||
|
|
||||||
class EventParticipation(models.Model):
|
class EventParticipation(models.Model):
|
||||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='participations')
|
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='participations')
|
||||||
@ -381,6 +398,7 @@ class CampaignSettings(models.Model):
|
|||||||
twilio_account_sid = models.CharField(max_length=100, blank=True, default='ACcd11acb5095cec6477245d385a2bf127')
|
twilio_account_sid = models.CharField(max_length=100, blank=True, default='ACcd11acb5095cec6477245d385a2bf127')
|
||||||
twilio_auth_token = models.CharField(max_length=100, blank=True, default='89ec830d0fa02ab0afa6c76084865713')
|
twilio_auth_token = models.CharField(max_length=100, blank=True, default='89ec830d0fa02ab0afa6c76084865713')
|
||||||
twilio_from_number = models.CharField(max_length=20, blank=True, default='+18556945903')
|
twilio_from_number = models.CharField(max_length=20, blank=True, default='+18556945903')
|
||||||
|
timezone = models.CharField(max_length=100, default="America/Chicago", choices=[(tz, tz) for tz in sorted(zoneinfo.available_timezones())])
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'Campaign Settings'
|
verbose_name = 'Campaign Settings'
|
||||||
|
|||||||
@ -40,6 +40,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="/door-visits/">Door Visits</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/events/">Events</a>
|
<a class="nav-link" href="/events/">Events</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
251
core/templates/core/door_visits.html
Normal file
251
core/templates/core/door_visits.html
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
{% 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">Door Visits</h1>
|
||||||
|
<div>
|
||||||
|
<span class="badge bg-primary rounded-pill px-3">{{ households.paginator.count }} Unvisited Households</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="card-title mb-3 text-primary">Filters</h5>
|
||||||
|
<form action="." method="GET" class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small text-muted fw-bold">District</label>
|
||||||
|
<input type="text" name="district" class="form-control" placeholder="Filter by district..." value="{{ district_filter }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small text-muted fw-bold">Neighborhood</label>
|
||||||
|
<input type="text" name="neighborhood" class="form-control" placeholder="Partial neighborhood..." value="{{ neighborhood_filter }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small text-muted fw-bold">Address</label>
|
||||||
|
<input type="text" name="address" class="form-control" placeholder="Partial address..." value="{{ address_filter }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex align-items-end">
|
||||||
|
<button type="submit" class="btn btn-primary w-100 py-2">Apply Filters</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm overflow-hidden">
|
||||||
|
<div class="card-header bg-white py-3 border-bottom">
|
||||||
|
<h5 class="mb-0">Unvisited Households</h5>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-4" style="min-width: 250px;">Target Voters</th>
|
||||||
|
<th>Neighborhood</th>
|
||||||
|
<th>Address</th>
|
||||||
|
<th>City, State</th>
|
||||||
|
<th class="text-end pe-4">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for household in households %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-4">
|
||||||
|
{% for voter in household.target_voters %}
|
||||||
|
<a href="{% url 'voter_detail' voter.id %}" class="fw-semibold text-primary text-decoration-none hover-underline">
|
||||||
|
{{ voter.first_name }} {{ voter.last_name }}
|
||||||
|
</a>{% if not forloop.last %}<span class="text-muted">, </span>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if household.neighborhood %}
|
||||||
|
<span class="badge bg-primary-subtle text-primary border border-primary-subtle px-2 py-1">{{ household.neighborhood }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small italic">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><span class="fw-medium text-dark">{{ household.address_street }}</span></td>
|
||||||
|
<td>{{ household.city }}, {{ household.state }}</td>
|
||||||
|
<td class="text-end pe-4">
|
||||||
|
<button type="button" class="btn btn-success btn-sm px-3 shadow-sm"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#logVisitModal"
|
||||||
|
data-address="{{ household.address_street }}"
|
||||||
|
data-city="{{ household.city }}"
|
||||||
|
data-state="{{ household.state }}"
|
||||||
|
data-zip="{{ household.zip_code }}">
|
||||||
|
<i class="bi bi-journal-check me-1"></i> Log Visit
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-5">
|
||||||
|
<div class="text-muted">
|
||||||
|
<i class="bi bi-house-door fs-1 text-secondary opacity-25 mb-3 d-block"></i>
|
||||||
|
<p class="mb-0 fs-5">No unvisited households found.</p>
|
||||||
|
<p class="small text-muted">Try adjusting your filters or targeting more voters.</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if households.paginator.num_pages > 1 %}
|
||||||
|
<div class="card-footer bg-white border-top py-4">
|
||||||
|
<nav aria-label="Page navigation">
|
||||||
|
<ul class="pagination justify-content-center mb-0">
|
||||||
|
{% if households.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page=1{% if district_filter %}&district={{ district_filter }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter }}{% endif %}{% if address_filter %}&address={{ address_filter }}{% endif %}" aria-label="First">
|
||||||
|
<span aria-hidden="true">««</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ households.previous_page_number }}{% if district_filter %}&district={{ district_filter }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter }}{% endif %}{% if address_filter %}&address={{ address_filter }}{% endif %}" aria-label="Previous">
|
||||||
|
<span aria-hidden="true">«</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for num in households.paginator.page_range %}
|
||||||
|
{% if households.number == num %}
|
||||||
|
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
|
||||||
|
{% elif num > households.number|add:'-3' and num < households.number|add:'3' %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ num }}{% if district_filter %}&district={{ district_filter }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter }}{% endif %}{% if address_filter %}&address={{ address_filter }}{% endif %}">{{ num }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if households.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ households.next_page_number }}{% if district_filter %}&district={{ district_filter }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter }}{% endif %}{% if address_filter %}&address={{ address_filter }}{% endif %}" aria-label="Next">
|
||||||
|
<span aria-hidden="true">»</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ households.paginator.num_pages }}{% if district_filter %}&district={{ district_filter }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter }}{% endif %}{% if address_filter %}&address={{ address_filter }}{% endif %}" aria-label="Last">
|
||||||
|
<span aria-hidden="true">»»</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log Visit Modal -->
|
||||||
|
<div class="modal fade" id="logVisitModal" tabindex="-1" aria-labelledby="logVisitModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content border-0 shadow-lg">
|
||||||
|
<form action="{% url 'log_door_visit' %}" method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-header bg-primary text-white border-0">
|
||||||
|
<h5 class="modal-title" id="logVisitModalLabel"><i class="bi bi-journal-plus me-2"></i>Log Door Visit</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<div class="mb-4 bg-light p-3 rounded border">
|
||||||
|
<p class="mb-0 small text-muted text-uppercase fw-bold ls-1">Address</p>
|
||||||
|
<p id="modalAddressDisplay" class="mb-0 fw-bold text-dark fs-5"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden fields for address -->
|
||||||
|
<input type="hidden" name="address_street" id="modal_address_street">
|
||||||
|
<input type="hidden" name="city" id="modal_city">
|
||||||
|
<input type="hidden" name="state" id="modal_state">
|
||||||
|
<input type="hidden" name="zip_code" id="modal_zip_code">
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-bold text-primary small text-uppercase">Outcome</label>
|
||||||
|
<div class="bg-light p-3 rounded border">
|
||||||
|
{% for radio in visit_form.outcome %}
|
||||||
|
<div class="form-check mb-2 last-child-mb-0">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<label class="form-check-label fw-medium" for="{{ radio.id_for_label }}">
|
||||||
|
{{ radio.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="{{ visit_form.notes.id_for_label }}" class="form-label fw-bold text-primary small text-uppercase">Notes / Conversation Summary</label>
|
||||||
|
{{ visit_form.notes }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-bold text-primary small text-uppercase">Support Status</label>
|
||||||
|
{{ visit_form.candidate_support }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-flex align-items-end">
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
{{ visit_form.wants_yard_sign }}
|
||||||
|
<label class="form-check-label fw-bold text-dark" for="{{ visit_form.wants_yard_sign.id_for_label }}">
|
||||||
|
Wants a Yard Sign
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer bg-light border-0 py-3">
|
||||||
|
<button type="button" class="btn btn-outline-secondary px-4" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary px-4 shadow-sm">Save Visit</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var logVisitModal = document.getElementById('logVisitModal');
|
||||||
|
if (logVisitModal) {
|
||||||
|
logVisitModal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
var button = event.relatedTarget;
|
||||||
|
var address = button.getAttribute('data-address');
|
||||||
|
var city = button.getAttribute('data-city');
|
||||||
|
var state = button.getAttribute('data-state');
|
||||||
|
var zip = button.getAttribute('data-zip');
|
||||||
|
|
||||||
|
document.getElementById('modalAddressDisplay').textContent = address + ', ' + city + ', ' + state + ' ' + zip;
|
||||||
|
document.getElementById('modal_address_street').value = address;
|
||||||
|
document.getElementById('modal_city').value = city;
|
||||||
|
document.getElementById('modal_state').value = state;
|
||||||
|
document.getElementById('modal_zip_code').value = zip;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hover-underline:hover {
|
||||||
|
text-decoration: underline !important;
|
||||||
|
}
|
||||||
|
.italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.bg-primary-subtle {
|
||||||
|
background-color: #e7f1ff;
|
||||||
|
}
|
||||||
|
.text-primary {
|
||||||
|
color: #0d6efd !important;
|
||||||
|
}
|
||||||
|
.border-primary-subtle {
|
||||||
|
border-color: #9ec5fe !important;
|
||||||
|
}
|
||||||
|
.last-child-mb-0:last-child {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
.ls-1 {
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@ -34,6 +34,12 @@
|
|||||||
<label class="small text-muted text-uppercase fw-bold d-block">Type</label>
|
<label class="small text-muted text-uppercase fw-bold d-block">Type</label>
|
||||||
<span>{{ event.event_type }}</span>
|
<span>{{ event.event_type }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% if event.default_volunteer_role %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="small text-muted text-uppercase fw-bold d-block">Default Volunteer Role</label>
|
||||||
|
<span class="badge bg-primary bg-opacity-10 text-primary border border-primary">{{ event.default_volunteer_role }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="small text-muted text-uppercase fw-bold d-block">Date</label>
|
<label class="small text-muted text-uppercase fw-bold d-block">Date</label>
|
||||||
<span>{{ event.date|date:"F d, Y" }}</span>
|
<span>{{ event.date|date:"F d, Y" }}</span>
|
||||||
@ -97,7 +103,11 @@
|
|||||||
{{ v.volunteer.first_name }} {{ v.volunteer.last_name }}
|
{{ v.volunteer.first_name }} {{ v.volunteer.last_name }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="small">{{ v.role }}</span></td>
|
<td>
|
||||||
|
<span class="small">
|
||||||
|
{{ v.role_type|default:"Assigned" }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td class="pe-4 text-end">
|
<td class="pe-4 text-end">
|
||||||
<form action="{% url 'event_remove_volunteer' v.id %}" method="POST" onsubmit="return confirm('Remove this volunteer?')">
|
<form action="{% url 'event_remove_volunteer' v.id %}" method="POST" onsubmit="return confirm('Remove this volunteer?')">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@ -229,7 +239,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer border-0 p-4 pt-0">
|
<div class="modal-footer border-0 p-4 pt-0">
|
||||||
<button type="button" class="btn btn-outline-secondary rounded-pill px-4" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary rounded-pill px-4" data-bs-modal="modal">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary rounded-pill px-4 shadow-sm" id="submitAddParticipant" disabled>Add Participant</button>
|
<button type="submit" class="btn btn-primary rounded-pill px-4 shadow-sm" id="submitAddParticipant" disabled>Add Participant</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -269,9 +279,8 @@
|
|||||||
<input type="hidden" name="volunteer" id="volunteer_id_hidden" required>
|
<input type="hidden" name="volunteer" id="volunteer_id_hidden" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-0">
|
<div class="mb-0">
|
||||||
<label for="{{ add_volunteer_form.role.id_for_label }}" class="form-label fw-bold">Role</label>
|
<label for="{{ add_volunteer_form.role_type.id_for_label }}" class="form-label fw-bold">Role Type</label>
|
||||||
{{ add_volunteer_form.role }}
|
{{ add_volunteer_form.role_type }}
|
||||||
<div class="form-text small">e.g., Driver, Caller, Organizer</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">
|
||||||
@ -296,42 +305,44 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
let debounceTimer;
|
let debounceTimer;
|
||||||
|
|
||||||
searchInput.addEventListener('input', function() {
|
if (searchInput) {
|
||||||
clearTimeout(debounceTimer);
|
searchInput.addEventListener('input', function() {
|
||||||
const query = this.value.trim();
|
clearTimeout(debounceTimer);
|
||||||
|
const query = this.value.trim();
|
||||||
if (query.length < 2) {
|
|
||||||
resultsContainer.classList.add('d-none');
|
if (query.length < 2) {
|
||||||
return;
|
resultsContainer.classList.add('d-none');
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
debounceTimer = setTimeout(() => {
|
|
||||||
fetch(`/voters/search/json/?q=${encodeURIComponent(query)}`)
|
debounceTimer = setTimeout(() => {
|
||||||
.then(response => response.json())
|
fetch(`/voters/search/json/?q=${encodeURIComponent(query)}`)
|
||||||
.then(data => {
|
.then(response => response.json())
|
||||||
resultsContainer.innerHTML = '';
|
.then(data => {
|
||||||
if (data.results && data.results.length > 0) {
|
resultsContainer.innerHTML = '';
|
||||||
data.results.forEach(voter => {
|
if (data.results && data.results.length > 0) {
|
||||||
const btn = document.createElement('button');
|
data.results.forEach(voter => {
|
||||||
btn.type = 'button';
|
const btn = document.createElement('button');
|
||||||
btn.className = 'list-group-item list-group-item-action py-2';
|
btn.type = 'button';
|
||||||
btn.innerHTML = `<div class="fw-bold text-dark">${voter.text}</div><div class="small text-muted">${voter.address || "No address"} | ${voter.phone || "No phone"}</div>`;
|
btn.className = 'list-group-item list-group-item-action py-2';
|
||||||
btn.addEventListener('click', () => {
|
btn.innerHTML = `<div class="fw-bold text-dark">${voter.text}</div><div class="small text-muted">${voter.address || "No address"} | ${voter.phone || "No phone"}</div>`;
|
||||||
selectVoter(voter.id, voter.text, voter.address, voter.phone);
|
btn.addEventListener('click', () => {
|
||||||
|
selectVoter(voter.id, voter.text, voter.address, voter.phone);
|
||||||
|
});
|
||||||
|
resultsContainer.appendChild(btn);
|
||||||
});
|
});
|
||||||
resultsContainer.appendChild(btn);
|
resultsContainer.classList.remove('d-none');
|
||||||
});
|
} else {
|
||||||
resultsContainer.classList.remove('d-none');
|
const div = document.createElement('div');
|
||||||
} else {
|
div.className = 'list-group-item text-muted small py-2';
|
||||||
const div = document.createElement('div');
|
div.textContent = 'No results found';
|
||||||
div.className = 'list-group-item text-muted small py-2';
|
resultsContainer.appendChild(div);
|
||||||
div.textContent = 'No results found';
|
resultsContainer.classList.remove('d-none');
|
||||||
resultsContainer.appendChild(div);
|
}
|
||||||
resultsContainer.classList.remove('d-none');
|
});
|
||||||
}
|
}, 300);
|
||||||
});
|
});
|
||||||
}, 300);
|
}
|
||||||
});
|
|
||||||
|
|
||||||
function selectVoter(id, text, address, phone) {
|
function selectVoter(id, text, address, phone) {
|
||||||
hiddenVoterId.value = id;
|
hiddenVoterId.value = id;
|
||||||
@ -342,14 +353,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearBtn.addEventListener('click', () => {
|
if (clearBtn) {
|
||||||
hiddenVoterId.value = '';
|
clearBtn.addEventListener('click', () => {
|
||||||
voterNameDisplay.textContent = '';
|
hiddenVoterId.value = '';
|
||||||
selectedDisplay.classList.add('d-none');
|
voterNameDisplay.textContent = '';
|
||||||
searchInput.parentElement.classList.remove('d-none');
|
selectedDisplay.classList.add('d-none');
|
||||||
searchInput.value = '';
|
searchInput.parentElement.classList.remove('d-none');
|
||||||
submitBtn.disabled = true;
|
searchInput.value = '';
|
||||||
});
|
submitBtn.disabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Volunteer Search Logic
|
// Volunteer Search Logic
|
||||||
const volSearchInput = document.getElementById('volunteerSearchInput');
|
const volSearchInput = document.getElementById('volunteerSearchInput');
|
||||||
@ -362,42 +375,44 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
let volDebounceTimer;
|
let volDebounceTimer;
|
||||||
|
|
||||||
volSearchInput.addEventListener('input', function() {
|
if (volSearchInput) {
|
||||||
clearTimeout(volDebounceTimer);
|
volSearchInput.addEventListener('input', function() {
|
||||||
const query = this.value.trim();
|
clearTimeout(volDebounceTimer);
|
||||||
|
const query = this.value.trim();
|
||||||
if (query.length < 2) {
|
|
||||||
volResultsContainer.classList.add('d-none');
|
if (query.length < 2) {
|
||||||
return;
|
volResultsContainer.classList.add('d-none');
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
volDebounceTimer = setTimeout(() => {
|
|
||||||
fetch(`/volunteers/search/json/?q=${encodeURIComponent(query)}`)
|
volDebounceTimer = setTimeout(() => {
|
||||||
.then(response => response.json())
|
fetch(`/volunteers/search/json/?q=${encodeURIComponent(query)}`)
|
||||||
.then(data => {
|
.then(response => response.json())
|
||||||
volResultsContainer.innerHTML = '';
|
.then(data => {
|
||||||
if (data.results && data.results.length > 0) {
|
volResultsContainer.innerHTML = '';
|
||||||
data.results.forEach(vol => {
|
if (data.results && data.results.length > 0) {
|
||||||
const btn = document.createElement('button');
|
data.results.forEach(vol => {
|
||||||
btn.type = 'button';
|
const btn = document.createElement('button');
|
||||||
btn.className = 'list-group-item list-group-item-action py-2';
|
btn.type = 'button';
|
||||||
btn.innerHTML = `<div class="fw-bold text-dark">${vol.text}</div><div class="small text-muted">${vol.phone || "No phone"}</div>`;
|
btn.className = 'list-group-item list-group-item-action py-2';
|
||||||
btn.addEventListener('click', () => {
|
btn.innerHTML = `<div class="fw-bold text-dark">${vol.text}</div><div class="small text-muted">${vol.phone || "No phone"}</div>`;
|
||||||
selectVolunteer(vol.id, vol.text, vol.phone);
|
btn.addEventListener('click', () => {
|
||||||
|
selectVolunteer(vol.id, vol.text, vol.phone);
|
||||||
|
});
|
||||||
|
volResultsContainer.appendChild(btn);
|
||||||
});
|
});
|
||||||
volResultsContainer.appendChild(btn);
|
volResultsContainer.classList.remove('d-none');
|
||||||
});
|
} else {
|
||||||
volResultsContainer.classList.remove('d-none');
|
const div = document.createElement('div');
|
||||||
} else {
|
div.className = 'list-group-item text-muted small py-2';
|
||||||
const div = document.createElement('div');
|
div.textContent = 'No results found';
|
||||||
div.className = 'list-group-item text-muted small py-2';
|
volResultsContainer.appendChild(div);
|
||||||
div.textContent = 'No results found';
|
volResultsContainer.classList.remove('d-none');
|
||||||
volResultsContainer.appendChild(div);
|
}
|
||||||
volResultsContainer.classList.remove('d-none');
|
});
|
||||||
}
|
}, 300);
|
||||||
});
|
});
|
||||||
}, 300);
|
}
|
||||||
});
|
|
||||||
|
|
||||||
function selectVolunteer(id, text, phone) {
|
function selectVolunteer(id, text, phone) {
|
||||||
volHiddenId.value = id;
|
volHiddenId.value = id;
|
||||||
@ -408,21 +423,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
volSubmitBtn.disabled = false;
|
volSubmitBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
volClearBtn.addEventListener('click', () => {
|
if (volClearBtn) {
|
||||||
volHiddenId.value = '';
|
volClearBtn.addEventListener('click', () => {
|
||||||
volNameDisplay.textContent = '';
|
volHiddenId.value = '';
|
||||||
volSelectedDisplay.classList.add('d-none');
|
volNameDisplay.textContent = '';
|
||||||
volSearchInput.parentElement.classList.remove('d-none');
|
volSelectedDisplay.classList.add('d-none');
|
||||||
volSearchInput.value = '';
|
volSearchInput.parentElement.classList.remove('d-none');
|
||||||
volSubmitBtn.disabled = true;
|
volSearchInput.value = '';
|
||||||
});
|
volSubmitBtn.disabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Close results when clicking outside
|
// Close results when clicking outside
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (!resultsContainer.contains(e.target) && e.target !== searchInput) {
|
if (resultsContainer && !resultsContainer.contains(e.target) && e.target !== searchInput) {
|
||||||
resultsContainer.classList.add('d-none');
|
resultsContainer.classList.add('d-none');
|
||||||
}
|
}
|
||||||
if (!volResultsContainer.contains(e.target) && e.target !== volSearchInput) {
|
if (volResultsContainer && !volResultsContainer.contains(e.target) && e.target !== volSearchInput) {
|
||||||
volResultsContainer.classList.add('d-none');
|
volResultsContainer.classList.add('d-none');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -39,6 +39,13 @@
|
|||||||
<div class="text-danger small">{{ form.event_type.errors }}</div>
|
<div class="text-danger small">{{ form.event_type.errors }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="{{ form.default_volunteer_role.id_for_label }}" class="form-label fw-bold">Default Volunteer Role</label>
|
||||||
|
{{ form.default_volunteer_role }}
|
||||||
|
{% if form.default_volunteer_role.errors %}
|
||||||
|
<div class="text-danger small">{{ form.default_volunteer_role.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="{{ form.date.id_for_label }}" class="form-label fw-bold">Date</label>
|
<label for="{{ form.date.id_for_label }}" class="form-label fw-bold">Date</label>
|
||||||
{{ form.date }}
|
{{ form.date }}
|
||||||
|
|||||||
@ -52,4 +52,8 @@ urlpatterns = [
|
|||||||
path('volunteers/bulk-sms/', views.volunteer_bulk_send_sms, name='volunteer_bulk_send_sms'),
|
path('volunteers/bulk-sms/', views.volunteer_bulk_send_sms, name='volunteer_bulk_send_sms'),
|
||||||
path('events/<int:event_id>/volunteer/add/', views.event_add_volunteer, name='event_add_volunteer'),
|
path('events/<int:event_id>/volunteer/add/', views.event_add_volunteer, name='event_add_volunteer'),
|
||||||
path('events/volunteer/<int:assignment_id>/delete/', views.event_remove_volunteer, name='event_remove_volunteer'),
|
path('events/volunteer/<int:assignment_id>/delete/', views.event_remove_volunteer, name='event_remove_volunteer'),
|
||||||
|
|
||||||
|
# Door Visits
|
||||||
|
path('door-visits/', views.door_visits, name='door_visits'),
|
||||||
|
path('door-visits/log/', views.log_door_visit, name='log_door_visit'),
|
||||||
]
|
]
|
||||||
174
core/views.py
174
core/views.py
@ -10,9 +10,10 @@ from django.shortcuts import render, redirect, get_object_or_404
|
|||||||
from django.db.models import Q, Sum
|
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 .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest
|
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
|
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm
|
||||||
import logging
|
import logging
|
||||||
|
import zoneinfo
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -700,7 +701,10 @@ def event_detail(request, event_id):
|
|||||||
# Form for adding a new participant
|
# Form for adding a new participant
|
||||||
add_form = EventParticipantAddForm(tenant=tenant)
|
add_form = EventParticipantAddForm(tenant=tenant)
|
||||||
# Form for adding a new volunteer
|
# Form for adding a new volunteer
|
||||||
add_volunteer_form = VolunteerEventAddForm(tenant=tenant)
|
default_role = event.default_volunteer_role
|
||||||
|
if not default_role and event.event_type:
|
||||||
|
default_role = event.event_type.default_volunteer_role
|
||||||
|
add_volunteer_form = VolunteerEventAddForm(tenant=tenant, initial={'role_type': default_role})
|
||||||
|
|
||||||
participation_statuses = ParticipationStatus.objects.filter(tenant=tenant, is_active=True)
|
participation_statuses = ParticipationStatus.objects.filter(tenant=tenant, is_active=True)
|
||||||
|
|
||||||
@ -1163,3 +1167,167 @@ def volunteer_bulk_send_sms(request):
|
|||||||
|
|
||||||
messages.success(request, f"Bulk SMS process completed: {success_count} successful, {fail_count} failed/skipped.")
|
messages.success(request, f"Bulk SMS process completed: {success_count} successful, {fail_count} failed/skipped.")
|
||||||
return redirect('volunteer_list')
|
return redirect('volunteer_list')
|
||||||
|
|
||||||
|
def door_visits(request):
|
||||||
|
"""
|
||||||
|
Manage door knocking visits. Groups unvisited targeted voters by household.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Filters from GET parameters
|
||||||
|
district_filter = request.GET.get('district', '').strip()
|
||||||
|
neighborhood_filter = request.GET.get('neighborhood', '').strip()
|
||||||
|
address_filter = request.GET.get('address', '').strip()
|
||||||
|
|
||||||
|
# Initial queryset: unvisited targeted voters for this tenant
|
||||||
|
voters = Voter.objects.filter(tenant=tenant, door_visit=False, is_targeted=True)
|
||||||
|
|
||||||
|
# Apply filters if provided
|
||||||
|
if district_filter:
|
||||||
|
voters = voters.filter(district__icontains=district_filter)
|
||||||
|
if neighborhood_filter:
|
||||||
|
voters = voters.filter(neighborhood__icontains=neighborhood_filter)
|
||||||
|
if address_filter:
|
||||||
|
voters = voters.filter(Q(address_street__icontains=address_filter) | Q(address__icontains=address_filter))
|
||||||
|
|
||||||
|
# Grouping by household (unique address)
|
||||||
|
households_dict = {}
|
||||||
|
for voter in voters:
|
||||||
|
# Key for grouping is the unique address components
|
||||||
|
key = (voter.address_street, voter.city, voter.state, voter.zip_code)
|
||||||
|
if key not in households_dict:
|
||||||
|
# Parse street name and number for sorting
|
||||||
|
street_number = ""
|
||||||
|
street_name = voter.address_street
|
||||||
|
match = re.match(r'^(\d+)\s+(.*)$', voter.address_street)
|
||||||
|
if match:
|
||||||
|
street_number = match.group(1)
|
||||||
|
street_name = match.group(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
street_number_sort = int(street_number)
|
||||||
|
except ValueError:
|
||||||
|
street_number_sort = 0
|
||||||
|
|
||||||
|
households_dict[key] = {
|
||||||
|
'address_street': voter.address_street,
|
||||||
|
'city': voter.city,
|
||||||
|
'state': voter.state,
|
||||||
|
'zip_code': voter.zip_code,
|
||||||
|
'neighborhood': voter.neighborhood,
|
||||||
|
'district': voter.district,
|
||||||
|
'street_name_sort': street_name.lower(),
|
||||||
|
'street_number_sort': street_number_sort,
|
||||||
|
'target_voters': []
|
||||||
|
}
|
||||||
|
households_dict[key]['target_voters'].append(voter)
|
||||||
|
|
||||||
|
households_list = list(households_dict.values())
|
||||||
|
households_list.sort(key=lambda x: (
|
||||||
|
(x['neighborhood'] or '').lower(),
|
||||||
|
x['street_name_sort'],
|
||||||
|
x['street_number_sort']
|
||||||
|
))
|
||||||
|
|
||||||
|
paginator = Paginator(households_list, 50)
|
||||||
|
page_number = request.GET.get('page')
|
||||||
|
households_page = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'selected_tenant': tenant,
|
||||||
|
'households': households_page,
|
||||||
|
'district_filter': district_filter,
|
||||||
|
'neighborhood_filter': neighborhood_filter,
|
||||||
|
'address_filter': address_filter,
|
||||||
|
'visit_form': DoorVisitLogForm(),
|
||||||
|
}
|
||||||
|
return render(request, 'core/door_visits.html', context)
|
||||||
|
|
||||||
|
def log_door_visit(request):
|
||||||
|
"""
|
||||||
|
Mark all targeted voters at a specific address as visited, update their flags,
|
||||||
|
and create interaction records.
|
||||||
|
"""
|
||||||
|
selected_tenant_id = request.session.get("tenant_id")
|
||||||
|
if not selected_tenant_id:
|
||||||
|
return redirect('index')
|
||||||
|
|
||||||
|
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
|
||||||
|
campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant)
|
||||||
|
|
||||||
|
# Get the volunteer linked to the current user
|
||||||
|
volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = DoorVisitLogForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
address_street = request.POST.get('address_street')
|
||||||
|
city = request.POST.get('city')
|
||||||
|
state = request.POST.get('state')
|
||||||
|
zip_code = request.POST.get('zip_code')
|
||||||
|
|
||||||
|
outcome = form.cleaned_data['outcome']
|
||||||
|
notes = form.cleaned_data['notes']
|
||||||
|
wants_yard_sign = form.cleaned_data['wants_yard_sign']
|
||||||
|
candidate_support = form.cleaned_data['candidate_support']
|
||||||
|
|
||||||
|
# Determine date/time in campaign timezone
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Get or create InteractionType
|
||||||
|
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Door Visit")
|
||||||
|
|
||||||
|
# Find targeted voters at this exact address
|
||||||
|
voters = Voter.objects.filter(
|
||||||
|
tenant=tenant,
|
||||||
|
address_street=address_street,
|
||||||
|
city=city,
|
||||||
|
state=state,
|
||||||
|
zip_code=zip_code,
|
||||||
|
is_targeted=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not voters.exists():
|
||||||
|
messages.warning(request, f"No targeted voters found at {address_street}.")
|
||||||
|
return redirect('door_visits')
|
||||||
|
|
||||||
|
for voter in voters:
|
||||||
|
# 1) Update voter flags
|
||||||
|
voter.door_visit = True
|
||||||
|
|
||||||
|
# 2) If "Wants a Yard Sign" checkbox is selected
|
||||||
|
if wants_yard_sign:
|
||||||
|
voter.yard_sign = 'wants'
|
||||||
|
|
||||||
|
# 3) Update support status if Supporting or Not Supporting
|
||||||
|
if candidate_support in ['supporting', 'not_supporting']:
|
||||||
|
voter.candidate_support = candidate_support
|
||||||
|
|
||||||
|
voter.save()
|
||||||
|
|
||||||
|
# 4) Create interaction
|
||||||
|
Interaction.objects.create(
|
||||||
|
voter=voter,
|
||||||
|
volunteer=volunteer,
|
||||||
|
type=interaction_type,
|
||||||
|
date=interaction_date,
|
||||||
|
description=outcome,
|
||||||
|
notes=notes
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.success(request, f"Door visit logged for {address_street}.")
|
||||||
|
else:
|
||||||
|
messages.error(request, "There was an error in the visit log form.")
|
||||||
|
|
||||||
|
return redirect('door_visits')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user