Autosave: 20260208-213524

This commit is contained in:
Flatlogic Bot 2026-02-08 21:35:24 +00:00
parent c92857d73b
commit 8de72675e5
12 changed files with 785 additions and 169 deletions

View File

@ -456,7 +456,12 @@ class DoorVisitLogForm(forms.Form):
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
label="Follow Up" label="Follow Up"
) )
follow_up_voter = forms.CharField( required=False, widget=forms.Select(attrs={"class": "form-select"}), label="Voter to Follow Up") follow_up_voter = forms.ChoiceField(choices=[], required=False, widget=forms.Select(attrs={"class": "form-select"}), label="Voter to Follow Up")
def __init__(self, *args, voter_choices=None, **kwargs):
super().__init__(*args, **kwargs)
if voter_choices:
self.fields["follow_up_voter"].choices = voter_choices
call_notes = forms.CharField( call_notes = forms.CharField(
widget=forms.Textarea(attrs={"class": "form-control", "rows": 2}), widget=forms.Textarea(attrs={"class": "form-control", "rows": 2}),
required=False, required=False,

View File

@ -78,8 +78,10 @@
</button> </button>
</td> </td>
<td> <td>
<div class="fw-bold text-dark">{{ household.address_street }}</div> <a href="{% url 'log_door_visit' %}?address_street={{ household.address_street|urlencode }}&city={{ household.city|urlencode }}&state={{ household.state|urlencode }}&zip_code={{ household.zip_code|urlencode }}&next_query_string={{ request.GET.urlencode|urlencode }}" class="text-decoration-none">
<div class="small text-muted">{{ household.city }}</div> <div class="fw-bold text-dark hover-underline">{{ household.address_street }}</div>
<div class="small text-muted">{{ household.city }}</div>
</a>
<div class="d-md-none mt-1"> <div class="d-md-none mt-1">
{% if household.neighborhood %} {% if household.neighborhood %}
<span class="badge border border-primary-subtle bg-primary-subtle text-primary fw-medium px-2 py-1 small"> <span class="badge border border-primary-subtle bg-primary-subtle text-primary fw-medium px-2 py-1 small">
@ -128,12 +130,12 @@
<ul class="pagination justify-content-center mb-0"> <ul class="pagination justify-content-center mb-0">
{% if households.has_previous %} {% if households.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" 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"> <a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page=1{% if district_filter %}&district={{ district_filter|urlencode }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter|urlencode }}{% endif %}{% if address_filter %}&address={{ address_filter|urlencode }}{% endif %}" aria-label="First">
<i class="bi bi-chevron-double-left small"></i> <i class="bi bi-chevron-double-left small"></i>
</a> </a>
</li> </li>
<li class="page-item"> <li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" 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"> <a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ households.previous_page_number }}{% if district_filter %}&district={{ district_filter|urlencode }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter|urlencode }}{% endif %}{% if address_filter %}&address={{ address_filter|urlencode }}{% endif %}" aria-label="Previous">
<i class="bi bi-chevron-left small"></i> <i class="bi bi-chevron-left small"></i>
</a> </a>
</li> </li>
@ -143,12 +145,12 @@
{% if households.has_next %} {% if households.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" 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"> <a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ households.next_page_number }}{% if district_filter %}&district={{ district_filter|urlencode }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter|urlencode }}{% endif %}{% if address_filter %}&address={{ address_filter|urlencode }}{% endif %}" aria-label="Next">
<i class="bi bi-chevron-right small"></i> <i class="bi bi-chevron-right small"></i>
</a> </a>
</li> </li>
<li class="page-item"> <li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" 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"> <a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ households.paginator.num_pages }}{% if district_filter %}&district={{ district_filter|urlencode }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter|urlencode }}{% endif %}{% if address_filter %}&address={{ address_filter|urlencode }}{% endif %}" aria-label="Last">
<i class="bi bi-chevron-double-right small"></i> <i class="bi bi-chevron-double-right small"></i>
</a> </a>
</li> </li>
@ -263,8 +265,10 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
<!-- Google Maps JS - Using global config matching voter page --> {% block extra_js %}
<!-- Google Maps JS -->
{% if GOOGLE_MAPS_API_KEY %} {% if GOOGLE_MAPS_API_KEY %}
<script src="https://maps.googleapis.com/maps/api/js?key={{ GOOGLE_MAPS_API_KEY }}"></script> <script src="https://maps.googleapis.com/maps/api/js?key={{ GOOGLE_MAPS_API_KEY }}"></script>
{% endif %} {% endif %}
@ -302,7 +306,17 @@
}); });
marker.addListener('click', function() { marker.addListener('click', function() {
infowindow.setContent('<strong>' + item.address + '</strong><br>' + item.voters); var logUrl = "{% url 'log_door_visit' %}?address_street=" + encodeURIComponent(item.address_street) +
"&city=" + encodeURIComponent(item.city) +
"&state=" + encodeURIComponent(item.state) +
"&zip_code=" + encodeURIComponent(item.zip_code) +
"&next_query_string=" + encodeURIComponent("{{ request.GET.urlencode|escapejs }}") + "" + "&source=map";
var content = '<strong>' + item.address + '</strong><br>' +
item.voters + '<br><br>' +
'<a href="' + logUrl + '" class="btn btn-sm btn-primary text-white px-3 py-1">Log Visit</a>';
infowindow.setContent(content);
infowindow.open(map, marker); infowindow.open(map, marker);
}); });
@ -317,20 +331,32 @@
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Automatically open map if requested
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('open_map') === '1') {
var mapModalElement = document.getElementById('mapModal');
if (mapModalElement) {
var mapModal = new bootstrap.Modal(mapModalElement);
mapModal.show();
}
}
// Trigger map initialization when modal is shown // Trigger map initialization when modal is shown
var mapModal = document.getElementById('mapModal'); var mapModalElement = document.getElementById('mapModal');
if (mapModal) { if (mapModalElement) {
mapModal.addEventListener('shown.bs.modal', function () { mapModalElement.addEventListener('shown.bs.modal', function () {
if (!map) { if (!map) {
initMap(); initMap();
} else { } else {
google.maps.event.trigger(map, 'resize'); if (window.google && window.google.maps) {
if (markers.length > 0) { google.maps.event.trigger(map, 'resize');
var bounds = new google.maps.LatLngBounds(); if (markers.length > 0) {
markers.forEach(function(marker) { var bounds = new google.maps.LatLngBounds();
bounds.extend(marker.getPosition()); markers.forEach(function(marker) {
}); bounds.extend(marker.getPosition());
map.fitBounds(bounds); });
map.fitBounds(bounds);
}
} }
} }
}); });
@ -340,30 +366,36 @@
if (logVisitModal) { if (logVisitModal) {
logVisitModal.addEventListener('show.bs.modal', function (event) { logVisitModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget; var button = event.relatedTarget;
if (!button) return;
var address = button.getAttribute('data-address'); var address = button.getAttribute('data-address');
var city = button.getAttribute('data-city'); var city = button.getAttribute('data-city');
var state = button.getAttribute('data-state'); var state = button.getAttribute('data-state');
var zip = button.getAttribute('data-zip'); var zip = button.getAttribute('data-zip');
document.getElementById('modalAddressDisplay').textContent = address + ', ' + city + ', ' + state + ' ' + zip; document.getElementById('modalAddressDisplay').textContent = (address || '') + ', ' + (city || '') + ', ' + (state || '') + ' ' + (zip || '');
document.getElementById('modal_address_street').value = address; document.getElementById('modal_address_street').value = address || '';
document.getElementById('modal_city').value = city; document.getElementById('modal_city').value = city || '';
document.getElementById('modal_state').value = state; document.getElementById('modal_state').value = state || '';
document.getElementById('modal_zip_code').value = zip; document.getElementById('modal_zip_code').value = zip || '';
// Populate voters dropdown // Populate voters dropdown
var votersJson = button.getAttribute('data-voters'); var votersJson = button.getAttribute('data-voters');
if (votersJson) { if (votersJson) {
var voters = JSON.parse(votersJson); try {
var voterSelect = document.getElementById('{{ visit_form.follow_up_voter.id_for_label }}'); var voters = JSON.parse(votersJson);
if (voterSelect) { var voterSelect = document.getElementById('{{ visit_form.follow_up_voter.id_for_label }}');
voterSelect.innerHTML = ''; if (voterSelect) {
voters.forEach(function(voter) { voterSelect.innerHTML = '';
var option = document.createElement('option'); voters.forEach(function(voter) {
option.value = voter.id; var option = document.createElement('option');
option.textContent = voter.name; option.value = voter.id;
voterSelect.appendChild(option); option.textContent = voter.name;
}); voterSelect.appendChild(option);
});
}
} catch (e) {
console.error("Error parsing voters JSON:", e);
} }
} }
}); });

View File

@ -0,0 +1,150 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-8">
<div class="d-flex align-items-center mb-4">
<a href="{{ redirect_url }}" class="btn btn-outline-secondary rounded-circle me-3">
<i class="bi bi-arrow-left"></i>
</a>
<div>
<h1 class="h3 fw-bold mb-0">Log Door Visit</h1>
<p class="text-muted mb-0">Record interaction for this household</p>
</div>
</div>
<div class="card border-0 shadow-sm rounded-4 overflow-hidden">
<div class="card-body p-4 p-md-5">
<div class="bg-primary-subtle p-4 rounded-3 mb-4 border border-primary-subtle">
<div class="small text-uppercase fw-bold text-primary mb-1">Household Address</div>
<div class="h4 mb-0 fw-bold text-dark">{{ address_street }}</div>
<div class="text-muted">{{ city }}, {{ state }} {{ zip_code }}</div>
</div>
<form action="{% url 'log_door_visit' %}" method="POST">
{% csrf_token %}
<input type="hidden" name="address_street" value="{{ address_street }}">
<input type="hidden" name="city" value="{{ city }}">
<input type="hidden" name="state" value="{{ state }}">
<input type="hidden" name="zip_code" value="{{ zip_code }}">
<input type="hidden" name="next_query_string" value="{{ next_query_string }}">
<input type="hidden" name="source" value="{{ source }}">
<div class="mb-4">
<label class="form-label fw-bold text-primary small text-uppercase">Visit Outcome</label>
<div class="row g-2">
{% for radio in visit_form.outcome %}
<div class="col-12 col-md-4">
{{ radio.tag }}
<label class="btn btn-outline-primary w-100 h-100 d-flex align-items-center justify-content-center text-center py-3 px-2" 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 mb-4">
<div class="col-12 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-12 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>
<hr class="my-5">
<div class="bg-light p-4 rounded-4">
<div class="form-check mb-4">
{{ visit_form.follow_up }}
<label class="form-check-label h6 fw-bold text-dark mb-0" for="{{ visit_form.follow_up.id_for_label }}">
Schedule a Follow-up Call
</label>
</div>
<div id="callNotesContainer" {% if not visit_form.follow_up.value %}style="display: none;"{% endif %}>
<div class="mb-3">
<label for="{{ visit_form.follow_up_voter.id_for_label }}" class="form-label fw-bold text-primary small text-uppercase">Recipient of the Call</label>
{{ visit_form.follow_up_voter }}
</div>
<div class="mb-0">
<label for="{{ visit_form.call_notes.id_for_label }}" class="form-label fw-bold text-primary small text-uppercase">Call Queue Notes</label>
{{ visit_form.call_notes }}
<div class="form-text small">These notes will be added to the call queue for the default caller.</div>
</div>
</div>
</div>
<div class="mt-5 d-grid d-md-flex justify-content-md-end gap-2">
<a href="{{ redirect_url }}" class="btn btn-light px-5 py-3 rounded-3 fw-bold">Cancel</a>
<button type="submit" class="btn btn-primary px-5 py-3 rounded-3 fw-bold shadow">Save Visit</button>
</div>
</form>
</div>
</div>
<div class="mt-4">
<h5 class="fw-bold text-dark mb-3">Targeted Voters at this Address</h5>
<div class="list-group list-group-flush shadow-sm rounded-4 overflow-hidden">
{% for voter in voters %}
<a href="{% url 'voter_detail' voter.id %}" class="list-group-item list-group-item-action py-3 border-0">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="fw-bold text-primary">{{ voter.first_name }} {{ voter.last_name }}</div>
<div class="small text-muted">Voter ID: {{ voter.voter_id|default:"N/A" }}</div>
</div>
<i class="bi bi-chevron-right text-muted"></i>
</div>
</a>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const followUpCheckbox = document.getElementById('{{ visit_form.follow_up.id_for_label }}');
const callNotesContainer = document.getElementById('callNotesContainer');
if (followUpCheckbox && callNotesContainer) {
followUpCheckbox.addEventListener('change', function() {
callNotesContainer.style.display = this.checked ? 'block' : 'none';
});
}
});
</script>
<style>
.btn-outline-primary {
color: #0d6efd !important;
border-color: #0d6efd !important;
}
.btn-outline-primary:hover, .btn-check:checked + .btn-outline-primary {
background-color: #0d6efd !important;
color: #ffffff !important;
}
.bg-primary-subtle {
background-color: #e7f1ff;
}
.text-primary {
color: #0d6efd !important;
}
.border-primary-subtle {
border-color: #9ec5fe !important;
}
</style>
{% endblock %}

