Compare commits

...

No commits in common. "master" and "pre-recovery-ai-dev-20260530T075327Z" have entirely different histories.

800 changed files with 133040 additions and 10344 deletions

11
.gitignore vendored
View File

@ -1,10 +1,3 @@
node_modules/
__pycache__/
*.pyc
*.pyo
*.sqlite3
.env
.env.*
.perm_test_*
staticfiles/
.DS_Store
*/node_modules/
*/build/

0
.perm_test_exec Normal file
View File

View File

@ -1,7 +1,7 @@
# Flatlogic Python Template Workspace
This workspace houses the Django application scaffold used for Python-based templates.
## Requirements
- Python 3.11+

View File

@ -1,17 +0,0 @@
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from core.models import Voter, Tenant
def check_none():
print(f"Voters with NULL address_street: {Voter.objects.filter(address_street__isnull=True).count()}")
print(f"Voters with empty address_street: {Voter.objects.filter(address_street='').count()}")
print(f"Voters with NULL neighborhood: {Voter.objects.filter(neighborhood__isnull=True).count()}")
print(f"Voters with empty neighborhood: {Voter.objects.filter(neighborhood='').count()}")
if __name__ == "__main__":
check_none()

View File

@ -1,36 +0,0 @@
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from core.models import Voter, Tenant
def check_neighborhoods():
tenants = Tenant.objects.all()
for tenant in tenants:
print(f"Tenant: {tenant.name}")
voters = Voter.objects.filter(tenant=tenant, is_inactive=False, yard_sign='wants')
households_dict = {}
for voter in voters:
key = (voter.address_street, voter.city, voter.state, voter.zip_code)
if key not in households_dict:
households_dict[key] = voter.neighborhood
else:
if not households_dict[key] and voter.neighborhood:
households_dict[key] = voter.neighborhood
total_households = len(households_dict)
households_with_nb = [nb for nb in households_dict.values() if nb]
households_without_nb = [nb for nb in households_dict.values() if not nb]
print(f" Total Households: {total_households}")
print(f" Households with Neighborhood: {len(households_with_nb)}")
print(f" Households without Neighborhood: {len(households_without_nb)}")
if len(households_without_nb) > 0:
print(f" First 10 neighborhoods (sorted): {sorted([nb or '' for nb in households_dict.values()])[:10]}")
if __name__ == "__main__":
check_neighborhoods()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
NONE

View File

@ -1 +0,0 @@
NONE

View File

@ -1,67 +0,0 @@
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':
subject = request.POST.get('subject')
body = request.POST.get('body')
is_html = request.POST.get("is_html") == "on"
select_all_results = request.POST.get('select_all_results') == 'true'
if select_all_results:
voters, _ = get_filtered_voter_queryset(request, tenant, data_source='POST')
voters = voters.exclude(email='')
else:
voter_ids = request.POST.getlist('selected_voters')
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
if campaign_settings.email_from_name:
from_email = f"{campaign_settings.email_from_name} <{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,
)
if is_html:
email.content_subtype = "html"
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.")
if error_count > 0:
messages.error(request, f"Failed to send {error_count} emails.")
return redirect('voter_advanced_search')

View File

@ -1,92 +0,0 @@
def bulk_send_sms(request):
"""
Sends bulk SMS to selected voters using Twilio API.
"""
if request.method != 'POST':
return redirect('voter_advanced_search')
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)
settings = getattr(tenant, 'settings', None)
if not settings:
messages.error(request, "Campaign settings not found.")
return redirect('voter_advanced_search')
account_sid = settings.twilio_account_sid
auth_token = settings.twilio_auth_token
from_number = settings.twilio_from_number
if not account_sid or not auth_token or not from_number:
messages.error(request, "Twilio configuration is incomplete in Campaign Settings.")
return redirect('voter_advanced_search')
message_body = request.POST.get('message_body')
if not message_body:
messages.error(request, "Message body cannot be empty.")
return redirect('voter_advanced_search')
select_all_results = request.POST.get('select_all_results') == 'true'
if select_all_results:
voters, _ = get_filtered_voter_queryset(request, tenant, data_source='POST')
voters = voters.filter(phone_type='cell').exclude(phone='')
else:
voter_ids = request.POST.getlist('selected_voters')
voters = Voter.objects.filter(tenant=tenant, id__in=voter_ids, phone_type='cell').exclude(phone='')
if not voters.exists():
messages.warning(request, "No voters with a valid cell phone number were selected.")
return redirect('voter_advanced_search')
success_count = 0
fail_count = 0
auth_str = f"{account_sid}:{auth_token}"
auth_header = base64.b64encode(auth_str.encode()).decode()
url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="SMS Text")
for voter in voters:
digits = re.sub(r'\D', '', str(voter.phone))
if len(digits) == 10:
to_number = f"+1{digits}"
elif len(digits) == 11 and digits.startswith('1'):
to_number = f"+{digits}"
else:
fail_count += 1
continue
data_dict = {
'To': to_number,
'From': from_number,
'Body': message_body
}
data = urllib.parse.urlencode(data_dict).encode()
req = urllib.request.Request(url, data=data, method='POST')
req.add_header("Authorization", f"Basic {auth_header}")
try:
with urllib.request.urlopen(req, timeout=10) as response:
if response.status in [200, 201]:
success_count += 1
Interaction.objects.create(
voter=voter,
type=interaction_type,
date=timezone.now(),
description='Mass SMS Text',
notes=message_body
)
else:
fail_count += 1
except Exception as e:
logger.error(f"Error sending SMS to {voter.phone}: {e}")
fail_count += 1
messages.success(request, f"Bulk SMS process completed: {success_count} successful, {fail_count} failed/skipped.")
return redirect('voter_advanced_search')

View File

