Autosave: 20260201-034149

This commit is contained in:
Flatlogic Bot 2026-02-01 03:41:49 +00:00
parent f7bc2da356
commit 77709c3744
30 changed files with 825 additions and 115 deletions

View File

@ -16,7 +16,7 @@ from .models import (
format_phone_number,
Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,
VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings,
Interest, Volunteer, VolunteerEvent, ParticipationStatus
Interest, Volunteer, VolunteerEvent, ParticipationStatus, VolunteerRole
)
from .forms import (
VoterImportForm, EventImportForm, EventParticipationImportForm,
@ -179,6 +179,12 @@ class DonationMethodAdmin(admin.ModelAdmin):
list_filter = ('tenant', 'is_active')
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)
class ElectionTypeAdmin(admin.ModelAdmin):
list_display = ('name', 'tenant', 'is_active')
@ -187,9 +193,10 @@ class ElectionTypeAdmin(admin.ModelAdmin):
@admin.register(EventType)
class EventTypeAdmin(admin.ModelAdmin):
list_display = ('name', 'tenant', 'is_active')
list_display = ('name', 'tenant', 'is_active', 'default_volunteer_role')
list_filter = ('tenant', 'is_active')
search_fields = ('name',)
filter_horizontal = ('available_roles',)
@admin.register(ParticipationStatus)
@ -223,6 +230,7 @@ class DonationInline(admin.TabularInline):
class InteractionInline(admin.TabularInline):
model = Interaction
extra = 1
autocomplete_fields = ['voter', 'type', 'volunteer']
class VoterLikelihoodInline(admin.TabularInline):
model = VoterLikelihood
@ -913,8 +921,8 @@ class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
@admin.register(VolunteerEvent)
class VolunteerEventAdmin(admin.ModelAdmin):
list_display = ('volunteer', 'event', 'role')
list_filter = ('event__tenant', 'event', 'role')
list_display = ('volunteer', 'event', 'role_type')
list_filter = ('event__tenant', 'event', 'role_type')
autocomplete_fields = ["volunteer", "event"]
@admin.register(EventParticipation)
@ -1798,9 +1806,9 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
@admin.register(CampaignSettings)
class CampaignSettingsAdmin(admin.ModelAdmin):
list_display = ('tenant', 'donation_goal', 'twilio_from_number')
list_display = ('tenant', 'donation_goal', 'twilio_from_number', 'timezone')
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)
class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):

View File

@ -6,9 +6,9 @@ class VoterForm(forms.ModelForm):
model = Voter
fields = [
'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',
'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 = {
'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")
city = forms.CharField(required=False)
zip_code = forms.CharField(required=False)
neighborhood = forms.CharField(required=False)
district = forms.CharField(required=False)
precinct = forms.CharField(required=False)
phone_type = forms.ChoiceField(
@ -55,6 +56,7 @@ class AdvancedVoterSearchForm(forms.Form):
required=False
)
is_targeted = forms.BooleanField(required=False, label="Targeted Only")
door_visit = forms.BooleanField(required=False, label="Visited Only")
candidate_support = forms.ChoiceField(
choices=[('', 'Any')] + Voter.SUPPORT_CHOICES,
required=False
@ -170,7 +172,7 @@ class EventParticipantAddForm(forms.ModelForm):
class EventForm(forms.ModelForm):
class Meta:
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 = {
'date': forms.DateInput(attrs={'type': 'date'}),
'start_time': forms.TimeInput(attrs={'type': 'time'}),
@ -182,9 +184,11 @@ class EventForm(forms.ModelForm):
super().__init__(*args, **kwargs)
if tenant:
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():
field.widget.attrs.update({'class': 'form-control'})
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):
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
@ -270,7 +274,7 @@ class VolunteerForm(forms.ModelForm):
class VolunteerEventForm(forms.ModelForm):
class Meta:
model = VolunteerEvent
fields = ['event', 'role']
fields = ['event', 'role_type']
def __init__(self, *args, tenant=None, **kwargs):
super().__init__(*args, **kwargs)
@ -283,7 +287,7 @@ class VolunteerEventForm(forms.ModelForm):
class VolunteerEventAddForm(forms.ModelForm):
class Meta:
model = VolunteerEvent
fields = ['volunteer', 'role']
fields = ['volunteer', 'role_type']
def __init__(self, *args, tenant=None, **kwargs):
super().__init__(*args, **kwargs)
@ -304,4 +308,31 @@ class VotingRecordImportForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
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"
)

View File

@ -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'),
),
]

View 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),
),
]

View 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',
),
]

View 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'),
),
]

View File

@ -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),
),
]

View File

@ -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',
),
]