View File

@ -1655,7 +1655,7 @@ def door_visits(request):
{ {
'lat': h['latitude'], 'lat': h['latitude'],
'lng': h['longitude'], 'lng': h['longitude'],
'address': f"{h['address_street']}, {h['city']}, {h['state']}", 'address_street': h['address_street'], 'city': h['city'], 'state': h['state'], 'zip_code': h['zip_code'], 'address': f"{h['address_street']}, {h['city']}, {h['state']}",
'voters': ", ".join([f"{v.first_name} {v.last_name}" for v in h['target_voters']]) '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'] for h in households_list if h['latitude'] and h['longitude']
@ -1672,7 +1672,7 @@ def door_visits(request):
'neighborhood_filter': neighborhood_filter, 'neighborhood_filter': neighborhood_filter,
'address_filter': address_filter, 'address_filter': address_filter,
'map_data_json': json.dumps(map_data), 'map_data_json': json.dumps(map_data),
'google_maps_api_key': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''), 'GOOGLE_MAPS_API_KEY': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''),
'visit_form': DoorVisitLogForm(), 'visit_form': DoorVisitLogForm(),
} }
return render(request, 'core/door_visits.html', context) return render(request, 'core/door_visits.html', context)
@ -1682,6 +1682,7 @@ def log_door_visit(request):
""" """
Mark all targeted voters at a specific address as visited, update their flags, Mark all targeted voters at a specific address as visited, update their flags,
and create interaction records. and create interaction records.
Can also render a standalone page for logging a visit.
""" """
selected_tenant_id = request.session.get("tenant_id") selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id: if not selected_tenant_id:
@ -1691,22 +1692,53 @@ def log_door_visit(request):
campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant) campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant)
# Capture query string for redirecting back with filters # Capture query string for redirecting back with filters
next_qs = request.POST.get("next_query_string", "") next_qs = request.POST.get("next_query_string", request.GET.get("next_query_string", ""))
source = request.POST.get("source", request.GET.get("source", ""))
redirect_url = reverse("door_visits") redirect_url = reverse("door_visits")
# Build redirect URL
redirect_params = []
if next_qs: if next_qs:
redirect_url += f"?{next_qs}" redirect_params.append(next_qs)
if source == "map":
redirect_params.append("open_map=1")
if redirect_params:
redirect_url += "?" + "&".join(redirect_params)
# Get address components from POST or GET
address_street = request.POST.get("address_street", request.GET.get("address_street"))
city = request.POST.get("city", request.GET.get("city"))
state = request.POST.get("state", request.GET.get("state"))
zip_code = request.POST.get("zip_code", request.GET.get("zip_code"))
if not address_street:
messages.warning(request, "No address provided.")
return redirect(redirect_url)
# 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() and request.method == "POST":
messages.warning(request, f"No targeted voters found at {address_street}.")
return redirect(redirect_url)
voter_choices = [(v.id, f"{v.first_name} {v.last_name}") for v in voters]
# Get the volunteer linked to the current user # Get the volunteer linked to the current user
volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first() volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first()
if request.method == "POST": if request.method == "POST":
form = DoorVisitLogForm(request.POST) form = DoorVisitLogForm(request.POST, voter_choices=voter_choices)
if form.is_valid(): 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"] outcome = form.cleaned_data["outcome"]
notes = form.cleaned_data["notes"] notes = form.cleaned_data["notes"]
wants_yard_sign = form.cleaned_data["wants_yard_sign"] wants_yard_sign = form.cleaned_data["wants_yard_sign"]
@ -1722,25 +1754,11 @@ def log_door_visit(request):
except: except:
tz = zoneinfo.ZoneInfo("America/Chicago") tz = zoneinfo.ZoneInfo("America/Chicago")
interaction_date = timezone.now().astimezone(tz); interaction_date = timezone.now().astimezone(tz)
# Get or create InteractionType # Get or create InteractionType
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Door Visit") 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 # Get default caller for follow-ups
default_caller = None default_caller = None
if follow_up: if follow_up:
@ -1784,10 +1802,26 @@ def log_door_visit(request):
messages.success(request, f"Door visit logged and follow-up call scheduled for {address_street}.") messages.success(request, f"Door visit logged and follow-up call scheduled for {address_street}.")
else: else:
messages.success(request, f"Door visit logged for {address_street}.") messages.success(request, f"Door visit logged for {address_street}.")
return redirect(redirect_url)
else: else:
messages.error(request, "There was an error in the visit log form.") messages.error(request, "There was an error in the visit log form.")
return redirect(redirect_url)
return redirect(redirect_url) else:
# GET request: render standalone page
form = DoorVisitLogForm(voter_choices=voter_choices)
context = {
'selected_tenant': tenant,
'visit_form': form,
'address_street': address_street,
'city': city,
'state': state,
'zip_code': zip_code,
'voters': voters,
'next_query_string': next_qs,
'source': source,
'redirect_url': redirect_url,
}
return render(request, 'core/log_door_visit.html', context)
def door_visit_history(request): def door_visit_history(request):
""" """