@ -2,11 +2,7 @@ import os
import time
from django.conf import settings
from .models import Tenant
from .permissions import (
can_view_donations, can_edit_voter, get_user_role,
can_view_volunteers, can_edit_volunteer, can_view_voters,
is_block_walker, STAFF_ROLES, can_access_call_queue
)
from .permissions import can_view_donations, can_edit_voter, get_user_role, can_view_volunteers, can_edit_volunteer, can_view_voters
def project_context(request):
"""
@ -21,10 +17,6 @@ def project_context(request):
}
if request.user.is_authenticated:
context['is_block_walker'] = is_block_walker(request.user)
context['is_staff'] = request.user.is_superuser
context['can_access_call_queue'] = can_access_call_queue(request.user)
tenant_id = request.session.get('tenant_id')
if tenant_id:
tenant = Tenant.objects.filter(id=tenant_id).first()
@ -34,10 +26,6 @@ def project_context(request):
context['can_view_voters'] = can_view_voters(request.user, tenant)
context['can_view_volunteers'] = can_view_volunteers(request.user, tenant)
context['can_edit_volunteer'] = can_edit_volunteer(request.user, tenant)
role = get_user_role(request.user, tenant)
context['user_role'] = role
if not context['is_staff']:
context['is_staff'] = role in STAFF_ROLES
context['user_role'] = get_user_role(request.user, tenant)
return context

View File

@ -1,64 +0,0 @@
def export_voters_csv(request):
"""
Exports selected or filtered voters to a CSV file.
"""
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)
if request.method != 'POST':
return redirect('voter_advanced_search')
action = request.POST.get('action')
select_all_results = request.POST.get('select_all_results') == 'true' or action == 'export_all'
if select_all_results:
voters, _ = get_filtered_voter_queryset(request, tenant, data_source='POST')
else:
voter_ids = request.POST.getlist('selected_voters')
voters = Voter.objects.filter(tenant=tenant, id__in=voter_ids, is_inactive=False)
voters = voters.order_by('last_name', 'first_name')
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="voters_export_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv"'
writer = csv.writer(response)
writer.writerow([
'Voter ID', 'First Name', 'Last Name', 'Nickname', 'Birthdate',
'Address', 'City', 'State', 'Zip Code', 'Neighborhood', 'Phone', 'Phone Type', 'Secondary Phone', 'Secondary Phone Type', 'Email',
'District', 'Precinct', 'Is Targeted', 'Voted', 'Support', 'Yard Sign', 'Window Sticker', 'Call Queue Status', 'Notes'
])
for voter in voters:
writer.writerow([
voter.voter_id,
voter.first_name,
voter.last_name,
voter.nickname,
voter.birthdate.strftime('%Y-%m-%d') if voter.birthdate else '',
voter.address,
voter.city,
voter.state,
voter.zip_code,
voter.neighborhood,
voter.phone,
voter.get_phone_type_display(),
voter.secondary_phone,
voter.get_secondary_phone_type_display(),
voter.email,
voter.district,
voter.precinct,
'Yes' if voter.is_targeted else 'No',
'Yes' if voter.voted else 'No',
voter.get_candidate_support_display(),
voter.get_yard_sign_display(),
voter.get_window_sticker_display(),
voter.get_call_queue_status_display(),
voter.notes
])
return response

View File

@ -1,114 +0,0 @@
import re
from django.db.models import Q, Sum, Value, DecimalField
from django.db.models.functions import Coalesce
from .models import Voter
from .forms import AdvancedVoterSearchForm
def get_phone_search_filters(phone_query, secondary=True):
"""
Returns a Q object that searches for various formats of a phone number.
"""
if not phone_query:
return Q()
digits = re.sub(r"\D", "", str(phone_query))
variants = {str(phone_query), digits}
if len(digits) == 10:
variants.add(f"({digits[:3]}) {digits[3:6]}-{digits[6:]}")
elif len(digits) == 11 and digits.startswith("1"):
variants.add(f"({digits[1:4]}) {digits[4:7]}-{digits[7:]}")
elif len(digits) == 7:
variants.add(f"{digits[:3]}-{digits[3:]}")
phone_filter = Q()
for variant in variants:
if variant:
phone_filter |= Q(phone__icontains=variant)
if secondary:
phone_filter |= Q(secondary_phone__icontains=variant)
return phone_filter
def get_filtered_voter_queryset_from_filters(tenant, filters):
"""
Apply voter filters from a dictionary of filters.
"""
voters = Voter.objects.filter(tenant=tenant, is_inactive=False).order_by("last_name", "first_name")
form = AdvancedVoterSearchForm(filters)
if form.is_valid():
data = form.cleaned_data
if data.get("first_name"):
voters = voters.filter(first_name__icontains=data["first_name"])
if data.get("last_name"):
voters = voters.filter(last_name__icontains=data["last_name"])
if data.get("address"):
voters = voters.filter(Q(address__icontains=data["address"]) | Q(address_street__icontains=data["address"]))
if data.get("voter_id"):
voters = voters.filter(voter_id__iexact=data["voter_id"])
if data.get("birth_month"):
voters = voters.filter(birthdate__month=data["birth_month"])
if data.get("city"):
voters = voters.filter(city__icontains=data["city"])
if data.get("zip_code"):
voters = voters.filter(zip_code__icontains=data["zip_code"])
if data.get("neighborhood"):
voters = voters.filter(neighborhood__icontains=data["neighborhood"])
if data.get("district"):
voters = voters.filter(district=data["district"])
if data.get("precinct"):
voters = voters.filter(precinct=data["precinct"])
if data.get("email"):
voters = voters.filter(email__icontains=data["email"])
if data.get("phone"):
voters = voters.filter(get_phone_search_filters(data["phone"]))
if data.get("phone_type"):
voters = voters.filter(phone_type=data["phone_type"])
if data.get("is_targeted"):
voters = voters.filter(is_targeted=(data["is_targeted"] == "True"))
if data.get("target_door_visit"):
voters = voters.filter(target_door_visit=(data["target_door_visit"] == "True"))
if data.get("door_visit"):
voters = voters.filter(door_visit=(data["door_visit"] == "True"))
if data.get("voted"):
voters = voters.filter(voted=(data["voted"] == "True"))
if data.get("candidate_support"):
voters = voters.filter(candidate_support=data["candidate_support"])
if data.get("yard_sign"):
voters = voters.filter(yard_sign=data["yard_sign"])
if data.get("window_sticker"):
voters = voters.filter(window_sticker=data["window_sticker"])
if data.get("call_queue_status"):
voters = voters.filter(call_queue_status=data["call_queue_status"])
# Add donation amount filters
min_total_donation = data.get("min_total_donation")
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), output_field=DecimalField()))
if min_total_donation is not None:
voters = voters.filter(total_donation_amount__gte=min_total_donation)
if max_total_donation is not None:
voters = voters.filter(total_donation_amount__lte=max_total_donation)
return voters
def get_filtered_voter_queryset(request, tenant, data_source="GET"):
"""
Helper to apply voter filters from AdvancedVoterSearchForm.
data_source: "GET" for search page, "POST" for bulk actions using filter_ prefix.
"""
if data_source == "POST":
filters = {}
for key, value in request.POST.items():
if key.startswith("filter_") and value:
field_name = key.replace("filter_", "")
filters[field_name] = value
else:
filters = request.GET
voters = get_filtered_voter_queryset_from_filters(tenant, filters)
form = AdvancedVoterSearchForm(filters)
return voters, form

View File

@ -20,8 +20,7 @@ class VoterForm(forms.ModelForm):
'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state', 'prior_state',
'zip_code', 'county', 'neighborhood', 'latitude', 'longitude',
'phone', 'phone_type', 'secondary_phone', 'secondary_phone_type', 'email', 'voter_id', 'district', 'precinct',
'registration_date', 'is_targeted', 'is_inactive', 'target_door_visit', 'door_visit', 'voted', 'candidate_support', 'yard_sign', 'window_sticker', 'notes',
'call_queue_status'
'registration_date', 'is_targeted', 'door_visit', 'candidate_support', 'yard_sign', 'window_sticker', 'notes'
]
widgets = {
'birthdate': forms.DateInput(attrs={'type': 'date'}),
@ -29,16 +28,12 @@ class VoterForm(forms.ModelForm):
'latitude': forms.TextInput(attrs={'class': 'form-control bg-light'}),
'longitude': forms.TextInput(attrs={'class': 'form-control bg-light'}),
'notes': forms.Textarea(attrs={'rows': 3}),
'call_queue_status': forms.Select(attrs={'class': 'form-select'}),
}
def __init__(self, *args, user=None, tenant=None, **kwargs):
self.user = user
self.tenant = tenant
super().__init__(*args, **kwargs)
# Always make call_queue_status readonly as it's automated
# Restrict fields for non-admin users
is_admin = False
if user:
@ -72,7 +67,6 @@ class VoterForm(forms.ModelForm):
self.fields['window_sticker'].widget.attrs.update({'class': 'form-select'})
self.fields['phone_type'].widget.attrs.update({'class': 'form-select'})
self.fields['secondary_phone_type'].widget.attrs.update({'class': 'form-select'})
self.fields['call_queue_status'].widget.attrs.update({'class': 'form-select'})
def clean(self):
@ -83,6 +77,9 @@ class VoterForm(forms.ModelForm):
user = getattr(self, "user", None)
tenant = getattr(self, "tenant", None)
# We need to set these on the form instance if we want to use them in clean
# or we can pass them in __init__ and store them
if self.user:
if self.user.is_superuser:
is_admin = True
@ -110,7 +107,6 @@ class AdvancedVoterSearchForm(forms.Form):
(5, 'May'), (6, 'June'), (7, 'July'), (8, 'August'),
(9, 'September'), (10, 'October'), (11, 'November'), (12, 'December')
]
BOOLEAN_CHOICES = [('', 'Any'), ('True', 'Yes'), ('False', 'No')]
first_name = forms.CharField(required=False)
last_name = forms.CharField(required=False)
@ -122,18 +118,13 @@ class AdvancedVoterSearchForm(forms.Form):
neighborhood = forms.CharField(required=False)
district = forms.CharField(required=False)
precinct = forms.CharField(required=False)
email = forms.EmailField(required=False)
phone = forms.CharField(required=False, label="Phone Number")
email = forms.EmailField(required=False) # Added email field
phone_type = forms.ChoiceField(
choices=[('', 'Any')] + Voter.PHONE_TYPE_CHOICES,
required=False
)
is_targeted = forms.ChoiceField(choices=BOOLEAN_CHOICES, required=False, label="Is Targeted")
target_door_visit = forms.ChoiceField(choices=BOOLEAN_CHOICES, required=False, label="Target Door Visit")
voted = forms.ChoiceField(choices=BOOLEAN_CHOICES, required=False, label="Voted")
door_visit = forms.ChoiceField(choices=BOOLEAN_CHOICES, required=False, label="Door Visited")
ever_had_yard_sign = forms.ChoiceField(choices=BOOLEAN_CHOICES, required=False, label="Ever Had Yard Sign")
ever_had_large_sign = forms.ChoiceField(choices=BOOLEAN_CHOICES, required=False, label="Ever Had Large Sign")
is_targeted = forms.BooleanField(required=False, label="Targeted Only")
door_visit = forms.BooleanField(required=False, label="Visited Only")
candidate_support = forms.ChoiceField(
choices=[('', 'Any')] + Voter.CANDIDATE_SUPPORT_CHOICES,
required=False
@ -146,11 +137,6 @@ class AdvancedVoterSearchForm(forms.Form):
choices=[('', 'Any')] + Voter.WINDOW_STICKER_CHOICES,
required=False
)
call_queue_status = forms.ChoiceField(
choices=[('', 'Any')] + Voter.CALL_QUEUE_STATUS_CHOICES,
required=False,
label="Call Queue Status"
)
min_total_donation = forms.DecimalField(required=False, min_value=0, label="Min Total Donation")
max_total_donation = forms.DecimalField(required=False, min_value=0, label="Max Total Donation")
@ -167,11 +153,6 @@ class AdvancedVoterSearchForm(forms.Form):
self.fields['yard_sign'].widget.attrs.update({'class': 'form-select'})
self.fields['window_sticker'].widget.attrs.update({'class': 'form-select'})
self.fields['phone_type'].widget.attrs.update({'class': 'form-select'})
self.fields['call_queue_status'].widget.attrs.update({'class': 'form-select'})
self.fields['is_targeted'].widget.attrs.update({'class': 'form-select'})
self.fields['target_door_visit'].widget.attrs.update({'class': 'form-select'})
self.fields['door_visit'].widget.attrs.update({'class': 'form-select'})
self.fields['voted'].widget.attrs.update({'class': 'form-select'})
class InteractionForm(forms.ModelForm):
class Meta:
@ -298,7 +279,7 @@ class EventImportForm(forms.Form):
self.fields['file'].widget.attrs.update({'class': 'form-control'})
class EventParticipationImportForm(forms.Form):
file = forms.FileField(label="Select CSV file")
file = forms.FileField(label="Select CSV/Excel file")
def __init__(self, *args, event=None, **kwargs):
super().__init__(*args, **kwargs)
@ -459,11 +440,10 @@ class DoorVisitLogForm(forms.Form):
required=False,
label="Notes"
)
yard_sign_status = forms.ChoiceField(
choices=[('no_change', 'No Change'), ('none', 'No Sign'), ('wants', 'Wants Yard Sign'), ('wants_large', 'Wants Large Sign')],
initial='no_change',
widget=forms.Select(attrs={"class": "form-select"}),
label="Yard Sign Status"
wants_yard_sign = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
label="Wants a Yard Sign"
)
candidate_support = forms.ChoiceField(
choices=Voter.CANDIDATE_SUPPORT_CHOICES,
@ -476,12 +456,12 @@ class DoorVisitLogForm(forms.Form):
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
label="Follow Up"
)
follow_up_voter = forms.ChoiceField(choices=[('', '-- Select Voter --')], required=False, widget=forms.Select(attrs={"class": "form-select"}), label="Voter to Follow Up")
follow_up_voter = forms.ChoiceField(choices=[], required=False, widget=forms.Select(attrs={"class": "form-select"}), label="Voter to Follow Up")
def __init__(self, *args, voter_choices=None, **kwargs):
super().__init__(*args, **kwargs)
if voter_choices:
self.fields["follow_up_voter"].choices = [('', '-- Select Voter --')] + list(voter_choices)
self.fields["follow_up_voter"].choices = voter_choices
call_notes = forms.CharField(
widget=forms.Textarea(attrs={"class": "form-control", "rows": 2}),
required=False,
@ -515,4 +495,4 @@ class UserUpdateForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs.update({'class': 'form-control'})
field.widget.attrs.update({'class': 'form-control'})

File diff suppressed because one or more lines are too long

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2026-02-13 02:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0042_campaignsettings_email_from_address_and_more'),
]
operations = [
migrations.AddField(
model_name='campaignsettings',
name='email_from_name',
field=models.CharField(blank=True, max_length=255),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2026-03-01 14:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0043_campaignsettings_email_from_name'),
]
operations = [
migrations.AddField(
model_name='voter',
name='target_door_visit',
field=models.BooleanField(db_index=True, default=False),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2026-03-03 15:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0044_voter_target_door_visit'),
]
operations = [
migrations.AddField(
model_name='voter',
name='is_inactive',
field=models.BooleanField(db_index=True, default=False),
),
]

View File

@ -1,25 +0,0 @@
# Generated by Django 5.2.7 on 2026-03-05 14:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0045_voter_is_inactive'),
]
operations = [
migrations.AddIndex(
model_name='voter',
index=models.Index(fields=['tenant', 'address_street', 'city', 'state', 'zip_code'], name='core_voter_tenant__6a281d_idx'),
),
migrations.AddIndex(
model_name='voter',
index=models.Index(fields=['tenant', 'is_inactive', 'door_visit', 'target_door_visit'], name='core_voter_tenant__52db3f_idx'),
),
migrations.AddIndex(
model_name='voter',
index=models.Index(fields=['tenant', 'last_name', 'first_name'], name='core_voter_tenant__ad8046_idx'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2026-03-08 00:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0046_voter_core_voter_tenant__6a281d_idx_and_more'),
]
operations = [
migrations.AlterField(
model_name='voter',
name='yard_sign',
field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants a yard sign'), ('wants_large', 'Wants a Large Sign'), ('has', 'Has a yard sign'), ('has_large', 'Has a Large Sign')], db_index=True, default='none', max_length=20),
),
]

View File

@ -1,37 +0,0 @@
# Generated by Django 5.2.7 on 2026-03-15 14:52
from django.db import migrations, models
def initialize_call_queue_status(apps, schema_editor):
Voter = apps.get_model('core', 'Voter')
ScheduledCall = apps.get_model('core', 'ScheduledCall')
# 1. Not targeted -> no_call_required
Voter.objects.filter(is_targeted=False).update(call_queue_status='no_call_required')
# 2. In call queue -> in_call_queue
pending_calls_voter_ids = ScheduledCall.objects.filter(status='pending').values_list('voter_id', flat=True).distinct()
Voter.objects.filter(is_targeted=True, id__in=pending_calls_voter_ids).update(call_queue_status='in_call_queue')
# 3. Targeted, not in queue, but was called -> called
completed_calls_voter_ids = ScheduledCall.objects.filter(status='completed').values_list('voter_id', flat=True).distinct()
Voter.objects.filter(is_targeted=True, id__in=completed_calls_voter_ids).exclude(call_queue_status='in_call_queue').update(call_queue_status='called')
# 4. Targeted, not in queue, never called -> to_be_called
# This covers voters who were targeted but never added to a call queue
Voter.objects.filter(is_targeted=True, call_queue_status='no_call_required').update(call_queue_status='to_be_called')
class Migration(migrations.Migration):
dependencies = [
('core', '0047_alter_voter_yard_sign'),
]
operations = [
migrations.AddField(
model_name='voter',
name='call_queue_status',
field=models.CharField(choices=[('no_call_required', 'No Call Required'), ('to_be_called', 'To Be Called'), ('in_call_queue', 'In Call Queue'), ('called', 'Called')], db_index=True, default='no_call_required', max_length=20),
),
migrations.RunPython(initialize_call_queue_status, reverse_code=migrations.RunPython.noop),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2026-03-17 04:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0048_voter_call_queue_status'),
]
operations = [
migrations.AddField(
model_name='campaignsettings',
name='call_script',
field=models.TextField(blank=True),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2026-04-15 03:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0049_campaignsettings_call_script'),
]
operations = [
migrations.AddField(
model_name='voter',
name='voted',
field=models.BooleanField(db_index=True, default=False),
),
]

View File

@ -1,29 +0,0 @@
# Generated by Django 5.2.7 on 2026-04-15 19:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0050_voter_voted'),
]
operations = [
migrations.CreateModel(
name='BulkTask',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('task_type', models.CharField(choices=[('sms', 'SMS'), ('email', 'Email')], max_length=10)),
('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'In Progress'), ('completed', 'Completed'), ('failed', 'Failed')], default='pending', max_length=20)),
('total_count', models.IntegerField(default=0)),
('success_count', models.IntegerField(default=0)),
('fail_count', models.IntegerField(default=0)),
('error_message', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bulk_tasks', to='core.tenant')),
],
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 5.2.7 on 2026-04-15 19:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0051_bulktask'),
]
operations = [
migrations.AddField(
model_name='bulktask',
name='message_body',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='bulktask',
name='subject',
field=models.CharField(blank=True, max_length=255),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 5.2.7 on 2026-05-18 02:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0052_bulktask_message_body_bulktask_subject'),
]
operations = [
migrations.AddField(
model_name='voter',
name='ever_had_large_sign',
field=models.BooleanField(db_index=True, default=False),
),
migrations.AddField(
model_name='voter',
name='ever_had_yard_sign',
field=models.BooleanField(db_index=True, default=False),
),
]

Binary file not shown.

View File

@ -1,6 +1,4 @@
import zoneinfo
from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver
from django.db import models
from django.contrib.auth.models import User
import json
@ -135,9 +133,7 @@ class Voter(models.Model):
YARD_SIGN_CHOICES = [
('none', 'None'),
('wants', 'Wants a yard sign'),
('wants_large', 'Wants a Large Sign'),
('has', 'Has a yard sign'),
('has_large', 'Has a Large Sign'),
]
WINDOW_STICKER_CHOICES = [
('none', 'None'),
@ -149,12 +145,6 @@ class Voter(models.Model):
('cell', 'Cell Phone'),
('work', 'Work Phone'),
]
CALL_QUEUE_STATUS_CHOICES = [
('no_call_required', 'No Call Required'),
('to_be_called', 'To Be Called'),
('in_call_queue', 'In Call Queue'),
('called', 'Called'),
]
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='voters')
voter_id = models.CharField(max_length=50, blank=True, db_index=True)
@ -180,28 +170,15 @@ class Voter(models.Model):
precinct = models.CharField(max_length=100, blank=True, db_index=True)
registration_date = models.DateField(null=True, blank=True)
is_targeted = models.BooleanField(default=False, db_index=True)
target_door_visit = models.BooleanField(default=False, db_index=True)
candidate_support = models.CharField(max_length=20, choices=CANDIDATE_SUPPORT_CHOICES, default='unknown', db_index=True)
yard_sign = models.CharField(max_length=20, choices=YARD_SIGN_CHOICES, default='none', db_index=True)
ever_had_yard_sign = models.BooleanField(default=False, db_index=True)
ever_had_large_sign = models.BooleanField(default=False, db_index=True)
window_sticker = models.CharField(max_length=20, choices=WINDOW_STICKER_CHOICES, default='none', verbose_name='Window Sticker Status', db_index=True)
notes = models.TextField(blank=True)
door_visit = models.BooleanField(default=False, db_index=True)
neighborhood = models.CharField(max_length=100, blank=True, db_index=True)
is_inactive = models.BooleanField(default=False, db_index=True)
call_queue_status = models.CharField(max_length=20, choices=CALL_QUEUE_STATUS_CHOICES, default='no_call_required', db_index=True)
voted = models.BooleanField(default=False, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=['tenant', 'address_street', 'city', 'state', 'zip_code']),
models.Index(fields=['tenant', 'is_inactive', 'door_visit', 'target_door_visit']),
models.Index(fields=['tenant', 'last_name', 'first_name']),
]
def geocode_address(self, use_fallback=True):
"""
Attempts to geocode the voter's address using Google Maps API.
@ -261,13 +238,6 @@ class Voter(models.Model):
return False, err
def save(self, *args, **kwargs):
if self.yard_sign in ['has', 'wants']:
self.ever_had_yard_sign = True
elif self.yard_sign in ['has_large', 'wants_large']:
self.ever_had_large_sign = True
skip_geocode = kwargs.pop("skip_geocode", False) or getattr(self, "_skip_geocode", False)
update_fields = kwargs.get('update_fields')
# Auto-format phone number
self.phone = format_phone_number(self.phone)
self.secondary_phone = format_phone_number(self.secondary_phone)
@ -284,60 +254,34 @@ class Voter(models.Model):
# Change detection
should_geocode = False
# Detect manual change of target_door_visit
if self.pk:
orig = getattr(self, "_orig_obj", None)
if not orig:
try:
orig = Voter.objects.get(pk=self.pk)
except Voter.DoesNotExist:
orig = None
if not self.pk:
# New record
# Only auto-geocode if coordinates were not already provided
if self.latitude is None or self.longitude is None:
should_geocode = True
else:
orig = Voter.objects.get(pk=self.pk)
# Detect if address components changed
address_changed = (self.address_street != orig.address_street or
self.city != orig.city or
self.state != orig.state or
self.zip_code != orig.zip_code)
if orig:
self._orig_obj = orig # Cache it for geocoding check and signals
if not orig.target_door_visit and self.target_door_visit:
# User manually checked the box (or changed it to True)
self._target_door_visit_manually_set = True
coords_provided = (self.latitude != orig.latitude or self.longitude != orig.longitude)
# If update_fields is set and doesn't include address components, skip geocode
if update_fields:
addr_fields = {'address_street', 'city', 'state', 'zip_code', 'latitude', 'longitude'}
if not addr_fields.intersection(update_fields):
skip_geocode = True
# If specifically provided in import, treat as provided even if same as DB
if getattr(self, "_coords_provided_in_import", False):
coords_provided = True
if not skip_geocode:
if not self.pk:
# New record
# Only auto-geocode if coordinates were not already provided
if self.latitude is None or self.longitude is None:
should_geocode = True
else:
orig = getattr(self, "_orig_obj", None) # Already set above but being safe
if orig:
# Detect if address components changed
address_changed = (self.address_street != orig.address_street or
self.city != orig.city or
self.state != orig.state or
self.zip_code != orig.zip_code)
coords_provided = (self.latitude != orig.latitude or self.longitude != orig.longitude)
# Auto-geocode if address changed AND coordinates were NOT manually updated
if address_changed and not coords_provided:
should_geocode = True
# Auto-geocode if coordinates are still missing and were not just provided
if (self.latitude is None or self.longitude is None) and not coords_provided:
should_geocode = True
# If specifically provided in import, treat as provided even if same as DB
if getattr(self, "_coords_provided_in_import", False):
coords_provided = True
# Auto-geocode if address changed AND coordinates were NOT manually updated
if address_changed and not coords_provided:
should_geocode = True
# Auto-geocode if coordinates are still missing and were not just provided
if (self.latitude is None or self.longitude is None) and not coords_provided:
should_geocode = True
else:
should_geocode = True
if not skip_geocode and should_geocode and self.address:
if should_geocode and self.address:
# We don't want to block save if geocoding fails, so we just call it
self.geocode_address()
@ -376,7 +320,6 @@ class Event(models.Model):
unique_together = ('tenant', 'name')
def save(self, *args, **kwargs):
skip_geocode = kwargs.pop("skip_geocode", False) or getattr(self, "_skip_geocode", False)
# Ensure coordinates are truncated to 12 characters before saving
if self.latitude:
self.latitude = Decimal(str(self.latitude)[:12])
@ -404,7 +347,6 @@ class Volunteer(models.Model):
notes = models.TextField(blank=True)
def save(self, *args, **kwargs):
skip_geocode = kwargs.pop("skip_geocode", False) or getattr(self, "_skip_geocode", False)
# Auto-format phone number
self.phone = format_phone_number(self.phone)
@ -486,32 +428,6 @@ class ScheduledCall(models.Model):
def __str__(self):
return f"Call for {self.voter} assigned to {self.volunteer}"
class BulkTask(models.Model):
TASK_TYPE_CHOICES = [
('sms', 'SMS'),
('email', 'Email'),
]
STATUS_CHOICES = [
('pending', 'Pending'),
('processing', 'In Progress'),
('completed', 'Completed'),
('failed', 'Failed'),
]
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='bulk_tasks')
task_type = models.CharField(max_length=10, choices=TASK_TYPE_CHOICES)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
total_count = models.IntegerField(default=0)
success_count = models.IntegerField(default=0)
fail_count = models.IntegerField(default=0)
error_message = models.TextField(blank=True)
message_body = models.TextField(blank=True)
subject = models.CharField(max_length=255, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.get_task_type_display()} Task - {self.status} ({self.created_at})"
class CampaignSettings(models.Model):
tenant = models.OneToOneField(Tenant, on_delete=models.CASCADE, related_name='settings')
donation_goal = models.DecimalField(max_digits=12, decimal_places=2, default=170000.00)
@ -526,358 +442,10 @@ class CampaignSettings(models.Model):
smtp_use_tls = models.BooleanField(default=True)
smtp_use_ssl = models.BooleanField(default=False)
email_from_address = models.EmailField(blank=True)
email_from_name = models.CharField(max_length=255, blank=True)
call_script = models.TextField(blank=True)
class Meta:
verbose_name = 'Campaign Settings'
verbose_name_plural = 'Campaign Settings'
def clean(self):
from django.core.exceptions import ValidationError
if self.smtp_use_tls and self.smtp_use_ssl:
raise ValidationError('SMTP Use TLS and SMTP Use SSL are mutually exclusive. Please choose only one.')
def save(self, *args, **kwargs):
skip_geocode = kwargs.pop("skip_geocode", False) or getattr(self, "_skip_geocode", False)
self.full_clean()
super().save(*args, **kwargs)
def __str__(self):
return f'Settings for {self.tenant.name}'
@receiver(post_save, sender=Donation)
def update_voter_support_on_donation(sender, instance, **kwargs):
"""
Automatically set candidate_support to 'supporting' if a voter has a donation > 0.
"""
if instance.amount > 0:
voter = instance.voter
if voter.candidate_support != 'supporting':
voter.candidate_support = 'supporting'
voter.save(update_fields=['candidate_support'])
@receiver(pre_save, sender=Voter)
def handle_voter_status_on_voted_pre_save(sender, instance, **kwargs):
"""
If a voter has voted, ensure they are not targets for door visits or calls.
"""
if instance.voted:
instance.target_door_visit = False
instance.call_queue_status = 'no_call_required'
@receiver(post_save, sender=Voter)
def update_voter_support_on_yard_sign(sender, instance, **kwargs):
"""
Automatically set candidate_support to "supporting" if:
- Voter is older than 30 (birthdate <= 30 years ago)
- Someone in their household (including themselves) has a yard sign ("wants" or "has")
"""
if getattr(instance, "_skip_signals", False):
return
orig = getattr(instance, "_orig_obj", None)
# Detection of manual changes or irrelevant updates
update_fields = kwargs.get("update_fields")
support_manually_changed = orig and instance.candidate_support != orig.candidate_support
relevant_fields = {"yard_sign", "birthdate", "address_street", "city", "state", "zip_code"}
if update_fields:
if not relevant_fields.intersection(update_fields):
return
elif orig and not kwargs.get("created"):
# If no update_fields, manually check if anything relevant changed
changed = False
for field in relevant_fields:
if getattr(instance, field) != getattr(orig, field):
changed = True
break
if not changed:
return
from datetime import date
today = date.today()
try:
thirty_years_ago = today.replace(year=today.year - 30)
except ValueError: # Leap year case
thirty_years_ago = today.replace(year=today.year - 30, day=today.day - 1)
# 1. If this voter now has a yard sign, update everyone in the household who is > 30
# ONLY update those whose support is currently "unknown" to avoid overwriting intentional choices.
if instance.yard_sign in ["wants", "has"]:
queryset = Voter.objects.filter(
address_street=instance.address_street,
city=instance.city,
state=instance.state,
zip_code=instance.zip_code,
tenant=instance.tenant,
birthdate__lte=thirty_years_ago,
candidate_support="unknown"
)
# If support was manually changed in THIS save, exclude this instance from auto-revert
if support_manually_changed:
queryset = queryset.exclude(pk=instance.pk)
queryset.update(candidate_support="supporting")
# 2. If this voter itself is > 30, check if anyone in the household has a yard sign
elif instance.birthdate and instance.birthdate <= thirty_years_ago:
# Only auto-set if support is currently unknown and wasn"t just manually changed.
if not support_manually_changed and instance.candidate_support == "unknown":
household_has_sign = Voter.objects.filter(
address_street=instance.address_street,
city=instance.city,
state=instance.state,
zip_code=instance.zip_code,
tenant=instance.tenant,
yard_sign__in=["wants", "has"]
).exists()
if household_has_sign:
Voter.objects.filter(pk=instance.pk).update(candidate_support="supporting")
elif instance.birthdate and instance.birthdate <= thirty_years_ago:
household_has_sign = Voter.objects.filter(
address_street=instance.address_street,
city=instance.city,
state=instance.state,
zip_code=instance.zip_code,
tenant=instance.tenant,
yard_sign__in=['wants', 'has']
).exists()
if household_has_sign and instance.candidate_support != 'supporting':
Voter.objects.filter(pk=instance.pk).update(candidate_support='supporting')
@receiver(post_save, sender=Voter)
def update_target_door_visit_logic(sender, instance, **kwargs):
"""
Set target_door_visit = False if door_visit = False and any voter record in the household:
1. Has a candidate support = 'Supporting' or 'Not Supporting'
2. Has attended an event (EventParticipation status = 'Attended')
3. NO ONE in the household is marked as is_targeted = True
"""
if getattr(instance, '_skip_signals', False):
return
# Manual override check: if target_door_visit was explicitly set to True in this save,
# skip the auto-reset logic for THIS voter.
is_manual_override = getattr(instance, '_target_door_visit_manually_set', False)
update_fields = kwargs.get('update_fields')
if update_fields:
relevant = {'candidate_support', 'is_targeted', 'door_visit', 'address_street', 'city', 'state', 'zip_code', 'voted'}
if not relevant.intersection(update_fields):
return
# 0. If this voter has voted, they are no longer a target for door visits.
if instance.voted:
if instance.target_door_visit:
Voter.objects.filter(pk=instance.pk).update(target_door_visit=False)
# 1. If this voter was just updated to Supporting or Not Supporting,
# remove everyone in the household who hasn't been visited from the target list.
if instance.candidate_support in ['supporting', 'not_supporting']:
queryset = Voter.objects.filter(
address_street=instance.address_street,
city=instance.city,
state=instance.state,
zip_code=instance.zip_code,
tenant=instance.tenant,
door_visit=False
)
if is_manual_override:
queryset = queryset.exclude(pk=instance.pk)
queryset.update(target_door_visit=False)
# 2. If this voter was just updated to is_targeted = False,
# and NO ONE in the household is targeted, set target_door_visit = False
# for everyone in the household who hasn't been visited.
elif not instance.is_targeted:
household_has_targeted = Voter.objects.filter(
address_street=instance.address_street,
city=instance.city,
state=instance.state,
zip_code=instance.zip_code,
tenant=instance.tenant,
is_targeted=True
).exists()
if not household_has_targeted:
queryset = Voter.objects.filter(
address_street=instance.address_street,
city=instance.city,
state=instance.state,
zip_code=instance.zip_code,
tenant=instance.tenant,
door_visit=False
)
if is_manual_override:
queryset = queryset.exclude(pk=instance.pk)
queryset.update(target_door_visit=False)
# 3. If this voter was just saved with door_visit=False,
# check if anyone in the household (including themselves) has known support,
# attended an event, or if NO ONE is targeted.
elif not instance.door_visit and not is_manual_override:
household_voters = Voter.objects.filter(
address_street=instance.address_street,
city=instance.city,
state=instance.state,
zip_code=instance.zip_code,
tenant=instance.tenant
)
household_has_known_support = household_voters.filter(
candidate_support__in=['supporting', 'not_supporting']
).exists()
household_has_attended = EventParticipation.objects.filter(
voter__in=household_voters,
participation_status__name='Attended'
).exists()
household_has_targeted = household_voters.filter(is_targeted=True).exists()
if (household_has_known_support or household_has_attended or not household_has_targeted) and instance.target_door_visit:
Voter.objects.filter(pk=instance.pk).update(target_door_visit=False)
@receiver(post_save, sender=EventParticipation)
def update_target_door_visit_on_participation(sender, instance, **kwargs):
"""
Set target_door_visit = False for all household members who haven't been visited
if someone in the household attended an event.
"""
if instance.participation_status and instance.participation_status.name == 'Attended':
voter = instance.voter
Voter.objects.filter(
address_street=voter.address_street,
city=voter.city,
state=voter.state,
zip_code=voter.zip_code,
tenant=voter.tenant,
door_visit=False
).update(target_door_visit=False)
@receiver(post_save, sender=Voter)
def update_voter_call_queue_status_on_voter_save(sender, instance, **kwargs):
"""
Sync call_queue_status when is_targeted, candidate_support or voted changes.
"""
if getattr(instance, '_skip_signals', False):
return
orig = getattr(instance, '_orig_obj', None)
if orig and instance.call_queue_status != orig.call_queue_status:
# If call_queue_status was manually changed, don't auto-override in this save
return
update_fields = kwargs.get('update_fields')
if update_fields:
relevant = {'is_targeted', 'candidate_support', 'voted'}
if not relevant.intersection(update_fields):
return
# PRIORITY 1: If they voted, no call required and cancel pending calls
if instance.voted:
# Cancel any pending calls
ScheduledCall.objects.filter(voter=instance, status='pending').update(status='cancelled')
if instance.call_queue_status != 'no_call_required':
Voter.objects.filter(pk=instance.pk).update(call_queue_status='no_call_required')
return
# PRIORITY 2: Check if in queue (pending scheduled call)
if ScheduledCall.objects.filter(voter=instance, status='pending').exists():
if instance.call_queue_status != 'in_call_queue':
Voter.objects.filter(pk=instance.pk).update(call_queue_status='in_call_queue')
return
# PRIORITY 3: If support is 'supporting', then 'no_call_required'
if instance.candidate_support == 'supporting':
if instance.call_queue_status != 'no_call_required':
Voter.objects.filter(pk=instance.pk).update(call_queue_status='no_call_required')
return
# PRIORITY 4: If un-targeted, set to no_call_required
if not instance.is_targeted:
if instance.call_queue_status != 'no_call_required':
Voter.objects.filter(pk=instance.pk).update(call_queue_status='no_call_required')
else:
# If targeted, and currently no_call_required, set to to_be_called
if instance.call_queue_status == 'no_call_required':
Voter.objects.filter(pk=instance.pk).update(call_queue_status='to_be_called')
@receiver(post_save, sender=ScheduledCall)
def update_voter_call_queue_status_on_call_save(sender, instance, **kwargs):
"""
Sync Voter.call_queue_status when a ScheduledCall is saved.
"""
voter = instance.voter
# PRIORITY 0: If they voted, always no_call_required
if voter.voted:
if voter.call_queue_status != 'no_call_required':
voter.call_queue_status = 'no_call_required'
voter.save(update_fields=['call_queue_status'])
return
# PRIORITY 1: If there is ANY pending call for this voter, ALWAYS in_call_queue
if ScheduledCall.objects.filter(voter=voter, status='pending').exists():
if voter.call_queue_status != 'in_call_queue':
voter.call_queue_status = 'in_call_queue'
voter.save(update_fields=['call_queue_status'])
return
# PRIORITY 2: If no pending calls, follow normal rules
if voter.candidate_support == 'supporting':
if voter.call_queue_status != 'no_call_required':
voter.call_queue_status = 'no_call_required'
voter.save(update_fields=['call_queue_status'])
return
if instance.status == 'completed':
if voter.call_queue_status != 'called':
voter.call_queue_status = 'called'
voter.save(update_fields=['call_queue_status'])
elif instance.status == 'cancelled':
if voter.is_targeted:
# Check if they were already called
if ScheduledCall.objects.filter(voter=voter, status='completed').exists():
voter.call_queue_status = 'called'
else:
voter.call_queue_status = 'to_be_called'
voter.save(update_fields=['call_queue_status'])
@receiver(post_delete, sender=ScheduledCall)
def update_voter_call_queue_status_on_call_delete(sender, instance, **kwargs):
"""
Sync Voter.call_queue_status when a ScheduledCall is deleted.
"""
voter = instance.voter
# PRIORITY 1: Check if there are other pending calls
if ScheduledCall.objects.filter(voter=voter, status='pending').exists():
if voter.call_queue_status != 'in_call_queue':
voter.call_queue_status = 'in_call_queue'
voter.save(update_fields=['call_queue_status'])
return
# PRIORITY 2: If no pending calls, follow normal rules
if voter.candidate_support == 'supporting':
if voter.call_queue_status != 'no_call_required':
voter.call_queue_status = 'no_call_required'
voter.save(update_fields=['call_queue_status'])
return
if voter.is_targeted:
# If no pending calls left, set back to called or to_be_called
if ScheduledCall.objects.filter(voter=voter, status='completed').exists():
voter.call_queue_status = 'called'
else:
voter.call_queue_status = 'to_be_called'
voter.save(update_fields=['call_queue_status'])
return f'Settings for {self.tenant.name}'