View 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),
),
]

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,4 @@
import zoneinfo
from django.db import models
from django.contrib.auth.models import User
import json
@ -77,8 +78,20 @@ class ElectionType(models.Model):
def __str__(self):
return self.name
class EventType(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='event_types')
class ParticipationStatus(models.Model):
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)
is_active = models.BooleanField(default=True)
@ -88,14 +101,15 @@ class EventType(models.Model):
def __str__(self):
return self.name
class ParticipationStatus(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='participation_statuses')
class EventType(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='event_types')
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)
class Meta:
unique_together = ('tenant', 'name')
verbose_name_plural = 'Participation Statuses'
def __str__(self):
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)
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)
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)
@ -288,6 +304,7 @@ class Event(models.Model):
start_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)
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)
location_name = 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):
volunteer = models.ForeignKey(Volunteer, on_delete=models.CASCADE, related_name="event_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):
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):
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_auth_token = models.CharField(max_length=100, blank=True, default='89ec830d0fa02ab0afa6c76084865713')
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:
verbose_name = 'Campaign Settings'

View File

@ -40,6 +40,9 @@
<li class="nav-item">
<a class="nav-link" href="/voters/">Voters</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/door-visits/">Door Visits</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/events/">Events</a>
</li>

View 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">&laquo;&laquo;</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">&laquo;</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">&raquo;</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">&raquo;&raquo;</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 %}

View File

