diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc
index 82d9cc3..709ba81 100644
Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ
diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc
index ef9dbe1..e5d5174 100644
Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ
diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc
index f61391b..c6c4cdf 100644
Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ
diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc
index 2acb99d..5afc5c8 100644
Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ
diff --git a/core/admin.py b/core/admin.py
index 58dc59c..2ecb813 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -54,6 +54,7 @@ VOTER_MAPPABLE_FIELDS = [
('longitude', 'Longitude'),
('secondary_phone', 'Secondary Phone'),
('secondary_phone_type', 'Secondary Phone Type'),
+ ('door_visit', 'Door Visit'),
]
EVENT_MAPPABLE_FIELDS = [
@@ -428,7 +429,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if val == "" and not created: continue # Skip empty updates for existing records unless specifically desired?
# Type conversion and normalization
- if field_name == "is_targeted":
+ if field_name in ["is_targeted", "door_visit"]:
val = val.lower() in ["true", "1", "yes"]
elif field_name in ["birthdate", "registration_date"]:
parsed_date = None
@@ -794,7 +795,7 @@ class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_filter = ('tenant',)
fields = ('tenant', 'user', 'first_name', 'last_name', 'email', 'phone', 'notes', 'interests')
search_fields = ('first_name', 'last_name', 'email', 'phone')
- inlines = [VolunteerEventInline, InteractionInline]
+ inlines = [VolunteerEventInline]
filter_horizontal = ('interests',)
change_list_template = "admin/volunteer_change_list.html"
diff --git a/core/forms.py b/core/forms.py
index 1b46fd8..f15fd6b 100644
--- a/core/forms.py
+++ b/core/forms.py
@@ -1,4 +1,5 @@
from django import forms
+from django.contrib.auth.models import User
from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType, Tenant, ParticipationStatus, Volunteer, VolunteerEvent, VolunteerRole, ScheduledCall
class VoterForm(forms.ModelForm):
@@ -44,6 +45,7 @@ class AdvancedVoterSearchForm(forms.Form):
first_name = forms.CharField(required=False)
last_name = forms.CharField(required=False)
+ address = forms.CharField(required=False)
voter_id = forms.CharField(required=False, label="Voter ID")
birth_month = forms.ChoiceField(choices=MONTH_CHOICES, required=False, label="Birth Month")
city = forms.CharField(required=False)
@@ -310,6 +312,7 @@ class VotingRecordImportForm(forms.Form):
super().__init__(*args, **kwargs)
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
self.fields['file'].widget.attrs.update({'class': 'form-control'})
+
class DoorVisitLogForm(forms.Form):
OUTCOME_CHOICES = [
("No Answer Left Literature", "No Answer Left Literature"),
@@ -337,6 +340,17 @@ class DoorVisitLogForm(forms.Form):
widget=forms.Select(attrs={"class": "form-select"}),
label="Candidate Support"
)
+ follow_up = forms.BooleanField(
+ required=False,
+ widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
+ label="Follow Up"
+ )
+ follow_up_voter = forms.CharField( required=False, widget=forms.Select(attrs={"class": "form-select"}), label="Voter to Follow Up")
+ call_notes = forms.CharField(
+ widget=forms.Textarea(attrs={"class": "form-control", "rows": 2}),
+ required=False,
+ label="Call Notes"
+ )
class ScheduledCallForm(forms.ModelForm):
class Meta:
@@ -356,3 +370,23 @@ class ScheduledCallForm(forms.ModelForm):
for field in self.fields.values():
field.widget.attrs.update({'class': 'form-control'})
self.fields['volunteer'].widget.attrs.update({'class': 'form-select'})
+
+class UserUpdateForm(forms.ModelForm):
+ class Meta:
+ model = User
+ fields = ['first_name', 'last_name', 'email']
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ for field in self.fields.values():
+ field.widget.attrs.update({'class': 'form-control'})
+
+class VolunteerProfileForm(forms.ModelForm):
+ class Meta:
+ model = Volunteer
+ fields = ['phone']
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ for field in self.fields.values():
+ field.widget.attrs.update({'class': 'form-control'})
diff --git a/core/templates/base.html b/core/templates/base.html
index fd94cde..87933dd 100644
--- a/core/templates/base.html
+++ b/core/templates/base.html
@@ -47,15 +47,27 @@
{% endif %}
diff --git a/core/templates/core/door_visits.html b/core/templates/core/door_visits.html
index 36c23ef..8d139ce 100644
--- a/core/templates/core/door_visits.html
+++ b/core/templates/core/door_visits.html
@@ -72,7 +72,8 @@
data-address="{{ household.address_street }}"
data-city="{{ household.city }}"
data-state="{{ household.state }}"
- data-zip="{{ household.zip_code }}">
+ data-zip="{{ household.zip_code }}"
+ data-voters="{{ household.voters_json_str }}">
Log Visit
@@ -217,7 +218,7 @@
{{ visit_form.notes }}
-
+
{{ visit_form.candidate_support }}
@@ -231,6 +232,28 @@
+
+
+
+
+
+ {{ visit_form.follow_up }}
+
+
+
+
+
+ {{ visit_form.follow_up_voter }}
+
+
+
+ {{ visit_form.call_notes }}
+
These notes will be added to the call queue for the default caller.
+
+
+
+
+
+ {{ form.address }}
+
{{ form.birth_month }}
diff --git a/core/templates/registration/password_change_done.html b/core/templates/registration/password_change_done.html
new file mode 100644
index 0000000..46b9d72
--- /dev/null
+++ b/core/templates/registration/password_change_done.html
@@ -0,0 +1,21 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block content %}
+
+
+
+
+
+
+
+
Password Changed!
+
Your password has been successfully updated. You can now use your new password to log in.
+
+
+
+
+
+{% endblock %}
diff --git a/core/templates/registration/password_change_form.html b/core/templates/registration/password_change_form.html
new file mode 100644
index 0000000..2a23bf2
--- /dev/null
+++ b/core/templates/registration/password_change_form.html
@@ -0,0 +1,53 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block content %}
+
+
+
+{% endblock %}
diff --git a/core/urls.py b/core/urls.py
index a6ac9a4..dd9cc0d 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -64,4 +64,5 @@ urlpatterns = [
path('call-queue/', views.call_queue, name='call_queue'),
path('call-queue/
/complete/', views.complete_call, name='complete_call'),
path('call-queue//delete/', views.delete_call, name='delete_call'),
+ path('profile/', views.profile, name='profile'),
]
\ No newline at end of file
diff --git a/core/views.py b/core/views.py
index 084dd40..8364507 100644
--- a/core/views.py
+++ b/core/views.py
@@ -1,3 +1,5 @@
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.forms import PasswordChangeForm
from django.utils.dateparse import parse_date
from datetime import datetime, time, timedelta
import base64
@@ -15,7 +17,7 @@ from django.contrib import messages
from django.core.paginator import Paginator
from django.conf import settings
from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest, VolunteerRole, ScheduledCall
-from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm, ScheduledCallForm
+from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm, ScheduledCallForm, UserUpdateForm, VolunteerProfileForm
import logging
import zoneinfo
from django.utils import timezone
@@ -446,6 +448,8 @@ def voter_advanced_search(request):
voters = voters.filter(first_name__icontains=data['first_name'])
if data.get('last_name'):
voters = voters.filter(last_name__icontains=data['last_name'])
+ if data.get('address'):
+ voters = voters.filter(Q(address__icontains=data['address']) | Q(address_street__icontains=data['address']))
if data.get('voter_id'):
voters = voters.filter(voter_id__icontains=data['voter_id'])
if data.get('birth_month'):
@@ -520,6 +524,8 @@ def export_voters_csv(request):
voters = voters.filter(first_name__icontains=data['first_name'])
if data.get('last_name'):
voters = voters.filter(last_name__icontains=data['last_name'])
+ if data.get('address'):
+ voters = voters.filter(Q(address__icontains=data['address']) | Q(address_street__icontains=data['address']))
if data.get('voter_id'):
voters = voters.filter(voter_id__icontains=data['voter_id'])
if data.get('birth_month'):
@@ -580,7 +586,7 @@ def voter_delete(request, voter_id):
return redirect('voter_detail', voter_id=voter.id)
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
+@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
def bulk_send_sms(request):
"""
Sends bulk SMS to selected voters using Twilio API.
@@ -839,23 +845,23 @@ def volunteer_list(request):
)
# Interest filter
- interest_id = request.GET.get("interest")
- if interest_id:
- volunteers = volunteers.filter(interests__id=interest_id)
+ interest_ids = request.GET.getlist("interest")
+ if interest_ids:
+ volunteers = volunteers.filter(interests__id__in=interest_ids).distinct()
+ interests = Interest.objects.filter(tenant=tenant).order_by('name')
+
paginator = Paginator(volunteers, 50)
page_number = request.GET.get('page')
volunteers_page = paginator.get_page(page_number)
- interests = Interest.objects.filter(tenant=tenant).order_by('name')
-
context = {
'tenant': tenant,
'selected_tenant': tenant,
'volunteers': volunteers_page,
'query': query,
'interests': interests,
- 'selected_interest': interest_id,
+ 'selected_interests': interest_ids,
}
return render(request, 'core/volunteer_list.html', context)
@@ -1113,7 +1119,7 @@ def event_remove_volunteer(request, assignment_id):
messages.success(request, f"{volunteer_name} removed from event volunteers.")
return redirect('event_detail', event_id=event_id)
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
+@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_volunteer')
def volunteer_bulk_send_sms(request):
"""
Sends bulk SMS to selected volunteers using Twilio API.
@@ -1196,6 +1202,7 @@ def volunteer_bulk_send_sms(request):
return redirect('volunteer_list')
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
+
def door_visits(request):
"""
Manage door knocking visits. Groups unvisited targeted voters by household.
@@ -1253,11 +1260,16 @@ def door_visits(request):
'longitude': float(voter.longitude) if voter.longitude else None,
'street_name_sort': street_name.lower(),
'street_number_sort': street_number_sort,
- 'target_voters': []
+ 'target_voters': [],
+ 'voters_json': []
}
households_dict[key]['target_voters'].append(voter)
+ households_dict[key]['voters_json'].append({'id': voter.id, 'name': f"{voter.first_name} {voter.last_name}"})
households_list = list(households_dict.values())
+ for h in households_list:
+ h['voters_json_str'] = json.dumps(h['voters_json'])
+
households_list.sort(key=lambda x: (
(x['neighborhood'] or '').lower(),
x['street_name_sort'],
@@ -1299,39 +1311,42 @@ def log_door_visit(request):
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
- return redirect('index')
+ return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant)
# Capture query string for redirecting back with filters
- next_qs = request.POST.get('next_query_string', '')
- redirect_url = reverse('door_visits')
+ next_qs = request.POST.get("next_query_string", "")
+ redirect_url = reverse("door_visits")
if next_qs:
redirect_url += f"?{next_qs}"
# Get the volunteer linked to the current user
volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first()
- if request.method == 'POST':
+ 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')
+ 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']
+ 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"]
+ follow_up = form.cleaned_data["follow_up"]
+ follow_up_voter_id = form.cleaned_data.get("follow_up_voter")
+ call_notes = form.cleaned_data["call_notes"]
# Determine date/time in campaign timezone
- campaign_tz_name = campaign_settings.timezone or 'America/Chicago'
+ campaign_tz_name = campaign_settings.timezone or "America/Chicago"
try:
tz = zoneinfo.ZoneInfo(campaign_tz_name)
except:
- tz = zoneinfo.ZoneInfo('America/Chicago')
+ tz = zoneinfo.ZoneInfo("America/Chicago")
interaction_date = timezone.now().astimezone(tz)
@@ -1352,16 +1367,21 @@ def log_door_visit(request):
messages.warning(request, f"No targeted voters found at {address_street}.")
return redirect(redirect_url)
+ # Get default caller for follow-ups
+ default_caller = None
+ if follow_up:
+ default_caller = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first()
+
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'
+ voter.yard_sign = "wants"
# 3) Update support status if Supporting or Not Supporting
- if candidate_support in ['supporting', 'not_supporting']:
+ if candidate_support in ["supporting", "not_supporting"]:
voter.candidate_support = candidate_support
voter.save()
@@ -1375,14 +1395,26 @@ def log_door_visit(request):
description=outcome,
notes=notes
)
+
+ # 5) Create ScheduledCall if follow_up is checked and this is the selected voter
+ if follow_up and follow_up_voter_id and str(voter.id) == follow_up_voter_id:
+ ScheduledCall.objects.create(
+ tenant=tenant,
+ voter=voter,
+ volunteer=default_caller,
+ comments=call_notes,
+ status="pending"
+ )
- messages.success(request, f"Door visit logged for {address_street}.")
+ if follow_up:
+ messages.success(request, f"Door visit logged and follow-up call scheduled for {address_street}.")
+ else:
+ 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(redirect_url)
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
def door_visit_history(request):
"""
Shows a distinct list of Door visit interactions for addresses.
@@ -1474,7 +1506,7 @@ def door_visit_history(request):
}
return render(request, "core/door_visit_history.html", context)
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
+@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_scheduledcall')
def schedule_call(request, voter_id):
selected_tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
@@ -1496,7 +1528,7 @@ def schedule_call(request, voter_id):
return redirect(referer)
return redirect('voter_detail', voter_id=voter.id)
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
+@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_scheduledcall')
def bulk_schedule_calls(request):
if request.method != 'POST':
return redirect('voter_advanced_search')
@@ -1530,7 +1562,7 @@ def bulk_schedule_calls(request):
messages.success(request, f"{count} calls added to queue.")
return redirect(request.META.get('HTTP_REFERER', 'voter_advanced_search'))
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
+@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_scheduledcall')
def call_queue(request):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
@@ -1550,8 +1582,7 @@ def call_queue(request):
}
return render(request, 'core/call_queue.html', context)
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
+@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_scheduledcall')
def complete_call(request, call_id):
selected_tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
@@ -1589,7 +1620,7 @@ def complete_call(request, call_id):
return redirect('call_queue')
-@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
+@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.delete_scheduledcall')
def delete_call(request, call_id):
selected_tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
@@ -1600,3 +1631,31 @@ def delete_call(request, call_id):
messages.success(request, "Call removed from queue.")
return redirect('call_queue')
+
+@login_required
+def profile(request):
+ try:
+ volunteer = request.user.volunteer_profile
+ except:
+ volunteer = None
+
+ if request.method == 'POST':
+ u_form = UserUpdateForm(request.POST, instance=request.user)
+ v_form = VolunteerProfileForm(request.POST, instance=volunteer) if volunteer else None
+
+ if u_form.is_valid() and (not v_form or v_form.is_valid()):
+ u_form.save()
+ if v_form:
+ v_form.save()
+ messages.success(request, f'Your profile has been updated!')
+ return redirect('profile')
+ else:
+ u_form = UserUpdateForm(instance=request.user)
+ v_form = VolunteerProfileForm(instance=volunteer) if volunteer else None
+
+ context = {
+ 'u_form': u_form,
+ 'v_form': v_form
+ }
+
+ return render(request, 'core/profile.html', context)
diff --git a/core/views_new.py b/core/views_new.py
new file mode 100644
index 0000000..552eea6
--- /dev/null
+++ b/core/views_new.py
@@ -0,0 +1,111 @@
+@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
+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)
+
+ # Capture query string for redirecting back with filters
+ next_qs = request.POST.get("next_query_string", "")
+ redirect_url = reverse("door_visits")
+ if next_qs:
+ redirect_url += f"?{next_qs}"
+
+ # 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"]
+ follow_up = form.cleaned_data["follow_up"]
+ follow_up_voter_id = form.cleaned_data.get("follow_up_voter")
+ call_notes = form.cleaned_data["call_notes"]
+
+ # 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(redirect_url)
+
+ # Get default caller for follow-ups
+ default_caller = None
+ if follow_up:
+ default_caller = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first()
+
+ 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
+ )
+
+ # 5) Create ScheduledCall if follow_up is checked and this is the selected voter
+ if follow_up and follow_up_voter_id and str(voter.id) == follow_up_voter_id:
+ ScheduledCall.objects.create(
+ tenant=tenant,
+ voter=voter,
+ volunteer=default_caller,
+ comments=call_notes,
+ status="pending"
+ )
+
+ if follow_up:
+ messages.success(request, f"Door visit logged and follow-up call scheduled for {address_street}.")
+ else:
+ 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(redirect_url)
diff --git a/door_views_update.py b/door_views_update.py
new file mode 100644
index 0000000..765c0e7
--- /dev/null
+++ b/door_views_update.py
@@ -0,0 +1,211 @@
+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=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,
+ 'latitude': float(voter.latitude) if voter.latitude else None,
+ 'longitude': float(voter.longitude) if voter.longitude else None,
+ 'street_name_sort': street_name.lower(),
+ 'street_number_sort': street_number_sort,
+ 'target_voters': [],
+ 'voters_json': []
+ }
+ households_dict[key]['target_voters'].append(voter)
+ households_dict[key]['voters_json'].append({'id': voter.id, 'name': f"{voter.first_name} {voter.last_name}"})
+
+ households_list = list(households_dict.values())
+ for h in households_list:
+ h['voters_json_str'] = json.dumps(h['voters_json'])
+
+ households_list.sort(key=lambda x: (
+ (x['neighborhood'] or '').lower(),
+ x['street_name_sort'],
+ x['street_number_sort']
+ ))
+
+ # Prepare data for Google Map (all filtered households with coordinates)
+ map_data = [
+ {
+ 'lat': h['latitude'],
+ 'lng': h['longitude'],
+ 'address': f"{h['address_street']}, {h['city']}, {h['state']}",
+ 'voters': ", ".join([f"{v.first_name} {v.last_name}" for v in h['target_voters']])
+ }
+ for h in households_list if h['latitude'] and h['longitude']
+ ]
+
+ 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,
+ 'map_data_json': json.dumps(map_data),
+ 'google_maps_api_key': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''),
+ 'visit_form': DoorVisitLogForm(),
+ }
+ return render(request, 'core/door_visits.html', context)
+
+@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
+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)
+
+ # Capture query string for redirecting back with filters
+ next_qs = request.POST.get("next_query_string", "")
+ redirect_url = reverse("door_visits")
+ if next_qs:
+ redirect_url += f"?{next_qs}"
+
+ # 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"]
+ follow_up = form.cleaned_data["follow_up"]
+ follow_up_voter_id = form.cleaned_data.get("follow_up_voter")
+ call_notes = form.cleaned_data["call_notes"]
+
+ # 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(redirect_url)
+
+ # Get default caller for follow-ups
+ default_caller = None
+ if follow_up:
+ default_caller = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first()
+
+ 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
+ )
+
+ # 5) Create ScheduledCall if follow_up is checked and this is the selected voter
+ if follow_up and follow_up_voter_id and str(voter.id) == follow_up_voter_id:
+ ScheduledCall.objects.create(
+ tenant=tenant,
+ voter=voter,
+ volunteer=default_caller,
+ comments=call_notes,
+ status="pending"
+ )
+
+ if follow_up:
+ messages.success(request, f"Door visit logged and follow-up call scheduled for {address_street}.")
+ else:
+ 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(redirect_url)
\ No newline at end of file
diff --git a/static/css/custom.css b/static/css/custom.css
index 93db4ac..a764764 100644
--- a/static/css/custom.css
+++ b/static/css/custom.css
@@ -22,7 +22,6 @@ h1, h2, h3, h4, h5, h6 {
}
.navbar {
- background-color: #ffffff;
border-bottom: 1px solid var(--border-color);
padding: 1rem 0;
}
diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css
index 93db4ac..a764764 100644
--- a/staticfiles/css/custom.css
+++ b/staticfiles/css/custom.css
@@ -22,7 +22,6 @@ h1, h2, h3, h4, h5, h6 {
}
.navbar {
- background-color: #ffffff;
border-bottom: 1px solid var(--border-color);
padding: 1rem 0;
}