Autosave: 20260203-133539

This commit is contained in:
Flatlogic Bot 2026-02-03 13:35:39 +00:00
parent c3568101a3
commit ac80c84fbd
19 changed files with 718 additions and 56 deletions

View File

@ -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"

View File

@ -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'})

View File

@ -47,15 +47,27 @@
{% endif %}
</ul>
<div class="d-flex align-items-center">
<a href="/admin/" class="btn btn-outline-primary btn-sm me-2">Admin Panel</a>
<a href="/admin/" class="btn btn-outline-primary btn-sm me-3">Admin Panel</a>
{% if user.is_authenticated %}
<span class="text-muted small me-3">{{ user.username }}</span>
<form method="post" action="{% url 'logout' %}" class="d-inline">
<div class="dropdown">
<button class="btn btn-link nav-link dropdown-toggle text-white d-flex align-items-center p-0" type="button" id="userDropdown" data-bs-toggle="dropdown" aria-expanded="false" style="text-decoration: none;">
<i class="bi bi-person-circle me-1"></i>
<span class="small">{{ user.username }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow border-0" aria-labelledby="userDropdown">
<li><a class="dropdown-item small" href="{% url 'profile' %}"><i class="bi bi-person me-2"></i>My Profile</a></li>
<li><a class="dropdown-item small" href="{% url 'password_change' %}"><i class="bi bi-shield-lock me-2"></i>Change Password</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="{% url 'logout' %}" class="px-3">
{% csrf_token %}
<button type="submit" class="btn btn-link nav-link d-inline p-0" style="text-decoration: none;">Logout</button>
<button type="submit" class="btn btn-link dropdown-item small p-0 m-0"><i class="bi bi-box-arrow-right me-2"></i>Logout</button>
</form>
</li>
</ul>
</div>
{% else %}
<a href="{% url 'login' %}" class="btn btn-link nav-link">Login</a>
<a href="{% url 'login' %}" class="btn btn-link nav-link text-white">Login</a>
{% endif %}
</div>
</div>

View File

@ -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
</button>
</td>
@ -217,7 +218,7 @@
{{ visit_form.notes }}
</div>
<div class="row g-3">
<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 }}
@ -231,6 +232,28 @@
</div>
</div>
</div>
<hr class="my-4">
<div class="bg-light p-3 rounded-3">
<div class="form-check mb-3">
{{ visit_form.follow_up }}
<label class="form-check-label fw-bold text-dark" for="{{ visit_form.follow_up.id_for_label }}">
Schedule a Follow-up Call
</label>
</div>
<div id="callNotesContainer" style="display: none;">
<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-3">
<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>
<div class="modal-footer bg-light border-0 py-3">
<button type="button" class="btn btn-outline-secondary px-4" data-bs-dismiss="modal">Cancel</button>
@ -327,6 +350,31 @@
document.getElementById('modal_city').value = city;
document.getElementById('modal_state').value = state;
document.getElementById('modal_zip_code').value = zip;
// Populate voters dropdown
var votersJson = button.getAttribute('data-voters');
if (votersJson) {
var voters = JSON.parse(votersJson);
var voterSelect = document.getElementById('{{ visit_form.follow_up_voter.id_for_label }}');
if (voterSelect) {
voterSelect.innerHTML = '';
voters.forEach(function(voter) {
var option = document.createElement('option');
option.value = voter.id;
option.textContent = voter.name;
voterSelect.appendChild(option);
});
}
}
});
}
// Toggle call notes visibility
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';
});
}
});

View File

