Autosave: 20260213-015538
This commit is contained in:
parent
fbcc2964bf
commit
88ee4c2516
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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',
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@ -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'
|
||||
|
||||
@ -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 %}
|
||||
@ -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 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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 %}
|
||||
@ -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) {
|
||||
|
||||
352
core/templates/core/yard_sign_voters.html
Normal file
352
core/templates/core/yard_sign_voters.html
Normal 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 %}
|
||||
@ -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'),
|
||||
|
||||
224
core/views.py
224
core/views.py
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
64
patch_volunteer_list_email.py
Normal file
64
patch_volunteer_list_email.py
Normal 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")
|
||||
64
patch_voter_advanced_search_email.py
Normal file
64
patch_voter_advanced_search_email.py
Normal 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")
|
||||
Loading…
x
Reference in New Issue
Block a user