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 %}
- Admin Panel + Admin Panel {% if user.is_authenticated %} - {{ user.username }} -
- {% csrf_token %} - -
+ {% else %} - Login + Login {% 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 }} + +
+ +
+
+ + {{ 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 %} +
+
+
+
+
+
+ +
+

Change Password

+

Secure your account by updating your password

+
+
+
+ {% csrf_token %} + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} +
{{ field.help_text }}
+ {% endif %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endfor %} +
+ + Cancel +
+
+
+
+
+
+
+ + +{% 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; }