144
core_views_log_visit.py Normal file
View File

@ -0,0 +1,144 @@
def log_door_visit(request):
"""
Mark all targeted voters at a specific address as visited, update their flags,
and create interaction records.
Can also render a standalone page for logging a visit.
"""
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", request.GET.get("next_query_string", ""))
source = request.POST.get("source", request.GET.get("source", ""))
redirect_url = reverse("door_visits")
# Build redirect URL
redirect_params = []
if next_qs:
redirect_params.append(next_qs)
if source == "map":
redirect_params.append("open_map=1")
if redirect_params:
redirect_url += "?" + "&".join(redirect_params)
# Get address components from POST or GET
address_street = request.POST.get("address_street", request.GET.get("address_street"))
city = request.POST.get("city", request.GET.get("city"))
state = request.POST.get("state", request.GET.get("state"))
zip_code = request.POST.get("zip_code", request.GET.get("zip_code"))
if not address_street:
messages.warning(request, "No address provided.")
return redirect(redirect_url)
# 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() and request.method == "POST":
messages.warning(request, f"No targeted voters found at {address_street}.")
return redirect(redirect_url)
voter_choices = [(v.id, f"{v.first_name} {v.last_name}") for v in voters]
# 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, voter_choices=voter_choices)
if form.is_valid():
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")
# 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}.")
return redirect(redirect_url)
else:
messages.error(request, "There was an error in the visit log form.")
return redirect(redirect_url)
else:
# GET request: render standalone page
form = DoorVisitLogForm(voter_choices=voter_choices)
context = {
'selected_tenant': tenant,
'visit_form': form,
'address_street': address_street,
'city': city,
'state': state,
'zip_code': zip_code,
'voters': voters,
'next_query_string': next_qs,
'source': source,
'redirect_url': redirect_url,
}
return render(request, 'core/log_door_visit.html', context)

