diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc
index 950bfbe..eedae02 100644
Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ
diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc
index 54e8551..2f1b65f 100644
Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ
diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc
index c59d399..54a0b64 100644
Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ
diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc
index 573167d..443e142 100644
Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ
diff --git a/core/admin.py b/core/admin.py
index 51dd659..47f7a7f 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -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)
\ No newline at end of file
+ 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',
+ ),
+ }),
+ )
diff --git a/core/migrations/0042_campaignsettings_email_from_address_and_more.py b/core/migrations/0042_campaignsettings_email_from_address_and_more.py
new file mode 100644
index 0000000..f01bfd6
--- /dev/null
+++ b/core/migrations/0042_campaignsettings_email_from_address_and_more.py
@@ -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),
+ ),
+ ]
diff --git a/core/migrations/__pycache__/0042_campaignsettings_email_from_address_and_more.cpython-311.pyc b/core/migrations/__pycache__/0042_campaignsettings_email_from_address_and_more.cpython-311.pyc
new file mode 100644
index 0000000..bfd44f3
Binary files /dev/null and b/core/migrations/__pycache__/0042_campaignsettings_email_from_address_and_more.cpython-311.pyc differ
diff --git a/core/models.py b/core/models.py
index 032d9ec..9efb0e3 100644
--- a/core/models.py
+++ b/core/models.py
@@ -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'
diff --git a/core/templates/core/door_visits.html b/core/templates/core/door_visits.html
index 28c4ea2..c6e21e8 100644
--- a/core/templates/core/door_visits.html
+++ b/core/templates/core/door_visits.html
@@ -180,7 +180,14 @@
@@ -283,8 +290,50 @@
{% endblock %}
\ No newline at end of file
diff --git a/core/templates/core/voter_advanced_search.html b/core/templates/core/voter_advanced_search.html
index 7f0ee6a..07a2ab0 100644
--- a/core/templates/core/voter_advanced_search.html
+++ b/core/templates/core/voter_advanced_search.html
@@ -116,6 +116,9 @@
@@ -331,6 +334,38 @@
+
+
@@ -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) {
diff --git a/core/templates/core/yard_sign_voters.html b/core/templates/core/yard_sign_voters.html
new file mode 100644
index 0000000..5e685a9
--- /dev/null
+++ b/core/templates/core/yard_sign_voters.html
@@ -0,0 +1,352 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block content %}
+
+
+
+
Yard Sign Requests
+
View and manage households that have requested a yard sign.
+
+
+
+ View Map
+
+
+
+
+
+
+
+
+
+
+
Households Wanting Signs
+
+ {{ households.paginator.count }} Households Found
+
+
+
+
+
+
+ | Household Address |
+ Voters Wanting Sign |
+ Neighborhood |
+
+
+
+ {% for household in households %}
+
+ |
+ {{ household.address_street }}
+ {{ household.city }}, {{ household.state }} {{ household.zip_code }}
+
+ {% if household.neighborhood %}
+
+ {{ household.neighborhood }}
+
+ {% endif %}
+
+ |
+
+
+ |
+
+ {% if household.neighborhood %}
+
+ {{ household.neighborhood }}
+
+ {% else %}
+ Not assigned
+ {% endif %}
+ |
+
+ {% empty %}
+
+ |
+
+
+
+ No households wanting signs found.
+ Try adjusting your filters or search criteria.
+ |
+
+ {% endfor %}
+
+
+
+
+ {% if households.paginator.num_pages > 1 %}
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% if GOOGLE_MAPS_API_KEY %}
+
+{% endif %}
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/core/urls.py b/core/urls.py
index a1ec9ee..3145ee7 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -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/
/', views.voter_detail, name='voter_detail'),
path('voters//edit/', views.voter_edit, name='voter_edit'),
path('voters//delete/', views.voter_delete, name='voter_delete'),
@@ -56,6 +57,7 @@ urlpatterns = [
path('volunteers/assignment//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//volunteer/add/', views.event_add_volunteer, name='event_add_volunteer'),
path('events/volunteer//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//complete/', views.complete_call, name='complete_call'),
diff --git a/core/views.py b/core/views.py
index 89560a6..f3745e9 100644
--- a/core/views.py
+++ b/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')
diff --git a/door_visit_patch.py b/door_visit_patch.py
index 10ef5bd..12d3894 100644
--- a/door_visit_patch.py
+++ b/door_visit_patch.py
@@ -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 '' in content:
+ content = content.replace('', style_to_add + '\n')
- with open('core/views.py', 'w') as f:
- f.write(content)
+# 2. Add the map controls container in the modal body
+map_div = ''
+map_controls = """
+ """
-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)
diff --git a/patch_volunteer_list_email.py b/patch_volunteer_list_email.py
new file mode 100644
index 0000000..ce2473b
--- /dev/null
+++ b/patch_volunteer_list_email.py
@@ -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")
diff --git a/patch_voter_advanced_search_email.py b/patch_voter_advanced_search_email.py
new file mode 100644
index 0000000..0c25ece
--- /dev/null
+++ b/patch_voter_advanced_search_email.py
@@ -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")