View File

@ -29,17 +29,6 @@ def has_role(user, tenant, roles):
def is_block_walker(user):
return user.groups.filter(name='Block Walker').exists()
def is_call_queue(user):
return user.groups.filter(name='Call Queue').exists()
def is_editor(user):
return user.groups.filter(name='Editor').exists()
def can_access_call_queue(user):
if user.is_superuser:
return True
return is_call_queue(user) or is_editor(user)
def can_view_voters(user, tenant):
if user.has_perm("core.view_voter"):
return True
@ -124,4 +113,4 @@ def role_required(roles, permission=None):
messages.error(request, "You do not have permission to perform this action.")
return redirect('index')
return _wrapped_view
return decorator
return decorator

View File

@ -1,217 +0,0 @@
import threading
import base64
import urllib.parse
import urllib.request
import re
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from django.utils import timezone
from django.db import connection, transaction
from .models import BulkTask, Voter, Volunteer, Interaction, InteractionType
logger = logging.getLogger(__name__)
def send_single_sms(url, auth_header, from_number, to_number, message_body):
"""
Sends a single SMS using Twilio API. Returns (success, error_msg).
"""
data_dict = {
'To': to_number,
'From': from_number,
'Body': message_body
}
data = urllib.parse.urlencode(data_dict).encode()
req = urllib.request.Request(url, data=data, method='POST')
req.add_header("Authorization", f"Basic {auth_header}")
try:
with urllib.request.urlopen(req, timeout=10) as response:
if response.status in [200, 201]:
return True, None
else:
return False, f"HTTP {response.status}"
except Exception as e:
return False, str(e)
def run_bulk_sms_task(task_id, object_ids, select_all_results, search_filters=None, object_type='voter'):
"""
Background task to send bulk SMS to voters or volunteers.
"""
# Ensure a fresh connection for the thread
connection.close()
try:
task = BulkTask.objects.get(id=task_id)
task.status = 'processing'
task.save()
tenant = task.tenant
settings_obj = getattr(tenant, 'settings', None)
if not settings_obj:
task.status = 'failed'
task.error_message = "Campaign settings not found."
task.save()
return
account_sid = settings_obj.twilio_account_sid
auth_token = settings_obj.twilio_auth_token
from_number = settings_obj.twilio_from_number
if not account_sid or not auth_token or not from_number:
task.status = 'failed'
task.error_message = "Twilio configuration is incomplete."
task.save()
return
message_body = task.message_body
# Determine the queryset of objects (Voters or Volunteers)
if object_type == 'voter':
if select_all_results and search_filters:
from .filter_helper import get_filtered_voter_queryset_from_filters
queryset = get_filtered_voter_queryset_from_filters(tenant, search_filters)
queryset = queryset.filter(phone_type='cell').exclude(phone='')
else:
queryset = Voter.objects.filter(tenant=tenant, id__in=object_ids, phone_type='cell').exclude(phone='')
else: # volunteer
queryset = Volunteer.objects.filter(tenant=tenant, id__in=object_ids).exclude(phone='')
task.total_count = queryset.count()
task.save()
if task.total_count == 0:
task.status = 'completed'
task.error_message = f"No {object_type}s with a valid phone number found."
task.save()
return
auth_str = f"{account_sid}:{auth_token}"
auth_header = base64.b64encode(auth_str.encode()).decode()
url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
interaction_type = None
if object_type == 'voter':
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="SMS Text")
success_count = 0
fail_count = 0
interactions_to_create = []
# Batch size for updating task status and creating interactions
batch_size = 50
max_workers = 10 # Parallelize Twilio requests
# Using iterator() to avoid loading all objects into memory
# Note: We need to handle IDs and phones to avoid "too many open connections" or stale data
# Actually, iterator() is fine.
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_obj = {}
for obj in queryset.iterator():
# Format phone to E.164 (assume US +1)
digits = re.sub(r'\D', '', str(obj.phone))
if len(digits) == 10:
to_number = f"+1{digits}"
elif len(digits) == 11 and digits.startswith('1'):
to_number = f"+{digits}"
else:
fail_count += 1
continue
future = executor.submit(send_single_sms, url, auth_header, from_number, to_number, message_body)
future_to_obj[future] = obj
# If we have many futures, process them to avoid memory issues and see progress
if len(future_to_obj) >= batch_size:
# Collect completed results
for future in as_completed(future_to_obj):
obj = future_to_obj.pop(future)
success, error = future.result()
if success:
success_count += 1
if object_type == 'voter':
interactions_to_create.append(Interaction(
voter=obj,
type=interaction_type,
date=timezone.now(),
description='Mass SMS Text',
notes=message_body
))
else:
fail_count += 1
logger.error(f"Error sending SMS to {obj.phone}: {error}")
if len(future_to_obj) < batch_size // 2: # Keep some buffer
break
# Update status and interactions
if len(interactions_to_create) >= batch_size:
Interaction.objects.bulk_create(interactions_to_create)
interactions_to_create = []
task.success_count = success_count
task.fail_count = fail_count
task.save()
# Process remaining futures
for future in as_completed(future_to_obj):
obj = future_to_obj[future]
success, error = future.result()
if success:
success_count += 1
if object_type == 'voter':
interactions_to_create.append(Interaction(
voter=obj,
type=interaction_type,
date=timezone.now(),
description='Mass SMS Text',
notes=message_body
))
else:
fail_count += 1
logger.error(f"Error sending SMS to {obj.phone}: {error}")
if interactions_to_create:
Interaction.objects.bulk_create(interactions_to_create)
interactions_to_create = []
task.success_count = success_count
task.fail_count = fail_count
task.status = 'completed'
task.save()
except Exception as e:
logger.exception(f"Unexpected error in bulk SMS task: {e}")
try:
task = BulkTask.objects.get(id=task_id)
task.status = 'failed'
task.error_message = str(e)
task.save()
except:
pass
finally:
connection.close()
def start_bulk_sms_task(tenant, message_body, object_ids, select_all_results, search_filters=None, object_type='voter'):
"""
Creates a BulkTask and starts the background thread.
"""
task = BulkTask.objects.create(
tenant=tenant,
task_type='sms',
message_body=message_body,
status='pending'
)
thread = threading.Thread(
target=run_bulk_sms_task,
args=(task.id, object_ids, select_all_results, search_filters, object_type)
)
thread.daemon = True
thread.start()
return task