@ -34,6 +34,12 @@
<label class="small text-muted text-uppercase fw-bold d-block">Type</label>
<span>{{ event.event_type }}</span>
</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">
<label class="small text-muted text-uppercase fw-bold d-block">Date</label>
<span>{{ event.date|date:"F d, Y" }}</span>
@ -97,7 +103,11 @@
{{ v.volunteer.first_name }} {{ v.volunteer.last_name }}
</a>
</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">
<form action="{% url 'event_remove_volunteer' v.id %}" method="POST" onsubmit="return confirm('Remove this volunteer?')">
{% csrf_token %}
@ -229,7 +239,7 @@
</div>
</div>
<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>
</div>
</form>
@ -269,9 +279,8 @@
<input type="hidden" name="volunteer" id="volunteer_id_hidden" required>
</div>
<div class="mb-0">
<label for="{{ add_volunteer_form.role.id_for_label }}" class="form-label fw-bold">Role</label>
{{ add_volunteer_form.role }}
<div class="form-text small">e.g., Driver, Caller, Organizer</div>
<label for="{{ add_volunteer_form.role_type.id_for_label }}" class="form-label fw-bold">Role Type</label>
{{ add_volunteer_form.role_type }}
</div>
</div>
<div class="modal-footer border-0 p-4 pt-0">
@ -296,42 +305,44 @@ document.addEventListener('DOMContentLoaded', function() {
let debounceTimer;
searchInput.addEventListener('input', function() {
clearTimeout(debounceTimer);
const query = this.value.trim();
if (query.length < 2) {
resultsContainer.classList.add('d-none');
return;
}
debounceTimer = setTimeout(() => {
fetch(`/voters/search/json/?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
resultsContainer.innerHTML = '';
if (data.results && data.results.length > 0) {
data.results.forEach(voter => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'list-group-item list-group-item-action py-2';
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.addEventListener('click', () => {
selectVoter(voter.id, voter.text, voter.address, voter.phone);
if (searchInput) {
searchInput.addEventListener('input', function() {
clearTimeout(debounceTimer);
const query = this.value.trim();
if (query.length < 2) {
resultsContainer.classList.add('d-none');
return;
}
debounceTimer = setTimeout(() => {
fetch(`/voters/search/json/?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
resultsContainer.innerHTML = '';
if (data.results && data.results.length > 0) {
data.results.forEach(voter => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'list-group-item list-group-item-action py-2';
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.addEventListener('click', () => {
selectVoter(voter.id, voter.text, voter.address, voter.phone);
});
resultsContainer.appendChild(btn);
});
resultsContainer.appendChild(btn);
});
resultsContainer.classList.remove('d-none');
} else {
const div = document.createElement('div');
div.className = 'list-group-item text-muted small py-2';
div.textContent = 'No results found';
resultsContainer.appendChild(div);
resultsContainer.classList.remove('d-none');
}
});
}, 300);
});
resultsContainer.classList.remove('d-none');
} else {
const div = document.createElement('div');
div.className = 'list-group-item text-muted small py-2';
div.textContent = 'No results found';
resultsContainer.appendChild(div);
resultsContainer.classList.remove('d-none');
}
});
}, 300);
});
}
function selectVoter(id, text, address, phone) {
hiddenVoterId.value = id;
@ -342,14 +353,16 @@ document.addEventListener('DOMContentLoaded', function() {
submitBtn.disabled = false;
}
clearBtn.addEventListener('click', () => {
hiddenVoterId.value = '';
voterNameDisplay.textContent = '';
selectedDisplay.classList.add('d-none');
searchInput.parentElement.classList.remove('d-none');
searchInput.value = '';
submitBtn.disabled = true;
});
if (clearBtn) {
clearBtn.addEventListener('click', () => {
hiddenVoterId.value = '';
voterNameDisplay.textContent = '';
selectedDisplay.classList.add('d-none');
searchInput.parentElement.classList.remove('d-none');
searchInput.value = '';
submitBtn.disabled = true;
});
}
// Volunteer Search Logic
const volSearchInput = document.getElementById('volunteerSearchInput');
@ -362,42 +375,44 @@ document.addEventListener('DOMContentLoaded', function() {
let volDebounceTimer;
volSearchInput.addEventListener('input', function() {
clearTimeout(volDebounceTimer);
const query = this.value.trim();
if (query.length < 2) {
volResultsContainer.classList.add('d-none');
return;
}
volDebounceTimer = setTimeout(() => {
fetch(`/volunteers/search/json/?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
volResultsContainer.innerHTML = '';
if (data.results && data.results.length > 0) {
data.results.forEach(vol => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'list-group-item list-group-item-action py-2';
btn.innerHTML = `<div class="fw-bold text-dark">${vol.text}</div><div class="small text-muted">${vol.phone || "No phone"}</div>`;
btn.addEventListener('click', () => {
selectVolunteer(vol.id, vol.text, vol.phone);
if (volSearchInput) {
volSearchInput.addEventListener('input', function() {
clearTimeout(volDebounceTimer);
const query = this.value.trim();
if (query.length < 2) {
volResultsContainer.classList.add('d-none');
return;
}
volDebounceTimer = setTimeout(() => {
fetch(`/volunteers/search/json/?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
volResultsContainer.innerHTML = '';
if (data.results && data.results.length > 0) {
data.results.forEach(vol => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'list-group-item list-group-item-action py-2';
btn.innerHTML = `<div class="fw-bold text-dark">${vol.text}</div><div class="small text-muted">${vol.phone || "No phone"}</div>`;
btn.addEventListener('click', () => {
selectVolunteer(vol.id, vol.text, vol.phone);
});
volResultsContainer.appendChild(btn);
});
volResultsContainer.appendChild(btn);
});
volResultsContainer.classList.remove('d-none');
} else {
const div = document.createElement('div');
div.className = 'list-group-item text-muted small py-2';
div.textContent = 'No results found';
volResultsContainer.appendChild(div);
volResultsContainer.classList.remove('d-none');
}
});
}, 300);
});
volResultsContainer.classList.remove('d-none');
} else {
const div = document.createElement('div');
div.className = 'list-group-item text-muted small py-2';
div.textContent = 'No results found';
volResultsContainer.appendChild(div);
volResultsContainer.classList.remove('d-none');
}
});
}, 300);
});
}
function selectVolunteer(id, text, phone) {
volHiddenId.value = id;
@ -408,21 +423,23 @@ document.addEventListener('DOMContentLoaded', function() {
volSubmitBtn.disabled = false;
}
volClearBtn.addEventListener('click', () => {
volHiddenId.value = '';
volNameDisplay.textContent = '';
volSelectedDisplay.classList.add('d-none');
volSearchInput.parentElement.classList.remove('d-none');
volSearchInput.value = '';
volSubmitBtn.disabled = true;
});
if (volClearBtn) {
volClearBtn.addEventListener('click', () => {
volHiddenId.value = '';
volNameDisplay.textContent = '';
volSelectedDisplay.classList.add('d-none');
volSearchInput.parentElement.classList.remove('d-none');
volSearchInput.value = '';
volSubmitBtn.disabled = true;
});
}
// Close results when clicking outside
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');
}
if (!volResultsContainer.contains(e.target) && e.target !== volSearchInput) {
if (volResultsContainer && !volResultsContainer.contains(e.target) && e.target !== volSearchInput) {
volResultsContainer.classList.add('d-none');
}
});

View File

@ -39,6 +39,13 @@
<div class="text-danger small">{{ form.event_type.errors }}</div>
{% endif %}
</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">
<label for="{{ form.date.id_for_label }}" class="form-label fw-bold">Date</label>
{{ form.date }}

View File

@ -52,4 +52,8 @@ urlpatterns = [
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/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'),
]

View File

@ -10,9 +10,10 @@ from django.shortcuts import render, redirect, get_object_or_404
from django.db.models import Q, Sum
from django.contrib import messages
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 .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm
from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest, VolunteerRole
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm
import logging
import zoneinfo
from django.utils import timezone
logger = logging.getLogger(__name__)
@ -700,7 +701,10 @@ def event_detail(request, event_id):
# Form for adding a new participant
add_form = EventParticipantAddForm(tenant=tenant)
# 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)
@ -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.")
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')