@ -0,0 +1,73 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card border-0 shadow-sm rounded-4 overflow-hidden mb-4">
<div class="card-header bg-white border-0 py-4 px-4">
<div class="d-flex align-items-center">
<div class="bg-primary bg-opacity-10 p-3 rounded-4 me-3">
<i class="bi bi-person-circle text-primary fs-3"></i>
</div>
<div>
<h2 class="h4 fw-bold mb-0">My Profile</h2>
<p class="text-muted small mb-0">Manage your account information</p>
</div>
</div>
</div>
<div class="card-body p-4">
<form method="post">
{% csrf_token %}
<h5 class="fw-bold mb-3">User Information</h5>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label small fw-bold text-muted">First Name</label>
{{ u_form.first_name }}
{% if u_form.first_name.errors %}
<div class="text-danger small">{{ u_form.first_name.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-muted">Last Name</label>
{{ u_form.last_name }}
{% if u_form.last_name.errors %}
<div class="text-danger small">{{ u_form.last_name.errors }}</div>
{% endif %}
</div>
<div class="col-12">
<label class="form-label small fw-bold text-muted">Email Address</label>
{{ u_form.email }}
{% if u_form.email.errors %}
<div class="text-danger small">{{ u_form.email.errors }}</div>
{% endif %}
</div>
</div>
{% if v_form %}
<hr class="my-4 opacity-10">
<h5 class="fw-bold mb-3">Volunteer Details</h5>
<div class="row g-3 mb-4">
<div class="col-md-12">
<label class="form-label small fw-bold text-muted">Phone Number</label>
{{ v_form.phone }}
{% if v_form.phone.errors %}
<div class="text-danger small">{{ v_form.phone.errors }}</div>
{% endif %}
</div>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary px-4 rounded-3">Save Changes</button>
<a href="{% url 'password_change' %}" class="btn btn-outline-secondary px-4 rounded-3">Change Password</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,5 +1,35 @@
{% extends "base.html" %}
{% block head %}
<!-- Tom Select CSS/JS -->
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/css/tom-select.bootstrap5.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js"></script>
<style>
.ts-control {
border: 1px solid #dee2e6 !important;
padding: 0.5rem 0.75rem !important;
border-radius: 0.375rem !important;
box-shadow: none !important;
}
.ts-control .item {
background: #e9ecef !important;
border: 1px solid #dee2e6 !important;
color: #212529 !important;
border-radius: 4px !important;
padding: 2px 8px !important;
}
.ts-dropdown {
border-radius: 0.375rem !important;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
.ts-control.focus {
border-color: #0d6efd !important;
outline: 0 !important;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.1) !important;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
@ -16,17 +46,16 @@
<input type="text" name="q" class="form-control" placeholder="Search by name or email..." value="{{ query|default:'' }}">
</div>
<div class="col-md-4">
<select name="interest" class="form-select">
<option value="">All Interests</option>
<select name="interest" id="interest-filter" class="form-select" multiple>
{% for interest in interests %}
<option value="{{ interest.id }}" {% if selected_interest == interest.id|stringformat:"s" %}selected{% endif %}>
<option value="{{ interest.id }}" {% if interest.id|stringformat:"s" in selected_interests %}selected{% endif %}>
{{ interest.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">Filter</button>
<button type="submit" class="btn btn-primary w-100 h-100">Filter</button>
</div>
</form>
</div>
@ -110,12 +139,12 @@
<ul class="pagination justify-content-center mb-0">
{% if volunteers.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if query %}&q={{ query }}{% endif %}{% if selected_interest %}&interest={{ selected_interest }}{% endif %}" aria-label="First">
<a class="page-link" href="?page=1{% if query %}&q={{ query }}{% endif %}{% for i in selected_interests %}&interest={{ i }}{% endfor %}" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ volunteers.previous_page_number }}{% if query %}&q={{ query }}{% endif %}{% if selected_interest %}&interest={{ selected_interest }}{% endif %}" aria-label="Previous">
<a class="page-link" href="?page={{ volunteers.previous_page_number }}{% if query %}&q={{ query }}{% endif %}{% for i in selected_interests %}&interest={{ i }}{% endfor %}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
@ -125,12 +154,12 @@
{% if volunteers.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ volunteers.next_page_number }}{% if query %}&q={{ query }}{% endif %}{% if selected_interest %}&interest={{ selected_interest }}{% endif %}" aria-label="Next">
<a class="page-link" href="?page={{ volunteers.next_page_number }}{% if query %}&q={{ query }}{% endif %}{% for i in selected_interests %}&interest={{ i }}{% endfor %}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ volunteers.paginator.num_pages }}{% if query %}&q={{ query }}{% endif %}{% if selected_interest %}&interest={{ selected_interest }}{% endif %}" aria-label="Last">
<a class="page-link" href="?page={{ volunteers.paginator.num_pages }}{% if query %}&q={{ query }}{% endif %}{% for i in selected_interests %}&interest={{ i }}{% endfor %}" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
@ -173,6 +202,13 @@
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Tom Select for interest filter
new TomSelect('#interest-filter', {
plugins: ['remove_button'],
placeholder: 'All Interests',
allowEmptyOption: true,
});
const selectAll = document.getElementById('select-all');
const checkboxes = document.querySelectorAll('.volunteer-checkbox');
const bulkActions = document.getElementById('bulk-actions');

View File

@ -28,6 +28,10 @@
<label class="form-label small fw-bold text-muted">Voter ID</label>
{{ form.voter_id }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Address</label>
{{ form.address }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Birth Month</label>
{{ form.birth_month }}

View File

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container py-5 text-center">
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card border-0 shadow-sm rounded-4 py-5 px-4">
<div class="bg-success bg-opacity-10 d-inline-block p-4 rounded-circle mb-4 mx-auto" style="width: 100px; height: 100px;">
<i class="bi bi-check-lg text-success fs-1"></i>
</div>
<h2 class="h3 fw-bold mb-3">Password Changed!</h2>
<p class="text-muted mb-4">Your password has been successfully updated. You can now use your new password to log in.</p>
<div>
<a href="{% url 'profile' %}" class="btn btn-primary px-5 py-2 rounded-3">Back to Profile</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,53 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card border-0 shadow-sm rounded-4 overflow-hidden">
<div class="card-header bg-white border-0 py-4 px-4 text-center">
<div class="bg-primary bg-opacity-10 d-inline-block p-3 rounded-4 mb-3">
<i class="bi bi-shield-lock text-primary fs-3"></i>
</div>
<h2 class="h4 fw-bold mb-0">Change Password</h2>
<p class="text-muted small mb-0">Secure your account by updating your password</p>
</div>
<div class="card-body p-4">
<form method="post">
{% csrf_token %}
{% for field in form %}
<div class="mb-3">
<label for="{{ field.id_for_label }}" class="form-label small fw-bold text-muted">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}
<div class="form-text small">{{ field.help_text }}</div>
{% endif %}
{% for error in field.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</div>
{% endfor %}
<div class="d-grid gap-2 mt-4">
<button type="submit" class="btn btn-primary py-2 rounded-3">Update Password</button>
<a href="{% url 'profile' %}" class="btn btn-light py-2 rounded-3">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<style>
/* Styling for password help text lists */
.form-text ul {
padding-left: 1rem;
margin-top: 0.5rem;
}
input.form-control {
border-radius: 0.75rem;
padding: 0.75rem 1rem;
}
</style>
{% endblock %}

View File

@ -64,4 +64,5 @@ urlpatterns = [
path('call-queue/', views.call_queue, name='call_queue'),
path('call-queue/<int:call_id>/complete/', views.complete_call, name='complete_call'),
path('call-queue/<int:call_id>/delete/', views.delete_call, name='delete_call'),
path('profile/', views.profile, name='profile'),
]

View File

@ -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()
@ -1376,13 +1396,25 @@ def log_door_visit(request):
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)
@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)

111
core/views_new.py Normal file
View File

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

211
door_views_update.py Normal file
View File

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

View File

@ -22,7 +22,6 @@ h1, h2, h3, h4, h5, h6 {
}
.navbar {
background-color: #ffffff;
border-bottom: 1px solid var(--border-color);
padding: 1rem 0;
}

View File

@ -22,7 +22,6 @@ h1, h2, h3, h4, h5, h6 {
}
.navbar {
background-color: #ffffff;
border-bottom: 1px solid var(--border-color);
padding: 1rem 0;
}