View File

@ -1,39 +0,0 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static admin_modify %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; Assign Volunteer
</div>
{% endblock %}
{% block content %}
<div id="content-main">
<p>Select a volunteer to assign to the {{ queryset|length }} selected interactions:</p>
<form action="" method="post">
{% csrf_token %}
<div>
{% for field in form %}
<div class="form-row">
{{ field.errors }}
{{ field.label_tag }} {{ field }}
</div>
{% endfor %}
<input type="hidden" name="action" value="mass_assign_volunteer" />
<input type="hidden" name="post" value="yes" />
{% for obj in queryset %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk }}" />
{% endfor %}
<div class="submit-row">
<input type="submit" name="apply" value="Assign Volunteer" class="default" />
<a href="." class="button cancel-link">Cancel</a>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -28,39 +28,18 @@
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="votersDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Voters
</a>
<ul class="dropdown-menu shadow border-0" aria-labelledby="votersDropdown">
<li><a class="dropdown-item small" href="/voters/"><i class="bi bi-people me-2"></i>Registry</a></li>
<li><a class="dropdown-item small" href="{% url 'voter_advanced_search' %}"><i class="bi bi-search me-2"></i>Advanced Search</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item small" href="{% url 'bulk_task_list' %}"><i class="bi bi-list-task me-2"></i>Bulk Operations Log</a></li>
</ul>
<li class="nav-item">
<a class="nav-link" href="/voters/">Voters</a>
</li>
{% if can_access_call_queue %}
<li class="nav-item">
<a class="nav-link" href="/call-queue/">Call Queue</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="/door-visits/">Door Visits</a>
</li>
{% if not is_block_walker or is_staff %}
<li class="nav-item">
<a class="nav-link" href="/events/">Events</a>
</li>
{% endif %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="signsDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Yard Signs
</a>
<ul class="dropdown-menu shadow border-0" aria-labelledby="signsDropdown">
<li><a class="dropdown-item small" href="{% url 'yard_sign_voters' %}"><i class="bi bi-card-checklist me-2"></i>Sign Requests</a></li>
<li><a class="dropdown-item small" href="{% url 'view_signs' %}"><i class="bi bi-signpost-2 me-2"></i>View Signs</a></li>
</ul>
</li>
{% if can_view_volunteers %}
<li class="nav-item">
<a class="nav-link" href="/volunteers/">Volunteers</a>

View File

@ -1,174 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% load core_tags %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="{% url 'voter_advanced_search' %}" class="text-decoration-none">Advanced Search</a></li>
<li class="breadcrumb-item active" aria-current="page">Bulk Operations</li>
</ol>
</nav>
<h1 class="h2 mb-0">Bulk Operations Log</h1>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.reload();">
<i class="bi bi-arrow-clockwise me-1"></i> Refresh Status
</button>
<a href="{% url 'voter_advanced_search' %}" class="btn btn-outline-secondary btn-sm">Back to Search</a>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-4">Type</th>
<th>Status</th>
<th>Progress</th>
<th>Counts</th>
<th>Created</th>
<th class="pe-4 text-end">Details</th>
</tr>
</thead>
<tbody>
{% for task in bulk_tasks %}
<tr>
<td class="ps-4">
<span class="badge {% if task.task_type == 'sms' %}bg-info{% else %}bg-primary{% endif %} text-uppercase px-2 py-1">
{{ task.get_task_type_display }}
</span>
</td>
<td>
{% if task.status == 'processing' %}
<span class="text-primary fw-medium"><i class="bi bi-arrow-repeat spin me-1"></i> In Progress</span>
{% elif task.status == 'completed' %}
<span class="text-success fw-medium"><i class="bi bi-check-circle me-1"></i> Completed</span>
{% elif task.status == 'failed' %}
<span class="text-danger fw-medium" title="{{ task.error_message }}"><i class="bi bi-exclamation-triangle me-1"></i> Failed</span>
{% else %}
<span class="text-muted fw-medium">{{ task.get_status_display }}</span>
{% endif %}
</td>
<td style="min-width: 200px;">
<div class="progress" style="height: 8px;">
{% if task.total_count > 0 %}
{% with success_percent=task.success_count|add:task.fail_count|mul:100|div:task.total_count %}
<div class="progress-bar {% if task.status == 'failed' %}bg-danger{% elif task.status == 'completed' %}bg-success{% else %}progress-bar-striped progress-bar-animated{% endif %}"
role="progressbar"
style="width: {{ success_percent }}%">
</div>
{% endwith %}
{% else %}
<div class="progress-bar bg-light" style="width: 0%"></div>
{% endif %}
</div>
</td>
<td>
<div class="small">
<span class="text-success">{{ task.success_count }} success</span>
<span class="text-danger">{{ task.fail_count }} fail</span>
<span class="text-muted">{{ task.total_count }} total</span>
</div>
</td>
<td class="small text-muted">
{{ task.created_at|date:"M j, Y" }}<br>
{{ task.created_at|date:"H:i" }}
</td>
<td class="pe-4 text-end">
<button type="button" class="btn btn-light btn-sm rounded-circle" data-bs-toggle="collapse" data-bs-target="#task-details-{{ task.id }}" aria-expanded="false">
<i class="bi bi-chevron-down"></i>
</button>
</td>
</tr>
<tr class="collapse" id="task-details-{{ task.id }}">
<td colspan="6" class="bg-light border-0 py-3 px-4">
<div class="row">
<div class="col-md-8">
<div class="mb-2">
<label class="small fw-bold text-muted text-uppercase mb-1">Message Body</label>
<div class="p-3 bg-white rounded border small text-break" style="white-space: pre-wrap;">{{ task.message_body|default:"(No content)" }}</div>
</div>
{% if task.subject %}
<div class="mb-2">
<label class="small fw-bold text-muted text-uppercase mb-1">Subject</label>
<div class="p-2 bg-white rounded border small">{{ task.subject }}</div>
</div>
{% endif %}
</div>
<div class="col-md-4">
{% if task.error_message %}
<div class="alert alert-danger py-2 small mb-0">
<div class="fw-bold mb-1"><i class="bi bi-exclamation-octagon me-1"></i> Error Details:</div>
{{ task.error_message }}
</div>
{% endif %}
<div class="mt-3 small text-muted">
<div><strong>Task ID:</strong> #{{ task.id }}</div>
<div><strong>Last Updated:</strong> {{ task.updated_at|date:"M j, H:i:s" }}</div>
</div>
</div>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<i class="bi bi-list-task display-4 d-block mb-3"></i>
<p class="mb-0">No bulk operations have been performed yet.</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if bulk_tasks.paginator.num_pages > 1 %}
<div class="card-footer bg-white border-0 py-3">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0 pagination-sm">
{% if bulk_tasks.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ bulk_tasks.previous_page_number }}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% endif %}
<li class="page-item active"><span class="page-link">{{ bulk_tasks.number }} of {{ bulk_tasks.paginator.num_pages }}</span></li>
{% if bulk_tasks.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ bulk_tasks.next_page_number }}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ bulk_tasks.paginator.num_pages }}" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
</div>
<style>
.spin { animation: spin 2s linear infinite; display: inline-block; }
@keyframes spin { 100% { transform: rotate(360deg); } }
.breadcrumb-item + .breadcrumb-item::before { content: ""; }
</style>
{% endblock %}

View File

