Autosave: 20260213-015538

This commit is contained in:
Flatlogic Bot 2026-02-13 01:55:39 +00:00
parent fbcc2964bf
commit 88ee4c2516
18 changed files with 1092 additions and 156 deletions

View File

@ -1974,4 +1974,29 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
context['form'] = form
context['title'] = "Import Voting Records"
context['opts'] = self.model._meta
return render(request, "admin/import_csv.html", context)
return render(request, "admin/import_csv.html", context)
@admin.register(CampaignSettings)
class CampaignSettingsAdmin(admin.ModelAdmin):
list_display = ('tenant', 'smtp_host', 'email_from_address', 'timezone')
list_filter = ('tenant',)
search_fields = ('tenant__name', 'smtp_host', 'email_from_address')
fieldsets = (
(None, {
'fields': ('tenant', 'timezone', 'donation_goal')
}),
('Twilio Settings', {
'fields': ('twilio_account_sid', 'twilio_auth_token', 'twilio_from_number'),
'classes': ('collapse',),
}),
('SMTP Settings', {
'fields': (
'email_from_address',
'smtp_host',
'smtp_port',
'smtp_username',
'smtp_password',
'smtp_use_tls',
'smtp_use_ssl',
),
}),
)

View File

@ -0,0 +1,48 @@
# Generated by Django 5.2.7 on 2026-02-11 15:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0041_alter_volunteer_options'),
]
operations = [
migrations.AddField(
model_name='campaignsettings',
name='email_from_address',
field=models.EmailField(blank=True, max_length=254),
),
migrations.AddField(
model_name='campaignsettings',
name='smtp_host',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='campaignsettings',
name='smtp_password',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='campaignsettings',
name='smtp_port',
field=models.IntegerField(default=587),
),
migrations.AddField(
model_name='campaignsettings',
name='smtp_use_ssl',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='campaignsettings',
name='smtp_use_tls',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='campaignsettings',
name='smtp_username',
field=models.CharField(blank=True, max_length=255),
),
]

View File

@ -435,6 +435,13 @@ class CampaignSettings(models.Model):
twilio_auth_token = models.CharField(max_length=100, blank=True, default='89ec830d0fa02ab0afa6c76084865713')
twilio_from_number = models.CharField(max_length=20, blank=True, default='+18556945903')
timezone = models.CharField(max_length=100, default="America/Chicago", choices=[(tz, tz) for tz in sorted(zoneinfo.available_timezones())])
smtp_host = models.CharField(max_length=255, blank=True)
smtp_port = models.IntegerField(default=587)
smtp_username = models.CharField(max_length=255, blank=True)
smtp_password = models.CharField(max_length=255, blank=True)
smtp_use_tls = models.BooleanField(default=True)
smtp_use_ssl = models.BooleanField(default=False)
email_from_address = models.EmailField(blank=True)
class Meta:
verbose_name = 'Campaign Settings'

View File

