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['form'] = form
context['title'] = "Import Voting Records" context['title'] = "Import Voting Records"
context['opts'] = self.model._meta 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_auth_token = models.CharField(max_length=100, blank=True, default='89ec830d0fa02ab0afa6c76084865713')
twilio_from_number = models.CharField(max_length=20, blank=True, default='+18556945903') 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())]) 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: class Meta:
verbose_name = 'Campaign Settings' 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> <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body p-0"> <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> </div>
</div> </div>
@ -283,8 +290,50 @@
<script> <script>
var map; var map;
var markers = []; var markers = [];
var userLocationMarker;
var mapData = {{ map_data_json|safe }}; 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() { function initMap() {
if (!window.google || !window.google.maps) { if (!window.google || !window.google.maps) {
console.error("Google Maps API not loaded"); console.error("Google Maps API not loaded");
@ -335,6 +384,17 @@
if (markers.length > 0) { if (markers.length > 0) {
map.fitBounds(bounds); 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() { document.addEventListener('DOMContentLoaded', function() {
@ -456,5 +516,31 @@
border: 0; 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> </style>
{% endblock %} {% endblock %}

View File

@ -111,6 +111,7 @@
</div> </div>
<h6 class="text-uppercase fw-bold small text-muted mb-1">Signs</h6> <h6 class="text-uppercase fw-bold small text-muted mb-1">Signs</h6>
<h3 class="mb-0 fw-bold">{{ metrics.total_signs }}</h3> <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> </div>
</div> </div>

View File

@ -38,6 +38,9 @@
<div id="bulk-actions" class="d-none"> <div id="bulk-actions" class="d-none">
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#smsModal"> <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 <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> </button>
</div> </div>
</div> </div>
@ -171,6 +174,38 @@
</div> </div>
</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> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const selectAll = document.getElementById('select-all'); 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> </script>
{% endblock %} {% endblock %}

View File

@ -116,6 +116,9 @@
</button> </button>
<button type="button" class="btn btn-primary btn-sm ms-2" data-bs-toggle="modal" data-bs-target="#smsModal"> <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 <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> </button>
</div> </div>
<div> <div>
@ -331,6 +334,38 @@
</div> </div>
</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 --> <!-- Individual Schedule Call Modal -->
<div class="modal fade" id="scheduleCallModal" tabindex="-1" aria-labelledby="scheduleCallModalLabel" aria-hidden="true"> <div class="modal fade" id="scheduleCallModal" tabindex="-1" aria-labelledby="scheduleCallModalLabel" aria-hidden="true">
<div class="modal-dialog"> <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 // Individual Schedule Call Modal dynamic content
var scheduleCallModal = document.getElementById('scheduleCallModal'); var scheduleCallModal = document.getElementById('scheduleCallModal');
if (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/advanced-search/', views.voter_advanced_search, name='voter_advanced_search'),
path('voters/export-csv/', views.export_voters_csv, name='export_voters_csv'), 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-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>/', views.voter_detail, name='voter_detail'),
path('voters/<int:voter_id>/edit/', views.voter_edit, name='voter_edit'), path('voters/<int:voter_id>/edit/', views.voter_edit, name='voter_edit'),
path('voters/<int:voter_id>/delete/', views.voter_delete, name='voter_delete'), 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/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/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-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/<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'), 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/history/', views.door_visit_history, name='door_visit_history'),
path('door-visits/neighborhoods/', views.neighborhood_counts, name='neighborhood_counts'), 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 # Call Queue
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'),

View File

@ -12,7 +12,7 @@ import json
from django.http import JsonResponse, HttpResponse from django.http import JsonResponse, HttpResponse
from django.urls import reverse from django.urls import reverse
from django.shortcuts import render, redirect, get_object_or_404 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.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
@ -93,7 +93,7 @@ def index(request):
'total_supporting': voters.filter(candidate_support='supporting').count(), 'total_supporting': voters.filter(candidate_support='supporting').count(),
'total_target_households': voters.filter(is_targeted=True).exclude(address='').values('address').distinct().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_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_window_stickers': voters.filter(Q(window_sticker='wants') | Q(window_sticker='has')).count(),
'total_donations': float(total_donations), 'total_donations': float(total_donations),
'donation_goal': float(donation_goal), '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: 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 # 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: if min_total_donation is not None:
voters = voters.filter(total_donation_amount__gte=min_total_donation) 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') max_total_donation = data.get('max_total_donation')
if min_total_donation is not None or max_total_donation is not None: 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: if min_total_donation is not None:
voters = voters.filter(total_donation_amount__gte=min_total_donation) voters = voters.filter(total_donation_amount__gte=min_total_donation)
@ -2133,3 +2133,219 @@ def neighborhood_counts(request):
'address_filter': address_filter, 'address_filter': address_filter,
} }
return render(request, 'core/neighborhood_counts.html', context) 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 sys
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') file_path = 'core/templates/core/door_visits.html'
django.setup() with open(file_path, 'r') as f:
content = f.read()
import re # 1. Add style for the custom control
from core import views style_to_add = """
#map-controls {
def patch_log_door_visit(): position: absolute;
with open('core/views.py', 'r') as f: top: 10px;
content = f.read() right: 10px;
z-index: 5;
new_view = """def log_door_visit(request): }
\"\"\" .map-control-btn {
Mark all targeted voters at a specific address as visited, update their flags, background-color: #fff;
and create interaction records. border: 2px solid #fff;
Can also render a standalone page for logging a visit. border-radius: 3px;
\"\"\" box-shadow: 0 2px 6px rgba(0,0,0,.3);
selected_tenant_id = request.session.get("tenant_id") cursor: pointer;
if not selected_tenant_id: margin-bottom: 22px;
return redirect("index") text-align: center;
width: 40px;
tenant = get_object_or_404(Tenant, id=selected_tenant_id) height: 40px;
campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant) display: flex;
align-items: center;
# Capture query string for redirecting back with filters justify-content: center;
next_qs = request.POST.get("next_query_string", request.GET.get("next_query_string", "")) color: #666;
redirect_url = reverse("door_visits") }
if next_qs: .map-control-btn:hover {
redirect_url += f"?{next_qs}" color: #333;
}
# Get address components from POST or GET
address_street = request.POST.get("address_street", request.GET.get("address_street"))
city = request.POST.get("city", request.GET.get("city"))
state = request.POST.get("state", request.GET.get("state"))
zip_code = request.POST.get("zip_code", request.GET.get("zip_code"))
if not address_street:
messages.warning(request, "No address provided.")
return redirect(redirect_url)
# Find targeted voters at this exact address
voters = Voter.objects.filter(
tenant=tenant,
address_street=address_street,
city=city,
state=state,
zip_code=zip_code,
is_targeted=True
)
if not voters.exists() and request.method == "POST":
messages.warning(request, f"No targeted voters found at {address_street}.")
return redirect(redirect_url)
voter_choices = [(v.id, f"{v.first_name} {v.last_name}") for v in voters]
# Get the volunteer linked to the current user
volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first()
if request.method == "POST":
form = DoorVisitLogForm(request.POST, voter_choices=voter_choices)
if form.is_valid():
outcome = form.cleaned_data["outcome"]
notes = form.cleaned_data["notes"]
wants_yard_sign = form.cleaned_data["wants_yard_sign"]
candidate_support = form.cleaned_data["candidate_support"]
follow_up = form.cleaned_data["follow_up"]
follow_up_voter_id = form.cleaned_data.get("follow_up_voter")
call_notes = form.cleaned_data["call_notes"]
# Determine date/time in campaign timezone
campaign_tz_name = campaign_settings.timezone or "America/Chicago"
try:
tz = zoneinfo.ZoneInfo(campaign_tz_name)
except:
tz = zoneinfo.ZoneInfo("America/Chicago")
interaction_date = timezone.now().astimezone(tz)
# Get or create InteractionType
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Door Visit")
# Get default caller for follow-ups
default_caller = None
if follow_up:
default_caller = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first()
for voter in voters:
# 1) Update voter flags
voter.door_visit = True
# 2) If "Wants a Yard Sign" checkbox is selected
if wants_yard_sign:
voter.yard_sign = "wants"
# 3) Update support status if Supporting or Not Supporting
if candidate_support in ["supporting", "not_supporting"]:
voter.candidate_support = candidate_support
voter.save()
# 4) Create interaction
Interaction.objects.create(
voter=voter,
volunteer=volunteer,
type=interaction_type,
date=interaction_date,
description=outcome,
notes=notes
)
# 5) Create ScheduledCall if follow_up is checked and this is the selected voter
if follow_up and follow_up_voter_id and str(voter.id) == follow_up_voter_id:
ScheduledCall.objects.create(
tenant=tenant,
voter=voter,
volunteer=default_caller,
comments=call_notes,
status="pending"
)
if follow_up:
messages.success(request, f"Door visit logged and follow-up call scheduled for {address_street}.")
else:
messages.success(request, f"Door visit logged for {address_street}.")
return redirect(redirect_url)
else:
messages.error(request, "There was an error in the visit log form.")
return redirect(redirect_url)
else:
# GET request: render standalone page
form = DoorVisitLogForm(voter_choices=voter_choices)
context = {
'selected_tenant': tenant,
'visit_form': form,
'address_street': address_street,
'city': city,
'state': state,
'zip_code': zip_code,
'voters': voters,
'next_query_string': next_qs,
}
return render(request, 'core/log_door_visit.html', context)
""" """
# Replace the old function. We use regex to find the start and then match indentation for the end. if '</style>' in content:
pattern = r'def log_door_visit\(request\):.*?return redirect\(redirect_url\)' content = content.replace('</style>', style_to_add + '\n</style>')
content = re.sub(pattern, new_view, content, flags=re.DOTALL)
with open('core/views.py', 'w') as f: # 2. Add the map controls container in the modal body
f.write(content) 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__": content = content.replace(map_div, map_controls)
patch_log_door_visit()
# 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")