@ -1,315 +1,126 @@
{% extends "base.html" %}
{% block content %}
<div class="container py-4">
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center mb-4 gap-3">
<h1 class="h2 mb-0 text-dark fw-bold">Call Queue</h1>
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="{% url 'export_call_queue' %}{% if selected_volunteer_id %}?volunteer_filter={{ selected_volunteer_id }}{% endif %}" class="btn btn-outline-primary px-3 py-2 fw-bold d-inline-flex align-items-center">
<i class="bi bi-download me-2"></i> Export CSV
</a>
{% if campaign_settings.call_script %}
<button type="button" class="btn btn-outline-primary px-3 py-2 fw-bold d-inline-flex align-items-center" data-bs-toggle="modal" data-bs-target="#callScriptModal">
<i class="bi bi-file-text me-2"></i> Call Script
</button>
{% endif %}
<button type="button" class="btn btn-primary px-4 py-2 fw-bold d-inline-flex align-items-center shadow-sm" data-bs-toggle="modal" data-bs-target="#populateQueueModal">
<i class="bi bi-plus-circle me-2"></i> Populate Queue
</button>
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Call Queue</h1>
<div class="d-flex gap-2">
<span class="badge bg-primary d-flex align-items-center px-3">{{ calls.paginator.count }} Pending Calls</span>
</div>
</div>
<div class="row mb-4 align-items-center">
<div class="col-12 col-lg-8">
{% if is_staff %}
<div class="card border-0 shadow-sm">
<div class="card-body p-2 px-3">
<form method="GET" class="row g-2 align-items-center">
<div class="col-auto">
<label for="volunteer_filter" class="form-label fw-bold small text-uppercase text-muted mb-0 me-2">Filter:</label>
</div>
<div class="col">
<select name="volunteer_filter" id="volunteer_filter" class="form-select border-0 bg-light fw-semibold" onchange="this.form.submit()">
<option value="all" {% if selected_volunteer_id == 'all' %}selected{% endif %}>All Volunteers</option>
{% for volunteer in volunteers %}
<option value="{{ volunteer.id }}" {% if selected_volunteer_id == volunteer.id|stringformat:"s" %}selected{% endif %}>
{{ volunteer }}
</option>
{% endfor %}
</select>
</div>
<div class="col-auto d-md-none">
<button type="submit" class="btn btn-primary px-3">Apply</button>
</div>
</form>
</div>
</div>
{% endif %}
</div>
<div class="col-12 col-lg-4 text-lg-end mt-3 mt-lg-0">
<span class="badge bg-soft-primary text-primary px-3 py-2 fs-6 rounded-pill border border-primary border-opacity-25">
<i class="bi bi-people me-1"></i> {{ calls.paginator.count }} Pending Calls
</span>
</div>
</div>
<!-- Desktop Table View (Hidden on mobile) -->
<div class="card border-0 shadow-sm d-none d-md-block overflow-hidden">
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="bg-light border-bottom">
<thead class="bg-light">
<tr>
<th class="ps-4 py-3 text-uppercase small fw-bold text-muted">Voter</th>
<th class="text-uppercase small fw-bold text-muted">Phone</th>
<th class="text-uppercase small fw-bold text-muted">Assigned</th>
<th class="text-uppercase small fw-bold text-muted">Comments</th>
<th class="text-uppercase small fw-bold text-muted">Age</th>
<th class="pe-4 text-end text-uppercase small fw-bold text-muted">Actions</th>
<th class="ps-4">Voter</th>
<th>Phone</th>
<th>Assigned Volunteer</th>
<th>Comments</th>
<th>Scheduled</th>
<th class="pe-4 text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for call in calls %}
<tr>
<td class="ps-4">
<a href="{% url 'voter_detail' call.voter.id %}" class="fw-bold text-primary text-decoration-none">
<a href="{% url 'voter_detail' call.voter.id %}" class="fw-semibold text-primary text-decoration-none d-block">
{{ call.voter.first_name }} {{ call.voter.last_name }}
</a>
</td>
<td>
{% if call.voter.phone %}
<div class="d-flex align-items-center">
<a href="tel:{{ call.voter.phone }}" class="text-decoration-none text-dark fw-semibold me-2">{{ call.voter.phone }}</a>
<button type="button" class="btn btn-link p-0 text-muted" onclick="copyToClipboard('{{ call.voter.phone }}', this)" title="Copy Number">
<i class="bi bi-clipboard"></i>
</button>
<a href="tel:{{ call.voter.phone }}" class="text-decoration-none text-dark fw-medium me-2">{{ call.voter.phone }}</a>
<a href="tel:{{ call.voter.phone }}" class="text-primary me-2" title="Call"><i class="bi bi-telephone" style="font-size: 0.85rem;"></i></a>
</div>
{% else %}
<span class="text-muted small">No phone</span>
-
{% endif %}
</td>
<td>
{% if call.volunteer %}
<div class="d-flex align-items-center">
<div class="avatar-sm me-2 bg-soft-primary text-primary rounded-circle d-flex align-items-center justify-content-center fw-bold" style="width: 24px; height: 24px; font-size: 0.7rem;">
{{ call.volunteer.first_name|first }}{{ call.volunteer.last_name|first }}
</div>
<span class="text-dark small fw-medium">{{ call.volunteer }}</span>
</div>
<a href="{% url 'volunteer_detail' call.volunteer.id %}" class="text-decoration-none text-dark">
{{ call.volunteer }}
</a>
{% else %}
<span class="badge bg-light text-muted fw-normal border">Unassigned</span>
<span class="text-muted small">Unassigned</span>
{% endif %}
</td>
<td><small class="text-muted text-truncate d-inline-block" style="max-width: 150px;">{{ call.comments|default:"-" }}</small></td>
<td><small class="text-muted">{{ call.created_at|timesince }}</small></td>
<td><small class="text-muted">{{ call.comments|default:"-" }}</small></td>
<td><small class="text-muted" title="{{ call.created_at }}">{{ call.created_at|timesince }} ago</small></td>
<td class="pe-4 text-end">
<div class="d-flex justify-content-end gap-2">
<button type="button" class="btn btn-success btn-sm px-3 fw-bold shadow-sm d-inline-flex align-items-center" data-bs-toggle="modal" data-bs-target="#callNotesModal"
data-call-id="{{ call.id }}" data-voter-name="{{ call.voter.first_name }} {{ call.voter.last_name }}"
data-support-status="{{ call.voter.candidate_support }}"
data-yard-sign="{{ call.voter.yard_sign }}">
<i class="bi bi-telephone-fill me-1"></i> Call
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#callNotesModal"
data-call-id="{{ call.id }}" data-voter-name="{{ call.voter.first_name }} {{ call.voter.last_name }}">
<i class="bi bi-telephone me-1"></i>Make Call
</button>
<form action="{% url 'delete_call' call.id %}" method="POST" class="d-inline ms-1">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('Remove this call from queue?')">
<i class="bi bi-trash"></i>
</button>
<form action="{% url 'delete_call' call.id %}" method="POST" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger btn-sm border-0" onclick="return confirm('Remove this call from queue?')" title="Remove">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<div class="py-4">
<i class="bi bi-telephone-x fs-1 d-block mb-3 opacity-25"></i>
<p class="mb-0 fw-medium">The call queue is currently empty.</p>
<p class="small text-muted">Click "Populate Queue" to add voters.</p>
</div>
<p class="mb-0">The call queue is currently empty.</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Mobile List View (Hidden on desktop) -->
<div class="d-md-none">
{% for call in calls %}
<div class="card border-0 shadow-sm mb-3">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="mb-0">
<a href="{% url 'voter_detail' call.voter.id %}" class="text-primary text-decoration-none fw-bold">
{{ call.voter.first_name }} {{ call.voter.last_name }}
{% if calls.paginator.num_pages > 1 %}
<div class="card-footer bg-white border-0 py-3">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0">
{% if calls.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ calls.previous_page_number }}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</h5>
<small class="text-muted">{{ call.created_at|timesince }}</small>
</div>
<div class="mb-3 d-flex align-items-center flex-wrap gap-2">
{% if call.voter.phone %}
<a href="tel:{{ call.voter.phone }}" class="btn btn-soft-primary btn-sm rounded-pill px-3 fw-bold">
<i class="bi bi-telephone me-1"></i> {{ call.voter.phone }}
</li>
{% endif %}
<li class="page-item active"><span class="page-link">{{ calls.number }} of {{ calls.paginator.num_pages }}</span></li>
{% if calls.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ calls.next_page_number }}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
<button type="button" class="btn btn-light btn-sm rounded-circle border shadow-sm d-flex align-items-center justify-content-center" style="width: 32px; height: 32px; padding: 0;" onclick="copyToClipboard('{{ call.voter.phone }}', this)" title="Copy Phone Number">
<i class="bi bi-clipboard"></i>
</button>
</li>
{% endif %}
{% if call.volunteer %}
<span class="badge bg-light text-dark fw-normal border">
<i class="bi bi-person me-1"></i>{{ call.volunteer }}
</span>
{% else %}
<span class="badge bg-light text-muted fw-normal border">Unassigned</span>
{% endif %}
</div>
{% if call.comments %}
<div class="bg-light rounded p-2 mb-3 border">
<small class="text-muted d-block mb-1 text-uppercase fw-bold" style="font-size: 0.65rem;">Comments</small>
<p class="mb-0 small">{{ call.comments }}</p>
</div>
{% endif %}
<div class="d-grid gap-2">
<button type="button" class="btn btn-success py-2 fw-bold shadow-sm" data-bs-toggle="modal" data-bs-target="#callNotesModal"
data-call-id="{{ call.id }}" data-voter-name="{{ call.voter.first_name }} {{ call.voter.last_name }}"
data-support-status="{{ call.voter.candidate_support }}"
data-yard-sign="{{ call.voter.yard_sign }}">
<i class="bi bi-telephone-fill me-2"></i>Make Call & Log Notes
</button>
<form action="{% url 'delete_call' call.id %}" method="POST">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger w-100 py-2 border-0 fw-semibold" onclick="return confirm('Remove this call from queue?')">
<i class="bi bi-trash me-2"></i>Remove from Queue
</button>
</form>
</div>
</div>
</ul>
</nav>
</div>
{% empty %}
<div class="text-center py-5 bg-white rounded shadow-sm">
<i class="bi bi-telephone-x fs-1 d-block mb-3 opacity-25"></i>
<p class="text-muted px-4 fw-medium">The call queue is currently empty.</p>
</div>
{% endfor %}
{% endif %}
</div>
<!-- Pagination -->
{% if calls.paginator.num_pages > 1 %}
<div class="py-4">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0">
{% if calls.has_previous %}
<li class="page-item">
<a class="page-link shadow-sm border-0 mx-1 rounded-circle d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;" href="?page={{ calls.previous_page_number }}&volunteer_filter={{ selected_volunteer_id }}" aria-label="Previous">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link shadow-sm border-0 mx-1 rounded px-3 fw-bold">{{ calls.number }} / {{ calls.paginator.num_pages }}</span>
</li>
{% if calls.has_next %}
<li class="page-item">
<a class="page-link shadow-sm border-0 mx-1 rounded-circle d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;" href="?page={{ calls.next_page_number }}&volunteer_filter={{ selected_volunteer_id }}" aria-label="Next">
<i class="bi bi-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
<!-- Call Script Modal -->
<div class="modal fade" id="callScriptModal" tabindex="-1" aria-labelledby="callScriptModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold" id="callScriptModalLabel">Call Script</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-3">
<div class="bg-light p-4 rounded-3 text-dark border shadow-sm" style="white-space: pre-wrap; font-size: 1.1rem; line-height: 1.6;">{{ campaign_settings.call_script }}</div>
</div>
<div class="modal-footer border-0 pt-0 pb-3">
<button type="button" class="btn btn-light px-4 py-2 fw-semibold" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Call Notes Modal -->
<div class="modal fade" id="callNotesModal" tabindex="-1" aria-labelledby="callNotesModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg">
<div class="modal-dialog">
<div class="modal-content border-0 shadow">
<form id="completeCallForm" method="POST">
{% csrf_token %}
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold" id="callNotesModalLabel">Call Notes: <span id="modalVoterName" class="text-primary"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="callNotesModalLabel">Call Notes for <span id="modalVoterName"></span></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-3">
<div class="mb-4">
<label class="form-label fw-bold small text-uppercase text-muted">Call Outcome</label>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary text-start outcome-btn py-3 px-3 fw-semibold transition-all d-flex align-items-center" data-outcome="No Answer No Voice Mail">
<i class="bi bi-telephone-minus fs-5 me-3"></i>No Answer / No Voice Mail
</button>
<button type="button" class="btn btn-outline-primary text-start outcome-btn py-3 px-3 fw-semibold transition-all d-flex align-items-center" data-outcome="No Answer Left Message">
<i class="bi bi-voicemail fs-5 me-3"></i>No Answer / Left Message
</button>
<button type="button" class="btn btn-outline-primary text-start outcome-btn py-3 px-3 fw-semibold transition-all d-flex align-items-center" data-outcome="Spoke to Voter">
<i class="bi bi-chat-dots fs-5 me-3"></i>Spoke to Voter
</button>
<button type="button" class="btn btn-outline-primary text-start outcome-btn py-3 px-3 fw-semibold transition-all d-flex align-items-center" data-outcome="Phone Number Incorrect">
<i class="bi bi-telephone-x fs-5 me-3"></i>Phone Number Incorrect
</button>
</div>
<input type="hidden" name="call_outcome" id="call_outcome" value="">
</div>
<div class="row g-3 mb-4">
<div class="col-6">
<label for="yard_sign" class="form-label fw-bold small text-uppercase text-muted">Yard Sign</label>
<select class="form-select border-0 bg-light fw-semibold" id="yard_sign" name="yard_sign">
<option value="no_change">No Change</option>
<option value="none">None</option>
<option value="wants">Wants</option>
<option value="wants_large">Wants Large</option>
<option value="has">Has</option>
<option value="has_large">Has Large</option>
</select>
</div>
<div class="col-6">
<label for="candidate_support" class="form-label fw-bold small text-uppercase text-muted">Support</label>
<select class="form-select border-0 bg-light fw-semibold" id="candidate_support" name="candidate_support">
<option value="unknown">Unknown</option>
<option value="supporting">Supporting</option>
<option value="not_supporting">Not Supporting</option>
</select>
</div>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="call_notes" class="form-label fw-bold small text-uppercase text-muted">Additional Notes</label>
<textarea class="form-control border-0 bg-light fw-medium" id="call_notes" name="call_notes" rows="3" placeholder="Add any extra context here..."></textarea>
</div>
<div class="alert alert-soft-primary border-0 small text-muted mb-0 d-flex align-items-center">
<i class="bi bi-info-circle-fill text-primary me-2"></i>
<span>Results will be saved to history.</span>
<label for="call_notes" class="form-label fw-bold">Call Results & Notes</label>
<textarea class="form-control" id="call_notes" name="call_notes" rows="4" placeholder="Enter notes from the call..."></textarea>
<div class="form-text">These notes will be saved to the voter's interaction history.</div>
</div>
</div>
<div class="modal-footer border-0 pt-0 pb-4">
<button type="button" class="btn btn-light px-4 py-2 fw-semibold" data-bs-dismiss="modal">Cancel</button>
<button type="submit" id="submitCallBtn" class="btn btn-success px-5 py-2 fw-bold shadow-sm" disabled>Save & Complete</button>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary px-4" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success px-4">Save Call & Complete</button>
</div>
</form>
</div>
@ -317,109 +128,31 @@
</div>
<script>
function copyToClipboard(text, element) {
navigator.clipboard.writeText(text).then(() => {
const originalHtml = element.innerHTML;
element.innerHTML = '<i class="bi bi-check2 text-success"></i>';
setTimeout(() => {
element.innerHTML = originalHtml;
}, 2000);
}).catch(err => {
console.error('Failed to copy: ', err);
});
}
document.addEventListener('DOMContentLoaded', function() {
const callNotesModal = document.getElementById('callNotesModal');
if (callNotesModal) {
const outcomeBtns = callNotesModal.querySelectorAll('.outcome-btn');
const outcomeInput = callNotesModal.querySelector('#call_outcome');
const submitBtn = callNotesModal.querySelector('#submitCallBtn');
const callNotesTextarea = callNotesModal.querySelector('#call_notes');
const supportSelect = callNotesModal.querySelector('#candidate_support');
const yardSignSelect = callNotesModal.querySelector('#yard_sign');
outcomeBtns.forEach(btn => {
btn.addEventListener('click', function() {
outcomeBtns.forEach(b => b.classList.remove('active'));
this.classList.add('active');
outcomeInput.value = this.getAttribute('data-outcome');
submitBtn.disabled = false;
});
});
callNotesModal.addEventListener('show.bs.modal', function(event) {
const button = event.relatedTarget;
const callId = button.getAttribute('data-call-id');
const voterName = button.getAttribute('data-voter-name');
const supportStatus = button.getAttribute('data-support-status');
const yardSign = button.getAttribute('data-yard-sign');
const modalVoterName = callNotesModal.querySelector('#modalVoterName');
const form = callNotesModal.querySelector('#completeCallForm');
modalVoterName.textContent = voterName;
if (supportSelect) supportSelect.value = supportStatus || 'unknown';
if (yardSignSelect) yardSignSelect.value = 'no_change';
// Construct the URL dynamically. The placeholder '0' will be replaced by the actual callId.
let baseUrl = "{% url 'complete_call' 0 %}";
form.action = baseUrl.replace('0', callId);
outcomeInput.value = '';
callNotesTextarea.value = '';
submitBtn.disabled = true;
outcomeBtns.forEach(b => b.classList.remove('active'));
// Clear the notes textarea each time the modal opens
callNotesModal.querySelector('#call_notes').value = '';
});
// Focus on the textarea when the modal is shown
callNotesModal.addEventListener('shown.bs.modal', function () {
callNotesModal.querySelector('#call_notes').focus();
});
}
});
</script>
<!-- Populate Queue Modal -->
<div class="modal fade" id="populateQueueModal" tabindex="-1" aria-labelledby="populateQueueModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg">
<form action="{% url 'populate_call_queue' %}" method="POST">
{% csrf_token %}
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold" id="populateQueueModalLabel">Populate Call Queue</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-3">
{% if is_staff %}
<div class="mb-3">
<label for="volunteer_id" class="form-label fw-bold small text-uppercase text-muted">Assign to Volunteer</label>
<select name="volunteer_id" id="volunteer_id" class="form-select border-0 bg-light fw-semibold py-2">
{% for volunteer in volunteers %}
<option value="{{ volunteer.id }}" {% if selected_volunteer_id == volunteer.id|stringformat:"s" %}selected{% endif %}>
{{ volunteer }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="mb-3">
<label for="num_voters" class="form-label fw-bold small text-uppercase text-muted">Number of Voters</label>
<select name="num_voters" id="num_voters" class="form-select border-0 bg-light fw-semibold py-2">
<option value="5">5 Voters</option>
<option value="10">10 Voters</option>
<option value="25">25 Voters</option>
</select>
</div>
<div class="mb-3">
<label for="district" class="form-label fw-bold small text-uppercase text-muted">District (Optional)</label>
<input type="text" name="district" id="district" class="form-control border-0 bg-light fw-medium py-2" placeholder="e.g., 12">
</div>
<div class="mb-3 form-check ms-1">
<input type="checkbox" name="include_door_visits" id="include_door_visits" class="form-check-input">
<label class="form-check-label fw-bold small text-uppercase text-muted" for="include_door_visits">Include Door Visits</label>
</div>
</div>
<div class="modal-footer border-0 pt-0 pb-4">
<button type="button" class="btn btn-light px-4 py-2 fw-semibold" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-5 py-2 fw-bold shadow-sm">Start Populating</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -23,7 +23,6 @@
<div class="card-body p-3 p-md-4">
<h5 class="card-title fw-bold mb-3">Filter by Date Range</h5>
<form method="get" class="row g-2">
{% if selected_volunteer_id %}<input type="hidden" name="volunteer" value="{{ selected_volunteer_id }}">{% endif %}
<div class="col-6 col-sm-5">
<label class="small text-muted mb-1 ms-1">From</label>
<input type="date" name="start_date" class="form-control rounded-pill border-light bg-light" value="{{ start_date|default:'' }}">
@ -37,7 +36,7 @@
</div>
{% if start_date or end_date %}
<div class="col-12 mt-1">
<a href="?{% if selected_volunteer_id %}volunteer={{ selected_volunteer_id }}{% endif %}" class="text-secondary small ms-1">
<a href="{% url 'door_visit_history' %}" class="text-secondary small ms-1">
<i class="bi bi-x-circle me-1"></i>Clear range
</a>
</div>
@ -51,23 +50,15 @@
<div class="col-lg-7">
<div class="card border-0 shadow-sm rounded-4 h-100">
<div class="card-body p-3 p-md-4">
<h5 class="card-title fw-bold mb-3 d-flex justify-content-between align-items-center">
<span>Visits per Volunteer</span>
{% if selected_volunteer_id %}
<a href="?{% if start_date %}start_date={{ start_date }}&{% endif %}{% if end_date %}end_date={{ end_date }}{% endif %}" class="small text-muted fw-normal text-decoration-none">
<i class="bi bi-x-circle me-1"></i>Clear filter
</a>
{% endif %}
</h5>
<h5 class="card-title fw-bold mb-3">Visits per Volunteer</h5>
<div class="d-flex flex-wrap gap-2">
{% for v in volunteer_counts %}
<a href="?volunteer={{ v.id }}{% if start_date %}&start_date={{ start_date }}{% endif %}{% if end_date %}&end_date={{ end_date }}{% endif %}"
class="text-decoration-none bg-light rounded-pill px-3 py-2 d-flex align-items-center border {% if selected_volunteer_id == v.id %}border-primary shadow-sm bg-primary-subtle{% endif %}">
<span class="fw-medium text-dark me-2 small">{{ v.name }}</span>
<span class="badge bg-primary rounded-pill">{{ v.count }}</span>
</a>
{% for v_name, count in volunteer_counts %}
<div class="bg-light rounded-pill px-3 py-2 d-flex align-items-center border">
<span class="fw-medium text-dark me-2 small">{{ v_name }}</span>
<span class="badge bg-primary rounded-pill">{{ count }}</span>
</div>
{% empty %}
<p class="text-muted small mb-0">No volunteer data available.</p>
<p class="text-muted small mb-0">No volunteer data available for this selection.</p>
{% endfor %}
</div>
</div>
@ -90,6 +81,7 @@
<th class="ps-3 ps-md-4 py-3 text-uppercase small ls-1">Household Address</th>
<th class="py-3 text-uppercase small ls-1 d-none d-md-table-cell">Voters Visited</th>
<th class="py-3 text-uppercase small ls-1">Last Visit</th>
<th class="py-3 text-uppercase small ls-1 d-none d-sm-table-cell">Volunteer</th>
<th class="py-3 text-uppercase small ls-1">Outcome</th>
<th class="pe-3 pe-md-4 py-3 text-uppercase small ls-1 d-none d-lg-table-cell">Comments</th>
</tr>
@ -127,10 +119,20 @@
</div>
</td>
<td>
<div class="text-dark small">
<span class="fw-bold">{{ household.last_visit_date|date:"M d, Y H:i" }}</span>
<div class="text-muted">({{ household.last_volunteer|default:"N/A" }})</div>
<div class="fw-bold text-dark small">{{ household.last_visit_date|date:"M d, Y" }}</div>
<div class="small text-muted d-none d-sm-block">{{ household.last_visit_date|date:"H:i" }}</div>
</td>
<td class="d-none d-sm-table-cell">
{% if household.last_volunteer %}
<div class="d-flex align-items-center">
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-flex align-items-center justify-content-center me-2 d-none d-md-flex" style="width: 32px; height: 32px;">
{{ household.last_volunteer.first_name|first }}{{ household.last_volunteer.last_name|first }}
</div>
<span class="fw-medium small">{{ household.last_volunteer }}</span>
</div>
{% else %}
<span class="text-muted small">N/A</span>
{% endif %}
</td>
<td>
<span class="badge {% if 'Spoke' in household.last_outcome %}bg-success{% elif 'Literature' in household.last_outcome %}bg-info{% else %}bg-secondary{% endif %} bg-opacity-10 {% if 'Spoke' in household.last_outcome %}text-success{% elif 'Literature' in household.last_outcome %}text-info{% else %}text-secondary{% endif %} px-2 py-1 small">
@ -145,7 +147,7 @@
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center py-5">
<td colspan="6" class="text-center py-5">
<div class="text-muted mb-2">
<i class="bi bi-calendar-x mb-2" style="font-size: 3rem; opacity: 0.3;"></i>
</div>
@ -164,12 +166,12 @@
<ul class="pagination pagination-sm justify-content-center mb-0">
{% if history.has_previous %}
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page=1{% if start_date %}&start_date={{ start_date }}{% endif %}{% if end_date %}&end_date={{ end_date }}{% endif %}{% if selected_volunteer_id %}&volunteer={{ selected_volunteer_id }}{% endif %}" aria-label="First">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page=1{% if start_date %}&start_date={{ start_date }}{% endif %}{% if end_date %}&end_date={{ end_date }}{% endif %}" aria-label="First">
<i class="bi bi-chevron-double-left"></i>
</a>
</li>
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ history.previous_page_number }}{% if start_date %}&start_date={{ start_date }}{% endif %}{% if end_date %}&end_date={{ end_date }}{% endif %}{% if selected_volunteer_id %}&volunteer={{ selected_volunteer_id }}{% endif %}" aria-label="Previous">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ history.previous_page_number }}{% if start_date %}&start_date={{ start_date }}{% endif %}{% if end_date %}&end_date={{ end_date }}{% endif %}" aria-label="Previous">
<i class="bi bi-chevron-left"></i>
</a>
</li>
@ -179,12 +181,12 @@
{% if history.has_next %}
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ history.next_page_number }}{% if start_date %}&start_date={{ start_date }}{% endif %}{% if end_date %}&end_date={{ end_date }}{% endif %}{% if selected_volunteer_id %}&volunteer={{ selected_volunteer_id }}{% endif %}" aria-label="Next">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ history.next_page_number }}{% if start_date %}&start_date={{ start_date }}{% endif %}{% if end_date %}&end_date={{ end_date }}{% endif %}" aria-label="Next">
<i class="bi bi-chevron-right"></i>
</a>
</li>
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ history.paginator.num_pages }}{% if start_date %}&start_date={{ start_date }}{% endif %}{% if end_date %}&end_date={{ end_date }}{% endif %}{% if selected_volunteer_id %}&volunteer={{ selected_volunteer_id }}{% endif %}" aria-label="Last">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ history.paginator.num_pages }}{% if start_date %}&start_date={{ start_date }}{% endif %}{% if end_date %}&end_date={{ end_date }}{% endif %}" aria-label="Last">
<i class="bi bi-chevron-double-right"></i>
</a>
</li>
@ -212,9 +214,6 @@
.text-info {
color: #055160 !important;
}
.bg-primary-subtle {
background-color: #cfe2ff !important;
}
.voter-badge:hover {
background-color: #e9ecef !important;
border-color: #adb5bd !important;

View File

@ -46,7 +46,7 @@
</div>
<div class="col-12 col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary w-100 rounded-3">Filter</button>
<a href="." class="btn btn-light w-100 rounded-3" onclick="localStorage.removeItem('door_map_lat'); localStorage.removeItem('door_map_lng'); localStorage.removeItem('door_map_zoom');">Reset</a>
<a href="." class="btn btn-light w-100 rounded-3">Reset</a>
</div>
</form>
</div>
@ -74,9 +74,15 @@
{% for household in households %}
<tr>
<td class="ps-4">
<a href="{% url 'log_door_visit' %}?address_street={{ household.address_street|urlencode }}&city={{ household.city|urlencode }}&state={{ household.state|urlencode }}&zip_code={{ household.zip_code|urlencode }}&next_query_string={{ request.GET.urlencode|urlencode }}" class="btn btn-sm btn-primary px-3 shadow-sm py-2">
<button type="button" class="btn btn-sm btn-primary px-3 shadow-sm py-2"
data-bs-toggle="modal" data-bs-target="#logVisitModal"
data-address="{{ household.address_street }}"
data-city="{{ household.city }}"
data-state="{{ household.state }}"
data-zip="{{ household.zip_code }}"
data-voters="{{ household.voters_json_str }}">
Log Visit
</a>
</button>
</td>
<td>
<a href="{% url 'log_door_visit' %}?address_street={{ household.address_street|urlencode }}&city={{ household.city|urlencode }}&state={{ household.state|urlencode }}&zip_code={{ household.zip_code|urlencode }}&next_query_string={{ request.GET.urlencode|urlencode }}" class="text-decoration-none">
@ -174,12 +180,7 @@
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
{% if map_limit_reached %}
<div class="alert alert-warning border-0 rounded-0 mb-0 d-flex align-items-center py-2 px-4 shadow-sm" style="z-index: 10; position: relative;">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<span class="small">Showing first 3,000 households. Please use filters to narrow down the results for a complete view.</span>
</div>
{% endif %}
<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">
@ -191,6 +192,93 @@
</div>
</div>
</div>
<!-- Log Visit Modal -->
<div class="modal fade" id="logVisitModal" tabindex="-1" aria-labelledby="logVisitModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content rounded-4 border-0 shadow-lg mx-2 mx-md-0">
<div class="modal-header bg-light border-0 py-3">
<h5 class="modal-title fw-bold text-dark" id="logVisitModalLabel">Log Door Visit</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'log_door_visit' %}" method="POST">
{% csrf_token %}
<div class="modal-body p-3 p-md-4">
<div class="bg-primary-subtle p-3 rounded-3 mb-3 mb-md-4 border border-primary-subtle">
<div class="small text-uppercase fw-bold text-primary mb-1">Household Address</div>
<div id="modalAddressDisplay" class="h6 h5-md mb-0 fw-bold text-dark"></div>
</div>
<input type="hidden" name="address_street" id="modal_address_street">
<input type="hidden" name="city" id="modal_city">
<input type="hidden" name="state" id="modal_state">
<input type="hidden" name="zip_code" id="modal_zip_code">
<input type="hidden" name="next_query_string" value="{{ request.GET.urlencode }}">
<div class="mb-4">
<label class="form-label fw-bold text-primary small text-uppercase">Visit Outcome</label>
<div class="row g-2">
{% for radio in visit_form.outcome %}
<div class="col-6 col-md-4">
{{ radio.tag }}
<label class="btn btn-outline-primary w-100 h-100 d-flex align-items-center justify-content-center text-center py-2 px-2 small" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
<div class="mb-4">
<label for="{{ visit_form.notes.id_for_label }}" class="form-label fw-bold text-primary small text-uppercase">Notes / Conversation Summary</label>
{{ visit_form.notes }}
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-6">
<label class="form-label fw-bold text-primary small text-uppercase">Support Status</label>
{{ visit_form.candidate_support }}
</div>
<div class="col-12 col-md-6 d-flex align-items-end">
<div class="form-check mb-2">
{{ visit_form.wants_yard_sign }}
<label class="form-check-label fw-bold text-dark" for="{{ visit_form.wants_yard_sign.id_for_label }}">
Wants a Yard Sign
</label>
</div>
</div>
</div>
<hr class="my-4">
<div class="bg-light p-3 rounded-3">
<div class="form-check mb-3">
{{ visit_form.follow_up }}
<label class="form-check-label fw-bold text-dark" for="{{ visit_form.follow_up.id_for_label }}">
Schedule a Follow-up Call
</label>
</div>
<div id="callNotesContainer" style="display: none;">
<div class="mb-3">
<label for="{{ visit_form.follow_up_voter.id_for_label }}" class="form-label fw-bold text-primary small text-uppercase">Recipient of the Call</label>
{{ visit_form.follow_up_voter }}
</div>
<div class="mb-3">
<label for="{{ visit_form.call_notes.id_for_label }}" class="form-label fw-bold text-primary small text-uppercase">Call Queue Notes</label>
{{ visit_form.call_notes }}
<div class="form-text small">These notes will be added to the call queue for the default caller.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer bg-light border-0 py-3">
<button type="button" class="btn btn-outline-secondary px-4" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-4 shadow-sm">Save Visit</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
@ -252,24 +340,13 @@
return;
}
// Restore map state from localStorage if available
var savedLat = localStorage.getItem('door_map_lat');
var savedLng = localStorage.getItem('door_map_lng');
var savedZoom = localStorage.getItem('door_map_zoom');
var restored = false;
var mapOptions = {
zoom: savedZoom ? parseInt(savedZoom) : 12,
center: (savedLat && savedLng) ? { lat: parseFloat(savedLat), lng: parseFloat(savedLng) } : { lat: 41.8781, lng: -87.6298 },
zoom: 12,
center: { lat: 41.8781, lng: -87.6298 }, // Default to Chicago if no data
mapTypeControl: true,
streetViewControl: true,
fullscreenControl: true
};
if (savedLat && savedLng && savedZoom) {
restored = true;
}
map = new google.maps.Map(document.getElementById('map'), mapOptions);
var bounds = new google.maps.LatLngBounds();
@ -304,19 +381,10 @@
}
});
// Only fit bounds if we didn't restore a saved view and have markers
if (markers.length > 0 && !restored) {
if (markers.length > 0) {
map.fitBounds(bounds);
}
// Save map state whenever it changes
map.addListener('idle', function() {
var center = map.getCenter();
localStorage.setItem('door_map_lat', center.lat());
localStorage.setItem('door_map_lng', center.lng());
localStorage.setItem('door_map_zoom', map.getZoom());
});
// Initial attempt to show user location
showUserLocation();
@ -349,11 +417,65 @@
} else {
if (window.google && window.google.maps) {
google.maps.event.trigger(map, 'resize');
// No fitBounds here to preserve user's last position during the session
if (markers.length > 0) {
var bounds = new google.maps.LatLngBounds();
markers.forEach(function(marker) {
bounds.extend(marker.getPosition());
});
map.fitBounds(bounds);
}
}
}
});
}
var logVisitModal = document.getElementById('logVisitModal');
if (logVisitModal) {
logVisitModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
if (!button) return;
var address = button.getAttribute('data-address');
var city = button.getAttribute('data-city');
var state = button.getAttribute('data-state');
var zip = button.getAttribute('data-zip');
document.getElementById('modalAddressDisplay').textContent = (address || '') + ', ' + (city || '') + ', ' + (state || '') + ' ' + (zip || '');
document.getElementById('modal_address_street').value = address || '';
document.getElementById('modal_city').value = city || '';
document.getElementById('modal_state').value = state || '';
document.getElementById('modal_zip_code').value = zip || '';
// Populate voters dropdown
var votersJson = button.getAttribute('data-voters');
if (votersJson) {
try {
var voters = JSON.parse(votersJson);
var voterSelect = document.getElementById('{{ visit_form.follow_up_voter.id_for_label }}');
if (voterSelect) {
voterSelect.innerHTML = '';
voters.forEach(function(voter) {
var option = document.createElement('option');
option.value = voter.id;
option.textContent = voter.name;
voterSelect.appendChild(option);
});
}
} catch (e) {
console.error("Error parsing voters JSON:", e);
}
}
});
}
// Toggle call notes visibility
const followUpCheckbox = document.getElementById('{{ visit_form.follow_up.id_for_label }}');
const callNotesContainer = document.getElementById('callNotesContainer');
if (followUpCheckbox && callNotesContainer) {
followUpCheckbox.addEventListener('change', function() {
callNotesContainer.style.display = this.checked ? 'block' : 'none';
});
}
});
</script>
@ -421,4 +543,4 @@
}
</style>
{% endblock %}
{% endblock %}