@ -180,7 +180,14 @@
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<div id="map" style="width: 100%; height: 100%; min-height: 500px;"></div>
<div id="map" style="width: 100%; height: 100%; min-height: 500px; position: relative;">
<div id="map-controls">
<button id="center-user" class="map-control-btn" title="Center on My Location">
<i class="bi bi-geo-alt-fill"></i>
</button>
</div>
</div>
</div>
</div>
</div>
@ -283,8 +290,50 @@
<script>
var map;
var markers = [];
var userLocationMarker;
var mapData = {{ map_data_json|safe }};
function showUserLocation(centerOnUser = false) {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(position) {
var pos = {
lat: position.coords.latitude,
lng: position.coords.longitude
};
if (userLocationMarker) {
userLocationMarker.setPosition(pos);
} else {
userLocationMarker = new google.maps.Marker({
position: pos,
map: map,
title: 'Your Location',
icon: {
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#4285F4',
fillOpacity: 1,
scale: 8,
strokeColor: 'white',
strokeWeight: 2
}
});
}
if (centerOnUser) {
map.setCenter(pos);
map.setZoom(15);
}
}, function(error) {
console.warn("Geolocation failed: " + error.message);
}, {
enableHighAccuracy: true,
timeout: 5000,
maximumAge: 0
});
}
}
function initMap() {
if (!window.google || !window.google.maps) {
console.error("Google Maps API not loaded");
@ -335,6 +384,17 @@
if (markers.length > 0) {
map.fitBounds(bounds);
}
// Initial attempt to show user location
showUserLocation();
// Setup control button
var centerBtn = document.getElementById('center-user');
if (centerBtn) {
centerBtn.addEventListener('click', function() {
showUserLocation(true);
});
}
}
document.addEventListener('DOMContentLoaded', function() {
@ -456,5 +516,31 @@
border: 0;
}
}
#map-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 5;
}
.map-control-btn {
background-color: #fff;
border: 2px solid #fff;
border-radius: 3px;
box-shadow: 0 2px 6px rgba(0,0,0,.3);
cursor: pointer;
margin-bottom: 22px;
text-align: center;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.map-control-btn:hover {
color: #333;
}
</style>
{% endblock %}

View File

@ -111,6 +111,7 @@
</div>
<h6 class="text-uppercase fw-bold small text-muted mb-1">Signs</h6>
<h3 class="mb-0 fw-bold">{{ metrics.total_signs }}</h3>
<a href="{% url 'yard_sign_voters' %}" class="small text-decoration-none mt-2 d-inline-block text-primary">Wants Sign &rarr;</a>
</div>
</div>
</div>

View File

@ -38,6 +38,9 @@
<div id="bulk-actions" class="d-none">
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#smsModal">
<i class="bi bi-chat-left-text me-1"></i> Send Bulk SMS
<button type="button" class="btn btn-primary btn-sm ms-2" data-bs-toggle="modal" data-bs-target="#emailModal">
<i class="bi bi-envelope me-1"></i> Send Bulk Email
</button>
</button>
</div>
</div>
@ -171,6 +174,38 @@
</div>
</div>
<!-- Email Modal -->
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold" id="emailModalLabel">Send Bulk Email</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url "volunteer_bulk_send_email" %}" method="POST" id="email-form">
{% csrf_token %}
<div class="modal-body py-4">
<p class="small text-muted mb-3">Emails will be sent to selected volunteers with an email address on file.</p>
<div id="selected-volunteers-email-container">
<!-- Volunteer IDs will be injected here -->
</div>
<div class="mb-3">
<label for="subject" class="form-label small fw-bold text-muted">Subject</label>
<input type="text" class="form-control" id="subject" name="subject" required placeholder="Email subject...">
</div>
<div class="mb-0">
<label for="body" class="form-label small fw-bold text-muted">Message Body</label>
<textarea class="form-control" id="body" name="body" rows="6" required placeholder="Type your email body here..."></textarea>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-4">Send Bulk Email</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const selectAll = document.getElementById('select-all');
@ -221,6 +256,23 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
}
const emailModal = document.getElementById('emailModal');
if (emailModal) {
emailModal.addEventListener('show.bs.modal', function () {
const container = document.getElementById('selected-volunteers-email-container');
container.innerHTML = '';
checkboxes.forEach(cb => {
if (cb.checked) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_volunteers';
input.value = cb.value;
container.appendChild(input);
}
});
});
}
});
</script>
{% endblock %}

View File