View File

@ -1,125 +1,152 @@
import sys import os
import re import django
file_path = 'core/views.py' os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
with open(file_path, 'r') as f: django.setup()
content = f.read()
# Define the new function as a single string from django.shortcuts import render, redirect, get_object_or_404
new_func = """def door_visit_history(request): from django.urls import reverse
from django.contrib import messages
from django.utils import timezone
from core.models import Tenant, CampaignSettings, Volunteer, Voter, InteractionType, Interaction, ScheduledCall
from core.forms import DoorVisitLogForm
import zoneinfo
def log_door_visit(request):
""" """
Shows a distinct list of Door visit interactions for addresses. Mark all targeted voters at a specific address as visited, update their flags,
and create interaction records.
Can also render a standalone page for logging a visit.
""" """
selected_tenant_id = request.session.get("tenant_id") selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id: if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index") return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id) tenant = get_object_or_404(Tenant, id=selected_tenant_id)
campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant)
# Date filter # Capture query string for redirecting back with filters
start_date = request.GET.get("start_date") next_qs = request.POST.get("next_query_string", request.GET.get("next_query_string", ""))
end_date = request.GET.get("end_date") redirect_url = reverse("door_visits")
if next_qs:
redirect_url += f"?{next_qs}"
# Get all "Door Visit" interactions for this tenant # Get address components from POST or GET
interactions = Interaction.objects.filter( address_street = request.POST.get("address_street", request.GET.get("address_street"))
voter__tenant=tenant, city = request.POST.get("city", request.GET.get("city"))
type__name="Door Visit" state = request.POST.get("state", request.GET.get("state"))
).select_related("voter", "volunteer") zip_code = request.POST.get("zip_code", request.GET.get("zip_code"))
if start_date or end_date: if not address_street:
try: messages.warning(request, "No address provided.")
if start_date: return redirect(redirect_url)
d = parse_date(start_date)
if d:
start_dt = timezone.make_aware(datetime.combine(d, time.min))
interactions = interactions.filter(date__gte=start_dt)
if end_date:
d = parse_date(end_date)
if d:
# Use lt with next day to capture everything on the end_date
end_dt = timezone.make_aware(datetime.combine(d + timedelta(days=1), time.min))
interactions = interactions.filter(date__lt=end_dt)
except Exception as e:
logger.error(f"Error filtering door visit history by date: {e}")
# Summary of counts per volunteer # Find targeted voters at this exact address
# Grouping by household (unique address) voters = Voter.objects.filter(
visited_households = {} tenant=tenant,
volunteer_counts = {} address_street=address_street,
city=city,
state=state,
zip_code=zip_code,
is_targeted=True
)
for interaction in interactions.order_by("-date"): if not voters.exists() and request.method == "POST":
v = interaction.voter messages.warning(request, f"No targeted voters found at {address_street}.")
addr = v.address.strip() if v.address else f"{v.address_street}, {v.city}, {v.state} {v.zip_code}".strip(", ") return redirect(redirect_url)
if not addr:
continue
key = addr.lower() voter_choices = [(v.id, f"{v.first_name} {v.last_name}") for v in voters]
if key not in visited_households: # Get the volunteer linked to the current user
# Calculate volunteer summary - only once per household volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first()
v_obj = interaction.volunteer
v_name = f"{v_obj.first_name} {v_obj.last_name}".strip() or v_obj.email if v_obj else "N/A"
volunteer_counts[v_name] = volunteer_counts.get(v_name, 0) + 1
# Parse street name and number for sorting if request.method == "POST":
street_number = "" form = DoorVisitLogForm(request.POST, voter_choices=voter_choices)
street_name = v.address_street or "" if form.is_valid():
match = re.match(r'^(\d+)\s+(.*)$', street_name) outcome = form.cleaned_data["outcome"]
if match: notes = form.cleaned_data["notes"]
street_number = match.group(1) wants_yard_sign = form.cleaned_data["wants_yard_sign"]
street_name = match.group(2) 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: try:
street_number_sort = int(street_number) tz = zoneinfo.ZoneInfo(campaign_tz_name)
except ValueError: except:
street_number_sort = 0 tz = zoneinfo.ZoneInfo("America/Chicago")
visited_households[key] = { interaction_date = timezone.now().astimezone(tz)
'address_display': addr,
'address_street': v.address_street,
'city': v.city,
'state': v.state,
'zip_code': v.zip_code,
'neighborhood': v.neighborhood,
'district': v.district,
'latitude': float(v.latitude) if v.latitude else None,
'longitude': float(v.longitude) if v.longitude else None,
'street_name_sort': street_name.lower(),
'street_number_sort': street_number_sort,
'last_visit_date': interaction.date,
'target_voters': [],
'voters_json': []
}
visited_households[key]["voters_json"].append({'id': v.id, 'name': f"{v.first_name} {v.last_name}"}) # Get or create InteractionType
visited_households[key]['target_voters'].append(v) interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Door Visit")
# Sort volunteer counts by total (descending) # Get default caller for follow-ups
sorted_volunteer_counts = sorted(volunteer_counts.items(), key=lambda x: x[1], reverse=True) default_caller = None
if follow_up:
default_caller = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first()
history_list = list(visited_households.values()) for voter in voters:
history_list.sort(key=lambda x: x["last_visit_date"], reverse=True) # 1) Update voter flags
voter.door_visit = True
paginator = Paginator(history_list, 50) # 2) If "Wants a Yard Sign" checkbox is selected
page_number = request.GET.get("page") if wants_yard_sign:
history_page = paginator.get_page(page_number) voter.yard_sign = "wants"
context = { # 3) Update support status if Supporting or Not Supporting
"selected_tenant": tenant, if candidate_support in ["supporting", "not_supporting"]:
"history": history_page, voter.candidate_support = candidate_support
"start_date": start_date, "end_date": end_date,
"volunteer_counts": sorted_volunteer_counts,
}
return render(request, "core/door_visit_history.html", context)
"""
# Use regex to find and replace the function voter.save()
pattern = r'def door_visit_history\(request\):.*?return render\(request, "core/door_visit_history\.html", context\)'
new_content = re.sub(pattern, new_func, content, flags=re.DOTALL) # 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}.")
return redirect(redirect_url)
else:
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
# If it's the modal, we might want to handle it differently,
# but currently it's a standard POST from modal.
pass
messages.error(request, "There was an error in the visit log form.")
else:
# GET request: render standalone page
form = DoorVisitLogForm(voter_choices=voter_choices)
context = {
'selected_tenant': tenant,
'visit_form': form,
'address_street': address_street,
'city': city,
'state': state,
'zip_code': zip_code,
'voters': voters,
'next_query_string': next_qs,
}
return render(request, 'core/log_door_visit.html', context)
return redirect(redirect_url)
if new_content != content:
with open(file_path, 'w') as f:
f.write(new_content)
print("Successfully updated door_visit_history")
else:
print("Could not find function to replace")