View File

@ -42,13 +42,11 @@
</div>
</form>
</div>
{% if not is_block_walker or is_staff %}
<div class="col-12 col-md-4 col-lg-3 mt-2 mt-md-0">
<a href="{% url 'voter_advanced_search' %}" class="btn btn-outline-primary shadow-sm w-100">
<i class="bi bi-sliders me-2"></i>Advanced Search
</a>
</div>
{% endif %}
</div>
<!-- Main Stats Row -->
@ -67,6 +65,7 @@
<div class="card-body p-4">
<h6 class="text-uppercase fw-bold small text-muted">Target Voters</h6>
<h2 class="mb-0 fw-bold text-dark">{{ metrics.total_target_voters }}</h2>
<a href="{% url 'voter_list' %}?is_targeted=true" class="small text-decoration-none mt-2 d-inline-block text-primary">View Targets &rarr;</a>
</div>
</div>
</div>
@ -75,6 +74,7 @@
<div class="card-body p-4">
<h6 class="text-uppercase fw-bold small text-muted">Supporting</h6>
<h2 class="mb-0 fw-bold text-success">{{ metrics.total_supporting }}</h2>
<a href="{% url 'voter_list' %}?support=supporting" class="small text-decoration-none mt-2 d-inline-block text-primary">View Supporters &rarr;</a>
</div>
</div>
</div>
@ -83,6 +83,7 @@
<div class="card-body p-4">
<h6 class="text-uppercase fw-bold small text-muted">Target Households</h6>
<h2 class="mb-0 fw-bold text-info">{{ metrics.total_target_households }}</h2>
<a href="{% url 'voter_list' %}?is_targeted=true&has_address=true" class="small text-decoration-none mt-2 d-inline-block text-primary">View Map &rarr;</a>
</div>
</div>
</div>
@ -97,16 +98,7 @@
<i class="bi bi-door-open-fill text-warning fs-3"></i>
</div>
<h6 class="text-uppercase fw-bold small text-muted mb-1">Door Visits</h6>
<div class="d-flex justify-content-around mt-2">
<div>
<div class="small text-muted">Visited</div>
<h4 class="mb-0 fw-bold">{{ metrics.total_door_visits }}</h4>
</div>
<div class="border-start ps-3">
<div class="small text-muted">Target</div>
<h4 class="mb-0 fw-bold">{{ metrics.total_target_door_visit_households }}</h4>
</div>
</div>
<h3 class="mb-0 fw-bold">{{ metrics.total_door_visits }}</h3>
<a href="{% url 'door_visit_history' %}" class="small text-decoration-none mt-2 d-inline-block text-primary">View Visits &rarr;</a>
</div>
</div>
@ -118,39 +110,8 @@
<i class="bi bi-signpost-2-fill text-danger fs-3"></i>
</div>
<h6 class="text-uppercase fw-bold small text-muted mb-1">Signs</h6>
<div class="d-flex justify-content-around mt-2">
<div>
<div class="small text-muted">Has</div>
<h4 class="mb-0 fw-bold">{{ metrics.total_has_signs }}</h4>
<a href="{% url 'view_signs' %}" class="small text-decoration-none d-block text-primary mt-1">View Existing &rarr;</a>
</div>
<div class="border-start ps-3">
<div class="small text-muted">Wants</div>
<h4 class="mb-0 fw-bold">{{ metrics.total_wants_signs }}</h4>
<a href="{% url 'yard_sign_voters' %}" class="small text-decoration-none d-block text-primary mt-1">Requests &rarr;</a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-4 text-center">
<div class="bg-success bg-opacity-10 p-3 rounded-circle d-inline-block mb-3">
<i class="bi bi-telephone-fill text-success fs-3"></i>
</div>
<h6 class="text-uppercase fw-bold small text-muted mb-1">Call Queue</h6>
<div class="d-flex justify-content-around mt-2">
<div>
<div class="small text-muted">To be Called</div>
<h4 class="mb-0 fw-bold">{{ metrics.total_to_be_called }}</h4>
</div>
<div class="border-start ps-3">
<div class="small text-muted">Called</div>
<h4 class="mb-0 fw-bold">{{ metrics.total_called }}</h4>
</div>
</div>
<a href="{% url 'call_queue' %}" class="small text-decoration-none mt-2 d-inline-block text-primary">View Queue &rarr;</a>
<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>
@ -161,16 +122,7 @@
<i class="bi bi-window-sidebar text-primary fs-3"></i>
</div>
<h6 class="text-uppercase fw-bold small text-muted mb-1">Window Stickers</h6>
<div class="d-flex justify-content-around mt-2">
<div>
<div class="small text-muted">Has</div>
<h4 class="mb-0 fw-bold">{{ metrics.total_has_window_stickers }}</h4>
</div>
<div class="border-start ps-3">
<div class="small text-muted">Wants</div>
<h4 class="mb-0 fw-bold">{{ metrics.total_wants_window_stickers }}</h4>
</div>
</div>
<h3 class="mb-0 fw-bold">{{ metrics.total_window_stickers }}</h3>
</div>
</div>
</div>
@ -191,7 +143,6 @@
<!-- Bottom Row: Other Metrics & Lists -->
<div class="row g-4">
{% if not is_block_walker or is_staff %}
<div class="col-lg-8">
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
@ -239,10 +190,8 @@
</div>
</div>
</div>
{% endif %}
<div class="{% if not is_block_walker or is_staff %}col-lg-4{% else %}col-lg-12{% endif %}">
{% if not is_block_walker or is_staff %}
<div class="col-lg-4">
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-bold">Upcoming Events</h5>
@ -270,7 +219,6 @@
</div>
</div>
</div>
{% endif %}
<!-- Campaign Overview -->
<div class="card border-0 shadow-sm">
@ -286,20 +234,14 @@
<span class="text-muted">Total Interactions</span>
<span class="fw-bold">{{ metrics.interactions_count }}</span>
</div>
{% if not is_block_walker or is_staff %}
<div class="d-flex justify-content-between mb-3">
<div class="d-flex justify-content-between mb-0">
<span class="text-muted">Total Events</span>
<span class="fw-bold">{{ metrics.events_count }}</span>
</div>
<div class="d-flex justify-content-between mb-0">
<span class="text-muted">Event Attendees</span>
<span class="fw-bold">{{ metrics.total_event_attendees }}</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% endblock %}