@ -116,6 +116,9 @@
</button>
<button type="button" class="btn btn-primary btn-sm ms-2" data-bs-toggle="modal" data-bs-target="#smsModal">
<i class="bi bi-chat-left-text me-1"></i> Send Bulk SMS
<button type="button" class="btn btn-primary btn-sm ms-2" data-bs-toggle="modal" data-bs-target="#emailModal">
<i class="bi bi-envelope me-1"></i> Send Bulk Email
</button>
</button>
</div>
<div>
@ -331,6 +334,38 @@
</div>
</div>
<!-- Email Modal -->
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold" id="emailModalLabel">Send Bulk Email</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url "voter_bulk_send_email" %}" method="POST" id="email-form">
{% csrf_token %}
<div class="modal-body py-4">
<p class="small text-muted mb-3">Emails will be sent to selected voters with an email address on file. Interactions will be logged.</p>
<div id="selected-voters-email-container">
<!-- Voter IDs will be injected here -->
</div>
<div class="mb-3">
<label for="subject" class="form-label small fw-bold text-muted">Subject</label>
<input type="text" class="form-control" id="subject" name="subject" required placeholder="Email subject...">
</div>
<div class="mb-0">
<label for="body" class="form-label small fw-bold text-muted">Message Body</label>
<textarea class="form-control" id="body" name="body" rows="6" required placeholder="Type your email body here..."></textarea>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-4">Send Bulk Email</button>
</div>
</form>
</div>
</div>
</div>
<!-- Individual Schedule Call Modal -->
<div class="modal fade" id="scheduleCallModal" tabindex="-1" aria-labelledby="scheduleCallModalLabel" aria-hidden="true">
<div class="modal-dialog">
@ -487,6 +522,23 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
const emailModal = document.getElementById('emailModal');
if (emailModal) {
emailModal.addEventListener('show.bs.modal', function () {
const container = document.getElementById('selected-voters-email-container');
container.innerHTML = '';
checkboxes.forEach(cb => {
if (cb.checked) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_voters';
input.value = cb.value;
container.appendChild(input);
}
});
});
}
// Individual Schedule Call Modal dynamic content
var scheduleCallModal = document.getElementById('scheduleCallModal');
if (scheduleCallModal) {

View File

@ -0,0 +1,352 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container-fluid py-4 py-md-5 px-3 px-md-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-4 mb-md-5 gap-3">
<div>
<h1 class="h2 fw-bold text-dark mb-1">Yard Sign Requests</h1>
<p class="text-muted mb-0">View and manage households that have requested a yard sign.</p>
</div>
<div class="d-flex flex-column flex-sm-row gap-2">
<button type="button" class="btn btn-outline-danger shadow-sm py-1 px-4 flex-grow-1" data-bs-toggle="modal" data-bs-target="#mapModal">
<i class="bi bi-map-fill me-1"></i> View Map
</button>
</div>
</div>
<!-- Filters Card -->
<div class="card border-0 shadow-sm rounded-4 mb-4 mb-md-5 overflow-hidden">
<div class="card-header bg-white py-3 border-0">
<h5 class="card-title mb-0 fw-bold text-danger">Filters</h5>
</div>
<div class="card-body p-3 p-md-4">
<form method="GET" action="." class="row g-3 align-items-end">
<div class="col-12 col-md-2">
<label class="form-label small fw-bold text-uppercase text-muted">District</label>
<input type="text" name="district" class="form-control rounded-3" placeholder="Filter by district..." value="{{ district_filter }}">
</div>
<div class="col-12 col-md-2">
<label class="form-label small fw-bold text-uppercase text-muted">Neighborhood</label>
<input type="text" name="neighborhood" class="form-control rounded-3" placeholder="Filter by neighborhood..." value="{{ neighborhood_filter }}">
</div>
<div class="col-12 col-md-2">
<label class="form-label small fw-bold text-uppercase text-muted">City</label>
<input type="text" name="city" class="form-control rounded-3" placeholder="Filter by city..." value="{{ city_filter }}">
</div>
<div class="col-12 col-md-4">
<label class="form-label small fw-bold text-uppercase text-muted">Address Search</label>
<input type="text" name="address" class="form-control rounded-3" placeholder="Filter by street address..." value="{{ address_filter }}">
</div>
<div class="col-12 col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-danger w-100 rounded-3">Filter</button>
<a href="." class="btn btn-light w-100 rounded-3">Reset</a>
</div>
</form>
</div>
</div>
<!-- Households List -->
<div class="card border-0 shadow-sm rounded-4 overflow-hidden">
<div class="card-header bg-white py-3 border-bottom d-flex flex-column flex-sm-row justify-content-between align-items-sm-center gap-2">
<h5 class="card-title mb-0 fw-bold text-dark">Households Wanting Signs</h5>
<span class="badge bg-danger-subtle text-danger px-3 py-2 rounded-pill w-auto align-self-start align-self-sm-center">
{{ households.paginator.count }} Households Found
</span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light text-muted">
<tr>
<th class="ps-4 py-3 text-uppercase small ls-1">Household Address</th>
<th class="py-3 text-uppercase small ls-1">Voters Wanting Sign</th>
<th class="pe-4 py-3 text-uppercase small ls-1 d-none d-md-table-cell">Neighborhood</th>
</tr>
</thead>
<tbody>
{% for household in households %}
<tr>
<td class="ps-4">
<div class="fw-bold text-dark">{{ household.address_street }}</div>
<div class="small text-muted">{{ household.city }}, {{ household.state }} {{ household.zip_code }}</div>
<div class="d-md-none mt-1">
{% if household.neighborhood %}
<span class="badge border border-danger-subtle bg-danger-subtle text-danger fw-medium px-2 py-1 small">
{{ household.neighborhood }}
</span>
{% endif %}
</div>
</td>
<td>
<div class="d-flex flex-wrap gap-1">
{% for voter in household.voters_who_want_sign %}
<a href="{% url 'voter_detail' voter.id %}" class="badge bg-light text-danger border border-danger-subtle text-decoration-none hover-underline">
{{ voter.first_name }} {{ voter.last_name }}
</a>
{% endfor %}
</div>
</td>
<td class="pe-4 d-none d-md-table-cell">
{% if household.neighborhood %}
<span class="badge border border-danger-subtle bg-danger-subtle text-danger fw-medium px-2 py-1">
{{ household.neighborhood }}
</span>
{% else %}
<span class="text-muted italic small">Not assigned</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center py-5">
<div class="text-muted mb-2">
<i class="bi bi-signpost-2 mb-2" style="font-size: 3rem; opacity: 0.3;"></i>
</div>
<p class="mb-0 fw-medium">No households wanting signs found.</p>
<p class="small text-muted">Try adjusting your filters or search criteria.</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if households.paginator.num_pages > 1 %}
<div class="card-footer bg-white border-0 py-4">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0">
{% if households.has_previous %}
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page=1{% if district_filter %}&district={{ district_filter|urlencode }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter|urlencode }}{% endif %}{% if city_filter %}&city={{ city_filter|urlencode }}{% endif %}{% if address_filter %}&address={{ address_filter|urlencode }}{% endif %}" aria-label="First">
<i class="bi bi-chevron-double-left small"></i>
</a>
</li>
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ households.previous_page_number }}{% if district_filter %}&district={{ district_filter|urlencode }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter|urlencode }}{% endif %}{% if city_filter %}&city={{ city_filter|urlencode }}{% endif %}{% if address_filter %}&address={{ address_filter|urlencode }}{% endif %}" aria-label="Previous">
<i class="bi bi-chevron-left small"></i>
</a>
</li>
{% endif %}
<li class="page-item active mx-1 mx-sm-2"><span class="page-link rounded-pill px-2 px-sm-3 border-0 small">Page {{ households.number }} of {{ households.paginator.num_pages }}</span></li>
{% if households.has_next %}
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ households.next_page_number }}{% if district_filter %}&district={{ district_filter|urlencode }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter|urlencode }}{% endif %}{% if city_filter %}&city={{ city_filter|urlencode }}{% endif %}{% if address_filter %}&address={{ address_filter|urlencode }}{% endif %}" aria-label="Next">
<i class="bi bi-chevron-right small"></i>
</a>
</li>
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ households.paginator.num_pages }}{% if district_filter %}&district={{ district_filter|urlencode }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter|urlencode }}{% endif %}{% if city_filter %}&city={{ city_filter|urlencode }}{% endif %}{% if address_filter %}&address={{ address_filter|urlencode }}{% endif %}" aria-label="Last">
<i class="bi bi-chevron-double-right small"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
</div>
<!-- Map Modal -->
<div class="modal fade" id="mapModal" tabindex="-1" aria-labelledby="mapModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen p-2 p-md-4">
<div class="modal-content rounded-4 border-0 shadow">
<div class="modal-header border-0 bg-danger text-white p-3 p-md-4">
<h5 class="modal-title d-flex align-items-center" id="mapModalLabel">
<i class="bi bi-map-fill me-2"></i> Yard Sign Requests Map
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<div id="map" style="width: 100%; height: 100%; min-height: 500px; position: relative;">
<div id="map-controls">
<button id="center-user" class="map-control-btn" title="Center on My Location">
<i class="bi bi-geo-alt-fill"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<!-- Google Maps JS -->
{% if GOOGLE_MAPS_API_KEY %}
<script src="https://maps.googleapis.com/maps/api/js?key={{ GOOGLE_MAPS_API_KEY }}"></script>
{% endif %}
<script>
var map;
var markers = [];
var userLocationMarker;
var mapData = {{ map_data_json|safe }};
function showUserLocation(centerOnUser = false) {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(position) {
var pos = {
lat: position.coords.latitude,
lng: position.coords.longitude
};
if (userLocationMarker) {
userLocationMarker.setPosition(pos);
} else {
userLocationMarker = new google.maps.Marker({
position: pos,
map: map,
title: 'Your Location',
icon: {
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#4285F4',
fillOpacity: 1,
scale: 8,
strokeColor: 'white',
strokeWeight: 2
}
});
}
if (centerOnUser) {
map.setCenter(pos);
map.setZoom(15);
}
}, function(error) {
console.warn("Geolocation failed: " + error.message);
}, {
enableHighAccuracy: true,
timeout: 5000,
maximumAge: 0
});
}
}
function initMap() {
if (!window.google || !window.google.maps) {
console.error("Google Maps API not loaded");
return;
}
var mapOptions = {
zoom: 12,
center: { lat: 41.8781, lng: -87.6298 }, // Default to Chicago
mapTypeControl: true,
streetViewControl: true,
fullscreenControl: true
};
map = new google.maps.Map(document.getElementById('map'), mapOptions);
var bounds = new google.maps.LatLngBounds();
var infowindow = new google.maps.InfoWindow();
mapData.forEach(function(item) {
if (item.lat && item.lng) {
var position = { lat: parseFloat(item.lat), lng: parseFloat(item.lng) };
var marker = new google.maps.Marker({
position: position,
map: map,
title: item.address,
icon: 'http://maps.google.com/mapfiles/ms/icons/red-dot.png'
});
marker.addListener('click', function() {
var content = '<strong>' + item.address + '</strong><br>' +
'Voters: ' + item.voters;
infowindow.setContent(content);
infowindow.open(map, marker);
});
markers.push(marker);
bounds.extend(position);
}
});
if (markers.length > 0) {
map.fitBounds(bounds);
}
showUserLocation();
var centerBtn = document.getElementById('center-user');
if (centerBtn) {
centerBtn.addEventListener('click', function() {
showUserLocation(true);
});
}
}
document.addEventListener('DOMContentLoaded', function() {
var mapModalElement = document.getElementById('mapModal');
if (mapModalElement) {
mapModalElement.addEventListener('shown.bs.modal', function () {
if (!map) {
initMap();
} else {
google.maps.event.trigger(map, 'resize');
if (markers.length > 0) {
var bounds = new google.maps.LatLngBounds();
markers.forEach(function(marker) {
bounds.extend(marker.getPosition());
});
map.fitBounds(bounds);
}
}
});
}
});
</script>
<style>
.btn-outline-danger {
color: #dc3545 !important;
border-color: #dc3545 !important;
}
.btn-outline-danger:hover {
background-color: #dc3545 !important;
color: #ffffff !important;
}
.hover-underline:hover {
text-decoration: underline !important;
}
.bg-danger-subtle {
background-color: #f8d7da;
}
.text-danger {
color: #dc3545 !important;
}
.border-danger-subtle {
border-color: #f5c2c7 !important;
}
.ls-1 {
letter-spacing: 1px;
}
#map-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 5;
}
.map-control-btn {
background-color: #fff;
border: 2px solid #fff;
border-radius: 3px;
box-shadow: 0 2px 6px rgba(0,0,0,.3);
cursor: pointer;
margin-bottom: 22px;
text-align: center;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
</style>
{% endblock %}

View File

@ -8,6 +8,7 @@ urlpatterns = [
path('voters/advanced-search/', views.voter_advanced_search, name='voter_advanced_search'),
path('voters/export-csv/', views.export_voters_csv, name='export_voters_csv'),
path('voters/bulk-sms/', views.bulk_send_sms, name='bulk_send_sms'),
path('voters/bulk-email/', views.voter_bulk_send_email, name='voter_bulk_send_email'),
path('voters/<int:voter_id>/', views.voter_detail, name='voter_detail'),
path('voters/<int:voter_id>/edit/', views.voter_edit, name='voter_edit'),
path('voters/<int:voter_id>/delete/', views.voter_delete, name='voter_delete'),
@ -56,6 +57,7 @@ urlpatterns = [
path('volunteers/assignment/<int:assignment_id>/remove/', views.volunteer_remove_event, name='volunteer_remove_event'),
path('volunteers/search/json/', views.volunteer_search_json, name='volunteer_search_json'),
path('volunteers/bulk-sms/', views.volunteer_bulk_send_sms, name='volunteer_bulk_send_sms'),
path('volunteers/bulk-email/', views.volunteer_bulk_send_email, name='volunteer_bulk_send_email'),
path('events/<int:event_id>/volunteer/add/', views.event_add_volunteer, name='event_add_volunteer'),
path('events/volunteer/<int:assignment_id>/delete/', views.event_remove_volunteer, name='event_remove_volunteer'),
@ -65,6 +67,9 @@ urlpatterns = [
path('door-visits/history/', views.door_visit_history, name='door_visit_history'),
path('door-visits/neighborhoods/', views.neighborhood_counts, name='neighborhood_counts'),
# Yard Signs
path('yard-signs/', views.yard_sign_voters, name='yard_sign_voters'),
# Call Queue
path('call-queue/', views.call_queue, name='call_queue'),
path('call-queue/<int:call_id>/complete/', views.complete_call, name='complete_call'),

View File

@ -12,7 +12,7 @@ import json
from django.http import JsonResponse, HttpResponse
from django.urls import reverse
from django.shortcuts import render, redirect, get_object_or_404
from django.db.models import Q, Sum, Value
from django.db.models import Q, Sum, Value, DecimalField
from django.contrib import messages
from django.core.paginator import Paginator
from django.conf import settings
@ -93,7 +93,7 @@ def index(request):
'total_supporting': voters.filter(candidate_support='supporting').count(),
'total_target_households': voters.filter(is_targeted=True).exclude(address='').values('address').distinct().count(),
'total_door_visits': voters.filter(door_visit=True).exclude(address='').values('address').distinct().count(),
'total_signs': voters.filter(Q(yard_sign='wants') | Q(yard_sign='has')).count(),
'total_signs': voters.filter(Q(yard_sign='wants') | Q(yard_sign='has')).exclude(address='').values('address').distinct().count(),
'total_window_stickers': voters.filter(Q(window_sticker='wants') | Q(window_sticker='has')).count(),
'total_donations': float(total_donations),
'donation_goal': float(donation_goal),
@ -517,7 +517,7 @@ def voter_advanced_search(request):
if min_total_donation is not None or max_total_donation is not None:
# Annotate each voter with their total donation amount, treating no donations as 0
voters = voters.annotate(total_donation_amount=Coalesce(Sum('donations__amount'), Value(0)))
voters = voters.annotate(total_donation_amount=Coalesce(Sum('donations__amount'), Value(0), output_field=DecimalField()))
if min_total_donation is not None:
voters = voters.filter(total_donation_amount__gte=min_total_donation)
@ -607,7 +607,7 @@ def export_voters_csv(request):
max_total_donation = data.get('max_total_donation')
if min_total_donation is not None or max_total_donation is not None:
voters = voters.annotate(total_donation_amount=Coalesce(Sum('donations__amount'), Value(0)))
voters = voters.annotate(total_donation_amount=Coalesce(Sum('donations__amount'), Value(0), output_field=DecimalField()))
if min_total_donation is not None:
voters = voters.filter(total_donation_amount__gte=min_total_donation)
@ -2133,3 +2133,219 @@ def neighborhood_counts(request):
'address_filter': address_filter,
}
return render(request, 'core/neighborhood_counts.html', context)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter')
def yard_sign_voters(request):
"""
Manage yard sign requests. Groups voters who want a yard sign 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)
city_filter = request.GET.get("city", "").strip()
district_filter = request.GET.get('district', '').strip()
neighborhood_filter = request.GET.get('neighborhood', '').strip()
address_filter = request.GET.get('address', '').strip()
# Initial queryset: voters who want a yard sign for this tenant
voters = Voter.objects.filter(tenant=tenant, yard_sign='wants')
if city_filter:
voters = voters.filter(city__icontains=city_filter)
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__icontains=address_filter) | Q(address_street__icontains=address_filter))
# Grouping by household (unique address)
households_dict = {}
for voter in voters:
key = (voter.address_street, voter.city, voter.state, voter.zip_code)
if key not in households_dict:
street_number = ""
street_name = voter.address_street or ""
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,
'voters_who_want_sign': [],
}
households_dict[key]['voters_who_want_sign'].append(voter)
households_list = list(households_dict.values())
households_list.sort(key=lambda x: (
(x['neighborhood'] or '').lower(),
x['street_name_sort'],
x['street_number_sort']
))
# Prepare data for Google Map
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['voters_who_want_sign']])
}
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,
"city_filter": city_filter,
'map_data_json': json.dumps(map_data),
'GOOGLE_MAPS_API_KEY': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''),
}
return render(request, 'core/yard_sign_voters.html', context)
from django.core.mail import get_connection, EmailMessage
def get_tenant_email_connection(campaign_settings):
if not campaign_settings.smtp_host:
return None
return get_connection(
host=campaign_settings.smtp_host,
port=campaign_settings.smtp_port,
username=campaign_settings.smtp_username,
password=campaign_settings.smtp_password,
use_tls=campaign_settings.smtp_use_tls,
use_ssl=campaign_settings.smtp_use_ssl,
)
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
def volunteer_bulk_send_email(request):
selected_tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
campaign_settings = CampaignSettings.objects.get(tenant=tenant)
if request.method == 'POST':
volunteer_ids = request.POST.getlist('selected_volunteers')
subject = request.POST.get('subject')
body = request.POST.get('body')
volunteers = Volunteer.objects.filter(id__in=volunteer_ids, tenant=tenant).exclude(email='')
if not volunteers.exists():
messages.warning(request, "No volunteers with email addresses selected.")
return redirect('volunteer_list')
connection = get_tenant_email_connection(campaign_settings)
if not connection:
messages.error(request, "SMTP settings are not configured. Please check Campaign Settings.")
return redirect('volunteer_list')
from_email = campaign_settings.email_from_address or settings.DEFAULT_FROM_EMAIL
sent_count = 0
error_count = 0
for volunteer in volunteers:
try:
email = EmailMessage(
subject,
body,
from_email,
[volunteer.email],
connection=connection,
)
email.send()
sent_count += 1
except Exception as e:
logger.error(f"Error sending bulk email to volunteer {volunteer.email}: {e}")
error_count += 1
if sent_count > 0:
messages.success(request, f"Successfully sent {sent_count} emails.")
if error_count > 0:
messages.error(request, f"Failed to send {error_count} emails.")
return redirect('volunteer_list')
@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'])
def voter_bulk_send_email(request):
selected_tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
campaign_settings = CampaignSettings.objects.get(tenant=tenant)
if request.method == 'POST':
voter_ids = request.POST.getlist('selected_voters')
subject = request.POST.get('subject')
body = request.POST.get('body')
voters = Voter.objects.filter(id__in=voter_ids, tenant=tenant).exclude(email='')
if not voters.exists():
messages.warning(request, "No voters with email addresses selected.")
return redirect('voter_advanced_search')
connection = get_tenant_email_connection(campaign_settings)
if not connection:
messages.error(request, "SMTP settings are not configured. Please check Campaign Settings.")
return redirect('voter_advanced_search')
from_email = campaign_settings.email_from_address or settings.DEFAULT_FROM_EMAIL
email_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name='Email')
sent_count = 0
error_count = 0
for voter in voters:
try:
email = EmailMessage(
subject,
body,
from_email,
[voter.email],
connection=connection,
)
email.send()
sent_count += 1
# Log interaction
Interaction.objects.create(
voter=voter,
type=email_type,
date=timezone.now(),
description=subject,
notes=body
)
except Exception as e:
logger.error(f"Error sending bulk email to voter {voter.email}: {e}")
error_count += 1
if sent_count > 0:
messages.success(request, f"Successfully sent {sent_count} emails and logged interactions.")
if error_count > 0:
messages.error(request, f"Failed to send {error_count} emails.")
return redirect('voter_advanced_search')

View File

@ -1,156 +1,120 @@
import os
import django
import sys
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
file_path = 'core/templates/core/door_visits.html'
with open(file_path, 'r') as f:
content = f.read()
import re
from core import views
def patch_log_door_visit():
with open('core/views.py', 'r') as f:
content = f.read()
new_view = """def log_door_visit(request):
\"\"\"
Mark all targeted voters at a specific address as visited, update their flags,
and create interaction records.
Can also render a standalone page for logging a visit.
\"\"\"
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant)
# Capture query string for redirecting back with filters
next_qs = request.POST.get("next_query_string", request.GET.get("next_query_string", ""))
redirect_url = reverse("door_visits")
if next_qs:
redirect_url += f"?{next_qs}"
# Get address components from POST or GET
address_street = request.POST.get("address_street", request.GET.get("address_street"))
city = request.POST.get("city", request.GET.get("city"))
state = request.POST.get("state", request.GET.get("state"))
zip_code = request.POST.get("zip_code", request.GET.get("zip_code"))
if not address_street:
messages.warning(request, "No address provided.")
return redirect(redirect_url)
# Find targeted voters at this exact address
voters = Voter.objects.filter(
tenant=tenant,
address_street=address_street,
city=city,
state=state,
zip_code=zip_code,
is_targeted=True
)
if not voters.exists() and request.method == "POST":
messages.warning(request, f"No targeted voters found at {address_street}.")
return redirect(redirect_url)
voter_choices = [(v.id, f"{v.first_name} {v.last_name}") for v in voters]
# Get the volunteer linked to the current user
volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first()
if request.method == "POST":
form = DoorVisitLogForm(request.POST, voter_choices=voter_choices)
if form.is_valid():
outcome = form.cleaned_data["outcome"]
notes = form.cleaned_data["notes"]
wants_yard_sign = form.cleaned_data["wants_yard_sign"]
candidate_support = form.cleaned_data["candidate_support"]
follow_up = form.cleaned_data["follow_up"]
follow_up_voter_id = form.cleaned_data.get("follow_up_voter")
call_notes = form.cleaned_data["call_notes"]
# Determine date/time in campaign timezone
campaign_tz_name = campaign_settings.timezone or "America/Chicago"
try:
tz = zoneinfo.ZoneInfo(campaign_tz_name)
except:
tz = zoneinfo.ZoneInfo("America/Chicago")
interaction_date = timezone.now().astimezone(tz)
# Get or create InteractionType
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Door Visit")
# Get default caller for follow-ups
default_caller = None
if follow_up:
default_caller = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first()
for voter in voters:
# 1) Update voter flags
voter.door_visit = True
# 2) If "Wants a Yard Sign" checkbox is selected
if wants_yard_sign:
voter.yard_sign = "wants"
# 3) Update support status if Supporting or Not Supporting
if candidate_support in ["supporting", "not_supporting"]:
voter.candidate_support = candidate_support
voter.save()
# 4) Create interaction
Interaction.objects.create(
voter=voter,
volunteer=volunteer,
type=interaction_type,
date=interaction_date,
description=outcome,
notes=notes
)
# 5) Create ScheduledCall if follow_up is checked and this is the selected voter
if follow_up and follow_up_voter_id and str(voter.id) == follow_up_voter_id:
ScheduledCall.objects.create(
tenant=tenant,
voter=voter,
volunteer=default_caller,
comments=call_notes,
status="pending"
)
if follow_up:
messages.success(request, f"Door visit logged and follow-up call scheduled for {address_street}.")
else:
messages.success(request, f"Door visit logged for {address_street}.")
return redirect(redirect_url)
else:
messages.error(request, "There was an error in the visit log form.")
return redirect(redirect_url)
else:
# GET request: render standalone page
form = DoorVisitLogForm(voter_choices=voter_choices)
context = {
'selected_tenant': tenant,
'visit_form': form,
'address_street': address_street,
'city': city,
'state': state,
'zip_code': zip_code,
'voters': voters,
'next_query_string': next_qs,
}
return render(request, 'core/log_door_visit.html', context)
# 1. Add style for the custom control
style_to_add = """
#map-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 5;
}
.map-control-btn {
background-color: #fff;
border: 2px solid #fff;
border-radius: 3px;
box-shadow: 0 2px 6px rgba(0,0,0,.3);
cursor: pointer;
margin-bottom: 22px;
text-align: center;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.map-control-btn:hover {
color: #333;
}
"""
# Replace the old function. We use regex to find the start and then match indentation for the end.
pattern = r'def log_door_visit\(request\):.*?return redirect\(redirect_url\)'
content = re.sub(pattern, new_view, content, flags=re.DOTALL)
if '</style>' in content:
content = content.replace('</style>', style_to_add + '\n</style>')
with open('core/views.py', 'w') as f:
f.write(content)
# 2. Add the map controls container in the modal body
map_div = '<div id="map" style="width: 100%; height: 100%; min-height: 500px;"></div>'
map_controls = """
<div id="map" style="width: 100%; height: 100%; min-height: 500px; position: relative;">
<div id="map-controls">
<button id="center-user" class="map-control-btn" title="Center on My Location">
<i class="bi bi-geo-alt-fill"></i>
</button>
</div>
</div>"""
if __name__ == "__main__":
patch_log_door_visit()
content = content.replace(map_div, map_controls)
# 3. Update the JavaScript
old_vars = ' var map;\n var markers = [];\n var mapData = {{ map_data_json|safe }};'
new_vars = ' var map;\n var markers = [];\n var userLocationMarker;\n var mapData = {{ map_data_json|safe }};'
content = content.replace(old_vars, new_vars)
geolocation_logic = """
function showUserLocation(centerOnUser = false) {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(position) {
var pos = {
lat: position.coords.latitude,
lng: position.coords.longitude
};
if (userLocationMarker) {
userLocationMarker.setPosition(pos);
} else {
userLocationMarker = new google.maps.Marker({
position: pos,
map: map,
title: 'Your Location',
icon: {
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#4285F4',
fillOpacity: 1,
scale: 8,
strokeColor: 'white',
strokeWeight: 2
}
});
}
if (centerOnUser) {
map.setCenter(pos);
map.setZoom(15);
}
}, function(error) {
console.warn("Geolocation failed: " + error.message);
}, {
enableHighAccuracy: true,
timeout: 5000,
maximumAge: 0
});
}
}
"""
content = content.replace(' function initMap() {', geolocation_logic + '\n function initMap() {')
# Add call to showUserLocation in initMap and setup button
init_map_end = ' if (markers.length > 0) {\n map.fitBounds(bounds);\n }\n }'
new_init_map_end = """ if (markers.length > 0) {
map.fitBounds(bounds);
}
// Initial attempt to show user location
showUserLocation();
// Setup control button
var centerBtn = document.getElementById('center-user');
if (centerBtn) {
centerBtn.addEventListener('click', function() {
showUserLocation(true);
});
}
}"""
content = content.replace(init_map_end, new_init_map_end)
with open(file_path, 'w') as f:
f.write(content)

View File

@ -0,0 +1,64 @@
import os
file_path = 'core/templates/core/volunteer_list.html'
with open(file_path, 'r') as f:
content = f.read()
old_code = """ const smsModal = document.getElementById('smsModal');
if (smsModal) {
smsModal.addEventListener('show.bs.modal', function () {
const container = document.getElementById('selected-volunteers-container');
container.innerHTML = '';
checkboxes.forEach(cb => {
if (cb.checked) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_volunteers';
input.value = cb.value;
container.appendChild(input);
}
});
});
}"""
new_code = """ const smsModal = document.getElementById('smsModal');
if (smsModal) {
smsModal.addEventListener('show.bs.modal', function () {
const container = document.getElementById('selected-volunteers-container');
container.innerHTML = '';
checkboxes.forEach(cb => {
if (cb.checked) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_volunteers';
input.value = cb.value;
container.appendChild(input);
}
});
});
}
const emailModal = document.getElementById('emailModal');
if (emailModal) {
emailModal.addEventListener('show.bs.modal', function () {
const container = document.getElementById('selected-volunteers-email-container');
container.innerHTML = '';
checkboxes.forEach(cb => {
if (cb.checked) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_volunteers';
input.value = cb.value;
container.appendChild(input);
}
});
});
}"""
if old_code in content:
new_content = content.replace(old_code, new_code)
with open(file_path, 'w') as f:
f.write(new_content)
print("Successfully patched core/templates/core/volunteer_list.html")
else:
print("Could not find the code block to patch in core/templates/core/volunteer_list.html")

View File

@ -0,0 +1,64 @@
import os
file_path = 'core/templates/core/voter_advanced_search.html'
with open(file_path, 'r') as f:
content = f.read()
old_code = """ const bulkCallModal = document.getElementById('bulkCallModal');
if (bulkCallModal) {
bulkCallModal.addEventListener('show.bs.modal', function () {
const container = document.getElementById('selected-voters-call-container');
container.innerHTML = '';
checkboxes.forEach(cb => {
if (cb.checked) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_voters';
input.value = cb.value;
container.appendChild(input);
}
});
});
}"""
new_code = """ const bulkCallModal = document.getElementById('bulkCallModal');
if (bulkCallModal) {
bulkCallModal.addEventListener('show.bs.modal', function () {
const container = document.getElementById('selected-voters-call-container');
container.innerHTML = '';
checkboxes.forEach(cb => {
if (cb.checked) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_voters';
input.value = cb.value;
container.appendChild(input);
}
});
});
}
const emailModal = document.getElementById('emailModal');
if (emailModal) {
emailModal.addEventListener('show.bs.modal', function () {
const container = document.getElementById('selected-voters-email-container');
container.innerHTML = '';
checkboxes.forEach(cb => {
if (cb.checked) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_voters';
input.value = cb.value;
container.appendChild(input);
}
});
});
}"""
if old_code in content:
new_content = content.replace(old_code, new_code)
with open(file_path, 'w') as f:
f.write(new_content)
print("Successfully patched core/templates/core/voter_advanced_search.html")
else:
print("Could not find the code block to patch in core/templates/core/voter_advanced_search.html")