156
door_visit_patch.py Normal file
View File

@ -0,0 +1,156 @@
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
import re
from core import views
def patch_log_door_visit():
with open('core/views.py', 'r') as f:
content = f.read()
new_view = """def log_door_visit(request):
\"\"\"
Mark all targeted voters at a specific address as visited, update their flags,
and create interaction records.
Can also render a standalone page for logging a visit.
\"\"\"
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", request.GET.get("next_query_string", ""))
redirect_url = reverse("door_visits")
if next_qs:
redirect_url += f"?{next_qs}"
# Get address components from POST or GET
address_street = request.POST.get("address_street", request.GET.get("address_street"))
city = request.POST.get("city", request.GET.get("city"))
state = request.POST.get("state", request.GET.get("state"))
zip_code = request.POST.get("zip_code", request.GET.get("zip_code"))
if not address_street:
messages.warning(request, "No address provided.")
return redirect(redirect_url)
# 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() and request.method == "POST":
messages.warning(request, f"No targeted voters found at {address_street}.")
return redirect(redirect_url)
voter_choices = [(v.id, f"{v.first_name} {v.last_name}") for v in voters]
# 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, voter_choices=voter_choices)
if form.is_valid():
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")
# 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}.")
return redirect(redirect_url)
else:
messages.error(request, "There was an error in the visit log form.")
return redirect(redirect_url)
else:
# GET request: render standalone page
form = DoorVisitLogForm(voter_choices=voter_choices)
context = {
'selected_tenant': tenant,
'visit_form': form,
'address_street': address_street,
'city': city,
'state': state,
'zip_code': zip_code,
'voters': voters,
'next_query_string': next_qs,
}
return render(request, 'core/log_door_visit.html', context)
"""
# Replace the old function. We use regex to find the start and then match indentation for the end.
pattern = r'def log_door_visit\(request\):.*?return redirect\(redirect_url\)'
content = re.sub(pattern, new_view, content, flags=re.DOTALL)
with open('core/views.py', 'w') as f:
f.write(content)
if __name__ == "__main__":
patch_log_door_visit()