View File

@ -23,7 +23,7 @@
<div class="text-muted">{{ city }}, {{ state }} {{ zip_code }}</div>
</div>
<form action="{% url 'log_door_visit' %}" method="POST" id="logVisitForm">
<form action="{% url 'log_door_visit' %}" method="POST">
{% csrf_token %}
<input type="hidden" name="address_street" value="{{ address_street }}">
<input type="hidden" name="city" value="{{ city }}">
@ -56,9 +56,13 @@
<label class="form-label fw-bold text-primary small text-uppercase">Support Status</label>
{{ visit_form.candidate_support }}
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-bold text-primary small text-uppercase">Yard Sign Status</label>
{{ visit_form.yard_sign_status }}
<div class="col-12 col-md-6 d-flex align-items-end">
<div class="form-check mb-2">
{{ visit_form.wants_yard_sign }}
<label class="form-check-label fw-bold text-dark" for="{{ visit_form.wants_yard_sign.id_for_label }}">
Wants a Yard Sign
</label>
</div>
</div>
</div>
@ -86,7 +90,7 @@
<div class="mt-5 d-grid d-md-flex justify-content-md-end gap-2">
<a href="{{ redirect_url }}" class="btn btn-light px-5 py-3 rounded-3 fw-bold">Cancel</a>
<button type="submit" class="btn btn-primary px-5 py-3 rounded-3 fw-bold shadow" id="saveVisitBtn">Save Visit</button>
<button type="submit" class="btn btn-primary px-5 py-3 rounded-3 fw-bold shadow">Save Visit</button>
</div>
</form>
</div>
@ -121,16 +125,6 @@
callNotesContainer.style.display = this.checked ? 'block' : 'none';
});
}
const visitForm = document.getElementById('logVisitForm');
const saveVisitBtn = document.getElementById('saveVisitBtn');
if (visitForm && saveVisitBtn) {
visitForm.addEventListener('submit', function() {
saveVisitBtn.disabled = true;
saveVisitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span> Saving...';
});
}
});
</script>
@ -153,4 +147,4 @@
border-color: #9ec5fe !important;
}
</style>
{% endblock %}
{% endblock %}

View File

@ -1,378 +0,0 @@
{% 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">View Signs</h1>
<p class="text-muted mb-0">View households that already have 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-2">
<label class="form-label small fw-bold text-uppercase text-muted">Sign Type</label>
<select name="sign_type" class="form-select rounded-3">
<option value="">All Types</option>
<option value="yard" {% if sign_type_filter == "yard" %}selected{% endif %}>Standard Yard Sign</option>
<option value="large" {% if sign_type_filter == "large" %}selected{% endif %}>Large Sign</option>
</select>
</div>
<div class="col-12 col-md-2">
<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="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 with 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 with Sign</th>
<th class="py-3 text-uppercase small ls-1">Sign Type</th>
<th class="py-3 text-uppercase small ls-1">Voter Notes</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 py-3">
<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 rounded-3">
{{ household.neighborhood }}
</span>
{% endif %}
</div>
</td>
<td>
<div class="d-flex flex-wrap gap-1">
{% for voter in household.voters_with_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>
<span class="badge rounded-pill px-3 py-2 {% if household.has_large %}bg-warning-subtle text-warning border border-warning-subtle{% else %}bg-success-subtle text-success border border-success-subtle{% endif %} fw-semibold">
{{ household.sign_types_display }}
</span>
</td>
<td>
{% for voter in household.voters_with_sign %}
{% if voter.notes %}
<div class="small mb-1"><span class="fw-bold text-dark small">{{ voter.first_name }}:</span> {{ voter.notes }}</div>
{% endif %}
{% endfor %}
</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 small rounded-3">
{{ household.neighborhood }}
</span>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center py-5 text-muted">
<i class="bi bi-info-circle me-1"></i> No records found with current filters.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if households.has_other_pages %}
<div class="card-footer bg-white py-4 border-0">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0 gap-2">
{% if households.has_previous %}
<li class="page-item">
<a class="page-link rounded-3 border-0 bg-light text-dark shadow-sm px-3" href="?page={{ households.previous_page_number }}&district={{ district_filter }}&neighborhood={{ neighborhood_filter }}&address={{ address_filter }}&city={{ city_filter }}&sign_type={{ sign_type_filter }}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for i in households.paginator.page_range %}
{% if households.number == i %}
<li class="page-item active"><span class="page-link rounded-3 border-0 bg-danger text-white shadow-sm px-3">{{ i }}</span></li>
{% elif i > households.number|add:'-3' and i < households.number|add:'3' %}
<li class="page-item">
<a class="page-link rounded-3 border-0 bg-light text-dark shadow-sm px-3" href="?page={{ i }}&district={{ district_filter }}&neighborhood={{ neighborhood_filter }}&address={{ address_filter }}&city={{ city_filter }}&sign_type={{ sign_type_filter }}">
{{ i }}
</a>
</li>
{% endif %}
{% endfor %}
{% if households.has_next %}
<li class="page-item">
<a class="page-link rounded-3 border-0 bg-light text-dark shadow-sm px-3" href="?page={{ households.next_page_number }}&district={{ district_filter }}&neighborhood={{ neighborhood_filter }}&address={{ address_filter }}&city={{ city_filter }}&sign_type={{ sign_type_filter }}">
<i class="bi bi-chevron-right"></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-xl modal-dialog-centered">
<div class="modal-content border-0 rounded-4 overflow-hidden shadow-lg">
<div class="modal-header bg-danger text-white py-3 border-0">
<h5 class="modal-title fw-bold" id="mapModalLabel">Sign Locations 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 position-relative">
{% if map_limit_reached %}
<div class="alert alert-warning border-0 rounded-0 mb-0 d-flex align-items-center" style="z-index: 1000;">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<div>
<strong>Map limit reached:</strong> Showing first 3,000 households. Please use filters to narrow down the results.
</div>
</div>
{% endif %}
<div id="map" style="height: 600px; width: 100%;">
<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>
<style>
.ls-1 { letter-spacing: 0.05em; }
.hover-underline:hover { text-decoration: underline !important; }
.pagination .page-link:hover { background-color: #f8f9fa; }
.form-control:focus, .form-select:focus {
border-color: #dc3545;
box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.1);
}
#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>
<script>
let map;
let markers = [];
let userLocationMarker;
const 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() {
const center = { lat: 41.8781, lng: -87.6298 }; // Default to Chicago if no data
map = new google.maps.Map(document.getElementById("map"), {
zoom: 12,
center: center,
mapTypeControl: true,
streetViewControl: true,
fullscreenControl: true,
styles: [
{
"featureType": "poi",
"stylers": [{ "visibility": "off" }]
}
]
});
const infoWindow = new google.maps.InfoWindow();
const bounds = new google.maps.LatLngBounds();
let hasPoints = false;
mapData.forEach(point => {
if (point.lat && point.lng) {
// Color coding for existing signs: Green (Standard) vs Yellow (Large)
const markerColor = point.is_large ? "#ffc107" : "#198754";
const label = point.is_large ? "Large Sign" : "Yard Sign";
const marker = new google.maps.Marker({
position: { lat: point.lat, lng: point.lng },
map: map,
title: point.address,
icon: {
path: google.maps.SymbolPath.CIRCLE,
fillColor: markerColor,
fillOpacity: 0.9,
strokeWeight: 1,
strokeColor: "#ffffff",
scale: 8
}
});
marker.addListener("click", () => {
let votersHtml = point.voters.map(v =>
`<a href="/voters/${v.id}/" class="text-danger text-decoration-none hover-underline fw-bold">${v.name}</a>`
).join(', ');
const contentString = `
<div class="p-2">
<h6 class="fw-bold mb-1">${point.address}</h6>
<p class="small text-muted mb-2"><strong>${label}</strong></p>
<p class="small mb-0"><strong>Voters:</strong> ${votersHtml}</p>
</div>
`;
infoWindow.setContent(contentString);
infoWindow.open(map, marker);
});
markers.push(marker);
bounds.extend(marker.getPosition());
hasPoints = true;
}
});
if (hasPoints) {
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);
});
}
}
// Initialize map when modal is shown
document.getElementById('mapModal').addEventListener('shown.bs.modal', function () {
if (!map) {
const script = document.createElement('script');
script.src = `https://maps.googleapis.com/maps/api/js?key={{ GOOGLE_MAPS_API_KEY }}&callback=initMap`;
script.async = true;
document.head.appendChild(script);
} 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>
{% endblock %}

View File

@ -1,17 +1,34 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Volunteer Directory</h1>
<div class="d-flex gap-2">
<a href="{% url 'bulk_task_list' %}" class="btn btn-outline-primary btn-sm"><i class="bi bi-list-task me-1"></i> Bulk Operations</a>
<a href="{% url 'volunteer_add' %}" class="btn btn-primary btn-sm">+ Add New Volunteer</a>
</div>
</div>
{{ task.get_task_type_display }}
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<form action="." method="GET" class="row g-3">
<div class="col-md-6">
<input type="text" name="q" class="form-control" placeholder="Search by name or email..." value="{{ query|default:'' }}">
</div>
<div class="col-md-4">
<select name="interest" id="interest-filter" class="form-select">
<option value="">All Interests</option>
{% for interest in interests %}
<option value="{{ interest.id }}" {% if interest.id|stringformat:"s" == selected_interest %}selected{% endif %}>
{{ interest.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100 h-100">Filter</button>
</div>
</form>
</div>
</div>
@ -21,10 +38,10 @@
<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>
<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>
<div class="table-responsive">
@ -176,12 +193,6 @@
<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="form-check mb-3">
<input class="form-check-input" type="checkbox" name="is_html" id="is_html">
<label class="form-check-label small fw-bold text-muted" for="is_html">
Send as HTML
</label>
</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>
@ -246,28 +257,6 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
const smsForm = document.getElementById("sms-form");
if (smsForm) {
smsForm.addEventListener("submit", function() {
const submitBtn = smsForm.querySelector("button[type=\"submit\"]");
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = `<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span> Sending...`;
}
});
}
const emailForm = document.getElementById("email-form");
if (emailForm) {
emailForm.addEventListener("submit", function() {
const submitBtn = emailForm.querySelector("button[type=\"submit\"]");
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = `<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span> Sending...`;
}
});
}
const emailModal = document.getElementById('emailModal');
if (emailModal) {
emailModal.addEventListener('show.bs.modal', function () {

View File

@ -1,261 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container py-5">
<div class="mb-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'voter_list' %}">Voters</a></li>
<li class="breadcrumb-item active" aria-current="page">Add New Voter</li>
</ol>
</nav>
<h1 class="h2 mb-0">Add New Voter</h1>
</div>
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form method="POST">
{% csrf_token %}
<div class="row">
<h5 class="fw-bold mb-3 border-bottom pb-2">Core Information</h5>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ form.first_name.label }}</label>
{{ form.first_name }}
{% if form.first_name.errors %}<div class="text-danger small">{{ form.first_name.errors }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ form.last_name.label }}</label>
{{ form.last_name }}
{% if form.last_name.errors %}<div class="text-danger small">{{ form.last_name.errors }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ form.nickname.label }}</label>
{{ form.nickname }}
{% if form.nickname.errors %}<div class="text-danger small">{{ form.nickname.errors }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ form.voter_id.label }}</label>
{{ form.voter_id }}
{% if form.voter_id.errors %}<div class="text-danger small">{{ form.voter_id.errors }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ form.birthdate.label }}</label>
{{ form.birthdate }}
{% if form.birthdate.errors %}<div class="text-danger small">{{ form.birthdate.errors }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ form.registration_date.label }}</label>
{{ form.registration_date }}
{% if form.registration_date.errors %}<div class="text-danger small">{{ form.registration_date.errors }}</div>{% endif %}
</div>
<h5 class="fw-bold mb-3 mt-4 border-bottom pb-2">Address</h5>
<div class="col-md-12 mb-3">
<label class="form-label fw-medium">Address Street</label>
{{ form.address_street }}
{% if form.address_street.errors %}<div class="text-danger small">{{ form.address_street.errors }}</div>{% endif %}
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">City</label>
{{ form.city }}
{% if form.city.errors %}<div class="text-danger small">{{ form.city.errors }}</div>{% endif %}
</div>
<div class="col-md-2 mb-3">
<label class="form-label fw-medium">State</label>
{{ form.state }}
{% if form.state.errors %}<div class="text-danger small">{{ form.state.errors }}</div>{% endif %}
</div>
<div class="col-md-2 mb-3">
<label class="form-label fw-medium">Prior State</label>
{{ form.prior_state }}
{% if form.prior_state.errors %}<div class="text-danger small">{{ form.prior_state.errors }}</div>{% endif %}
</div>
<div class="col-md-2 mb-3">
<label class="form-label fw-medium">Zip Code</label>
{{ form.zip_code }}
{% if form.zip_code.errors %}<div class="text-danger small">{{ form.zip_code.errors }}</div>{% endif %}
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">County</label>
{{ form.county }}
{% if form.county.errors %}<div class="text-danger small">{{ form.county.errors }}</div>{% endif %}
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">{{ form.neighborhood.label }}</label>
{{ form.neighborhood }}
{% if form.neighborhood.errors %}<div class="text-danger small">{{ form.neighborhood.errors }}</div>{% endif %}
</div>
<div class="col-12 mb-4">
<button type="button" id="manualGeocodeBtn" class="btn btn-outline-primary w-100 fw-bold py-2 shadow-sm">
<i class="bi bi-geo-alt-fill me-2"></i>Manual Geocode Address
</button>
<div id="geocodeStatus" class="small mt-2 text-center" style="display: none;"></div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">Latitude</label>
{{ form.latitude }}
{% if form.latitude.errors %}<div class="text-danger small">{{ form.latitude.errors }}</div>{% endif %}
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">Longitude</label>
{{ form.longitude }}
{% if form.longitude.errors %}<div class="text-danger small">{{ form.longitude.errors }}</div>{% endif %}
</div>
<h5 class="fw-bold mb-3 mt-4 border-bottom pb-2">Contact Information</h5>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">{{ form.phone.label }}</label>
{{ form.phone }}
{% if form.phone.errors %}<div class="text-danger small">{{ form.phone.errors }}</div>{% endif %}
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">{{ form.phone_type.label }}</label>
{{ form.phone_type }}
{% if form.phone_type.errors %}<div class="text-danger small">{{ form.phone_type.errors }}</div>{% endif %}
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">{{ form.secondary_phone.label }}</label>
{{ form.secondary_phone }}
{% if form.secondary_phone.errors %}<div class="text-danger small">{{ form.secondary_phone.errors }}</div>{% endif %}
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-medium">{{ form.secondary_phone_type.label }}</label>
{{ form.secondary_phone_type }}
{% if form.secondary_phone_type.errors %}<div class="text-danger small">{{ form.secondary_phone_type.errors }}</div>{% endif %}
</div>
<div class="col-md-12 mb-3">
<label class="form-label fw-medium">{{ form.email.label }}</label>
{{ form.email }}
{% if form.email.errors %}<div class="text-danger small">{{ form.email.errors }}</div>{% endif %}
</div>
<h5 class="fw-bold mb-3 mt-4 border-bottom pb-2">Campaign Data</h5>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ form.district.label }}</label>
{{ form.district }}
{% if form.district.errors %}<div class="text-danger small">{{ form.district.errors }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ form.precinct.label }}</label>
{{ form.precinct }}
{% if form.precinct.errors %}<div class="text-danger small">{{ form.precinct.errors }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ form.candidate_support.label }}</label>
{{ form.candidate_support }}
{% if form.candidate_support.errors %}<div class="text-danger small">{{ form.candidate_support.errors }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ form.yard_sign.label }}</label>
{{ form.yard_sign }}
{% if form.yard_sign.errors %}<div class="text-danger small">{{ form.yard_sign.errors }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ form.window_sticker.label }}</label>
{{ form.window_sticker }}
{% if form.window_sticker.errors %}<div class="text-danger small">{{ form.window_sticker.errors }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ form.call_queue_status.label }}</label>
{{ form.call_queue_status }}
{% if form.call_queue_status.errors %}<div class="text-danger small">{{ form.call_queue_status.errors }}</div>{% endif %}
</div>
<div class="col-md-6 mb-3 pt-4">
<div class="form-check">
{{ form.is_targeted }}
<label class="form-check-label fw-medium" for="{{ form.is_targeted.id_for_label }}">
Targeted Voter
</label>
</div>
</div>
<div class="col-md-6 mb-3 pt-4">
<div class="form-check">
{{ form.target_door_visit }}
<label class="form-check-label fw-medium" for="{{ form.target_door_visit.id_for_label }}">
Target Door Visit
</label>
</div>
</div>
<div class="col-md-12 mb-3">
<label class="form-label fw-medium">Notes</label>
{{ form.notes }}
{% if form.notes.errors %}<div class="text-danger small">{{ form.notes.errors }}</div>{% endif %}
</div>
</div>
<div class="mt-4 d-flex gap-3 justify-content-end">
<a href="{% url 'voter_list' %}" class="btn btn-outline-secondary px-5 rounded-pill">Cancel</a>
<button type="submit" class="btn btn-primary px-5 rounded-pill shadow-sm">Add Voter</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Manual Geocode Logic
const geocodeBtn = document.getElementById('manualGeocodeBtn');
const statusDiv = document.getElementById('geocodeStatus');
if (geocodeBtn) {
geocodeBtn.addEventListener('click', function() {
const street = document.querySelector('[name="address_street"]').value;
const city = document.querySelector('[name="city"]').value;
const state = document.querySelector('[name="state"]').value;
const zip = document.querySelector('[name="zip_code"]').value;
if (!street && !city) {
statusDiv.innerHTML = '<span class="text-danger">Please enter at least a street or city.</span>';
statusDiv.style.display = 'block';
return;
}
geocodeBtn.disabled = true;
geocodeBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Geocoding...';
statusDiv.style.display = 'none';
const formData = new FormData();
formData.append('address_street', street);
formData.append('city', city);
formData.append('state', state);
formData.append('zip_code', zip);
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
// We use a dummy ID of 0 for geocoding a new voter if the backend supports it,
// or we might need a separate endpoint for geocoding arbitrary addresses.
// Let's check if voter_geocode requires an existing voter.
// In views.py: voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
// So it DOES require an existing voter.
// I should probably add a generic geocode endpoint or modify voter_geocode to handle ID 0.
statusDiv.innerHTML = '<span class="text-warning">Geocoding is available after saving the voter, or use a generic geocoder.</span>';
statusDiv.style.display = 'block';
geocodeBtn.disabled = false;
geocodeBtn.innerHTML = '<i class="bi bi-geo-alt-fill me-2"></i>Manual Geocode Address';
});
}
});
</script>
<style>
.form-control, .form-select {
border-color: #e5e7eb;
padding: 0.6rem 0.8rem;
}
.form-control:focus, .form-select:focus {
border-color: #059669;
box-shadow: 0 0 0 0.25rem rgba(5, 150, 105, 0.1);
}
</style>
{% endblock %}

