Autosave: 20260203-133539
This commit is contained in:
parent
c3568101a3
commit
ac80c84fbd
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -54,6 +54,7 @@ VOTER_MAPPABLE_FIELDS = [
|
|||||||
('longitude', 'Longitude'),
|
('longitude', 'Longitude'),
|
||||||
('secondary_phone', 'Secondary Phone'),
|
('secondary_phone', 'Secondary Phone'),
|
||||||
('secondary_phone_type', 'Secondary Phone Type'),
|
('secondary_phone_type', 'Secondary Phone Type'),
|
||||||
|
('door_visit', 'Door Visit'),
|
||||||
]
|
]
|
||||||
|
|
||||||
EVENT_MAPPABLE_FIELDS = [
|
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?
|
if val == "" and not created: continue # Skip empty updates for existing records unless specifically desired?
|
||||||
|
|
||||||
# Type conversion and normalization
|
# Type conversion and normalization
|
||||||
if field_name == "is_targeted":
|
if field_name in ["is_targeted", "door_visit"]:
|
||||||
val = val.lower() in ["true", "1", "yes"]
|
val = val.lower() in ["true", "1", "yes"]
|
||||||
elif field_name in ["birthdate", "registration_date"]:
|
elif field_name in ["birthdate", "registration_date"]:
|
||||||
parsed_date = None
|
parsed_date = None
|
||||||
@ -794,7 +795,7 @@ class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
list_filter = ('tenant',)
|
list_filter = ('tenant',)
|
||||||
fields = ('tenant', 'user', 'first_name', 'last_name', 'email', 'phone', 'notes', 'interests')
|
fields = ('tenant', 'user', 'first_name', 'last_name', 'email', 'phone', 'notes', 'interests')
|
||||||
search_fields = ('first_name', 'last_name', 'email', 'phone')
|
search_fields = ('first_name', 'last_name', 'email', 'phone')
|
||||||
inlines = [VolunteerEventInline, InteractionInline]
|
inlines = [VolunteerEventInline]
|
||||||
filter_horizontal = ('interests',)
|
filter_horizontal = ('interests',)
|
||||||
change_list_template = "admin/volunteer_change_list.html"
|
change_list_template = "admin/volunteer_change_list.html"
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
from django import forms
|
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
|
from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType, Tenant, ParticipationStatus, Volunteer, VolunteerEvent, VolunteerRole, ScheduledCall
|
||||||
|
|
||||||
class VoterForm(forms.ModelForm):
|
class VoterForm(forms.ModelForm):
|
||||||
@ -44,6 +45,7 @@ class AdvancedVoterSearchForm(forms.Form):
|
|||||||
|
|
||||||
first_name = forms.CharField(required=False)
|
first_name = forms.CharField(required=False)
|
||||||
last_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")
|
voter_id = forms.CharField(required=False, label="Voter ID")
|
||||||
birth_month = forms.ChoiceField(choices=MONTH_CHOICES, required=False, label="Birth Month")
|
birth_month = forms.ChoiceField(choices=MONTH_CHOICES, required=False, label="Birth Month")
|
||||||
city = forms.CharField(required=False)
|
city = forms.CharField(required=False)
|
||||||
@ -310,6 +312,7 @@ class VotingRecordImportForm(forms.Form):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
||||||
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||||
|
|
||||||
class DoorVisitLogForm(forms.Form):
|
class DoorVisitLogForm(forms.Form):
|
||||||
OUTCOME_CHOICES = [
|
OUTCOME_CHOICES = [
|
||||||
("No Answer Left Literature", "No Answer Left Literature"),
|
("No Answer Left Literature", "No Answer Left Literature"),
|
||||||
@ -337,6 +340,17 @@ class DoorVisitLogForm(forms.Form):
|
|||||||
widget=forms.Select(attrs={"class": "form-select"}),
|
widget=forms.Select(attrs={"class": "form-select"}),
|
||||||
label="Candidate Support"
|
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 ScheduledCallForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -356,3 +370,23 @@ class ScheduledCallForm(forms.ModelForm):
|
|||||||
for field in self.fields.values():
|
for field in self.fields.values():
|
||||||
field.widget.attrs.update({'class': 'form-control'})
|
field.widget.attrs.update({'class': 'form-control'})
|
||||||
self.fields['volunteer'].widget.attrs.update({'class': 'form-select'})
|
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'})
|
||||||
|
|||||||
@ -47,15 +47,27 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="d-flex align-items-center">
|
<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 %}
|
{% if user.is_authenticated %}
|
||||||
<span class="text-muted small me-3">{{ user.username }}</span>
|
<div class="dropdown">
|
||||||
<form method="post" action="{% url 'logout' %}" class="d-inline">
|
<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;">
|
||||||
{% csrf_token %}
|
<i class="bi bi-person-circle me-1"></i>
|
||||||
<button type="submit" class="btn btn-link nav-link d-inline p-0" style="text-decoration: none;">Logout</button>
|
<span class="small">{{ user.username }}</span>
|
||||||
</form>
|
</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 dropdown-item small p-0 m-0"><i class="bi bi-box-arrow-right me-2"></i>Logout</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -72,7 +72,8 @@
|
|||||||
data-address="{{ household.address_street }}"
|
data-address="{{ household.address_street }}"
|
||||||
data-city="{{ household.city }}"
|
data-city="{{ household.city }}"
|
||||||
data-state="{{ household.state }}"
|
data-state="{{ household.state }}"
|
||||||
data-zip="{{ household.zip_code }}">
|
data-zip="{{ household.zip_code }}"
|
||||||
|
data-voters="{{ household.voters_json_str }}">
|
||||||
Log Visit
|
Log Visit
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@ -217,7 +218,7 @@
|
|||||||
{{ visit_form.notes }}
|
{{ visit_form.notes }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label class="form-label fw-bold text-primary small text-uppercase">Support Status</label>
|
<label class="form-label fw-bold text-primary small text-uppercase">Support Status</label>
|
||||||
{{ visit_form.candidate_support }}
|
{{ visit_form.candidate_support }}
|
||||||
@ -231,6 +232,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
<div class="modal-footer bg-light border-0 py-3">
|
<div class="modal-footer bg-light border-0 py-3">
|
||||||
<button type="button" class="btn btn-outline-secondary px-4" data-bs-dismiss="modal">Cancel</button>
|
<button type="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_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
|
||||||
|
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';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
73
core/templates/core/profile.html
Normal file
73
core/templates/core/profile.html
Normal 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 %}
|
||||||
@ -1,5 +1,35 @@
|
|||||||
{% extends "base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="container py-5">
|
<div class="container py-5">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<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:'' }}">
|
<input type="text" name="q" class="form-control" placeholder="Search by name or email..." value="{{ query|default:'' }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<select name="interest" class="form-select">
|
<select name="interest" id="interest-filter" class="form-select" multiple>
|
||||||
<option value="">All Interests</option>
|
|
||||||
{% for interest in interests %}
|
{% 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 }}
|
{{ interest.name }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -110,12 +139,12 @@
|
|||||||
<ul class="pagination justify-content-center mb-0">
|
<ul class="pagination justify-content-center mb-0">
|
||||||
{% if volunteers.has_previous %}
|
{% if volunteers.has_previous %}
|
||||||
<li class="page-item">
|
<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">««</span>
|
<span aria-hidden="true">««</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="page-item">
|
<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">«</span>
|
<span aria-hidden="true">«</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@ -125,12 +154,12 @@
|
|||||||
|
|
||||||
{% if volunteers.has_next %}
|
{% if volunteers.has_next %}
|
||||||
<li class="page-item">
|
<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">»</span>
|
<span aria-hidden="true">»</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="page-item">
|
<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">»»</span>
|
<span aria-hidden="true">»»</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@ -173,6 +202,13 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
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 selectAll = document.getElementById('select-all');
|
||||||
const checkboxes = document.querySelectorAll('.volunteer-checkbox');
|
const checkboxes = document.querySelectorAll('.volunteer-checkbox');
|
||||||
const bulkActions = document.getElementById('bulk-actions');
|
const bulkActions = document.getElementById('bulk-actions');
|
||||||
|
|||||||
@ -28,6 +28,10 @@
|
|||||||
<label class="form-label small fw-bold text-muted">Voter ID</label>
|
<label class="form-label small fw-bold text-muted">Voter ID</label>
|
||||||
{{ form.voter_id }}
|
{{ form.voter_id }}
|
||||||
</div>
|
</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">
|
<div class="col-md-4">
|
||||||
<label class="form-label small fw-bold text-muted">Birth Month</label>
|
<label class="form-label small fw-bold text-muted">Birth Month</label>
|
||||||
{{ form.birth_month }}
|
{{ form.birth_month }}
|
||||||
|
|||||||
21
core/templates/registration/password_change_done.html
Normal file
21
core/templates/registration/password_change_done.html
Normal 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 %}
|
||||||
53
core/templates/registration/password_change_form.html
Normal file
53
core/templates/registration/password_change_form.html
Normal 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 %}
|
||||||
@ -64,4 +64,5 @@ urlpatterns = [
|
|||||||
path('call-queue/', views.call_queue, name='call_queue'),
|
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>/complete/', views.complete_call, name='complete_call'),
|
||||||
path('call-queue/<int:call_id>/delete/', views.delete_call, name='delete_call'),
|
path('call-queue/<int:call_id>/delete/', views.delete_call, name='delete_call'),
|
||||||
|
path('profile/', views.profile, name='profile'),
|
||||||
]
|
]
|
||||||
127
core/views.py
127
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 django.utils.dateparse import parse_date
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
import base64
|
import base64
|
||||||
@ -15,7 +17,7 @@ from django.contrib import messages
|
|||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.conf import settings
|
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 .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 logging
|
||||||
import zoneinfo
|
import zoneinfo
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -446,6 +448,8 @@ def voter_advanced_search(request):
|
|||||||
voters = voters.filter(first_name__icontains=data['first_name'])
|
voters = voters.filter(first_name__icontains=data['first_name'])
|
||||||
if data.get('last_name'):
|
if data.get('last_name'):
|
||||||
voters = voters.filter(last_name__icontains=data['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'):
|
if data.get('voter_id'):
|
||||||
voters = voters.filter(voter_id__icontains=data['voter_id'])
|
voters = voters.filter(voter_id__icontains=data['voter_id'])
|
||||||
if data.get('birth_month'):
|
if data.get('birth_month'):
|
||||||
@ -520,6 +524,8 @@ def export_voters_csv(request):
|
|||||||
voters = voters.filter(first_name__icontains=data['first_name'])
|
voters = voters.filter(first_name__icontains=data['first_name'])
|
||||||
if data.get('last_name'):
|
if data.get('last_name'):
|
||||||
voters = voters.filter(last_name__icontains=data['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'):
|
if data.get('voter_id'):
|
||||||
voters = voters.filter(voter_id__icontains=data['voter_id'])
|
voters = voters.filter(voter_id__icontains=data['voter_id'])
|
||||||
if data.get('birth_month'):
|
if data.get('birth_month'):
|
||||||
@ -580,7 +586,7 @@ def voter_delete(request, voter_id):
|
|||||||
|
|
||||||
return redirect('voter_detail', voter_id=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):
|
def bulk_send_sms(request):
|
||||||
"""
|
"""
|
||||||
Sends bulk SMS to selected voters using Twilio API.
|
Sends bulk SMS to selected voters using Twilio API.
|
||||||
@ -839,23 +845,23 @@ def volunteer_list(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Interest filter
|
# Interest filter
|
||||||
interest_id = request.GET.get("interest")
|
interest_ids = request.GET.getlist("interest")
|
||||||
if interest_id:
|
if interest_ids:
|
||||||
volunteers = volunteers.filter(interests__id=interest_id)
|
volunteers = volunteers.filter(interests__id__in=interest_ids).distinct()
|
||||||
|
|
||||||
|
interests = Interest.objects.filter(tenant=tenant).order_by('name')
|
||||||
|
|
||||||
paginator = Paginator(volunteers, 50)
|
paginator = Paginator(volunteers, 50)
|
||||||
page_number = request.GET.get('page')
|
page_number = request.GET.get('page')
|
||||||
volunteers_page = paginator.get_page(page_number)
|
volunteers_page = paginator.get_page(page_number)
|
||||||
|
|
||||||
interests = Interest.objects.filter(tenant=tenant).order_by('name')
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'tenant': tenant,
|
'tenant': tenant,
|
||||||
'selected_tenant': tenant,
|
'selected_tenant': tenant,
|
||||||
'volunteers': volunteers_page,
|
'volunteers': volunteers_page,
|
||||||
'query': query,
|
'query': query,
|
||||||
'interests': interests,
|
'interests': interests,
|
||||||
'selected_interest': interest_id,
|
'selected_interests': interest_ids,
|
||||||
}
|
}
|
||||||
return render(request, 'core/volunteer_list.html', context)
|
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.")
|
messages.success(request, f"{volunteer_name} removed from event volunteers.")
|
||||||
return redirect('event_detail', event_id=event_id)
|
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):
|
def volunteer_bulk_send_sms(request):
|
||||||
"""
|
"""
|
||||||
Sends bulk SMS to selected volunteers using Twilio API.
|
Sends bulk SMS to selected volunteers using Twilio API.
|
||||||
@ -1196,6 +1202,7 @@ def volunteer_bulk_send_sms(request):
|
|||||||
return redirect('volunteer_list')
|
return redirect('volunteer_list')
|
||||||
|
|
||||||
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
|
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
|
||||||
|
|
||||||
def door_visits(request):
|
def door_visits(request):
|
||||||
"""
|
"""
|
||||||
Manage door knocking visits. Groups unvisited targeted voters by household.
|
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,
|
'longitude': float(voter.longitude) if voter.longitude else None,
|
||||||
'street_name_sort': street_name.lower(),
|
'street_name_sort': street_name.lower(),
|
||||||
'street_number_sort': street_number_sort,
|
'street_number_sort': street_number_sort,
|
||||||
'target_voters': []
|
'target_voters': [],
|
||||||
|
'voters_json': []
|
||||||
}
|
}
|
||||||
households_dict[key]['target_voters'].append(voter)
|
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())
|
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: (
|
households_list.sort(key=lambda x: (
|
||||||
(x['neighborhood'] or '').lower(),
|
(x['neighborhood'] or '').lower(),
|
||||||
x['street_name_sort'],
|
x['street_name_sort'],
|
||||||
@ -1299,39 +1311,42 @@ def log_door_visit(request):
|
|||||||
"""
|
"""
|
||||||
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:
|
||||||
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)
|
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", "")
|
||||||
redirect_url = reverse('door_visits')
|
redirect_url = reverse("door_visits")
|
||||||
if next_qs:
|
if next_qs:
|
||||||
redirect_url += f"?{next_qs}"
|
redirect_url += f"?{next_qs}"
|
||||||
|
|
||||||
# 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)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
address_street = request.POST.get('address_street')
|
address_street = request.POST.get("address_street")
|
||||||
city = request.POST.get('city')
|
city = request.POST.get("city")
|
||||||
state = request.POST.get('state')
|
state = request.POST.get("state")
|
||||||
zip_code = request.POST.get('zip_code')
|
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"]
|
||||||
candidate_support = form.cleaned_data['candidate_support']
|
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
|
# 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:
|
try:
|
||||||
tz = zoneinfo.ZoneInfo(campaign_tz_name)
|
tz = zoneinfo.ZoneInfo(campaign_tz_name)
|
||||||
except:
|
except:
|
||||||
tz = zoneinfo.ZoneInfo('America/Chicago')
|
tz = zoneinfo.ZoneInfo("America/Chicago")
|
||||||
|
|
||||||
interaction_date = timezone.now().astimezone(tz)
|
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}.")
|
messages.warning(request, f"No targeted voters found at {address_street}.")
|
||||||
return redirect(redirect_url)
|
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:
|
for voter in voters:
|
||||||
# 1) Update voter flags
|
# 1) Update voter flags
|
||||||
voter.door_visit = True
|
voter.door_visit = True
|
||||||
|
|
||||||
# 2) If "Wants a Yard Sign" checkbox is selected
|
# 2) If "Wants a Yard Sign" checkbox is selected
|
||||||
if wants_yard_sign:
|
if wants_yard_sign:
|
||||||
voter.yard_sign = 'wants'
|
voter.yard_sign = "wants"
|
||||||
|
|
||||||
# 3) Update support status if Supporting or Not Supporting
|
# 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.candidate_support = candidate_support
|
||||||
|
|
||||||
voter.save()
|
voter.save()
|
||||||
@ -1376,13 +1396,25 @@ def log_door_visit(request):
|
|||||||
notes=notes
|
notes=notes
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, f"Door visit logged for {address_street}.")
|
# 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:
|
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)
|
||||||
|
|
||||||
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
|
|
||||||
def door_visit_history(request):
|
def door_visit_history(request):
|
||||||
"""
|
"""
|
||||||
Shows a distinct list of Door visit interactions for addresses.
|
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)
|
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):
|
def schedule_call(request, voter_id):
|
||||||
selected_tenant_id = request.session.get("tenant_id")
|
selected_tenant_id = request.session.get("tenant_id")
|
||||||
tenant = get_object_or_404(Tenant, id=selected_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(referer)
|
||||||
return redirect('voter_detail', voter_id=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.add_scheduledcall')
|
||||||
def bulk_schedule_calls(request):
|
def bulk_schedule_calls(request):
|
||||||
if request.method != 'POST':
|
if request.method != 'POST':
|
||||||
return redirect('voter_advanced_search')
|
return redirect('voter_advanced_search')
|
||||||
@ -1530,7 +1562,7 @@ def bulk_schedule_calls(request):
|
|||||||
messages.success(request, f"{count} calls added to queue.")
|
messages.success(request, f"{count} calls added to queue.")
|
||||||
return redirect(request.META.get('HTTP_REFERER', 'voter_advanced_search'))
|
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):
|
def call_queue(request):
|
||||||
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:
|
||||||
@ -1550,8 +1582,7 @@ def call_queue(request):
|
|||||||
}
|
}
|
||||||
return render(request, 'core/call_queue.html', context)
|
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'], permission='core.change_scheduledcall')
|
||||||
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
|
|
||||||
def complete_call(request, call_id):
|
def complete_call(request, call_id):
|
||||||
selected_tenant_id = request.session.get("tenant_id")
|
selected_tenant_id = request.session.get("tenant_id")
|
||||||
tenant = get_object_or_404(Tenant, id=selected_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')
|
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):
|
def delete_call(request, call_id):
|
||||||
selected_tenant_id = request.session.get("tenant_id")
|
selected_tenant_id = request.session.get("tenant_id")
|
||||||
tenant = get_object_or_404(Tenant, id=selected_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.")
|
messages.success(request, "Call removed from queue.")
|
||||||
|
|
||||||
return redirect('call_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
111
core/views_new.py
Normal 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
211
door_views_update.py
Normal 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)
|
||||||
@ -22,7 +22,6 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.navbar {
|
.navbar {
|
||||||
background-color: #ffffff;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,6 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.navbar {
|
.navbar {
|
||||||
background-color: #ffffff;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user