22
fix_views.py Normal file
View File

@ -0,0 +1,22 @@
import os
with open('core/views.py', 'r') as f:
lines = f.readlines()
new_lines = []
skip = False
for i, line in enumerate(lines):
if "return render(request, 'core/log_door_visit.html', context)" in line:
new_lines.append(line)
# Look ahead to see if there is duplicated code
if i + 3 < len(lines) and "default_caller = None" in lines[i+3]:
skip = True
continue
if skip:
if "return redirect(redirect_url)" in line:
skip = False
continue
new_lines.append(line)
with open('core/views.py', 'w') as f:
f.writelines(new_lines)

13
fix_views_v2.py Normal file
View File

@ -0,0 +1,13 @@
import os
import re
with open('core/views.py', 'r') as f:
content = f.read()
# Find the first occurrence of return render(request, 'core/log_door_visit.html', context)
# and the next def door_visit_history(request):
pattern = r"(return render\(request, 'core/log_door_visit.html', context\)).*?(def door_visit_history\(request\):)"
new_content = re.sub(pattern, r"\1\n\n\2", content, flags=re.DOTALL)
with open('core/views.py', 'w') as f:
f.write(new_content)

33
patch_log_visit.py Normal file
View File

@ -0,0 +1,33 @@
import os
with open('core/views.py', 'r') as f:
lines = f.readlines()
start_line = -1
end_line = -1
for i, line in enumerate(lines):
if 'def log_door_visit(request):' in line:
start_line = i
if start_line != -1 and 'def door_visit_history(request):' in line:
end_line = i
break
if start_line != -1 and end_line != -1:
with open('core_views_log_visit.py', 'r') as f:
new_content = f.read()
# Ensure there is a newline before door_visit_history
if not new_content.endswith('\n'):
new_content += '\n'
# Prepend indentation to new_content if needed, but it should be top-level def
lines[start_line:end_line] = [new_content + '\n']
with open('core/views.py', 'w') as f:
f.writelines(lines)
print("Successfully patched log_door_visit in core/views.py")
else:
print(f"Could not find log_door_visit boundaries: {start_line}, {end_line}")