View File

@ -1,20 +1,18 @@
{% extends "base.html" %}
{% load static %}
{% load core_tags %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Advanced Voter Search</h1>
<div class="d-flex gap-2">
<a href="{% url 'bulk_task_list' %}" class="btn btn-outline-primary btn-sm"><i class="bi bi-list-task me-1"></i> View Bulk Operations</a>
<a href="{% url 'voter_list' %}" class="btn btn-outline-secondary btn-sm">Back to Registry</a>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<form method="GET" class="row g-3">
<form action="." method="GET" class="row g-3">
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">First Name</label>
{{ form.first_name }}
@ -43,10 +41,6 @@
<label class="form-label small fw-bold text-muted">Zip Code</label>
{{ form.zip_code }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Neighborhood</label>
{{ form.neighborhood }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">District</label>
{{ form.district }}
@ -59,10 +53,6 @@
<label class="form-label small fw-bold text-muted">Email</label>
{{ form.email }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Phone Number</label>
{{ form.phone }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Phone Type</label>
{{ form.phone_type }}
@ -79,10 +69,6 @@
<label class="form-label small fw-bold text-muted">Window Sticker</label>
{{ form.window_sticker }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Call Queue Status</label>
{{ form.call_queue_status }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Min Total Donation</label>
{{ form.min_total_donation }}
@ -91,31 +77,15 @@
<label class="form-label small fw-bold text-muted">Max Total Donation</label>
{{ form.max_total_donation }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Is Targeted</label>
{{ form.is_targeted }}
<div class="col-md-4 d-flex align-items-end">
<div class="form-check mb-2">
{{ form.is_targeted }}
<label class="form-check-label ms-1" for="{{ form.is_targeted.id_for_label }}">
Targeted Only
</label>
</div>
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Target Door Visit</label>
{{ form.target_door_visit }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Voted</label>
{{ form.voted }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Door Visited</label>
{{ form.door_visit }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Ever Had Yard Sign</label>
{{ form.ever_had_yard_sign }}
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Ever Had Large Sign</label>
{{ form.ever_had_large_sign }}
</div>
<div class="col-12 text-end mt-4">
<div class="col-12 text-end">
<a href="{% url 'voter_advanced_search' %}" class="btn btn-light me-2">Clear Filters</a>
<button type="submit" class="btn btn-primary px-4">Search Voters</button>
</div>
@ -131,41 +101,34 @@
<input type="hidden" name="filter_{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}
<input type="hidden" name="select_all_results" id="select-all-results-hidden" value="false">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-3 border-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-bold">Search Results ({{ voters.paginator.count }})</h5>
{% if can_edit_voter %}
<div class="d-flex align-items-center">
<div id="bulk-actions" class="d-none me-3 align-items-center">
<span class="badge bg-primary-subtle text-primary border border-primary-subtle me-3 py-2 px-3" id="selection-count">
0 selected
</span>
<div id="bulk-actions" class="d-none me-2">
<button type="submit" name="action" value="export_selected" class="btn btn-primary btn-sm">
<i class="bi bi-file-earmark-spreadsheet me-1"></i> Export Selected
</button>
<button type="button" class="btn btn-primary btn-sm ms-2" data-bs-toggle="modal" data-bs-target="#bulkCallModal">
<i class="bi bi-telephone-plus me-1"></i> Call Selected
</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>
<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>
<button type="button" id="select-all-results-btn" class="btn btn-primary btn-sm">
<i class="bi bi-check-all me-1"></i> Select All {{ voters.paginator.count }} Results
<button type="submit" name="action" value="export_all" class="btn btn-primary btn-sm">
<i class="bi bi-file-earmark-spreadsheet me-1"></i> Export All Results
</button>
</div>
</div>
{% endif %}
</div>
<div id="selection-banner" class="alert alert-primary rounded-0 border-0 mb-0 py-2 small d-none">
<div class="container d-flex justify-content-between align-items-center">
<span>All <strong>{{ voters.paginator.count }}</strong> voters in this search are selected.</span>
<button type="button" id="clear-selection-btn" class="btn btn-link btn-sm text-primary p-0 text-decoration-none fw-bold">Clear Selection</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="bg-light">
@ -175,10 +138,10 @@
</th>
<th>Name</th>
<th>District</th>
<th>Neighborhood</th>
<th>Phone</th>
<th>Target Voter</th>
<th class="pe-4">Supporter</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@ -194,7 +157,6 @@
<div class="small text-muted">{{ voter.address|default:"No address provided" }}</div>
</td>
<td><span class="badge bg-light text-dark border">{{ voter.district|default:"-" }}</span></td>
<td><span class="badge bg-light text-dark border">{{ voter.neighborhood|default:"-" }}</span></td>
<td>
{% if voter.phone %}
<div class="d-flex align-items-center mb-1">
@ -233,6 +195,15 @@
<span class="badge bg-secondary">Unknown</span>
{% endif %}
</td>
<td class="pe-4 text-end">
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal"
data-bs-target="#scheduleCallModal"
data-voter-id="{{ voter.id }}"
data-voter-name="{{ voter.first_name }} {{ voter.last_name }}">
<i class="bi bi-telephone-plus"></i>
</button>
</td>
</tr>
{% empty %}
<tr>
@ -316,12 +287,6 @@
<div id="selected-voters-sms-container">
<input type="hidden" name="client_time" id="client_time">
<!-- Voter IDs will be injected here -->
{% for key, value in request.GET.items %}
{% if key != 'csrfmiddlewaretoken' and key != 'page' %}
<input type="hidden" name="filter_{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}
<input type="hidden" name="select_all_results" class="select-all-results-val" value="false">
</div>
<div class="mb-0">
<label for="message_body" class="form-label small fw-bold text-muted">Message Body</label>
@ -337,6 +302,38 @@
</div>
</div>
<!-- Bulk Call Modal -->
<div class="modal fade" id="bulkCallModal" tabindex="-1" 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">Bulk Schedule Calls</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'bulk_schedule_calls' %}" method="POST" id="bulk-call-form">
{% csrf_token %}
<div class="modal-body py-4">
<div id="selected-voters-call-container">
<!-- Voter IDs will be injected here -->
</div>
<div class="mb-3">
<label class="form-label small fw-bold text-muted">Volunteer to call back</label>
{{ call_form.volunteer }}
</div>
<div class="mb-0">
<label class="form-label small fw-bold text-muted">Comments</label>
{{ call_form.comments }}
</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">Add to Call Queue</button>
</div>
</form>
</div>
</div>
</div>
<!-- Email Modal -->
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
<div class="modal-dialog">
@ -351,23 +348,11 @@
<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 -->
{% for key, value in request.GET.items %}
{% if key != 'csrfmiddlewaretoken' and key != 'page' %}
<input type="hidden" name="filter_{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}
<input type="hidden" name="select_all_results" class="select-all-results-val" value="false">
</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="form-check mb-3">
<input class="form-check-input" type="checkbox" name="is_html" id="is_html">
<label class="form-check-label small fw-bold text-muted" for="is_html">
Send as HTML
</label>
</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>
@ -381,6 +366,34 @@
</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">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0 bg-light">
<h5 class="modal-title">Schedule Call: <span id="voterNameLabel"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="scheduleCallForm" action="" method="POST">
{% csrf_token %}
<div class="modal-body p-4">
<div class="mb-3">
<label class="form-label fw-medium">Volunteer to call back</label>
{{ call_form.volunteer }}
</div>
<div class="mb-0">
<label class="form-label fw-medium">Comments</label>
{{ call_form.comments }}
</div>
</div>
<div class="modal-footer border-0 p-4 pt-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-4">Add to Call Queue</button>
</div>
</form>
</div>
</div>
</div>
<!-- Google Maps JS -->
{% if GOOGLE_MAPS_API_KEY %}
@ -391,7 +404,6 @@
var map;
var markers = [];
var mapData = {{ map_data_json|safe|default:"[]" }};
const totalCount = {{ voters.paginator.count }};
function initMap() {
if (!window.google || !window.google.maps) {
@ -439,67 +451,20 @@ document.addEventListener('DOMContentLoaded', function() {
const selectAll = document.getElementById('select-all');
const checkboxes = document.querySelectorAll('.voter-checkbox');
const bulkActions = document.getElementById('bulk-actions');
const selectionCount = document.getElementById('selection-count');
const selectAllResultsBtn = document.getElementById('select-all-results-btn');
const selectAllResultsHidden = document.getElementById('select-all-results-hidden');
const selectionBanner = document.getElementById('selection-banner');
const clearSelectionBtn = document.getElementById('clear-selection-btn');
const selectAllResultsModals = document.querySelectorAll('.select-all-results-val');
let isAllResultsSelected = false;
function updateBulkActionsVisibility() {
const checkedCount = document.querySelectorAll('.voter-checkbox:checked').length;
if (isAllResultsSelected) {
if (selectionCount) selectionCount.textContent = totalCount + ' records selected';
if (bulkActions) {
if (bulkActions) {
if (checkedCount > 0) {
bulkActions.classList.remove('d-none');
bulkActions.classList.add('d-flex');
} else {
bulkActions.classList.add('d-none');
}
if (selectionBanner) selectionBanner.classList.remove('d-none');
if (selectAllResultsBtn) selectAllResultsBtn.parentElement.classList.add('d-none');
selectAllResultsHidden.value = "true";
selectAllResultsModals.forEach(el => el.value = "true");
} else {
if (selectionCount) selectionCount.textContent = checkedCount + (checkedCount === 1 ? ' record selected' : ' records selected');
if (bulkActions) {
if (checkedCount > 0) {
bulkActions.classList.remove('d-none');
bulkActions.classList.add('d-flex');
} else {
bulkActions.classList.add('d-none');
bulkActions.classList.remove('d-flex');
}
}
if (selectionBanner) selectionBanner.classList.add('d-none');
if (selectAllResultsBtn) selectAllResultsBtn.parentElement.classList.remove('d-none');
selectAllResultsHidden.value = "false";
selectAllResultsModals.forEach(el => el.value = "false");
}
}
if (selectAllResultsBtn) {
selectAllResultsBtn.addEventListener('click', function() {
isAllResultsSelected = true;
checkboxes.forEach(cb => cb.checked = true);
if (selectAll) selectAll.checked = true;
updateBulkActionsVisibility();
});
}
if (clearSelectionBtn) {
clearSelectionBtn.addEventListener('click', function() {
isAllResultsSelected = false;
checkboxes.forEach(cb => cb.checked = false);
if (selectAll) selectAll.checked = false;
updateBulkActionsVisibility();
});
}
if (selectAll) {
selectAll.addEventListener('change', function() {
isAllResultsSelected = false;
checkboxes.forEach(cb => {
cb.checked = selectAll.checked;
});
@ -509,7 +474,6 @@ document.addEventListener('DOMContentLoaded', function() {
checkboxes.forEach(cb => {
cb.addEventListener('change', function() {
isAllResultsSelected = false;
const allChecked = Array.from(checkboxes).every(c => c.checked);
selectAll.checked = allChecked;
updateBulkActionsVisibility();
@ -520,21 +484,16 @@ document.addEventListener('DOMContentLoaded', function() {
if (smsModal) {
smsModal.addEventListener('show.bs.modal', function () {
const container = document.getElementById('selected-voters-sms-container');
// Keep existing inputs but update selected_voters
container.querySelectorAll('input[name="selected_voters"]').forEach(el => el.remove());
if (!isAllResultsSelected) {
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);
}
});
}
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);
}
});
// Set current browser time
const clientTimeInput = document.getElementById("client_time");
if (clientTimeInput) {
@ -546,28 +505,20 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
const smsForm = document.getElementById("sms-form");
if (smsForm) {
smsForm.addEventListener("submit", function() {
// No need to disable button here, we want to allow multiple tasks
// but providing feedback is good.
// Actually, disabling prevents double-submit.
const submitBtn = smsForm.querySelector("button[type=\"submit\"]");
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = `<span class=\"spinner-border spinner-border-sm me-2\" role=\"status" aria-hidden=\"true\"></span> Starting Task...`;
}
});
}
const emailForm = document.getElementById("email-form");
if (emailForm) {
emailForm.addEventListener("submit", function() {
const submitBtn = emailForm.querySelector("button[type=\"submit\"]");
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = `<span class=\"spinner-border spinner-border-sm me-2\" role=\"status" aria-hidden=\"true\"></span> Sending...`;
}
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);
}
});
});
}
@ -575,19 +526,32 @@ document.addEventListener('DOMContentLoaded', function() {
if (emailModal) {
emailModal.addEventListener('show.bs.modal', function () {
const container = document.getElementById('selected-voters-email-container');
container.querySelectorAll('input[name="selected_voters"]').forEach(el => el.remove());
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 (!isAllResultsSelected) {
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) {
scheduleCallModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
var voterId = button.getAttribute('data-voter-id');
var voterName = button.getAttribute('data-voter-name');
var modalTitle = scheduleCallModal.querySelector('#voterNameLabel');
var form = scheduleCallModal.querySelector('#scheduleCallForm');
modalTitle.textContent = voterName;
form.action = '/voters/' + voterId + '/schedule-call/';
});
}

Some files were not shown because too many files have changed in this diff Show More