From 7fa97bf38caf2bc8b41aa513fcb5e6735fc0f1eb Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 30 May 2026 08:01:02 +0000 Subject: [PATCH] Recover VM workspace state 20260530T075327Z --- .gitignore | 10 + ERD.md | 159 + README.md | 31 + ai/__init__.py | 3 + ai/local_ai_api.py | 420 +++ assets/.gitkeep | 0 check_none.py | 17 + check_tenants_neighborhoods.py | 36 + config/__init__.py | 0 config/asgi.py | 16 + config/settings.py | 192 ++ config/urls.py | 33 + config/wsgi.py | 16 + core/__init__.py | 0 core/admin.py | 620 ++++ core/admin_backup.txt | 0 core/admin_reconstruct.txt | 1 + core/admin_recovered.py | 1 + core/admin_restored.py | 1 + core/apps.py | 6 + core/bulk_email_new.py | 67 + core/bulk_sms_new.py | 92 + core/context_processors.py | 43 + core/export_new.py | 64 + core/filter_helper.py | 114 + core/forms.py | 518 +++ core/grep_dump.txt | 24 + core/middleware.py | 68 + core/migrations/0001_initial.py | 78 + ...eventparticipation_interaction_and_more.py | 75 + core/migrations/0003_tenantuserrole.py | 28 + ...ocode_donationmethod_is_active_and_more.py | 50 + ...5_eventparticipation_participation_type.py | 18 + core/migrations/0006_voter_is_targeted.py | 18 + ...street_voter_city_voter_county_and_more.py | 48 + ...er_voter_latitude_alter_voter_longitude.py | 23 + core/migrations/0009_voter_window_sticker.py | 18 + ...r_voter_window_sticker_campaignsettings.py | 31 + .../0011_voter_birthdate_voter_nickname.py | 23 + ...012_voter_prior_state_alter_voter_state.py | 23 + ...description_remove_tenant_slug_and_more.py | 71 + ...igned_events_alter_volunteerevent_event.py | 24 + ...rticipation_participation_type_and_more.py | 18 + ...icipation_participation_status_and_more.py | 37 + ...tion_participation_status_link_and_more.py | 23 + ...nt_end_time_event_name_event_start_time.py | 28 + ...olunteer_first_name_volunteer_last_name.py | 23 + core/migrations/0020_remove_volunteer_name.py | 17 + core/migrations/0021_voter_phone_type.py | 18 + core/migrations/0022_voter_notes.py | 18 + ...s_street_alter_voter_birthdate_and_more.py | 83 + ..._event_name_alter_event_unique_together.py | 22 + ...ignsettings_twilio_account_sid_and_more.py | 28 + .../migrations/0026_alter_interaction_date.py | 18 + ...ondary_phone_voter_secondary_phone_type.py | 23 + core/migrations/0028_volunteer_notes.py | 18 + ...ress_event_city_event_latitude_and_more.py | 43 + core/migrations/0030_event_location_name.py | 18 + ...e_event_default_volunteer_role_and_more.py | 41 + .../0032_alter_volunteerevent_role.py | 18 + .../0033_remove_volunteerevent_role.py | 17 + .../0034_eventtype_default_volunteer_role.py | 19 + ...visit_interaction_neighborhood_and_more.py | 43 + ..._remove_interaction_door_visit_and_more.py | 29 + .../0037_campaignsettings_timezone.py | 18 + .../0038_alter_campaignsettings_timezone.py | 18 + .../0039_alter_tenantuserrole_role.py | 18 + ...lunteer_is_default_caller_scheduledcall.py | 32 + .../0041_alter_volunteer_options.py | 17 + ...ignsettings_email_from_address_and_more.py | 48 + .../0043_campaignsettings_email_from_name.py | 18 + .../0044_voter_target_door_visit.py | 18 + core/migrations/0045_voter_is_inactive.py | 18 + ..._core_voter_tenant__6a281d_idx_and_more.py | 25 + core/migrations/0047_alter_voter_yard_sign.py | 18 + .../0048_voter_call_queue_status.py | 37 + .../0049_campaignsettings_call_script.py | 18 + core/migrations/0050_voter_voted.py | 18 + core/migrations/0051_bulktask.py | 29 + ..._bulktask_message_body_bulktask_subject.py | 23 + ...had_large_sign_voter_ever_had_yard_sign.py | 23 + core/migrations/__init__.py | 0 core/models.py | 883 ++++++ core/permissions.py | 127 + core/task_runners.py | 217 ++ .../templates/admin/donation_change_list.html | 9 + core/templates/admin/event_change_list.html | 38 + .../admin/eventparticipation_change_list.html | 7 + core/templates/admin/import_csv.html | 42 + core/templates/admin/import_mapping.html | 48 + core/templates/admin/import_preview.html | 87 + .../admin/interaction_change_list.html | 9 + .../admin/mass_assign_volunteer.html | 39 + .../participationstatus_change_list.html | 31 + .../admin/volunteer_change_list.html | 38 + core/templates/admin/voter_change_list.html | 38 + .../admin/voterlikelihood_change_list.html | 9 + .../admin/votingrecord_change_list.html | 38 + core/templates/base.html | 115 + core/templates/core/article_detail.html | 14 + core/templates/core/bulk_task_list.html | 174 ++ core/templates/core/call_queue.html | 425 +++ core/templates/core/door_visit_history.html | 236 ++ core/templates/core/door_visits.html | 424 +++ core/templates/core/event_detail.html | 476 +++ core/templates/core/event_edit.html | 142 + core/templates/core/event_list.html | 66 + .../core/event_participant_map_fields.html | 80 + .../core/event_participant_matching.html | 154 + core/templates/core/index.html | 305 ++ core/templates/core/log_door_visit.html | 156 + core/templates/core/neighborhood_counts.html | 76 + core/templates/core/profile.html | 73 + core/templates/core/view_signs.html | 378 +++ core/templates/core/volunteer_detail.html | 386 +++ core/templates/core/volunteer_list.html | 289 ++ core/templates/core/voter_add.html | 261 ++ .../templates/core/voter_advanced_search.html | 613 ++++ core/templates/core/voter_detail.html | 1299 ++++++++ core/templates/core/voter_list.html | 246 ++ core/templates/core/yard_sign_voters.html | 392 +++ core/templates/registration/login.html | 52 + .../registration/password_change_done.html | 21 + .../registration/password_change_form.html | 53 + core/templatetags/__init__.py | 0 core/templatetags/core_tags.py | 23 + core/tests.py | 3 + core/urls.py | 84 + core/views.py | 2777 +++++++++++++++++ core/views.py.bak | 2700 ++++++++++++++++ core/views.py_start.py | 59 + core/views_new.py | 39 + core/voter_import_logic.py | 182 ++ db/config.php | 17 + fix_door_visits.py | 48 + fix_duplication.py | 21 + fix_redundancy.py | 36 + fix_view.py | 127 + fix_voted_voters.py | 24 + fix_voter_search.py | 174 ++ fix_voter_search_v2.py | 127 + fix_voter_search_v3.py | 94 + manage.py | 22 + package-lock.json | 46 + package.json | 15 + patch_admin_import.py | 37 + patch_admin_import_v2.py | 37 + patch_door_visits.py | 141 + patch_door_visits_template.py | 24 + patch_door_visits_view.py | 70 + patch_forms.py | 24 + patch_import_logic.py | 22 + patch_log_door_visit_template.py | 24 + patch_log_door_visit_view.py | 26 + patch_models_v2.py | 46 + patch_models_v3.py | 118 + patch_models_v4.py | 103 + patch_template_view_signs.py | 26 + patch_template_yard_sign.py | 24 + patch_templates_list.py | 68 + patch_view_signs.py | 63 + patch_view_signs_template.py | 24 + patch_view_yard_sign.py | 126 + patch_views.py | 28 + patch_views_optimized_sms.py | 109 + patch_views_v10.py | 52 + patch_views_v11.py | 29 + patch_views_v12.py | 29 + patch_views_v2.py | 50 + patch_views_v3.py | 45 + patch_views_v4.py | 61 + patch_views_v5.py | 45 + patch_views_v6.py | 45 + patch_views_v8.py | 49 + patch_views_v9.py | 45 + patch_volunteer_list_context.py | 26 + patch_voter_search_context.py | 33 + patch_yard_sign_voters.py | 78 + patch_yard_sign_voters_template.py | 24 + requirements.txt | 4 + static/css/custom.css | 152 + test_voter_status.py | 39 + update_target_door_visit_event.py | 46 + update_target_door_visit_v2.py | 39 + yard_sign_voters_func.py | 94 + 185 files changed, 21387 insertions(+) create mode 100644 .gitignore create mode 100644 ERD.md create mode 100644 README.md create mode 100644 ai/__init__.py create mode 100644 ai/local_ai_api.py create mode 100644 assets/.gitkeep create mode 100644 check_none.py create mode 100644 check_tenants_neighborhoods.py create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/settings.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 core/__init__.py create mode 100644 core/admin.py create mode 100644 core/admin_backup.txt create mode 100644 core/admin_reconstruct.txt create mode 100644 core/admin_recovered.py create mode 100644 core/admin_restored.py create mode 100644 core/apps.py create mode 100644 core/bulk_email_new.py create mode 100644 core/bulk_sms_new.py create mode 100644 core/context_processors.py create mode 100644 core/export_new.py create mode 100644 core/filter_helper.py create mode 100644 core/forms.py create mode 100644 core/grep_dump.txt create mode 100644 core/middleware.py create mode 100644 core/migrations/0001_initial.py create mode 100644 core/migrations/0002_donation_event_eventparticipation_interaction_and_more.py create mode 100644 core/migrations/0003_tenantuserrole.py create mode 100644 core/migrations/0004_remove_voter_geocode_donationmethod_is_active_and_more.py create mode 100644 core/migrations/0005_eventparticipation_participation_type.py create mode 100644 core/migrations/0006_voter_is_targeted.py create mode 100644 core/migrations/0007_voter_address_street_voter_city_voter_county_and_more.py create mode 100644 core/migrations/0008_alter_voter_latitude_alter_voter_longitude.py create mode 100644 core/migrations/0009_voter_window_sticker.py create mode 100644 core/migrations/0010_alter_voter_window_sticker_campaignsettings.py create mode 100644 core/migrations/0011_voter_birthdate_voter_nickname.py create mode 100644 core/migrations/0012_voter_prior_state_alter_voter_state.py create mode 100644 core/migrations/0013_remove_tenant_description_remove_tenant_slug_and_more.py create mode 100644 core/migrations/0014_volunteer_assigned_events_alter_volunteerevent_event.py create mode 100644 core/migrations/0015_remove_eventparticipation_participation_type_and_more.py create mode 100644 core/migrations/0016_alter_eventparticipation_participation_status_and_more.py create mode 100644 core/migrations/0017_remove_eventparticipation_participation_status_link_and_more.py create mode 100644 core/migrations/0018_event_end_time_event_name_event_start_time.py create mode 100644 core/migrations/0019_volunteer_first_name_volunteer_last_name.py create mode 100644 core/migrations/0020_remove_volunteer_name.py create mode 100644 core/migrations/0021_voter_phone_type.py create mode 100644 core/migrations/0022_voter_notes.py create mode 100644 core/migrations/0023_alter_voter_address_street_alter_voter_birthdate_and_more.py create mode 100644 core/migrations/0024_alter_event_name_alter_event_unique_together.py create mode 100644 core/migrations/0025_campaignsettings_twilio_account_sid_and_more.py create mode 100644 core/migrations/0026_alter_interaction_date.py create mode 100644 core/migrations/0027_voter_secondary_phone_voter_secondary_phone_type.py create mode 100644 core/migrations/0028_volunteer_notes.py create mode 100644 core/migrations/0029_event_address_event_city_event_latitude_and_more.py create mode 100644 core/migrations/0030_event_location_name.py create mode 100644 core/migrations/0031_volunteerrole_event_default_volunteer_role_and_more.py create mode 100644 core/migrations/0032_alter_volunteerevent_role.py create mode 100644 core/migrations/0033_remove_volunteerevent_role.py create mode 100644 core/migrations/0034_eventtype_default_volunteer_role.py create mode 100644 core/migrations/0035_interaction_door_visit_interaction_neighborhood_and_more.py create mode 100644 core/migrations/0036_remove_interaction_door_visit_and_more.py create mode 100644 core/migrations/0037_campaignsettings_timezone.py create mode 100644 core/migrations/0038_alter_campaignsettings_timezone.py create mode 100644 core/migrations/0039_alter_tenantuserrole_role.py create mode 100644 core/migrations/0040_volunteer_is_default_caller_scheduledcall.py create mode 100644 core/migrations/0041_alter_volunteer_options.py create mode 100644 core/migrations/0042_campaignsettings_email_from_address_and_more.py create mode 100644 core/migrations/0043_campaignsettings_email_from_name.py create mode 100644 core/migrations/0044_voter_target_door_visit.py create mode 100644 core/migrations/0045_voter_is_inactive.py create mode 100644 core/migrations/0046_voter_core_voter_tenant__6a281d_idx_and_more.py create mode 100644 core/migrations/0047_alter_voter_yard_sign.py create mode 100644 core/migrations/0048_voter_call_queue_status.py create mode 100644 core/migrations/0049_campaignsettings_call_script.py create mode 100644 core/migrations/0050_voter_voted.py create mode 100644 core/migrations/0051_bulktask.py create mode 100644 core/migrations/0052_bulktask_message_body_bulktask_subject.py create mode 100644 core/migrations/0053_voter_ever_had_large_sign_voter_ever_had_yard_sign.py create mode 100644 core/migrations/__init__.py create mode 100644 core/models.py create mode 100644 core/permissions.py create mode 100644 core/task_runners.py create mode 100644 core/templates/admin/donation_change_list.html create mode 100644 core/templates/admin/event_change_list.html create mode 100644 core/templates/admin/eventparticipation_change_list.html create mode 100644 core/templates/admin/import_csv.html create mode 100644 core/templates/admin/import_mapping.html create mode 100644 core/templates/admin/import_preview.html create mode 100644 core/templates/admin/interaction_change_list.html create mode 100644 core/templates/admin/mass_assign_volunteer.html create mode 100644 core/templates/admin/participationstatus_change_list.html create mode 100644 core/templates/admin/volunteer_change_list.html create mode 100644 core/templates/admin/voter_change_list.html create mode 100644 core/templates/admin/voterlikelihood_change_list.html create mode 100644 core/templates/admin/votingrecord_change_list.html create mode 100644 core/templates/base.html create mode 100644 core/templates/core/article_detail.html create mode 100644 core/templates/core/bulk_task_list.html create mode 100644 core/templates/core/call_queue.html create mode 100644 core/templates/core/door_visit_history.html create mode 100644 core/templates/core/door_visits.html create mode 100644 core/templates/core/event_detail.html create mode 100644 core/templates/core/event_edit.html create mode 100644 core/templates/core/event_list.html create mode 100644 core/templates/core/event_participant_map_fields.html create mode 100644 core/templates/core/event_participant_matching.html create mode 100644 core/templates/core/index.html create mode 100644 core/templates/core/log_door_visit.html create mode 100644 core/templates/core/neighborhood_counts.html create mode 100644 core/templates/core/profile.html create mode 100644 core/templates/core/view_signs.html create mode 100644 core/templates/core/volunteer_detail.html create mode 100644 core/templates/core/volunteer_list.html create mode 100644 core/templates/core/voter_add.html create mode 100644 core/templates/core/voter_advanced_search.html create mode 100644 core/templates/core/voter_detail.html create mode 100644 core/templates/core/voter_list.html create mode 100644 core/templates/core/yard_sign_voters.html create mode 100644 core/templates/registration/login.html create mode 100644 core/templates/registration/password_change_done.html create mode 100644 core/templates/registration/password_change_form.html create mode 100644 core/templatetags/__init__.py create mode 100644 core/templatetags/core_tags.py create mode 100644 core/tests.py create mode 100644 core/urls.py create mode 100644 core/views.py create mode 100644 core/views.py.bak create mode 100644 core/views.py_start.py create mode 100644 core/views_new.py create mode 100644 core/voter_import_logic.py create mode 100644 db/config.php create mode 100644 fix_door_visits.py create mode 100644 fix_duplication.py create mode 100644 fix_redundancy.py create mode 100644 fix_view.py create mode 100644 fix_voted_voters.py create mode 100644 fix_voter_search.py create mode 100644 fix_voter_search_v2.py create mode 100644 fix_voter_search_v3.py create mode 100755 manage.py create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 patch_admin_import.py create mode 100644 patch_admin_import_v2.py create mode 100644 patch_door_visits.py create mode 100644 patch_door_visits_template.py create mode 100644 patch_door_visits_view.py create mode 100644 patch_forms.py create mode 100644 patch_import_logic.py create mode 100644 patch_log_door_visit_template.py create mode 100644 patch_log_door_visit_view.py create mode 100644 patch_models_v2.py create mode 100644 patch_models_v3.py create mode 100644 patch_models_v4.py create mode 100644 patch_template_view_signs.py create mode 100644 patch_template_yard_sign.py create mode 100644 patch_templates_list.py create mode 100644 patch_view_signs.py create mode 100644 patch_view_signs_template.py create mode 100644 patch_view_yard_sign.py create mode 100644 patch_views.py create mode 100644 patch_views_optimized_sms.py create mode 100644 patch_views_v10.py create mode 100644 patch_views_v11.py create mode 100644 patch_views_v12.py create mode 100644 patch_views_v2.py create mode 100644 patch_views_v3.py create mode 100644 patch_views_v4.py create mode 100644 patch_views_v5.py create mode 100644 patch_views_v6.py create mode 100644 patch_views_v8.py create mode 100644 patch_views_v9.py create mode 100644 patch_volunteer_list_context.py create mode 100644 patch_voter_search_context.py create mode 100644 patch_yard_sign_voters.py create mode 100644 patch_yard_sign_voters_template.py create mode 100644 requirements.txt create mode 100644 static/css/custom.css create mode 100644 test_voter_status.py create mode 100644 update_target_door_visit_event.py create mode 100644 update_target_door_visit_v2.py create mode 100644 yard_sign_voters_func.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9530a7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +__pycache__/ +*.pyc +*.pyo +*.sqlite3 +.env +.env.* +.perm_test_* +staticfiles/ +.DS_Store diff --git a/ERD.md b/ERD.md new file mode 100644 index 0000000..68315ba --- /dev/null +++ b/ERD.md @@ -0,0 +1,159 @@ +# Entity Relationship Diagram + +```mermaid +erDiagram + Tenant ||--o{ TenantUserRole : has + Tenant ||--o{ InteractionType : defines + Tenant ||--o{ DonationMethod : defines + Tenant ||--o{ ElectionType : defines + Tenant ||--o{ EventType : defines + Tenant ||--o{ ParticipationStatus : defines + Tenant ||--o{ Voter : belongs_to + Tenant ||--o{ Event : organizes + + User ||--o{ TenantUserRole : assigned_to + + Voter ||--o{ VotingRecord : has + Voter ||--o{ EventParticipation : participates + Voter ||--o{ Donation : makes + Voter ||--o{ Interaction : receives + Voter ||--o{ VoterLikelihood : has + + Event ||--o{ EventParticipation : includes + EventType ||--o{ Event : categorizes + ParticipationStatus ||--o{ EventParticipation : defines_status + + InteractionType ||--o{ Interaction : categorizes + DonationMethod ||--o{ Donation : categorizes + ElectionType ||--o{ VoterLikelihood : categorizes + + Tenant { + int id PK + string name + string slug + text description + datetime created_at + } + + User { + int id PK + string username + string email + string first_name + string last_name + } + + TenantUserRole { + int id PK + int user_id FK + int tenant_id FK + string role + } + + InteractionType { + int id PK + int tenant_id FK + string name + boolean is_active + } + + DonationMethod { + int id PK + int tenant_id FK + string name + boolean is_active + } + + ElectionType { + int id PK + int tenant_id FK + string name + boolean is_active + } + + EventType { + int id PK + int tenant_id FK + string name + boolean is_active + } + + ParticipationStatus { + int id PK + int tenant_id FK + string name + boolean is_active + } + + Voter { + int id PK + int tenant_id FK + string voter_id + string first_name + string last_name + text address + string address_street + string city + string state + string zip_code + string county + decimal latitude + decimal longitude + string phone + string email + string district + string precinct + date registration_date + boolean is_targeted + string candidate_support + string yard_sign + datetime created_at + } + + VotingRecord { + int id PK + int voter_id FK + date election_date + string election_description + string primary_party + } + + Event { + int id PK + int tenant_id FK + date date + int event_type_id FK + text description + } + + EventParticipation { + int id PK + int event_id FK + int voter_id FK + int participation_status_id FK + } + + Donation { + int id PK + int voter_id FK + date date + int method_id FK + decimal amount + } + + Interaction { + int id PK + int voter_id FK + int type_id FK + date date + string description + text notes + } + + VoterLikelihood { + int id PK + int voter_id FK + int election_type_id FK + string likelihood + } +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ec3cd4 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Flatlogic Python Template Workspace + +This workspace houses the Django application scaffold used for Python-based templates. + +## Requirements + +- Python 3.11+ +- MariaDB (or MySQL-compatible server) with the credentials prepared by `setup_mariadb_project.sh` +- System packages: `pkg-config`, `libmariadb-dev` (already installed on golden images) + +## Getting Started + +```bash +python3 -m pip install --break-system-packages -r requirements.txt +python3 manage.py migrate +python3 manage.py runserver 0.0.0.0:8000 +``` + +Environment variables are loaded from `../.env` (the executor root). See `.env.example` if you need to populate values manually. + +## Project Structure + +- `config/` – Django project settings, URLs, WSGI entrypoint. +- `core/` – Default app with a basic health-check route. +- `manage.py` – Django management entrypoint. + +## Next Steps + +- Create additional apps and views according to the generated project requirements. +- Configure serving via Apache + mod_wsgi or gunicorn (instructions to be added). +- Run `python3 manage.py collectstatic` before serving through Apache. diff --git a/ai/__init__.py b/ai/__init__.py new file mode 100644 index 0000000..37a7b09 --- /dev/null +++ b/ai/__init__.py @@ -0,0 +1,3 @@ +"""Helpers for interacting with the Flatlogic AI proxy from Django code.""" + +from .local_ai_api import LocalAIApi, create_response, request, decode_json_from_response # noqa: F401 diff --git a/ai/local_ai_api.py b/ai/local_ai_api.py new file mode 100644 index 0000000..bcff732 --- /dev/null +++ b/ai/local_ai_api.py @@ -0,0 +1,420 @@ +""" +LocalAIApi — lightweight Python client for the Flatlogic AI proxy. + +Usage (inside the Django workspace): + + from ai.local_ai_api import LocalAIApi + + response = LocalAIApi.create_response({ + "input": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Summarise this text in two sentences."}, + ], + "text": {"format": {"type": "json_object"}}, + }) + + if response.get("success"): + data = LocalAIApi.decode_json_from_response(response) + # ... + +# Typical successful payload (truncated): +# { +# "id": "resp_xxx", +# "status": "completed", +# "output": [ +# {"type": "reasoning", "summary": []}, +# {"type": "message", "content": [{"type": "output_text", "text": "Your final answer here."}]} +# ], +# "usage": { "input_tokens": 123, "output_tokens": 456 } +# } + +The helper automatically injects the project UUID header and falls back to +reading executor/.env if environment variables are missing. +""" + +from __future__ import annotations + +import json +import os +import time +import ssl +from typing import Any, Dict, Iterable, Optional +from urllib import error as urlerror +from urllib import request as urlrequest + +__all__ = [ + "LocalAIApi", + "create_response", + "request", + "fetch_status", + "await_response", + "extract_text", + "decode_json_from_response", +] + + +_CONFIG_CACHE: Optional[Dict[str, Any]] = None + + +class LocalAIApi: + """Static helpers mirroring the PHP implementation.""" + + @staticmethod + def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return create_response(params, options or {}) + + @staticmethod + def request(path: Optional[str] = None, payload: Optional[Dict[str, Any]] = None, + options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return request(path, payload or {}, options or {}) + + @staticmethod + def extract_text(response: Dict[str, Any]) -> str: + return extract_text(response) + + @staticmethod + def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return decode_json_from_response(response) + + +def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Signature compatible with the OpenAI Responses API.""" + options = options or {} + payload = dict(params) + + if not isinstance(payload.get("input"), list) or not payload["input"]: + return { + "success": False, + "error": "input_missing", + "message": 'Parameter "input" is required and must be a non-empty list.', + } + + cfg = _config() + if not payload.get("model"): + payload["model"] = cfg["default_model"] + + initial = request(options.get("path"), payload, options) + if not initial.get("success"): + return initial + + data = initial.get("data") + if isinstance(data, dict) and "ai_request_id" in data: + ai_request_id = data["ai_request_id"] + poll_timeout = int(options.get("poll_timeout", 300)) + poll_interval = int(options.get("poll_interval", 5)) + return await_response(ai_request_id, { + "interval": poll_interval, + "timeout": poll_timeout, + "headers": options.get("headers"), + "timeout_per_call": options.get("timeout"), + }) + + return initial + + +def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Perform a raw request to the AI proxy.""" + cfg = _config() + options = options or {} + + resolved_path = path or options.get("path") or cfg["responses_path"] + if not resolved_path: + return { + "success": False, + "error": "project_id_missing", + "message": "PROJECT_ID is not defined; cannot resolve AI proxy endpoint.", + } + + project_uuid = cfg["project_uuid"] + if not project_uuid: + return { + "success": False, + "error": "project_uuid_missing", + "message": "PROJECT_UUID is not defined; aborting AI request.", + } + + if "project_uuid" not in payload and project_uuid: + payload["project_uuid"] = project_uuid + + url = _build_url(resolved_path, cfg["base_url"]) + opt_timeout = options.get("timeout") + timeout = int(cfg["timeout"] if opt_timeout is None else opt_timeout) + verify_tls = options.get("verify_tls", cfg["verify_tls"]) + + headers: Dict[str, str] = { + "Content-Type": "application/json", + "Accept": "application/json", + cfg["project_header"]: project_uuid, + } + extra_headers = options.get("headers") + if isinstance(extra_headers, Iterable): + for header in extra_headers: + if isinstance(header, str) and ":" in header: + name, value = header.split(":", 1) + headers[name.strip()] = value.strip() + + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + return _http_request(url, "POST", body, headers, timeout, verify_tls) + + +def fetch_status(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Fetch status for a queued AI request.""" + cfg = _config() + options = options or {} + + project_uuid = cfg["project_uuid"] + if not project_uuid: + return { + "success": False, + "error": "project_uuid_missing", + "message": "PROJECT_UUID is not defined; aborting status check.", + } + + status_path = _resolve_status_path(ai_request_id, cfg) + url = _build_url(status_path, cfg["base_url"]) + + opt_timeout = options.get("timeout") + timeout = int(cfg["timeout"] if opt_timeout is None else opt_timeout) + verify_tls = options.get("verify_tls", cfg["verify_tls"]) + + headers: Dict[str, str] = { + "Accept": "application/json", + cfg["project_header"]: project_uuid, + } + extra_headers = options.get("headers") + if isinstance(extra_headers, Iterable): + for header in extra_headers: + if isinstance(header, str) and ":" in header: + name, value = header.split(":", 1) + headers[name.strip()] = value.strip() + + return _http_request(url, "GET", None, headers, timeout, verify_tls) + + +def await_response(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Poll status endpoint until the request is complete or timed out.""" + options = options or {} + timeout = int(options.get("timeout", 300)) + interval = int(options.get("interval", 5)) + if interval <= 0: + interval = 5 + per_call_timeout = options.get("timeout_per_call") + + deadline = time.time() + max(timeout, interval) + + while True: + status_resp = fetch_status(ai_request_id, { + "headers": options.get("headers"), + "timeout": per_call_timeout, + "verify_tls": options.get("verify_tls"), + }) + if status_resp.get("success"): + data = status_resp.get("data") or {} + if isinstance(data, dict): + status_value = data.get("status") + if status_value == "success": + return { + "success": True, + "status": 200, + "data": data.get("response", data), + } + if status_value == "failed": + return { + "success": False, + "status": 500, + "error": str(data.get("error") or "AI request failed"), + "data": data, + } + else: + return status_resp + + if time.time() >= deadline: + return { + "success": False, + "error": "timeout", + "message": "Timed out waiting for AI response.", + } + time.sleep(interval) + + +def extract_text(response: Dict[str, Any]) -> str: + """Public helper to extract plain text from a Responses payload.""" + return _extract_text(response) + + +def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Attempt to decode JSON emitted by the model (handles markdown fences).""" + text = _extract_text(response) + if text == "": + return None + + try: + decoded = json.loads(text) + if isinstance(decoded, dict): + return decoded + except json.JSONDecodeError: + pass + + stripped = text.strip() + if stripped.startswith("```json"): + stripped = stripped[7:] + if stripped.endswith("```"): + stripped = stripped[:-3] + stripped = stripped.strip() + if stripped and stripped != text: + try: + decoded = json.loads(stripped) + if isinstance(decoded, dict): + return decoded + except json.JSONDecodeError: + return None + return None + + +def _extract_text(response: Dict[str, Any]) -> str: + payload = response.get("data") if response.get("success") else response.get("response") + if isinstance(payload, dict): + output = payload.get("output") + if isinstance(output, list): + combined = "" + for item in output: + content = item.get("content") if isinstance(item, dict) else None + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "output_text" and block.get("text"): + combined += str(block["text"]) + if combined: + return combined + choices = payload.get("choices") + if isinstance(choices, list) and choices: + message = choices[0].get("message") + if isinstance(message, dict) and message.get("content"): + return str(message["content"]) + if isinstance(payload, str): + return payload + return "" + + +def _config() -> Dict[str, Any]: + global _CONFIG_CACHE # noqa: PLW0603 + if _CONFIG_CACHE is not None: + return _CONFIG_CACHE + + _ensure_env_loaded() + + base_url = os.getenv("AI_PROXY_BASE_URL", "https://flatlogic.com") + project_id = os.getenv("PROJECT_ID") or None + responses_path = os.getenv("AI_RESPONSES_PATH") + if not responses_path and project_id: + responses_path = f"/projects/{project_id}/ai-request" + + _CONFIG_CACHE = { + "base_url": base_url, + "responses_path": responses_path, + "project_id": project_id, + "project_uuid": os.getenv("PROJECT_UUID"), + "project_header": os.getenv("AI_PROJECT_HEADER", "project-uuid"), + "default_model": os.getenv("AI_DEFAULT_MODEL", "gpt-5-mini"), + "timeout": int(os.getenv("AI_TIMEOUT", "30")), + "verify_tls": os.getenv("AI_VERIFY_TLS", "true").lower() not in {"0", "false", "no"}, + } + return _CONFIG_CACHE + + +def _build_url(path: str, base_url: str) -> str: + trimmed = path.strip() + if trimmed.startswith("http://") or trimmed.startswith("https://"): + return trimmed + if trimmed.startswith("/"): + return f"{base_url}{trimmed}" + return f"{base_url}/{trimmed}" + + +def _resolve_status_path(ai_request_id: Any, cfg: Dict[str, Any]) -> str: + base_path = (cfg.get("responses_path") or "").rstrip("/") + if not base_path: + return f"/ai-request/{ai_request_id}/status" + if not base_path.endswith("/ai-request"): + base_path = f"{base_path}/ai-request" + return f"{base_path}/{ai_request_id}/status" + + +def _http_request(url: str, method: str, body: Optional[bytes], headers: Dict[str, str], + timeout: int, verify_tls: bool) -> Dict[str, Any]: + """ + Shared HTTP helper for GET/POST requests. + """ + req = urlrequest.Request(url, data=body, method=method.upper()) + for name, value in headers.items(): + req.add_header(name, value) + + context = None + if not verify_tls: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + try: + with urlrequest.urlopen(req, timeout=timeout, context=context) as resp: + status = resp.getcode() + response_body = resp.read().decode("utf-8", errors="replace") + except urlerror.HTTPError as exc: + status = exc.getcode() + response_body = exc.read().decode("utf-8", errors="replace") + except Exception as exc: # pylint: disable=broad-except + return { + "success": False, + "error": "request_failed", + "message": str(exc), + } + + decoded = None + if response_body: + try: + decoded = json.loads(response_body) + except json.JSONDecodeError: + decoded = None + + if 200 <= status < 300: + return { + "success": True, + "status": status, + "data": decoded if decoded is not None else response_body, + } + + error_message = "AI proxy request failed" + if isinstance(decoded, dict): + error_message = decoded.get("error") or decoded.get("message") or error_message + elif response_body: + error_message = response_body + + return { + "success": False, + "status": status, + "error": error_message, + "response": decoded if decoded is not None else response_body, + } + + +def _ensure_env_loaded() -> None: + """Populate os.environ from executor/.env if variables are missing.""" + if os.getenv("PROJECT_UUID") and os.getenv("PROJECT_ID"): + return + + env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".env")) + if not os.path.exists(env_path): + return + + try: + with open(env_path, "r", encoding="utf-8") as handle: + for line in handle: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + key = key.strip() + value = value.strip().strip('\'"') + if key and not os.getenv(key): + os.environ[key] = value + except OSError: + pass diff --git a/assets/.gitkeep b/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/check_none.py b/check_none.py new file mode 100644 index 0000000..cc48542 --- /dev/null +++ b/check_none.py @@ -0,0 +1,17 @@ + +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() diff --git a/check_tenants_neighborhoods.py b/check_tenants_neighborhoods.py new file mode 100644 index 0000000..939a1c4 --- /dev/null +++ b/check_tenants_neighborhoods.py @@ -0,0 +1,36 @@ +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() \ No newline at end of file diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..ed7c431 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..b7e7236 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,192 @@ +""" +Django settings for config project. + +Generated by 'django-admin startproject' using Django 5.2.7. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path +import os +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent.parent +load_dotenv(BASE_DIR.parent / ".env") + +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me") +DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true" + +ALLOWED_HOSTS = [ + "127.0.0.1", + "localhost", + "grassrootscrm.flatlogic.app", + os.getenv("HOST_FQDN", ""), +] + +CSRF_TRUSTED_ORIGINS = [ + "https://grassrootscrm.flatlogic.app", +] +CSRF_TRUSTED_ORIGINS += [ + origin for origin in [ + os.getenv("HOST_FQDN", ""), + os.getenv("CSRF_TRUSTED_ORIGIN", "") + ] if origin +] +CSRF_TRUSTED_ORIGINS = [ + f"https://{host}" if not host.startswith(("http://", "https://")) else host + for host in CSRF_TRUSTED_ORIGINS +] + +# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy. +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SESSION_COOKIE_SAMESITE = "None" +CSRF_COOKIE_SAMESITE = "None" + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'core', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'core.middleware.LoginRequiredMiddleware', + 'core.middleware.TimezoneMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + # Disable X-Frame-Options middleware to allow Flatlogic preview iframes. + # 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +X_FRAME_OPTIONS = 'ALLOWALL' + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + # IMPORTANT: do not remove – injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp + 'core.context_processors.project_context', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': os.getenv('DB_NAME', ''), + 'USER': os.getenv('DB_USER', ''), + 'PASSWORD': os.getenv('DB_PASS', ''), + 'HOST': os.getenv('DB_HOST', '127.0.0.1'), + 'PORT': os.getenv('DB_PORT', '3306'), + 'OPTIONS': { + 'charset': 'utf8mb4', + }, + }, +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_URL = 'static/' +# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS. +STATIC_ROOT = BASE_DIR / 'staticfiles' + +STATICFILES_DIRS = [ + BASE_DIR / 'static', + BASE_DIR / 'assets', + BASE_DIR / 'node_modules', +] + +# Email +EMAIL_BACKEND = os.getenv( + "EMAIL_BACKEND", + "django.core.mail.backends.smtp.EmailBackend" +) +EMAIL_HOST = os.getenv("EMAIL_HOST", "127.0.0.1") +EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587")) +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") +EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true" +EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true" +DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "no-reply@example.com") +CONTACT_EMAIL_TO = [ + item.strip() + for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",") + if item.strip() +] + +# When both TLS and SSL flags are enabled, prefer SSL explicitly +if EMAIL_USE_SSL: + EMAIL_USE_TLS = False +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +GOOGLE_MAPS_API_KEY = os.getenv("GOOGLE_MAPS_API_KEY", "AIzaSyAluZTEjH-RSiGJUHnfrSqWbcAXCGzGOq4") +LOGIN_URL = 'login' +LOGIN_REDIRECT_URL = 'index' +LOGOUT_REDIRECT_URL = 'login' \ No newline at end of file diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..0864e06 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,33 @@ +""" +URL configuration for config project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path +from django.views.i18n import JavaScriptCatalog + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import include, path +from django.views.i18n import JavaScriptCatalog +from django.conf import settings +from django.conf.urls.static import static + +urlpatterns = [ + path("admin/", admin.site.urls), + path("jsi18n/", JavaScriptCatalog.as_view(), name="jsi18n"), + path("", include("core.urls")), + path("accounts/", include("django.contrib.auth.urls")), +] + +if settings.DEBUG: + urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets") + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..e2fbd58 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/admin.py b/core/admin.py new file mode 100644 index 0000000..e652e12 --- /dev/null +++ b/core/admin.py @@ -0,0 +1,620 @@ +from django import forms +from decimal import Decimal, InvalidOperation +from datetime import datetime, date +import csv +import io +import logging +import tempfile +import os +import zoneinfo +from django.db import transaction +from django.http import HttpResponse +from django.utils.safestring import mark_safe +from django.utils.dateparse import parse_date, parse_datetime +from django.utils import timezone as django_timezone +from django.contrib import admin, messages +from django.urls import path, reverse +from django.shortcuts import render, redirect +from django.template.response import TemplateResponse + +from .models import ( + format_phone_number, + Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter, + VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings, + Interest, Volunteer, VolunteerEvent, ParticipationStatus, VolunteerRole, ScheduledCall +) +from .forms import ( + VoterImportForm, EventImportForm, EventParticipationImportForm, + DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm, + VolunteerImportForm, VotingRecordImportForm +) + +logger = logging.getLogger(__name__) + +def parse_any_date(date_str, tz_name=None): + if not date_str or not isinstance(date_str, str): return None + date_str = date_str.strip() + if not date_str: return None + dt = parse_datetime(date_str) + if dt: + if django_timezone.is_naive(dt) and tz_name: + try: dt = django_timezone.make_aware(dt, zoneinfo.ZoneInfo(tz_name)) + except: pass + return dt + d = parse_date(date_str) + if d: return d + formats = ["%m/%d/%Y", "%m/%d/%y", "%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d", "%m-%d-%Y", "%d-%m-%Y", "%Y/%m/%d", "%m/%d/%Y %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%m/%d/%Y %I:%M %p", "%m/%d/%Y %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ"] + for fmt in formats: + try: + dt = datetime.strptime(date_str, fmt) + if any(x in fmt for x in ["%H", "%I", "T"]): + if django_timezone.is_naive(dt) and tz_name: + try: dt = django_timezone.make_aware(dt, zoneinfo.ZoneInfo(tz_name)) + except: pass + return dt + return dt.date() + except ValueError: continue + return None + +def _robust_decode(content): + if not content: return "" + for enc in ["utf-8-sig", "utf-8", "iso-8859-1", "windows-1252"]: + try: return content.decode(enc) + except UnicodeDecodeError: continue + return content.decode("utf-8", errors="replace") + +def _read_csv_robust(file_path): + """ + Optimized version: Read and decode the file into memory once, + but return a StringIO for stream-like processing. + """ + with open(file_path, "rb") as f: + content = _robust_decode(f.read()) + return io.StringIO(content) + +class BaseImportAdminMixin: + actions = ["export_as_csv"] + + def export_as_csv(self, request, queryset): + meta = self.model._meta + field_names = [field.name for field in meta.fields] + include_voter_id = "voter" in field_names and self.model != Voter + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f"attachment; filename={meta.model_name}_export.csv" + writer = csv.writer(response) + headers = [] + for name in field_names: + headers.append(name) + if name == "voter" and include_voter_id: headers.append("voter_id") + writer.writerow(headers) + for obj in queryset: + row = [] + for field in field_names: + value = getattr(obj, field) + if isinstance(value, (datetime, date)): value = value.strftime("%Y-%m-%d %H:%M:%S") if isinstance(value, datetime) else value.strftime("%Y-%m-%d") + elif hasattr(value, "id"): value = str(value) + row.append(value) + if field == "voter" and include_voter_id: row.append(obj.voter.voter_id if obj.voter else "") + writer.writerow(row) + return response + export_as_csv.short_description = "Export Selected as CSV" + + def download_errors(self, request): + failed_rows = request.session.get(f"{self.model._meta.model_name}_import_errors", []) + if not failed_rows: + self.message_user(request, "No errors found.", level=messages.WARNING) + return redirect("../") + output = io.StringIO() + if failed_rows: + writer = csv.DictWriter(output, fieldnames=failed_rows[0].keys()) + writer.writeheader() + writer.writerows(failed_rows) + response = HttpResponse(output.getvalue(), content_type='text/csv') + response['Content-Disposition'] = f'attachment; filename="{self.model._meta.model_name}_import_errors.csv"' + return response + +VOTER_MAPPABLE_FIELDS = [ + ('voter_id', 'Voter ID'), + ('first_name', 'First Name'), + ('last_name', 'Last Name'), + ('nickname', 'Nickname'), + ('birthdate', 'Birthdate'), + ('address_street', 'Street Address'), + ('city', 'City'), + ('state', 'State'), + ('zip_code', 'Zip Code'), + ('phone', 'Phone'), + ('email', 'Email'), + ('is_targeted', 'Is Targeted'), + ('target_door_visit', 'Target Door Visit'), + ('candidate_support', 'Candidate Support'), + ('yard_sign', 'Yard Sign'), + ('ever_had_yard_sign', 'Ever Had Yard Sign'), + ('ever_had_large_sign', 'Ever Had Large Sign'), + ('is_inactive', 'Is Inactive'), + ('door_visit', 'Door Visit'), + ('voted', 'Voted'), + ('neighborhood', 'Neighborhood'), + ('district', 'District'), + ('precinct', 'Precinct'), + ('registration_date', 'Registration Date'), + ('call_queue_status', 'Call Queue Status'), +] + +INTERACTION_MAPPABLE_FIELDS = [('voter_id', 'Voter ID'), ('volunteer_email', 'Volunteer Email'), ('date', 'Date'), ('type', 'Type'), ('description', 'Description'), ('notes', 'Notes')] +VOLUNTEER_MAPPABLE_FIELDS = [('first_name', 'First Name'), ('last_name', 'Last Name'), ('email', 'Email'), ('phone', 'Phone')] +VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [('voter_id', 'Voter ID'), ('election_type', 'Election Type'), ('likelihood', 'Likelihood')] +VOTING_RECORD_MAPPABLE_FIELDS = [('voter_id', 'Voter ID'), ('election_date', 'Election Date'), ('election_description', 'Description'), ('primary_party', 'Primary Party')] +EVENT_MAPPABLE_FIELDS = [('name', 'Name'), ('date', 'Date'), ('event_type', 'Event Type'), ('location_name', 'Location'), ('address', 'Address'), ('city', 'City'), ('state', 'State'), ('zip_code', 'Zip Code'), ('start_time', 'Start Time'), ('end_time', 'End Time')] + +@admin.register(Voter) +class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('voter_id', 'first_name', 'last_name', 'city', 'state', 'is_inactive', 'target_door_visit', 'ever_had_yard_sign', 'ever_had_large_sign', 'tenant') + list_filter = ('tenant', 'is_inactive', 'ever_had_yard_sign', 'ever_had_large_sign', 'target_door_visit', 'candidate_support', 'call_queue_status') + search_fields = ('voter_id', 'first_name', 'last_name', 'email', 'phone') + change_list_template = "admin/voter_change_list.html" + + def get_urls(self): + return [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voter-download-errors'), + path('import-voters/', self.admin_site.admin_view(self.import_voters), name='import-voters') + ] + super().get_urls() + + def import_voters(self, request): + if request.method == "POST": + if "_preview" in request.POST: + file_path, tenant_id = request.POST.get("file_path"), request.POST.get("tenant") + tenant, mapping = Tenant.objects.get(id=tenant_id), {fn: request.POST.get(f"map_{fn}") for fn, _ in VOTER_MAPPABLE_FIELDS} + try: + with _read_csv_robust(file_path) as f: + total_count = sum(1 for line in f) - 1 + f.seek(0) + reader = csv.DictReader(f) + preview_rows, v_ids = [], [] + for i, row in enumerate(reader): + if i < 10: + preview_rows.append(row) + vid = row.get(mapping.get("voter_id")) + if vid: v_ids.append(vid.strip()) + else: break + existing = set(Voter.objects.filter(tenant=tenant, voter_id__in=v_ids).values_list("voter_id", flat=True)) + preview_data = [{ + "action": "update" if r.get(mapping.get("voter_id"), "").strip() in existing else "create", + "identifier": f"Voter ID: {r.get(mapping.get('voter_id'))}", + "details": f"Name: {r.get(mapping.get('first_name', ''))} {r.get(mapping.get('last_name', ''))}" + } for r in preview_rows] + context = self.admin_site.each_context(request) + context.update({ + "title": "Import Preview", + "total_count": total_count, + "create_count": sum(1 for d in preview_data if d['action'] == 'create'), + "update_count": sum(1 for d in preview_data if d['action'] == 'update'), + "preview_data": preview_data, + "mapping": mapping, + "file_path": file_path, + "tenant_id": tenant_id, + "action_url": request.path, + "opts": self.model._meta + }) + return render(request, "admin/import_preview.html", context) + except Exception as e: + self.message_user(request, f"Error: {e}", level=messages.ERROR) + return redirect("../") + + elif "_import" in request.POST: + file_path, tenant_id = request.POST.get("file_path"), request.POST.get("tenant") + tenant, mapping = Tenant.objects.get(id=tenant_id), {fn: request.POST.get(f"map_{fn}") for fn, _ in VOTER_MAPPABLE_FIELDS} + try: + created, updated, errors, failed = 0, 0, 0, [] + + with _read_csv_robust(file_path) as f: + reader = csv.DictReader(f) + chunk_size = 500 + chunk = [] + for row in reader: + chunk.append(row) + if len(chunk) >= chunk_size: + c, u, e, f_rows = self._process_voter_chunk(tenant, mapping, chunk) + created += c; updated += u; errors += e; failed.extend(f_rows) + chunk = [] + if chunk: + c, u, e, f_rows = self._process_voter_chunk(tenant, mapping, chunk) + created += c; updated += u; errors += e; failed.extend(f_rows) + + # Efficient post-import cleanup for the entire tenant + self._run_voter_post_import_cleanup(tenant) + + if os.path.exists(file_path): os.remove(file_path) + self.message_user(request, f"Import complete: {created} created, {updated} updated, {errors} errors") + request.session[f"{self.model._meta.model_name}_import_errors"] = failed[:1000] + request.session.modified = True + return redirect("../") + except Exception as e: + logger.error(f"Voter import failed: {e}", exc_info=True) + self.message_user(request, f"Error: {e}", level=messages.ERROR) + return redirect("../") + else: + form = VoterImportForm(request.POST, request.FILES) + if form.is_valid(): + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in request.FILES['file'].chunks(): tmp.write(chunk) + file_path = tmp.name + with _read_csv_robust(file_path) as f: + headers = next(csv.reader(f)) + context = self.admin_site.each_context(request) + context.update({ + "title": "Map Voter Fields", + "headers": headers, + "model_fields": VOTER_MAPPABLE_FIELDS, + "tenant_id": form.cleaned_data['tenant'].id, + "file_path": file_path, + "action_url": request.path, + "opts": self.model._meta + }) + return render(request, "admin/import_mapping.html", context) + return render(request, "admin/import_csv.html", {'form': VoterImportForm(), 'title': "Import Voters", 'opts': self.model._meta, 'action_url': request.path}) + + def _process_voter_chunk(self, tenant, mapping, chunk): + created, updated, errors = 0, 0, 0 + failed = [] + voter_ids = [row.get(mapping.get("voter_id"), "").strip() for row in chunk if row.get(mapping.get("voter_id"))] + existing_voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids)} + + to_create = [] + to_update = [] + + # We'll use a transaction for each chunk to keep it atomic but not lock the whole table for long + with transaction.atomic(): + for row in chunk: + try: + vid = row.get(mapping.get("voter_id"), "").strip() + if not vid: + row["Import Error"] = "Missing Voter ID" + failed.append(row); errors += 1; continue + + defaults = {} + for fn, _ in VOTER_MAPPABLE_FIELDS: + if fn == "voter_id": continue + val = row.get(mapping.get(fn), "").strip() + if not val: continue + if fn in ["birthdate", "registration_date"]: + defaults[fn] = parse_any_date(val) + elif fn in ["is_targeted", "is_inactive", "target_door_visit", "door_visit", "voted"]: + defaults[fn] = val.lower() in ['true', '1', 'yes'] + elif fn == "phone": + defaults[fn] = format_phone_number(val) + elif fn == "email": + defaults[fn] = val.lower() + elif fn == "call_queue_status": + # Try to match label if it's not a valid internal value + valid_keys = [c[0] for c in Voter.CALL_QUEUE_STATUS_CHOICES] + if val not in valid_keys: + label_map = {c[1].lower(): c[0] for c in Voter.CALL_QUEUE_STATUS_CHOICES} + if val.lower() in label_map: + defaults[fn] = label_map[val.lower()] + else: + defaults[fn] = val + else: + defaults[fn] = val + else: + defaults[fn] = val + + if defaults.get("voted") is True: + defaults["target_door_visit"] = False + defaults["call_queue_status"] = "no_call_required" + + voter = existing_voters.get(vid) + if voter: + for k, v in defaults.items(): setattr(voter, k, v) + voter._skip_geocode = True # Important for performance + to_update.append(voter) + updated += 1 + else: + voter = Voter(tenant=tenant, voter_id=vid, **defaults) + voter._skip_geocode = True + to_create.append(voter) + created += 1 + except Exception as e: + row["Import Error"] = str(e) + failed.append(row); errors += 1 + + if to_create: + Voter.objects.bulk_create(to_create) + if to_update: + # bulk_update requires specifying fields + fields = [fn for fn, _ in VOTER_MAPPABLE_FIELDS if fn != 'voter_id'] + Voter.objects.bulk_update(to_update, fields) + + return created, updated, errors, failed + + def _run_voter_post_import_cleanup(self, tenant): + """ + Runs the logic that was previously in signals but optimized for bulk. + """ + from django.db.models import Exists, OuterRef + + # 0. Ensure consistency for voters who voted + Voter.objects.filter(tenant=tenant, voted=True).update( + target_door_visit=False, + call_queue_status="no_call_required" + ) + ScheduledCall.objects.filter(tenant=tenant, voter__voted=True, status="pending").update(status="cancelled") + + # 1. Update target_door_visit logic (based on signal logic) + # Set target_door_visit = False if door_visit = False and someone in household is targeted or has support + # This is a bit complex to do in one query, but let's do the most important parts. + + # Signal 1: Update target_door_visit = False if someone in household attended event or has support + subquery = Voter.objects.filter( + address_street=OuterRef('address_street'), + city=OuterRef('city'), + state=OuterRef('state'), + zip_code=OuterRef('zip_code'), + tenant=tenant, + is_targeted=True + ) + + # Set target_door_visit = False if NO ONE in household is targeted + Voter.objects.filter( + tenant=tenant, + door_visit=False, + target_door_visit=True + ).annotate(has_targeted=Exists(subquery)).filter(has_targeted=False).update(target_door_visit=False) + + # Signal 2: Update candidate_support to 'supporting' if someone in household has yard sign AND voter is > 30 + from datetime import date + today = date.today() + thirty_years_ago = today.replace(year=today.year - 30) if today.month != 2 or today.day != 29 else today.replace(year=today.year - 30, day=28) + + sign_subquery = Voter.objects.filter( + address_street=OuterRef('address_street'), + city=OuterRef('city'), + state=OuterRef('state'), + zip_code=OuterRef('zip_code'), + tenant=tenant, + yard_sign__in=['wants', 'has'] + ) + + Voter.objects.filter( + tenant=tenant, + birthdate__lte=thirty_years_ago + ).exclude( + candidate_support='supporting' + ).annotate(household_has_sign=Exists(sign_subquery)).filter(household_has_sign=True).update(candidate_support='supporting') + +class MassAssignVolunteerForm(forms.Form): + volunteer = forms.ModelChoiceField(queryset=Volunteer.objects.none(), required=True) + + def __init__(self, *args, **kwargs): + tenant_ids = kwargs.pop('tenant_ids', []) + super().__init__(*args, **kwargs) + if tenant_ids: + self.fields['volunteer'].queryset = Volunteer.objects.filter(tenant_id__in=tenant_ids).order_by('first_name', 'last_name') + else: + self.fields['volunteer'].queryset = Volunteer.objects.all().order_by('first_name', 'last_name') + + +@admin.register(Interaction) +class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('voter', 'date', 'type', 'description', 'volunteer') + list_filter = ('voter__tenant', 'type', 'volunteer') + search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description') + autocomplete_fields = ['voter', 'volunteer'] + change_list_template = 'admin/interaction_change_list.html' + actions = ['mass_assign_volunteer'] + + @admin.action(description="Assign selected interactions to a volunteer") + def mass_assign_volunteer(self, request, queryset): + tenant_ids = list(queryset.values_list('voter__tenant_id', flat=True).distinct()) + + if 'apply' in request.POST: + form = MassAssignVolunteerForm(request.POST, tenant_ids=tenant_ids) + if form.is_valid(): + volunteer = form.cleaned_data['volunteer'] + updated = queryset.update(volunteer=volunteer) + self.message_user(request, f"Successfully assigned {updated} interactions to {volunteer}.", messages.SUCCESS) + return None + else: + form = MassAssignVolunteerForm(tenant_ids=tenant_ids) + + return TemplateResponse(request, "admin/mass_assign_volunteer.html", { + 'queryset': queryset, + 'form': form, + 'opts': self.model._meta, + 'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME, + }) + + + def get_urls(self): + return [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='interaction-download-errors'), + path('import-interactions/', self.admin_site.admin_view(self.import_interactions), name='import-interactions') + ] + super().get_urls() + + def import_interactions(self, request): + if request.method == "POST": + if "_preview" in request.POST: + file_path, tenant_id = request.POST.get('file_path'), request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + campaign_tz = getattr(tenant.settings, 'timezone', 'UTC') + mapping = {fn: request.POST.get(f'map_{fn}') for fn, _ in INTERACTION_MAPPABLE_FIELDS} + try: + with _read_csv_robust(file_path) as f: + reader = csv.DictReader(f) + total_count, create_count, update_count, preview_data = 0, 0, 0, [] + for row in reader: + total_count += 1 + vid, type_name, date_str = row.get(mapping.get('voter_id')), row.get(mapping.get('type')), row.get(mapping.get('date')) + parsed_date = parse_any_date(date_str, campaign_tz) + exists = False + if vid and type_name and parsed_date: + try: + voter = Voter.objects.get(tenant=tenant, voter_id=vid) + exists = Interaction.objects.filter(voter=voter, type__name=type_name, date=parsed_date).exists() + except: pass + if exists: update_count += 1 + else: create_count += 1 + if len(preview_data) < 10: + preview_data.append({'action': 'update' if exists else 'create', 'identifier': f"Voter ID: {vid}", 'details': f"Type: {type_name}, Date: {date_str}"}) + context = self.admin_site.each_context(request) + context.update({'title': "Import Preview", 'total_count': total_count, 'create_count': create_count, 'update_count': update_count, 'preview_data': preview_data, 'mapping': mapping, 'file_path': file_path, 'tenant_id': tenant_id, 'action_url': request.path, 'opts': self.model._meta}) + return render(request, "admin/import_preview.html", context) + except Exception as e: + self.message_user(request, f"Error: {e}", level=messages.ERROR) + return redirect("../") + + elif "_import" in request.POST: + file_path, tenant_id = request.POST.get('file_path'), request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + campaign_tz = getattr(tenant.settings, 'timezone', 'UTC') + mapping = {fn: request.POST.get(f'map_{fn}') for fn, _ in INTERACTION_MAPPABLE_FIELDS} + try: + count, errors, failed = 0, 0, [] + # Optimized to avoid loading ALL voters + with _read_csv_robust(file_path) as f: + reader = csv.DictReader(f) + chunk_size = 500 + chunk = [] + for row in reader: + chunk.append(row) + if len(chunk) >= chunk_size: + c, e, f_rows = self._process_interaction_chunk(tenant, mapping, chunk, campaign_tz) + count += c; errors += e; failed.extend(f_rows) + chunk = [] + if chunk: + c, e, f_rows = self._process_interaction_chunk(tenant, mapping, chunk, campaign_tz) + count += c; errors += e; failed.extend(f_rows) + + if os.path.exists(file_path): os.remove(file_path) + self.message_user(request, f"Imported {count} interactions, {errors} errors") + request.session[f"{self.model._meta.model_name}_import_errors"] = failed[:1000] + request.session.modified = True + return redirect("../") + except Exception as e: + self.message_user(request, f"Error: {e}", level=messages.ERROR) + return redirect("../") + else: + form = InteractionImportForm(request.POST, request.FILES) + if form.is_valid(): + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in request.FILES['file'].chunks(): tmp.write(chunk) + file_path = tmp.name + with _read_csv_robust(file_path) as f: + headers = next(csv.reader(f)) + context = self.admin_site.each_context(request) + context.update({'title': "Map Interaction Fields", 'headers': headers, 'model_fields': INTERACTION_MAPPABLE_FIELDS, 'tenant_id': form.cleaned_data['tenant'].id, 'file_path': file_path, 'action_url': request.path, 'opts': self.model._meta}) + return render(request, "admin/import_mapping.html", context) + return render(request, "admin/import_csv.html", {'form': InteractionImportForm(), 'title': "Import Interactions", 'opts': self.model._meta, 'action_url': request.path}) + + def _process_interaction_chunk(self, tenant, mapping, chunk, campaign_tz): + count, errors = 0, 0 + failed = [] + voter_ids = [row.get(mapping.get("voter_id"), "").strip() for row in chunk if row.get(mapping.get("voter_id"))] + voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids)} + + # Pre-fetch interaction types + type_names = [row.get(mapping.get("type"), "").strip() for row in chunk if row.get(mapping.get("type"))] + types = {t.name: t for t in InteractionType.objects.filter(tenant=tenant, name__in=type_names)} + + to_create = [] + with transaction.atomic(): + for row in chunk: + try: + vid, type_name, date_str = row.get(mapping.get('voter_id'), "").strip(), row.get(mapping.get('type'), "").strip(), row.get(mapping.get('date'), "").strip() + if not vid or not type_name or not date_str: + row["Import Error"] = "Missing fields"; failed.append(row); errors += 1; continue + + voter = voters.get(vid) + if not voter: + row["Import Error"] = f"Voter {vid} not found"; failed.append(row); errors += 1; continue + + it_type = types.get(type_name) + if not it_type: + it_type, created = InteractionType.objects.get_or_create(tenant=tenant, name=type_name) + types[type_name] = it_type + + parsed_date = parse_any_date(date_str, campaign_tz) + if not parsed_date: + row["Import Error"] = f"Invalid date: {date_str}"; failed.append(row); errors += 1; continue + + # Interaction model uses DateTimeField, so if we got a date, we should make it a datetime + if isinstance(parsed_date, date) and not isinstance(parsed_date, datetime): + parsed_date = datetime.combine(parsed_date, datetime.min.time()) + if django_timezone.is_naive(parsed_date): + parsed_date = django_timezone.make_aware(parsed_date, zoneinfo.ZoneInfo(campaign_tz)) + + to_create.append(Interaction( + voter=voter, + type=it_type, + date=parsed_date, + description=row.get(mapping.get('description'), "")[:255], + notes=row.get(mapping.get('notes'), "") + )) + count += 1 + except Exception as e: + row["Import Error"] = str(e) + failed.append(row); errors += 1 + + if to_create: + Interaction.objects.bulk_create(to_create) + return count, errors, failed + +@admin.register(DonationMethod) +class DonationMethodAdmin(admin.ModelAdmin): + list_display = ('name', 'tenant', 'is_active') + list_filter = ('tenant', 'is_active') + +@admin.register(Donation) +class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('voter', 'amount', 'date', 'method', 'tenant_name') + list_filter = ('voter__tenant', 'method', 'date') + search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id') + autocomplete_fields = ['voter'] + + def tenant_name(self, obj): return obj.voter.tenant.name + tenant_name.short_description = "Tenant" + +@admin.register(InteractionType) +class InteractionTypeAdmin(admin.ModelAdmin): + list_display = ('name', 'tenant', 'is_active') + list_filter = ('tenant', 'is_active') + +@admin.register(ElectionType) +class ElectionTypeAdmin(admin.ModelAdmin): + list_display = ('name', 'tenant', 'is_active') + list_filter = ('tenant', 'is_active') + +@admin.register(VoterLikelihood) +class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('voter', 'election_type', 'likelihood') + list_filter = ('voter__tenant', 'election_type', 'likelihood') + autocomplete_fields = ['voter'] + +@admin.register(VotingRecord) +class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('voter', 'election_date', 'election_description', 'primary_party') + list_filter = ('voter__tenant', 'election_date', 'primary_party') + autocomplete_fields = ['voter'] + +@admin.register(Event) +class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('name', 'date', 'event_type', 'tenant') + list_filter = ('tenant', 'event_type', 'date') + search_fields = ('name', 'location_name') + +@admin.register(Volunteer) +class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant') + list_filter = ('tenant',) + search_fields = ('first_name', 'last_name', 'email') + +@admin.register(CampaignSettings) +class CampaignSettingsAdmin(admin.ModelAdmin): + list_display = ('tenant', 'timezone', 'donation_goal') + list_filter = ('tenant',) + +@admin.register(ScheduledCall) +class ScheduledCallAdmin(admin.ModelAdmin): + list_display = ('voter', 'volunteer', 'status', 'created_at', 'tenant') + list_filter = ('tenant', 'status', 'volunteer') + autocomplete_fields = ['voter', 'volunteer'] diff --git a/core/admin_backup.txt b/core/admin_backup.txt new file mode 100644 index 0000000..e69de29 diff --git a/core/admin_reconstruct.txt b/core/admin_reconstruct.txt new file mode 100644 index 0000000..4200c9a --- /dev/null +++ b/core/admin_reconstruct.txt @@ -0,0 +1 @@ +cess (PID\n: 2054)...'\\nUser: '[INFO] Gemini process stopped successfully'\\nUser: 'what mak\nes a unique interaction?'\\nAssistant: 'An **Interaction** in this system is a re\ncord of contact between the campaign and a voter. Based on the `Interaction` mod\nel in `core/models.py`, a unique interaction is defined by the combination of th\ne following components: 1. **Voter:** The person being contacted. 2. **Interacti\non Type:** The method of contact (e.g., Email, SMS, Call, Door Visit). 3. **Date\n & Time:** When the contact occurred. 4. **Volunteer:** The person who performed\n the contact (optional, can be null). 5. **Description/Outcome:** A short label \nof what happened (e.g., \\\"Left Message\\\", \\\"Talked to Voter\\\", \\\"Mass SMS Text\\\"\n). 6. **Notes:** Any detailed text or the body of the message (e.g., the specifi\nc email content). **Technically**, the only field that ensures uniqueness in the\n database is the **Primary Key (ID)**. There are no `unique_together` constraint\ns on the `Interaction` model itself, meaning the system can log multiple interac\ntions for the same voter, even with identical details, as long as they are creat\ned as separate records. However, **Interaction Types** (like \\\"Email\\\" or \\\"Door\n Visit\\\") are enforced as unique per tenant (campaign) via a `unique_together = \n('tenant', 'name')` constraint. Next: Would you like me to implement any specifi\nc logic to prevent duplicate interactions, or do you have another question about\n the data structure?'\\nUser: 'when importing interactions, all interactions are \nunique, however the import action states UPDATE'\\nUser: '[INFO] Gemini editing: \ncore/sedBlBMEg'\\nUser: '[INFO] Gemini editing: fix_admin.py'\\nUser: '[INFO] Gemi\nni editing: fix_admin_v2.py'\\nUser: '[INFO] Gemini editing: fix_admin_v3.py'\\nUs\ner: '[INFO] Gemini editing: fix_admin.py'\\nAssistant: 'Plan:'\\nAssistant: '1. **\nIdentify Root Cause**: Found that the import preview logic for `Interaction`, `D\nonation`, and `VoterLikelihood` was checking for the existence of the **Voter** \nrather than the record being imported. This caused all records for existing vote\nrs to be incorrectly labeled as \\\"UPDATE\\\". 2. **Implement Fix**: - Updated `Int\neractionAdmin.import_interactions` to check if an identical interaction (same vo\nter, type, and date) already exists. - Updated `DonationAdmin.import_donations` \nto check for existing identical donations (same voter, date, and amount). - Upda\nted `VoterLikelihoodAdmin.import_likelihoods` to check for existing likelihoods \nfor the specific voter and election type. 3. **Verification**: Restarted the ser\nvice and confirmed the application is running smoothly. Changed: * `core/admin.p\ny`: Corrected the preview logic for `Interaction`, `Donation`, and `VoterLikelih\nood` imports to ensure accurate \\\"CREATE\\\" vs \\\"UPDATE\\\" status reporting. Notes\n: * Since the system uses `create()` for interactions and donations (allowing du\nplicates), they will now correctly show as **CREATE** in the preview as long as \nan identical record doesn't already exist in the database. * This fix also ensur\nes that if you re-upload the same file twice, the preview will correctly warn yo\nu with an **UPDATE** label (even though the final import might still create a du\nplicate depending on the specific model's logic). Next: Please try the interacti\non import again. You should now see the \\\"CREATE\\\" label for your unique interac\ntions. Reminder: click **Save** in the editor to sync changes.'\\nUser: 'when imp\norting interactions, I'm getting an error \\\"Invalid date format\\\" when importing\n a valid timestamp'\\nUser: '[INFO] Gemini editing: core/admin.py'\\nUser: '[INFO]\n Gemini editing: core/admin.py'\\nUser: '[INFO] Gemini editing: core/admin.py'\\nU\nser: '[INFO] Gemini editing: core/admin.py'\\nUser: '[INFO] Gemini editing: core/\nadmin.py'\\nUser: '[INFO] Gemini editing: core/admin.py'\\nAssistant: 'Plan:'\\nAss\nistant: '1. **Identified Root Cause**: The date parsing logic in `core/admin.py`\n was overly restrictive, only supporting `%m/%d/%Y` and `%Y-%m-%d` formats. This\n caused failures for valid timestamps (ISO 8601 or formats including time). 2. *\n*Implemented Robust Parsing**: - Added a `parse_any_date` helper function to `co\nre/admin.py` that utilizes Django's `parse_datetime` and `parse_date` utilities,\n along with a comprehensive list of common CSV date formats. - Updated `Interact\nionAdmin.import_interactions` to use this new helper in both the import preview \n(for existence checking) and the actual import process. 3. **Broad Improvements*\n*: Applied the same robust parsing to `VoterAdmin`, `DonationAdmin`, and `Voting\nRecordAdmin` to ensure consistency across all data import types. 4. **Verificati\non**: Restarted the Django service and performed a health check to ensure the ap\nplication is running correctly. Changed: * `core/admin.py`: Added `parse_any_dat\ne` helper and updated all import views (`import_voters`, `import_donations`, `im\nport_interactions`, `import_voting_records`) to support flexible date and timest\namp formats. Notes: * The system now supports a wide range of date formats inclu\nding `YYYY-MM-DD HH:MM:SS`, `MM/DD/YYYY`, ISO 8601, and more. * If a timestamp i\nncludes time information, it will now be correctly parsed and stored in `DateTim\neField` fields (like `Interaction.date`) or truncated to a date for `DateField` \nfields (like `Voter.birthdate`). Next: Please try importing your interactions ag\nain with the timestamp. They should now be processed successfully. Reminder: cli\nck **Save** in the editor to sync changes.'\\nUser: 'when importing interactions,\n the time imported should be in the same timezone as the one in the campaign set\ntings'\\nUser: 'Project log version'\\n```\\nCurrent user message:\\nwhen importing \ninteractions, the time imported should be in the same timezone as the one in the\n campaign settings\\n\\n\",\"gemini_session_id\":null,\"model\":\"gemini-3-flash-preview\n\",\"context\":{\"url\":\"https://grassroots-campaign-manager-3d43.dev.flatlogic.app\",\n\"project_id\":37769,\"conversation_uuid\":\"66309fec-614d-4430-9a11-774c1761a2eb\",\"u\nser_profile\":\"User Experience Context (Survey):\\n- What industry is the most app\nlicable to your business?: Government/Public\\n- How many years of experience in \nweb development (programming) do you have?: 5-10 years\\n- What kind of app are y\nou looking to build?: SAAS\"}}} \n126584:Assistant: '1. **Identified Root Cause**: The date parsing logic in `core\n/admin.py` was overly restrictive, only supporting `%m/%d/%Y` and `%Y-%m-%d` for\nmats. This caused failures for valid timestamps (ISO 8601 or formats including t\nime). 2. **Implemented Robust Parsing**: - Added a `parse_any_date` helper funct\nion to `core/admin.py` that utilizes Django's `parse_datetime` and `parse_date` \nutilities, along with a comprehensive list of common CSV date formats. - Updated\n `InteractionAdmin.import_interactions` to use this new helper in both the impor\nt preview (for existence checking) and the actual import process. 3. **Broad Imp\nrovements**: Applied the same robust parsing to `VoterAdmin`, `DonationAdmin`, a\nnd `VotingRecordAdmin` to ensure consistency across all data import types. 4. **\nVerification**: Restarted the Django service and performed a health check to ens\nure the application is running correctly. Changed: * `core/admin.py`: Added `par\nse_any_date` helper and updated all import views (`import_voters`, `import_donat\nions`, `import_interactions`, `import_voting_records`) to support flexible date \nand timestamp formats. Notes: * The system now supports a wide range of date for\nmats including `YYYY-MM-DD HH:MM:SS`, `MM/DD/YYYY`, ISO 8601, and more. * If a t\nimestamp includes time information, it will now be correctly parsed and stored i\nn `DateTimeField` fields (like `Interaction.date`) or truncated to a date for `D\nateField` fields (like `Voter.birthdate`). Next: Please try importing your inter\nactions again with the timestamp. They should now be processed successfully. Rem\ninder: click **Save** in the editor to sync changes.' \n126646:Assistant: '1. **Identified Root Cause**: The date parsing logic in `core\n/admin.py` was overly restrictive, only supporting `%m/%d/%Y` and `%Y-%m-%d` for\nmats. This caused failures for valid timestamps (ISO 8601 or formats including t\nime). 2. **Implemented Robust Parsing**: - Added a `parse_any_date` helper funct\nion to `core/admin.py` that utilizes Django's `parse_datetime` and `parse_date` \nutilities, along with a comprehensive list of common CSV date formats. - Updated\n `InteractionAdmin.import_interactions` to use this new helper in both the impor\nt preview (for existence checking) and the actual import process. 3. **Broad Imp\nrovements**: Applied the same robust parsing to `VoterAdmin`, `DonationAdmin`, a\nnd `VotingRecordAdmin` to ensure consistency across all data import types. 4. **\nVerification**: Restarted the Django service and performed a health check to ens\nure the application is running correctly. Changed: * `core/admin.py`: Added `par\nse_any_date` helper and updated all import views (`import_voters`, `import_donat\nions`, `import_interactions`, `import_voting_records`) to support flexible date \nand timestamp formats. Notes: * The system now supports a wide range of date for\nmats including `YYYY-MM-DD HH:MM:SS`, `MM/DD/YYYY`, ISO 8601, and more. * If a t\nimestamp includes time information, it will now be correctly parsed and stored i\nn `DateTimeField` fields (like `Interaction.date`) or truncated to a date for `D\nateField` fields (like `Voter.birthdate`). Next: Please try importing your inter\nactions again with the timestamp. They should now be processed successfully. Rem\ninder: click **Save** in the editor to sync changes.'", diff --git a/core/admin_recovered.py b/core/admin_recovered.py new file mode 100644 index 0000000..7a2d820 --- /dev/null +++ b/core/admin_recovered.py @@ -0,0 +1 @@ +NONE diff --git a/core/admin_restored.py b/core/admin_restored.py new file mode 100644 index 0000000..7a2d820 --- /dev/null +++ b/core/admin_restored.py @@ -0,0 +1 @@ +NONE diff --git a/core/apps.py b/core/apps.py new file mode 100644 index 0000000..8115ae6 --- /dev/null +++ b/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core' diff --git a/core/bulk_email_new.py b/core/bulk_email_new.py new file mode 100644 index 0000000..220c060 --- /dev/null +++ b/core/bulk_email_new.py @@ -0,0 +1,67 @@ +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') diff --git a/core/bulk_sms_new.py b/core/bulk_sms_new.py new file mode 100644 index 0000000..6d78461 --- /dev/null +++ b/core/bulk_sms_new.py @@ -0,0 +1,92 @@ +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') diff --git a/core/context_processors.py b/core/context_processors.py new file mode 100644 index 0000000..d773b15 --- /dev/null +++ b/core/context_processors.py @@ -0,0 +1,43 @@ +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 +) + +def project_context(request): + """ + Adds project-specific environment variables to the template context globally. + """ + context = { + "project_description": os.getenv("PROJECT_DESCRIPTION", ""), + "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), + # Used for cache-busting static assets + "deployment_timestamp": int(time.time()), + "GOOGLE_MAPS_API_KEY": getattr(settings, 'GOOGLE_MAPS_API_KEY', ''), + } + + 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() + if tenant: + context['can_view_donations'] = can_view_donations(request.user, tenant) + context['can_edit_voter'] = can_edit_voter(request.user, tenant) + 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 + + return context diff --git a/core/export_new.py b/core/export_new.py new file mode 100644 index 0000000..a1aea71 --- /dev/null +++ b/core/export_new.py @@ -0,0 +1,64 @@ +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 diff --git a/core/filter_helper.py b/core/filter_helper.py new file mode 100644 index 0000000..5dcdb2c --- /dev/null +++ b/core/filter_helper.py @@ -0,0 +1,114 @@ +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 \ No newline at end of file diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..4cb6d7c --- /dev/null +++ b/core/forms.py @@ -0,0 +1,518 @@ +from django import forms +from django.contrib.auth.models import User +from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType, Tenant, ParticipationStatus, Volunteer, VolunteerEvent, VolunteerRole, ScheduledCall +from core.permissions import get_user_role + +class Select2MultipleWidget(forms.SelectMultiple): + """ + Custom widget to mark fields for Select2 initialization in the template. + """ + def __init__(self, attrs=None, choices=()): + default_attrs = {"multiple": "multiple"} + if attrs: + default_attrs.update(attrs) + super().__init__(attrs=default_attrs, choices=choices) + +class VoterForm(forms.ModelForm): + class Meta: + model = Voter + fields = [ + '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' + ] + widgets = { + 'birthdate': forms.DateInput(attrs={'type': 'date'}), + 'registration_date': forms.DateInput(attrs={'type': 'date'}), + '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: + if user.is_superuser: + is_admin = True + elif tenant: + role = get_user_role(user, tenant) + if role in ["admin", "system_admin", "campaign_admin"]: + is_admin = True + + if not is_admin: + restricted_fields = [ + "first_name", "last_name", "voter_id", "district", "precinct", + "registration_date", "address_street", "city", "state", "zip_code" + ] + for field_name in restricted_fields: + if field_name in self.fields: + self.fields[field_name].widget.attrs["readonly"] = True + self.fields[field_name].widget.attrs["class"] = self.fields[field_name].widget.attrs.get("class", "") + " bg-light" + + for name, field in self.fields.items(): + if name in ['latitude', 'longitude']: + continue + if isinstance(field.widget, forms.CheckboxInput): + field.widget.attrs.update({'class': 'form-check-input'}) + else: + field.widget.attrs.update({'class': 'form-control'}) + + self.fields['candidate_support'].widget.attrs.update({'class': 'form-select'}) + 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['secondary_phone_type'].widget.attrs.update({'class': 'form-select'}) + self.fields['call_queue_status'].widget.attrs.update({'class': 'form-select'}) + + + def clean(self): + cleaned_data = super().clean() + + # Backend protection for restricted fields + is_admin = False + user = getattr(self, "user", None) + tenant = getattr(self, "tenant", None) + + if self.user: + if self.user.is_superuser: + is_admin = True + elif self.tenant: + role = get_user_role(self.user, self.tenant) + if role in ["admin", "system_admin", "campaign_admin"]: + is_admin = True + + if not is_admin and self.instance.pk: + restricted_fields = [ + "first_name", "last_name", "voter_id", "district", "precinct", + "registration_date", "address_street", "city", "state", "zip_code" + ] + for field in restricted_fields: + if field in self.changed_data: + # Revert to original value + cleaned_data[field] = getattr(self.instance, field) + + return cleaned_data + +class AdvancedVoterSearchForm(forms.Form): + MONTH_CHOICES = [ + ('', 'Any Month'), + (1, 'January'), (2, 'February'), (3, 'March'), (4, 'April'), + (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) + address = forms.CharField(required=False) + voter_id = forms.CharField(required=False, label="Voter ID") + birth_month = forms.ChoiceField(choices=MONTH_CHOICES, required=False, label="Birth Month") + city = forms.CharField(required=False) + zip_code = forms.CharField(required=False) + 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") + 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") + candidate_support = forms.ChoiceField( + choices=[('', 'Any')] + Voter.CANDIDATE_SUPPORT_CHOICES, + required=False + ) + yard_sign = forms.ChoiceField( + choices=[('', 'Any')] + Voter.YARD_SIGN_CHOICES, + required=False + ) + window_sticker = forms.ChoiceField( + 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") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + if isinstance(field.widget, forms.CheckboxInput): + field.widget.attrs.update({'class': 'form-check-input'}) + else: + field.widget.attrs.update({'class': 'form-control'}) + + self.fields['birth_month'].widget.attrs.update({'class': 'form-select'}) + self.fields['candidate_support'].widget.attrs.update({'class': 'form-select'}) + 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: + model = Interaction + fields = ['type', 'volunteer', 'date', 'description', 'notes'] + widgets = { + 'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'), + 'notes': forms.Textarea(attrs={'rows': 2}), + } + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + self.fields['type'].queryset = InteractionType.objects.filter(tenant=tenant, is_active=True) + self.fields['volunteer'].queryset = Volunteer.objects.filter(tenant=tenant) + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['type'].widget.attrs.update({'class': 'form-select'}) + self.fields['volunteer'].widget.attrs.update({'class': 'form-select'}) + if self.instance and self.instance.date: + self.initial['date'] = self.instance.date.strftime('%Y-%m-%dT%H:%M') + +class DonationForm(forms.ModelForm): + class Meta: + model = Donation + fields = ['date', 'method', 'amount'] + widgets = { + 'date': forms.DateInput(attrs={'type': 'date'}), + } + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + self.fields['method'].queryset = DonationMethod.objects.filter(tenant=tenant, is_active=True) + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['method'].widget.attrs.update({'class': 'form-select'}) + +class VoterLikelihoodForm(forms.ModelForm): + class Meta: + model = VoterLikelihood + fields = ['election_type', 'likelihood'] + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + self.fields['election_type'].queryset = ElectionType.objects.filter(tenant=tenant, is_active=True) + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['election_type'].widget.attrs.update({'class': 'form-select'}) + self.fields['likelihood'].widget.attrs.update({'class': 'form-select'}) + +class EventParticipationForm(forms.ModelForm): + class Meta: + model = EventParticipation + fields = ['event', 'participation_status'] + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + self.fields['event'].queryset = Event.objects.filter(tenant=tenant) + self.fields['participation_status'].queryset = ParticipationStatus.objects.filter(tenant=tenant, is_active=True) + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['event'].widget.attrs.update({'class': 'form-select'}) + self.fields['participation_status'].widget.attrs.update({'class': 'form-select'}) + +class EventParticipantAddForm(forms.ModelForm): + class Meta: + model = EventParticipation + fields = ['voter', 'participation_status'] + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + voter_id = self.data.get('voter') or self.initial.get('voter') + if voter_id: + self.fields['voter'].queryset = Voter.objects.filter(tenant=tenant, id=voter_id) + else: + self.fields['voter'].queryset = Voter.objects.none() + self.fields['participation_status'].queryset = ParticipationStatus.objects.filter(tenant=tenant, is_active=True) + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['voter'].widget.attrs.update({'class': 'form-select'}) + self.fields['participation_status'].widget.attrs.update({'class': 'form-select'}) + +class EventForm(forms.ModelForm): + class Meta: + model = Event + fields = ['name', 'date', 'start_time', 'end_time', 'event_type', 'default_volunteer_role', 'description', 'location_name', 'address', 'city', 'state', 'zip_code', 'latitude', 'longitude'] + widgets = { + 'date': forms.DateInput(attrs={'type': 'date'}), + 'start_time': forms.TimeInput(attrs={'type': 'time'}), + 'end_time': forms.TimeInput(attrs={'type': 'time'}), + 'description': forms.Textarea(attrs={'rows': 2}), + } + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + self.fields['event_type'].queryset = EventType.objects.filter(tenant=tenant, is_active=True) + self.fields['default_volunteer_role'].queryset = VolunteerRole.objects.filter(tenant=tenant, is_active=True) + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['event_type'].widget.attrs.update({'class': 'form-select'}) + self.fields['default_volunteer_role'].widget.attrs.update({'class': 'form-select'}) + +class VoterImportForm(forms.Form): + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign") + file = forms.FileField(label="Select CSV file") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'}) + self.fields['file'].widget.attrs.update({'class': 'form-control'}) + +class EventImportForm(forms.Form): + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign") + file = forms.FileField(label="Select CSV file") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'}) + self.fields['file'].widget.attrs.update({'class': 'form-control'}) + +class EventParticipationImportForm(forms.Form): + file = forms.FileField(label="Select CSV file") + + def __init__(self, *args, event=None, **kwargs): + super().__init__(*args, **kwargs) + # No tenant field needed as event_id is passed directly + self.fields['file'].widget.attrs.update({'class': 'form-control'}) + +class ParticipantMappingForm(forms.Form): + def __init__(self, *args, headers, tenant, **kwargs): + super().__init__(*args, **kwargs) + self.fields['email_column'] = forms.ChoiceField( + choices=[(header, header) for header in headers], + label="Column for Email Address", + required=True, + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + name_choices = [('', '-- Select Name Column (Optional) --')] + [(header, header) for header in headers] + self.fields['name_column'] = forms.ChoiceField( + choices=name_choices, + label="Column for Participant Name", + required=False, + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + phone_choices = [('', '-- Select Phone Column (Optional) --')] + [(header, header) for header in headers] + self.fields['phone_column'] = forms.ChoiceField( + choices=phone_choices, + label="Column for Phone Number", + required=False, + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + participation_status_choices = [('', '-- Select Status Column (Optional) --')] + [(header, header) for header in headers] + self.fields['participation_status_column'] = forms.ChoiceField( + choices=participation_status_choices, + label="Column for Participation Status", + required=False, + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + # Optional: Add a default participation status if no column is mapped + self.fields['default_participation_status'] = forms.ModelChoiceField( + queryset=ParticipationStatus.objects.filter(tenant=tenant, is_active=True), + label="Default Participation Status (if no column mapped or column is empty)", + required=False, + empty_label="-- Select a Default Status --", + widget=forms.Select(attrs={'class': 'form-select'}) + ) + +class DonationImportForm(forms.Form): + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign") + file = forms.FileField(label="Select CSV file") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'}) + self.fields['file'].widget.attrs.update({'class': 'form-control'}) + +class InteractionImportForm(forms.Form): + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign") + file = forms.FileField(label="Select CSV file") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'}) + self.fields['file'].widget.attrs.update({'class': 'form-control'}) + +class VoterLikelihoodImportForm(forms.Form): + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign") + file = forms.FileField(label="Select CSV file") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'}) + self.fields['file'].widget.attrs.update({'class': 'form-control'}) + +class VolunteerImportForm(forms.Form): + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign") + file = forms.FileField(label="Select CSV file") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'}) + self.fields['file'].widget.attrs.update({'class': 'form-control'}) + +class VolunteerForm(forms.ModelForm): + class Meta: + model = Volunteer + fields = ['first_name', 'last_name', 'email', 'phone', 'is_default_caller', 'notes', 'interests'] + widgets = { + 'notes': forms.Textarea(attrs={'rows': 3}), + 'interests': Select2MultipleWidget(), + } + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + from .models import Interest + self.fields['interests'].queryset = Interest.objects.filter(tenant=tenant) + for field in self.fields.values(): + if not isinstance(field.widget, forms.CheckboxInput): + field.widget.attrs.update({'class': 'form-control'}) + else: + field.widget.attrs.update({'class': 'form-check-input'}) + +class VolunteerEventForm(forms.ModelForm): + class Meta: + model = VolunteerEvent + fields = ['event', 'role_type'] + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + self.fields['event'].queryset = Event.objects.filter(tenant=tenant) + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['event'].widget.attrs.update({'class': 'form-select'}) + +class VolunteerEventAddForm(forms.ModelForm): + class Meta: + model = VolunteerEvent + fields = ['volunteer', 'role_type'] + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + volunteer_id = self.data.get('volunteer') or self.initial.get('volunteer') + if volunteer_id: + self.fields['volunteer'].queryset = Volunteer.objects.filter(tenant=tenant, id=volunteer_id) + else: + self.fields['volunteer'].queryset = Volunteer.objects.none() + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['volunteer'].widget.attrs.update({'class': 'form-select'}) + +class VotingRecordImportForm(forms.Form): + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign") + file = forms.FileField(label="Select CSV file") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'}) + self.fields['file'].widget.attrs.update({'class': 'form-control'}) + +class DoorVisitLogForm(forms.Form): + OUTCOME_CHOICES = [ + ("No Answer Left Literature", "No Answer Left Literature"), + ("Spoke to voter", "Spoke to voter"), + ("No Access to House", "No Access to House"), + ] + outcome = forms.ChoiceField( + choices=OUTCOME_CHOICES, + widget=forms.RadioSelect(attrs={"class": "btn-check"}), + label="Outcome" + ) + notes = forms.CharField( + widget=forms.Textarea(attrs={"class": "form-control", "rows": 3}), + 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" + ) + candidate_support = forms.ChoiceField( + choices=Voter.CANDIDATE_SUPPORT_CHOICES, + initial="unknown", + widget=forms.Select(attrs={"class": "form-select"}), + label="Candidate Support" + ) + follow_up = forms.BooleanField( + required=False, + 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") + + 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) + call_notes = forms.CharField( + widget=forms.Textarea(attrs={"class": "form-control", "rows": 2}), + required=False, + label="Call Notes" + ) + +class ScheduledCallForm(forms.ModelForm): + class Meta: + model = ScheduledCall + fields = ['volunteer', 'comments'] + widgets = { + 'comments': forms.Textarea(attrs={'rows': 3}), + } + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + self.fields['volunteer'].queryset = Volunteer.objects.filter(tenant=tenant) + default_caller = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first() + if default_caller: + self.initial['volunteer'] = default_caller + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['volunteer'].widget.attrs.update({'class': 'form-select'}) + +class UserUpdateForm(forms.ModelForm): + class Meta: + model = User + fields = ['first_name', 'last_name', 'email'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) \ No newline at end of file diff --git a/core/grep_dump.txt b/core/grep_dump.txt new file mode 100644 index 0000000..e136d5d --- /dev/null +++ b/core/grep_dump.txt @@ -0,0 +1,24 @@ +/home/ubuntu/executor/workspace/core/admin_reconstruct.txt:cess (PID\n: 2054)...'\\nUser: '[INFO] Gemini process stopped successfully'\\nUser: 'what mak\nes a unique interaction?'\\nAssistant: 'An **Interaction** in this system is a re\ncord of contact between the campaign and a voter. Based on the `Interaction` mod\nel in `core/models.py`, a unique interaction is defined by the combination of th\ne following components: 1. **Voter:** The person being contacted. 2. **Interacti\non Type:** The method of contact (e.g., Email, SMS, Call, Door Visit). 3. **Date\n & Time:** When the contact occurred. 4. **Volunteer:** The person who performed\n the contact (optional, can be null). 5. **Description/Outcome:** A short label \nof what happened (e.g., \\\"Left Message\\\", \\\"Talked to Voter\\\", \\\"Mass SMS Text\\\"\n). 6. **Notes:** Any detailed text or the body of the message (e.g., the specifi\nc email content). **Technically**, the only field that ensures uniqueness in the\n database is the **Primary Key (ID)**. There are no `unique_together` constraint\ns on the `Interaction` model itself, meaning the system can log multiple interac\ntions for the same voter, even with identical details, as long as they are creat\ned as separate records. However, **Interaction Types** (like \\\"Email\\\" or \\\"Door\n Visit\\\") are enforced as unique per tenant (campaign) via a `unique_together = \n('tenant', 'name')` constraint. Next: Would you like me to implement any specifi\nc logic to prevent duplicate interactions, or do you have another question about\n the data structure?'\\nUser: 'when importing interactions, all interactions are \nunique, however the import action states UPDATE'\\nUser: '[INFO] Gemini editing: \ncore/sedBlBMEg'\\nUser: '[INFO] Gemini editing: fix_admin.py'\\nUser: '[INFO] Gemi\nni editing: fix_admin_v2.py'\\nUser: '[INFO] Gemini editing: fix_admin_v3.py'\\nUs\ner: '[INFO] Gemini editing: fix_admin.py'\\nAssistant: 'Plan:'\\nAssistant: '1. **\nIdentify Root Cause**: Found that the import preview logic for `Interaction`, `D\nonation`, and `VoterLikelihood` was checking for the existence of the **Voter** \nrather than the record being imported. This caused all records for existing vote\nrs to be incorrectly labeled as \\\"UPDATE\\\". 2. **Implement Fix**: - Updated `Int\neractionAdmin.import_interactions` to check if an identical interaction (same vo\nter, type, and date) already exists. - Updated `DonationAdmin.import_donations` \nto check for existing identical donations (same voter, date, and amount). - Upda\nted `VoterLikelihoodAdmin.import_likelihoods` to check for existing likelihoods \nfor the specific voter and election type. 3. **Verification**: Restarted the ser\nvice and confirmed the application is running smoothly. Changed: * `core/admin.p\ny`: Corrected the preview logic for `Interaction`, `Donation`, and `VoterLikelih\nood` imports to ensure accurate \\\"CREATE\\\" vs \\\"UPDATE\\\" status reporting. Notes\n: * Since the system uses `create()` for interactions and donations (allowing du\nplicates), they will now correctly show as **CREATE** in the preview as long as \nan identical record doesn't already exist in the database. * This fix also ensur\nes that if you re-upload the same file twice, the preview will correctly warn yo\nu with an **UPDATE** label (even though the final import might still create a du\nplicate depending on the specific model's logic). Next: Please try the interacti\non import again. You should now see the \\\"CREATE\\\" label for your unique interac\ntions. Reminder: click **Save** in the editor to sync changes.'\\nUser: 'when imp\norting interactions, I'm getting an error \\\"Invalid date format\\\" when importing\n a valid timestamp'\\nUser: '[INFO] Gemini editing: core/admin.py'\\nUser: '[INFO]\n Gemini editing: core/admin.py'\\nUser: '[INFO] Gemini editing: core/admin.py'\\nU\nser: '[INFO] Gemini editing: core/admin.py'\\nUser: '[INFO] Gemini editing: core/\nadmin.py'\\nUser: '[INFO] Gemini editing: core/admin.py'\\nAssistant: 'Plan:'\\nAss\nistant: '1. **Identified Root Cause**: The date parsing logic in `core/admin.py`\n was overly restrictive, only supporting `%m/%d/%Y` and `%Y-%m-%d` formats. This\n caused failures for valid timestamps (ISO 8601 or formats including time). 2. *\n*Implemented Robust Parsing**: - Added a `parse_any_date` helper function to `co\nre/admin.py` that utilizes Django's `parse_datetime` and `parse_date` utilities,\n along with a comprehensive list of common CSV date formats. - Updated `Interact\nionAdmin.import_interactions` to use this new helper in both the import preview \n(for existence checking) and the actual import process. 3. **Broad Improvements*\n*: Applied the same robust parsing to `VoterAdmin`, `DonationAdmin`, and `Voting\nRecordAdmin` to ensure consistency across all data import types. 4. **Verificati\non**: Restarted the Django service and performed a health check to ensure the ap\nplication is running correctly. Changed: * `core/admin.py`: Added `parse_any_dat\ne` helper and updated all import views (`import_voters`, `import_donations`, `im\nport_interactions`, `import_voting_records`) to support flexible date and timest\namp formats. Notes: * The system now supports a wide range of date formats inclu\nding `YYYY-MM-DD HH:MM:SS`, `MM/DD/YYYY`, ISO 8601, and more. * If a timestamp i\nncludes time information, it will now be correctly parsed and stored in `DateTim\neField` fields (like `Interaction.date`) or truncated to a date for `DateField` \nfields (like `Voter.birthdate`). Next: Please try importing your interactions ag\nain with the timestamp. They should now be processed successfully. Reminder: cli\nck **Save** in the editor to sync changes.'\\nUser: 'when importing interactions,\n the time imported should be in the same timezone as the one in the campaign set\ntings'\\nUser: 'Project log version'\\n```\\nCurrent user message:\\nwhen importing \ninteractions, the time imported should be in the same timezone as the one in the\n campaign settings\\n\\n\",\"gemini_session_id\":null,\"model\":\"gemini-3-flash-preview\n\",\"context\":{\"url\":\"https://grassroots-campaign-manager-3d43.dev.flatlogic.app\",\n\"project_id\":37769,\"conversation_uuid\":\"66309fec-614d-4430-9a11-774c1761a2eb\",\"u\nser_profile\":\"User Experience Context (Survey):\\n- What industry is the most app\nlicable to your business?: Government/Public\\n- How many years of experience in \nweb development (programming) do you have?: 5-10 years\\n- What kind of app are y\nou looking to build?: SAAS\"}}} \n126584:Assistant: '1. **Identified Root Cause**: The date parsing logic in `core\n/admin.py` was overly restrictive, only supporting `%m/%d/%Y` and `%Y-%m-%d` for\nmats. This caused failures for valid timestamps (ISO 8601 or formats including t\nime). 2. **Implemented Robust Parsing**: - Added a `parse_any_date` helper funct\nion to `core/admin.py` that utilizes Django's `parse_datetime` and `parse_date` \nutilities, along with a comprehensive list of common CSV date formats. - Updated\n `InteractionAdmin.import_interactions` to use this new helper in both the impor\nt preview (for existence checking) and the actual import process. 3. **Broad Imp\nrovements**: Applied the same robust parsing to `VoterAdmin`, `DonationAdmin`, a\nnd `VotingRecordAdmin` to ensure consistency across all data import types. 4. **\nVerification**: Restarted the Django service and performed a health check to ens\nure the application is running correctly. Changed: * `core/admin.py`: Added `par\nse_any_date` helper and updated all import views (`import_voters`, `import_donat\nions`, `import_interactions`, `import_voting_records`) to support flexible date \nand timestamp formats. Notes: * The system now supports a wide range of date for\nmats including `YYYY-MM-DD HH:MM:SS`, `MM/DD/YYYY`, ISO 8601, and more. * If a t\nimestamp includes time information, it will now be correctly parsed and stored i\nn `DateTimeField` fields (like `Interaction.date`) or truncated to a date for `D\nateField` fields (like `Voter.birthdate`). Next: Please try importing your inter\nactions again with the timestamp. They should now be processed successfully. Rem\ninder: click **Save** in the editor to sync changes.' \n126646:Assistant: '1. **Identified Root Cause**: The date parsing logic in `core\n/admin.py` was overly restrictive, only supporting `%m/%d/%Y` and `%Y-%m-%d` for\nmats. This caused failures for valid timestamps (ISO 8601 or formats including t\nime). 2. **Implemented Robust Parsing**: - Added a `parse_any_date` helper funct\nion to `core/admin.py` that utilizes Django's `parse_datetime` and `parse_date` \nutilities, along with a comprehensive list of common CSV date formats. - Updated\n `InteractionAdmin.import_interactions` to use this new helper in both the impor\nt preview (for existence checking) and the actual import process. 3. **Broad Imp\nrovements**: Applied the same robust parsing to `VoterAdmin`, `DonationAdmin`, a\nnd `VotingRecordAdmin` to ensure consistency across all data import types. 4. **\nVerification**: Restarted the Django service and performed a health check to ens\nure the application is running correctly. Changed: * `core/admin.py`: Added `par\nse_any_date` helper and updated all import views (`import_voters`, `import_donat\nions`, `import_interactions`, `import_voting_records`) to support flexible date \nand timestamp formats. Notes: * The system now supports a wide range of date for\nmats including `YYYY-MM-DD HH:MM:SS`, `MM/DD/YYYY`, ISO 8601, and more. * If a t\nimestamp includes time information, it will now be correctly parsed and stored i\nn `DateTimeField` fields (like `Interaction.date`) or truncated to a date for `D\nateField` fields (like `Voter.birthdate`). Next: Please try importing your inter\nactions again with the timestamp. They should now be processed successfully. Rem\ninder: click **Save** in the editor to sync changes.'", +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-38-2d2510b4.json: "content": "User Experience Context (Survey):\n- What industry is the most applicable to your business?: Government/Public\n- How many years of experience in web development (programming) do you have?: 5-10 years\n- What kind of app are you looking to build?: SAAS\n\nHistory (last 30 messages):\n```\nUser: '502 Bad Gateway An error occurred while opening a stream to the origin.'\nUser: 'ping'\nAssistant: 'I am here. I'm investigating the \"502 Bad Gateway\" error. It appears the server is under heavy load due to some unoptimized queries on the large dataset (180k+ voters). I'm currently optimizing the **Door Visits** and **Dashboard** views to handle this scale more efficiently and prevent timeouts. Plan: 1. **Dashboard Optimization**: Update the household count logic to use the indexed `address_street` instead of the non-indexed `address` field. 2. **Door Visits Optimization**: Rewrite the grouping logic to use `values()` instead of instantiating thousands of Django objects, and optimize the Google Maps data payload to reduce response size (currently ~4.2MB). 3. **Service Restart**: After optimization, I'll restart the service to ensure it's responsive. Next: I will begin applying these optimizations to `core/views.py`. Reminder: click **Save** in the editor to sync changes once I've finished the edits. (I will start by optimizing the `index` metrics and the `door_visits` view).'\nUser: '[INFO] Gemini editing: core/sedDyWQm9'\nUser: '[INFO] Gemini editing: patch_door_visits.py'\nUser: '[INFO] Gemini editing: patch_door_visits.py'\nAssistant: 'AI generation was stopped by user request.'\nUser: '[INFO] Stopping active Gemini process (PID: 2054)...'\nUser: '[INFO] Gemini process stopped successfully'\nUser: 'what makes a unique interaction?'\nAssistant: 'An **Interaction** in this system is a record of contact between the campaign and a voter. Based on the `Interaction` model in `core/models.py`, a unique interaction is defined by the combination of the following components: 1. **Voter:** The person being contacted. 2. **Interaction Type:** The method of contact (e.g., Email, SMS, Call, Door Visit). 3. **Date & Time:** When the contact occurred. 4. **Volunteer:** The person who performed the contact (optional, can be null). 5. **Description/Outcome:** A short label of what happened (e.g., \"Left Message\", \"Talked to Voter\", \"Mass SMS Text\"). 6. **Notes:** Any detailed text or the body of the message (e.g., the specific email content). **Technically**, the only field that ensures uniqueness in the database is the **Primary Key (ID)**. There are no `unique_together` constraints on the `Interaction` model itself, meaning the system can log multiple interactions for the same voter, even with identical details, as long as they are created as separate records. However, **Interaction Types** (like \"Email\" or \"Door Visit\") are enforced as unique per tenant (campaign) via a `unique_together = ('tenant', 'name')` constraint. Next: Would you like me to implement any specific logic to prevent duplicate interactions, or do you have another question about the data structure?'\nUser: 'when importing interactions, all interactions are unique, however the import action states UPDATE'\nUser: '[INFO] Gemini editing: core/sedBlBMEg'\nUser: '[INFO] Gemini editing: fix_admin.py'\nUser: '[INFO] Gemini editing: fix_admin_v2.py'\nUser: '[INFO] Gemini editing: fix_admin_v3.py'\nUser: '[INFO] Gemini editing: fix_admin.py'\nAssistant: 'Plan:'\nAssistant: '1. **Identify Root Cause**: Found that the import preview logic for `Interaction`, `Donation`, and `VoterLikelihood` was checking for the existence of the **Voter** rather than the record being imported. This caused all records for existing voters to be incorrectly labeled as \"UPDATE\". 2. **Implement Fix**: - Updated `InteractionAdmin.import_interactions` to check if an identical interaction (same voter, type, and date) already exists. - Updated `DonationAdmin.import_donations` to check for existing identical donations (same voter, date, and amount). - Updated `VoterLikelihoodAdmin.import_likelihoods` to check for existing likelihoods for the specific voter and election type. 3. **Verification**: Restarted the service and confirmed the application is running smoothly. Changed: * `core/admin.py`: Corrected the preview logic for `Interaction`, `Donation`, and `VoterLikelihood` imports to ensure accurate \"CREATE\" vs \"UPDATE\" status reporting. Notes: * Since the system uses `create()` for interactions and donations (allowing duplicates), they will now correctly show as **CREATE** in the preview as long as an identical record doesn't already exist in the database. * This fix also ensures that if you re-upload the same file twice, the preview will correctly warn you with an **UPDATE** label (even though the final import might still create a duplicate depending on the specific model's logic). Next: Please try the interaction import again. You should now see the \"CREATE\" label for your unique interactions. Reminder: click **Save** in the editor to sync changes.'\nUser: 'when importing interactions, I'm getting an error \"Invalid date format\" when importing a valid timestamp'\nUser: '[INFO] Gemini editing: core/admin.py'\nUser: '[INFO] Gemini editing: core/admin.py'\nUser: '[INFO] Gemini editing: core/admin.py'\nUser: '[INFO] Gemini editing: core/admin.py'\nUser: '[INFO] Gemini editing: core/admin.py'\nUser: '[INFO] Gemini editing: core/admin.py'\nAssistant: 'Plan:'\nAssistant: '1. **Identified Root Cause**: The date parsing logic in `core/admin.py` was overly restrictive, only supporting `%m/%d/%Y` and `%Y-%m-%d` formats. This caused failures for valid timestamps (ISO 8601 or formats including time). 2. **Implemented Robust Parsing**: - Added a `parse_any_date` helper function to `core/admin.py` that utilizes Django's `parse_datetime` and `parse_date` utilities, along with a comprehensive list of common CSV date formats. - Updated `InteractionAdmin.import_interactions` to use this new helper in both the import preview (for existence checking) and the actual import process. 3. **Broad Improvements**: Applied the same robust parsing to `VoterAdmin`, `DonationAdmin`, and `VotingRecordAdmin` to ensure consistency across all data import types. 4. **Verification**: Restarted the Django service and performed a health check to ensure the application is running correctly. Changed: * `core/admin.py`: Added `parse_any_date` helper and updated all import views (`import_voters`, `import_donations`, `import_interactions`, `import_voting_records`) to support flexible date and timestamp formats. Notes: * The system now supports a wide range of date formats including `YYYY-MM-DD HH:MM:SS`, `MM/DD/YYYY`, ISO 8601, and more. * If a timestamp includes time information, it will now be correctly parsed and stored in `DateTimeField` fields (like `Interaction.date`) or truncated to a date for `DateField` fields (like `Voter.birthdate`). Next: Please try importing your interactions again with the timestamp. They should now be processed successfully. Reminder: click **Save** in the editor to sync changes.'\nUser: 'when importing interactions, the time imported should be in the same timezone as the one in the campaign settings'\nUser: 'Project log version'\n```\nCurrent user message:\nwhen importing interactions, the time imported should be in the same timezone as the one in the campaign settings\n\n" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-38-2d2510b4.json: "output": "Command: grep -an \"import_voters\" /home/ubuntu/.pm2/logs/fl-executor-out.log | tail -n 5\nDirectory: (root)\nOutput: 108376:Assistant: 'I have verified that `CANDIDATE_SUPPORT_CHOICES` is correctly\n implemented in: * `core/models.py`: Renamed from `SUPPORT_CHOICES` to match the\n expected attribute during import. * `core/forms.py`: All references updated. * \n`core/admin.py`: The `import_voters` logic now correctly references `Voter.CANDI\nDATE_SUPPORT_CHOICES`. No remaining references to the old `SUPPORT_CHOICES` were\n found in the codebase. You should now be able to proceed with the voter import \nwithout the previous attribute error. Reminder: click Save in the editor to sync\n changes.' \n126466:[gemini][stdout] * `core/admin.py`: Added `parse_any_date` helper and upd\nated all import views (`import_voters`, `import_donations`, `import_interactions\n`, `import_voting_records`) to support flexible date and timestamp formats. \n126542:[gemini][prompt.raw] {\"command\":\"exec.prompt\",\"data\":{\"project_id\":37769,\n\"project_uuid\":\"05736420-f47f-46c0-9196-f6ddfdb0d67a\",\"conversation_id\":41441,\"s\nervice_id\":30107,\"content\":\"User Experience Context (Survey):\\n- What industry i\ns the most applicable to your business?: Government/Public\\n- How many years of \nexperience in web development (programming) do you have?: 5-10 years\\n- What kin\nd of app are you looking to build?: SAAS\\n\\nHistory (last 30 messages):\\n```\\nUs\ner: '502 Bad Gateway An error occurred while opening a stream to the origin.'\\nU\nser: 'ping'\\nAssistant: 'I am here. I'm investigating the \\\"502 Bad Gateway\\\" er\nror. It appears the server is under heavy load due to some unoptimized queries o\nn the large dataset (180k+ voters). I'm currently optimizing the **Door Visits**\n and **Dashboard** views to handle this scale more efficiently and prevent timeo\nuts. Plan: 1. **Dashboard Optimization**: Update the household count logic to us\ne the indexed `address_street` instead of the non-indexed `address` field. 2. **\nDoor Visits Optimization**: Rewrite the grouping logic to use `values()` instead\n of instantiating thousands of Django objects, and optimize the Google Maps data\n payload to reduce response size (currently ~4.2MB). 3. **Service Restart**: Aft\ner optimization, I'll restart the service to ensure it's responsive. Next: I wil\nl begin applying these optimizations to `core/views.py`. Reminder: click **Save*\n* in the editor to sync changes once I've finished the edits. (I will start by o\nptimizing the `index` metrics and the `door_visits` view).'\\nUser: '[INFO] Gemin\ni editing: core/sedDyWQm9'\\nUser: '[INFO] Gemini editing: patch_door_visits.py'\\\nnUser: '[INFO] Gemini editing: patch_door_visits.py'\\nAssistant: 'AI generation \nwas stopped by user request.'\\nUser: '[INFO] Stopping active Gemini process (PID\n: 2054)...'\\nUser: '[INFO] Gemini process stopped successfully'\\nUser: 'what mak\nes a unique interaction?'\\nAssistant: 'An **Interaction** in this system is a re\ncord of contact between the campaign and a voter. Based on the `Interaction` mod\nel in `core/models.py`, a unique interaction is defined by the combination of th\ne following components: 1. **Voter:** The person being contacted. 2. **Interacti\non Type:** The method of contact (e.g., Email, SMS, Call, Door Visit). 3. **Date\n & Time:** When the contact occurred. 4. **Volunteer:** The person who performed\n the contact (optional, can be null). 5. **Description/Outcome:** A short label \nof what happened (e.g., \\\"Left Message\\\", \\\"Talked to Voter\\\", \\\"Mass SMS Text\\\"\n). 6. **Notes:** Any detailed text or the body of the message (e.g., the specifi\nc email content). **Technically**, the only field that ensures uniqueness in the\n database is the **Primary Key (ID)**. There are no `unique_together` constraint\ns on the `Interaction` model itself, meaning the system can log multiple interac\ntions for the same voter, even with identical details, as long as they are creat\ned as separate records. However, **Interaction Types** (like \\\"Email\\\" or \\\"Door\n Visit\\\") are enforced as unique per tenant (campaign) via a `unique_together = \n('tenant', 'name')` constraint. Next: Would you like me to implement any specifi\nc logic to prevent duplicate interactions, or do you have another question about\n the data structure?'\\nUser: 'when importing interactions, all interactions are \nunique, however the import action states UPDATE'\\nUser: '[INFO] Gemini editing: \ncore/sedBlBMEg'\\nUser: '[INFO] Gemini editing: fix_admin.py'\\nUser: '[INFO] Gemi\nni editing: fix_admin_v2.py'\\nUser: '[INFO] Gemini editing: fix_admin_v3.py'\\nUs\ner: '[INFO] Gemini editing: fix_admin.py'\\nAssistant: 'Plan:'\\nAssistant: '1. **\nIdentify Root Cause**: Found that the import preview logic for `Interaction`, `D\nonation`, and `VoterLikelihood` was checking for the existence of the **Voter** \nrather than the record being imported. This caused all records for existing vote\nrs to be incorrectly labeled as \\\"UPDATE\\\". 2. **Implement Fix**: - Updated `Int\neractionAdmin.import_interactions` to check if an identical interaction (same vo\nter, type, and date) already exists. - Updated `DonationAdmin.import_donations` \nto check for existing identical donations (same voter, date, and amount). - Upda\nted `VoterLikelihoodAdmin.import_likelihoods` to check for existing likelihoods \nfor the specific voter and election type. 3. **Verification**: Restarted the ser\nvice and confirmed the application is running smoothly. Changed: * `core/admin.p\ny`: Corrected the preview logic for `Interaction`, `Donation`, and `VoterLikelih\nood` imports to ensure accurate \\\"CREATE\\\" vs \\\"UPDATE\\\" status reporting. Notes\n: * Since the system uses `create()` for interactions and donations (allowing du\nplicates), they will now correctly show as **CREATE** in the preview as long as \nan identical record doesn't already exist in the database. * This fix also ensur\nes that if you re-upload the same file twice, the preview will correctly warn yo\nu with an **UPDATE** label (even though the final import might still create a du\nplicate depending on the specific model's logic). Next: Please try the interacti\non import again. You should now see the \\\"CREATE\\\" label for your unique interac\ntions. Reminder: click **Save** in the editor to sync changes.'\\nUser: 'when imp\norting interactions, I'm getting an error \\\"Invalid date format\\\" when importing\n a valid timestamp'\\nUser: '[INFO] Gemini editing: core/admin.py'\\nUser: '[INFO]\n Gemini editing: core/admin.py'\\nUser: '[INFO] Gemini editing: core/admin.py'\\nU\nser: '[INFO] Gemini editing: core/admin.py'\\nUser: '[INFO] Gemini editing: core/\nadmin.py'\\nUser: '[INFO] Gemini editing: core/admin.py'\\nAssistant: 'Plan:'\\nAss\nistant: '1. **Identified Root Cause**: The date parsing logic in `core/admin.py`\n was overly restrictive, only supporting `%m/%d/%Y` and `%Y-%m-%d` formats. This\n caused failures for valid timestamps (ISO 8601 or formats including time). 2. *\n*Implemented Robust Parsing**: - Added a `parse_any_date` helper function to `co\nre/admin.py` that utilizes Django's `parse_datetime` and `parse_date` utilities,\n along with a comprehensive list of common CSV date formats. - Updated `Interact\nionAdmin.import_interactions` to use this new helper in both the import preview \n(for existence checking) and the actual import process. 3. **Broad Improvements*\n*: Applied the same robust parsing to `VoterAdmin`, `DonationAdmin`, and `Voting\nRecordAdmin` to ensure consistency across all data import types. 4. **Verificati\non**: Restarted the Django service and performed a health check to ensure the ap\nplication is running correctly. Changed: * `core/admin.py`: Added `parse_any_dat\ne` helper and updated all import views (`import_voters`, `import_donations`, `im\nport_interactions`, `import_voting_records`) to support flexible date and timest\namp formats. Notes: * The system now supports a wide range of date formats inclu\nding `YYYY-MM-DD HH:MM:SS`, `MM/DD/YYYY`, ISO 8601, and more. * If a timestamp i\nncludes time information, it will now be correctly parsed and stored in `DateTim\neField` fields (like `Interaction.date`) or truncated to a date for `DateField` \nfields (like `Voter.birthdate`). Next: Please try importing your interactions ag\nain with the timestamp. They should now be processed successfully. Reminder: cli\nck **Save** in the editor to sync changes.'\\nUser: 'when importing interactions,\n the time imported should be in the same timezone as the one in the campaign set\ntings'\\nUser: 'Project log version'\\n```\\nCurrent user message:\\nwhen importing \ninteractions, the time imported should be in the same timezone as the one in the\n campaign settings\\n\\n\",\"gemini_session_id\":null,\"model\":\"gemini-3-flash-preview\n\",\"context\":{\"url\":\"https://grassroots-campaign-manager-3d43.dev.flatlogic.app\",\n\"project_id\":37769,\"conversation_uuid\":\"66309fec-614d-4430-9a11-774c1761a2eb\",\"u\nser_profile\":\"User Experience Context (Survey):\\n- What industry is the most app\nlicable to your business?: Government/Public\\n- How many years of experience in \nweb development (programming) do you have?: 5-10 years\\n- What kind of app are y\nou looking to build?: SAAS\"}}} \n126584:Assistant: '1. **Identified Root Cause**: The date parsing logic in `core\n/admin.py` was overly restrictive, only supporting `%m/%d/%Y` and `%Y-%m-%d` for\nmats. This caused failures for valid timestamps (ISO 8601 or formats including t\nime). 2. **Implemented Robust Parsing**: - Added a `parse_any_date` helper funct\nion to `core/admin.py` that utilizes Django's `parse_datetime` and `parse_date` \nutilities, along with a comprehensive list of common CSV date formats. - Updated\n `InteractionAdmin.import_interactions` to use this new helper in both the impor\nt preview (for existence checking) and the actual import process. 3. **Broad Imp\nrovements**: Applied the same robust parsing to `VoterAdmin`, `DonationAdmin`, a\nnd `VotingRecordAdmin` to ensure consistency across all data import types. 4. **\nVerification**: Restarted the Django service and performed a health check to ens\nure the application is running correctly. Changed: * `core/admin.py`: Added `par\nse_any_date` helper and updated all import views (`import_voters`, `import_donat\nions`, `import_interactions`, `import_voting_records`) to support flexible date \nand timestamp formats. Notes: * The system now supports a wide range of date for\nmats including `YYYY-MM-DD HH:MM:SS`, `MM/DD/YYYY`, ISO 8601, and more. * If a t\nimestamp includes time information, it will now be correctly parsed and stored i\nn `DateTimeField` fields (like `Interaction.date`) or truncated to a date for `D\nateField` fields (like `Voter.birthdate`). Next: Please try importing your inter\nactions again with the timestamp. They should now be processed successfully. Rem\ninder: click **Save** in the editor to sync changes.' \n126646:Assistant: '1. **Identified Root Cause**: The date parsing logic in `core\n/admin.py` was overly restrictive, only supporting `%m/%d/%Y` and `%Y-%m-%d` for\nmats. This caused failures for valid timestamps (ISO 8601 or formats including t\nime). 2. **Implemented Robust Parsing**: - Added a `parse_any_date` helper funct\nion to `core/admin.py` that utilizes Django's `parse_datetime` and `parse_date` \nutilities, along with a comprehensive list of common CSV date formats. - Updated\n `InteractionAdmin.import_interactions` to use this new helper in both the impor\nt preview (for existence checking) and the actual import process. 3. **Broad Imp\nrovements**: Applied the same robust parsing to `VoterAdmin`, `DonationAdmin`, a\nnd `VotingRecordAdmin` to ensure consistency across all data import types. 4. **\nVerification**: Restarted the Django service and performed a health check to ens\nure the application is running correctly. Changed: * `core/admin.py`: Added `par\nse_any_date` helper and updated all import views (`import_voters`, `import_donat\nions`, `import_interactions`, `import_voting_records`) to support flexible date \nand timestamp formats. Notes: * The system now supports a wide range of date for\nmats including `YYYY-MM-DD HH:MM:SS`, `MM/DD/YYYY`, ISO 8601, and more. * If a t\nimestamp includes time information, it will now be correctly parsed and stored i\nn `DateTimeField` fields (like `Interaction.date`) or truncated to a date for `D\nateField` fields (like `Voter.birthdate`). Next: Please try importing your inter\nactions again with the timestamp. They should now be processed successfully. Rem\ninder: click **Save** in the editor to sync changes.'\nError: (none)\nExit Code: 0\nSignal: 0\nBackground PIDs: (none)\nProcess Group PGID: 15451" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-38-2d2510b4.json: "resultDisplay": "108376:Assistant: 'I have verified that `CANDIDATE_SUPPORT_CHOICES` is correctly\n implemented in: * `core/models.py`: Renamed from `SUPPORT_CHOICES` to match the\n expected attribute during import. * `core/forms.py`: All references updated. * \n`core/admin.py`: The `import_voters` logic now correctly references `Voter.CANDI\nDATE_SUPPORT_CHOICES`. No remaining references to the old `SUPPORT_CHOICES` were\n found in the codebase. You should now be able to proceed with the voter import \nwithout the previous attribute error. Reminder: click Save in the editor to sync\n changes.' \n126466:[gemini][stdout] * `core/admin.py`: Added `parse_any_date` helper and upd\nated all import views (`import_voters`, `import_donations`, `import_interactions\n`, `import_voting_records`) to support flexible date and timestamp formats. \n126542:[gemini][prompt.raw] {\"command\":\"exec.prompt\",\"data\":{\"project_id\":37769,\n\"project_uuid\":\"05736420-f47f-46c0-9196-f6ddfdb0d67a\",\"conversation_id\":41441,\"s\nervice_id\":30107,\"content\":\"User Experience Context (Survey):\\n- What industry i\ns the most applicable to your business?: Government/Public\\n- How many years of \nexperience in web development (programming) do you have?: 5-10 years\\n- What kin\nd of app are you looking to build?: SAAS\\n\\nHistory (last 30 messages):\\n```\\nUs\ner: '502 Bad Gateway An error occurred while opening a stream to the origin.'\\nU\nser: 'ping'\\nAssistant: 'I am here. I'm investigating the \\\"502 Bad Gateway\\\" er\nror. It appears the server is under heavy load due to some unoptimized queries o\nn the large dataset (180k+ voters). I'm currently optimizing the **Door Visits**\n and **Dashboard** views to handle this scale more efficiently and prevent timeo\nuts. Plan: 1. **Dashboard Optimization**: Update the household count logic to us\ne the indexed `address_street` instead of the non-indexed `address` field. 2. **\nDoor Visits Optimization**: Rewrite the grouping logic to use `values()` instead\n of instantiating thousands of Django objects, and optimize the Google Maps data\n payload to reduce response size (currently ~4.2MB). 3. **Service Restart**: Aft\ner optimization, I'll restart the service to ensure it's responsive. Next: I wil\nl begin applying these optimizations to `core/views.py`. Reminder: click **Save*\n* in the editor to sync changes once I've finished the edits. (I will start by o\nptimizing the `index` metrics and the `door_visits` view).'\\nUser: '[INFO] Gemin\ni editing: core/sedDyWQm9'\\nUser: '[INFO] Gemini editing: patch_door_visits.py'\\\nnUser: '[INFO] Gemini editing: patch_door_visits.py'\\nAssistant: 'AI generation \nwas stopped by user request.'\\nUser: '[INFO] Stopping active Gemini process (PID\n: 2054)...'\\nUser: '[INFO] Gemini process stopped successfully'\\nUser: 'what mak\nes a unique interaction?'\\nAssistant: 'An **Interaction** in this system is a re\ncord of contact between the campaign and a voter. Based on the `Interaction` mod\nel in `core/models.py`, a unique interaction is defined by the combination of th\ne following components: 1. **Voter:** The person being contacted. 2. **Interacti\non Type:** The method of contact (e.g., Email, SMS, Call, Door Visit). 3. **Date\n & Time:** When the contact occurred. 4. **Volunteer:** The person who performed\n the contact (optional, can be null). 5. **Description/Outcome:** A short label \nof what happened (e.g., \\\"Left Message\\\", \\\"Talked to Voter\\\", \\\"Mass SMS Text\\\"\n). 6. **Notes:** Any detailed text or the body of the message (e.g., the specifi\nc email content). **Technically**, the only field that ensures uniqueness in the\n database is the **Primary Key (ID)**. There are no `unique_together` constraint\ns on the `Interaction` model itself, meaning the system can log multiple interac\ntions for the same voter, even with identical details, as long as they are creat\ned as separate records. However, **Interaction Types** (like \\\"Email\\\" or \\\"Door\n Visit\\\") are enforced as unique per tenant (campaign) via a `unique_together = \n('tenant', 'name')` constraint. Next: Would you like me to implement any specifi\nc logic to prevent duplicate interactions, or do you have another question about\n the data structure?'\\nUser: 'when importing interactions, all interactions are \nunique, however the import action states UPDATE'\\nUser: '[INFO] Gemini editing: \ncore/sedBlBMEg'\\nUser: '[INFO] Gemini editing: fix_admin.py'\\nUser: '[INFO] Gemi\nni editing: fix_admin_v2.py'\\nUser: '[INFO] Gemini editing: fix_admin_v3.py'\\nUs\ner: '[INFO] Gemini editing: fix_admin.py'\\nAssistant: 'Plan:'\\nAssistant: '1. **\nIdentify Root Cause**: Found that the import preview logic for `Interaction`, `D\nonation`, and `VoterLikelihood` was checking for the existence of the **Voter** \nrather than the record being imported. This caused all records for existing vote\nrs to be incorrectly labeled as \\\"UPDATE\\\". 2. **Implement Fix**: - Updated `Int\neractionAdmin.import_interactions` to check if an identical interaction (same vo\nter, type, and date) already exists. - Updated `DonationAdmin.import_donations` \nto check for existing identical donations (same voter, date, and amount). - Upda\nted `VoterLikelihoodAdmin.import_likelihoods` to check for existing likelihoods \nfor the specific voter and election type. 3. **Verification**: Restarted the ser\nvice and confirmed the application is running smoothly. Changed: * `core/admin.p\ny`: Corrected the preview logic for `Interaction`, `Donation`, and `VoterLikelih\nood` imports to ensure accurate \\\"CREATE\\\" vs \\\"UPDATE\\\" status reporting. Notes\n: * Since the system uses `create()` for interactions and donations (allowing du\nplicates), they will now correctly show as **CREATE** in the preview as long as \nan identical record doesn't already exist in the database. * This fix also ensur\nes that if you re-upload the same file twice, the preview will correctly warn yo\nu with an **UPDATE** label (even though the final import might still create a du\nplicate depending on the specific model's logic). Next: Please try the interacti\non import again. You should now see the \\\"CREATE\\\" label for your unique interac\ntions. Reminder: click **Save** in the editor to sync changes.'\\nUser: 'when imp\norting interactions, I'm getting an error \\\"Invalid date format\\\" when importing\n a valid timestamp'\\nUser: '[INFO] Gemini editing: core/admin.py'\\nUser: '[INFO]\n Gemini editing: core/admin.py'\\nUser: '[INFO] Gemini editing: core/admin.py'\\nU\nser: '[INFO] Gemini editing: core/admin.py'\\nUser: '[INFO] Gemini editing: core/\nadmin.py'\\nUser: '[INFO] Gemini editing: core/admin.py'\\nAssistant: 'Plan:'\\nAss\nistant: '1. **Identified Root Cause**: The date parsing logic in `core/admin.py`\n was overly restrictive, only supporting `%m/%d/%Y` and `%Y-%m-%d` formats. This\n caused failures for valid timestamps (ISO 8601 or formats including time). 2. *\n*Implemented Robust Parsing**: - Added a `parse_any_date` helper function to `co\nre/admin.py` that utilizes Django's `parse_datetime` and `parse_date` utilities,\n along with a comprehensive list of common CSV date formats. - Updated `Interact\nionAdmin.import_interactions` to use this new helper in both the import preview \n(for existence checking) and the actual import process. 3. **Broad Improvements*\n*: Applied the same robust parsing to `VoterAdmin`, `DonationAdmin`, and `Voting\nRecordAdmin` to ensure consistency across all data import types. 4. **Verificati\non**: Restarted the Django service and performed a health check to ensure the ap\nplication is running correctly. Changed: * `core/admin.py`: Added `parse_any_dat\ne` helper and updated all import views (`import_voters`, `import_donations`, `im\nport_interactions`, `import_voting_records`) to support flexible date and timest\namp formats. Notes: * The system now supports a wide range of date formats inclu\nding `YYYY-MM-DD HH:MM:SS`, `MM/DD/YYYY`, ISO 8601, and more. * If a timestamp i\nncludes time information, it will now be correctly parsed and stored in `DateTim\neField` fields (like `Interaction.date`) or truncated to a date for `DateField` \nfields (like `Voter.birthdate`). Next: Please try importing your interactions ag\nain with the timestamp. They should now be processed successfully. Reminder: cli\nck **Save** in the editor to sync changes.'\\nUser: 'when importing interactions,\n the time imported should be in the same timezone as the one in the campaign set\ntings'\\nUser: 'Project log version'\\n```\\nCurrent user message:\\nwhen importing \ninteractions, the time imported should be in the same timezone as the one in the\n campaign settings\\n\\n\",\"gemini_session_id\":null,\"model\":\"gemini-3-flash-preview\n\",\"context\":{\"url\":\"https://grassroots-campaign-manager-3d43.dev.flatlogic.app\",\n\"project_id\":37769,\"conversation_uuid\":\"66309fec-614d-4430-9a11-774c1761a2eb\",\"u\nser_profile\":\"User Experience Context (Survey):\\n- What industry is the most app\nlicable to your business?: Government/Public\\n- How many years of experience in \nweb development (programming) do you have?: 5-10 years\\n- What kind of app are y\nou looking to build?: SAAS\"}}} \n126584:Assistant: '1. **Identified Root Cause**: The date parsing logic in `core\n/admin.py` was overly restrictive, only supporting `%m/%d/%Y` and `%Y-%m-%d` for\nmats. This caused failures for valid timestamps (ISO 8601 or formats including t\nime). 2. **Implemented Robust Parsing**: - Added a `parse_any_date` helper funct\nion to `core/admin.py` that utilizes Django's `parse_datetime` and `parse_date` \nutilities, along with a comprehensive list of common CSV date formats. - Updated\n `InteractionAdmin.import_interactions` to use this new helper in both the impor\nt preview (for existence checking) and the actual import process. 3. **Broad Imp\nrovements**: Applied the same robust parsing to `VoterAdmin`, `DonationAdmin`, a\nnd `VotingRecordAdmin` to ensure consistency across all data import types. 4. **\nVerification**: Restarted the Django service and performed a health check to ens\nure the application is running correctly. Changed: * `core/admin.py`: Added `par\nse_any_date` helper and updated all import views (`import_voters`, `import_donat\nions`, `import_interactions`, `import_voting_records`) to support flexible date \nand timestamp formats. Notes: * The system now supports a wide range of date for\nmats including `YYYY-MM-DD HH:MM:SS`, `MM/DD/YYYY`, ISO 8601, and more. * If a t\nimestamp includes time information, it will now be correctly parsed and stored i\nn `DateTimeField` fields (like `Interaction.date`) or truncated to a date for `D\nateField` fields (like `Voter.birthdate`). Next: Please try importing your inter\nactions again with the timestamp. They should now be processed successfully. Rem\ninder: click **Save** in the editor to sync changes.' \n126646:Assistant: '1. **Identified Root Cause**: The date parsing logic in `core\n/admin.py` was overly restrictive, only supporting `%m/%d/%Y` and `%Y-%m-%d` for\nmats. This caused failures for valid timestamps (ISO 8601 or formats including t\nime). 2. **Implemented Robust Parsing**: - Added a `parse_any_date` helper funct\nion to `core/admin.py` that utilizes Django's `parse_datetime` and `parse_date` \nutilities, along with a comprehensive list of common CSV date formats. - Updated\n `InteractionAdmin.import_interactions` to use this new helper in both the impor\nt preview (for existence checking) and the actual import process. 3. **Broad Imp\nrovements**: Applied the same robust parsing to `VoterAdmin`, `DonationAdmin`, a\nnd `VotingRecordAdmin` to ensure consistency across all data import types. 4. **\nVerification**: Restarted the Django service and performed a health check to ens\nure the application is running correctly. Changed: * `core/admin.py`: Added `par\nse_any_date` helper and updated all import views (`import_voters`, `import_donat\nions`, `import_interactions`, `import_voting_records`) to support flexible date \nand timestamp formats. Notes: * The system now supports a wide range of date for\nmats including `YYYY-MM-DD HH:MM:SS`, `MM/DD/YYYY`, ISO 8601, and more. * If a t\nimestamp includes time information, it will now be correctly parsed and stored i\nn `DateTimeField` fields (like `Interaction.date`) or truncated to a date for `D\nateField` fields (like `Voter.birthdate`). Next: Please try importing your inter\nactions again with the timestamp. They should now be processed successfully. Rem\ninder: click **Save** in the editor to sync changes.'", +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-38-2d2510b4.json: "command": "grep -a \"InteractionAdmin\" /home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-38-2d2510b4.json | tail -c 10000 > core/admin_reconstruct.txt" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-38-2d2510b4.json: "output": "Command: grep -a \"InteractionAdmin\" /home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-38-2d2510b4.json | tail -c 10000 > core/admin_reconstruct.txt\nDirectory: (root)\nOutput: (empty)\nError: (none)\nExit Code: 0\nSignal: 0\nBackground PIDs: (none)\nProcess Group PGID: 15471" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-38-2d2510b4.json: "command": "find /home/ubuntu -mtime -30 -name \"*.py\" 2>/dev/null | xargs grep -l \"InteractionAdmin\" | grep -v \"core/admin.py\"" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-38-2d2510b4.json: "output": "Command: find /home/ubuntu -mtime -30 -name \"*.py\" 2>/dev/null | xargs grep -l \"InteractionAdmin\" | grep -v \"core/admin.py\"\nDirectory: (root)\nOutput: (empty)\nError: (none)\nExit Code: 1\nSignal: 0\nBackground PIDs: (none)\nProcess Group PGID: 15480" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-01-a16108b3.json: "output": "\nIMPORTANT: The file content has been truncated.\nStatus: Showing lines 1-2000 of 2004 total lines.\nAction: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: 2000.\n\n--- FILE CONTENT (truncated) ---\nfrom decimal import Decimal\nfrom datetime import datetime, date\nfrom django.db import transaction\nfrom django.http import HttpResponse\nfrom django.utils.safestring import mark_safe\nimport csv\nimport io\nimport logging\nimport tempfile\nimport os\nfrom django.contrib import admin, messages\nfrom django.urls import path, reverse\nfrom django.shortcuts import render, redirect\nfrom django.template.response import TemplateResponse\nfrom .models import (\n format_phone_number,\n Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,\n VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings,\n Interest, Volunteer, VolunteerEvent, ParticipationStatus, VolunteerRole\n)\nfrom .forms import (\n VoterImportForm, EventImportForm, EventParticipationImportForm,\n DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm,\n VolunteerImportForm, VotingRecordImportForm\n)\n\nlogger = logging.getLogger(__name__)\n\nVOTER_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('nickname', 'Nickname'),\n ('birthdate', 'Birthdate'),\n ('address_street', 'Street Address'),\n ('city', 'City'),\n ('state', 'State'),\n ('prior_state', 'Prior State'),\n ('zip_code', 'Zip Code'),\n ('county', 'County'),\n ('neighborhood', 'Neighborhood'),\n ('phone', 'Phone'),\n ('notes', 'Notes'),\n ('phone_type', 'Phone Type'),\n ('email', 'Email'),\n ('district', 'District'),\n ('precinct', 'Precinct'),\n ('registration_date', 'Registration Date'),\n ('is_targeted', 'Is Targeted'),\n ('candidate_support', 'Candidate Support'),\n ('yard_sign', 'Yard Sign'),\n ('window_sticker', 'Window Sticker'),\n ('latitude', 'Latitude'),\n ('longitude', 'Longitude'),\n ('secondary_phone', 'Secondary Phone'),\n ('secondary_phone_type', 'Secondary Phone Type'),\n ('door_visit', 'Door Visit'),\n]\n\nEVENT_MAPPABLE_FIELDS = [\n ('name', 'Name'),\n ('date', 'Date'),\n ('start_time', 'Start Time'),\n ('end_time', 'End Time'),\n ('event_type', 'Event Type (Name)'),\n ('description', 'Description'),\n ('location_name', 'Location Name'),\n ('address', 'Address'),\n ('city', 'City'),\n ('state', 'State'),\n ('zip_code', 'Zip Code'),\n ('latitude', 'Latitude'),\n ('longitude', 'Longitude'),\n]\n\nEVENT_PARTICIPATION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('event_name', 'Event Name'),\n ('participation_status', 'Participation Status'),\n]\n\nDONATION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('date', 'Date'),\n ('amount', 'Amount'),\n ('method', 'Donation Method (Name)'),\n]\n\nINTERACTION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('volunteer_email', 'Volunteer Email'),\n ('date', 'Date'),\n ('type', 'Interaction Type (Name)'),\n ('description', 'Description'),\n ('notes', 'Notes'),\n]\n\n\nVOLUNTEER_MAPPABLE_FIELDS = [\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('email', 'Email'),\n ('phone', 'Phone'),\n ('notes', 'Notes'),\n]\n\nVOTER_LIKELIHOOD_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('election_type', 'Election Type (Name)'),\n ('likelihood', 'Likelihood'),\n]\n\nVOTING_RECORD_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('election_date', 'Election Date'),\n ('election_description', 'Election Description'),\n ('primary_party', 'Primary Party'),\n]\n\nclass BaseImportAdminMixin:\n def download_errors(self, request):\n logger.info(f\"download_errors called for {self.model._meta.model_name}\")\n session_key = f\"{self.model._meta.model_name}_import_errors\"\n failed_rows = request.session.get(session_key, [])\n if not failed_rows:\n self.message_user(request, \"No error log found in session.\", level=messages.WARNING)\n return redirect(\"../\")\n\n response = HttpResponse(content_type=\"text/csv\")\n response[\"Content-Disposition\"] = f\"attachment; filename={self.model._meta.model_name}_import_errors.csv\"\n\n if failed_rows:\n all_keys = set()\n for r in failed_rows:\n all_keys.update(r.keys())\n\n writer = csv.DictWriter(response, fieldnames=sorted(list(all_keys)))\n writer.writeheader()\n writer.writerows(failed_rows)\n\n return response\n\n def chunk_reader(self, reader, size):\n chunk = []\n for row in reader:\n chunk.append(row)\n if len(chunk) == size:\n yield chunk\n chunk = []\n if chunk:\n yield chunk\nclass TenantUserRoleInline(admin.TabularInline):\n model = TenantUserRole\n extra = 1\n\nclass CampaignSettingsInline(admin.StackedInline):\n model = CampaignSettings\n can_delete = False\n\n@admin.register(Tenant)\nclass TenantAdmin(admin.ModelAdmin):\n list_display = ('name', 'created_at')\n search_fields = ('name',)\n inlines = [TenantUserRoleInline, CampaignSettingsInline]\n\n@admin.register(TenantUserRole)\nclass TenantUserRoleAdmin(admin.ModelAdmin):\n list_display = ('user', 'tenant', 'role')\n list_filter = ('tenant', 'role')\n search_fields = ('user__username', 'tenant__name')\n\n@admin.register(InteractionType)\nclass InteractionTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(DonationMethod)\nclass DonationMethodAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(VolunteerRole)\nclass VolunteerRoleAdmin(admin.ModelAdmin):\n list_display = (\"name\", \"tenant\", \"is_active\")\n list_filter = (\"tenant\", \"is_active\")\n search_fields = (\"name\",)\n\n@admin.register(ElectionType)\nclass ElectionTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(EventType)\nclass EventTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active', 'default_volunteer_role')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n filter_horizontal = ('available_roles',)\n\n\n@admin.register(ParticipationStatus)\nclass ParticipationStatusAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n change_list_template = 'admin/participationstatus_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n@admin.register(Interest)\nclass InterestAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant')\n list_filter = ('tenant',)\n fields = ('tenant', 'name')\n search_fields = ('name',)\n\nclass VotingRecordInline(admin.TabularInline):\n model = VotingRecord\n extra = 1\n\nclass DonationInline(admin.TabularInline):\n model = Donation\n extra = 1\n\nclass InteractionInline(admin.TabularInline):\n model = Interaction\n extra = 1\n autocomplete_fields = ['voter', 'type', 'volunteer']\n\nclass VoterLikelihoodInline(admin.TabularInline):\n model = VoterLikelihood\n extra = 1\n\nclass VolunteerEventInline(admin.TabularInline):\n model = VolunteerEvent\n extra = 1\n\n@admin.register(Voter)\nclass VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('first_name', 'last_name', 'nickname', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state', 'prior_state')\n list_filter = ('tenant', 'candidate_support', 'is_targeted', 'phone_type', 'yard_sign', 'district', 'city', 'state', 'prior_state')\n search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'prior_state', 'zip_code', 'county')\n inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline]\n readonly_fields = ('address',)\n change_list_template = \"admin/voter_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context[\"tenants\"] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voter-download-errors'),\n path('import-voters/', self.admin_site.admin_view(self.import_voters), name='import-voters'),\n ]\n return my_urls + urls\n\n\n def import_voters(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get(\"file_path\")\n tenant_id = request.POST.get(\"tenant\")\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f\"map_{field_name}\")\n\n try:\n with open(file_path, \"r\", encoding=\"utf-8-sig\") as f:\n # Optimization: Skip full count for very large files in preview if needed,\n # but here we'll keep it for accuracy unless it's a known bottleneck.\n # For now, let's just do a fast line count.\n total_count = sum(1 for line in f) - 1\n f.seek(0)\n reader = csv.DictReader(f)\n preview_rows = []\n voter_ids_for_preview = []\n for i, row in enumerate(reader):\n if i < 10:\n preview_rows.append(row)\n v_id = row.get(mapping.get(\"voter_id\"))\n if v_id:\n voter_ids_for_preview.append(v_id.strip())\n else:\n break\n\n existing_preview_ids = set(Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids_for_preview).values_list(\"voter_id\", flat=True))\n\n create_count = 0\n update_count = 0\n\n for row in preview_rows:\n voter_id_val = row.get(mapping.get(\"voter_id\"))\n if voter_id_val and voter_id_val.strip() in existing_preview_ids:\n update_count += 1\n else:\n create_count += 1\n\n context = self.admin_site.each_context(request)\n context.update({\n \"title\": \"Import Preview\",\n \"total_count\": total_count,\n \"create_count\": create_count,\n \"update_count\": update_count,\n \"preview_data\": preview_rows, # This should be improved to show actual changes\n \"mapping\": mapping,\n \"file_path\": file_path,\n \"tenant_id\": tenant_id,\n \"action_url\": request.path,\n \"opts\": self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n elif \"_import\" in request.POST:\n file_path = request.POST.get(\"file_path\")\n tenant_id = request.POST.get(\"tenant\")\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f\"map_{field_name}\")\n\n try:\n created_count = 0\n updated_count = 0\n skipped_no_change = 0\n skipped_no_id = 0\n errors = 0\n failed_rows = []\n total_processed = 0\n\n # Temporary storage for error rows to avoid holding large file in memory\n temp_error_file = None\n temp_error_file_path = None\n\n # Process in chunks to reduce memory usage for very large files\n with open(file_path, \"r\", encoding=\"utf-8-sig\") as f_read:\n reader = csv.DictReader(f_read)\n for i, row in enumerate(reader):\n total_processed += 1\n try:\n raw_voter_id = row.get(mapping.get(\"voter_id\"))\n voter_id = raw_voter_id.strip() if raw_voter_id else None\n\n if not voter_id:\n # Enhanced error message to guide the user\n mapped_column_name = mapping.get(\"voter_id\", \"N/A\")\n error_detail = f\"Raw value: '{raw_voter_id}'. \" if raw_voter_id is not None else \"Value was None.\"\n row[\"Import Error\"] = f\"Voter ID is required. Please check if the '{mapped_column_name}' column is correctly mapped and contains values for all rows. {error_detail}\"\n failed_rows.append(row)\n skipped_no_id += 1\n errors += 1\n continue\n\n defaults = {}\n # Map other fields dynamically\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n csv_column = mapping.get(field_name)\n if csv_column and csv_column in row:\n field_value = row[csv_column].strip()\n if field_name == \"birthdate\" or field_name == \"registration_date\":\n # Handle date conversions\n if field_value:\n try:\n # Attempt to parse common date formats\n if '/' in field_value:\n # Try MM/DD/YYYY or DD/MM/YYYY\n if len(field_value.split('/')[2]) == 2: # YY format\n dt = datetime.strptime(field_value, '%m/%d/%y').date() if len(field_value.split('/')[0]) < 3 else datetime.strptime(field_value, '%d/%m/%y').date() # noqa\n else:\n dt = datetime.strptime(field_value, '%m/%d/%Y').date() if len(field_value.split('/')[0]) < 3 else datetime.strptime(field_value, '%d/%m/%Y').date() # noqa\n elif '-' in field_value:\n # Try YYYY-MM-DD or DD-MM-YYYY or MM-DD-YYYY\n if len(field_value.split('-')[0]) == 4: # YYYY format\n dt = datetime.strptime(field_value, '%Y-%m-%d').date()\n elif len(field_value.split('-')[2]) == 4: # YYYY format\n dt = datetime.strptime(field_value, '%m-%d-%Y').date() if len(field_value.split('-')[0]) < 3 else datetime.strptime(field_value, '%d-%m-%Y').date() # noqa\n else:\n # Default to MM-DD-YY\n dt = datetime.strptime(field_value, '%m-%d-%y').date()\n else:\n dt = None\n\n if dt:\n defaults[field_name] = dt\n else:\n logger.warning(f\"Could not parse date '{field_value}' for field {field_name}. Skipping.\")\n except ValueError as ve:\n logger.warning(f\"Date parsing error for '{field_value}' in field {field_name}: {ve}\")\n except Exception as ex:\n logger.error(f\"Unexpected error parsing date '{field_value}' for field {field_name}: {ex}\")\n elif field_name == \"is_targeted\" or field_name == \"yard_sign\" or field_name == \"window_sticker\" or field_name == \"door_visit\":\n # Handle boolean fields\n if field_value.lower() == 'true' or field_value == '1':\n defaults[field_name] = True\n elif field_value.lower() == 'false' or field_value == '0':\n defaults[field_name] = False\n else:\n defaults[field_name] = None # Or sensible default/error\n elif field_name == \"phone\":\n defaults[field_name] = format_phone_number(field_value)\n elif field_name == \"email\":\n defaults[field_name] = field_value.lower() # Store emails as lowercase\n elif field_name == \"candidate_support\":\n if field_value in [choice[0] for choice in Voter.CANDIDATE_SUPPORT_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid candidate_support value: {field_value}. Skipping.\")\n elif field_name == \"phone_type\":\n if field_value in [choice[0] for choice in Voter.PHONE_TYPE_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid phone_type value: {field_value}. Skipping.\")\n elif field_name == \"secondary_phone_type\":\n if field_value in [choice[0] for choice in Voter.PHONE_TYPE_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid secondary_phone_type value: {field_value}. Skipping.\")\n elif field_name == \"state\" or field_name == \"prior_state\":\n # Ensure state is uppercase and valid length\n if field_value and len(field_value) <= 2:\n defaults[field_name] = field_value.upper()\n else:\n logger.warning(f\"Invalid state value: {field_value}. Skipping.\")\n else:\n defaults[field_name] = field_value\n\n # Try to get voter. If not found, create new. Update if found.\n voter, created = Voter.objects.update_or_create(\n tenant=tenant,\n voter_id=voter_id,\n defaults=defaults\n )\n if created:\n created_count += 1\n else:\n updated_count += 1\n\n # Special handling for interests - assuming a comma-separated list in CSV\n if 'interests' in mapping and row.get(mapping['interests']):\n interest_names = [name.strip() for name in row[mapping['interests']].split(',') if name.strip()]\n for interest_name in interest_names:\n interest, _ = Interest.objects.get_or_create(tenant=tenant, name=interest_name)\n voter.interests.add(interest)\n\n if (i + 1) % 100 == 0:\n print(f\"DEBUG: Voter import progress: {total_processed} processed. {created_count} created. {updated_count} updated.\")\n\n except Exception as e:\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n logger.error(f\"Error importing row: {row}. Error: {e}\")\n\n # Clean up the temporary file\n if os.path.exists(file_path):\n os.remove(file_path)\n\n if temp_error_file_path and os.path.exists(temp_error_file_path):\n os.remove(temp_error_file_path)\n\n self.message_user(request, f\"Import complete: {created_count + updated_count} voters created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped with no changes, {skipped_no_id} skipped missing ID, {errors} errors)\")\n # Store failed rows in session for download, limit to avoid session overflow\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n\n if errors > 0:\n error_url = reverse(\"admin:voter-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VoterImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n \"title\": \"Map Voter Fields\",\n \"headers\": headers,\n \"model_fields\": VOTER_MAPPABLE_FIELDS,\n \"tenant_id\": tenant.id,\n \"file_path\": file_path,\n \"action_url\": request.path,\n \"opts\": self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VoterImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Voters\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(Event)\nclass EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('name', 'date', 'event_type', 'tenant', 'location_name', 'address', 'city', 'state', 'zip_code')\n list_filter = ('tenant', 'event_type')\n search_fields = ('name', 'location_name', 'address', 'city', 'state', 'zip_code')\n inlines = [VolunteerEventInline]\n change_list_template = \"admin/event_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='event-download-errors'),\n path('import-events/', self.admin_site.admin_view(self.import_events), name='import-events'),\n ]\n return my_urls + urls\n\n def import_events(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in EVENT_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n event_name = row.get(mapping.get('name'))\n event_date = row.get(mapping.get('date'))\n\n exists = False\n if event_name and event_date:\n try:\n # Assuming name and date uniquely identify an event\n # This might need refinement based on actual data uniqueness requirements\n if '/' in event_date:\n dt = datetime.strptime(event_date, '%m/%d/%Y').date()\n elif '-' in event_date:\n dt = datetime.strptime(event_date, '%Y-%m-%d').date()\n else:\n dt = None\n\n if dt:\n exists = Event.objects.filter(tenant=tenant, name=event_name, date=dt).exists()\n\n except ValueError:\n # Handle cases where date parsing fails\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Event: {event_name} (Date: {event_date})\",\n 'details': f\"Location: {row.get(mapping.get('location_name', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in EVENT_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n event_name = row.get(mapping.get('name'))\n event_date = row.get(mapping.get('date'))\n event_type_name = row.get(mapping.get('event_type'))\n\n if not event_name or not event_date or not event_type_name:\n row[\"Import Error\"] = \"Missing event name, date, or type\"\n failed_rows.append(row)\n errors += 1\n continue\n\n # Date parsing for event_date\n try:\n if '/' in event_date:\n parsed_date = datetime.strptime(event_date, '%m/%d/%Y').date()\n elif '-' in event_date:\n parsed_date = datetime.strptime(event_date, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n event_type_obj, _ = EventType.objects.get_or_create(tenant=tenant, name=event_type_name)\n\n defaults = {\n 'date': parsed_date,\n 'event_type': event_type_obj,\n 'description': row.get(mapping.get('description')) or '',\n 'location_name': row.get(mapping.get('location_name')) or '',\n 'address': row.get(mapping.get('address')) or '',\n 'city': row.get(mapping.get('city')) or '',\n 'state': row.get(mapping.get('state')) or '',\n 'zip_code': row.get(mapping.get('zip_code')) or '',\n 'latitude': row.get(mapping.get('latitude')) or None,\n 'longitude': row.get(mapping.get('longitude')) or None,\n }\n\n # Handle start_time and end_time\n start_time_str = row.get(mapping.get('start_time'))\n if start_time_str:\n try:\n defaults['start_time'] = datetime.strptime(start_time_str, '%H:%M').time()\n except ValueError:\n logger.warning(f\"Invalid start_time format: {start_time_str}. Skipping.\")\n end_time_str = row.get(mapping.get('end_time'))\n if end_time_str:\n try:\n defaults['end_time'] = datetime.strptime(end_time_str, '%H:%M').time()\n except ValueError:\n logger.warning(f\"Invalid end_time format: {end_time_str}. Skipping.\")\n\n Event.objects.update_or_create(\n tenant=tenant,\n name=event_name,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} events.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:event-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = EventImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Event Fields\",\n 'headers': headers,\n 'model_fields': EVENT_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = EventImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Events\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(Volunteer)\nclass VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant')\n list_filter = ('tenant',)\n search_fields = ('first_name', 'last_name', 'email', 'phone')\n change_list_template = \"admin/volunteer_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='volunteer-download-errors'),\n path('import-volunteers/', self.admin_site.admin_view(self.import_volunteers), name='import-volunteers'),\n ]\n return my_urls + urls\n\n def import_volunteers(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOLUNTEER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n email = row.get(mapping.get('email'))\n\n exists = False\n if email:\n exists = Volunteer.objects.filter(tenant=tenant, email=email).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Volunteer: {email}\",\n 'details': f\"Name: {row.get(mapping.get('first_name', '')) or ''} {row.get(mapping.get('last_name', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOLUNTEER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n email = row.get(mapping.get('email'))\n if not email:\n row[\"Import Error\"] = \"Missing email\"\n failed_rows.append(row)\n errors += 1\n continue\n\n defaults = {\n 'first_name': row.get(mapping.get('first_name')) or '',\n 'last_name': row.get(mapping.get('last_name')) or '',\n 'phone': format_phone_number(row.get(mapping.get('phone')) or ''),\n 'notes': row.get(mapping.get('notes')) or '',\n }\n\n Volunteer.objects.update_or_create(\n tenant=tenant,\n email=email,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing volunteer: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} volunteers.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:volunteer-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VolunteerImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Volunteer Fields\",\n 'headers': headers,\n 'model_fields': VOLUNTEER_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VolunteerImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Volunteers\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(EventParticipation)\nclass EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('event', 'voter', 'participation_status')\n list_filter = ('event', 'participation_status', 'voter__tenant')\n search_fields = ('event__name', 'voter__first_name', 'voter__last_name', 'voter__voter_id')\n change_list_template = 'admin/eventparticipation_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_list(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='eventparticipation-download-errors'),\n path('import-event-participations/', self.admin_site.admin_view(self.import_event_participations), name='import-event-participations'),\n ]\n return my_urls + urls\n\n def import_event_participations(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in EVENT_PARTICIPATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n # Extract first_name and last_name from CSV based on mapping\n csv_first_name = row.get(mapping.get('first_name'), '')\n csv_last_name = row.get(mapping.get('last_name'), '')\n csv_full_name = f\"{csv_first_name} {csv_last_name}\".strip()\n\n exists = False\n voter_full_name = \"N/A\" # Initialize voter_full_name\n if voter_id:\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n voter_full_name = f\"{voter.first_name} {voter.last_name}\" # Get voter's full name\n if event_name:\n exists = EventParticipation.objects.filter(voter=voter, event__name=event_name).exists()\n except Voter.DoesNotExist:\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'csv_full_name': csv_full_name, # Add CSV name\n 'identifier': f\"Voter: {voter_full_name} (ID: {voter_id})\" if voter_id else \"N/A\", # Include full name\n 'details': f\"Participation: {row.get(mapping.get('participation_status', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in EVENT_PARTICIPATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n count = 0\n errors = 0\n failed_rows = []\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None\n participation_status_val = row.get(mapping.get('participation_status')) if mapping.get('participation_status') else None\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n error_msg = f\"Voter with ID {voter_id} not found\"\n logger.error(error_msg)\n row[\"Import Error\"] = error_msg\n failed_rows.append(row)\n errors += 1\n continue\n\n event = None\n event_name = row.get(mapping.get('event_name')) if mapping.get('event_name') else None\n if event_name:\n try:\n event = Event.objects.get(tenant=tenant, name=event_name)\n except Event.DoesNotExist:\n pass\n\n if not event:\n error_msg = \"Event not found (check Event Name)\"\n logger.error(error_msg)\n row[\"Import Error\"] = error_msg\n failed_rows.append(row)\n errors += 1\n continue\n\n defaults = {}\n if participation_status_val and participation_status_val.strip():\n status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name=participation_status_val.strip())\n defaults['participation_status'] = status_obj\n else:\n # Default to 'Invited' if not specified\n status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Invited')\n defaults['participation_status'] = status_obj\n EventParticipation.objects.update_or_create(\n event=event,\n voter=voter,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} participations.\")\n # Optimization: Limit error log size in session to avoid overflow\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n logger.info(f\"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}\")\n if errors > 0:\n error_url = reverse(\"admin:eventparticipation-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = EventParticipationImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Participation Fields\",\n 'headers': headers,\n 'model_fields': EVENT_PARTICIPATION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = EventParticipationImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Participations\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(Donation)\nclass DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'date', 'amount', 'method')\n list_filter = ('voter__tenant', 'method')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'method__name')\n change_list_template = 'admin/donation_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='donation-download-errors'),\n path('import-donations/', self.admin_site.admin_view(self.import_donations), name='import-donations'),\n ]\n return my_urls + urls\n\n def import_donations(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in DONATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Amount: {row.get(mapping.get('amount', '')) or ''}, Method: {row.get(mapping.get('method', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in DONATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n date_str = row.get(mapping.get('date'))\n amount_str = row.get(mapping.get('amount'))\n method_name = row.get(mapping.get('method'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not date_str or not amount_str:\n row[\"Import Error\"] = \"Missing date or amount\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n if '/' in date_str:\n parsed_date = datetime.strptime(date_str, '%m/%d/%Y').date()\n elif '-' in date_str:\n parsed_date = datetime.strptime(date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n amount = Decimal(amount_str)\n except InvalidOperation:\n row[\"Import Error\"] = \"Invalid amount format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n donation_method, _ = DonationMethod.objects.get_or_create(tenant=tenant, name=method_name)\n\n Donation.objects.create(\n voter=voter,\n date=parsed_date,\n amount=amount,\n method=donation_method\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} donations.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:donation-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = DonationImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Donation Fields\",\n 'headers': headers,\n 'model_fields': DONATION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = DonationImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Donations\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(Interaction)\nclass InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'date', 'type', 'description', 'volunteer')\n list_filter = ('voter__tenant', 'type', 'volunteer')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description', 'volunteer__first_name', 'volunteer__last_name')\n autocomplete_fields = ['voter', 'volunteer']\n change_list_template = 'admin/interaction_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='interaction-download-errors'),\n path('import-interactions/', self.admin_site.admin_view(self.import_interactions), name='import-interactions'),\n ]\n return my_urls + urls\n\n def import_interactions(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in INTERACTION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n volunteer_email = row.get(mapping.get('volunteer_email'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Type: {row.get(mapping.get('type', '')) or ''}, Volunteer: {volunteer_email or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in INTERACTION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n volunteer_email = row.get(mapping.get('volunteer_email'))\n date_str = row.get(mapping.get('date'))\n type_name = row.get(mapping.get('type'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not date_str or not type_name:\n row[\"Import Error\"] = \"Missing date or description\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n volunteer = None\n if volunteer_email:\n try:\n volunteer = Volunteer.objects.get(tenant=tenant, email=volunteer_email)\n except Volunteer.DoesNotExist:\n pass # Volunteer is optional\n\n try:\n if '/' in date_str:\n parsed_date = datetime.strptime(date_str, '%m/%d/%Y').date()\n elif '-' in date_str:\n parsed_date = datetime.strptime(date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name=type_name)\n\n Interaction.objects.create(\n voter=voter,\n volunteer=volunteer,\n date=parsed_date,\n type=interaction_type,\n description=row.get(mapping.get('description')) or '',\n notes=row.get(mapping.get('notes')) or ''\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} interactions.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:interaction-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = InteractionImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Interaction Fields\",\n 'headers': headers,\n 'model_fields': INTERACTION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = InteractionImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Interactions\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(VoterLikelihood)\nclass VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'election_type', 'likelihood')\n list_filter = ('voter__tenant', 'election_type', 'likelihood')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'election_type__name')\n change_list_template = 'admin/voterlikelihood_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voterlikelihood-download-errors'),\n path('import-likelihoods/', self.admin_site.admin_view(self.import_likelihoods), name='import-likelihoods'),\n ]\n return my_urls + urls\n\n def import_likelihoods(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Likelihood: {row.get(mapping.get('likelihood', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n election_type_name = row.get(mapping.get('election_type'))\n likelihood_val = row.get(mapping.get('likelihood'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not election_type_name or not likelihood_val:\n row[\"Import Error\"] = \"Missing election type or likelihood\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n election_type, _ = ElectionType.objects.get_or_create(tenant=tenant, name=election_type_name)\n\n VoterLikelihood.objects.update_or_create(\n voter=voter,\n election_type=election_type,\n defaults={'likelihood': likelihood_val}\n )\n count += 1\n except Exception as e:\n print(f\"DEBUG: Likelihood import failed: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Import complete: {count} likelihoods created/updated.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:voterlikelihood-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VoterLikelihoodImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Likelihood Fields\",\n 'headers': headers,\n 'model_fields': VOTER_LIKELIHOOD_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VoterLikelihoodImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Likelihoods\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(VotingRecord)\nclass VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'election_date', 'election_description', 'primary_party')\n list_filter = ('voter__tenant', 'primary_party')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'election_description')\n change_list_template = 'admin/votingrecord_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='votingrecord-download-errors'),\n path('import-voting-records/', self.admin_site.admin_view(self.import_voting_records), name='import-voting-records'),\n ]\n return my_urls + urls\n\n def import_voting_records(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOTING_RECORD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n election_date = row.get(mapping.get('election_date'))\n\n exists = False\n if voter_id and election_date:\n try:\n # Assuming voter_id and election_date uniquely identify a voting record\n # This might need refinement based on actual data uniqueness requirements\n if '/' in election_date:\n dt = datetime.strptime(election_date, '%m/%d/%Y').date()\n elif '-' in election_date:\n dt = datetime.strptime(election_date, '%Y-%m-%d').date()\n else:\n dt = None\n\n if dt:\n exists = VotingRecord.objects.filter(voter__tenant=tenant, voter__voter_id=voter_id, election_date=dt).exists()\n\n except ValueError:\n # Handle cases where date parsing fails\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id} (Election: {election_date})\",\n 'details': f\"Party: {row.get(mapping.get('primary_party', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTING_RECORD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n election_date_str = row.get(mapping.get('election_date'))\n election_description = row.get(mapping.get('election_description'))\n primary_party = row.get(mapping.get('primary_party'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not election_date_str or not election_description:\n row[\"Import Error\"] = \"Missing election date or description\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n if '/' in election_date_str:\n parsed_election_date = datetime.strptime(election_date_str, '%m/%d/%Y').date()\n elif '-' in election_date_str:\n parsed_election_date = datetime.strptime(election_date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n VotingRecord.objects.update_or_create(\n voter=voter,\n election_date=parsed_election_date,\n defaults={\n 'election_description': election_description,\n 'primary_party': primary_party or ''\n }\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} voting records.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:votingrecord-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VotingRecordImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Voting Record Fields\",\n 'headers': headers,\n 'model_fields': VOTING_RECORD_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VotingRecordImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Voting Records\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n@admin.register(CampaignSettings)\nclass CampaignSettingsAdmin(admin.ModelAdmin):\n list_display = ('tenant', 'smtp_host', 'email_from_address', 'timezone')\n list_filter = ('tenant',)\n search_fields = ('tenant__name', 'smtp_host', 'email_from_address')\n fieldsets = (\n (None, {\n 'fields': ('tenant', 'timezone', 'donation_goal')\n }),\n ('Twilio Settings', {\n 'fields': ('twilio_account_sid', 'twilio_auth_token', 'twilio_from_number'),\n 'classes': ('collapse',),\n }),\n ('SMTP Settings', {\n 'fields': (\n 'email_from_address',\n 'email_from_name',\n 'smtp_host',\n 'smtp_port',\n 'smtp_username',\n 'smtp_password',\n 'smtp_use_tls',\n 'smtp_use_ssl'," +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-01-a16108b3.json: "content": "Plan:\n1. **Identify Root Cause**: Found that the import preview logic for `Interaction`, `Donation`, and `VoterLikelihood` was checking for the existence of the **Voter** rather than the record being imported. This caused all records for existing voters to be incorrectly labeled as \"UPDATE\".\n2. **Implement Fix**: \n - Updated `InteractionAdmin.import_interactions` to check if an identical interaction (same voter, type, and date) already exists.\n - Updated `DonationAdmin.import_donations` to check for existing identical donations (same voter, date, and amount).\n - Updated `VoterLikelihoodAdmin.import_likelihoods` to check for existing likelihoods for the specific voter and election type.\n3. **Verification**: Restarted the service and confirmed the application is running smoothly.\n\nChanged:\n* `core/admin.py`: Corrected the preview logic for `Interaction`, `Donation`, and `VoterLikelihood` imports to ensure accurate \"CREATE\" vs \"UPDATE\" status reporting.\n\nNotes:\n* Since the system uses `create()` for interactions and donations (allowing duplicates), they will now correctly show as **CREATE** in the preview as long as an identical record doesn't already exist in the database.\n* This fix also ensures that if you re-upload the same file twice, the preview will correctly warn you with an **UPDATE** label (even though the final import might still create a duplicate depending on the specific model's logic).\n\nNext: Please try the interaction import again. You should now see the \"CREATE\" label for your unique interactions.\n\nReminder: click **Save** in the editor to sync changes.", +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-07T23-54-534758c4.json: "output": "from decimal import Decimal\nfrom datetime import datetime, date\nfrom django.db import transaction\nfrom django.http import HttpResponse\nfrom django.utils.safestring import mark_safe\nimport csv\nimport io\nimport logging\nimport tempfile\nimport os\nfrom django.contrib import admin, messages\nfrom django.urls import path, reverse\nfrom django.shortcuts import render, redirect\nfrom django.template.response import TemplateResponse\nfrom .models import (\n format_phone_number,\n Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,\n VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings,\n Interest, Volunteer, VolunteerEvent, ParticipationStatus, VolunteerRole\n)\nfrom .forms import (\n VoterImportForm, EventImportForm, EventParticipationImportForm,\n DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm,\n VolunteerImportForm, VotingRecordImportForm\n)\n\nlogger = logging.getLogger(__name__)\n\nVOTER_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('nickname', 'Nickname'),\n ('birthdate', 'Birthdate'),\n ('address_street', 'Street Address'),\n ('city', 'City'),\n ('state', 'State'),\n ('prior_state', 'Prior State'),\n ('zip_code', 'Zip Code'),\n ('county', 'County'),\n ('neighborhood', 'Neighborhood'),\n ('phone', 'Phone'),\n ('notes', 'Notes'),\n ('phone_type', 'Phone Type'),\n ('email', 'Email'),\n ('district', 'District'),\n ('precinct', 'Precinct'),\n ('registration_date', 'Registration Date'),\n ('is_targeted', 'Is Targeted'),\n ('candidate_support', 'Candidate Support'),\n ('yard_sign', 'Yard Sign'),\n ('window_sticker', 'Window Sticker'),\n ('latitude', 'Latitude'),\n ('longitude', 'Longitude'),\n ('secondary_phone', 'Secondary Phone'),\n ('secondary_phone_type', 'Secondary Phone Type'),\n ('door_visit', 'Door Visit'),\n]\n\nEVENT_MAPPABLE_FIELDS = [\n ('name', 'Name'),\n ('date', 'Date'),\n ('start_time', 'Start Time'),\n ('end_time', 'End Time'),\n ('event_type', 'Event Type (Name)'),\n ('description', 'Description'),\n ('location_name', 'Location Name'),\n ('address', 'Address'),\n ('city', 'City'),\n ('state', 'State'),\n ('zip_code', 'Zip Code'),\n ('latitude', 'Latitude'),\n ('longitude', 'Longitude'),\n]\n\nEVENT_PARTICIPATION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('event_name', 'Event Name'),\n ('participation_status', 'Participation Status'),\n]\n\nDONATION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('date', 'Date'),\n ('amount', 'Amount'),\n ('method', 'Donation Method (Name)'),\n]\n\nINTERACTION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('volunteer_email', 'Volunteer Email'),\n ('date', 'Date'),\n ('type', 'Interaction Type (Name)'),\n ('description', 'Description'),\n ('notes', 'Notes'),\n]\n\n\nVOLUNTEER_MAPPABLE_FIELDS = [\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('email', 'Email'),\n ('phone', 'Phone'),\n ('notes', 'Notes'),\n]\n\nVOTER_LIKELIHOOD_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('election_type', 'Election Type (Name)'),\n ('likelihood', 'Likelihood'),\n]\n\nVOTING_RECORD_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('election_date', 'Election Date'),\n ('election_description', 'Election Description'),\n ('primary_party', 'Primary Party'),\n]\n\nclass BaseImportAdminMixin:\n def download_errors(self, request):\n logger.info(f\"download_errors called for {self.model._meta.model_name}\")\n session_key = f\"{self.model._meta.model_name}_import_errors\"\n failed_rows = request.session.get(session_key, [])\n if not failed_rows:\n self.message_user(request, \"No error log found in session.\", level=messages.WARNING)\n return redirect(\"../\")\n\n response = HttpResponse(content_type=\"text/csv\")\n response[\"Content-Disposition\"] = f\"attachment; filename={self.model._meta.model_name}_import_errors.csv\"\n\n if failed_rows:\n all_keys = set()\n for r in failed_rows:\n all_keys.update(r.keys())\n\n writer = csv.DictWriter(response, fieldnames=sorted(list(all_keys)))\n writer.writeheader()\n writer.writerows(failed_rows)\n\n return response\n\n def chunk_reader(self, reader, size):\n chunk = []\n for row in reader:\n chunk.append(row)\n if len(chunk) == size:\n yield chunk\n chunk = []\n if chunk:\n yield chunk\nclass TenantUserRoleInline(admin.TabularInline):\n model = TenantUserRole\n extra = 1\n\nclass CampaignSettingsInline(admin.StackedInline):\n model = CampaignSettings\n can_delete = False\n\n@admin.register(Tenant)\nclass TenantAdmin(admin.ModelAdmin):\n list_display = ('name', 'created_at')\n search_fields = ('name',)\n inlines = [TenantUserRoleInline, CampaignSettingsInline]\n\n@admin.register(TenantUserRole)\nclass TenantUserRoleAdmin(admin.ModelAdmin):\n list_display = ('user', 'tenant', 'role')\n list_filter = ('tenant', 'role')\n search_fields = ('user__username', 'tenant__name')\n\n@admin.register(InteractionType)\nclass InteractionTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(DonationMethod)\nclass DonationMethodAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(VolunteerRole)\nclass VolunteerRoleAdmin(admin.ModelAdmin):\n list_display = (\"name\", \"tenant\", \"is_active\")\n list_filter = (\"tenant\", \"is_active\")\n search_fields = (\"name\",)\n\n@admin.register(ElectionType)\nclass ElectionTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(EventType)\nclass EventTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active', 'default_volunteer_role')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n filter_horizontal = ('available_roles',)\n\n\n@admin.register(ParticipationStatus)\nclass ParticipationStatusAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n change_list_template = 'admin/participationstatus_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n@admin.register(Interest)\nclass InterestAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant')\n list_filter = ('tenant',)\n fields = ('tenant', 'name')\n search_fields = ('name',)\n\nclass VotingRecordInline(admin.TabularInline):\n model = VotingRecord\n extra = 1\n\nclass DonationInline(admin.TabularInline):\n model = Donation\n extra = 1\n\nclass InteractionInline(admin.TabularInline):\n model = Interaction\n extra = 1\n autocomplete_fields = ['voter', 'type', 'volunteer']\n\nclass VoterLikelihoodInline(admin.TabularInline):\n model = VoterLikelihood\n extra = 1\n\nclass VolunteerEventInline(admin.TabularInline):\n model = VolunteerEvent\n extra = 1\n\n@admin.register(Voter)\nclass VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('first_name', 'last_name', 'nickname', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state', 'prior_state')\n list_filter = ('tenant', 'candidate_support', 'is_targeted', 'phone_type', 'yard_sign', 'district', 'city', 'state', 'prior_state')\n search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'prior_state', 'zip_code', 'county')\n inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline]\n readonly_fields = ('address',)\n change_list_template = \"admin/voter_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context[\"tenants\"] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voter-download-errors'),\n path('import-voters/', self.admin_site.admin_view(self.import_voters), name='import-voters'),\n ]\n return my_urls + urls\n\n\n def import_voters(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get(\"file_path\")\n tenant_id = request.POST.get(\"tenant\")\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f\"map_{field_name}\")\n\n try:\n with open(file_path, \"r\", encoding=\"utf-8-sig\") as f:\n # Optimization: Skip full count for very large files in preview if needed,\n # but here we'll keep it for accuracy unless it's a known bottleneck.\n # For now, let's just do a fast line count.\n total_count = sum(1 for line in f) - 1\n f.seek(0)\n reader = csv.DictReader(f)\n preview_rows = []\n voter_ids_for_preview = []\n for i, row in enumerate(reader):\n if i < 10:\n preview_rows.append(row)\n v_id = row.get(mapping.get(\"voter_id\"))\n if v_id:\n voter_ids_for_preview.append(v_id.strip())\n else:\n break\n\n existing_preview_ids = set(Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids_for_preview).values_list(\"voter_id\", flat=True))\n\n create_count = 0\n update_count = 0\n\n for row in preview_rows:\n voter_id_val = row.get(mapping.get(\"voter_id\"))\n if voter_id_val and voter_id_val.strip() in existing_preview_ids:\n update_count += 1\n else:\n create_count += 1\n\n context = self.admin_site.each_context(request)\n context.update({\n \"title\": \"Import Preview\",\n \"total_count\": total_count,\n \"create_count\": create_count,\n \"update_count\": update_count,\n \"preview_data\": preview_rows, # This should be improved to show actual changes\n \"mapping\": mapping,\n \"file_path\": file_path,\n \"tenant_id\": tenant_id,\n \"action_url\": request.path,\n \"opts\": self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n elif \"_import\" in request.POST:\n file_path = request.POST.get(\"file_path\")\n tenant_id = request.POST.get(\"tenant\")\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f\"map_{field_name}\")\n\n try:\n created_count = 0\n updated_count = 0\n skipped_no_change = 0\n skipped_no_id = 0\n errors = 0\n failed_rows = []\n total_processed = 0\n\n # Temporary storage for error rows to avoid holding large file in memory\n temp_error_file = None\n temp_error_file_path = None\n\n # Process in chunks to reduce memory usage for very large files\n with open(file_path, \"r\", encoding=\"utf-8-sig\") as f_read:\n reader = csv.DictReader(f_read)\n for i, row in enumerate(reader):\n total_processed += 1\n try:\n raw_voter_id = row.get(mapping.get(\"voter_id\"))\n voter_id = raw_voter_id.strip() if raw_voter_id else None\n\n if not voter_id:\n # Enhanced error message to guide the user\n mapped_column_name = mapping.get(\"voter_id\", \"N/A\")\n error_detail = f\"Raw value: '{raw_voter_id}'. \" if raw_voter_id is not None else \"Value was None.\"\n row[\"Import Error\"] = f\"Voter ID is required. Please check if the '{mapped_column_name}' column is correctly mapped and contains values for all rows. {error_detail}\"\n failed_rows.append(row)\n skipped_no_id += 1\n errors += 1\n continue\n\n defaults = {}\n # Map other fields dynamically\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n csv_column = mapping.get(field_name)\n if csv_column and csv_column in row:\n field_value = row[csv_column].strip()\n if field_name == \"birthdate\" or field_name == \"registration_date\":\n # Handle date conversions\n if field_value:\n try:\n # Attempt to parse common date formats\n if '/' in field_value:\n # Try MM/DD/YYYY or DD/MM/YYYY\n if len(field_value.split('/')[2]) == 2: # YY format\n dt = datetime.strptime(field_value, '%m/%d/%y').date() if len(field_value.split('/')[0]) < 3 else datetime.strptime(field_value, '%d/%m/%y').date() # noqa\n else:\n dt = datetime.strptime(field_value, '%m/%d/%Y').date() if len(field_value.split('/')[0]) < 3 else datetime.strptime(field_value, '%d/%m/%Y').date() # noqa\n elif '-' in field_value:\n # Try YYYY-MM-DD or DD-MM-YYYY or MM-DD-YYYY\n if len(field_value.split('-')[0]) == 4: # YYYY format\n dt = datetime.strptime(field_value, '%Y-%m-%d').date()\n elif len(field_value.split('-')[2]) == 4: # YYYY format\n dt = datetime.strptime(field_value, '%m-%d-%Y').date() if len(field_value.split('-')[0]) < 3 else datetime.strptime(field_value, '%d-%m-%Y').date() # noqa\n else:\n # Default to MM-DD-YY\n dt = datetime.strptime(field_value, '%m-%d-%y').date()\n else:\n dt = None\n\n if dt:\n defaults[field_name] = dt\n else:\n logger.warning(f\"Could not parse date '{field_value}' for field {field_name}. Skipping.\")\n except ValueError as ve:\n logger.warning(f\"Date parsing error for '{field_value}' in field {field_name}: {ve}\")\n except Exception as ex:\n logger.error(f\"Unexpected error parsing date '{field_value}' for field {field_name}: {ex}\")\n elif field_name == \"is_targeted\" or field_name == \"yard_sign\" or field_name == \"window_sticker\" or field_name == \"door_visit\":\n # Handle boolean fields\n if field_value.lower() == 'true' or field_value == '1':\n defaults[field_name] = True\n elif field_value.lower() == 'false' or field_value == '0':\n defaults[field_name] = False\n else:\n defaults[field_name] = None # Or sensible default/error\n elif field_name == \"phone\":\n defaults[field_name] = format_phone_number(field_value)\n elif field_name == \"email\":\n defaults[field_name] = field_value.lower() # Store emails as lowercase\n elif field_name == \"candidate_support\":\n if field_value in [choice[0] for choice in Voter.CANDIDATE_SUPPORT_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid candidate_support value: {field_value}. Skipping.\")\n elif field_name == \"phone_type\":\n if field_value in [choice[0] for choice in Voter.PHONE_TYPE_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid phone_type value: {field_value}. Skipping.\")\n elif field_name == \"secondary_phone_type\":\n if field_value in [choice[0] for choice in Voter.PHONE_TYPE_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid secondary_phone_type value: {field_value}. Skipping.\")\n elif field_name == \"state\" or field_name == \"prior_state\":\n # Ensure state is uppercase and valid length\n if field_value and len(field_value) <= 2:\n defaults[field_name] = field_value.upper()\n else:\n logger.warning(f\"Invalid state value: {field_value}. Skipping.\")\n else:\n defaults[field_name] = field_value\n\n # Try to get voter. If not found, create new. Update if found.\n voter, created = Voter.objects.update_or_create(\n tenant=tenant,\n voter_id=voter_id,\n defaults=defaults\n )\n if created:\n created_count += 1\n else:\n updated_count += 1\n\n # Special handling for interests - assuming a comma-separated list in CSV\n if 'interests' in mapping and row.get(mapping['interests']):\n interest_names = [name.strip() for name in row[mapping['interests']].split(',') if name.strip()]\n for interest_name in interest_names:\n interest, _ = Interest.objects.get_or_create(tenant=tenant, name=interest_name)\n voter.interests.add(interest)\n\n if (i + 1) % 100 == 0:\n print(f\"DEBUG: Voter import progress: {total_processed} processed. {created_count} created. {updated_count} updated.\")\n\n except Exception as e:\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n logger.error(f\"Error importing row: {row}. Error: {e}\")\n\n # Clean up the temporary file\n if os.path.exists(file_path):\n os.remove(file_path)\n\n if temp_error_file_path and os.path.exists(temp_error_file_path):\n os.remove(temp_error_file_path)\n\n self.message_user(request, f\"Import complete: {created_count + updated_count} voters created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped with no changes, {skipped_no_id} skipped missing ID, {errors} errors)\")\n # Store failed rows in session for download, limit to avoid session overflow\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n\n if errors > 0:\n error_url = reverse(\"admin:voter-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VoterImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n \"title\": \"Map Voter Fields\",\n \"headers\": headers,\n \"model_fields\": VOTER_MAPPABLE_FIELDS,\n \"tenant_id\": tenant.id,\n \"file_path\": file_path,\n \"action_url\": request.path,\n \"opts\": self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VoterImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Voters\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(Event)\nclass EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('name', 'date', 'event_type', 'tenant', 'location_name', 'address', 'city', 'state', 'zip_code')\n list_filter = ('tenant', 'event_type')\n search_fields = ('name', 'location_name', 'address', 'city', 'state', 'zip_code')\n inlines = [VolunteerEventInline]\n change_list_template = \"admin/event_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='event-download-errors'),\n path('import-events/', self.admin_site.admin_view(self.import_events), name='import-events'),\n ]\n return my_urls + urls\n\n def import_events(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in EVENT_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n event_name = row.get(mapping.get('name'))\n event_date = row.get(mapping.get('date'))\n\n exists = False\n if event_name and event_date:\n try:\n # Assuming name and date uniquely identify an event\n # This might need refinement based on actual data uniqueness requirements\n if '/' in event_date:\n dt = datetime.strptime(event_date, '%m/%d/%Y').date()\n elif '-' in event_date:\n dt = datetime.strptime(event_date, '%Y-%m-%d').date()\n else:\n dt = None\n\n if dt:\n exists = Event.objects.filter(tenant=tenant, name=event_name, date=dt).exists()\n\n except ValueError:\n # Handle cases where date parsing fails\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Event: {event_name} (Date: {event_date})\",\n 'details': f\"Location: {row.get(mapping.get('location_name', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in EVENT_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n event_name = row.get(mapping.get('name'))\n event_date = row.get(mapping.get('date'))\n event_type_name = row.get(mapping.get('event_type'))\n\n if not event_name or not event_date or not event_type_name:\n row[\"Import Error\"] = \"Missing event name, date, or type\"\n failed_rows.append(row)\n errors += 1\n continue\n\n # Date parsing for event_date\n try:\n if '/' in event_date:\n parsed_date = datetime.strptime(event_date, '%m/%d/%Y').date()\n elif '-' in event_date:\n parsed_date = datetime.strptime(event_date, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n event_type_obj, _ = EventType.objects.get_or_create(tenant=tenant, name=event_type_name)\n\n defaults = {\n 'date': parsed_date,\n 'event_type': event_type_obj,\n 'description': row.get(mapping.get('description')) or '',\n 'location_name': row.get(mapping.get('location_name')) or '',\n 'address': row.get(mapping.get('address')) or '',\n 'city': row.get(mapping.get('city')) or '',\n 'state': row.get(mapping.get('state')) or '',\n 'zip_code': row.get(mapping.get('zip_code')) or '',\n 'latitude': row.get(mapping.get('latitude')) or None,\n 'longitude': row.get(mapping.get('longitude')) or None,\n }\n\n # Handle start_time and end_time\n start_time_str = row.get(mapping.get('start_time'))\n if start_time_str:\n try:\n defaults['start_time'] = datetime.strptime(start_time_str, '%H:%M').time()\n except ValueError:\n logger.warning(f\"Invalid start_time format: {start_time_str}. Skipping.\")\n end_time_str = row.get(mapping.get('end_time'))\n if end_time_str:\n try:\n defaults['end_time'] = datetime.strptime(end_time_str, '%H:%M').time()\n except ValueError:\n logger.warning(f\"Invalid end_time format: {end_time_str}. Skipping.\")\n\n Event.objects.update_or_create(\n tenant=tenant,\n name=event_name,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} events.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:event-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = EventImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Event Fields\",\n 'headers': headers,\n 'model_fields': EVENT_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = EventImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Events\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(Volunteer)\nclass VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant')\n list_filter = ('tenant',)\n search_fields = ('first_name', 'last_name', 'email', 'phone')\n change_list_template = \"admin/volunteer_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='volunteer-download-errors'),\n path('import-volunteers/', self.admin_site.admin_view(self.import_volunteers), name='import-volunteers'),\n ]\n return my_urls + urls\n\n def import_volunteers(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOLUNTEER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n email = row.get(mapping.get('email'))\n\n exists = False\n if email:\n exists = Volunteer.objects.filter(tenant=tenant, email=email).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Volunteer: {email}\",\n 'details': f\"Name: {row.get(mapping.get('first_name', '')) or ''} {row.get(mapping.get('last_name', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOLUNTEER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n email = row.get(mapping.get('email'))\n if not email:\n row[\"Import Error\"] = \"Missing email\"\n failed_rows.append(row)\n errors += 1\n continue\n\n defaults = {\n 'first_name': row.get(mapping.get('first_name')) or '',\n 'last_name': row.get(mapping.get('last_name')) or '',\n 'phone': format_phone_number(row.get(mapping.get('phone')) or ''),\n 'notes': row.get(mapping.get('notes')) or '',\n }\n\n Volunteer.objects.update_or_create(\n tenant=tenant,\n email=email,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing volunteer: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} volunteers.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:volunteer-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VolunteerImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Volunteer Fields\",\n 'headers': headers,\n 'model_fields': VOLUNTEER_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VolunteerImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Volunteers\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(EventParticipation)\nclass EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('event', 'voter', 'participation_status')\n list_filter = ('event', 'participation_status', 'voter__tenant')\n search_fields = ('event__name', 'voter__first_name', 'voter__last_name', 'voter__voter_id')\n change_list_template = 'admin/eventparticipation_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_list(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='eventparticipation-download-errors'),\n path('import-event-participations/', self.admin_site.admin_view(self.import_event_participations), name='import-event-participations'),\n ]\n return my_urls + urls\n\n def import_event_participations(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in EVENT_PARTICIPATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n # Extract first_name and last_name from CSV based on mapping\n csv_first_name = row.get(mapping.get('first_name'), '')\n csv_last_name = row.get(mapping.get('last_name'), '')\n csv_full_name = f\"{csv_first_name} {csv_last_name}\".strip()\n\n exists = False\n voter_full_name = \"N/A\" # Initialize voter_full_name\n if voter_id:\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n voter_full_name = f\"{voter.first_name} {voter.last_name}\" # Get voter's full name\n if event_name:\n exists = EventParticipation.objects.filter(voter=voter, event__name=event_name).exists()\n except Voter.DoesNotExist:\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'csv_full_name': csv_full_name, # Add CSV name\n 'identifier': f\"Voter: {voter_full_name} (ID: {voter_id})\" if voter_id else \"N/A\", # Include full name\n 'details': f\"Participation: {row.get(mapping.get('participation_status', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in EVENT_PARTICIPATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n count = 0\n errors = 0\n failed_rows = []\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None\n participation_status_val = row.get(mapping.get('participation_status')) if mapping.get('participation_status') else None\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n error_msg = f\"Voter with ID {voter_id} not found\"\n logger.error(error_msg)\n row[\"Import Error\"] = error_msg\n failed_rows.append(row)\n errors += 1\n continue\n\n event = None\n event_name = row.get(mapping.get('event_name')) if mapping.get('event_name') else None\n if event_name:\n try:\n event = Event.objects.get(tenant=tenant, name=event_name)\n except Event.DoesNotExist:\n pass\n\n if not event:\n error_msg = \"Event not found (check Event Name)\"\n logger.error(error_msg)\n row[\"Import Error\"] = error_msg\n failed_rows.append(row)\n errors += 1\n continue\n\n defaults = {}\n if participation_status_val and participation_status_val.strip():\n status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name=participation_status_val.strip())\n defaults['participation_status'] = status_obj\n else:\n # Default to 'Invited' if not specified\n status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Invited')\n defaults['participation_status'] = status_obj\n EventParticipation.objects.update_or_create(\n event=event,\n voter=voter,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} participations.\")\n # Optimization: Limit error log size in session to avoid overflow\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n logger.info(f\"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}\")\n if errors > 0:\n error_url = reverse(\"admin:eventparticipation-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = EventParticipationImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Participation Fields\",\n 'headers': headers,\n 'model_fields': EVENT_PARTICIPATION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = EventParticipationImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Participations\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(Donation)\nclass DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'date', 'amount', 'method')\n list_filter = ('voter__tenant', 'method')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'method__name')\n change_list_template = 'admin/donation_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='donation-download-errors'),\n path('import-donations/', self.admin_site.admin_view(self.import_donations), name='import-donations'),\n ]\n return my_urls + urls\n\n def import_donations(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in DONATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Amount: {row.get(mapping.get('amount', '')) or ''}, Method: {row.get(mapping.get('method', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in DONATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n date_str = row.get(mapping.get('date'))\n amount_str = row.get(mapping.get('amount'))\n method_name = row.get(mapping.get('method'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not date_str or not amount_str:\n row[\"Import Error\"] = \"Missing date or amount\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n if '/' in date_str:\n parsed_date = datetime.strptime(date_str, '%m/%d/%Y').date()\n elif '-' in date_str:\n parsed_date = datetime.strptime(date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n amount = Decimal(amount_str)\n except InvalidOperation:\n row[\"Import Error\"] = \"Invalid amount format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n donation_method, _ = DonationMethod.objects.get_or_create(tenant=tenant, name=method_name)\n\n Donation.objects.create(\n voter=voter,\n date=parsed_date,\n amount=amount,\n method=donation_method\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} donations.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:donation-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = DonationImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Donation Fields\",\n 'headers': headers,\n 'model_fields': DONATION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = DonationImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Donations\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(Interaction)\nclass InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'date', 'type', 'description', 'volunteer')\n list_filter = ('voter__tenant', 'type', 'volunteer')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description', 'volunteer__first_name', 'volunteer__last_name')\n autocomplete_fields = ['voter', 'volunteer']\n change_list_template = 'admin/interaction_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='interaction-download-errors'),\n path('import-interactions/', self.admin_site.admin_view(self.import_interactions), name='import-interactions'),\n ]\n return my_urls + urls\n\n def import_interactions(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in INTERACTION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n volunteer_email = row.get(mapping.get('volunteer_email'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Type: {row.get(mapping.get('type', '')) or ''}, Volunteer: {volunteer_email or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in INTERACTION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n volunteer_email = row.get(mapping.get('volunteer_email'))\n date_str = row.get(mapping.get('date'))\n type_name = row.get(mapping.get('type'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not date_str or not type_name:\n row[\"Import Error\"] = \"Missing date or description\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n volunteer = None\n if volunteer_email:\n try:\n volunteer = Volunteer.objects.get(tenant=tenant, email=volunteer_email)\n except Volunteer.DoesNotExist:\n pass # Volunteer is optional\n\n try:\n if '/' in date_str:\n parsed_date = datetime.strptime(date_str, '%m/%d/%Y').date()\n elif '-' in date_str:\n parsed_date = datetime.strptime(date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name=type_name)\n\n Interaction.objects.create(\n voter=voter,\n volunteer=volunteer,\n date=parsed_date,\n type=interaction_type,\n description=row.get(mapping.get('description')) or '',\n notes=row.get(mapping.get('notes')) or ''\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} interactions.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:interaction-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = InteractionImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Interaction Fields\",\n 'headers': headers,\n 'model_fields': INTERACTION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = InteractionImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Interactions\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(VoterLikelihood)\nclass VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'election_type', 'likelihood')\n list_filter = ('voter__tenant', 'election_type', 'likelihood')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'election_type__name')\n change_list_template = 'admin/voterlikelihood_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voterlikelihood-download-errors'),\n path('import-likelihoods/', self.admin_site.admin_view(self.import_likelihoods), name='import-likelihoods'),\n ]\n return my_urls + urls\n\n def import_likelihoods(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Likelihood: {row.get(mapping.get('likelihood', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n election_type_name = row.get(mapping.get('election_type'))\n likelihood_val = row.get(mapping.get('likelihood'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not election_type_name or not likelihood_val:\n row[\"Import Error\"] = \"Missing election type or likelihood\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n election_type, _ = ElectionType.objects.get_or_create(tenant=tenant, name=election_type_name)\n\n VoterLikelihood.objects.update_or_create(\n voter=voter,\n election_type=election_type,\n defaults={'likelihood': likelihood_val}\n )\n count += 1\n except Exception as e:\n print(f\"DEBUG: Likelihood import failed: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Import complete: {count} likelihoods created/updated.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:voterlikelihood-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VoterLikelihoodImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Likelihood Fields\",\n 'headers': headers,\n 'model_fields': VOTER_LIKELIHOOD_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VoterLikelihoodImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Likelihoods\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(VotingRecord)\nclass VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'election_date', 'election_description', 'primary_party')\n list_filter = ('voter__tenant', 'primary_party')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'election_description')\n change_list_template = 'admin/votingrecord_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='votingrecord-download-errors'),\n path('import-voting-records/', self.admin_site.admin_view(self.import_voting_records), name='import-voting-records'),\n ]\n return my_urls + urls\n\n def import_voting_records(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOTING_RECORD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n election_date = row.get(mapping.get('election_date'))\n\n exists = False\n if voter_id and election_date:\n try:\n # Assuming voter_id and election_date uniquely identify a voting record\n # This might need refinement based on actual data uniqueness requirements\n if '/' in election_date:\n dt = datetime.strptime(election_date, '%m/%d/%Y').date()\n elif '-' in election_date:\n dt = datetime.strptime(election_date, '%Y-%m-%d').date()\n else:\n dt = None\n\n if dt:\n exists = VotingRecord.objects.filter(voter__tenant=tenant, voter__voter_id=voter_id, election_date=dt).exists()\n\n except ValueError:\n # Handle cases where date parsing fails\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id} (Election: {election_date})\",\n 'details': f\"Party: {row.get(mapping.get('primary_party', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTING_RECORD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n election_date_str = row.get(mapping.get('election_date'))\n election_description = row.get(mapping.get('election_description'))\n primary_party = row.get(mapping.get('primary_party'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not election_date_str or not election_description:\n row[\"Import Error\"] = \"Missing election date or description\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n if '/' in election_date_str:\n parsed_election_date = datetime.strptime(election_date_str, '%m/%d/%Y').date()\n elif '-' in election_date_str:\n parsed_election_date = datetime.strptime(election_date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n VotingRecord.objects.update_or_create(\n voter=voter,\n election_date=parsed_election_date,\n defaults={\n 'election_description': election_description,\n 'primary_party': primary_party or ''\n }\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} voting records.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:votingrecord-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VotingRecordImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Voting Record Fields\",\n 'headers': headers,\n 'model_fields': VOTING_RECORD_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VotingRecordImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Voting Records\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-16-abf14f66.json: "content": "User Experience Context (Survey):\n- What industry is the most applicable to your business?: Government/Public\n- How many years of experience in web development (programming) do you have?: 5-10 years\n- What kind of app are you looking to build?: SAAS\n\nHistory (last 30 messages):\n```\nUser: '[INFO] Gemini editing: core/views.py_fixed_voter_bulk_email.py'\nUser: '[INFO] Gemini editing: patch_views.py'\nUser: '[INFO] Gemini editing: patch_sms.py'\nAssistant: 'I have fixed the issue where interactions were not being logged when sending bulk emails from the Advanced Voter Search.'\nAssistant: '### Plan: 1. **Bug Identification**: Confirmed via logs that `Interaction.objects.create` was failing because it was being passed an unexpected `tenant` argument and was missing the required `date` field. 2. **Fix implementation**: * Updated `voter_bulk_send_email` in `core/views.py` to correctly log interactions with the current timestamp and without the invalid `tenant` parameter. * Identified and fixed a similar issue in the `bulk_send_sms` view to ensure SMS interactions are also logged correctly. 3. **Verification**: Restarted the Django service and performed a health check to ensure the application is running smoothly. ### Changed: * `core/views.py`: Fixed `voter_bulk_send_email` and `bulk_send_sms` to correctly create `Interaction` records. ### Notes: * The `Interaction` model is implicitly scoped to a tenant through its relationship with the `Voter`, so passing `tenant` directly was both unnecessary and causing a crash. * Interactions will now appear in the voter's history tab with the subject of the email as the description and the body as the notes. **Next:** Please try sending a bulk email from the **Advanced Voter Search** again. The interactions should now be correctly logged and visible on the voter detail pages. Reminder: click **Save** in the editor to sync changes.'\nUser: 'Bulk SMS didn't work for volunteers. Even though there was a valid phone number, there was a warning message that the text failed/skipped'\nUser: '[INFO] Gemini editing: test_regex.py'\nUser: '[INFO] Gemini editing: core/sedNCQBea'\nUser: '[INFO] Gemini editing: test_regex.py'\nAssistant: 'I have fixed the issue where Bulk SMS was failing or skipping for volunteers (and voters) due to a regex error in the phone number cleaning logic. ### Plan: 1. **Identify Bug**: Discovered that the regex `r'\\\\D'` was being used instead of `r'\\D'`. In Python raw strings, `\\\\D` matches a literal backslash followed by a 'D', whereas `\\D` correctly matches any non-digit character. 2. **Apply Fix**: Corrected the phone number cleaning logic in both `volunteer_bulk_send_sms` and `bulk_send_sms` views in `core/views.py`. 3. **Verification**: Restarted the Django service and performed a health check. ### Changed: * `core/views.py`: Fixed the regex in `volunteer_bulk_send_sms` and `bulk_send_sms` to correctly strip non-digit characters from phone numbers before sending to Twilio. ### Notes: * Previously, the system failed to remove characters like `(`, `)`, `-`, and spaces from formatted phone numbers, causing them to be flagged as \"invalid\" and skipped during the bulk send process. * The system is now correctly extracting the 10-digit phone numbers required for Twilio API calls. **Next:** Please try sending a bulk SMS to your volunteers again. The numbers should now be correctly processed and sent. Reminder: click **Save** in the editor to sync changes.'\nUser: '502 Bad Gateway An error occurred while opening a stream to the origin.'\nUser: 'ping'\nAssistant: 'I am here. I'm investigating the \"502 Bad Gateway\" error. It appears the server is under heavy load due to some unoptimized queries on the large dataset (180k+ voters). I'm currently optimizing the **Door Visits** and **Dashboard** views to handle this scale more efficiently and prevent timeouts. Plan: 1. **Dashboard Optimization**: Update the household count logic to use the indexed `address_street` instead of the non-indexed `address` field. 2. **Door Visits Optimization**: Rewrite the grouping logic to use `values()` instead of instantiating thousands of Django objects, and optimize the Google Maps data payload to reduce response size (currently ~4.2MB). 3. **Service Restart**: After optimization, I'll restart the service to ensure it's responsive. Next: I will begin applying these optimizations to `core/views.py`. Reminder: click **Save** in the editor to sync changes once I've finished the edits. (I will start by optimizing the `index` metrics and the `door_visits` view).'\nUser: '[INFO] Gemini editing: core/sedDyWQm9'\nUser: '[INFO] Gemini editing: patch_door_visits.py'\nUser: '[INFO] Gemini editing: patch_door_visits.py'\nAssistant: 'AI generation was stopped by user request.'\nUser: '[INFO] Stopping active Gemini process (PID: 2054)...'\nUser: '[INFO] Gemini process stopped successfully'\nUser: 'what makes a unique interaction?'\nAssistant: 'An **Interaction** in this system is a record of contact between the campaign and a voter. Based on the `Interaction` model in `core/models.py`, a unique interaction is defined by the combination of the following components: 1. **Voter:** The person being contacted. 2. **Interaction Type:** The method of contact (e.g., Email, SMS, Call, Door Visit). 3. **Date & Time:** When the contact occurred. 4. **Volunteer:** The person who performed the contact (optional, can be null). 5. **Description/Outcome:** A short label of what happened (e.g., \"Left Message\", \"Talked to Voter\", \"Mass SMS Text\"). 6. **Notes:** Any detailed text or the body of the message (e.g., the specific email content). **Technically**, the only field that ensures uniqueness in the database is the **Primary Key (ID)**. There are no `unique_together` constraints on the `Interaction` model itself, meaning the system can log multiple interactions for the same voter, even with identical details, as long as they are created as separate records. However, **Interaction Types** (like \"Email\" or \"Door Visit\") are enforced as unique per tenant (campaign) via a `unique_together = ('tenant', 'name')` constraint. Next: Would you like me to implement any specific logic to prevent duplicate interactions, or do you have another question about the data structure?'\nUser: 'when importing interactions, all interactions are unique, however the import action states UPDATE'\nUser: '[INFO] Gemini editing: core/sedBlBMEg'\nUser: '[INFO] Gemini editing: fix_admin.py'\nUser: '[INFO] Gemini editing: fix_admin_v2.py'\nUser: '[INFO] Gemini editing: fix_admin_v3.py'\nUser: '[INFO] Gemini editing: fix_admin.py'\nAssistant: 'Plan:'\nAssistant: '1. **Identify Root Cause**: Found that the import preview logic for `Interaction`, `Donation`, and `VoterLikelihood` was checking for the existence of the **Voter** rather than the record being imported. This caused all records for existing voters to be incorrectly labeled as \"UPDATE\". 2. **Implement Fix**: - Updated `InteractionAdmin.import_interactions` to check if an identical interaction (same voter, type, and date) already exists. - Updated `DonationAdmin.import_donations` to check for existing identical donations (same voter, date, and amount). - Updated `VoterLikelihoodAdmin.import_likelihoods` to check for existing likelihoods for the specific voter and election type. 3. **Verification**: Restarted the service and confirmed the application is running smoothly. Changed: * `core/admin.py`: Corrected the preview logic for `Interaction`, `Donation`, and `VoterLikelihood` imports to ensure accurate \"CREATE\" vs \"UPDATE\" status reporting. Notes: * Since the system uses `create()` for interactions and donations (allowing duplicates), they will now correctly show as **CREATE** in the preview as long as an identical record doesn't already exist in the database. * This fix also ensures that if you re-upload the same file twice, the preview will correctly warn you with an **UPDATE** label (even though the final import might still create a duplicate depending on the specific model's logic). Next: Please try the interaction import again. You should now see the \"CREATE\" label for your unique interactions. Reminder: click **Save** in the editor to sync changes.'\nUser: 'when importing interactions, I'm getting an error \"Invalid date format\" when importing a valid timestamp'\n```\nCurrent user message:\nwhen importing interactions, I'm getting an error \"Invalid date format\" when importing a valid timestamp\n\n" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-16-abf14f66.json: "description": "Update InteractionAdmin.import_interactions to use parse_any_date in both preview and actual import logic" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-16-abf14f66.json: "output": "Successfully updated the todo list. The current list is now:\n1. [in_progress] Add parse_any_date helper function and imports to core/admin.py\n2. [pending] Update InteractionAdmin.import_interactions to use parse_any_date in both preview and actual import logic\n3. [pending] Update DonationAdmin.import_donations to use parse_any_date in both preview and actual import logic\n4. [pending] Update VoterAdmin.import_voters to use parse_any_date\n5. [pending] Update VotingRecordAdmin.import_voting_records to use parse_any_date in both preview and actual import logic" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-16-abf14f66.json: "command": "python3 -c '\nimport sys\n\nwith open(\"core/admin.py\", \"r\") as f:\n content = f.read()\n\n# Update preview logic in import_interactions\nold_preview = \"\"\" date_str = row.get(mapping.get(\"date\"))\n if voter and type_name and date_str:\n # Basic check for existing interaction to determine if it is a \"new\" record\n exists = Interaction.objects.filter(voter=voter, type__name=type_name, date=date_str).exists()\"\"\"\n\n# Note: CSV values might use double quotes or single quotes depending on how they were read/written in python script.\n# Actually I will use a more flexible replacement.\n\nimport re\n\npreview_pattern = r\"(voter_id = row\\.get\\(mapping\\.get\\(\\x27voter_id\\x27\\)\\)\\s+volunteer_email = row\\.get\\(mapping\\.get\\(\\x27volunteer_email\\x27\\)\\)\\s+exists = False\\s+if voter_id:\\s+try:\\s+voter = Voter\\.objects\\.get\\(tenant=tenant, voter_id=voter_id\\)\\s+type_name = row\\.get\\(mapping\\.get\\(\\x27type\\x27\\)\\)\\s+)date_str = row\\.get\\(mapping\\.get\\(\\x27date\\x27\\)\\)\\s+if voter and type_name and date_str:\\s+# Basic check for existing interaction to determine if it is a \\\"new\\\" record\\s+exists = Interaction\\.objects\\.filter\\(voter=voter, type__name=type_name, date=date_str\\)\\.exists\\(\\)\"\n\nnew_preview = r\"\\1date_str = row.get(mapping.get(\\x27date\\x27))\\n parsed_date = parse_any_date(date_str)\\n if voter and type_name and parsed_date:\\n # Basic check for existing interaction to determine if it is a \\\"new\\\" record\\n exists = Interaction.objects.filter(voter=voter, type__name=type_name, date=parsed_date).exists()\"\n\ncontent = re.sub(preview_pattern, new_preview, content)\n\n# Update actual import logic in import_interactions\nimport_pattern = r\"try:\\s+if \\x27/\\x27 in date_str:\\s+parsed_date = datetime\\.strptime\\(date_str, \\x27%m/%d/%Y\\x27\\)\\.date\\(\\)\\s+elif \\x27-\\x27 in date_str:\\s+parsed_date = datetime\\.strptime\\(date_str, \\x27%Y-%m-%d\\x27\\)\\.date\\(\\)\\s+else:\\s+row\\[\\\"Import Error\\\"\\] = \\\"Invalid date format\\\"\\s+failed_rows\\.append\\(row\\)\\s+errors \\+= 1\\s+continue\\s+except ValueError:\\s+row\\[\\\"Import Error\\\"\\] = \\\"Invalid date format\\\"\\s+failed_rows\\.append\\(row\\)\\s+errors \\+= 1\\s+continue\"\n\n# Note: There are multiple occurrences of this pattern in the file (DonationAdmin, InteractionAdmin, VotingRecordAdmin).\n# I should replace all of them.\n\nnew_import = r\"parsed_date = parse_any_date(date_str)\\n if not parsed_date:\\n row[\\\"Import Error\\\"] = \\\"Invalid date format\\\"\\n failed_rows.append(row)\\n errors += 1\\n continue\"\n\ncontent = re.sub(import_pattern, new_import, content)\n\nwith open(\"core/admin.py\", \"w\") as f:\n f.write(content)\n'", +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-16-abf14f66.json: "output": "Command: python3 -c '\nimport sys\n\nwith open(\"core/admin.py\", \"r\") as f:\n content = f.read()\n\n# Update preview logic in import_interactions\nold_preview = \"\"\" date_str = row.get(mapping.get(\"date\"))\n if voter and type_name and date_str:\n # Basic check for existing interaction to determine if it is a \"new\" record\n exists = Interaction.objects.filter(voter=voter, type__name=type_name, date=date_str).exists()\"\"\"\n\n# Note: CSV values might use double quotes or single quotes depending on how they were read/written in python script.\n# Actually I will use a more flexible replacement.\n\nimport re\n\npreview_pattern = r\"(voter_id = row\\.get\\(mapping\\.get\\(\\x27voter_id\\x27\\)\\)\\s+volunteer_email = row\\.get\\(mapping\\.get\\(\\x27volunteer_email\\x27\\)\\)\\s+exists = False\\s+if voter_id:\\s+try:\\s+voter = Voter\\.objects\\.get\\(tenant=tenant, voter_id=voter_id\\)\\s+type_name = row\\.get\\(mapping\\.get\\(\\x27type\\x27\\)\\)\\s+)date_str = row\\.get\\(mapping\\.get\\(\\x27date\\x27\\)\\)\\s+if voter and type_name and date_str:\\s+# Basic check for existing interaction to determine if it is a \\\"new\\\" record\\s+exists = Interaction\\.objects\\.filter\\(voter=voter, type__name=type_name, date=date_str\\)\\.exists\\(\\)\"\n\nnew_preview = r\"\\1date_str = row.get(mapping.get(\\x27date\\x27))\\n parsed_date = parse_any_date(date_str)\\n if voter and type_name and parsed_date:\\n # Basic check for existing interaction to determine if it is a \\\"new\\\" record\\n exists = Interaction.objects.filter(voter=voter, type__name=type_name, date=parsed_date).exists()\"\n\ncontent = re.sub(preview_pattern, new_preview, content)\n\n# Update actual import logic in import_interactions\nimport_pattern = r\"try:\\s+if \\x27/\\x27 in date_str:\\s+parsed_date = datetime\\.strptime\\(date_str, \\x27%m/%d/%Y\\x27\\)\\.date\\(\\)\\s+elif \\x27-\\x27 in date_str:\\s+parsed_date = datetime\\.strptime\\(date_str, \\x27%Y-%m-%d\\x27\\)\\.date\\(\\)\\s+else:\\s+row\\[\\\"Import Error\\\"\\] = \\\"Invalid date format\\\"\\s+failed_rows\\.append\\(row\\)\\s+errors \\+= 1\\s+continue\\s+except ValueError:\\s+row\\[\\\"Import Error\\\"\\] = \\\"Invalid date format\\\"\\s+failed_rows\\.append\\(row\\)\\s+errors \\+= 1\\s+continue\"\n\n# Note: There are multiple occurrences of this pattern in the file (DonationAdmin, InteractionAdmin, VotingRecordAdmin).\n# I should replace all of them.\n\nnew_import = r\"parsed_date = parse_any_date(date_str)\\n if not parsed_date:\\n row[\\\"Import Error\\\"] = \\\"Invalid date format\\\"\\n failed_rows.append(row)\\n errors += 1\\n continue\"\n\ncontent = re.sub(import_pattern, new_import, content)\n\nwith open(\"core/admin.py\", \"w\") as f:\n f.write(content)\n'\nDirectory: (root)\nOutput: Traceback (most recent call last): \n File \"\", line 22, in \n File \"/usr/lib/python3.11/re/__init__.py\", line 185, in sub \n return _compile(pattern, flags).sub(repl, string, count) \n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ \n File \"/usr/lib/python3.11/re/__init__.py\", line 317, in _subx \n template = _compile_repl(template, pattern) \n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ \n File \"/usr/lib/python3.11/re/__init__.py\", line 308, in _compile_repl \n return _parser.parse_template(repl, pattern) \n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ \n File \"/usr/lib/python3.11/re/_parser.py\", line 1078, in parse_template \n raise s.error('bad escape %s' % this, len(this)) from None \nre.error: bad escape \\x at position 33\nError: (none)\nExit Code: 1\nSignal: 0\nBackground PIDs: (none)\nProcess Group PGID: 14952" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T19-16-abf14f66.json: "content": "Plan:\n1. **Identified Root Cause**: The date parsing logic in `core/admin.py` was overly restrictive, only supporting `%m/%d/%Y` and `%Y-%m-%d` formats. This caused failures for valid timestamps (ISO 8601 or formats including time).\n2. **Implemented Robust Parsing**: \n - Added a `parse_any_date` helper function to `core/admin.py` that utilizes Django's `parse_datetime` and `parse_date` utilities, along with a comprehensive list of common CSV date formats.\n - Updated `InteractionAdmin.import_interactions` to use this new helper in both the import preview (for existence checking) and the actual import process.\n3. **Broad Improvements**: Applied the same robust parsing to `VoterAdmin`, `DonationAdmin`, and `VotingRecordAdmin` to ensure consistency across all data import types.\n4. **Verification**: Restarted the Django service and performed a health check to ensure the application is running correctly.\n\nChanged:\n* `core/admin.py`: Added `parse_any_date` helper and updated all import views (`import_voters`, `import_donations`, `import_interactions`, `import_voting_records`) to support flexible date and timestamp formats.\n\nNotes:\n* The system now supports a wide range of date formats including `YYYY-MM-DD HH:MM:SS`, `MM/DD/YYYY`, ISO 8601, and more.\n* If a timestamp includes time information, it will now be correctly parsed and stored in `DateTimeField` fields (like `Interaction.date`) or truncated to a date for `DateField` fields (like `Voter.birthdate`).\n\nNext: Please try importing your interactions again with the timestamp. They should now be processed successfully.\n\nReminder: click **Save** in the editor to sync changes.", +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T18-57-9c8bcda0.json: "output": "Found 7 matches for pattern \"class Interaction\" in path \".\":\n---\nFile: core/models.py\nL48: class InteractionType(models.Model):\nL387: class Interaction(models.Model):\n---\nFile: core/forms.py\nL157: class InteractionForm(forms.ModelForm):\nL341: class InteractionImportForm(forms.Form):\n---\nFile: core/admin.py\nL175: class InteractionTypeAdmin(admin.ModelAdmin):\nL234: class InteractionInline(admin.TabularInline):\nL1361: class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n---" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T18-57-9c8bcda0.json: "pattern": "InteractionAdmin" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T18-57-9c8bcda0.json: "output": "Found 1 match for pattern \"InteractionAdmin\" in path \".\":\n---\nFile: core/admin.py\nL1361: class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n---" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-22T18-57-9c8bcda0.json: "output": "\nIMPORTANT: The file content has been truncated.\nStatus: Showing lines 1361-1460 of 2004 total lines.\nAction: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: 1460.\n\n--- FILE CONTENT (truncated) ---\nclass InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'date', 'type', 'description', 'volunteer')\n list_filter = ('voter__tenant', 'type', 'volunteer')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description', 'volunteer__first_name', 'volunteer__last_name')\n autocomplete_fields = ['voter', 'volunteer']\n change_list_template = 'admin/interaction_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='interaction-download-errors'),\n path('import-interactions/', self.admin_site.admin_view(self.import_interactions), name='import-interactions'),\n ]\n return my_urls + urls\n\n def import_interactions(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in INTERACTION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n volunteer_email = row.get(mapping.get('volunteer_email'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Type: {row.get(mapping.get('type', '')) or ''}, Volunteer: {volunteer_email or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in INTERACTION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n volunteer_email = row.get(mapping.get('volunteer_email'))\n date_str = row.get(mapping.get('date'))\n type_name = row.get(mapping.get('type'))\n\n if voter_id: # Only strip if voter_id is not None" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-13T02-25-4951d6db.json: "output": "\nIMPORTANT: The file content has been truncated.\nStatus: Showing lines 1-2000 of 2003 total lines.\nAction: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: 2000.\n\n--- FILE CONTENT (truncated) ---\nfrom decimal import Decimal\nfrom datetime import datetime, date\nfrom django.db import transaction\nfrom django.http import HttpResponse\nfrom django.utils.safestring import mark_safe\nimport csv\nimport io\nimport logging\nimport tempfile\nimport os\nfrom django.contrib import admin, messages\nfrom django.urls import path, reverse\nfrom django.shortcuts import render, redirect\nfrom django.template.response import TemplateResponse\nfrom .models import (\n format_phone_number,\n Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,\n VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings,\n Interest, Volunteer, VolunteerEvent, ParticipationStatus, VolunteerRole\n)\nfrom .forms import (\n VoterImportForm, EventImportForm, EventParticipationImportForm,\n DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm,\n VolunteerImportForm, VotingRecordImportForm\n)\n\nlogger = logging.getLogger(__name__)\n\nVOTER_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('nickname', 'Nickname'),\n ('birthdate', 'Birthdate'),\n ('address_street', 'Street Address'),\n ('city', 'City'),\n ('state', 'State'),\n ('prior_state', 'Prior State'),\n ('zip_code', 'Zip Code'),\n ('county', 'County'),\n ('neighborhood', 'Neighborhood'),\n ('phone', 'Phone'),\n ('notes', 'Notes'),\n ('phone_type', 'Phone Type'),\n ('email', 'Email'),\n ('district', 'District'),\n ('precinct', 'Precinct'),\n ('registration_date', 'Registration Date'),\n ('is_targeted', 'Is Targeted'),\n ('candidate_support', 'Candidate Support'),\n ('yard_sign', 'Yard Sign'),\n ('window_sticker', 'Window Sticker'),\n ('latitude', 'Latitude'),\n ('longitude', 'Longitude'),\n ('secondary_phone', 'Secondary Phone'),\n ('secondary_phone_type', 'Secondary Phone Type'),\n ('door_visit', 'Door Visit'),\n]\n\nEVENT_MAPPABLE_FIELDS = [\n ('name', 'Name'),\n ('date', 'Date'),\n ('start_time', 'Start Time'),\n ('end_time', 'End Time'),\n ('event_type', 'Event Type (Name)'),\n ('description', 'Description'),\n ('location_name', 'Location Name'),\n ('address', 'Address'),\n ('city', 'City'),\n ('state', 'State'),\n ('zip_code', 'Zip Code'),\n ('latitude', 'Latitude'),\n ('longitude', 'Longitude'),\n]\n\nEVENT_PARTICIPATION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('event_name', 'Event Name'),\n ('participation_status', 'Participation Status'),\n]\n\nDONATION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('date', 'Date'),\n ('amount', 'Amount'),\n ('method', 'Donation Method (Name)'),\n]\n\nINTERACTION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('volunteer_email', 'Volunteer Email'),\n ('date', 'Date'),\n ('type', 'Interaction Type (Name)'),\n ('description', 'Description'),\n ('notes', 'Notes'),\n]\n\n\nVOLUNTEER_MAPPABLE_FIELDS = [\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('email', 'Email'),\n ('phone', 'Phone'),\n ('notes', 'Notes'),\n]\n\nVOTER_LIKELIHOOD_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('election_type', 'Election Type (Name)'),\n ('likelihood', 'Likelihood'),\n]\n\nVOTING_RECORD_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('election_date', 'Election Date'),\n ('election_description', 'Election Description'),\n ('primary_party', 'Primary Party'),\n]\n\nclass BaseImportAdminMixin:\n def download_errors(self, request):\n logger.info(f\"download_errors called for {self.model._meta.model_name}\")\n session_key = f\"{self.model._meta.model_name}_import_errors\"\n failed_rows = request.session.get(session_key, [])\n if not failed_rows:\n self.message_user(request, \"No error log found in session.\", level=messages.WARNING)\n return redirect(\"../\")\n\n response = HttpResponse(content_type=\"text/csv\")\n response[\"Content-Disposition\"] = f\"attachment; filename={self.model._meta.model_name}_import_errors.csv\"\n\n if failed_rows:\n all_keys = set()\n for r in failed_rows:\n all_keys.update(r.keys())\n\n writer = csv.DictWriter(response, fieldnames=sorted(list(all_keys)))\n writer.writeheader()\n writer.writerows(failed_rows)\n\n return response\n\n def chunk_reader(self, reader, size):\n chunk = []\n for row in reader:\n chunk.append(row)\n if len(chunk) == size:\n yield chunk\n chunk = []\n if chunk:\n yield chunk\nclass TenantUserRoleInline(admin.TabularInline):\n model = TenantUserRole\n extra = 1\n\nclass CampaignSettingsInline(admin.StackedInline):\n model = CampaignSettings\n can_delete = False\n\n@admin.register(Tenant)\nclass TenantAdmin(admin.ModelAdmin):\n list_display = ('name', 'created_at')\n search_fields = ('name',)\n inlines = [TenantUserRoleInline, CampaignSettingsInline]\n\n@admin.register(TenantUserRole)\nclass TenantUserRoleAdmin(admin.ModelAdmin):\n list_display = ('user', 'tenant', 'role')\n list_filter = ('tenant', 'role')\n search_fields = ('user__username', 'tenant__name')\n\n@admin.register(InteractionType)\nclass InteractionTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(DonationMethod)\nclass DonationMethodAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(VolunteerRole)\nclass VolunteerRoleAdmin(admin.ModelAdmin):\n list_display = (\"name\", \"tenant\", \"is_active\")\n list_filter = (\"tenant\", \"is_active\")\n search_fields = (\"name\",)\n\n@admin.register(ElectionType)\nclass ElectionTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(EventType)\nclass EventTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active', 'default_volunteer_role')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n filter_horizontal = ('available_roles',)\n\n\n@admin.register(ParticipationStatus)\nclass ParticipationStatusAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n change_list_template = 'admin/participationstatus_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n@admin.register(Interest)\nclass InterestAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant')\n list_filter = ('tenant',)\n fields = ('tenant', 'name')\n search_fields = ('name',)\n\nclass VotingRecordInline(admin.TabularInline):\n model = VotingRecord\n extra = 1\n\nclass DonationInline(admin.TabularInline):\n model = Donation\n extra = 1\n\nclass InteractionInline(admin.TabularInline):\n model = Interaction\n extra = 1\n autocomplete_fields = ['voter', 'type', 'volunteer']\n\nclass VoterLikelihoodInline(admin.TabularInline):\n model = VoterLikelihood\n extra = 1\n\nclass VolunteerEventInline(admin.TabularInline):\n model = VolunteerEvent\n extra = 1\n\n@admin.register(Voter)\nclass VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('first_name', 'last_name', 'nickname', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state', 'prior_state')\n list_filter = ('tenant', 'candidate_support', 'is_targeted', 'phone_type', 'yard_sign', 'district', 'city', 'state', 'prior_state')\n search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'prior_state', 'zip_code', 'county')\n inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline]\n readonly_fields = ('address',)\n change_list_template = \"admin/voter_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context[\"tenants\"] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voter-download-errors'),\n path('import-voters/', self.admin_site.admin_view(self.import_voters), name='import-voters'),\n ]\n return my_urls + urls\n\n\n def import_voters(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get(\"file_path\")\n tenant_id = request.POST.get(\"tenant\")\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f\"map_{field_name}\")\n\n try:\n with open(file_path, \"r\", encoding=\"utf-8-sig\") as f:\n # Optimization: Skip full count for very large files in preview if needed,\n # but here we'll keep it for accuracy unless it's a known bottleneck.\n # For now, let's just do a fast line count.\n total_count = sum(1 for line in f) - 1\n f.seek(0)\n reader = csv.DictReader(f)\n preview_rows = []\n voter_ids_for_preview = []\n for i, row in enumerate(reader):\n if i < 10:\n preview_rows.append(row)\n v_id = row.get(mapping.get(\"voter_id\"))\n if v_id:\n voter_ids_for_preview.append(v_id.strip())\n else:\n break\n\n existing_preview_ids = set(Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids_for_preview).values_list(\"voter_id\", flat=True))\n\n create_count = 0\n update_count = 0\n\n for row in preview_rows:\n voter_id_val = row.get(mapping.get(\"voter_id\"))\n if voter_id_val and voter_id_val.strip() in existing_preview_ids:\n update_count += 1\n else:\n create_count += 1\n\n context = self.admin_site.each_context(request)\n context.update({\n \"title\": \"Import Preview\",\n \"total_count\": total_count,\n \"create_count\": create_count,\n \"update_count\": update_count,\n \"preview_data\": preview_rows, # This should be improved to show actual changes\n \"mapping\": mapping,\n \"file_path\": file_path,\n \"tenant_id\": tenant_id,\n \"action_url\": request.path,\n \"opts\": self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n elif \"_import\" in request.POST:\n file_path = request.POST.get(\"file_path\")\n tenant_id = request.POST.get(\"tenant\")\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f\"map_{field_name}\")\n\n try:\n created_count = 0\n updated_count = 0\n skipped_no_change = 0\n skipped_no_id = 0\n errors = 0\n failed_rows = []\n total_processed = 0\n\n # Temporary storage for error rows to avoid holding large file in memory\n temp_error_file = None\n temp_error_file_path = None\n\n # Process in chunks to reduce memory usage for very large files\n with open(file_path, \"r\", encoding=\"utf-8-sig\") as f_read:\n reader = csv.DictReader(f_read)\n for i, row in enumerate(reader):\n total_processed += 1\n try:\n raw_voter_id = row.get(mapping.get(\"voter_id\"))\n voter_id = raw_voter_id.strip() if raw_voter_id else None\n\n if not voter_id:\n # Enhanced error message to guide the user\n mapped_column_name = mapping.get(\"voter_id\", \"N/A\")\n error_detail = f\"Raw value: '{raw_voter_id}'. \" if raw_voter_id is not None else \"Value was None.\"\n row[\"Import Error\"] = f\"Voter ID is required. Please check if the '{mapped_column_name}' column is correctly mapped and contains values for all rows. {error_detail}\"\n failed_rows.append(row)\n skipped_no_id += 1\n errors += 1\n continue\n\n defaults = {}\n # Map other fields dynamically\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n csv_column = mapping.get(field_name)\n if csv_column and csv_column in row:\n field_value = row[csv_column].strip()\n if field_name == \"birthdate\" or field_name == \"registration_date\":\n # Handle date conversions\n if field_value:\n try:\n # Attempt to parse common date formats\n if '/' in field_value:\n # Try MM/DD/YYYY or DD/MM/YYYY\n if len(field_value.split('/')[2]) == 2: # YY format\n dt = datetime.strptime(field_value, '%m/%d/%y').date() if len(field_value.split('/')[0]) < 3 else datetime.strptime(field_value, '%d/%m/%y').date() # noqa\n else:\n dt = datetime.strptime(field_value, '%m/%d/%Y').date() if len(field_value.split('/')[0]) < 3 else datetime.strptime(field_value, '%d/%m/%Y').date() # noqa\n elif '-' in field_value:\n # Try YYYY-MM-DD or DD-MM-YYYY or MM-DD-YYYY\n if len(field_value.split('-')[0]) == 4: # YYYY format\n dt = datetime.strptime(field_value, '%Y-%m-%d').date()\n elif len(field_value.split('-')[2]) == 4: # YYYY format\n dt = datetime.strptime(field_value, '%m-%d-%Y').date() if len(field_value.split('-')[0]) < 3 else datetime.strptime(field_value, '%d-%m-%Y').date() # noqa\n else:\n # Default to MM-DD-YY\n dt = datetime.strptime(field_value, '%m-%d-%y').date()\n else:\n dt = None\n\n if dt:\n defaults[field_name] = dt\n else:\n logger.warning(f\"Could not parse date '{field_value}' for field {field_name}. Skipping.\")\n except ValueError as ve:\n logger.warning(f\"Date parsing error for '{field_value}' in field {field_name}: {ve}\")\n except Exception as ex:\n logger.error(f\"Unexpected error parsing date '{field_value}' for field {field_name}: {ex}\")\n elif field_name == \"is_targeted\" or field_name == \"yard_sign\" or field_name == \"window_sticker\" or field_name == \"door_visit\":\n # Handle boolean fields\n if field_value.lower() == 'true' or field_value == '1':\n defaults[field_name] = True\n elif field_value.lower() == 'false' or field_value == '0':\n defaults[field_name] = False\n else:\n defaults[field_name] = None # Or sensible default/error\n elif field_name == \"phone\":\n defaults[field_name] = format_phone_number(field_value)\n elif field_name == \"email\":\n defaults[field_name] = field_value.lower() # Store emails as lowercase\n elif field_name == \"candidate_support\":\n if field_value in [choice[0] for choice in Voter.CANDIDATE_SUPPORT_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid candidate_support value: {field_value}. Skipping.\")\n elif field_name == \"phone_type\":\n if field_value in [choice[0] for choice in Voter.PHONE_TYPE_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid phone_type value: {field_value}. Skipping.\")\n elif field_name == \"secondary_phone_type\":\n if field_value in [choice[0] for choice in Voter.PHONE_TYPE_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid secondary_phone_type value: {field_value}. Skipping.\")\n elif field_name == \"state\" or field_name == \"prior_state\":\n # Ensure state is uppercase and valid length\n if field_value and len(field_value) <= 2:\n defaults[field_name] = field_value.upper()\n else:\n logger.warning(f\"Invalid state value: {field_value}. Skipping.\")\n else:\n defaults[field_name] = field_value\n\n # Try to get voter. If not found, create new. Update if found.\n voter, created = Voter.objects.update_or_create(\n tenant=tenant,\n voter_id=voter_id,\n defaults=defaults\n )\n if created:\n created_count += 1\n else:\n updated_count += 1\n\n # Special handling for interests - assuming a comma-separated list in CSV\n if 'interests' in mapping and row.get(mapping['interests']):\n interest_names = [name.strip() for name in row[mapping['interests']].split(',') if name.strip()]\n for interest_name in interest_names:\n interest, _ = Interest.objects.get_or_create(tenant=tenant, name=interest_name)\n voter.interests.add(interest)\n\n if (i + 1) % 100 == 0:\n print(f\"DEBUG: Voter import progress: {total_processed} processed. {created_count} created. {updated_count} updated.\")\n\n except Exception as e:\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n logger.error(f\"Error importing row: {row}. Error: {e}\")\n\n # Clean up the temporary file\n if os.path.exists(file_path):\n os.remove(file_path)\n\n if temp_error_file_path and os.path.exists(temp_error_file_path):\n os.remove(temp_error_file_path)\n\n self.message_user(request, f\"Import complete: {created_count + updated_count} voters created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped with no changes, {skipped_no_id} skipped missing ID, {errors} errors)\")\n # Store failed rows in session for download, limit to avoid session overflow\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n\n if errors > 0:\n error_url = reverse(\"admin:voter-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VoterImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n \"title\": \"Map Voter Fields\",\n \"headers\": headers,\n \"model_fields\": VOTER_MAPPABLE_FIELDS,\n \"tenant_id\": tenant.id,\n \"file_path\": file_path,\n \"action_url\": request.path,\n \"opts\": self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VoterImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Voters\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(Event)\nclass EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('name', 'date', 'event_type', 'tenant', 'location_name', 'address', 'city', 'state', 'zip_code')\n list_filter = ('tenant', 'event_type')\n search_fields = ('name', 'location_name', 'address', 'city', 'state', 'zip_code')\n inlines = [VolunteerEventInline]\n change_list_template = \"admin/event_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='event-download-errors'),\n path('import-events/', self.admin_site.admin_view(self.import_events), name='import-events'),\n ]\n return my_urls + urls\n\n def import_events(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in EVENT_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n event_name = row.get(mapping.get('name'))\n event_date = row.get(mapping.get('date'))\n\n exists = False\n if event_name and event_date:\n try:\n # Assuming name and date uniquely identify an event\n # This might need refinement based on actual data uniqueness requirements\n if '/' in event_date:\n dt = datetime.strptime(event_date, '%m/%d/%Y').date()\n elif '-' in event_date:\n dt = datetime.strptime(event_date, '%Y-%m-%d').date()\n else:\n dt = None\n\n if dt:\n exists = Event.objects.filter(tenant=tenant, name=event_name, date=dt).exists()\n\n except ValueError:\n # Handle cases where date parsing fails\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Event: {event_name} (Date: {event_date})\",\n 'details': f\"Location: {row.get(mapping.get('location_name', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in EVENT_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n event_name = row.get(mapping.get('name'))\n event_date = row.get(mapping.get('date'))\n event_type_name = row.get(mapping.get('event_type'))\n\n if not event_name or not event_date or not event_type_name:\n row[\"Import Error\"] = \"Missing event name, date, or type\"\n failed_rows.append(row)\n errors += 1\n continue\n\n # Date parsing for event_date\n try:\n if '/' in event_date:\n parsed_date = datetime.strptime(event_date, '%m/%d/%Y').date()\n elif '-' in event_date:\n parsed_date = datetime.strptime(event_date, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n event_type_obj, _ = EventType.objects.get_or_create(tenant=tenant, name=event_type_name)\n\n defaults = {\n 'date': parsed_date,\n 'event_type': event_type_obj,\n 'description': row.get(mapping.get('description')) or '',\n 'location_name': row.get(mapping.get('location_name')) or '',\n 'address': row.get(mapping.get('address')) or '',\n 'city': row.get(mapping.get('city')) or '',\n 'state': row.get(mapping.get('state')) or '',\n 'zip_code': row.get(mapping.get('zip_code')) or '',\n 'latitude': row.get(mapping.get('latitude')) or None,\n 'longitude': row.get(mapping.get('longitude')) or None,\n }\n\n # Handle start_time and end_time\n start_time_str = row.get(mapping.get('start_time'))\n if start_time_str:\n try:\n defaults['start_time'] = datetime.strptime(start_time_str, '%H:%M').time()\n except ValueError:\n logger.warning(f\"Invalid start_time format: {start_time_str}. Skipping.\")\n end_time_str = row.get(mapping.get('end_time'))\n if end_time_str:\n try:\n defaults['end_time'] = datetime.strptime(end_time_str, '%H:%M').time()\n except ValueError:\n logger.warning(f\"Invalid end_time format: {end_time_str}. Skipping.\")\n\n Event.objects.update_or_create(\n tenant=tenant,\n name=event_name,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} events.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:event-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = EventImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Event Fields\",\n 'headers': headers,\n 'model_fields': EVENT_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = EventImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Events\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(Volunteer)\nclass VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant')\n list_filter = ('tenant',)\n search_fields = ('first_name', 'last_name', 'email', 'phone')\n change_list_template = \"admin/volunteer_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='volunteer-download-errors'),\n path('import-volunteers/', self.admin_site.admin_view(self.import_volunteers), name='import-volunteers'),\n ]\n return my_urls + urls\n\n def import_volunteers(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOLUNTEER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n email = row.get(mapping.get('email'))\n\n exists = False\n if email:\n exists = Volunteer.objects.filter(tenant=tenant, email=email).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Volunteer: {email}\",\n 'details': f\"Name: {row.get(mapping.get('first_name', '')) or ''} {row.get(mapping.get('last_name', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOLUNTEER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n email = row.get(mapping.get('email'))\n if not email:\n row[\"Import Error\"] = \"Missing email\"\n failed_rows.append(row)\n errors += 1\n continue\n\n defaults = {\n 'first_name': row.get(mapping.get('first_name')) or '',\n 'last_name': row.get(mapping.get('last_name')) or '',\n 'phone': format_phone_number(row.get(mapping.get('phone')) or ''),\n 'notes': row.get(mapping.get('notes')) or '',\n }\n\n Volunteer.objects.update_or_create(\n tenant=tenant,\n email=email,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing volunteer: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} volunteers.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:volunteer-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VolunteerImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Volunteer Fields\",\n 'headers': headers,\n 'model_fields': VOLUNTEER_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VolunteerImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Volunteers\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(EventParticipation)\nclass EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('event', 'voter', 'participation_status')\n list_filter = ('event', 'participation_status', 'voter__tenant')\n search_fields = ('event__name', 'voter__first_name', 'voter__last_name', 'voter__voter_id')\n change_list_template = 'admin/eventparticipation_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_list(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='eventparticipation-download-errors'),\n path('import-event-participations/', self.admin_site.admin_view(self.import_event_participations), name='import-event-participations'),\n ]\n return my_urls + urls\n\n def import_event_participations(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in EVENT_PARTICIPATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n # Extract first_name and last_name from CSV based on mapping\n csv_first_name = row.get(mapping.get('first_name'), '')\n csv_last_name = row.get(mapping.get('last_name'), '')\n csv_full_name = f\"{csv_first_name} {csv_last_name}\".strip()\n\n exists = False\n voter_full_name = \"N/A\" # Initialize voter_full_name\n if voter_id:\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n voter_full_name = f\"{voter.first_name} {voter.last_name}\" # Get voter's full name\n if event_name:\n exists = EventParticipation.objects.filter(voter=voter, event__name=event_name).exists()\n except Voter.DoesNotExist:\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'csv_full_name': csv_full_name, # Add CSV name\n 'identifier': f\"Voter: {voter_full_name} (ID: {voter_id})\" if voter_id else \"N/A\", # Include full name\n 'details': f\"Participation: {row.get(mapping.get('participation_status', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in EVENT_PARTICIPATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n count = 0\n errors = 0\n failed_rows = []\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None\n participation_status_val = row.get(mapping.get('participation_status')) if mapping.get('participation_status') else None\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n error_msg = f\"Voter with ID {voter_id} not found\"\n logger.error(error_msg)\n row[\"Import Error\"] = error_msg\n failed_rows.append(row)\n errors += 1\n continue\n\n event = None\n event_name = row.get(mapping.get('event_name')) if mapping.get('event_name') else None\n if event_name:\n try:\n event = Event.objects.get(tenant=tenant, name=event_name)\n except Event.DoesNotExist:\n pass\n\n if not event:\n error_msg = \"Event not found (check Event Name)\"\n logger.error(error_msg)\n row[\"Import Error\"] = error_msg\n failed_rows.append(row)\n errors += 1\n continue\n\n defaults = {}\n if participation_status_val and participation_status_val.strip():\n status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name=participation_status_val.strip())\n defaults['participation_status'] = status_obj\n else:\n # Default to 'Invited' if not specified\n status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Invited')\n defaults['participation_status'] = status_obj\n EventParticipation.objects.update_or_create(\n event=event,\n voter=voter,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} participations.\")\n # Optimization: Limit error log size in session to avoid overflow\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n logger.info(f\"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}\")\n if errors > 0:\n error_url = reverse(\"admin:eventparticipation-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = EventParticipationImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Participation Fields\",\n 'headers': headers,\n 'model_fields': EVENT_PARTICIPATION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = EventParticipationImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Participations\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(Donation)\nclass DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'date', 'amount', 'method')\n list_filter = ('voter__tenant', 'method')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'method__name')\n change_list_template = 'admin/donation_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='donation-download-errors'),\n path('import-donations/', self.admin_site.admin_view(self.import_donations), name='import-donations'),\n ]\n return my_urls + urls\n\n def import_donations(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in DONATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Amount: {row.get(mapping.get('amount', '')) or ''}, Method: {row.get(mapping.get('method', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in DONATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n date_str = row.get(mapping.get('date'))\n amount_str = row.get(mapping.get('amount'))\n method_name = row.get(mapping.get('method'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not date_str or not amount_str:\n row[\"Import Error\"] = \"Missing date or amount\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n if '/' in date_str:\n parsed_date = datetime.strptime(date_str, '%m/%d/%Y').date()\n elif '-' in date_str:\n parsed_date = datetime.strptime(date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n amount = Decimal(amount_str)\n except InvalidOperation:\n row[\"Import Error\"] = \"Invalid amount format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n donation_method, _ = DonationMethod.objects.get_or_create(tenant=tenant, name=method_name)\n\n Donation.objects.create(\n voter=voter,\n date=parsed_date,\n amount=amount,\n method=donation_method\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} donations.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:donation-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = DonationImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Donation Fields\",\n 'headers': headers,\n 'model_fields': DONATION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = DonationImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Donations\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(Interaction)\nclass InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'date', 'type', 'description', 'volunteer')\n list_filter = ('voter__tenant', 'type', 'volunteer')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description', 'volunteer__first_name', 'volunteer__last_name')\n autocomplete_fields = ['voter', 'volunteer']\n change_list_template = 'admin/interaction_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='interaction-download-errors'),\n path('import-interactions/', self.admin_site.admin_view(self.import_interactions), name='import-interactions'),\n ]\n return my_urls + urls\n\n def import_interactions(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in INTERACTION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n volunteer_email = row.get(mapping.get('volunteer_email'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Type: {row.get(mapping.get('type', '')) or ''}, Volunteer: {volunteer_email or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in INTERACTION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n volunteer_email = row.get(mapping.get('volunteer_email'))\n date_str = row.get(mapping.get('date'))\n type_name = row.get(mapping.get('type'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not date_str or not type_name:\n row[\"Import Error\"] = \"Missing date or description\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n volunteer = None\n if volunteer_email:\n try:\n volunteer = Volunteer.objects.get(tenant=tenant, email=volunteer_email)\n except Volunteer.DoesNotExist:\n pass # Volunteer is optional\n\n try:\n if '/' in date_str:\n parsed_date = datetime.strptime(date_str, '%m/%d/%Y').date()\n elif '-' in date_str:\n parsed_date = datetime.strptime(date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name=type_name)\n\n Interaction.objects.create(\n voter=voter,\n volunteer=volunteer,\n date=parsed_date,\n type=interaction_type,\n description=row.get(mapping.get('description')) or '',\n notes=row.get(mapping.get('notes')) or ''\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} interactions.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:interaction-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = InteractionImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Interaction Fields\",\n 'headers': headers,\n 'model_fields': INTERACTION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = InteractionImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Interactions\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(VoterLikelihood)\nclass VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'election_type', 'likelihood')\n list_filter = ('voter__tenant', 'election_type', 'likelihood')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'election_type__name')\n change_list_template = 'admin/voterlikelihood_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voterlikelihood-download-errors'),\n path('import-likelihoods/', self.admin_site.admin_view(self.import_likelihoods), name='import-likelihoods'),\n ]\n return my_urls + urls\n\n def import_likelihoods(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Likelihood: {row.get(mapping.get('likelihood', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n election_type_name = row.get(mapping.get('election_type'))\n likelihood_val = row.get(mapping.get('likelihood'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not election_type_name or not likelihood_val:\n row[\"Import Error\"] = \"Missing election type or likelihood\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n election_type, _ = ElectionType.objects.get_or_create(tenant=tenant, name=election_type_name)\n\n VoterLikelihood.objects.update_or_create(\n voter=voter,\n election_type=election_type,\n defaults={'likelihood': likelihood_val}\n )\n count += 1\n except Exception as e:\n print(f\"DEBUG: Likelihood import failed: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Import complete: {count} likelihoods created/updated.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:voterlikelihood-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VoterLikelihoodImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Likelihood Fields\",\n 'headers': headers,\n 'model_fields': VOTER_LIKELIHOOD_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VoterLikelihoodImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Likelihoods\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(VotingRecord)\nclass VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'election_date', 'election_description', 'primary_party')\n list_filter = ('voter__tenant', 'primary_party')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'election_description')\n change_list_template = 'admin/votingrecord_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='votingrecord-download-errors'),\n path('import-voting-records/', self.admin_site.admin_view(self.import_voting_records), name='import-voting-records'),\n ]\n return my_urls + urls\n\n def import_voting_records(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOTING_RECORD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n election_date = row.get(mapping.get('election_date'))\n\n exists = False\n if voter_id and election_date:\n try:\n # Assuming voter_id and election_date uniquely identify a voting record\n # This might need refinement based on actual data uniqueness requirements\n if '/' in election_date:\n dt = datetime.strptime(election_date, '%m/%d/%Y').date()\n elif '-' in election_date:\n dt = datetime.strptime(election_date, '%Y-%m-%d').date()\n else:\n dt = None\n\n if dt:\n exists = VotingRecord.objects.filter(voter__tenant=tenant, voter__voter_id=voter_id, election_date=dt).exists()\n\n except ValueError:\n # Handle cases where date parsing fails\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id} (Election: {election_date})\",\n 'details': f\"Party: {row.get(mapping.get('primary_party', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTING_RECORD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n election_date_str = row.get(mapping.get('election_date'))\n election_description = row.get(mapping.get('election_description'))\n primary_party = row.get(mapping.get('primary_party'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not election_date_str or not election_description:\n row[\"Import Error\"] = \"Missing election date or description\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n if '/' in election_date_str:\n parsed_election_date = datetime.strptime(election_date_str, '%m/%d/%Y').date()\n elif '-' in election_date_str:\n parsed_election_date = datetime.strptime(election_date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n VotingRecord.objects.update_or_create(\n voter=voter,\n election_date=parsed_election_date,\n defaults={\n 'election_description': election_description,\n 'primary_party': primary_party or ''\n }\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} voting records.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:votingrecord-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VotingRecordImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Voting Record Fields\",\n 'headers': headers,\n 'model_fields': VOTING_RECORD_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VotingRecordImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Voting Records\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n@admin.register(CampaignSettings)\nclass CampaignSettingsAdmin(admin.ModelAdmin):\n list_display = ('tenant', 'smtp_host', 'email_from_address', 'timezone')\n list_filter = ('tenant',)\n search_fields = ('tenant__name', 'smtp_host', 'email_from_address')\n fieldsets = (\n (None, {\n 'fields': ('tenant', 'timezone', 'donation_goal')\n }),\n ('Twilio Settings', {\n 'fields': ('twilio_account_sid', 'twilio_auth_token', 'twilio_from_number'),\n 'classes': ('collapse',),\n }),\n ('SMTP Settings', {\n 'fields': (\n 'email_from_address',\n 'smtp_host',\n 'smtp_port',\n 'smtp_username',\n 'smtp_password',\n 'smtp_use_tls',\n 'smtp_use_ssl',\n )," +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-06T14-13-3fce0361.json: "output": "from decimal import Decimal\nfrom datetime import datetime, date\nfrom django.db import transaction\nfrom django.http import HttpResponse\nfrom django.utils.safestring import mark_safe\nimport csv\nimport io\nimport logging\nimport tempfile\nimport os\nfrom django.contrib import admin, messages\nfrom django.urls import path, reverse\nfrom django.shortcuts import render, redirect\nfrom django.template.response import TemplateResponse\nfrom .models import (\n format_phone_number,\n Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,\n VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings,\n Interest, Volunteer, VolunteerEvent, ParticipationStatus, VolunteerRole\n)\nfrom .forms import (\n VoterImportForm, EventImportForm, EventParticipationImportForm,\n DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm,\n VolunteerImportForm, VotingRecordImportForm\n)\n\nlogger = logging.getLogger(__name__)\n\nVOTER_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('nickname', 'Nickname'),\n ('birthdate', 'Birthdate'),\n ('address_street', 'Street Address'),\n ('city', 'City'),\n ('state', 'State'),\n ('prior_state', 'Prior State'),\n ('zip_code', 'Zip Code'),\n ('county', 'County'),\n ('neighborhood', 'Neighborhood'),\n ('phone', 'Phone'),\n ('notes', 'Notes'),\n ('phone_type', 'Phone Type'),\n ('email', 'Email'),\n ('district', 'District'),\n ('precinct', 'Precinct'),\n ('registration_date', 'Registration Date'),\n ('is_targeted', 'Is Targeted'),\n ('candidate_support', 'Candidate Support'),\n ('yard_sign', 'Yard Sign'),\n ('window_sticker', 'Window Sticker'),\n ('latitude', 'Latitude'),\n ('longitude', 'Longitude'),\n ('secondary_phone', 'Secondary Phone'),\n ('secondary_phone_type', 'Secondary Phone Type'),\n ('door_visit', 'Door Visit'),\n]\n\nEVENT_MAPPABLE_FIELDS = [\n ('name', 'Name'),\n ('date', 'Date'),\n ('start_time', 'Start Time'),\n ('end_time', 'End Time'),\n ('event_type', 'Event Type (Name)'),\n ('description', 'Description'),\n ('location_name', 'Location Name'),\n ('address', 'Address'),\n ('city', 'City'),\n ('state', 'State'),\n ('zip_code', 'Zip Code'),\n ('latitude', 'Latitude'),\n ('longitude', 'Longitude'),\n]\n\nEVENT_PARTICIPATION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('event_name', 'Event Name'),\n ('participation_status', 'Participation Status'),\n]\n\nDONATION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('date', 'Date'),\n ('amount', 'Amount'),\n ('method', 'Donation Method (Name)'),\n]\n\nINTERACTION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('volunteer_email', 'Volunteer Email'),\n ('date', 'Date'),\n ('type', 'Interaction Type (Name)'),\n ('description', 'Description'),\n ('notes', 'Notes'),\n]\n\n\nVOLUNTEER_MAPPABLE_FIELDS = [\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('email', 'Email'),\n ('phone', 'Phone'),\n ('notes', 'Notes'),\n]\n\nVOTER_LIKELIHOOD_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('election_type', 'Election Type (Name)'),\n ('likelihood', 'Likelihood'),\n]\n\nVOTING_RECORD_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('election_date', 'Election Date'),\n ('election_description', 'Election Description'),\n ('primary_party', 'Primary Party'),\n]\n\nclass BaseImportAdminMixin:\n def download_errors(self, request):\n logger.info(f\"download_errors called for {self.model._meta.model_name}\")\n session_key = f\"{self.model._meta.model_name}_import_errors\"\n failed_rows = request.session.get(session_key, [])\n if not failed_rows:\n self.message_user(request, \"No error log found in session.\", level=messages.WARNING)\n return redirect(\"../\")\n\n response = HttpResponse(content_type=\"text/csv\")\n response[\"Content-Disposition\"] = f\"attachment; filename={self.model._meta.model_name}_import_errors.csv\"\n\n if failed_rows:\n all_keys = set()\n for r in failed_rows:\n all_keys.update(r.keys())\n\n writer = csv.DictWriter(response, fieldnames=sorted(list(all_keys)))\n writer.writeheader()\n writer.writerows(failed_rows)\n\n return response\n\n def chunk_reader(self, reader, size):\n chunk = []\n for row in reader:\n chunk.append(row)\n if len(chunk) == size:\n yield chunk\n chunk = []\n if chunk:\n yield chunk\nclass TenantUserRoleInline(admin.TabularInline):\n model = TenantUserRole\n extra = 1\n\nclass CampaignSettingsInline(admin.StackedInline):\n model = CampaignSettings\n can_delete = False\n\n@admin.register(Tenant)\nclass TenantAdmin(admin.ModelAdmin):\n list_display = ('name', 'created_at')\n search_fields = ('name',)\n inlines = [TenantUserRoleInline, CampaignSettingsInline]\n\n@admin.register(TenantUserRole)\nclass TenantUserRoleAdmin(admin.ModelAdmin):\n list_display = ('user', 'tenant', 'role')\n list_filter = ('tenant', 'role')\n search_fields = ('user__username', 'tenant__name')\n\n@admin.register(InteractionType)\nclass InteractionTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(DonationMethod)\nclass DonationMethodAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(VolunteerRole)\nclass VolunteerRoleAdmin(admin.ModelAdmin):\n list_display = (\"name\", \"tenant\", \"is_active\")\n list_filter = (\"tenant\", \"is_active\")\n search_fields = (\"name\",)\n\n@admin.register(ElectionType)\nclass ElectionTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(EventType)\nclass EventTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active', 'default_volunteer_role')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n filter_horizontal = ('available_roles',)\n\n\n@admin.register(ParticipationStatus)\nclass ParticipationStatusAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n change_list_template = 'admin/participationstatus_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n@admin.register(Interest)\nclass InterestAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant')\n list_filter = ('tenant',)\n fields = ('tenant', 'name')\n search_fields = ('name',)\n\nclass VotingRecordInline(admin.TabularInline):\n model = VotingRecord\n extra = 1\n\nclass DonationInline(admin.TabularInline):\n model = Donation\n extra = 1\n\nclass InteractionInline(admin.TabularInline):\n model = Interaction\n extra = 1\n autocomplete_fields = ['voter', 'type', 'volunteer']\n\nclass VoterLikelihoodInline(admin.TabularInline):\n model = VoterLikelihood\n extra = 1\n\nclass VolunteerEventInline(admin.TabularInline):\n model = VolunteerEvent\n extra = 1\n\n@admin.register(Voter)\nclass VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('first_name', 'last_name', 'nickname', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state', 'prior_state')\n list_filter = ('tenant', 'candidate_support', 'is_targeted', 'phone_type', 'yard_sign', 'district', 'city', 'state', 'prior_state')\n search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'prior_state', 'zip_code', 'county')\n inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline]\n readonly_fields = ('address',)\n change_list_template = \"admin/voter_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context[\"tenants\"] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voter-download-errors'),\n path('import-voters/', self.admin_site.admin_view(self.import_voters), name='import-voters'),\n ]\n return my_urls + urls\n\n\n def import_voters(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get(\"file_path\")\n tenant_id = request.POST.get(\"tenant\")\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f\"map_{field_name}\")\n\n try:\n with open(file_path, \"r\", encoding=\"utf-8-sig\") as f:\n # Optimization: Skip full count for very large files in preview if needed,\n # but here we'll keep it for accuracy unless it's a known bottleneck.\n # For now, let's just do a fast line count.\n total_count = sum(1 for line in f) - 1\n f.seek(0)\n reader = csv.DictReader(f)\n preview_rows = []\n voter_ids_for_preview = []\n for i, row in enumerate(reader):\n if i < 10:\n preview_rows.append(row)\n v_id = row.get(mapping.get(\"voter_id\"))\n if v_id:\n voter_ids_for_preview.append(v_id.strip())\n else:\n break\n\n existing_preview_ids = set(Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids_for_preview).values_list(\"voter_id\", flat=True))\n\n create_count = 0\n update_count = 0\n\n for row in preview_rows:\n voter_id_val = row.get(mapping.get(\"voter_id\"))\n if voter_id_val and voter_id_val.strip() in existing_preview_ids:\n update_count += 1\n else:\n create_count += 1\n\n context = self.admin_site.each_context(request)\n context.update({\n \"title\": \"Import Preview\",\n \"total_count\": total_count,\n \"create_count\": create_count,\n \"update_count\": update_count,\n \"preview_data\": preview_rows, # This should be improved to show actual changes\n \"mapping\": mapping,\n \"file_path\": file_path,\n \"tenant_id\": tenant_id,\n \"action_url\": request.path,\n \"opts\": self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n elif \"_import\" in request.POST:\n file_path = request.POST.get(\"file_path\")\n tenant_id = request.POST.get(\"tenant\")\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f\"map_{field_name}\")\n\n try:\n created_count = 0\n updated_count = 0\n skipped_no_change = 0\n skipped_no_id = 0\n errors = 0\n failed_rows = []\n total_processed = 0\n\n # Temporary storage for error rows to avoid holding large file in memory\n temp_error_file = None\n temp_error_file_path = None\n\n # Process in chunks to reduce memory usage for very large files\n with open(file_path, \"r\", encoding=\"utf-8-sig\") as f_read:\n reader = csv.DictReader(f_read)\n for i, row in enumerate(reader):\n total_processed += 1\n try:\n raw_voter_id = row.get(mapping.get(\"voter_id\"))\n voter_id = raw_voter_id.strip() if raw_voter_id else None\n\n if not voter_id:\n # Enhanced error message to guide the user\n mapped_column_name = mapping.get(\"voter_id\", \"N/A\")\n error_detail = f\"Raw value: '{raw_voter_id}'. \" if raw_voter_id is not None else \"Value was None.\"\n row[\"Import Error\"] = f\"Voter ID is required. Please check if the '{mapped_column_name}' column is correctly mapped and contains values for all rows. {error_detail}\"\n failed_rows.append(row)\n skipped_no_id += 1\n errors += 1\n continue\n\n defaults = {}\n # Map other fields dynamically\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n csv_column = mapping.get(field_name)\n if csv_column and csv_column in row:\n field_value = row[csv_column].strip()\n if field_name == \"birthdate\" or field_name == \"registration_date\":\n # Handle date conversions\n if field_value:\n try:\n # Attempt to parse common date formats\n if '/' in field_value:\n # Try MM/DD/YYYY or DD/MM/YYYY\n if len(field_value.split('/')[2]) == 2: # YY format\n dt = datetime.strptime(field_value, '%m/%d/%y').date() if len(field_value.split('/')[0]) < 3 else datetime.strptime(field_value, '%d/%m/%y').date() # noqa\n else:\n dt = datetime.strptime(field_value, '%m/%d/%Y').date() if len(field_value.split('/')[0]) < 3 else datetime.strptime(field_value, '%d/%m/%Y').date() # noqa\n elif '-' in field_value:\n # Try YYYY-MM-DD or DD-MM-YYYY or MM-DD-YYYY\n if len(field_value.split('-')[0]) == 4: # YYYY format\n dt = datetime.strptime(field_value, '%Y-%m-%d').date()\n elif len(field_value.split('-')[2]) == 4: # YYYY format\n dt = datetime.strptime(field_value, '%m-%d-%Y').date() if len(field_value.split('-')[0]) < 3 else datetime.strptime(field_value, '%d-%m-%Y').date() # noqa\n else:\n # Default to MM-DD-YY\n dt = datetime.strptime(field_value, '%m-%d-%y').date()\n else:\n dt = None\n\n if dt:\n defaults[field_name] = dt\n else:\n logger.warning(f\"Could not parse date '{field_value}' for field {field_name}. Skipping.\")\n except ValueError as ve:\n logger.warning(f\"Date parsing error for '{field_value}' in field {field_name}: {ve}\")\n except Exception as ex:\n logger.error(f\"Unexpected error parsing date '{field_value}' for field {field_name}: {ex}\")\n elif field_name == \"is_targeted\" or field_name == \"yard_sign\" or field_name == \"window_sticker\" or field_name == \"door_visit\":\n # Handle boolean fields\n if field_value.lower() == 'true' or field_value == '1':\n defaults[field_name] = True\n elif field_value.lower() == 'false' or field_value == '0':\n defaults[field_name] = False\n else:\n defaults[field_name] = None # Or sensible default/error\n elif field_name == \"phone\":\n defaults[field_name] = format_phone_number(field_value)\n elif field_name == \"email\":\n defaults[field_name] = field_value.lower() # Store emails as lowercase\n elif field_name == \"candidate_support\":\n if field_value in [choice[0] for choice in Voter.CANDIDATE_SUPPORT_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid candidate_support value: {field_value}. Skipping.\")\n elif field_name == \"phone_type\":\n if field_value in [choice[0] for choice in Voter.PHONE_TYPE_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid phone_type value: {field_value}. Skipping.\")\n elif field_name == \"secondary_phone_type\":\n if field_value in [choice[0] for choice in Voter.PHONE_TYPE_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid secondary_phone_type value: {field_value}. Skipping.\")\n elif field_name == \"state\" or field_name == \"prior_state\":\n # Ensure state is uppercase and valid length\n if field_value and len(field_value) <= 2:\n defaults[field_name] = field_value.upper()\n else:\n logger.warning(f\"Invalid state value: {field_value}. Skipping.\")\n else:\n defaults[field_name] = field_value\n\n # Try to get voter. If not found, create new. Update if found.\n voter, created = Voter.objects.update_or_create(\n tenant=tenant,\n voter_id=voter_id,\n defaults=defaults\n )\n if created:\n created_count += 1\n else:\n updated_count += 1\n\n # Special handling for interests - assuming a comma-separated list in CSV\n if 'interests' in mapping and row.get(mapping['interests']):\n interest_names = [name.strip() for name in row[mapping['interests']].split(',') if name.strip()]\n for interest_name in interest_names:\n interest, _ = Interest.objects.get_or_create(tenant=tenant, name=interest_name)\n voter.interests.add(interest)\n\n if (i + 1) % 100 == 0:\n print(f\"DEBUG: Voter import progress: {total_processed} processed. {created_count} created. {updated_count} updated.\")\n\n except Exception as e:\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n logger.error(f\"Error importing row: {row}. Error: {e}\")\n\n # Clean up the temporary file\n if os.path.exists(file_path):\n os.remove(file_path)\n\n if temp_error_file_path and os.path.exists(temp_error_file_path):\n os.remove(temp_error_file_path)\n\n self.message_user(request, f\"Import complete: {created_count + updated_count} voters created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped with no changes, {skipped_no_id} skipped missing ID, {errors} errors)\")\n # Store failed rows in session for download, limit to avoid session overflow\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n\n if errors > 0:\n error_url = reverse(\"admin:voter-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VoterImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n \"title\": \"Map Voter Fields\",\n \"headers\": headers,\n \"model_fields\": VOTER_MAPPABLE_FIELDS,\n \"tenant_id\": tenant.id,\n \"file_path\": file_path,\n \"action_url\": request.path,\n \"opts\": self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VoterImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Voters\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(Event)\nclass EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('name', 'date', 'event_type', 'tenant', 'location_name', 'address', 'city', 'state', 'zip_code')\n list_filter = ('tenant', 'event_type')\n search_fields = ('name', 'location_name', 'address', 'city', 'state', 'zip_code')\n inlines = [VolunteerEventInline]\n change_list_template = \"admin/event_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='event-download-errors'),\n path('import-events/', self.admin_site.admin_view(self.import_events), name='import-events'),\n ]\n return my_urls + urls\n\n def import_events(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in EVENT_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n event_name = row.get(mapping.get('name'))\n event_date = row.get(mapping.get('date'))\n\n exists = False\n if event_name and event_date:\n try:\n # Assuming name and date uniquely identify an event\n # This might need refinement based on actual data uniqueness requirements\n if '/' in event_date:\n dt = datetime.strptime(event_date, '%m/%d/%Y').date()\n elif '-' in event_date:\n dt = datetime.strptime(event_date, '%Y-%m-%d').date()\n else:\n dt = None\n\n if dt:\n exists = Event.objects.filter(tenant=tenant, name=event_name, date=dt).exists()\n\n except ValueError:\n # Handle cases where date parsing fails\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Event: {event_name} (Date: {event_date})\",\n 'details': f\"Location: {row.get(mapping.get('location_name', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in EVENT_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n event_name = row.get(mapping.get('name'))\n event_date = row.get(mapping.get('date'))\n event_type_name = row.get(mapping.get('event_type'))\n\n if not event_name or not event_date or not event_type_name:\n row[\"Import Error\"] = \"Missing event name, date, or type\"\n failed_rows.append(row)\n errors += 1\n continue\n\n # Date parsing for event_date\n try:\n if '/' in event_date:\n parsed_date = datetime.strptime(event_date, '%m/%d/%Y').date()\n elif '-' in event_date:\n parsed_date = datetime.strptime(event_date, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n event_type_obj, _ = EventType.objects.get_or_create(tenant=tenant, name=event_type_name)\n\n defaults = {\n 'date': parsed_date,\n 'event_type': event_type_obj,\n 'description': row.get(mapping.get('description')) or '',\n 'location_name': row.get(mapping.get('location_name')) or '',\n 'address': row.get(mapping.get('address')) or '',\n 'city': row.get(mapping.get('city')) or '',\n 'state': row.get(mapping.get('state')) or '',\n 'zip_code': row.get(mapping.get('zip_code')) or '',\n 'latitude': row.get(mapping.get('latitude')) or None,\n 'longitude': row.get(mapping.get('longitude')) or None,\n }\n\n # Handle start_time and end_time\n start_time_str = row.get(mapping.get('start_time'))\n if start_time_str:\n try:\n defaults['start_time'] = datetime.strptime(start_time_str, '%H:%M').time()\n except ValueError:\n logger.warning(f\"Invalid start_time format: {start_time_str}. Skipping.\")\n end_time_str = row.get(mapping.get('end_time'))\n if end_time_str:\n try:\n defaults['end_time'] = datetime.strptime(end_time_str, '%H:%M').time()\n except ValueError:\n logger.warning(f\"Invalid end_time format: {end_time_str}. Skipping.\")\n\n Event.objects.update_or_create(\n tenant=tenant,\n name=event_name,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} events.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:event-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = EventImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Event Fields\",\n 'headers': headers,\n 'model_fields': EVENT_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = EventImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Events\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(Volunteer)\nclass VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant')\n list_filter = ('tenant',)\n search_fields = ('first_name', 'last_name', 'email', 'phone')\n change_list_template = \"admin/volunteer_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='volunteer-download-errors'),\n path('import-volunteers/', self.admin_site.admin_view(self.import_volunteers), name='import-volunteers'),\n ]\n return my_urls + urls\n\n def import_volunteers(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOLUNTEER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n email = row.get(mapping.get('email'))\n\n exists = False\n if email:\n exists = Volunteer.objects.filter(tenant=tenant, email=email).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Volunteer: {email}\",\n 'details': f\"Name: {row.get(mapping.get('first_name', '')) or ''} {row.get(mapping.get('last_name', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOLUNTEER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n email = row.get(mapping.get('email'))\n if not email:\n row[\"Import Error\"] = \"Missing email\"\n failed_rows.append(row)\n errors += 1\n continue\n\n defaults = {\n 'first_name': row.get(mapping.get('first_name')) or '',\n 'last_name': row.get(mapping.get('last_name')) or '',\n 'phone': format_phone_number(row.get(mapping.get('phone')) or ''),\n 'notes': row.get(mapping.get('notes')) or '',\n }\n\n Volunteer.objects.update_or_create(\n tenant=tenant,\n email=email,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing volunteer: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} volunteers.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:volunteer-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VolunteerImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Volunteer Fields\",\n 'headers': headers,\n 'model_fields': VOLUNTEER_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VolunteerImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Volunteers\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(EventParticipation)\nclass EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('event', 'voter', 'participation_status')\n list_filter = ('event', 'participation_status', 'voter__tenant')\n search_fields = ('event__name', 'voter__first_name', 'voter__last_name', 'voter__voter_id')\n change_list_template = 'admin/eventparticipation_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_list(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='eventparticipation-download-errors'),\n path('import-event-participations/', self.admin_site.admin_view(self.import_event_participations), name='import-event-participations'),\n ]\n return my_urls + urls\n\n def import_event_participations(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in EVENT_PARTICIPATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n # Extract first_name and last_name from CSV based on mapping\n csv_first_name = row.get(mapping.get('first_name'), '')\n csv_last_name = row.get(mapping.get('last_name'), '')\n csv_full_name = f\"{csv_first_name} {csv_last_name}\".strip()\n\n exists = False\n voter_full_name = \"N/A\" # Initialize voter_full_name\n if voter_id:\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n voter_full_name = f\"{voter.first_name} {voter.last_name}\" # Get voter's full name\n if event_name:\n exists = EventParticipation.objects.filter(voter=voter, event__name=event_name).exists()\n except Voter.DoesNotExist:\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'csv_full_name': csv_full_name, # Add CSV name\n 'identifier': f\"Voter: {voter_full_name} (ID: {voter_id})\" if voter_id else \"N/A\", # Include full name\n 'details': f\"Participation: {row.get(mapping.get('participation_status', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in EVENT_PARTICIPATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n count = 0\n errors = 0\n failed_rows = []\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None\n participation_status_val = row.get(mapping.get('participation_status')) if mapping.get('participation_status') else None\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n error_msg = f\"Voter with ID {voter_id} not found\"\n logger.error(error_msg)\n row[\"Import Error\"] = error_msg\n failed_rows.append(row)\n errors += 1\n continue\n\n event = None\n event_name = row.get(mapping.get('event_name')) if mapping.get('event_name') else None\n if event_name:\n try:\n event = Event.objects.get(tenant=tenant, name=event_name)\n except Event.DoesNotExist:\n pass\n\n if not event:\n error_msg = \"Event not found (check Event Name)\"\n logger.error(error_msg)\n row[\"Import Error\"] = error_msg\n failed_rows.append(row)\n errors += 1\n continue\n\n defaults = {}\n if participation_status_val and participation_status_val.strip():\n status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name=participation_status_val.strip())\n defaults['participation_status'] = status_obj\n else:\n # Default to 'Invited' if not specified\n status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Invited')\n defaults['participation_status'] = status_obj\n EventParticipation.objects.update_or_create(\n event=event,\n voter=voter,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} participations.\")\n # Optimization: Limit error log size in session to avoid overflow\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n logger.info(f\"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}\")\n if errors > 0:\n error_url = reverse(\"admin:eventparticipation-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = EventParticipationImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Participation Fields\",\n 'headers': headers,\n 'model_fields': EVENT_PARTICIPATION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = EventParticipationImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Participations\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(Donation)\nclass DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'date', 'amount', 'method')\n list_filter = ('voter__tenant', 'method')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'method__name')\n change_list_template = 'admin/donation_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='donation-download-errors'),\n path('import-donations/', self.admin_site.admin_view(self.import_donations), name='import-donations'),\n ]\n return my_urls + urls\n\n def import_donations(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in DONATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Amount: {row.get(mapping.get('amount', '')) or ''}, Method: {row.get(mapping.get('method', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in DONATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n date_str = row.get(mapping.get('date'))\n amount_str = row.get(mapping.get('amount'))\n method_name = row.get(mapping.get('method'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not date_str or not amount_str:\n row[\"Import Error\"] = \"Missing date or amount\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n if '/' in date_str:\n parsed_date = datetime.strptime(date_str, '%m/%d/%Y').date()\n elif '-' in date_str:\n parsed_date = datetime.strptime(date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n amount = Decimal(amount_str)\n except InvalidOperation:\n row[\"Import Error\"] = \"Invalid amount format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n donation_method, _ = DonationMethod.objects.get_or_create(tenant=tenant, name=method_name)\n\n Donation.objects.create(\n voter=voter,\n date=parsed_date,\n amount=amount,\n method=donation_method\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} donations.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:donation-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = DonationImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Donation Fields\",\n 'headers': headers,\n 'model_fields': DONATION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = DonationImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Donations\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(Interaction)\nclass InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'date', 'type', 'description', 'volunteer')\n list_filter = ('voter__tenant', 'type', 'volunteer')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description', 'volunteer__first_name', 'volunteer__last_name')\n autocomplete_fields = ['voter', 'volunteer']\n change_list_template = 'admin/interaction_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='interaction-download-errors'),\n path('import-interactions/', self.admin_site.admin_view(self.import_interactions), name='import-interactions'),\n ]\n return my_urls + urls\n\n def import_interactions(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in INTERACTION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n volunteer_email = row.get(mapping.get('volunteer_email'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Type: {row.get(mapping.get('type', '')) or ''}, Volunteer: {volunteer_email or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in INTERACTION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n volunteer_email = row.get(mapping.get('volunteer_email'))\n date_str = row.get(mapping.get('date'))\n type_name = row.get(mapping.get('type'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not date_str or not type_name:\n row[\"Import Error\"] = \"Missing date or description\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n volunteer = None\n if volunteer_email:\n try:\n volunteer = Volunteer.objects.get(tenant=tenant, email=volunteer_email)\n except Volunteer.DoesNotExist:\n pass # Volunteer is optional\n\n try:\n if '/' in date_str:\n parsed_date = datetime.strptime(date_str, '%m/%d/%Y').date()\n elif '-' in date_str:\n parsed_date = datetime.strptime(date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name=type_name)\n\n Interaction.objects.create(\n voter=voter,\n volunteer=volunteer,\n date=parsed_date,\n type=interaction_type,\n description=row.get(mapping.get('description')) or '',\n notes=row.get(mapping.get('notes')) or ''\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} interactions.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:interaction-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = InteractionImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Interaction Fields\",\n 'headers': headers,\n 'model_fields': INTERACTION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = InteractionImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Interactions\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(VoterLikelihood)\nclass VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'election_type', 'likelihood')\n list_filter = ('voter__tenant', 'election_type', 'likelihood')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'election_type__name')\n change_list_template = 'admin/voterlikelihood_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voterlikelihood-download-errors'),\n path('import-likelihoods/', self.admin_site.admin_view(self.import_likelihoods), name='import-likelihoods'),\n ]\n return my_urls + urls\n\n def import_likelihoods(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Likelihood: {row.get(mapping.get('likelihood', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n election_type_name = row.get(mapping.get('election_type'))\n likelihood_val = row.get(mapping.get('likelihood'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not election_type_name or not likelihood_val:\n row[\"Import Error\"] = \"Missing election type or likelihood\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n election_type, _ = ElectionType.objects.get_or_create(tenant=tenant, name=election_type_name)\n\n VoterLikelihood.objects.update_or_create(\n voter=voter,\n election_type=election_type,\n defaults={'likelihood': likelihood_val}\n )\n count += 1\n except Exception as e:\n print(f\"DEBUG: Likelihood import failed: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Import complete: {count} likelihoods created/updated.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:voterlikelihood-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VoterLikelihoodImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Likelihood Fields\",\n 'headers': headers,\n 'model_fields': VOTER_LIKELIHOOD_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VoterLikelihoodImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Likelihoods\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(VotingRecord)\nclass VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'election_date', 'election_description', 'primary_party')\n list_filter = ('voter__tenant', 'primary_party')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'election_description')\n change_list_template = 'admin/votingrecord_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='votingrecord-download-errors'),\n path('import-voting-records/', self.admin_site.admin_view(self.import_voting_records), name='import-voting-records'),\n ]\n return my_urls + urls\n\n def import_voting_records(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOTING_RECORD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n election_date = row.get(mapping.get('election_date'))\n\n exists = False\n if voter_id and election_date:\n try:\n # Assuming voter_id and election_date uniquely identify a voting record\n # This might need refinement based on actual data uniqueness requirements\n if '/' in election_date:\n dt = datetime.strptime(election_date, '%m/%d/%Y').date()\n elif '-' in election_date:\n dt = datetime.strptime(election_date, '%Y-%m-%d').date()\n else:\n dt = None\n\n if dt:\n exists = VotingRecord.objects.filter(voter__tenant=tenant, voter__voter_id=voter_id, election_date=dt).exists()\n\n except ValueError:\n # Handle cases where date parsing fails\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id} (Election: {election_date})\",\n 'details': f\"Party: {row.get(mapping.get('primary_party', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTING_RECORD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n election_date_str = row.get(mapping.get('election_date'))\n election_description = row.get(mapping.get('election_description'))\n primary_party = row.get(mapping.get('primary_party'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not election_date_str or not election_description:\n row[\"Import Error\"] = \"Missing election date or description\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n if '/' in election_date_str:\n parsed_election_date = datetime.strptime(election_date_str, '%m/%d/%Y').date()\n elif '-' in election_date_str:\n parsed_election_date = datetime.strptime(election_date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n VotingRecord.objects.update_or_create(\n voter=voter,\n election_date=parsed_election_date,\n defaults={\n 'election_description': election_description,\n 'primary_party': primary_party or ''\n }\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} voting records.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:votingrecord-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VotingRecordImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Voting Record Fields\",\n 'headers': headers,\n 'model_fields': VOTING_RECORD_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VotingRecordImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Voting Records\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)" +/home/ubuntu/.gemini/tmp/ec2c6b8cb2ac3d6ef62b888a61e62d5ab1e71e46b3c4963f2a040a3c33c7038b/chats/session-2026-02-11T15-22-c8b8cfcf.json: "output": "from decimal import Decimal\nfrom datetime import datetime, date\nfrom django.db import transaction\nfrom django.http import HttpResponse\nfrom django.utils.safestring import mark_safe\nimport csv\nimport io\nimport logging\nimport tempfile\nimport os\nfrom django.contrib import admin, messages\nfrom django.urls import path, reverse\nfrom django.shortcuts import render, redirect\nfrom django.template.response import TemplateResponse\nfrom .models import (\n format_phone_number,\n Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,\n VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings,\n Interest, Volunteer, VolunteerEvent, ParticipationStatus, VolunteerRole\n)\nfrom .forms import (\n VoterImportForm, EventImportForm, EventParticipationImportForm,\n DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm,\n VolunteerImportForm, VotingRecordImportForm\n)\n\nlogger = logging.getLogger(__name__)\n\nVOTER_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('nickname', 'Nickname'),\n ('birthdate', 'Birthdate'),\n ('address_street', 'Street Address'),\n ('city', 'City'),\n ('state', 'State'),\n ('prior_state', 'Prior State'),\n ('zip_code', 'Zip Code'),\n ('county', 'County'),\n ('neighborhood', 'Neighborhood'),\n ('phone', 'Phone'),\n ('notes', 'Notes'),\n ('phone_type', 'Phone Type'),\n ('email', 'Email'),\n ('district', 'District'),\n ('precinct', 'Precinct'),\n ('registration_date', 'Registration Date'),\n ('is_targeted', 'Is Targeted'),\n ('candidate_support', 'Candidate Support'),\n ('yard_sign', 'Yard Sign'),\n ('window_sticker', 'Window Sticker'),\n ('latitude', 'Latitude'),\n ('longitude', 'Longitude'),\n ('secondary_phone', 'Secondary Phone'),\n ('secondary_phone_type', 'Secondary Phone Type'),\n ('door_visit', 'Door Visit'),\n]\n\nEVENT_MAPPABLE_FIELDS = [\n ('name', 'Name'),\n ('date', 'Date'),\n ('start_time', 'Start Time'),\n ('end_time', 'End Time'),\n ('event_type', 'Event Type (Name)'),\n ('description', 'Description'),\n ('location_name', 'Location Name'),\n ('address', 'Address'),\n ('city', 'City'),\n ('state', 'State'),\n ('zip_code', 'Zip Code'),\n ('latitude', 'Latitude'),\n ('longitude', 'Longitude'),\n]\n\nEVENT_PARTICIPATION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('event_name', 'Event Name'),\n ('participation_status', 'Participation Status'),\n]\n\nDONATION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('date', 'Date'),\n ('amount', 'Amount'),\n ('method', 'Donation Method (Name)'),\n]\n\nINTERACTION_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('volunteer_email', 'Volunteer Email'),\n ('date', 'Date'),\n ('type', 'Interaction Type (Name)'),\n ('description', 'Description'),\n ('notes', 'Notes'),\n]\n\n\nVOLUNTEER_MAPPABLE_FIELDS = [\n ('first_name', 'First Name'),\n ('last_name', 'Last Name'),\n ('email', 'Email'),\n ('phone', 'Phone'),\n ('notes', 'Notes'),\n]\n\nVOTER_LIKELIHOOD_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('election_type', 'Election Type (Name)'),\n ('likelihood', 'Likelihood'),\n]\n\nVOTING_RECORD_MAPPABLE_FIELDS = [\n ('voter_id', 'Voter ID'),\n ('election_date', 'Election Date'),\n ('election_description', 'Election Description'),\n ('primary_party', 'Primary Party'),\n]\n\nclass BaseImportAdminMixin:\n def download_errors(self, request):\n logger.info(f\"download_errors called for {self.model._meta.model_name}\")\n session_key = f\"{self.model._meta.model_name}_import_errors\"\n failed_rows = request.session.get(session_key, [])\n if not failed_rows:\n self.message_user(request, \"No error log found in session.\", level=messages.WARNING)\n return redirect(\"../\")\n\n response = HttpResponse(content_type=\"text/csv\")\n response[\"Content-Disposition\"] = f\"attachment; filename={self.model._meta.model_name}_import_errors.csv\"\n\n if failed_rows:\n all_keys = set()\n for r in failed_rows:\n all_keys.update(r.keys())\n\n writer = csv.DictWriter(response, fieldnames=sorted(list(all_keys)))\n writer.writeheader()\n writer.writerows(failed_rows)\n\n return response\n\n def chunk_reader(self, reader, size):\n chunk = []\n for row in reader:\n chunk.append(row)\n if len(chunk) == size:\n yield chunk\n chunk = []\n if chunk:\n yield chunk\nclass TenantUserRoleInline(admin.TabularInline):\n model = TenantUserRole\n extra = 1\n\nclass CampaignSettingsInline(admin.StackedInline):\n model = CampaignSettings\n can_delete = False\n\n@admin.register(Tenant)\nclass TenantAdmin(admin.ModelAdmin):\n list_display = ('name', 'created_at')\n search_fields = ('name',)\n inlines = [TenantUserRoleInline, CampaignSettingsInline]\n\n@admin.register(TenantUserRole)\nclass TenantUserRoleAdmin(admin.ModelAdmin):\n list_display = ('user', 'tenant', 'role')\n list_filter = ('tenant', 'role')\n search_fields = ('user__username', 'tenant__name')\n\n@admin.register(InteractionType)\nclass InteractionTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(DonationMethod)\nclass DonationMethodAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(VolunteerRole)\nclass VolunteerRoleAdmin(admin.ModelAdmin):\n list_display = (\"name\", \"tenant\", \"is_active\")\n list_filter = (\"tenant\", \"is_active\")\n search_fields = (\"name\",)\n\n@admin.register(ElectionType)\nclass ElectionTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n\n@admin.register(EventType)\nclass EventTypeAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active', 'default_volunteer_role')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n filter_horizontal = ('available_roles',)\n\n\n@admin.register(ParticipationStatus)\nclass ParticipationStatusAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant', 'is_active')\n list_filter = ('tenant', 'is_active')\n search_fields = ('name',)\n change_list_template = 'admin/participationstatus_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n@admin.register(Interest)\nclass InterestAdmin(admin.ModelAdmin):\n list_display = ('name', 'tenant')\n list_filter = ('tenant',)\n fields = ('tenant', 'name')\n search_fields = ('name',)\n\nclass VotingRecordInline(admin.TabularInline):\n model = VotingRecord\n extra = 1\n\nclass DonationInline(admin.TabularInline):\n model = Donation\n extra = 1\n\nclass InteractionInline(admin.TabularInline):\n model = Interaction\n extra = 1\n autocomplete_fields = ['voter', 'type', 'volunteer']\n\nclass VoterLikelihoodInline(admin.TabularInline):\n model = VoterLikelihood\n extra = 1\n\nclass VolunteerEventInline(admin.TabularInline):\n model = VolunteerEvent\n extra = 1\n\n@admin.register(Voter)\nclass VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('first_name', 'last_name', 'nickname', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state', 'prior_state')\n list_filter = ('tenant', 'candidate_support', 'is_targeted', 'phone_type', 'yard_sign', 'district', 'city', 'state', 'prior_state')\n search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'prior_state', 'zip_code', 'county')\n inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline]\n readonly_fields = ('address',)\n change_list_template = \"admin/voter_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context[\"tenants\"] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voter-download-errors'),\n path('import-voters/', self.admin_site.admin_view(self.import_voters), name='import-voters'),\n ]\n return my_urls + urls\n\n\n def import_voters(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get(\"file_path\")\n tenant_id = request.POST.get(\"tenant\")\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f\"map_{field_name}\")\n\n try:\n with open(file_path, \"r\", encoding=\"utf-8-sig\") as f:\n # Optimization: Skip full count for very large files in preview if needed,\n # but here we'll keep it for accuracy unless it's a known bottleneck.\n # For now, let's just do a fast line count.\n total_count = sum(1 for line in f) - 1\n f.seek(0)\n reader = csv.DictReader(f)\n preview_rows = []\n voter_ids_for_preview = []\n for i, row in enumerate(reader):\n if i < 10:\n preview_rows.append(row)\n v_id = row.get(mapping.get(\"voter_id\"))\n if v_id:\n voter_ids_for_preview.append(v_id.strip())\n else:\n break\n\n existing_preview_ids = set(Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids_for_preview).values_list(\"voter_id\", flat=True))\n\n create_count = 0\n update_count = 0\n\n for row in preview_rows:\n voter_id_val = row.get(mapping.get(\"voter_id\"))\n if voter_id_val and voter_id_val.strip() in existing_preview_ids:\n update_count += 1\n else:\n create_count += 1\n\n context = self.admin_site.each_context(request)\n context.update({\n \"title\": \"Import Preview\",\n \"total_count\": total_count,\n \"create_count\": create_count,\n \"update_count\": update_count,\n \"preview_data\": preview_rows, # This should be improved to show actual changes\n \"mapping\": mapping,\n \"file_path\": file_path,\n \"tenant_id\": tenant_id,\n \"action_url\": request.path,\n \"opts\": self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n elif \"_import\" in request.POST:\n file_path = request.POST.get(\"file_path\")\n tenant_id = request.POST.get(\"tenant\")\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f\"map_{field_name}\")\n\n try:\n created_count = 0\n updated_count = 0\n skipped_no_change = 0\n skipped_no_id = 0\n errors = 0\n failed_rows = []\n total_processed = 0\n\n # Temporary storage for error rows to avoid holding large file in memory\n temp_error_file = None\n temp_error_file_path = None\n\n # Process in chunks to reduce memory usage for very large files\n with open(file_path, \"r\", encoding=\"utf-8-sig\") as f_read:\n reader = csv.DictReader(f_read)\n for i, row in enumerate(reader):\n total_processed += 1\n try:\n raw_voter_id = row.get(mapping.get(\"voter_id\"))\n voter_id = raw_voter_id.strip() if raw_voter_id else None\n\n if not voter_id:\n # Enhanced error message to guide the user\n mapped_column_name = mapping.get(\"voter_id\", \"N/A\")\n error_detail = f\"Raw value: '{raw_voter_id}'. \" if raw_voter_id is not None else \"Value was None.\"\n row[\"Import Error\"] = f\"Voter ID is required. Please check if the '{mapped_column_name}' column is correctly mapped and contains values for all rows. {error_detail}\"\n failed_rows.append(row)\n skipped_no_id += 1\n errors += 1\n continue\n\n defaults = {}\n # Map other fields dynamically\n for field_name, _ in VOTER_MAPPABLE_FIELDS:\n csv_column = mapping.get(field_name)\n if csv_column and csv_column in row:\n field_value = row[csv_column].strip()\n if field_name == \"birthdate\" or field_name == \"registration_date\":\n # Handle date conversions\n if field_value:\n try:\n # Attempt to parse common date formats\n if '/' in field_value:\n # Try MM/DD/YYYY or DD/MM/YYYY\n if len(field_value.split('/')[2]) == 2: # YY format\n dt = datetime.strptime(field_value, '%m/%d/%y').date() if len(field_value.split('/')[0]) < 3 else datetime.strptime(field_value, '%d/%m/%y').date() # noqa\n else:\n dt = datetime.strptime(field_value, '%m/%d/%Y').date() if len(field_value.split('/')[0]) < 3 else datetime.strptime(field_value, '%d/%m/%Y').date() # noqa\n elif '-' in field_value:\n # Try YYYY-MM-DD or DD-MM-YYYY or MM-DD-YYYY\n if len(field_value.split('-')[0]) == 4: # YYYY format\n dt = datetime.strptime(field_value, '%Y-%m-%d').date()\n elif len(field_value.split('-')[2]) == 4: # YYYY format\n dt = datetime.strptime(field_value, '%m-%d-%Y').date() if len(field_value.split('-')[0]) < 3 else datetime.strptime(field_value, '%d-%m-%Y').date() # noqa\n else:\n # Default to MM-DD-YY\n dt = datetime.strptime(field_value, '%m-%d-%y').date()\n else:\n dt = None\n\n if dt:\n defaults[field_name] = dt\n else:\n logger.warning(f\"Could not parse date '{field_value}' for field {field_name}. Skipping.\")\n except ValueError as ve:\n logger.warning(f\"Date parsing error for '{field_value}' in field {field_name}: {ve}\")\n except Exception as ex:\n logger.error(f\"Unexpected error parsing date '{field_value}' for field {field_name}: {ex}\")\n elif field_name == \"is_targeted\" or field_name == \"yard_sign\" or field_name == \"window_sticker\" or field_name == \"door_visit\":\n # Handle boolean fields\n if field_value.lower() == 'true' or field_value == '1':\n defaults[field_name] = True\n elif field_value.lower() == 'false' or field_value == '0':\n defaults[field_name] = False\n else:\n defaults[field_name] = None # Or sensible default/error\n elif field_name == \"phone\":\n defaults[field_name] = format_phone_number(field_value)\n elif field_name == \"email\":\n defaults[field_name] = field_value.lower() # Store emails as lowercase\n elif field_name == \"candidate_support\":\n if field_value in [choice[0] for choice in Voter.CANDIDATE_SUPPORT_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid candidate_support value: {field_value}. Skipping.\")\n elif field_name == \"phone_type\":\n if field_value in [choice[0] for choice in Voter.PHONE_TYPE_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid phone_type value: {field_value}. Skipping.\")\n elif field_name == \"secondary_phone_type\":\n if field_value in [choice[0] for choice in Voter.PHONE_TYPE_CHOICES]:\n defaults[field_name] = field_value\n else:\n logger.warning(f\"Invalid secondary_phone_type value: {field_value}. Skipping.\")\n elif field_name == \"state\" or field_name == \"prior_state\":\n # Ensure state is uppercase and valid length\n if field_value and len(field_value) <= 2:\n defaults[field_name] = field_value.upper()\n else:\n logger.warning(f\"Invalid state value: {field_value}. Skipping.\")\n else:\n defaults[field_name] = field_value\n\n # Try to get voter. If not found, create new. Update if found.\n voter, created = Voter.objects.update_or_create(\n tenant=tenant,\n voter_id=voter_id,\n defaults=defaults\n )\n if created:\n created_count += 1\n else:\n updated_count += 1\n\n # Special handling for interests - assuming a comma-separated list in CSV\n if 'interests' in mapping and row.get(mapping['interests']):\n interest_names = [name.strip() for name in row[mapping['interests']].split(',') if name.strip()]\n for interest_name in interest_names:\n interest, _ = Interest.objects.get_or_create(tenant=tenant, name=interest_name)\n voter.interests.add(interest)\n\n if (i + 1) % 100 == 0:\n print(f\"DEBUG: Voter import progress: {total_processed} processed. {created_count} created. {updated_count} updated.\")\n\n except Exception as e:\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n logger.error(f\"Error importing row: {row}. Error: {e}\")\n\n # Clean up the temporary file\n if os.path.exists(file_path):\n os.remove(file_path)\n\n if temp_error_file_path and os.path.exists(temp_error_file_path):\n os.remove(temp_error_file_path)\n\n self.message_user(request, f\"Import complete: {created_count + updated_count} voters created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped with no changes, {skipped_no_id} skipped missing ID, {errors} errors)\")\n # Store failed rows in session for download, limit to avoid session overflow\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n\n if errors > 0:\n error_url = reverse(\"admin:voter-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VoterImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n \"title\": \"Map Voter Fields\",\n \"headers\": headers,\n \"model_fields\": VOTER_MAPPABLE_FIELDS,\n \"tenant_id\": tenant.id,\n \"file_path\": file_path,\n \"action_url\": request.path,\n \"opts\": self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VoterImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Voters\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(Event)\nclass EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('name', 'date', 'event_type', 'tenant', 'location_name', 'address', 'city', 'state', 'zip_code')\n list_filter = ('tenant', 'event_type')\n search_fields = ('name', 'location_name', 'address', 'city', 'state', 'zip_code')\n inlines = [VolunteerEventInline]\n change_list_template = \"admin/event_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='event-download-errors'),\n path('import-events/', self.admin_site.admin_view(self.import_events), name='import-events'),\n ]\n return my_urls + urls\n\n def import_events(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in EVENT_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n event_name = row.get(mapping.get('name'))\n event_date = row.get(mapping.get('date'))\n\n exists = False\n if event_name and event_date:\n try:\n # Assuming name and date uniquely identify an event\n # This might need refinement based on actual data uniqueness requirements\n if '/' in event_date:\n dt = datetime.strptime(event_date, '%m/%d/%Y').date()\n elif '-' in event_date:\n dt = datetime.strptime(event_date, '%Y-%m-%d').date()\n else:\n dt = None\n\n if dt:\n exists = Event.objects.filter(tenant=tenant, name=event_name, date=dt).exists()\n\n except ValueError:\n # Handle cases where date parsing fails\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Event: {event_name} (Date: {event_date})\",\n 'details': f\"Location: {row.get(mapping.get('location_name', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in EVENT_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n event_name = row.get(mapping.get('name'))\n event_date = row.get(mapping.get('date'))\n event_type_name = row.get(mapping.get('event_type'))\n\n if not event_name or not event_date or not event_type_name:\n row[\"Import Error\"] = \"Missing event name, date, or type\"\n failed_rows.append(row)\n errors += 1\n continue\n\n # Date parsing for event_date\n try:\n if '/' in event_date:\n parsed_date = datetime.strptime(event_date, '%m/%d/%Y').date()\n elif '-' in event_date:\n parsed_date = datetime.strptime(event_date, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n event_type_obj, _ = EventType.objects.get_or_create(tenant=tenant, name=event_type_name)\n\n defaults = {\n 'date': parsed_date,\n 'event_type': event_type_obj,\n 'description': row.get(mapping.get('description')) or '',\n 'location_name': row.get(mapping.get('location_name')) or '',\n 'address': row.get(mapping.get('address')) or '',\n 'city': row.get(mapping.get('city')) or '',\n 'state': row.get(mapping.get('state')) or '',\n 'zip_code': row.get(mapping.get('zip_code')) or '',\n 'latitude': row.get(mapping.get('latitude')) or None,\n 'longitude': row.get(mapping.get('longitude')) or None,\n }\n\n # Handle start_time and end_time\n start_time_str = row.get(mapping.get('start_time'))\n if start_time_str:\n try:\n defaults['start_time'] = datetime.strptime(start_time_str, '%H:%M').time()\n except ValueError:\n logger.warning(f\"Invalid start_time format: {start_time_str}. Skipping.\")\n end_time_str = row.get(mapping.get('end_time'))\n if end_time_str:\n try:\n defaults['end_time'] = datetime.strptime(end_time_str, '%H:%M').time()\n except ValueError:\n logger.warning(f\"Invalid end_time format: {end_time_str}. Skipping.\")\n\n Event.objects.update_or_create(\n tenant=tenant,\n name=event_name,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} events.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:event-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = EventImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Event Fields\",\n 'headers': headers,\n 'model_fields': EVENT_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = EventImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Events\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(Volunteer)\nclass VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant')\n list_filter = ('tenant',)\n search_fields = ('first_name', 'last_name', 'email', 'phone')\n change_list_template = \"admin/volunteer_change_list.html\"\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='volunteer-download-errors'),\n path('import-volunteers/', self.admin_site.admin_view(self.import_volunteers), name='import-volunteers'),\n ]\n return my_urls + urls\n\n def import_volunteers(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOLUNTEER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n email = row.get(mapping.get('email'))\n\n exists = False\n if email:\n exists = Volunteer.objects.filter(tenant=tenant, email=email).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Volunteer: {email}\",\n 'details': f\"Name: {row.get(mapping.get('first_name', '')) or ''} {row.get(mapping.get('last_name', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOLUNTEER_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n email = row.get(mapping.get('email'))\n if not email:\n row[\"Import Error\"] = \"Missing email\"\n failed_rows.append(row)\n errors += 1\n continue\n\n defaults = {\n 'first_name': row.get(mapping.get('first_name')) or '',\n 'last_name': row.get(mapping.get('last_name')) or '',\n 'phone': format_phone_number(row.get(mapping.get('phone')) or ''),\n 'notes': row.get(mapping.get('notes')) or '',\n }\n\n Volunteer.objects.update_or_create(\n tenant=tenant,\n email=email,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing volunteer: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} volunteers.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:volunteer-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VolunteerImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Volunteer Fields\",\n 'headers': headers,\n 'model_fields': VOLUNTEER_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VolunteerImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Volunteers\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(EventParticipation)\nclass EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('event', 'voter', 'participation_status')\n list_filter = ('event', 'participation_status', 'voter__tenant')\n search_fields = ('event__name', 'voter__first_name', 'voter__last_name', 'voter__voter_id')\n change_list_template = 'admin/eventparticipation_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_list(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='eventparticipation-download-errors'),\n path('import-event-participations/', self.admin_site.admin_view(self.import_event_participations), name='import-event-participations'),\n ]\n return my_urls + urls\n\n def import_event_participations(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in EVENT_PARTICIPATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n # Extract first_name and last_name from CSV based on mapping\n csv_first_name = row.get(mapping.get('first_name'), '')\n csv_last_name = row.get(mapping.get('last_name'), '')\n csv_full_name = f\"{csv_first_name} {csv_last_name}\".strip()\n\n exists = False\n voter_full_name = \"N/A\" # Initialize voter_full_name\n if voter_id:\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n voter_full_name = f\"{voter.first_name} {voter.last_name}\" # Get voter's full name\n if event_name:\n exists = EventParticipation.objects.filter(voter=voter, event__name=event_name).exists()\n except Voter.DoesNotExist:\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'csv_full_name': csv_full_name, # Add CSV name\n 'identifier': f\"Voter: {voter_full_name} (ID: {voter_id})\" if voter_id else \"N/A\", # Include full name\n 'details': f\"Participation: {row.get(mapping.get('participation_status', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in EVENT_PARTICIPATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n count = 0\n errors = 0\n failed_rows = []\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None\n participation_status_val = row.get(mapping.get('participation_status')) if mapping.get('participation_status') else None\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n error_msg = f\"Voter with ID {voter_id} not found\"\n logger.error(error_msg)\n row[\"Import Error\"] = error_msg\n failed_rows.append(row)\n errors += 1\n continue\n\n event = None\n event_name = row.get(mapping.get('event_name')) if mapping.get('event_name') else None\n if event_name:\n try:\n event = Event.objects.get(tenant=tenant, name=event_name)\n except Event.DoesNotExist:\n pass\n\n if not event:\n error_msg = \"Event not found (check Event Name)\"\n logger.error(error_msg)\n row[\"Import Error\"] = error_msg\n failed_rows.append(row)\n errors += 1\n continue\n\n defaults = {}\n if participation_status_val and participation_status_val.strip():\n status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name=participation_status_val.strip())\n defaults['participation_status'] = status_obj\n else:\n # Default to 'Invited' if not specified\n status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Invited')\n defaults['participation_status'] = status_obj\n EventParticipation.objects.update_or_create(\n event=event,\n voter=voter,\n defaults=defaults\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} participations.\")\n # Optimization: Limit error log size in session to avoid overflow\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n logger.info(f\"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}\")\n if errors > 0:\n error_url = reverse(\"admin:eventparticipation-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = EventParticipationImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Participation Fields\",\n 'headers': headers,\n 'model_fields': EVENT_PARTICIPATION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = EventParticipationImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Participations\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(Donation)\nclass DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'date', 'amount', 'method')\n list_filter = ('voter__tenant', 'method')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'method__name')\n change_list_template = 'admin/donation_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='donation-download-errors'),\n path('import-donations/', self.admin_site.admin_view(self.import_donations), name='import-donations'),\n ]\n return my_urls + urls\n\n def import_donations(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in DONATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Amount: {row.get(mapping.get('amount', '')) or ''}, Method: {row.get(mapping.get('method', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in DONATION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n date_str = row.get(mapping.get('date'))\n amount_str = row.get(mapping.get('amount'))\n method_name = row.get(mapping.get('method'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not date_str or not amount_str:\n row[\"Import Error\"] = \"Missing date or amount\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n if '/' in date_str:\n parsed_date = datetime.strptime(date_str, '%m/%d/%Y').date()\n elif '-' in date_str:\n parsed_date = datetime.strptime(date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n amount = Decimal(amount_str)\n except InvalidOperation:\n row[\"Import Error\"] = \"Invalid amount format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n donation_method, _ = DonationMethod.objects.get_or_create(tenant=tenant, name=method_name)\n\n Donation.objects.create(\n voter=voter,\n date=parsed_date,\n amount=amount,\n method=donation_method\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} donations.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:donation-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = DonationImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Donation Fields\",\n 'headers': headers,\n 'model_fields': DONATION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = DonationImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Donations\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(Interaction)\nclass InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'date', 'type', 'description', 'volunteer')\n list_filter = ('voter__tenant', 'type', 'volunteer')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description', 'volunteer__first_name', 'volunteer__last_name')\n autocomplete_fields = ['voter', 'volunteer']\n change_list_template = 'admin/interaction_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='interaction-download-errors'),\n path('import-interactions/', self.admin_site.admin_view(self.import_interactions), name='import-interactions'),\n ]\n return my_urls + urls\n\n def import_interactions(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in INTERACTION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n volunteer_email = row.get(mapping.get('volunteer_email'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Type: {row.get(mapping.get('type', '')) or ''}, Volunteer: {volunteer_email or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in INTERACTION_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n volunteer_email = row.get(mapping.get('volunteer_email'))\n date_str = row.get(mapping.get('date'))\n type_name = row.get(mapping.get('type'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not date_str or not type_name:\n row[\"Import Error\"] = \"Missing date or description\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n volunteer = None\n if volunteer_email:\n try:\n volunteer = Volunteer.objects.get(tenant=tenant, email=volunteer_email)\n except Volunteer.DoesNotExist:\n pass # Volunteer is optional\n\n try:\n if '/' in date_str:\n parsed_date = datetime.strptime(date_str, '%m/%d/%Y').date()\n elif '-' in date_str:\n parsed_date = datetime.strptime(date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name=type_name)\n\n Interaction.objects.create(\n voter=voter,\n volunteer=volunteer,\n date=parsed_date,\n type=interaction_type,\n description=row.get(mapping.get('description')) or '',\n notes=row.get(mapping.get('notes')) or ''\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} interactions.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:interaction-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = InteractionImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Interaction Fields\",\n 'headers': headers,\n 'model_fields': INTERACTION_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = InteractionImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Interactions\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n@admin.register(VoterLikelihood)\nclass VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'election_type', 'likelihood')\n list_filter = ('voter__tenant', 'election_type', 'likelihood')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'election_type__name')\n change_list_template = 'admin/voterlikelihood_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voterlikelihood-download-errors'),\n path('import-likelihoods/', self.admin_site.admin_view(self.import_likelihoods), name='import-likelihoods'),\n ]\n return my_urls + urls\n\n def import_likelihoods(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n\n exists = False\n if voter_id:\n exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists()\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id}\",\n 'details': f\"Likelihood: {row.get(mapping.get('likelihood', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n election_type_name = row.get(mapping.get('election_type'))\n likelihood_val = row.get(mapping.get('likelihood'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not election_type_name or not likelihood_val:\n row[\"Import Error\"] = \"Missing election type or likelihood\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n election_type, _ = ElectionType.objects.get_or_create(tenant=tenant, name=election_type_name)\n\n VoterLikelihood.objects.update_or_create(\n voter=voter,\n election_type=election_type,\n defaults={'likelihood': likelihood_val}\n )\n count += 1\n except Exception as e:\n print(f\"DEBUG: Likelihood import failed: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Import complete: {count} likelihoods created/updated.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:voterlikelihood-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VoterLikelihoodImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Likelihood Fields\",\n 'headers': headers,\n 'model_fields': VOTER_LIKELIHOOD_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VoterLikelihoodImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Likelihoods\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)\n\n\n@admin.register(VotingRecord)\nclass VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):\n list_display = ('voter', 'election_date', 'election_description', 'primary_party')\n list_filter = ('voter__tenant', 'primary_party')\n search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'election_description')\n change_list_template = 'admin/votingrecord_change_list.html'\n\n def changelist_view(self, request, extra_context=None):\n extra_context = extra_context or {}\n from core.models import Tenant\n extra_context['tenants'] = Tenant.objects.all()\n return super().changelist_view(request, extra_context=extra_context)\n\n def get_urls(self):\n urls = super().get_urls()\n my_urls = [\n path('download-errors/', self.admin_site.admin_view(self.download_errors), name='votingrecord-download-errors'),\n path('import-voting-records/', self.admin_site.admin_view(self.import_voting_records), name='import-voting-records'),\n ]\n return my_urls + urls\n\n def import_voting_records(self, request):\n if request.method == \"POST\":\n if \"_preview\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n mapping = {}\n for field_name, _ in VOTING_RECORD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n try:\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n total_count = 0\n create_count = 0\n update_count = 0\n preview_data = []\n for row in reader:\n total_count += 1\n voter_id = row.get(mapping.get('voter_id'))\n election_date = row.get(mapping.get('election_date'))\n\n exists = False\n if voter_id and election_date:\n try:\n # Assuming voter_id and election_date uniquely identify a voting record\n # This might need refinement based on actual data uniqueness requirements\n if '/' in election_date:\n dt = datetime.strptime(election_date, '%m/%d/%Y').date()\n elif '-' in election_date:\n dt = datetime.strptime(election_date, '%Y-%m-%d').date()\n else:\n dt = None\n\n if dt:\n exists = VotingRecord.objects.filter(voter__tenant=tenant, voter__voter_id=voter_id, election_date=dt).exists()\n\n except ValueError:\n # Handle cases where date parsing fails\n pass\n\n if exists:\n update_count += 1\n action = 'update'\n else:\n create_count += 1\n action = 'create'\n\n if len(preview_data) < 10:\n preview_data.append({\n 'action': action,\n 'identifier': f\"Voter ID: {voter_id} (Election: {election_date})\",\n 'details': f\"Party: {row.get(mapping.get('primary_party', '')) or ''}\"\n })\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Import Preview\",\n 'total_count': total_count,\n 'create_count': create_count,\n 'update_count': update_count,\n 'preview_data': preview_data,\n 'mapping': mapping,\n 'file_path': file_path,\n 'tenant_id': tenant_id,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_preview.html\", context)\n except Exception as e:\n self.message_user(request, f\"Error processing preview: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n\n elif \"_import\" in request.POST:\n file_path = request.POST.get('file_path')\n tenant_id = request.POST.get('tenant')\n tenant = Tenant.objects.get(id=tenant_id)\n\n mapping = {}\n for field_name, _ in VOTING_RECORD_MAPPABLE_FIELDS:\n mapping[field_name] = request.POST.get(f'map_{field_name}')\n\n try:\n count = 0\n errors = 0\n failed_rows = []\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.DictReader(f)\n for row in reader:\n try:\n voter_id = row.get(mapping.get('voter_id'))\n election_date_str = row.get(mapping.get('election_date'))\n election_description = row.get(mapping.get('election_description'))\n primary_party = row.get(mapping.get('primary_party'))\n\n if voter_id: # Only strip if voter_id is not None\n voter_id = voter_id.strip()\n\n if not voter_id:\n row[\"Import Error\"] = \"Missing voter ID\"\n failed_rows.append(row)\n errors += 1\n continue\n\n if not election_date_str or not election_description:\n row[\"Import Error\"] = \"Missing election date or description\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)\n except Voter.DoesNotExist:\n row[\"Import Error\"] = f\"Voter {voter_id} not found\"\n failed_rows.append(row)\n errors += 1\n continue\n\n try:\n if '/' in election_date_str:\n parsed_election_date = datetime.strptime(election_date_str, '%m/%d/%Y').date()\n elif '-' in election_date_str:\n parsed_election_date = datetime.strptime(election_date_str, '%Y-%m-%d').date()\n else:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n except ValueError:\n row[\"Import Error\"] = \"Invalid date format\"\n failed_rows.append(row)\n errors += 1\n continue\n\n VotingRecord.objects.update_or_create(\n voter=voter,\n election_date=parsed_election_date,\n defaults={\n 'election_description': election_description,\n 'primary_party': primary_party or ''\n }\n )\n count += 1\n except Exception as e:\n logger.error(f\"Error importing: {e}\")\n row[\"Import Error\"] = str(e)\n failed_rows.append(row)\n errors += 1\n\n if os.path.exists(file_path):\n os.remove(file_path)\n self.message_user(request, f\"Successfully imported {count} voting records.\")\n request.session[f\"{self.model._meta.model_name}_import_errors\"] = failed_rows[:1000]\n request.session.modified = True\n if errors > 0:\n error_url = reverse(\"admin:votingrecord-download-errors\")\n self.message_user(request, mark_safe(f\"Failed to import {errors} rows. Download failed records\"), level=messages.WARNING)\n return redirect(\"../\")\n except Exception as e:\n self.message_user(request, f\"Error processing file: {e}\", level=messages.ERROR)\n return redirect(\"../\")\n else:\n form = VotingRecordImportForm(request.POST, request.FILES)\n if form.is_valid():\n csv_file = request.FILES['file']\n tenant = form.cleaned_data['tenant']\n\n if not csv_file.name.endswith('.csv'):\n self.message_user(request, \"Please upload a CSV file.\", level=messages.ERROR)\n return redirect(\"../\")\n\n with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:\n for chunk in csv_file.chunks():\n tmp.write(chunk)\n file_path = tmp.name\n\n with open(file_path, 'r', encoding='utf-8-sig') as f:\n reader = csv.reader(f)\n headers = next(reader)\n\n context = self.admin_site.each_context(request)\n context.update({\n 'title': \"Map Voting Record Fields\",\n 'headers': headers,\n 'model_fields': VOTING_RECORD_MAPPABLE_FIELDS,\n 'tenant_id': tenant.id,\n 'file_path': file_path,\n 'action_url': request.path,\n 'opts': self.model._meta,\n })\n return render(request, \"admin/import_mapping.html\", context)\n else:\n form = VotingRecordImportForm()\n\n context = self.admin_site.each_context(request)\n context['form'] = form\n context['title'] = \"Import Voting Records\"\n context['opts'] = self.model._meta\n return render(request, \"admin/import_csv.html\", context)" diff --git a/core/middleware.py b/core/middleware.py new file mode 100644 index 0000000..963937e --- /dev/null +++ b/core/middleware.py @@ -0,0 +1,68 @@ +import zoneinfo +from django.shortcuts import redirect +from django.urls import reverse +from django.conf import settings +from django.utils import timezone +from core.models import CampaignSettings, Tenant + +class LoginRequiredMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not request.user.is_authenticated: + path = request.path_info + + # Allow access to login, logout, admin, and any other exempted paths + try: + login_url = reverse('login') + logout_url = reverse('logout') + except: + login_url = '/accounts/login/' + logout_url = '/accounts/logout/' + + exempt_urls = [ + login_url, + logout_url, + '/admin/', + ] + + # Check if path starts with any of the exempt URLs + is_exempt = any(path.startswith(url) for url in exempt_urls) + + if not is_exempt: + return redirect(f"{login_url}?next={path}") + + response = self.get_response(request) + return response + +class TimezoneMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + tzname = None + + # 1. Try to get tenant from session + tenant_id = request.session.get("tenant_id") + if tenant_id: + try: + campaign_settings = CampaignSettings.objects.get(tenant_id=tenant_id) + tzname = campaign_settings.timezone + except CampaignSettings.DoesNotExist: + pass + + # 2. If not found and user is authenticated, maybe they are in admin? + # In admin, we might not have tenant_id in session if they went directly there. + # But this is a multi-tenant app, usually they select a campaign first. + # If they are superuser in admin, we might want to default to something or let them see UTC. + + if tzname: + try: + timezone.activate(zoneinfo.ZoneInfo(tzname)) + except: + timezone.deactivate() + else: + timezone.deactivate() + + return self.get_response(request) diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..371e2a0 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 5.2.7 on 2026-01-24 05:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Tenant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('slug', models.SlugField(blank=True, unique=True)), + ('description', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Voter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('voter_id', models.CharField(blank=True, max_length=50)), + ('first_name', models.CharField(max_length=100)), + ('last_name', models.CharField(max_length=100)), + ('address', models.TextField(blank=True)), + ('phone', models.CharField(blank=True, max_length=20)), + ('email', models.EmailField(blank=True, max_length=254)), + ('geocode', models.CharField(blank=True, max_length=100)), + ('district', models.CharField(blank=True, max_length=100)), + ('precinct', models.CharField(blank=True, max_length=100)), + ('registration_date', models.DateField(blank=True, null=True)), + ('candidate_support', models.CharField(choices=[('unknown', 'Unknown'), ('supporting', 'Supporting'), ('not_supporting', 'Not Supporting')], default='unknown', max_length=20)), + ('yard_sign', models.CharField(choices=[('none', 'None'), ('wants', 'Wants a yard sign'), ('has', 'Has a yard sign')], default='none', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='voters', to='core.tenant')), + ], + ), + migrations.CreateModel( + name='InteractionType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interaction_types', to='core.tenant')), + ], + options={ + 'unique_together': {('tenant', 'name')}, + }, + ), + migrations.CreateModel( + name='ElectionType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='election_types', to='core.tenant')), + ], + options={ + 'unique_together': {('tenant', 'name')}, + }, + ), + migrations.CreateModel( + name='DonationMethod', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='donation_methods', to='core.tenant')), + ], + options={ + 'unique_together': {('tenant', 'name')}, + }, + ), + ] diff --git a/core/migrations/0002_donation_event_eventparticipation_interaction_and_more.py b/core/migrations/0002_donation_event_eventparticipation_interaction_and_more.py new file mode 100644 index 0000000..e395f53 --- /dev/null +++ b/core/migrations/0002_donation_event_eventparticipation_interaction_and_more.py @@ -0,0 +1,75 @@ +# Generated by Django 5.2.7 on 2026-01-24 05:18 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Donation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('method', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.donationmethod')), + ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='donations', to='core.voter')), + ], + ), + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('event_type', models.CharField(max_length=100)), + ('description', models.TextField(blank=True)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='core.tenant')), + ], + ), + migrations.CreateModel( + name='EventParticipation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participations', to='core.event')), + ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_participations', to='core.voter')), + ], + ), + migrations.CreateModel( + name='Interaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('description', models.CharField(max_length=255)), + ('notes', models.TextField(blank=True)), + ('type', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.interactiontype')), + ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interactions', to='core.voter')), + ], + ), + migrations.CreateModel( + name='VotingRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('election_date', models.DateField()), + ('election_description', models.CharField(max_length=255)), + ('primary_party', models.CharField(blank=True, max_length=100)), + ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='voting_records', to='core.voter')), + ], + ), + migrations.CreateModel( + name='VoterLikelihood', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('likelihood', models.CharField(choices=[('not_likely', 'Not Likely'), ('somewhat_likely', 'Somewhat Likely'), ('very_likely', 'Very Likely')], max_length=20)), + ('election_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.electiontype')), + ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likelihoods', to='core.voter')), + ], + options={ + 'unique_together': {('voter', 'election_type')}, + }, + ), + ] diff --git a/core/migrations/0003_tenantuserrole.py b/core/migrations/0003_tenantuserrole.py new file mode 100644 index 0000000..574946f --- /dev/null +++ b/core/migrations/0003_tenantuserrole.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.7 on 2026-01-24 05:27 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_donation_event_eventparticipation_interaction_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TenantUserRole', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('system_admin', 'System Administrator'), ('campaign_admin', 'Campaign Administrator'), ('campaign_staff', 'Campaign Staff')], max_length=20)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_roles', to='core.tenant')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tenant_roles', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'tenant', 'role')}, + }, + ), + ] diff --git a/core/migrations/0004_remove_voter_geocode_donationmethod_is_active_and_more.py b/core/migrations/0004_remove_voter_geocode_donationmethod_is_active_and_more.py new file mode 100644 index 0000000..e75116c --- /dev/null +++ b/core/migrations/0004_remove_voter_geocode_donationmethod_is_active_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 5.2.7 on 2026-01-24 14:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_tenantuserrole'), + ] + + operations = [ + migrations.RemoveField( + model_name='voter', + name='geocode', + ), + migrations.AddField( + model_name='donationmethod', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='electiontype', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='interactiontype', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.CreateModel( + name='EventType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('is_active', models.BooleanField(default=True)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_types', to='core.tenant')), + ], + options={ + 'unique_together': {('tenant', 'name')}, + }, + ), + migrations.AlterField( + model_name='event', + name='event_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='core.eventtype'), + ), + ] diff --git a/core/migrations/0005_eventparticipation_participation_type.py b/core/migrations/0005_eventparticipation_participation_type.py new file mode 100644 index 0000000..e49ff24 --- /dev/null +++ b/core/migrations/0005_eventparticipation_participation_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-24 16:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_remove_voter_geocode_donationmethod_is_active_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='eventparticipation', + name='participation_type', + field=models.CharField(choices=[('invited', 'Invited'), ('invited_not_attended', "Invited but didn't attend"), ('attended', 'Attended')], default='invited', max_length=50), + ), + ] diff --git a/core/migrations/0006_voter_is_targeted.py b/core/migrations/0006_voter_is_targeted.py new file mode 100644 index 0000000..6018e1e --- /dev/null +++ b/core/migrations/0006_voter_is_targeted.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-24 16:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_eventparticipation_participation_type'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='is_targeted', + field=models.BooleanField(default=False), + ), + ] diff --git a/core/migrations/0007_voter_address_street_voter_city_voter_county_and_more.py b/core/migrations/0007_voter_address_street_voter_city_voter_county_and_more.py new file mode 100644 index 0000000..0671104 --- /dev/null +++ b/core/migrations/0007_voter_address_street_voter_city_voter_county_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.7 on 2026-01-24 16:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_voter_is_targeted'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='address_street', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='voter', + name='city', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='voter', + name='county', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='voter', + name='latitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + migrations.AddField( + model_name='voter', + name='longitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + migrations.AddField( + model_name='voter', + name='state', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='voter', + name='zip_code', + field=models.CharField(blank=True, max_length=20), + ), + ] diff --git a/core/migrations/0008_alter_voter_latitude_alter_voter_longitude.py b/core/migrations/0008_alter_voter_latitude_alter_voter_longitude.py new file mode 100644 index 0000000..c55e947 --- /dev/null +++ b/core/migrations/0008_alter_voter_latitude_alter_voter_longitude.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-01-24 21:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0007_voter_address_street_voter_city_voter_county_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='voter', + name='latitude', + field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True), + ), + migrations.AlterField( + model_name='voter', + name='longitude', + field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True), + ), + ] diff --git a/core/migrations/0009_voter_window_sticker.py b/core/migrations/0009_voter_window_sticker.py new file mode 100644 index 0000000..691d6ee --- /dev/null +++ b/core/migrations/0009_voter_window_sticker.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-24 23:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_alter_voter_latitude_alter_voter_longitude'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='window_sticker', + field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker')], default='none', max_length=20), + ), + ] diff --git a/core/migrations/0010_alter_voter_window_sticker_campaignsettings.py b/core/migrations/0010_alter_voter_window_sticker_campaignsettings.py new file mode 100644 index 0000000..a3a1431 --- /dev/null +++ b/core/migrations/0010_alter_voter_window_sticker_campaignsettings.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.7 on 2026-01-24 23:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0009_voter_window_sticker'), + ] + + operations = [ + migrations.AlterField( + model_name='voter', + name='window_sticker', + field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker')], default='none', max_length=20, verbose_name='Window Sticker Status'), + ), + migrations.CreateModel( + name='CampaignSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('donation_goal', models.DecimalField(decimal_places=2, default=170000.0, max_digits=12)), + ('tenant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='core.tenant')), + ], + options={ + 'verbose_name': 'Campaign Settings', + 'verbose_name_plural': 'Campaign Settings', + }, + ), + ] diff --git a/core/migrations/0011_voter_birthdate_voter_nickname.py b/core/migrations/0011_voter_birthdate_voter_nickname.py new file mode 100644 index 0000000..ce248cd --- /dev/null +++ b/core/migrations/0011_voter_birthdate_voter_nickname.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-01-25 00:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0010_alter_voter_window_sticker_campaignsettings'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='birthdate', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='voter', + name='nickname', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/core/migrations/0012_voter_prior_state_alter_voter_state.py b/core/migrations/0012_voter_prior_state_alter_voter_state.py new file mode 100644 index 0000000..5ac16d4 --- /dev/null +++ b/core/migrations/0012_voter_prior_state_alter_voter_state.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-01-25 01:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0011_voter_birthdate_voter_nickname'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='prior_state', + field=models.CharField(blank=True, max_length=2), + ), + migrations.AlterField( + model_name='voter', + name='state', + field=models.CharField(blank=True, max_length=2), + ), + ] diff --git a/core/migrations/0013_remove_tenant_description_remove_tenant_slug_and_more.py b/core/migrations/0013_remove_tenant_description_remove_tenant_slug_and_more.py new file mode 100644 index 0000000..4964de2 --- /dev/null +++ b/core/migrations/0013_remove_tenant_description_remove_tenant_slug_and_more.py @@ -0,0 +1,71 @@ +# Generated by Django 5.2.7 on 2026-01-25 16:33 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0012_voter_prior_state_alter_voter_state'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='tenant', + name='description', + ), + migrations.RemoveField( + model_name='tenant', + name='slug', + ), + migrations.AlterField( + model_name='tenant', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='tenantuserrole', + name='role', + field=models.CharField(choices=[('admin', 'Admin'), ('campaign_manager', 'Campaign Manager'), ('campaign_staff', 'Campaign Staff')], max_length=20), + ), + migrations.CreateModel( + name='Interest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interests', to='core.tenant')), + ], + options={ + 'unique_together': {('tenant', 'name')}, + }, + ), + migrations.CreateModel( + name='Volunteer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('email', models.EmailField(max_length=254)), + ('phone', models.CharField(blank=True, max_length=20)), + ('interests', models.ManyToManyField(blank=True, related_name='volunteers', to='core.interest')), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteers', to='core.tenant')), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='volunteer_profile', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='interaction', + name='volunteer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interactions', to='core.volunteer'), + ), + migrations.CreateModel( + name='VolunteerEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(max_length=100)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteers', to='core.event')), + ('volunteer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_assignments', to='core.volunteer')), + ], + ), + ] diff --git a/core/migrations/0014_volunteer_assigned_events_alter_volunteerevent_event.py b/core/migrations/0014_volunteer_assigned_events_alter_volunteerevent_event.py new file mode 100644 index 0000000..34d0459 --- /dev/null +++ b/core/migrations/0014_volunteer_assigned_events_alter_volunteerevent_event.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.7 on 2026-01-25 16:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0013_remove_tenant_description_remove_tenant_slug_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='volunteer', + name='assigned_events', + field=models.ManyToManyField(related_name='assigned_volunteers', through='core.VolunteerEvent', to='core.event'), + ), + migrations.AlterField( + model_name='volunteerevent', + name='event', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteer_assignments', to='core.event'), + ), + ] diff --git a/core/migrations/0015_remove_eventparticipation_participation_type_and_more.py b/core/migrations/0015_remove_eventparticipation_participation_type_and_more.py new file mode 100644 index 0000000..458f78f --- /dev/null +++ b/core/migrations/0015_remove_eventparticipation_participation_type_and_more.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-25 18:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0014_volunteer_assigned_events_alter_volunteerevent_event'), + ] + + operations = [ + migrations.RenameField( + model_name='eventparticipation', + old_name='participation_type', + new_name='participation_status', + ), + ] \ No newline at end of file diff --git a/core/migrations/0016_alter_eventparticipation_participation_status_and_more.py b/core/migrations/0016_alter_eventparticipation_participation_status_and_more.py new file mode 100644 index 0000000..95d46a5 --- /dev/null +++ b/core/migrations/0016_alter_eventparticipation_participation_status_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.7 on 2026-01-25 18:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0015_remove_eventparticipation_participation_type_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='eventparticipation', + name='participation_status', + field=models.CharField(blank=True, max_length=50), + ), + migrations.CreateModel( + name='ParticipationStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('is_active', models.BooleanField(default=True)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participation_statuses', to='core.tenant')), + ], + options={ + 'verbose_name_plural': 'Participation Statuses', + 'unique_together': {('tenant', 'name')}, + }, + ), + migrations.AddField( + model_name='eventparticipation', + name='participation_status_link', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='core.participationstatus'), + ), + ] diff --git a/core/migrations/0017_remove_eventparticipation_participation_status_link_and_more.py b/core/migrations/0017_remove_eventparticipation_participation_status_link_and_more.py new file mode 100644 index 0000000..7ae907b --- /dev/null +++ b/core/migrations/0017_remove_eventparticipation_participation_status_link_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-01-25 18:52 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_alter_eventparticipation_participation_status_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='eventparticipation', + name='participation_status', + ), + migrations.RenameField( + model_name='eventparticipation', + old_name='participation_status_link', + new_name='participation_status', + ), + ] \ No newline at end of file diff --git a/core/migrations/0018_event_end_time_event_name_event_start_time.py b/core/migrations/0018_event_end_time_event_name_event_start_time.py new file mode 100644 index 0000000..dfaa7c4 --- /dev/null +++ b/core/migrations/0018_event_end_time_event_name_event_start_time.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.7 on 2026-01-25 19:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0017_remove_eventparticipation_participation_status_link_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='end_time', + field=models.TimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='event', + name='name', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='event', + name='start_time', + field=models.TimeField(blank=True, null=True), + ), + ] diff --git a/core/migrations/0019_volunteer_first_name_volunteer_last_name.py b/core/migrations/0019_volunteer_first_name_volunteer_last_name.py new file mode 100644 index 0000000..313fb1e --- /dev/null +++ b/core/migrations/0019_volunteer_first_name_volunteer_last_name.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-01-26 05:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0018_event_end_time_event_name_event_start_time'), + ] + + operations = [ + migrations.AddField( + model_name='volunteer', + name='first_name', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='volunteer', + name='last_name', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/core/migrations/0020_remove_volunteer_name.py b/core/migrations/0020_remove_volunteer_name.py new file mode 100644 index 0000000..b38b88b --- /dev/null +++ b/core/migrations/0020_remove_volunteer_name.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.7 on 2026-01-26 13:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0019_volunteer_first_name_volunteer_last_name'), + ] + + operations = [ + migrations.RemoveField( + model_name='volunteer', + name='name', + ), + ] diff --git a/core/migrations/0021_voter_phone_type.py b/core/migrations/0021_voter_phone_type.py new file mode 100644 index 0000000..81f3f7a --- /dev/null +++ b/core/migrations/0021_voter_phone_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-26 16:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0020_remove_volunteer_name'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='phone_type', + field=models.CharField(choices=[('home', 'Home Phone'), ('cell', 'Cell Phone'), ('work', 'Work Phone')], default='cell', max_length=10), + ), + ] diff --git a/core/migrations/0022_voter_notes.py b/core/migrations/0022_voter_notes.py new file mode 100644 index 0000000..3f3f725 --- /dev/null +++ b/core/migrations/0022_voter_notes.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-26 17:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0021_voter_phone_type'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='notes', + field=models.TextField(blank=True), + ), + ] diff --git a/core/migrations/0023_alter_voter_address_street_alter_voter_birthdate_and_more.py b/core/migrations/0023_alter_voter_address_street_alter_voter_birthdate_and_more.py new file mode 100644 index 0000000..161345e --- /dev/null +++ b/core/migrations/0023_alter_voter_address_street_alter_voter_birthdate_and_more.py @@ -0,0 +1,83 @@ +# Generated by Django 5.2.7 on 2026-01-28 04:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0022_voter_notes'), + ] + + operations = [ + migrations.AlterField( + model_name='voter', + name='address_street', + field=models.CharField(blank=True, db_index=True, max_length=255), + ), + migrations.AlterField( + model_name='voter', + name='birthdate', + field=models.DateField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name='voter', + name='candidate_support', + field=models.CharField(choices=[('unknown', 'Unknown'), ('supporting', 'Supporting'), ('not_supporting', 'Not Supporting')], db_index=True, default='unknown', max_length=20), + ), + migrations.AlterField( + model_name='voter', + name='city', + field=models.CharField(blank=True, db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='voter', + name='district', + field=models.CharField(blank=True, db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='voter', + name='first_name', + field=models.CharField(db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='voter', + name='is_targeted', + field=models.BooleanField(db_index=True, default=False), + ), + migrations.AlterField( + model_name='voter', + name='last_name', + field=models.CharField(db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='voter', + name='precinct', + field=models.CharField(blank=True, db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='voter', + name='state', + field=models.CharField(blank=True, db_index=True, max_length=2), + ), + migrations.AlterField( + model_name='voter', + name='voter_id', + field=models.CharField(blank=True, db_index=True, max_length=50), + ), + migrations.AlterField( + model_name='voter', + name='window_sticker', + field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker')], db_index=True, default='none', max_length=20, verbose_name='Window Sticker Status'), + ), + migrations.AlterField( + model_name='voter', + name='yard_sign', + field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants a yard sign'), ('has', 'Has a yard sign')], db_index=True, default='none', max_length=20), + ), + migrations.AlterField( + model_name='voter', + name='zip_code', + field=models.CharField(blank=True, db_index=True, max_length=20), + ), + ] diff --git a/core/migrations/0024_alter_event_name_alter_event_unique_together.py b/core/migrations/0024_alter_event_name_alter_event_unique_together.py new file mode 100644 index 0000000..44ceb73 --- /dev/null +++ b/core/migrations/0024_alter_event_name_alter_event_unique_together.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.7 on 2026-01-28 21:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0023_alter_voter_address_street_alter_voter_birthdate_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='name', + field=models.CharField(db_index=True, max_length=255), + ), + migrations.AlterUniqueTogether( + name='event', + unique_together={('tenant', 'name')}, + ), + ] diff --git a/core/migrations/0025_campaignsettings_twilio_account_sid_and_more.py b/core/migrations/0025_campaignsettings_twilio_account_sid_and_more.py new file mode 100644 index 0000000..e86d807 --- /dev/null +++ b/core/migrations/0025_campaignsettings_twilio_account_sid_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.7 on 2026-01-29 01:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0024_alter_event_name_alter_event_unique_together'), + ] + + operations = [ + migrations.AddField( + model_name='campaignsettings', + name='twilio_account_sid', + field=models.CharField(blank=True, default='ACcd11acb5095cec6477245d385a2bf127', max_length=100), + ), + migrations.AddField( + model_name='campaignsettings', + name='twilio_auth_token', + field=models.CharField(blank=True, default='89ec830d0fa02ab0afa6c76084865713', max_length=100), + ), + migrations.AddField( + model_name='campaignsettings', + name='twilio_from_number', + field=models.CharField(blank=True, default='+18556945903', max_length=20), + ), + ] diff --git a/core/migrations/0026_alter_interaction_date.py b/core/migrations/0026_alter_interaction_date.py new file mode 100644 index 0000000..0edada6 --- /dev/null +++ b/core/migrations/0026_alter_interaction_date.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-29 03:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0025_campaignsettings_twilio_account_sid_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='interaction', + name='date', + field=models.DateTimeField(), + ), + ] diff --git a/core/migrations/0027_voter_secondary_phone_voter_secondary_phone_type.py b/core/migrations/0027_voter_secondary_phone_voter_secondary_phone_type.py new file mode 100644 index 0000000..5ac9d53 --- /dev/null +++ b/core/migrations/0027_voter_secondary_phone_voter_secondary_phone_type.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-01-29 18:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0026_alter_interaction_date'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='secondary_phone', + field=models.CharField(blank=True, max_length=20), + ), + migrations.AddField( + model_name='voter', + name='secondary_phone_type', + field=models.CharField(choices=[('home', 'Home Phone'), ('cell', 'Cell Phone'), ('work', 'Work Phone')], default='cell', max_length=10), + ), + ] diff --git a/core/migrations/0028_volunteer_notes.py b/core/migrations/0028_volunteer_notes.py new file mode 100644 index 0000000..c02db8a --- /dev/null +++ b/core/migrations/0028_volunteer_notes.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-29 21:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0027_voter_secondary_phone_voter_secondary_phone_type'), + ] + + operations = [ + migrations.AddField( + model_name='volunteer', + name='notes', + field=models.TextField(blank=True), + ), + ] diff --git a/core/migrations/0029_event_address_event_city_event_latitude_and_more.py b/core/migrations/0029_event_address_event_city_event_latitude_and_more.py new file mode 100644 index 0000000..3c0d514 --- /dev/null +++ b/core/migrations/0029_event_address_event_city_event_latitude_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.7 on 2026-01-29 22:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0028_volunteer_notes'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='address', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='event', + name='city', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='event', + name='latitude', + field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True), + ), + migrations.AddField( + model_name='event', + name='longitude', + field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True), + ), + migrations.AddField( + model_name='event', + name='state', + field=models.CharField(blank=True, max_length=2), + ), + migrations.AddField( + model_name='event', + name='zip_code', + field=models.CharField(blank=True, max_length=20), + ), + ] diff --git a/core/migrations/0030_event_location_name.py b/core/migrations/0030_event_location_name.py new file mode 100644 index 0000000..b4c5122 --- /dev/null +++ b/core/migrations/0030_event_location_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-29 22:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0029_event_address_event_city_event_latitude_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='location_name', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/core/migrations/0031_volunteerrole_event_default_volunteer_role_and_more.py b/core/migrations/0031_volunteerrole_event_default_volunteer_role_and_more.py new file mode 100644 index 0000000..52882c5 --- /dev/null +++ b/core/migrations/0031_volunteerrole_event_default_volunteer_role_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.7 on 2026-01-31 13:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0030_event_location_name'), + ] + + operations = [ + migrations.CreateModel( + name='VolunteerRole', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('is_active', models.BooleanField(default=True)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteer_roles', to='core.tenant')), + ], + options={ + 'unique_together': {('tenant', 'name')}, + }, + ), + migrations.AddField( + model_name='event', + name='default_volunteer_role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_events', to='core.volunteerrole'), + ), + migrations.AddField( + model_name='eventtype', + name='available_roles', + field=models.ManyToManyField(blank=True, related_name='event_types', to='core.volunteerrole'), + ), + migrations.AddField( + model_name='volunteerevent', + name='role_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='volunteer_assignments', to='core.volunteerrole'), + ), + ] diff --git a/core/migrations/0032_alter_volunteerevent_role.py b/core/migrations/0032_alter_volunteerevent_role.py new file mode 100644 index 0000000..50957eb --- /dev/null +++ b/core/migrations/0032_alter_volunteerevent_role.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-02-01 00:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0031_volunteerrole_event_default_volunteer_role_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='volunteerevent', + name='role', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/core/migrations/0033_remove_volunteerevent_role.py b/core/migrations/0033_remove_volunteerevent_role.py new file mode 100644 index 0000000..0882b66 --- /dev/null +++ b/core/migrations/0033_remove_volunteerevent_role.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.7 on 2026-02-01 00:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0032_alter_volunteerevent_role'), + ] + + operations = [ + migrations.RemoveField( + model_name='volunteerevent', + name='role', + ), + ] diff --git a/core/migrations/0034_eventtype_default_volunteer_role.py b/core/migrations/0034_eventtype_default_volunteer_role.py new file mode 100644 index 0000000..99bf4e0 --- /dev/null +++ b/core/migrations/0034_eventtype_default_volunteer_role.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.7 on 2026-02-01 01:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0033_remove_volunteerevent_role'), + ] + + operations = [ + migrations.AddField( + model_name='eventtype', + name='default_volunteer_role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_event_types', to='core.volunteerrole'), + ), + ] diff --git a/core/migrations/0035_interaction_door_visit_interaction_neighborhood_and_more.py b/core/migrations/0035_interaction_door_visit_interaction_neighborhood_and_more.py new file mode 100644 index 0000000..653bbe1 --- /dev/null +++ b/core/migrations/0035_interaction_door_visit_interaction_neighborhood_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.7 on 2026-02-01 01:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0034_eventtype_default_volunteer_role'), + ] + + operations = [ + migrations.AddField( + model_name='interaction', + name='door_visit', + field=models.BooleanField(db_index=True, default=False), + ), + migrations.AddField( + model_name='interaction', + name='neighborhood', + field=models.CharField(blank=True, db_index=True, max_length=100), + ), + migrations.AddField( + model_name='volunteer', + name='door_visit', + field=models.BooleanField(db_index=True, default=False), + ), + migrations.AddField( + model_name='volunteer', + name='neighborhood', + field=models.CharField(blank=True, db_index=True, max_length=100), + ), + migrations.AddField( + model_name='voter', + name='door_visit', + field=models.BooleanField(db_index=True, default=False), + ), + migrations.AddField( + model_name='voter', + name='neighborhood', + field=models.CharField(blank=True, db_index=True, max_length=100), + ), + ] diff --git a/core/migrations/0036_remove_interaction_door_visit_and_more.py b/core/migrations/0036_remove_interaction_door_visit_and_more.py new file mode 100644 index 0000000..b04e32c --- /dev/null +++ b/core/migrations/0036_remove_interaction_door_visit_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.7 on 2026-02-01 01:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0035_interaction_door_visit_interaction_neighborhood_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='interaction', + name='door_visit', + ), + migrations.RemoveField( + model_name='interaction', + name='neighborhood', + ), + migrations.RemoveField( + model_name='volunteer', + name='door_visit', + ), + migrations.RemoveField( + model_name='volunteer', + name='neighborhood', + ), + ] diff --git a/core/migrations/0037_campaignsettings_timezone.py b/core/migrations/0037_campaignsettings_timezone.py new file mode 100644 index 0000000..2eb667b --- /dev/null +++ b/core/migrations/0037_campaignsettings_timezone.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-02-01 03:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0036_remove_interaction_door_visit_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='campaignsettings', + name='timezone', + field=models.CharField(default='America/Chicago', max_length=50), + ), + ] diff --git a/core/migrations/0038_alter_campaignsettings_timezone.py b/core/migrations/0038_alter_campaignsettings_timezone.py new file mode 100644 index 0000000..e985057 --- /dev/null +++ b/core/migrations/0038_alter_campaignsettings_timezone.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-02-01 03:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0037_campaignsettings_timezone'), + ] + + operations = [ + migrations.AlterField( + model_name='campaignsettings', + name='timezone', + field=models.CharField(choices=[('Africa/Abidjan', 'Africa/Abidjan'), ('Africa/Accra', 'Africa/Accra'), ('Africa/Addis_Ababa', 'Africa/Addis_Ababa'), ('Africa/Algiers', 'Africa/Algiers'), ('Africa/Asmara', 'Africa/Asmara'), ('Africa/Asmera', 'Africa/Asmera'), ('Africa/Bamako', 'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'), ('Africa/Banjul', 'Africa/Banjul'), ('Africa/Bissau', 'Africa/Bissau'), ('Africa/Blantyre', 'Africa/Blantyre'), ('Africa/Brazzaville', 'Africa/Brazzaville'), ('Africa/Bujumbura', 'Africa/Bujumbura'), ('Africa/Cairo', 'Africa/Cairo'), ('Africa/Casablanca', 'Africa/Casablanca'), ('Africa/Ceuta', 'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'), ('Africa/Dakar', 'Africa/Dakar'), ('Africa/Dar_es_Salaam', 'Africa/Dar_es_Salaam'), ('Africa/Djibouti', 'Africa/Djibouti'), ('Africa/Douala', 'Africa/Douala'), ('Africa/El_Aaiun', 'Africa/El_Aaiun'), ('Africa/Freetown', 'Africa/Freetown'), ('Africa/Gaborone', 'Africa/Gaborone'), ('Africa/Harare', 'Africa/Harare'), ('Africa/Johannesburg', 'Africa/Johannesburg'), ('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'), ('Africa/Khartoum', 'Africa/Khartoum'), ('Africa/Kigali', 'Africa/Kigali'), ('Africa/Kinshasa', 'Africa/Kinshasa'), ('Africa/Lagos', 'Africa/Lagos'), ('Africa/Libreville', 'Africa/Libreville'), ('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'), ('Africa/Lubumbashi', 'Africa/Lubumbashi'), ('Africa/Lusaka', 'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'), ('Africa/Maputo', 'Africa/Maputo'), ('Africa/Maseru', 'Africa/Maseru'), ('Africa/Mbabane', 'Africa/Mbabane'), ('Africa/Mogadishu', 'Africa/Mogadishu'), ('Africa/Monrovia', 'Africa/Monrovia'), ('Africa/Nairobi', 'Africa/Nairobi'), ('Africa/Ndjamena', 'Africa/Ndjamena'), ('Africa/Niamey', 'Africa/Niamey'), ('Africa/Nouakchott', 'Africa/Nouakchott'), ('Africa/Ouagadougou', 'Africa/Ouagadougou'), ('Africa/Porto-Novo', 'Africa/Porto-Novo'), ('Africa/Sao_Tome', 'Africa/Sao_Tome'), ('Africa/Timbuktu', 'Africa/Timbuktu'), ('Africa/Tripoli', 'Africa/Tripoli'), ('Africa/Tunis', 'Africa/Tunis'), ('Africa/Windhoek', 'Africa/Windhoek'), ('America/Adak', 'America/Adak'), ('America/Anchorage', 'America/Anchorage'), ('America/Anguilla', 'America/Anguilla'), ('America/Antigua', 'America/Antigua'), ('America/Araguaina', 'America/Araguaina'), ('America/Argentina/Buenos_Aires', 'America/Argentina/Buenos_Aires'), ('America/Argentina/Catamarca', 'America/Argentina/Catamarca'), ('America/Argentina/ComodRivadavia', 'America/Argentina/ComodRivadavia'), ('America/Argentina/Cordoba', 'America/Argentina/Cordoba'), ('America/Argentina/Jujuy', 'America/Argentina/Jujuy'), ('America/Argentina/La_Rioja', 'America/Argentina/La_Rioja'), ('America/Argentina/Mendoza', 'America/Argentina/Mendoza'), ('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio_Gallegos'), ('America/Argentina/Salta', 'America/Argentina/Salta'), ('America/Argentina/San_Juan', 'America/Argentina/San_Juan'), ('America/Argentina/San_Luis', 'America/Argentina/San_Luis'), ('America/Argentina/Tucuman', 'America/Argentina/Tucuman'), ('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'), ('America/Aruba', 'America/Aruba'), ('America/Asuncion', 'America/Asuncion'), ('America/Atikokan', 'America/Atikokan'), ('America/Atka', 'America/Atka'), ('America/Bahia', 'America/Bahia'), ('America/Bahia_Banderas', 'America/Bahia_Banderas'), ('America/Barbados', 'America/Barbados'), ('America/Belem', 'America/Belem'), ('America/Belize', 'America/Belize'), ('America/Blanc-Sablon', 'America/Blanc-Sablon'), ('America/Boa_Vista', 'America/Boa_Vista'), ('America/Bogota', 'America/Bogota'), ('America/Boise', 'America/Boise'), ('America/Buenos_Aires', 'America/Buenos_Aires'), ('America/Cambridge_Bay', 'America/Cambridge_Bay'), ('America/Campo_Grande', 'America/Campo_Grande'), ('America/Cancun', 'America/Cancun'), ('America/Caracas', 'America/Caracas'), ('America/Catamarca', 'America/Catamarca'), ('America/Cayenne', 'America/Cayenne'), ('America/Cayman', 'America/Cayman'), ('America/Chicago', 'America/Chicago'), ('America/Chihuahua', 'America/Chihuahua'), ('America/Ciudad_Juarez', 'America/Ciudad_Juarez'), ('America/Coral_Harbour', 'America/Coral_Harbour'), ('America/Cordoba', 'America/Cordoba'), ('America/Costa_Rica', 'America/Costa_Rica'), ('America/Coyhaique', 'America/Coyhaique'), ('America/Creston', 'America/Creston'), ('America/Cuiaba', 'America/Cuiaba'), ('America/Curacao', 'America/Curacao'), ('America/Danmarkshavn', 'America/Danmarkshavn'), ('America/Dawson', 'America/Dawson'), ('America/Dawson_Creek', 'America/Dawson_Creek'), ('America/Denver', 'America/Denver'), ('America/Detroit', 'America/Detroit'), ('America/Dominica', 'America/Dominica'), ('America/Edmonton', 'America/Edmonton'), ('America/Eirunepe', 'America/Eirunepe'), ('America/El_Salvador', 'America/El_Salvador'), ('America/Ensenada', 'America/Ensenada'), ('America/Fort_Nelson', 'America/Fort_Nelson'), ('America/Fort_Wayne', 'America/Fort_Wayne'), ('America/Fortaleza', 'America/Fortaleza'), ('America/Glace_Bay', 'America/Glace_Bay'), ('America/Godthab', 'America/Godthab'), ('America/Goose_Bay', 'America/Goose_Bay'), ('America/Grand_Turk', 'America/Grand_Turk'), ('America/Grenada', 'America/Grenada'), ('America/Guadeloupe', 'America/Guadeloupe'), ('America/Guatemala', 'America/Guatemala'), ('America/Guayaquil', 'America/Guayaquil'), ('America/Guyana', 'America/Guyana'), ('America/Halifax', 'America/Halifax'), ('America/Havana', 'America/Havana'), ('America/Hermosillo', 'America/Hermosillo'), ('America/Indiana/Indianapolis', 'America/Indiana/Indianapolis'), ('America/Indiana/Knox', 'America/Indiana/Knox'), ('America/Indiana/Marengo', 'America/Indiana/Marengo'), ('America/Indiana/Petersburg', 'America/Indiana/Petersburg'), ('America/Indiana/Tell_City', 'America/Indiana/Tell_City'), ('America/Indiana/Vevay', 'America/Indiana/Vevay'), ('America/Indiana/Vincennes', 'America/Indiana/Vincennes'), ('America/Indiana/Winamac', 'America/Indiana/Winamac'), ('America/Indianapolis', 'America/Indianapolis'), ('America/Inuvik', 'America/Inuvik'), ('America/Iqaluit', 'America/Iqaluit'), ('America/Jamaica', 'America/Jamaica'), ('America/Jujuy', 'America/Jujuy'), ('America/Juneau', 'America/Juneau'), ('America/Kentucky/Louisville', 'America/Kentucky/Louisville'), ('America/Kentucky/Monticello', 'America/Kentucky/Monticello'), ('America/Knox_IN', 'America/Knox_IN'), ('America/Kralendijk', 'America/Kralendijk'), ('America/La_Paz', 'America/La_Paz'), ('America/Lima', 'America/Lima'), ('America/Los_Angeles', 'America/Los_Angeles'), ('America/Louisville', 'America/Louisville'), ('America/Lower_Princes', 'America/Lower_Princes'), ('America/Maceio', 'America/Maceio'), ('America/Managua', 'America/Managua'), ('America/Manaus', 'America/Manaus'), ('America/Marigot', 'America/Marigot'), ('America/Martinique', 'America/Martinique'), ('America/Matamoros', 'America/Matamoros'), ('America/Mazatlan', 'America/Mazatlan'), ('America/Mendoza', 'America/Mendoza'), ('America/Menominee', 'America/Menominee'), ('America/Merida', 'America/Merida'), ('America/Metlakatla', 'America/Metlakatla'), ('America/Mexico_City', 'America/Mexico_City'), ('America/Miquelon', 'America/Miquelon'), ('America/Moncton', 'America/Moncton'), ('America/Monterrey', 'America/Monterrey'), ('America/Montevideo', 'America/Montevideo'), ('America/Montreal', 'America/Montreal'), ('America/Montserrat', 'America/Montserrat'), ('America/Nassau', 'America/Nassau'), ('America/New_York', 'America/New_York'), ('America/Nipigon', 'America/Nipigon'), ('America/Nome', 'America/Nome'), ('America/Noronha', 'America/Noronha'), ('America/North_Dakota/Beulah', 'America/North_Dakota/Beulah'), ('America/North_Dakota/Center', 'America/North_Dakota/Center'), ('America/North_Dakota/New_Salem', 'America/North_Dakota/New_Salem'), ('America/Nuuk', 'America/Nuuk'), ('America/Ojinaga', 'America/Ojinaga'), ('America/Panama', 'America/Panama'), ('America/Pangnirtung', 'America/Pangnirtung'), ('America/Paramaribo', 'America/Paramaribo'), ('America/Phoenix', 'America/Phoenix'), ('America/Port-au-Prince', 'America/Port-au-Prince'), ('America/Port_of_Spain', 'America/Port_of_Spain'), ('America/Porto_Acre', 'America/Porto_Acre'), ('America/Porto_Velho', 'America/Porto_Velho'), ('America/Puerto_Rico', 'America/Puerto_Rico'), ('America/Punta_Arenas', 'America/Punta_Arenas'), ('America/Rainy_River', 'America/Rainy_River'), ('America/Rankin_Inlet', 'America/Rankin_Inlet'), ('America/Recife', 'America/Recife'), ('America/Regina', 'America/Regina'), ('America/Resolute', 'America/Resolute'), ('America/Rio_Branco', 'America/Rio_Branco'), ('America/Rosario', 'America/Rosario'), ('America/Santa_Isabel', 'America/Santa_Isabel'), ('America/Santarem', 'America/Santarem'), ('America/Santiago', 'America/Santiago'), ('America/Santo_Domingo', 'America/Santo_Domingo'), ('America/Sao_Paulo', 'America/Sao_Paulo'), ('America/Scoresbysund', 'America/Scoresbysund'), ('America/Shiprock', 'America/Shiprock'), ('America/Sitka', 'America/Sitka'), ('America/St_Barthelemy', 'America/St_Barthelemy'), ('America/St_Johns', 'America/St_Johns'), ('America/St_Kitts', 'America/St_Kitts'), ('America/St_Lucia', 'America/St_Lucia'), ('America/St_Thomas', 'America/St_Thomas'), ('America/St_Vincent', 'America/St_Vincent'), ('America/Swift_Current', 'America/Swift_Current'), ('America/Tegucigalpa', 'America/Tegucigalpa'), ('America/Thule', 'America/Thule'), ('America/Thunder_Bay', 'America/Thunder_Bay'), ('America/Tijuana', 'America/Tijuana'), ('America/Toronto', 'America/Toronto'), ('America/Tortola', 'America/Tortola'), ('America/Vancouver', 'America/Vancouver'), ('America/Virgin', 'America/Virgin'), ('America/Whitehorse', 'America/Whitehorse'), ('America/Winnipeg', 'America/Winnipeg'), ('America/Yakutat', 'America/Yakutat'), ('America/Yellowknife', 'America/Yellowknife'), ('Antarctica/Casey', 'Antarctica/Casey'), ('Antarctica/Davis', 'Antarctica/Davis'), ('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'), ('Antarctica/Macquarie', 'Antarctica/Macquarie'), ('Antarctica/Mawson', 'Antarctica/Mawson'), ('Antarctica/McMurdo', 'Antarctica/McMurdo'), ('Antarctica/Palmer', 'Antarctica/Palmer'), ('Antarctica/Rothera', 'Antarctica/Rothera'), ('Antarctica/South_Pole', 'Antarctica/South_Pole'), ('Antarctica/Syowa', 'Antarctica/Syowa'), ('Antarctica/Troll', 'Antarctica/Troll'), ('Antarctica/Vostok', 'Antarctica/Vostok'), ('Arctic/Longyearbyen', 'Arctic/Longyearbyen'), ('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'), ('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'), ('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'), ('Asia/Ashgabat', 'Asia/Ashgabat'), ('Asia/Ashkhabad', 'Asia/Ashkhabad'), ('Asia/Atyrau', 'Asia/Atyrau'), ('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'), ('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'), ('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'), ('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'), ('Asia/Calcutta', 'Asia/Calcutta'), ('Asia/Chita', 'Asia/Chita'), ('Asia/Choibalsan', 'Asia/Choibalsan'), ('Asia/Chongqing', 'Asia/Chongqing'), ('Asia/Chungking', 'Asia/Chungking'), ('Asia/Colombo', 'Asia/Colombo'), ('Asia/Dacca', 'Asia/Dacca'), ('Asia/Damascus', 'Asia/Damascus'), ('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'), ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'), ('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'), ('Asia/Harbin', 'Asia/Harbin'), ('Asia/Hebron', 'Asia/Hebron'), ('Asia/Ho_Chi_Minh', 'Asia/Ho_Chi_Minh'), ('Asia/Hong_Kong', 'Asia/Hong_Kong'), ('Asia/Hovd', 'Asia/Hovd'), ('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Istanbul', 'Asia/Istanbul'), ('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'), ('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'), ('Asia/Kamchatka', 'Asia/Kamchatka'), ('Asia/Karachi', 'Asia/Karachi'), ('Asia/Kashgar', 'Asia/Kashgar'), ('Asia/Kathmandu', 'Asia/Kathmandu'), ('Asia/Katmandu', 'Asia/Katmandu'), ('Asia/Khandyga', 'Asia/Khandyga'), ('Asia/Kolkata', 'Asia/Kolkata'), ('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'), ('Asia/Kuala_Lumpur', 'Asia/Kuala_Lumpur'), ('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'), ('Asia/Macao', 'Asia/Macao'), ('Asia/Macau', 'Asia/Macau'), ('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'), ('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'), ('Asia/Nicosia', 'Asia/Nicosia'), ('Asia/Novokuznetsk', 'Asia/Novokuznetsk'), ('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'), ('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom_Penh'), ('Asia/Pontianak', 'Asia/Pontianak'), ('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'), ('Asia/Qostanay', 'Asia/Qostanay'), ('Asia/Qyzylorda', 'Asia/Qyzylorda'), ('Asia/Rangoon', 'Asia/Rangoon'), ('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Saigon', 'Asia/Saigon'), ('Asia/Sakhalin', 'Asia/Sakhalin'), ('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'), ('Asia/Shanghai', 'Asia/Shanghai'), ('Asia/Singapore', 'Asia/Singapore'), ('Asia/Srednekolymsk', 'Asia/Srednekolymsk'), ('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'), ('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'), ('Asia/Tel_Aviv', 'Asia/Tel_Aviv'), ('Asia/Thimbu', 'Asia/Thimbu'), ('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'), ('Asia/Tomsk', 'Asia/Tomsk'), ('Asia/Ujung_Pandang', 'Asia/Ujung_Pandang'), ('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'), ('Asia/Ulan_Bator', 'Asia/Ulan_Bator'), ('Asia/Urumqi', 'Asia/Urumqi'), ('Asia/Ust-Nera', 'Asia/Ust-Nera'), ('Asia/Vientiane', 'Asia/Vientiane'), ('Asia/Vladivostok', 'Asia/Vladivostok'), ('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yangon', 'Asia/Yangon'), ('Asia/Yekaterinburg', 'Asia/Yekaterinburg'), ('Asia/Yerevan', 'Asia/Yerevan'), ('Atlantic/Azores', 'Atlantic/Azores'), ('Atlantic/Bermuda', 'Atlantic/Bermuda'), ('Atlantic/Canary', 'Atlantic/Canary'), ('Atlantic/Cape_Verde', 'Atlantic/Cape_Verde'), ('Atlantic/Faeroe', 'Atlantic/Faeroe'), ('Atlantic/Faroe', 'Atlantic/Faroe'), ('Atlantic/Jan_Mayen', 'Atlantic/Jan_Mayen'), ('Atlantic/Madeira', 'Atlantic/Madeira'), ('Atlantic/Reykjavik', 'Atlantic/Reykjavik'), ('Atlantic/South_Georgia', 'Atlantic/South_Georgia'), ('Atlantic/St_Helena', 'Atlantic/St_Helena'), ('Atlantic/Stanley', 'Atlantic/Stanley'), ('Australia/ACT', 'Australia/ACT'), ('Australia/Adelaide', 'Australia/Adelaide'), ('Australia/Brisbane', 'Australia/Brisbane'), ('Australia/Broken_Hill', 'Australia/Broken_Hill'), ('Australia/Canberra', 'Australia/Canberra'), ('Australia/Currie', 'Australia/Currie'), ('Australia/Darwin', 'Australia/Darwin'), ('Australia/Eucla', 'Australia/Eucla'), ('Australia/Hobart', 'Australia/Hobart'), ('Australia/LHI', 'Australia/LHI'), ('Australia/Lindeman', 'Australia/Lindeman'), ('Australia/Lord_Howe', 'Australia/Lord_Howe'), ('Australia/Melbourne', 'Australia/Melbourne'), ('Australia/NSW', 'Australia/NSW'), ('Australia/North', 'Australia/North'), ('Australia/Perth', 'Australia/Perth'), ('Australia/Queensland', 'Australia/Queensland'), ('Australia/South', 'Australia/South'), ('Australia/Sydney', 'Australia/Sydney'), ('Australia/Tasmania', 'Australia/Tasmania'), ('Australia/Victoria', 'Australia/Victoria'), ('Australia/West', 'Australia/West'), ('Australia/Yancowinna', 'Australia/Yancowinna'), ('Brazil/Acre', 'Brazil/Acre'), ('Brazil/DeNoronha', 'Brazil/DeNoronha'), ('Brazil/East', 'Brazil/East'), ('Brazil/West', 'Brazil/West'), ('CET', 'CET'), ('CST6CDT', 'CST6CDT'), ('Canada/Atlantic', 'Canada/Atlantic'), ('Canada/Central', 'Canada/Central'), ('Canada/Eastern', 'Canada/Eastern'), ('Canada/Mountain', 'Canada/Mountain'), ('Canada/Newfoundland', 'Canada/Newfoundland'), ('Canada/Pacific', 'Canada/Pacific'), ('Canada/Saskatchewan', 'Canada/Saskatchewan'), ('Canada/Yukon', 'Canada/Yukon'), ('Chile/Continental', 'Chile/Continental'), ('Chile/EasterIsland', 'Chile/EasterIsland'), ('Cuba', 'Cuba'), ('EET', 'EET'), ('EST', 'EST'), ('EST5EDT', 'EST5EDT'), ('Egypt', 'Egypt'), ('Eire', 'Eire'), ('Etc/GMT', 'Etc/GMT'), ('Etc/GMT+0', 'Etc/GMT+0'), ('Etc/GMT+1', 'Etc/GMT+1'), ('Etc/GMT+10', 'Etc/GMT+10'), ('Etc/GMT+11', 'Etc/GMT+11'), ('Etc/GMT+12', 'Etc/GMT+12'), ('Etc/GMT+2', 'Etc/GMT+2'), ('Etc/GMT+3', 'Etc/GMT+3'), ('Etc/GMT+4', 'Etc/GMT+4'), ('Etc/GMT+5', 'Etc/GMT+5'), ('Etc/GMT+6', 'Etc/GMT+6'), ('Etc/GMT+7', 'Etc/GMT+7'), ('Etc/GMT+8', 'Etc/GMT+8'), ('Etc/GMT+9', 'Etc/GMT+9'), ('Etc/GMT-0', 'Etc/GMT-0'), ('Etc/GMT-1', 'Etc/GMT-1'), ('Etc/GMT-10', 'Etc/GMT-10'), ('Etc/GMT-11', 'Etc/GMT-11'), ('Etc/GMT-12', 'Etc/GMT-12'), ('Etc/GMT-13', 'Etc/GMT-13'), ('Etc/GMT-14', 'Etc/GMT-14'), ('Etc/GMT-2', 'Etc/GMT-2'), ('Etc/GMT-3', 'Etc/GMT-3'), ('Etc/GMT-4', 'Etc/GMT-4'), ('Etc/GMT-5', 'Etc/GMT-5'), ('Etc/GMT-6', 'Etc/GMT-6'), ('Etc/GMT-7', 'Etc/GMT-7'), ('Etc/GMT-8', 'Etc/GMT-8'), ('Etc/GMT-9', 'Etc/GMT-9'), ('Etc/GMT0', 'Etc/GMT0'), ('Etc/Greenwich', 'Etc/Greenwich'), ('Etc/UCT', 'Etc/UCT'), ('Etc/UTC', 'Etc/UTC'), ('Etc/Universal', 'Etc/Universal'), ('Etc/Zulu', 'Etc/Zulu'), ('Europe/Amsterdam', 'Europe/Amsterdam'), ('Europe/Andorra', 'Europe/Andorra'), ('Europe/Astrakhan', 'Europe/Astrakhan'), ('Europe/Athens', 'Europe/Athens'), ('Europe/Belfast', 'Europe/Belfast'), ('Europe/Belgrade', 'Europe/Belgrade'), ('Europe/Berlin', 'Europe/Berlin'), ('Europe/Bratislava', 'Europe/Bratislava'), ('Europe/Brussels', 'Europe/Brussels'), ('Europe/Bucharest', 'Europe/Bucharest'), ('Europe/Budapest', 'Europe/Budapest'), ('Europe/Busingen', 'Europe/Busingen'), ('Europe/Chisinau', 'Europe/Chisinau'), ('Europe/Copenhagen', 'Europe/Copenhagen'), ('Europe/Dublin', 'Europe/Dublin'), ('Europe/Gibraltar', 'Europe/Gibraltar'), ('Europe/Guernsey', 'Europe/Guernsey'), ('Europe/Helsinki', 'Europe/Helsinki'), ('Europe/Isle_of_Man', 'Europe/Isle_of_Man'), ('Europe/Istanbul', 'Europe/Istanbul'), ('Europe/Jersey', 'Europe/Jersey'), ('Europe/Kaliningrad', 'Europe/Kaliningrad'), ('Europe/Kiev', 'Europe/Kiev'), ('Europe/Kirov', 'Europe/Kirov'), ('Europe/Kyiv', 'Europe/Kyiv'), ('Europe/Lisbon', 'Europe/Lisbon'), ('Europe/Ljubljana', 'Europe/Ljubljana'), ('Europe/London', 'Europe/London'), ('Europe/Luxembourg', 'Europe/Luxembourg'), ('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'), ('Europe/Mariehamn', 'Europe/Mariehamn'), ('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'), ('Europe/Moscow', 'Europe/Moscow'), ('Europe/Nicosia', 'Europe/Nicosia'), ('Europe/Oslo', 'Europe/Oslo'), ('Europe/Paris', 'Europe/Paris'), ('Europe/Podgorica', 'Europe/Podgorica'), ('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'), ('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'), ('Europe/San_Marino', 'Europe/San_Marino'), ('Europe/Sarajevo', 'Europe/Sarajevo'), ('Europe/Saratov', 'Europe/Saratov'), ('Europe/Simferopol', 'Europe/Simferopol'), ('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'), ('Europe/Stockholm', 'Europe/Stockholm'), ('Europe/Tallinn', 'Europe/Tallinn'), ('Europe/Tirane', 'Europe/Tirane'), ('Europe/Tiraspol', 'Europe/Tiraspol'), ('Europe/Ulyanovsk', 'Europe/Ulyanovsk'), ('Europe/Uzhgorod', 'Europe/Uzhgorod'), ('Europe/Vaduz', 'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'), ('Europe/Vienna', 'Europe/Vienna'), ('Europe/Vilnius', 'Europe/Vilnius'), ('Europe/Volgograd', 'Europe/Volgograd'), ('Europe/Warsaw', 'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'), ('Europe/Zaporozhye', 'Europe/Zaporozhye'), ('Europe/Zurich', 'Europe/Zurich'), ('Factory', 'Factory'), ('GB', 'GB'), ('GB-Eire', 'GB-Eire'), ('GMT', 'GMT'), ('GMT+0', 'GMT+0'), ('GMT-0', 'GMT-0'), ('GMT0', 'GMT0'), ('Greenwich', 'Greenwich'), ('HST', 'HST'), ('Hongkong', 'Hongkong'), ('Iceland', 'Iceland'), ('Indian/Antananarivo', 'Indian/Antananarivo'), ('Indian/Chagos', 'Indian/Chagos'), ('Indian/Christmas', 'Indian/Christmas'), ('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'), ('Indian/Kerguelen', 'Indian/Kerguelen'), ('Indian/Mahe', 'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'), ('Indian/Mauritius', 'Indian/Mauritius'), ('Indian/Mayotte', 'Indian/Mayotte'), ('Indian/Reunion', 'Indian/Reunion'), ('Iran', 'Iran'), ('Israel', 'Israel'), ('Jamaica', 'Jamaica'), ('Japan', 'Japan'), ('Kwajalein', 'Kwajalein'), ('Libya', 'Libya'), ('MET', 'MET'), ('MST', 'MST'), ('MST7MDT', 'MST7MDT'), ('Mexico/BajaNorte', 'Mexico/BajaNorte'), ('Mexico/BajaSur', 'Mexico/BajaSur'), ('Mexico/General', 'Mexico/General'), ('NZ', 'NZ'), ('NZ-CHAT', 'NZ-CHAT'), ('Navajo', 'Navajo'), ('PRC', 'PRC'), ('PST8PDT', 'PST8PDT'), ('Pacific/Apia', 'Pacific/Apia'), ('Pacific/Auckland', 'Pacific/Auckland'), ('Pacific/Bougainville', 'Pacific/Bougainville'), ('Pacific/Chatham', 'Pacific/Chatham'), ('Pacific/Chuuk', 'Pacific/Chuuk'), ('Pacific/Easter', 'Pacific/Easter'), ('Pacific/Efate', 'Pacific/Efate'), ('Pacific/Enderbury', 'Pacific/Enderbury'), ('Pacific/Fakaofo', 'Pacific/Fakaofo'), ('Pacific/Fiji', 'Pacific/Fiji'), ('Pacific/Funafuti', 'Pacific/Funafuti'), ('Pacific/Galapagos', 'Pacific/Galapagos'), ('Pacific/Gambier', 'Pacific/Gambier'), ('Pacific/Guadalcanal', 'Pacific/Guadalcanal'), ('Pacific/Guam', 'Pacific/Guam'), ('Pacific/Honolulu', 'Pacific/Honolulu'), ('Pacific/Johnston', 'Pacific/Johnston'), ('Pacific/Kanton', 'Pacific/Kanton'), ('Pacific/Kiritimati', 'Pacific/Kiritimati'), ('Pacific/Kosrae', 'Pacific/Kosrae'), ('Pacific/Kwajalein', 'Pacific/Kwajalein'), ('Pacific/Majuro', 'Pacific/Majuro'), ('Pacific/Marquesas', 'Pacific/Marquesas'), ('Pacific/Midway', 'Pacific/Midway'), ('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'), ('Pacific/Norfolk', 'Pacific/Norfolk'), ('Pacific/Noumea', 'Pacific/Noumea'), ('Pacific/Pago_Pago', 'Pacific/Pago_Pago'), ('Pacific/Palau', 'Pacific/Palau'), ('Pacific/Pitcairn', 'Pacific/Pitcairn'), ('Pacific/Pohnpei', 'Pacific/Pohnpei'), ('Pacific/Ponape', 'Pacific/Ponape'), ('Pacific/Port_Moresby', 'Pacific/Port_Moresby'), ('Pacific/Rarotonga', 'Pacific/Rarotonga'), ('Pacific/Saipan', 'Pacific/Saipan'), ('Pacific/Samoa', 'Pacific/Samoa'), ('Pacific/Tahiti', 'Pacific/Tahiti'), ('Pacific/Tarawa', 'Pacific/Tarawa'), ('Pacific/Tongatapu', 'Pacific/Tongatapu'), ('Pacific/Truk', 'Pacific/Truk'), ('Pacific/Wake', 'Pacific/Wake'), ('Pacific/Wallis', 'Pacific/Wallis'), ('Pacific/Yap', 'Pacific/Yap'), ('Poland', 'Poland'), ('Portugal', 'Portugal'), ('ROC', 'ROC'), ('ROK', 'ROK'), ('Singapore', 'Singapore'), ('Turkey', 'Turkey'), ('UCT', 'UCT'), ('US/Alaska', 'US/Alaska'), ('US/Aleutian', 'US/Aleutian'), ('US/Arizona', 'US/Arizona'), ('US/Central', 'US/Central'), ('US/East-Indiana', 'US/East-Indiana'), ('US/Eastern', 'US/Eastern'), ('US/Hawaii', 'US/Hawaii'), ('US/Indiana-Starke', 'US/Indiana-Starke'), ('US/Michigan', 'US/Michigan'), ('US/Mountain', 'US/Mountain'), ('US/Pacific', 'US/Pacific'), ('US/Samoa', 'US/Samoa'), ('UTC', 'UTC'), ('Universal', 'Universal'), ('W-SU', 'W-SU'), ('WET', 'WET'), ('Zulu', 'Zulu'), ('localtime', 'localtime')], default='America/Chicago', max_length=100), + ), + ] diff --git a/core/migrations/0039_alter_tenantuserrole_role.py b/core/migrations/0039_alter_tenantuserrole_role.py new file mode 100644 index 0000000..3310a92 --- /dev/null +++ b/core/migrations/0039_alter_tenantuserrole_role.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-02-01 15:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0038_alter_campaignsettings_timezone'), + ] + + operations = [ + migrations.AlterField( + model_name='tenantuserrole', + name='role', + field=models.CharField(choices=[('system_admin', 'System Administrator'), ('campaign_admin', 'Campaign Administrator'), ('campaign_staff', 'Campaign Staff')], max_length=20), + ), + ] diff --git a/core/migrations/0040_volunteer_is_default_caller_scheduledcall.py b/core/migrations/0040_volunteer_is_default_caller_scheduledcall.py new file mode 100644 index 0000000..4e41184 --- /dev/null +++ b/core/migrations/0040_volunteer_is_default_caller_scheduledcall.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.7 on 2026-02-03 01:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0039_alter_tenantuserrole_role'), + ] + + operations = [ + migrations.AddField( + model_name='volunteer', + name='is_default_caller', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='ScheduledCall', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('comments', models.TextField(blank=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)), + ('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='scheduled_calls', to='core.tenant')), + ('volunteer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_calls', to='core.volunteer')), + ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_calls', to='core.voter')), + ], + ), + ] diff --git a/core/migrations/0041_alter_volunteer_options.py b/core/migrations/0041_alter_volunteer_options.py new file mode 100644 index 0000000..ffe8c4d --- /dev/null +++ b/core/migrations/0041_alter_volunteer_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.7 on 2026-02-03 03:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0040_volunteer_is_default_caller_scheduledcall'), + ] + + operations = [ + migrations.AlterModelOptions( + name='volunteer', + options={'ordering': ('last_name', 'first_name')}, + ), + ] diff --git a/core/migrations/0042_campaignsettings_email_from_address_and_more.py b/core/migrations/0042_campaignsettings_email_from_address_and_more.py new file mode 100644 index 0000000..f01bfd6 --- /dev/null +++ b/core/migrations/0042_campaignsettings_email_from_address_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.7 on 2026-02-11 15:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0041_alter_volunteer_options'), + ] + + operations = [ + migrations.AddField( + model_name='campaignsettings', + name='email_from_address', + field=models.EmailField(blank=True, max_length=254), + ), + migrations.AddField( + model_name='campaignsettings', + name='smtp_host', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='campaignsettings', + name='smtp_password', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='campaignsettings', + name='smtp_port', + field=models.IntegerField(default=587), + ), + migrations.AddField( + model_name='campaignsettings', + name='smtp_use_ssl', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='campaignsettings', + name='smtp_use_tls', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='campaignsettings', + name='smtp_username', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/core/migrations/0043_campaignsettings_email_from_name.py b/core/migrations/0043_campaignsettings_email_from_name.py new file mode 100644 index 0000000..19027c3 --- /dev/null +++ b/core/migrations/0043_campaignsettings_email_from_name.py @@ -0,0 +1,18 @@ +# 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), + ), + ] diff --git a/core/migrations/0044_voter_target_door_visit.py b/core/migrations/0044_voter_target_door_visit.py new file mode 100644 index 0000000..da3cce4 --- /dev/null +++ b/core/migrations/0044_voter_target_door_visit.py @@ -0,0 +1,18 @@ +# 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), + ), + ] diff --git a/core/migrations/0045_voter_is_inactive.py b/core/migrations/0045_voter_is_inactive.py new file mode 100644 index 0000000..e08bcb9 --- /dev/null +++ b/core/migrations/0045_voter_is_inactive.py @@ -0,0 +1,18 @@ +# 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), + ), + ] diff --git a/core/migrations/0046_voter_core_voter_tenant__6a281d_idx_and_more.py b/core/migrations/0046_voter_core_voter_tenant__6a281d_idx_and_more.py new file mode 100644 index 0000000..3d9de45 --- /dev/null +++ b/core/migrations/0046_voter_core_voter_tenant__6a281d_idx_and_more.py @@ -0,0 +1,25 @@ +# 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'), + ), + ] diff --git a/core/migrations/0047_alter_voter_yard_sign.py b/core/migrations/0047_alter_voter_yard_sign.py new file mode 100644 index 0000000..fa4b24d --- /dev/null +++ b/core/migrations/0047_alter_voter_yard_sign.py @@ -0,0 +1,18 @@ +# 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), + ), + ] diff --git a/core/migrations/0048_voter_call_queue_status.py b/core/migrations/0048_voter_call_queue_status.py new file mode 100644 index 0000000..b15b405 --- /dev/null +++ b/core/migrations/0048_voter_call_queue_status.py @@ -0,0 +1,37 @@ +# 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), + ] \ No newline at end of file diff --git a/core/migrations/0049_campaignsettings_call_script.py b/core/migrations/0049_campaignsettings_call_script.py new file mode 100644 index 0000000..322feb5 --- /dev/null +++ b/core/migrations/0049_campaignsettings_call_script.py @@ -0,0 +1,18 @@ +# 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), + ), + ] diff --git a/core/migrations/0050_voter_voted.py b/core/migrations/0050_voter_voted.py new file mode 100644 index 0000000..0aae15f --- /dev/null +++ b/core/migrations/0050_voter_voted.py @@ -0,0 +1,18 @@ +# 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), + ), + ] diff --git a/core/migrations/0051_bulktask.py b/core/migrations/0051_bulktask.py new file mode 100644 index 0000000..4691f4f --- /dev/null +++ b/core/migrations/0051_bulktask.py @@ -0,0 +1,29 @@ +# 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')), + ], + ), + ] diff --git a/core/migrations/0052_bulktask_message_body_bulktask_subject.py b/core/migrations/0052_bulktask_message_body_bulktask_subject.py new file mode 100644 index 0000000..8399f69 --- /dev/null +++ b/core/migrations/0052_bulktask_message_body_bulktask_subject.py @@ -0,0 +1,23 @@ +# 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), + ), + ] diff --git a/core/migrations/0053_voter_ever_had_large_sign_voter_ever_had_yard_sign.py b/core/migrations/0053_voter_ever_had_large_sign_voter_ever_had_yard_sign.py new file mode 100644 index 0000000..0a16db7 --- /dev/null +++ b/core/migrations/0053_voter_ever_had_large_sign_voter_ever_had_yard_sign.py @@ -0,0 +1,23 @@ +# 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), + ), + ] diff --git a/core/migrations/__init__.py b/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..011178f --- /dev/null +++ b/core/models.py @@ -0,0 +1,883 @@ +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 +import urllib.parse +import urllib.request +import logging +from decimal import Decimal +from django.conf import settings +import re + +logger = logging.getLogger(__name__) + +def format_phone_number(phone): + """Formats a phone number to (xxx) xxx-xxxx if it has 10 digits or 11 starting with 1.""" + if not phone: + return phone + digits = re.sub(r'\D', '', str(phone)) + if len(digits) == 10: + return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}" + elif len(digits) == 11 and digits.startswith('1'): + return f"({digits[1:4]}) {digits[4:7]}-{digits[7:]}" + return phone + +class Tenant(models.Model): + name = models.CharField(max_length=100) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name + +class TenantUserRole(models.Model): + ROLE_CHOICES = [ + ('system_admin', 'System Administrator'), + ('campaign_admin', 'Campaign Administrator'), + ('campaign_staff', 'Campaign Staff'), + ] + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tenant_roles') + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='user_roles') + role = models.CharField(max_length=20, choices=ROLE_CHOICES) + + class Meta: + unique_together = ('user', 'tenant', 'role') + + def __str__(self): + return f"{self.user.username} - {self.tenant.name} ({self.role})" + +class InteractionType(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='interaction_types') + name = models.CharField(max_length=100) + is_active = models.BooleanField(default=True) + + class Meta: + unique_together = ('tenant', 'name') + + def __str__(self): + return self.name + +class DonationMethod(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='donation_methods') + name = models.CharField(max_length=100) + is_active = models.BooleanField(default=True) + + class Meta: + unique_together = ('tenant', 'name') + + def __str__(self): + return self.name + +class ElectionType(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='election_types') + name = models.CharField(max_length=100) + is_active = models.BooleanField(default=True) + + class Meta: + unique_together = ('tenant', 'name') + + def __str__(self): + return self.name + +class ParticipationStatus(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='participation_statuses') + name = models.CharField(max_length=100) + is_active = models.BooleanField(default=True) + + class Meta: + unique_together = ('tenant', 'name') + verbose_name_plural = 'Participation Statuses' + + def __str__(self): + return self.name + +class VolunteerRole(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='volunteer_roles') + name = models.CharField(max_length=100) + is_active = models.BooleanField(default=True) + + class Meta: + unique_together = ('tenant', 'name') + + def __str__(self): + return self.name + +class EventType(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='event_types') + name = models.CharField(max_length=100) + available_roles = models.ManyToManyField(VolunteerRole, blank=True, related_name='event_types') + default_volunteer_role = models.ForeignKey(VolunteerRole, on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_event_types") + is_active = models.BooleanField(default=True) + + class Meta: + unique_together = ('tenant', 'name') + + def __str__(self): + return self.name + +class Interest(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='interests') + name = models.CharField(max_length=100) + + class Meta: + unique_together = ('tenant', 'name') + + def __str__(self): + return self.name + +class Voter(models.Model): + CANDIDATE_SUPPORT_CHOICES = [ + ('unknown', 'Unknown'), + ('supporting', 'Supporting'), + ('not_supporting', 'Not Supporting'), + ] + 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'), + ('wants', 'Wants Sticker'), + ('has', 'Has Sticker'), + ] + PHONE_TYPE_CHOICES = [ + ('home', 'Home Phone'), + ('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) + first_name = models.CharField(max_length=100, db_index=True) + last_name = models.CharField(max_length=100, db_index=True) + nickname = models.CharField(max_length=100, blank=True) + birthdate = models.DateField(null=True, blank=True, db_index=True) + address = models.TextField(blank=True) + address_street = models.CharField(max_length=255, blank=True, db_index=True) + city = models.CharField(max_length=100, blank=True, db_index=True) + state = models.CharField(max_length=2, blank=True, db_index=True) + prior_state = models.CharField(max_length=2, blank=True) + zip_code = models.CharField(max_length=20, blank=True, db_index=True) + county = models.CharField(max_length=100, blank=True) + latitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) + longitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) + phone = models.CharField(max_length=20, blank=True) + phone_type = models.CharField(max_length=10, choices=PHONE_TYPE_CHOICES, default='cell') + secondary_phone = models.CharField(max_length=20, blank=True) + secondary_phone_type = models.CharField(max_length=10, choices=PHONE_TYPE_CHOICES, default="cell") + email = models.EmailField(blank=True) + district = models.CharField(max_length=100, blank=True, db_index=True) + 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. + Returns (success, error_message). + """ + if not self.address: + return False, "No address provided." + + api_key = getattr(settings, 'GOOGLE_MAPS_API_KEY', None) + if not api_key: + return False, "Google Maps API Key not configured." + + def _fetch(addr): + try: + query = urllib.parse.quote(addr) + url = f"https://maps.googleapis.com/maps/api/geocode/json?address={query}&key={api_key}" + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=10) as response: + data = json.loads(response.read().decode()) + if data.get('status') == 'OK': + result = data['results'][0] + return result['geometry']['location']['lat'], result['geometry']['location']['lng'], None + elif data.get('status') == 'ZERO_RESULTS': + return None, None, "No results found." + elif data.get('status') == 'OVER_QUERY_LIMIT': + return None, None, "Query limit exceeded." + elif data.get('status') == 'REQUEST_DENIED': + return None, None, f"Request denied: {data.get('error_message', 'No message')}" + elif data.get('status') == 'INVALID_REQUEST': + return None, None, "Invalid request." + else: + return None, None, f"Google Maps Error: {data.get('status')}" + except Exception as e: + return None, None, str(e) + + logger.info(f"Geocoding with Google Maps: {self.address}") + lat, lon, err = _fetch(self.address) + + if not lat and use_fallback: + # Try fallback: City, State, Zip + fallback_parts = [self.city, self.state, self.zip_code] + fallback_addr = ", ".join([p for p in fallback_parts if p]) + if fallback_addr and fallback_addr != self.address: + logger.info(f"Geocoding fallback: {fallback_addr}") + lat, lon, fallback_err = _fetch(fallback_addr) + if lat: + err = None # Clear previous error if fallback works + + if lat and lon: + # Truncate coordinates to 12 characters as requested + self.latitude = Decimal(str(lat)[:12]) + self.longitude = Decimal(str(lon)[:12]) + logger.info(f"Geocoding success: {self.latitude}, {self.longitude}") + return True, None + + logger.warning(f"Geocoding failed for {self.address}: {err}") + 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) + + # Ensure coordinates are truncated to 12 characters before saving + if self.latitude: + self.latitude = Decimal(str(self.latitude)[:12]) + if self.longitude: + self.longitude = Decimal(str(self.longitude)[:12]) + + # Auto concatenation: address street, city, state, zip + parts = [self.address_street, self.city, self.state, self.zip_code] + self.address = ", ".join([p for p in parts if p]) + + # 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 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 + + # 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 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) + + # 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: + # We don't want to block save if geocoding fails, so we just call it + self.geocode_address() + + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.first_name} {self.last_name}" + +class VotingRecord(models.Model): + voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='voting_records') + election_date = models.DateField() + election_description = models.CharField(max_length=255) + primary_party = models.CharField(max_length=100, blank=True) + + def __str__(self): + return f"{self.voter} - {self.election_description}" + +class Event(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='events') + name = models.CharField(max_length=255, db_index=True) + date = models.DateField() + start_time = models.TimeField(null=True, blank=True) + end_time = models.TimeField(null=True, blank=True) + event_type = models.ForeignKey(EventType, on_delete=models.PROTECT, null=True) + default_volunteer_role = models.ForeignKey(VolunteerRole, on_delete=models.SET_NULL, null=True, blank=True, related_name='default_for_events') + description = models.TextField(blank=True) + location_name = models.CharField(max_length=255, blank=True) + address = models.CharField(max_length=255, blank=True) + city = models.CharField(max_length=100, blank=True) + state = models.CharField(max_length=2, blank=True) + zip_code = models.CharField(max_length=20, blank=True) + latitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) + longitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) + + class Meta: + 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]) + if self.longitude: + self.longitude = Decimal(str(self.longitude)[:12]) + super().save(*args, **kwargs) + + def __str__(self): + if self.name: + return f"{self.name} ({self.date})" + return f"{self.event_type} on {self.date}" + +class Volunteer(models.Model): + class Meta: + ordering = ("last_name", "first_name") + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='volunteers') + user = models.OneToOneField(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='volunteer_profile') + first_name = models.CharField(max_length=100, blank=True) + last_name = models.CharField(max_length=100, blank=True) + email = models.EmailField() + phone = models.CharField(max_length=20, blank=True) + interests = models.ManyToManyField(Interest, blank=True, related_name='volunteers') + assigned_events = models.ManyToManyField(Event, through='VolunteerEvent', related_name='assigned_volunteers') + is_default_caller = models.BooleanField(default=False) + 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) + + if self.is_default_caller: + # Only one default caller per tenant + Volunteer.objects.filter(tenant=self.tenant, is_default_caller=True).exclude(pk=self.pk).update(is_default_caller=False) + + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.first_name} {self.last_name}".strip() or self.email + +class VolunteerEvent(models.Model): + volunteer = models.ForeignKey(Volunteer, on_delete=models.CASCADE, related_name="event_assignments") + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="volunteer_assignments") + role_type = models.ForeignKey(VolunteerRole, on_delete=models.SET_NULL, null=True, blank=True, related_name="volunteer_assignments") + + def __str__(self): + return f"{self.volunteer} at {self.event} as {self.role_type or 'Assigned'}" + +class EventParticipation(models.Model): + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='participations') + voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='event_participations') + participation_status = models.ForeignKey(ParticipationStatus, on_delete=models.PROTECT, null=True) + + def __str__(self): + return f"{self.voter} at {self.event} ({self.participation_status})" + +class Donation(models.Model): + voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='donations') + date = models.DateField() + method = models.ForeignKey(DonationMethod, on_delete=models.SET_NULL, null=True) + amount = models.DecimalField(max_digits=10, decimal_places=2) + + def __str__(self): + return f"{self.voter} - {self.amount} on {self.date}" + +class Interaction(models.Model): + voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='interactions') + volunteer = models.ForeignKey(Volunteer, on_delete=models.SET_NULL, null=True, blank=True, related_name='interactions') + type = models.ForeignKey(InteractionType, on_delete=models.SET_NULL, null=True) + date = models.DateTimeField() + description = models.CharField(max_length=255) + notes = models.TextField(blank=True) + + def __str__(self): + return f"{self.voter} - {self.type} on {self.date}" + +class VoterLikelihood(models.Model): + LIKELIHOOD_CHOICES = [ + ('not_likely', 'Not Likely'), + ('somewhat_likely', 'Somewhat Likely'), + ('very_likely', 'Very Likely'), + ] + voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='likelihoods') + election_type = models.ForeignKey(ElectionType, on_delete=models.CASCADE) + likelihood = models.CharField(max_length=20, choices=LIKELIHOOD_CHOICES) + + class Meta: + unique_together = ('voter', 'election_type') + + def __str__(self): + return f"{self.voter} - {self.election_type}: {self.get_likelihood_display()}" + +class ScheduledCall(models.Model): + STATUS_CHOICES = [ + ('pending', 'Pending'), + ('completed', 'Completed'), + ('cancelled', 'Cancelled'), + ] + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='scheduled_calls') + voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='scheduled_calls') + volunteer = models.ForeignKey(Volunteer, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_calls') + comments = models.TextField(blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + 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) + twilio_account_sid = models.CharField(max_length=100, blank=True, default='ACcd11acb5095cec6477245d385a2bf127') + twilio_auth_token = models.CharField(max_length=100, blank=True, default='89ec830d0fa02ab0afa6c76084865713') + twilio_from_number = models.CharField(max_length=20, blank=True, default='+18556945903') + timezone = models.CharField(max_length=100, default="America/Chicago", choices=[(tz, tz) for tz in sorted(zoneinfo.available_timezones())]) + smtp_host = models.CharField(max_length=255, blank=True) + smtp_port = models.IntegerField(default=587) + smtp_username = models.CharField(max_length=255, blank=True) + smtp_password = models.CharField(max_length=255, blank=True) + smtp_use_tls = models.BooleanField(default=True) + smtp_use_ssl = models.BooleanField(default=False) + email_from_address = models.EmailField(blank=True) + 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']) \ No newline at end of file diff --git a/core/permissions.py b/core/permissions.py new file mode 100644 index 0000000..b36e4e4 --- /dev/null +++ b/core/permissions.py @@ -0,0 +1,127 @@ +from functools import wraps +from django.core.exceptions import PermissionDenied +from django.shortcuts import redirect +from django.contrib import messages +from .models import TenantUserRole + +# Allowed roles for staff/admin actions +STAFF_ROLES = [ + 'admin', 'campaign_manager', 'campaign_staff', + 'system_admin', 'campaign_admin' +] + +def get_user_role(user, tenant): + if user.is_superuser: + return 'admin' + role_obj = TenantUserRole.objects.filter(user=user, tenant=tenant).first() + if role_obj: + return role_obj.role + return None + +def has_role(user, tenant, roles): + if user.is_superuser: + return True + if not tenant: + return False + user_role = get_user_role(user, tenant) + return user_role in 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 + if user.is_superuser: + return True + # If they can edit, they can view + if can_edit_voter(user, tenant): + return True + # All authenticated users with a tenant role can usually view voters in our app + # but we should restrict it if they have NO role and NO permission. + role = get_user_role(user, tenant) + if role: # Any role (even if not in STAFF_ROLES) allows viewing voters? + # Block Walkers don't have a TenantUserRole usually, they have a Group. + return True + return False + +def can_view_donations(user, tenant): + if user.has_perm("core.view_donation"): + return True + if user.is_superuser: + return True + role = get_user_role(user, tenant) + if role in STAFF_ROLES: + return True + return False + +def can_edit_voter(user, tenant): + if user.has_perm("core.change_voter"): + return True + if user.is_superuser: + return True + role = get_user_role(user, tenant) + if role in STAFF_ROLES: + return True + return False + +def can_view_volunteers(user, tenant): + if user.has_perm("core.view_volunteer"): + return True + if user.is_superuser: + return True + role = get_user_role(user, tenant) + if role in STAFF_ROLES: + return True + return False + +def can_edit_volunteer(user, tenant): + if user.has_perm("core.change_volunteer"): + return True + if user.is_superuser: + return True + role = get_user_role(user, tenant) + if role in STAFF_ROLES: + return True + return False + +def role_required(roles, permission=None): + def decorator(view_func): + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + from .models import Tenant + tenant_id = request.session.get('tenant_id') + if not tenant_id: + if request.user.is_superuser: + return view_func(request, *args, **kwargs) + messages.warning(request, "Please select a campaign first.") + return redirect('index') + + tenant = Tenant.objects.filter(id=tenant_id).first() + if not tenant: + messages.warning(request, "Campaign not found.") + return redirect('index') + + # Check roles first + if has_role(request.user, tenant, roles): + return view_func(request, *args, **kwargs) + + # Check for specific permission if provided + if permission and request.user.has_perm(permission): + return view_func(request, *args, **kwargs) + + messages.error(request, "You do not have permission to perform this action.") + return redirect('index') + return _wrapped_view + return decorator \ No newline at end of file diff --git a/core/task_runners.py b/core/task_runners.py new file mode 100644 index 0000000..10986ff --- /dev/null +++ b/core/task_runners.py @@ -0,0 +1,217 @@ +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 diff --git a/core/templates/admin/donation_change_list.html b/core/templates/admin/donation_change_list.html new file mode 100644 index 0000000..a7cf7a0 --- /dev/null +++ b/core/templates/admin/donation_change_list.html @@ -0,0 +1,9 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +
  • + + Import Donations + +
  • + {{ block.super }} +{% endblock %} diff --git a/core/templates/admin/event_change_list.html b/core/templates/admin/event_change_list.html new file mode 100644 index 0000000..b0d7076 --- /dev/null +++ b/core/templates/admin/event_change_list.html @@ -0,0 +1,38 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls static admin_list %} + +{% block object-tools-items %} +
  • + Import Events +
  • + {{ block.super }} +{% endblock %} + +{% block search %} + {{ block.super }} +
    + + +
    + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/admin/eventparticipation_change_list.html b/core/templates/admin/eventparticipation_change_list.html new file mode 100644 index 0000000..201d73c --- /dev/null +++ b/core/templates/admin/eventparticipation_change_list.html @@ -0,0 +1,7 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +
  • + Import Participants +
  • + {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/core/templates/admin/import_csv.html b/core/templates/admin/import_csv.html new file mode 100644 index 0000000..ccd6b51 --- /dev/null +++ b/core/templates/admin/import_csv.html @@ -0,0 +1,42 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    + {% csrf_token %} +
    +
    +

    Upload a CSV file to import {{ opts.verbose_name_plural }}.

    + {% if title == "Import Voters" %} +

    Expected columns (header mandatory): voter_id, first_name, last_name, address_street, city, state, zip_code, county, phone, email, district, precinct, registration_date, is_targeted, candidate_support, yard_sign, window_sticker

    + {% else %} +

    Expected columns (header mandatory): date, event_type, description

    + {% endif %} +
    + {% for field in form %} +
    + {{ field.errors }} + + {{ field }} + {% if field.help_text %} +
    {{ field.help_text|safe }}
    + {% endif %} +
    + {% endfor %} +
    +
    + +
    +
    +
    +{% endblock %} diff --git a/core/templates/admin/import_mapping.html b/core/templates/admin/import_mapping.html new file mode 100644 index 0000000..b9ed1d7 --- /dev/null +++ b/core/templates/admin/import_mapping.html @@ -0,0 +1,48 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    + {% csrf_token %} + + + +
    +

    {% translate "Map CSV Columns to Model Fields" %}

    +
    + Select which CSV column matches each model field. Leave blank to skip. +
    + + {% for field_name, verbose_name in model_fields %} +
    +
    + + +
    +
    + {% endfor %} +
    + +
    + +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/core/templates/admin/import_preview.html b/core/templates/admin/import_preview.html new file mode 100644 index 0000000..ba17685 --- /dev/null +++ b/core/templates/admin/import_preview.html @@ -0,0 +1,87 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    +

    {% translate "Import Preview" %}

    +

    + {% blocktranslate with total=total_count created=create_count updated=update_count %} + Found {{ total }} records in the CSV file. +
    + - {{ created }} will be created. +
    + - {{ updated }} will be updated. + {% endblocktranslate %} +

    + + {% if preview_data %} +
    +

    {% translate "Sample Records" %}

    + + + + + + + + + + {% for row in preview_data %} + + + + + + {% endfor %} + +
    {% translate "Action" %}{% translate "CSV Name / Matched Voter" %}{% translate "Details" %}
    + {% if row.action == 'create' %} + {% translate "CREATE" %} + {% else %} + {% translate "UPDATE" %} + {% endif %} + + {% if row.csv_full_name %} + CSV: {{ row.csv_full_name }} + {% if "Voter: N/A" not in row.identifier %}
    {% endif %} + {% endif %} + {% if "Voter: N/A" not in row.identifier %} + Matched: {{ row.identifier|cut:"Voter: " }} + {% else %} + {% if not row.csv_full_name %}N/A{% endif %} + {% endif %} +
    {{ row.details }}
    + {% if total_count > preview_data|length %} +

    ... and {{ total_count|add:"-10" }} more records.

    + {% endif %} +
    + {% endif %} +
    + +
    + {% csrf_token %} + + + + {# Pass mapping as hidden fields #} + {% for field_name, csv_col in mapping.items %} + + {% endfor %} + + +
    +
    +{% endblock %} \ No newline at end of file diff --git a/core/templates/admin/interaction_change_list.html b/core/templates/admin/interaction_change_list.html new file mode 100644 index 0000000..0f1a028 --- /dev/null +++ b/core/templates/admin/interaction_change_list.html @@ -0,0 +1,9 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +
  • + + Import Interactions + +
  • + {{ block.super }} +{% endblock %} diff --git a/core/templates/admin/mass_assign_volunteer.html b/core/templates/admin/mass_assign_volunteer.html new file mode 100644 index 0000000..3fad5bb --- /dev/null +++ b/core/templates/admin/mass_assign_volunteer.html @@ -0,0 +1,39 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static admin_modify %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +

    Select a volunteer to assign to the {{ queryset|length }} selected interactions:

    +
    + {% csrf_token %} +
    + {% for field in form %} +
    + {{ field.errors }} + {{ field.label_tag }} {{ field }} +
    + {% endfor %} + + + + {% for obj in queryset %} + + {% endfor %} + +
    + + Cancel +
    +
    +
    +
    +{% endblock %} diff --git a/core/templates/admin/participationstatus_change_list.html b/core/templates/admin/participationstatus_change_list.html new file mode 100644 index 0000000..4d3b166 --- /dev/null +++ b/core/templates/admin/participationstatus_change_list.html @@ -0,0 +1,31 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls static admin_list %} + +{% block search %} + {{ block.super }} +
    + + +
    + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/admin/volunteer_change_list.html b/core/templates/admin/volunteer_change_list.html new file mode 100644 index 0000000..0fe92f4 --- /dev/null +++ b/core/templates/admin/volunteer_change_list.html @@ -0,0 +1,38 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls static admin_list %} + +{% block object-tools-items %} +
  • + Import Volunteers +
  • + {{ block.super }} +{% endblock %} + +{% block search %} + {{ block.super }} +
    + + +
    + + +{% endblock %} diff --git a/core/templates/admin/voter_change_list.html b/core/templates/admin/voter_change_list.html new file mode 100644 index 0000000..c2393d1 --- /dev/null +++ b/core/templates/admin/voter_change_list.html @@ -0,0 +1,38 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls static admin_list %} + +{% block object-tools-items %} +
  • + Import Voters +
  • + {{ block.super }} +{% endblock %} + +{% block search %} + {{ block.super }} +
    + + +
    + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/admin/voterlikelihood_change_list.html b/core/templates/admin/voterlikelihood_change_list.html new file mode 100644 index 0000000..45091c2 --- /dev/null +++ b/core/templates/admin/voterlikelihood_change_list.html @@ -0,0 +1,9 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +
  • + + Import Likelihoods + +
  • + {{ block.super }} +{% endblock %} diff --git a/core/templates/admin/votingrecord_change_list.html b/core/templates/admin/votingrecord_change_list.html new file mode 100644 index 0000000..57151b0 --- /dev/null +++ b/core/templates/admin/votingrecord_change_list.html @@ -0,0 +1,38 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls static admin_list %} + +{% block object-tools-items %} +
  • + Import Voting Records +
  • + {{ block.super }} +{% endblock %} + +{% block search %} + {{ block.super }} +
    + + +
    + + +{% endblock %} diff --git a/core/templates/base.html b/core/templates/base.html new file mode 100644 index 0000000..ce7d0ce --- /dev/null +++ b/core/templates/base.html @@ -0,0 +1,115 @@ +{% load static %} + + + + + + {% block title %}Grassroots Campaign Manager{% endblock %} + + + + {% if project_description %} + + {% endif %} + {% if project_image_url %} + + {% endif %} + {% block extra_css %}{% endblock %} + + + + +
    + {% if messages %} + {% for message in messages %} +
    + {{ message }} + +
    + {% endfor %} + {% endif %} + + {% block content %}{% endblock %} +
    + + + + {% block extra_js %}{% endblock %} + + \ No newline at end of file diff --git a/core/templates/core/article_detail.html b/core/templates/core/article_detail.html new file mode 100644 index 0000000..8820990 --- /dev/null +++ b/core/templates/core/article_detail.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} + +{% block title %}{{ article.title }}{% endblock %} + +{% block content %} +
    +

    {{ article.title }}

    +

    Published on {{ article.created_at|date:"F d, Y" }}

    +
    +
    + {{ article.content|safe }} +
    +
    +{% endblock %} diff --git a/core/templates/core/bulk_task_list.html b/core/templates/core/bulk_task_list.html new file mode 100644 index 0000000..b8c9d17 --- /dev/null +++ b/core/templates/core/bulk_task_list.html @@ -0,0 +1,174 @@ +{% extends "base.html" %} +{% load static %} +{% load core_tags %} + +{% block content %} +
    +
    +
    + +

    Bulk Operations Log

    +
    +
    + + Back to Search +
    +
    + +
    +
    +
    + + + + + + + + + + + + + {% for task in bulk_tasks %} + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    TypeStatusProgressCountsCreatedDetails
    + + {{ task.get_task_type_display }} + + + {% if task.status == 'processing' %} + In Progress + {% elif task.status == 'completed' %} + Completed + {% elif task.status == 'failed' %} + Failed + {% else %} + {{ task.get_status_display }} + {% endif %} + +
    + {% if task.total_count > 0 %} + {% with success_percent=task.success_count|add:task.fail_count|mul:100|div:task.total_count %} +
    +
    + {% endwith %} + {% else %} +
    + {% endif %} +
    +
    +
    + {{ task.success_count }} success • + {{ task.fail_count }} fail • + {{ task.total_count }} total +
    +
    + {{ task.created_at|date:"M j, Y" }}
    + {{ task.created_at|date:"H:i" }} +
    + +
    +
    +
    +
    + +
    {{ task.message_body|default:"(No content)" }}
    +
    + {% if task.subject %} +
    + +
    {{ task.subject }}
    +
    + {% endif %} +
    +
    + {% if task.error_message %} +
    +
    Error Details:
    + {{ task.error_message }} +
    + {% endif %} +
    +
    Task ID: #{{ task.id }}
    +
    Last Updated: {{ task.updated_at|date:"M j, H:i:s" }}
    +
    +
    +
    +
    + +

    No bulk operations have been performed yet.

    +
    +
    +
    + {% if bulk_tasks.paginator.num_pages > 1 %} + + {% endif %} +
    +
    + + +{% endblock %} diff --git a/core/templates/core/call_queue.html b/core/templates/core/call_queue.html new file mode 100644 index 0000000..be6c486 --- /dev/null +++ b/core/templates/core/call_queue.html @@ -0,0 +1,425 @@ +{% extends "base.html" %} + +{% block content %} +
    +
    +

    Call Queue

    +
    + + Export CSV + + {% if campaign_settings.call_script %} + + {% endif %} + +
    +
    + +
    +
    + {% if is_staff %} +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    + {% endif %} +
    +
    + + {{ calls.paginator.count }} Pending Calls + +
    +
    + + +
    +
    + + + + + + + + + + + + + {% for call in calls %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    VoterPhoneAssignedCommentsAgeActions
    + + {{ call.voter.first_name }} {{ call.voter.last_name }} + + + {% if call.voter.phone %} + + {% else %} + No phone + {% endif %} + + {% if call.volunteer %} +
    +
    + {{ call.volunteer.first_name|first }}{{ call.volunteer.last_name|first }} +
    + {{ call.volunteer }} +
    + {% else %} + Unassigned + {% endif %} +
    {{ call.comments|default:"-" }}{{ call.created_at|timesince }} +
    + +
    + {% csrf_token %} + +
    +
    +
    +
    + +

    The call queue is currently empty.

    +

    Click "Populate Queue" to add voters.

    +
    +
    +
    +
    + + +
    + {% for call in calls %} +
    +
    +
    +
    + + {{ call.voter.first_name }} {{ call.voter.last_name }} + +
    + {{ call.created_at|timesince }} +
    + +
    + {% if call.voter.phone %} + + {{ call.voter.phone }} + + + {% endif %} + {% if call.volunteer %} + + {{ call.volunteer }} + + {% else %} + Unassigned + {% endif %} +
    + + {% if call.comments %} +
    + Comments +

    {{ call.comments }}

    +
    + {% endif %} + +
    + +
    + {% csrf_token %} + +
    +
    +
    +
    + {% empty %} +
    + +

    The call queue is currently empty.

    +
    + {% endfor %} +
    + + + {% if calls.paginator.num_pages > 1 %} +
    + +
    + {% endif %} +
    + + + + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/door_visit_history.html b/core/templates/core/door_visit_history.html new file mode 100644 index 0000000..1e79c0d --- /dev/null +++ b/core/templates/core/door_visit_history.html @@ -0,0 +1,236 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    +
    +

    Door Visit History

    +

    Review completed door-to-door visits and outcomes.

    +
    + +
    + + +
    + +
    +
    +
    +
    Filter by Date Range
    +
    + {% if selected_volunteer_id %}{% endif %} +
    + + +
    +
    + + +
    +
    + +
    + {% if start_date or end_date %} + + {% endif %} +
    +
    +
    +
    + + +
    +
    +
    +
    + Visits per Volunteer + {% if selected_volunteer_id %} + + Clear filter + + {% endif %} +
    +
    + {% for v in volunteer_counts %} + + {{ v.name }} + {{ v.count }} + + {% empty %} +

    No volunteer data available.

    + {% endfor %} +
    +
    +
    +
    +
    + + +
    +
    +
    Visited Households
    + + {{ history.paginator.count }} Households Visited + +
    +
    + + + + + + + + + + + + {% for household in history %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    Household AddressVoters VisitedLast VisitOutcomeComments
    +
    {{ household.address_display }}
    +
    + {% if household.neighborhood %} + + {{ household.neighborhood }} + + {% endif %} + {% if household.district %} + + District: {{ household.district }} + + {% endif %} +
    + {% for v_id, v_name in household.voters_at_address %} + {{ v_name }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
    +
    +
    +
    + {% for v_id, v_name in household.voters_at_address %} + + {{ v_name }} + + {% endfor %} +
    +
    +
    + {{ household.last_visit_date|date:"M d, Y H:i" }} +
    ({{ household.last_volunteer|default:"N/A" }})
    +
    +
    + + {{ household.last_outcome }} + + +
    + {{ household.notes|default:"-" }} +
    +
    +
    + +
    +

    No door visits found for this selection.

    +

    Try clearing your filters or visit the Door Visits page to log more visits.

    +
    +
    + + {% if history.paginator.num_pages > 1 %} + + {% endif %} +
    +
    + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/door_visits.html b/core/templates/core/door_visits.html new file mode 100644 index 0000000..af4fa3e --- /dev/null +++ b/core/templates/core/door_visits.html @@ -0,0 +1,424 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    +
    +

    Door Visits

    +

    Manage and track your door-to-door campaign progress.

    +
    + +
    + + +
    +
    +
    Filters
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + Reset +
    +
    +
    +
    + + +
    +
    +
    Unvisited Targeted Households
    + + {{ households.paginator.count }} Households Found + +
    +
    + + + + + + + + + + + {% for household in households %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    ActionHousehold AddressTargeted VotersNeighborhood
    + + Log Visit + + + +
    {{ household.address_street }}
    +
    {{ household.city }}
    +
    +
    + {% if household.neighborhood %} + + {{ household.neighborhood }} + + {% endif %} +
    +
    +
    + {% for voter in household.target_voters %} + + {{ voter.first_name }} {{ voter.last_name }} + + {% endfor %} +
    +
    + {% if household.neighborhood %} + + {{ household.neighborhood }} + + {% else %} + Not assigned + {% endif %} +
    +
    + +
    +

    No unvisited households found.

    +

    Try adjusting your filters or target more voters.

    +
    +
    + + {% if households.paginator.num_pages > 1 %} + + {% endif %} +
    +
    + + + +{% endblock %} + +{% block extra_js %} + +{% if GOOGLE_MAPS_API_KEY %} + +{% endif %} + + + + +{% endblock %} diff --git a/core/templates/core/event_detail.html b/core/templates/core/event_detail.html new file mode 100644 index 0000000..abcb681 --- /dev/null +++ b/core/templates/core/event_detail.html @@ -0,0 +1,476 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    + +
    +

    {{ event.name|default:event.event_type }}

    +
    + Edit Event Info + + + +
    +
    +
    + +
    + +
    +
    +
    +
    Event Details
    +
    + + {{ event.event_type }} +
    + {% if event.default_volunteer_role %} +
    + + {{ event.default_volunteer_role }} +
    + {% endif %} +
    + + {{ event.date|date:"F d, Y" }} +
    +
    + + + {% if event.start_time %} + {{ event.start_time|time:"g:i A" }} + {% if event.end_time %} - {{ event.end_time|time:"g:i A" }}{% endif %} + {% else %} + Not specified + {% endif %} + +
    +
    + + {% if event.location_name %}{{ event.location_name }}
    {% endif %} + + {% if event.address %} + {{ event.address }}
    + {{ event.city }}{% if event.city and event.state %}, {% endif %}{{ event.state }} {{ event.zip_code }} + {% elif event.city or event.state or event.zip_code %} + {{ event.city }}{% if event.city and event.state %}, {% endif %}{{ event.state }} {{ event.zip_code }} + {% else %} + No location provided. + {% endif %} +
    + {% if event.latitude and event.longitude %} +
    + {{ event.latitude }}, {{ event.longitude }} +
    + {% endif %} +
    +
    + +

    {{ event.description|default:"No description provided." }}

    +
    +
    +
    + + +
    +
    +
    Volunteers ({{ volunteers.count }})
    +
    +
    + + + + + + + + + + {% for v in volunteers %} + + + + + + {% empty %} + + + + {% endfor %} + +
    VolunteerRole
    + + {{ v.volunteer.first_name }} {{ v.volunteer.last_name }} + + + + {{ v.role_type|default:"Assigned" }} + + +
    + {% csrf_token %} + +
    +
    + No volunteers assigned. +
    +
    +
    +
    + + +
    +
    +
    +
    Participants ({{ participations.count }})
    +
    +
    + + + + + + + + + + {% for p in participations %} + + + + + + {% empty %} + + + + {% endfor %} + +
    Voter NameStatusActions
    + + {{ p.voter.first_name }} {{ p.voter.last_name }} + + + + {{ p.participation_status.name }} + + +
    + + +
    +
    + No participants yet. +
    +
    +
    +
    +
    +
    + + + + + + + + + + + +{% endblock %} diff --git a/core/templates/core/event_edit.html b/core/templates/core/event_edit.html new file mode 100644 index 0000000..7fb9700 --- /dev/null +++ b/core/templates/core/event_edit.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    + +

    {% if is_create %}Create New Event{% else %}Edit Event{% endif %}

    +
    + +
    +
    +
    +
    +
    + {% csrf_token %} +
    +
    + + {{ form.name }} + {% if form.name.errors %} +
    {{ form.name.errors }}
    + {% endif %} +
    +
    + + {{ form.event_type }} + {% if form.event_type.errors %} +
    {{ form.event_type.errors }}
    + {% endif %} +
    +
    + + {{ form.default_volunteer_role }} + {% if form.default_volunteer_role.errors %} +
    {{ form.default_volunteer_role.errors }}
    + {% endif %} +
    +
    + + {{ form.date }} + {% if form.date.errors %} +
    {{ form.date.errors }}
    + {% endif %} +
    +
    + + {{ form.start_time }} + {% if form.start_time.errors %} +
    {{ form.start_time.errors }}
    + {% endif %} +
    +
    + + {{ form.end_time }} + {% if form.end_time.errors %} +
    {{ form.end_time.errors }}
    + {% endif %} +
    +
    + + {{ form.description }} + {% if form.description.errors %} +
    {{ form.description.errors }}
    + {% endif %} +
    + +
    +
    Location Information
    + +
    + + {{ form.location_name }} + {% if form.location_name.errors %} +
    {{ form.location_name.errors }}
    + {% endif %} +
    +
    + + {{ form.address }} + {% if form.address.errors %} +
    {{ form.address.errors }}
    + {% endif %} +
    +
    + + {{ form.city }} + {% if form.city.errors %} +
    {{ form.city.errors }}
    + {% endif %} +
    +
    + + {{ form.state }} + {% if form.state.errors %} +
    {{ form.state.errors }}
    + {% endif %} +
    +
    + + {{ form.zip_code }} + {% if form.zip_code.errors %} +
    {{ form.zip_code.errors }}
    + {% endif %} +
    +
    + + {{ form.latitude }} + {% if form.latitude.errors %} +
    {{ form.latitude.errors }}
    + {% endif %} +
    +
    + + {{ form.longitude }} + {% if form.longitude.errors %} +
    {{ form.longitude.errors }}
    + {% endif %} +
    + +
    + + Cancel +
    +
    +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/event_list.html b/core/templates/core/event_list.html new file mode 100644 index 0000000..f865152 --- /dev/null +++ b/core/templates/core/event_list.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} + +{% block content %} +
    +
    +

    Campaign Events

    + +
    + +
    +
    + + + + + + + + + + + + + {% for event in events %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    Event NameTypeDateTimeLocationActions
    + + {{ event.name|default:event.event_type }} + + {{ event.event_type }}{{ event.date|date:"M d, Y" }} + {% if event.start_time %} + {{ event.start_time|time:"g:i A" }} + {% if event.end_time %} - {{ event.end_time|time:"g:i A" }}{% endif %} + {% else %} + - + {% endif %} + + {% if event.city or event.state %} + {{ event.city }}{% if event.city and event.state %}, {% endif %}{{ event.state }} + {% else %} + - + {% endif %} + + View Details +
    +

    No events found for this campaign.

    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/event_participant_map_fields.html b/core/templates/core/event_participant_map_fields.html new file mode 100644 index 0000000..bf39958 --- /dev/null +++ b/core/templates/core/event_participant_map_fields.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    + +

    Map Participant Fields for {{ event.name|default:event.event_type }}

    +

    File: {{ file_name }}

    +
    + +
    +
    +
    Column Mapping
    +
    + {% csrf_token %} +
    +
    + {{ form.email_column.label_tag }} + {{ form.email_column }} + {% if form.email_column.errors %} +
    {% for error in form.email_column.errors %}{{ error }}{% endfor %}
    + {% endif %} +
    +
    + {{ form.name_column.label_tag }} + {{ form.name_column }} + {% if form.name_column.errors %} +
    {% for error in form.name_column.errors %}{{ error }}{% endfor %}
    + {% endif %} +
    +
    + {{ form.phone_column.label_tag }} + {{ form.phone_column }} + {% if form.phone_column.errors %} +
    {% for error in form.phone_column.errors %}{{ error }}{% endfor %}
    + {% endif %} +
    +
    + {{ form.participation_status_column.label_tag }} + {{ form.participation_status_column }} + {% if form.participation_status_column.errors %} +
    {% for error in form.participation_status_column.errors %}{{ error }}{% endfor %}
    + {% endif %} +
    +
    + {{ form.default_participation_status.label_tag }} + {{ form.default_participation_status }} + {% if form.default_participation_status.errors %} +
    {% for error in form.default_participation_status.errors %}{{ error }}{% endfor %}
    + {% endif %} +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    Detected Headers
    +
    +
    +
      + {% for header in headers %} +
    • {{ header }}
    • + {% endfor %} +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/event_participant_matching.html b/core/templates/core/event_participant_matching.html new file mode 100644 index 0000000..3bb0051 --- /dev/null +++ b/core/templates/core/event_participant_matching.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} +{% load static %} +{% load core_tags %} + +{% block content %} +
    +
    + +

    Match Participants for {{ event.name|default:event.event_type }}

    +

    File: {{ file_name }}

    +
    + + {% if unmatched_rows %} +
    +
    +
    Unmatched Participants ({{ unmatched_rows|length }})
    +

    Manually match these participants to existing voters. If a voter is matched, their email will be updated with the one from the file, and they will be added to the event.

    +
    +
    + {% csrf_token %} +
    + {% for row_data in unmatched_rows %} +
    +
    +
    + From File (Name): {{ row_data.row_data|get_item:name_column|default:"N/A" }} +
    +
    + From File (Email): {{ row_data.row_data|get_item:email_column|default:"N/A" }} +
    +
    + From File (Status): {{ row_data.row_data|get_item:participation_status_column|default:"N/A" }} +
    +
    + From File (Phone): {{ row_data.row_data|get_item:phone_column|default:"N/A" }} +
    +
    +
    + + +
    +
    +
    + + +
    +
    + +
    +
    + {% endfor %} +
    + +
    +
    + {% else %} + + Back to Event Details + {% endif %} +
    + + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/index.html b/core/templates/core/index.html new file mode 100644 index 0000000..8330f07 --- /dev/null +++ b/core/templates/core/index.html @@ -0,0 +1,305 @@ +{% extends 'base.html' %} +{% load static %} + +{% block content %} +
    + {% if not selected_tenant %} +
    +
    +

    Welcome to Campaign Manager

    +

    Select a campaign to view the dashboard.

    +
    + {% for tenant in tenants %} +
    +
    +
    +
    {{ tenant.name }}
    + Manage Campaign +
    +
    +
    + {% endfor %} +
    +
    +
    + {% else %} +
    +
    +

    {{ selected_tenant.name }} Dashboard

    +

    Overview of voter engagement and field operations.

    +
    +
    + +
    +
    +
    +
    + + + + + +
    +
    +
    + {% if not is_block_walker or is_staff %} + + {% endif %} +
    + + +
    +
    +
    +
    +
    Active Voters
    +

    {{ metrics.total_registered_voters }}

    + View All → +
    +
    +
    +
    +
    +
    +
    Target Voters
    +

    {{ metrics.total_target_voters }}

    +
    +
    +
    +
    +
    +
    +
    Supporting
    +

    {{ metrics.total_supporting }}

    +
    +
    +
    +
    +
    +
    +
    Target Households
    +

    {{ metrics.total_target_households }}

    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    Door Visits
    +
    +
    +
    Visited
    +

    {{ metrics.total_door_visits }}

    +
    +
    +
    Target
    +

    {{ metrics.total_target_door_visit_households }}

    +
    +
    + View Visits → +
    +
    +
    +
    +
    +
    +
    + +
    +
    Signs
    +
    +
    +
    Has
    +

    {{ metrics.total_has_signs }}

    + View Existing → +
    +
    +
    Wants
    +

    {{ metrics.total_wants_signs }}

    + Requests → +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    Call Queue
    +
    +
    +
    To be Called
    +

    {{ metrics.total_to_be_called }}

    +
    +
    +
    Called
    +

    {{ metrics.total_called }}

    +
    +
    + View Queue → +
    +
    +
    +
    +
    +
    +
    + +
    +
    Window Stickers
    +
    +
    +
    Has
    +

    {{ metrics.total_has_window_stickers }}

    +
    +
    +
    Wants
    +

    {{ metrics.total_wants_window_stickers }}

    +
    +
    +
    +
    +
    +
    +
    +
    + {% if can_view_donations %}
    Donation Goal
    +
    +

    {{ metrics.donation_percentage }}%

    +
    +
    +
    +
    {% endif %} +
    +
    +
    +
    + + +
    + {% if not is_block_walker or is_staff %} +
    +
    +
    +
    Recent Interactions
    + View All +
    +
    +
    + + + + + + + + + + + {% for interaction in recent_interactions %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    VoterTypeDateNotes
    +
    {{ interaction.voter }}
    +
    {{ interaction.volunteer|default:"Staff" }}
    +
    + + {{ interaction.type }} + + + {{ interaction.date|date:"M d, Y H:i" }} + + {{ interaction.description|truncatechars:50 }} +
    No recent interactions found.
    +
    +
    +
    +
    + {% endif %} + +
    + {% if not is_block_walker or is_staff %} +
    +
    +
    Upcoming Events
    + View All +
    +
    +
    + {% for event in upcoming_events %} + +
    +
    {{ event.name|default:event.event_type }}
    + + {{ event.event_type }} + +
    +
    + {{ event.date|date:"M d, Y" }} +
    +
    + {% empty %} +
    + No upcoming events. +
    + {% endfor %} +
    +
    +
    + {% endif %} + + +
    +
    +
    Field Operations
    +
    +
    +
    + Total Volunteers + {{ metrics.volunteers_count }} +
    +
    + Total Interactions + {{ metrics.interactions_count }} +
    + {% if not is_block_walker or is_staff %} +
    + Total Events + {{ metrics.events_count }} +
    +
    + Event Attendees + {{ metrics.total_event_attendees }} +
    + {% endif %} +
    +
    +
    +
    + {% endif %} +
    +{% endblock %} diff --git a/core/templates/core/log_door_visit.html b/core/templates/core/log_door_visit.html new file mode 100644 index 0000000..cb58a8f --- /dev/null +++ b/core/templates/core/log_door_visit.html @@ -0,0 +1,156 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    +
    +
    + + + +
    +

    Log Door Visit

    +

    Record interaction for this household

    +
    +
    + +
    +
    +
    +
    Household Address
    +
    {{ address_street }}
    +
    {{ city }}, {{ state }} {{ zip_code }}
    +
    + +
    + {% csrf_token %} + + + + + + + +
    + +
    + {% for radio in visit_form.outcome %} +
    + {{ radio.tag }} + +
    + {% endfor %} +
    +
    + +
    + + {{ visit_form.notes }} +
    + +
    +
    + + {{ visit_form.candidate_support }} +
    +
    + + {{ visit_form.yard_sign_status }} +
    +
    + +
    + +
    +
    + {{ visit_form.follow_up }} + +
    +
    +
    + + {{ visit_form.follow_up_voter }} +
    +
    + + {{ visit_form.call_notes }} +
    These notes will be added to the call queue for the default caller.
    +
    +
    +
    + +
    + Cancel + +
    +
    +
    +
    + +
    +
    Targeted Voters at this Address
    + +
    +
    +
    +
    + + + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/neighborhood_counts.html b/core/templates/core/neighborhood_counts.html new file mode 100644 index 0000000..79a3edd --- /dev/null +++ b/core/templates/core/neighborhood_counts.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    +
    +

    Neighborhood Summary

    +

    Household counts by neighborhood based on active filters.

    +
    + +
    + + + {% if district_filter or neighborhood_filter or city_filter or address_filter %} +
    + + Active Filters: + {% if district_filter %}District: {{ district_filter }}{% endif %} + {% if neighborhood_filter %}{% if district_filter %}, {% endif %}Neighborhood: {{ neighborhood_filter }}{% endif %} + {% if city_filter %}{% if district_filter or neighborhood_filter %}, {% endif %}City: {{ city_filter }}{% endif %} + {% if address_filter %}{% if district_filter or neighborhood_filter or city_filter %}, {% endif %}Address: {{ address_filter }}{% endif %} +
    + {% endif %} + + +
    +
    +
    Neighborhoods
    + + {{ neighborhoods|length }} Neighborhoods + +
    +
    + + + + + + + + + + {% for item in neighborhoods %} + + + + + + {% empty %} + + + + {% endfor %} + +
    NeighborhoodUnvisited HouseholdsAction
    + {{ item.display_name }} + + + {{ item.count }} + + + + View Households + +
    + No data found for the current filters. +
    +
    +
    +
    +{% endblock %} diff --git a/core/templates/core/profile.html b/core/templates/core/profile.html new file mode 100644 index 0000000..b7f4016 --- /dev/null +++ b/core/templates/core/profile.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    My Profile

    +

    Manage your account information

    +
    +
    +
    +
    +
    + {% csrf_token %} + +
    User Information
    +
    +
    + + {{ u_form.first_name }} + {% if u_form.first_name.errors %} +
    {{ u_form.first_name.errors }}
    + {% endif %} +
    +
    + + {{ u_form.last_name }} + {% if u_form.last_name.errors %} +
    {{ u_form.last_name.errors }}
    + {% endif %} +
    +
    + + {{ u_form.email }} + {% if u_form.email.errors %} +
    {{ u_form.email.errors }}
    + {% endif %} +
    +
    + + {% if v_form %} +
    +
    Volunteer Details
    +
    +
    + + {{ v_form.phone }} + {% if v_form.phone.errors %} +
    {{ v_form.phone.errors }}
    + {% endif %} +
    +
    + {% endif %} + +
    + + Change Password +
    +
    +
    +
    +
    +
    +
    +{% endblock %} diff --git a/core/templates/core/view_signs.html b/core/templates/core/view_signs.html new file mode 100644 index 0000000..5dbfdee --- /dev/null +++ b/core/templates/core/view_signs.html @@ -0,0 +1,378 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    +
    +

    View Signs

    +

    View households that already have a yard sign.

    +
    +
    + +
    +
    + + +
    +
    +
    Filters
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + Reset +
    +
    +
    +
    + + +
    +
    +
    Households with Signs
    + + {{ households.paginator.count }} Households Found + +
    +
    + + + + + + + + + + + + {% for household in households %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    Household AddressVoters with SignSign TypeVoter NotesNeighborhood
    +
    {{ household.address_street }}
    +
    {{ household.city }}, {{ household.state }} {{ household.zip_code }}
    +
    + {% if household.neighborhood %} + + {{ household.neighborhood }} + + {% endif %} +
    +
    +
    + {% for voter in household.voters_with_sign %} + + {{ voter.first_name }} {{ voter.last_name }} + + {% endfor %} +
    +
    + + {{ household.sign_types_display }} + + + {% for voter in household.voters_with_sign %} + {% if voter.notes %} +
    {{ voter.first_name }}: {{ voter.notes }}
    + {% endif %} + {% endfor %} +
    + {% if household.neighborhood %} + + {{ household.neighborhood }} + + {% else %} + + {% endif %} +
    + No records found with current filters. +
    +
    + + + {% if households.has_other_pages %} + + {% endif %} +
    +
    + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/volunteer_detail.html b/core/templates/core/volunteer_detail.html new file mode 100644 index 0000000..9ed1aba --- /dev/null +++ b/core/templates/core/volunteer_detail.html @@ -0,0 +1,386 @@ +{% extends "base.html" %} +{% load static %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} +
    +
    + +
    +

    {% if volunteer %}{{ volunteer.first_name }} {{ volunteer.last_name }}{% else %}New Volunteer{% endif %}

    + {% if volunteer %} +
    + {% csrf_token %} + +
    + {% endif %} +
    +
    + +
    + +
    +
    +
    +
    Volunteer Information
    +
    + {% csrf_token %} +
    +
    + + {{ form.first_name }} +
    +
    + + {{ form.last_name }} +
    +
    + + {{ form.email }} +
    +
    +
    + + {% if volunteer.phone %} +
    + + +
    + {% endif %} +
    + {{ form.phone }} +
    +
    +
    + {{ form.is_default_caller }} + +
    If enabled, this volunteer will be the default assigned person for new call queue entries.
    +
    +
    +
    +
    + +
    + + +
    +
    +
    + {{ form.interests }} +
    +
    Search and select multiple interest types for this volunteer.
    +
    +
    + + {{ form.notes }} +
    +
    +
    + Cancel + +
    +
    +
    +
    +
    + + {% if volunteer %} + +
    +
    +
    +
    Event Assignments
    + +
    +
    + + + + + + + + + + {% for assignment in assignments %} + + + + + + {% empty %} + + + + {% endfor %} + +
    EventRoleAction
    + + {{ assignment.event.name|default:assignment.event.event_type }} + +
    {{ assignment.event.date|date:"M d, Y" }}
    +
    {{ assignment.role_type|default:"Assigned" }} +
    + {% csrf_token %} + +
    +
    + No events assigned yet. +
    +
    +
    +
    + {% endif %} +
    +
    + + + + + + + +{% if volunteer %} + + +{% endif %} +{% endblock %} + +{% block extra_js %} + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/volunteer_list.html b/core/templates/core/volunteer_list.html new file mode 100644 index 0000000..ec02c07 --- /dev/null +++ b/core/templates/core/volunteer_list.html @@ -0,0 +1,289 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    +

    Volunteer Directory

    + +
    + + {{ task.get_task_type_display }} +
    + + +
    +
    +
    Volunteers ({{ volunteers.paginator.count }})
    +
    + + +
    +
    +
    + + + + + + + + + + + + {% for volunteer in volunteers %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    + + NamePhoneEmailInterests
    + + +
    + + {{ volunteer.first_name }} {{ volunteer.last_name }} + + {% if volunteer.is_default_caller %} + + Default Caller + + {% endif %} +
    +
    + {% if volunteer.phone %} + + {% else %} + - + {% endif %} + {{ volunteer.email }} + {% for interest in volunteer.interests.all %} + {{ interest.name }} + {% empty %} + No interests listed + {% endfor %} +
    +

    No volunteers found matching your search.

    + Add the first volunteer +
    +
    + + {% if volunteers.paginator.num_pages > 1 %} + + {% endif %} +
    + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/voter_add.html b/core/templates/core/voter_add.html new file mode 100644 index 0000000..cb996ce --- /dev/null +++ b/core/templates/core/voter_add.html @@ -0,0 +1,261 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    + +

    Add New Voter

    +
    + +
    +
    +
    +
    +
    + {% csrf_token %} + +
    +
    Core Information
    +
    + + {{ form.first_name }} + {% if form.first_name.errors %}
    {{ form.first_name.errors }}
    {% endif %} +
    +
    + + {{ form.last_name }} + {% if form.last_name.errors %}
    {{ form.last_name.errors }}
    {% endif %} +
    +
    + + {{ form.nickname }} + {% if form.nickname.errors %}
    {{ form.nickname.errors }}
    {% endif %} +
    +
    + + {{ form.voter_id }} + {% if form.voter_id.errors %}
    {{ form.voter_id.errors }}
    {% endif %} +
    +
    + + {{ form.birthdate }} + {% if form.birthdate.errors %}
    {{ form.birthdate.errors }}
    {% endif %} +
    +
    + + {{ form.registration_date }} + {% if form.registration_date.errors %}
    {{ form.registration_date.errors }}
    {% endif %} +
    + +
    Address
    +
    + + {{ form.address_street }} + {% if form.address_street.errors %}
    {{ form.address_street.errors }}
    {% endif %} +
    +
    + + {{ form.city }} + {% if form.city.errors %}
    {{ form.city.errors }}
    {% endif %} +
    +
    + + {{ form.state }} + {% if form.state.errors %}
    {{ form.state.errors }}
    {% endif %} +
    +
    + + {{ form.prior_state }} + {% if form.prior_state.errors %}
    {{ form.prior_state.errors }}
    {% endif %} +
    +
    + + {{ form.zip_code }} + {% if form.zip_code.errors %}
    {{ form.zip_code.errors }}
    {% endif %} +
    +
    + + {{ form.county }} + {% if form.county.errors %}
    {{ form.county.errors }}
    {% endif %} +
    +
    + + {{ form.neighborhood }} + {% if form.neighborhood.errors %}
    {{ form.neighborhood.errors }}
    {% endif %} +
    + +
    + + +
    + +
    + + {{ form.latitude }} + {% if form.latitude.errors %}
    {{ form.latitude.errors }}
    {% endif %} +
    +
    + + {{ form.longitude }} + {% if form.longitude.errors %}
    {{ form.longitude.errors }}
    {% endif %} +
    + +
    Contact Information
    +
    + + {{ form.phone }} + {% if form.phone.errors %}
    {{ form.phone.errors }}
    {% endif %} +
    +
    + + {{ form.phone_type }} + {% if form.phone_type.errors %}
    {{ form.phone_type.errors }}
    {% endif %} +
    +
    + + {{ form.secondary_phone }} + {% if form.secondary_phone.errors %}
    {{ form.secondary_phone.errors }}
    {% endif %} +
    +
    + + {{ form.secondary_phone_type }} + {% if form.secondary_phone_type.errors %}
    {{ form.secondary_phone_type.errors }}
    {% endif %} +
    +
    + + {{ form.email }} + {% if form.email.errors %}
    {{ form.email.errors }}
    {% endif %} +
    + +
    Campaign Data
    +
    + + {{ form.district }} + {% if form.district.errors %}
    {{ form.district.errors }}
    {% endif %} +
    +
    + + {{ form.precinct }} + {% if form.precinct.errors %}
    {{ form.precinct.errors }}
    {% endif %} +
    +
    + + {{ form.candidate_support }} + {% if form.candidate_support.errors %}
    {{ form.candidate_support.errors }}
    {% endif %} +
    +
    + + {{ form.yard_sign }} + {% if form.yard_sign.errors %}
    {{ form.yard_sign.errors }}
    {% endif %} +
    +
    + + {{ form.window_sticker }} + {% if form.window_sticker.errors %}
    {{ form.window_sticker.errors }}
    {% endif %} +
    +
    + + {{ form.call_queue_status }} + {% if form.call_queue_status.errors %}
    {{ form.call_queue_status.errors }}
    {% endif %} +
    +
    +
    + {{ form.is_targeted }} + +
    +
    +
    +
    + {{ form.target_door_visit }} + +
    +
    + +
    + + {{ form.notes }} + {% if form.notes.errors %}
    {{ form.notes.errors }}
    {% endif %} +
    +
    + +
    + Cancel + +
    +
    +
    +
    +
    +
    +
    + + + + +{% endblock %} diff --git a/core/templates/core/voter_advanced_search.html b/core/templates/core/voter_advanced_search.html new file mode 100644 index 0000000..225799e --- /dev/null +++ b/core/templates/core/voter_advanced_search.html @@ -0,0 +1,613 @@ +{% extends "base.html" %} +{% load static %} +{% load core_tags %} + +{% block content %} +
    +
    +

    Advanced Voter Search

    + +
    + +
    +
    +
    +
    + + {{ form.first_name }} +
    +
    + + {{ form.last_name }} +
    +
    + + {{ form.voter_id }} +
    +
    + + {{ form.address }} +
    +
    + + {{ form.birth_month }} +
    +
    + + {{ form.city }} +
    +
    + + {{ form.zip_code }} +
    +
    + + {{ form.neighborhood }} +
    +
    + + {{ form.district }} +
    +
    + + {{ form.precinct }} +
    +
    + + {{ form.email }} +
    +
    + + {{ form.phone }} +
    +
    + + {{ form.phone_type }} +
    +
    + + {{ form.candidate_support }} +
    +
    + + {{ form.yard_sign }} +
    +
    + + {{ form.window_sticker }} +
    +
    + + {{ form.call_queue_status }} +
    +
    + + {{ form.min_total_donation }} +
    +
    + + {{ form.max_total_donation }} +
    +
    + + {{ form.is_targeted }} +
    +
    + + {{ form.target_door_visit }} +
    +
    + + {{ form.voted }} +
    +
    + + {{ form.door_visit }} +
    +
    + + {{ form.ever_had_yard_sign }} +
    +
    + + {{ form.ever_had_large_sign }} +
    +
    + Clear Filters + +
    +
    +
    +
    + +
    + {% csrf_token %} + + {% for key, value in request.GET.items %} + {% if key != 'csrfmiddlewaretoken' and key != 'page' %} + + {% endif %} + {% endfor %} + + +
    +
    +
    Search Results ({{ voters.paginator.count }})
    + {% if can_edit_voter %} +
    +
    + + 0 selected + + + + +
    +
    + +
    +
    + {% endif %} +
    +
    +
    + All {{ voters.paginator.count }} voters in this search are selected. + +
    +
    +
    + + + + + + + + + + + + + + {% for voter in voters %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    + + NameDistrictNeighborhoodPhoneTarget VoterSupporter
    + + + + {{ voter.first_name }} {{ voter.last_name }} + +
    {{ voter.address|default:"No address provided" }}
    +
    {{ voter.district|default:"-" }}{{ voter.neighborhood|default:"-" }} + {% if voter.phone %} + +
    {{ voter.get_phone_type_display }}
    + {% else %} + - + {% endif %} + {% if voter.secondary_phone %} +
    + +
    {{ voter.get_secondary_phone_type_display }}
    +
    + {% endif %} +
    + {% if voter.is_targeted %} + Yes + {% else %} + No + {% endif %} + + {% if voter.candidate_support == 'supporting' %} + Supporting + {% elif voter.candidate_support == 'not_supporting' %} + Not Supporting + {% else %} + Unknown + {% endif %} +
    +

    No voters found matching your search criteria.

    +
    +
    + + {% if voters.paginator.num_pages > 1 %} + + {% endif %} +
    +
    +
    + + + + + + + + + + + +{% if GOOGLE_MAPS_API_KEY %} + +{% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/voter_detail.html b/core/templates/core/voter_detail.html new file mode 100644 index 0000000..ac5afd0 --- /dev/null +++ b/core/templates/core/voter_detail.html @@ -0,0 +1,1299 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} + + + +
    + + + + +
    +
    +
    +
    +
    + {% if voter.nickname %}{{ voter.nickname|first }}{% else %}{{ voter.first_name|first }}{% endif %}{{ voter.last_name|first }} +
    +
    +
    +
    +

    {% if voter.nickname %}{{ voter.nickname }}{% else %}{{ voter.first_name }}{% endif %} {{ voter.last_name }}

    + {% if can_edit_voter %} + + {% endif %} +
    +

    + + {% if other_voters_at_address %} + + {{ voter.address|default:"No address on file" }} + {{ other_voters_at_address|length|add:1 }} voters + + {% else %} + {{ voter.address|default:"No address on file" }} + {% endif %} +

    +
    + Voter ID: {{ voter.voter_id|default:"N/A" }} + District: {{ voter.district|default:"-" }} + Neighborhood: {{ voter.neighborhood|default:"-" }} + Precinct: {{ voter.precinct|default:"-" }} + Neighborhood: {{ voter.neighborhood|default:"-" }} +
    +
    +
    + {% if voter.voted %} +
    Voted
    + {% endif %} + {% if voter.is_targeted %} +
    Targeted Voter
    + {% endif %} + {% if voter.target_door_visit %} +
    Target Door Visit
    + {% endif %} + {% if voter.call_queue_status == 'in_call_queue' %} +
    In Call Queue
    + {% elif voter.call_queue_status == 'called' %} +
    Called
    + {% elif voter.call_queue_status == 'to_be_called' %} +
    To Be Called
    + {% endif %} + {% if voter.candidate_support == 'supporting' %} +
    Supporting
    + {% elif voter.candidate_support == 'not_supporting' %} +
    Not Supporting
    + {% else %} +
    Unknown Support
    + {% endif %} +
    +
    +
    +
    + +
    + +
    + +
    +
    +
    Location Map
    +
    +
    + {% if not voter.latitude or not voter.longitude %} +
    + +

    No coordinates available for this address.

    +

    Edit profile to trigger auto-geocoding.

    +
    + {% endif %} +
    +
    + +
    +
    +
    Contact Information
    + {% if not is_block_walker or is_staff %} + + {% endif %} +
    +
    +
      +
    • + + {{ voter.email|default:"N/A" }} +
    • +
    • + + {% if voter.phone %} +
      + {{ voter.phone }} + + + + + + + {{ voter.get_phone_type_display }} +
      + {% else %} + N/A + {% endif %} +
    • + {% if voter.secondary_phone %} +
    • + +
      + {{ voter.secondary_phone }} + + + + + + + {{ voter.get_secondary_phone_type_display }} +
      +
    • + {% endif %} +
    • + + {{ voter.birthdate|date:"M d, Y"|default:"N/A" }} +
    • +
    • + + {{ voter.prior_state|default:"N/A" }} +
    • +
    • + + {{ voter.registration_date|date:"M d, Y"|default:"Unknown" }} +
    • +
    +
    +
    + +
    +
    +
    Likelihood to Vote
    + {% if can_edit_voter %} + + {% endif %} +
    +
    + {% for likelihood in likelihoods %} +
    +
    + {{ likelihood.election_type.name }} + {% if likelihood.likelihood == 'very_likely' %} + Very Likely + {% elif likelihood.likelihood == 'somewhat_likely' %} + Somewhat Likely + {% else %} + Not Likely + {% endif %} +
    + {% if can_edit_voter %} +
    + + +
    + {% endif %} +
    + {% empty %} +

    No likelihood data available.

    + {% endfor %} +
    +
    + +
    +
    +
    Campaign Assets
    +
    +
    +
    +
    + Yard Sign Status +
    +
    + {% if voter.yard_sign == 'has' %} + Has Sign + {% elif voter.yard_sign == 'has_large' %} + Has Large Sign + {% elif voter.yard_sign == 'wants' %} + Wants Sign + {% elif voter.yard_sign == 'wants_large' %} + Wants Large Sign + {% else %} + None + {% endif %} +
    +
    +
    +
    + Window Sticker Status +
    +
    + {% if voter.window_sticker == 'has' %} + Has Sticker + {% elif voter.window_sticker == 'wants' %} + Wants Sticker + {% else %} + None + {% endif %} +
    +
    +
    +
    + +
    +
    +
    Notes
    +
    +
    +

    {{ voter.notes|default:"No notes available." }}

    +
    +
    +
    + + +
    + + + +
    + +
    +
    +
    +
    Interaction History
    + +
    +
    + + + + + + + + + {% if can_edit_voter %} + + {% endif %} + + + + {% for interaction in interactions %} + + + + + + + {% if can_edit_voter %} + + {% endif %} + + {% empty %} + + {% endfor %} + +
    DateTypeVolunteerDescriptionNotesActions
    {{ interaction.date|date:"M d, Y H:i" }}{{ interaction.type.name }}{% if interaction.volunteer %}{{ interaction.volunteer }}{% else %}-{% endif %}{{ interaction.description }} + {{ interaction.notes|truncatechars:30 }} + + + +
    No interactions recorded.
    +
    +
    +
    + + {% if not is_block_walker or is_staff %} + +
    +
    +
    +
    Event Participation
    + +
    +
    + + + + + + + + + {% if can_edit_voter %} + + {% endif %} + + + + {% for participation in event_participations %} + + + + + + + {% if can_edit_voter %} + + {% endif %} + + {% empty %} + + {% endfor %} + +
    DateEvent NameEvent TypeStatusDescriptionActions
    {{ participation.event.date|date:"M d, Y" }}{{ participation.event.name|default:"(No Name)" }}{{ participation.event.event_type.name }} + {% if participation.participation_status.name|lower == 'attended' %} + Attended + {% elif participation.participation_status.name|lower == "invited but didn't attend" or participation.participation_status.name|lower == "invited but didn't attend" %} + Did Not Attend + {% else %} + {{ participation.participation_status.name }} + {% endif %} + + {{ participation.event.description|truncatechars:60 }} + + + +
    No event participations found.
    +
    +
    +
    + {% endif %} + + + {% if can_view_donations %} +
    +
    +
    +
    Donation History
    + +
    +
    + + + + + + + + + + + {% for donation in donations %} + + + + + + + {% empty %} + + {% endfor %} + +
    DateMethodAmountActions
    {{ donation.date|date:"M d, Y" }}{{ donation.method.name }}${{ donation.amount }} + + +
    No donations recorded.
    +
    +
    +
    + {% endif %} + + +
    +
    +
    + + + + + + + + + + {% for record in voting_records %} + + + + + + {% empty %} + + {% endfor %} + +
    Election DateDescriptionParty
    {{ record.election_date|date:"M d, Y" }}{{ record.election_description }}{{ record.primary_party|default:"-" }}
    No voting records found.
    +
    +
    +
    +
    +
    +
    +
    + +{% if can_edit_voter %} + + + + + + +{% endif %} + + + + +{% if can_edit_voter %} + +{% for interaction in interactions %} + + + + +{% endfor %} +{% endif %} + +{% if can_view_donations %} + + + + +{% for donation in donations %} + + + + +{% endfor %} +{% endif %} + + + + +{% if can_edit_voter %} + +{% for likelihood in likelihoods %} + + + + +{% endfor %} +{% endif %} + +{% if not is_block_walker or is_staff %} + + + +{% if can_edit_voter %} + +{% for participation in event_participations %} + + + + +{% endfor %} +{% endif %} +{% endif %} + +{% if not is_block_walker or is_staff %} + + +{% endif %} + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/voter_list.html b/core/templates/core/voter_list.html new file mode 100644 index 0000000..35674ff --- /dev/null +++ b/core/templates/core/voter_list.html @@ -0,0 +1,246 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    +

    Voter Registry

    +
    + Advanced Search + {% if can_edit_voter %} + + Add Voter + + {% endif %} +
    +
    + +
    +
    +
    +
    + + + + +
    + + {% if query or request.GET.is_targeted or request.GET.support or request.GET.has_address or request.GET.visited or request.GET.yard_sign or request.GET.window_sticker or request.GET.has_donations %} + Clear + {% endif %} +
    +
    +
    + +
    +
    +
    Voters ({{ voters.paginator.count }})
    +
    + +
    +
    +
    + + + + + + + + + + + + + + {% for voter in voters %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    NameDistrictNeighborhoodPhoneTargetedVisit TargetSupporter
    + + {{ voter.first_name }} {{ voter.last_name }} + +
    {{ voter.address|default:"No address provided" }}
    +
    {{ voter.district|default:"-" }}{{ voter.neighborhood|default:"-" }} + {% if voter.phone %} + + {% else %} + - + {% endif %} + {% if voter.secondary_phone %} + + {% endif %} + + {% if voter.is_targeted %} + Yes + {% else %} + No + {% endif %} + + {% if voter.target_door_visit %} + Yes + {% else %} + No + {% endif %} + + {% if voter.candidate_support == 'supporting' %} + Supporting + {% elif voter.candidate_support == 'not_supporting' %} + Not Supporting + {% else %} + Unknown + {% endif %} +
    +

    No voters found matching your search.

    +
    +
    + + {% if voters.paginator.num_pages > 1 %} + + {% endif %} +
    +
    + + + + + +{% if GOOGLE_MAPS_API_KEY %} + +{% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/yard_sign_voters.html b/core/templates/core/yard_sign_voters.html new file mode 100644 index 0000000..ad281fd --- /dev/null +++ b/core/templates/core/yard_sign_voters.html @@ -0,0 +1,392 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    +
    +

    Yard Sign Requests

    +

    View and manage households that have requested a yard sign.

    +
    +
    + +
    +
    + + +
    +
    +
    Filters
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + Reset +
    +
    +
    +
    + + +
    +
    +
    Households Wanting Signs
    + + {{ households.paginator.count }} Households Found + +
    +
    + + + + + + + + + + + + + {% for household in households %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    ACTIONHousehold AddressVoters Wanting SignSign TypeVoter NotesNeighborhood
    +
    + {% csrf_token %} + {% for voter in household.voters_who_want_sign %} + + {% endfor %} + +
    +
    +
    {{ household.address_street }}
    +
    {{ household.city }}, {{ household.state }} {{ household.zip_code }}
    +
    + {% if household.neighborhood %} + + {{ household.neighborhood }} + + {% endif %} +
    +
    +
    + {% for voter in household.voters_who_want_sign %} + + {{ voter.first_name }} {{ voter.last_name }} + + {% endfor %} +
    +
    + + {{ household.sign_types_display }} + + + {% for voter in household.voters_who_want_sign %} + {% if voter.notes %} +
    {{ voter.first_name }}: {{ voter.notes }}
    + {% endif %} + {% endfor %} +
    + {% if household.neighborhood %} + + {{ household.neighborhood }} + + {% else %} + + {% endif %} +
    + No records found with current filters. +
    +
    + + + {% if households.has_other_pages %} + + {% endif %} +
    +
    + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/registration/login.html b/core/templates/registration/login.html new file mode 100644 index 0000000..3e62255 --- /dev/null +++ b/core/templates/registration/login.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    +
    +
    +
    +

    Login

    +

    Please log in to access the Grassroots Campaign Manager.

    + + {% if form.errors %} +
    + Your username and password didn't match. Please try again. +
    + {% endif %} + + {% if next %} + {% if user.is_authenticated %} +
    + Your account doesn't have access to this page. To proceed, + please log in with an account that has access. +
    + {% else %} +
    + Please log in to see this page. +
    + {% endif %} + {% endif %} + +
    + {% csrf_token %} +
    + + +
    +
    + + +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +{% endblock %} diff --git a/core/templates/registration/password_change_done.html b/core/templates/registration/password_change_done.html new file mode 100644 index 0000000..46b9d72 --- /dev/null +++ b/core/templates/registration/password_change_done.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    +
    +
    +
    + +
    +

    Password Changed!

    +

    Your password has been successfully updated. You can now use your new password to log in.

    + +
    +
    +
    +
    +{% endblock %} diff --git a/core/templates/registration/password_change_form.html b/core/templates/registration/password_change_form.html new file mode 100644 index 0000000..2a23bf2 --- /dev/null +++ b/core/templates/registration/password_change_form.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    +
    +
    +
    +
    + +
    +

    Change Password

    +

    Secure your account by updating your password

    +
    +
    +
    + {% csrf_token %} + {% for field in form %} +
    + + {{ field }} + {% if field.help_text %} +
    {{ field.help_text }}
    + {% endif %} + {% for error in field.errors %} +
    {{ error }}
    + {% endfor %} +
    + {% endfor %} +
    + + Cancel +
    +
    +
    +
    +
    +
    +
    + + +{% endblock %} diff --git a/core/templatetags/__init__.py b/core/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/templatetags/core_tags.py b/core/templatetags/core_tags.py new file mode 100644 index 0000000..9af6b83 --- /dev/null +++ b/core/templatetags/core_tags.py @@ -0,0 +1,23 @@ +from django import template + +register = template.Library() + +@register.filter +def get_item(dictionary, key): + return dictionary.get(key) + +@register.filter +def mul(value, arg): + try: + return float(value) * float(arg) + except (ValueError, TypeError): + return 0 + +@register.filter +def div(value, arg): + try: + if float(arg) == 0: + return 0 + return float(value) / float(arg) + except (ValueError, TypeError): + return 0 \ No newline at end of file diff --git a/core/tests.py b/core/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..0067e45 --- /dev/null +++ b/core/urls.py @@ -0,0 +1,84 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.index, name='index'), + path('select-campaign//', views.select_campaign, name='select_campaign'), + path('voters/', views.voter_list, name='voter_list'), + path('voters/add/', views.voter_add, name='voter_add'), + path('voters/advanced-search/', views.voter_advanced_search, name='voter_advanced_search'), + path('bulk-tasks/', views.bulk_task_list, name='bulk_task_list'), + path('voters/export-csv/', views.export_voters_csv, name='export_voters_csv'), + path('voters/bulk-sms/', views.bulk_send_sms, name='bulk_send_sms'), + path('voters/bulk-email/', views.voter_bulk_send_email, name='voter_bulk_send_email'), + path('voters//', views.voter_detail, name='voter_detail'), + path('voters//edit/', views.voter_edit, name='voter_edit'), + path('voters//delete/', views.voter_delete, name='voter_delete'), + path('voters//geocode/', views.voter_geocode, name='voter_geocode'), + path('voters//schedule-call/', views.schedule_call, name='schedule_call'), + path('voters/bulk-schedule-calls/', views.bulk_schedule_calls, name='bulk_schedule_calls'), + + path('voters//interaction/add/', views.add_interaction, name='add_interaction'), + path('interaction//edit/', views.edit_interaction, name='edit_interaction'), + path('interaction//delete/', views.delete_interaction, name='delete_interaction'), + + path('voters//donation/add/', views.add_donation, name='add_donation'), + path('donation//edit/', views.edit_donation, name='edit_donation'), + path('donation//delete/', views.delete_donation, name='delete_donation'), + + path('voters//likelihood/add/', views.add_likelihood, name='add_likelihood'), + path('likelihood//edit/', views.edit_likelihood, name='edit_likelihood'), + path('likelihood//delete/', views.delete_likelihood, name='delete_likelihood'), + + path('voters//event-participation/add/', views.add_event_participation, name='add_event_participation'), + path('event-participation//edit/', views.edit_event_participation, name='edit_event_participation'), + path('event-participation//delete/', views.delete_event_participation, name='delete_event_participation'), + + # Event Detail and Participant Management + path('events/', views.event_list, name='event_list'), + path('events//', views.event_detail, name='event_detail'), + path('events/add/', views.event_create, name='event_create'), + path('events//edit/', views.event_edit, name='event_edit'), + path('events//participant/add/', views.event_add_participant, name='event_add_participant'), + path('events/participant//edit/', views.event_edit_participant, name='event_edit_participant'), + path('events/participant//delete/', views.event_delete_participant, name='event_delete_participant'), + path('voters/search/json/', views.voter_search_json, name='voter_search_json'), + path('events//import-participants/', views.import_participants, name='import_participants'), + path('events//import-participants/map-fields/', views.import_participants_map_fields, name='import_participants_map_fields'), + path('events//import-participants/process/', views.process_participants_import, name='process_participants_import'), + path('events//import-participants/match/', views.match_participants, name='match_participants'), + + # Volunteer Management + path('interests/add/', views.interest_add, name='interest_add'), + path('interests//delete/', views.interest_delete, name='interest_delete'), + path('volunteers/', views.volunteer_list, name='volunteer_list'), + path('volunteers/add/', views.volunteer_add, name='volunteer_add'), + path('volunteers//', views.volunteer_detail, name='volunteer_detail'), + path('volunteers//delete/', views.volunteer_delete, name='volunteer_delete'), + path('volunteers//assign-event/', views.volunteer_assign_event, name='volunteer_assign_event'), + path('volunteers/assignment//remove/', views.volunteer_remove_event, name='volunteer_remove_event'), + path('volunteers/search/json/', views.volunteer_search_json, name='volunteer_search_json'), + path('volunteers/bulk-sms/', views.volunteer_bulk_send_sms, name='volunteer_bulk_send_sms'), + path('volunteers/bulk-email/', views.volunteer_bulk_send_email, name='volunteer_bulk_send_email'), + path('events//volunteer/add/', views.event_add_volunteer, name='event_add_volunteer'), + path('events/volunteer//delete/', views.event_remove_volunteer, name='event_remove_volunteer'), + + # Door Visits + path('door-visits/', views.door_visits, name='door_visits'), + path('door-visits/log/', views.log_door_visit, name='log_door_visit'), + path('door-visits/history/', views.door_visit_history, name='door_visit_history'), + path('door-visits/neighborhoods/', views.neighborhood_counts, name='neighborhood_counts'), + + # Yard Signs + path('yard-signs/', views.yard_sign_voters, name='yard_sign_voters'), + path('yard-signs/view/', views.view_signs, name='view_signs'), + path('yard-signs/mark-delivered/', views.mark_yard_sign_delivered, name='mark_yard_sign_delivered'), + + # Call Queue + path('call-queue/', views.call_queue, name='call_queue'), + path('call-queue//complete/', views.complete_call, name='complete_call'), + path('call-queue//delete/', views.delete_call, name='delete_call'), + path('call-queue/populate/', views.populate_call_queue, name='populate_call_queue'), + path('call-queue/export/', views.export_call_queue, name='export_call_queue'), + path('profile/', views.profile, name='profile'), +] \ No newline at end of file diff --git a/core/views.py b/core/views.py new file mode 100644 index 0000000..fa44bb3 --- /dev/null +++ b/core/views.py @@ -0,0 +1,2777 @@ +import os +from django.contrib.auth.decorators import login_required +from django.contrib.auth.forms import PasswordChangeForm +from django.utils.dateparse import parse_date +from datetime import datetime, time, timedelta +import base64 +import re +import urllib.parse +import urllib.request +import csv +import io +import json +from django.http import JsonResponse, HttpResponse +from django.urls import reverse +from django.shortcuts import render, redirect, get_object_or_404 +from django.db import transaction +from django.db.models import Q, Sum, Value, DecimalField +from django.contrib import messages +from django.core.paginator import Paginator +from django.conf import settings +from django.db.models.functions import Coalesce +from .models import format_phone_number, Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest, VolunteerRole, ScheduledCall, BulkTask +from .filter_helper import get_filtered_voter_queryset, get_phone_search_filters +from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm, ScheduledCallForm, UserUpdateForm, EventParticipationImportForm, ParticipantMappingForm +from django.core.mail import get_connection, EmailMessage +import logging +import zoneinfo +from django.utils import timezone + +from .permissions import role_required, can_view_donations, can_edit_voter, can_view_volunteers, can_edit_volunteer, can_view_voters, get_user_role, STAFF_ROLES, can_access_call_queue +logger = logging.getLogger(__name__) + +from .task_runners import start_bulk_sms_task + +def _robust_decode(content): + if not content: return "" + for enc in ["utf-8-sig", "utf-8", "iso-8859-1", "windows-1252"]: + try: return content.decode(enc) + except UnicodeDecodeError: continue + return content.decode("utf-8", errors="replace") + + +def _handle_uploaded_file(uploaded_file): + """ + Handles uploaded CSV files, saves them to a temporary file, and extracts headers. + Returns (headers, temp_file_path) or (None, None) if an error occurs. + """ + import tempfile + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp: + for chunk in uploaded_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + + with open(file_path, "r", encoding="utf-8-sig", errors="replace") as f: + # Read first line for headers + content = f.readline() + io_string = io.StringIO(content) + try: + dialect = csv.Sniffer().sniff(content[:1024]) + io_string.seek(0) + reader = csv.reader(io_string, dialect) + except: + io_string.seek(0) + reader = csv.reader(io_string) + headers = [header.strip() for header in next(reader)] + + return headers, file_path + except Exception as e: + logger.error(f"Error processing uploaded file: {e}") + return None, None + except Exception as e: + logger.error(f"Error processing uploaded file: {e}") + return None, None + +def index(request): + """ + Main landing page for Grassroots Campaign Manager. + Displays a list of campaigns if the user is logged in but hasn't selected one. + """ + tenants = Tenant.objects.all() + selected_tenant_id = request.session.get('tenant_id') + selected_tenant = None + metrics = {} + recent_interactions = [] + upcoming_events = [] + + if selected_tenant_id: + selected_tenant = Tenant.objects.filter(id=selected_tenant_id).first() + if selected_tenant: + voters = selected_tenant.voters.filter(is_inactive=False) + total_donations = Donation.objects.filter(voter__tenant=selected_tenant).aggregate(total=Sum('amount'))['total'] or 0 + + # Get or create settings for the tenant + campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=selected_tenant) + donation_goal = campaign_settings.donation_goal + + donation_percentage = 0 + if donation_goal > 0: + donation_percentage = float(round((total_donations / donation_goal) * 100, 1)) + + metrics = { + 'total_registered_voters': voters.count(), + 'total_target_voters': voters.filter(is_targeted=True).count(), + 'total_supporting': voters.filter(candidate_support='supporting').count(), + 'total_target_households': voters.filter(is_targeted=True).exclude(address='').values('address_street').distinct().count(), + 'total_door_visits': voters.filter(door_visit=True).exclude(address='').values('address_street').distinct().count(), + 'total_target_door_visit_households': voters.filter(target_door_visit=True).exclude(address='').values('address_street').distinct().count(), + 'total_has_signs': voters.filter(yard_sign__in=['has', 'has_large']).exclude(address='').values('address_street').distinct().count(), + 'total_wants_signs': voters.filter(yard_sign__in=['wants', 'wants_large']).exclude(address='').values('address_street').distinct().count(), + 'total_has_window_stickers': voters.filter(window_sticker='has').count(), + 'total_wants_window_stickers': voters.filter(window_sticker='wants').count(), + 'total_donations': float(total_donations), + 'donation_goal': float(donation_goal), + 'donation_percentage': donation_percentage, + 'volunteers_count': Volunteer.objects.filter(tenant=selected_tenant).count(), + "total_to_be_called": voters.exclude(Q(phone="") & Q(secondary_phone="")).filter(call_queue_status__in=["to_be_called", "in_call_queue"]).count(), + "total_called": voters.exclude(Q(phone="") & Q(secondary_phone="")).filter(call_queue_status="called").count(), + "total_event_attendees": EventParticipation.objects.filter(event__tenant=selected_tenant).count(), + 'interactions_count': Interaction.objects.filter(voter__tenant=selected_tenant).count(), + 'events_count': Event.objects.filter(tenant=selected_tenant).count(), + 'pending_calls_count': ScheduledCall.objects.filter(tenant=selected_tenant, status='pending').count(), + } + + recent_interactions = Interaction.objects.filter(voter__tenant=selected_tenant).order_by('-date')[:5] + upcoming_events = Event.objects.filter(tenant=selected_tenant, date__gte=timezone.now().date()).order_by('date')[:5] + + context = { + 'tenants': tenants, + 'selected_tenant': selected_tenant, + 'metrics': metrics, + 'recent_interactions': recent_interactions, + 'upcoming_events': upcoming_events, + } + return render(request, 'core/index.html', context) + +def select_campaign(request, tenant_id): + """ + Sets the selected campaign in the session. + """ + tenant = get_object_or_404(Tenant, id=tenant_id) + request.session['tenant_id'] = tenant.id + messages.success(request, f"You are now managing: {tenant.name}") + return redirect('index') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def voter_list(request): + """ + List and search voters. Restricted to selected tenant. + """ + 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) + query = request.GET.get("q") + voters = Voter.objects.filter(tenant=tenant, is_inactive=False).order_by("last_name", "first_name") + + # Filtering based on dashboard metrics + if request.GET.get("is_targeted") == "true": + voters = voters.filter(is_targeted=True) + if request.GET.get("support") == "supporting": + voters = voters.filter(candidate_support="supporting") + if request.GET.get("has_address") == "true": + voters = voters.exclude(address__isnull=True).exclude(address="") + if request.GET.get("visited") == "true": + voters = voters.filter(door_visit=True) + if request.GET.get("yard_sign") == "true": + voters = voters.filter(yard_sign__in=['wants', 'has', 'wants_large', 'has_large']) + if request.GET.get("window_sticker") == "true": + voters = voters.filter(Q(window_sticker="wants") | Q(window_sticker="has")) + if request.GET.get("has_donations") == "true": + voters = voters.filter(donations__isnull=False).distinct() + + if query: + query = query.strip() + search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(voter_id__iexact=query) | get_phone_search_filters(query) + + if "," in query: + parts = [p.strip() for p in query.split(",")] + if len(parts) >= 2: + last_part = parts[0] + first_part = parts[1] + search_filter |= Q(last_name__icontains=last_part, first_name__icontains=first_part) + elif " " in query: + parts = query.split() + if len(parts) >= 2: + first_part = parts[0] + last_part = " ".join(parts[1:]) + search_filter |= Q(first_name__icontains=first_part, last_name__icontains=last_part) + + voters = voters.filter(search_filter).order_by("last_name", "first_name") + + paginator = Paginator(voters, 50) + page_number = request.GET.get('page') + voters_page = paginator.get_page(page_number) + + + context = { + "voters": voters_page, + "query": query, + "selected_tenant": tenant, + "call_form": ScheduledCallForm(tenant=tenant), + } + return render(request, "core/voter_list.html", context) + +@role_required(["admin", "campaign_manager", "campaign_staff", "system_admin", "campaign_admin"], permission="core.add_voter") +def voter_add(request): + """ + Add a new voter to the campaign. + """ + 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": + form = VoterForm(request.POST, user=request.user, tenant=tenant) + if form.is_valid(): + voter = form.save(commit=False) + voter.tenant = tenant + voter.save() + messages.success(request, f"Voter {voter.first_name} {voter.last_name} added successfully.") + return redirect("voter_detail", voter_id=voter.id) + else: + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f"Error in {field}: {error}") + else: + form = VoterForm(user=request.user, tenant=tenant) + + context = { + "form": form, + "selected_tenant": tenant, + "is_create": True, + } + return render(request, "core/voter_add.html", context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def voter_detail(request, voter_id): + """ + 360-degree view of a voter. + """ + 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) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + other_voters_at_address = [] + if voter.address_street and voter.city: + other_voters_at_address = Voter.objects.filter( + tenant=tenant, + address_street=voter.address_street, + city=voter.city, + state=voter.state, + zip_code=voter.zip_code + ).exclude(id=voter.id) + + context = { + 'voter': voter, + "other_voters_at_address": other_voters_at_address, + 'selected_tenant': tenant, + 'voting_records': voter.voting_records.all().order_by('-election_date'), + 'donations': voter.donations.all().order_by('-date'), + 'interactions': voter.interactions.all().order_by('-date'), + 'event_participations': voter.event_participations.all().order_by('-event__date'), + 'likelihoods': voter.likelihoods.all(), + 'voter_form': VoterForm(instance=voter, user=request.user, tenant=tenant), + 'interaction_form': InteractionForm(tenant=tenant), + 'donation_form': DonationForm(tenant=tenant), + 'likelihood_form': VoterLikelihoodForm(tenant=tenant), + 'event_participation_form': EventParticipationForm(tenant=tenant), + 'call_form': ScheduledCallForm(tenant=tenant), + } + return render(request, 'core/voter_detail.html', context) + +@role_required(["admin", "campaign_manager", "campaign_staff", "system_admin", "campaign_admin"], permission="core.change_voter") +def voter_edit(request, voter_id): + """ + Update voter core demographics. + """ + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + # Log incoming coordinate data for debugging + lat_raw = request.POST.get('latitude') + lon_raw = request.POST.get('longitude') + logger.info(f"Voter Edit POST: lat={lat_raw}, lon={lon_raw}") + + form = VoterForm(request.POST, instance=voter, user=request.user, tenant=tenant) + if form.is_valid(): + # If coordinates were provided in POST, ensure they are applied to the instance + # This handles cases where readonly or other widget settings might interfere + voter = form.save(commit=False); + if lat_raw: + try: + voter.latitude = lat_raw + except: pass + if lon_raw: + try: + voter.longitude = lon_raw + except: pass + + voter.save() + messages.success(request, "Voter profile updated successfully.") + else: + logger.warning(f"Voter Edit Form Invalid: {form.errors}") + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f"Error in {field}: {error}") + return redirect('voter_detail', voter_id=voter.id) + +def add_interaction(request, voter_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = InteractionForm(request.POST, tenant=tenant) + if form.is_valid(): + interaction = form.save(commit=False) + interaction.voter = voter + interaction.save() + messages.success(request, "Interaction added.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter.id}) + '?active_tab=interactions') + +def edit_interaction(request, interaction_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + interaction = get_object_or_404(Interaction, id=interaction_id, voter__tenant=tenant) + + if request.method == 'POST': + form = InteractionForm(request.POST, instance=interaction, tenant=tenant) + if form.is_valid(): + form.save() + messages.success(request, "Interaction updated.") + return redirect(reverse('voter_detail', kwargs={'voter_id': interaction.voter.id}) + '?active_tab=interactions') + +def delete_interaction(request, interaction_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + interaction = get_object_or_404(Interaction, id=interaction_id, voter__tenant=tenant) + voter_id = interaction.voter.id + + if request.method == 'POST': + interaction.delete() + messages.success(request, "Interaction deleted.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter_id}) + '?active_tab=interactions') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_donation') +def add_donation(request, voter_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = DonationForm(request.POST, tenant=tenant) + if form.is_valid(): + donation = form.save(commit=False) + donation.voter = voter + donation.save() + messages.success(request, "Donation recorded.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter.id}) + '?active_tab=donations') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_donation') +def edit_donation(request, donation_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + donation = get_object_or_404(Donation, id=donation_id, voter__tenant=tenant) + + if request.method == 'POST': + form = DonationForm(request.POST, instance=donation, tenant=tenant) + if form.is_valid(): + form.save() + messages.success(request, "Donation updated.") + return redirect(reverse('voter_detail', kwargs={'voter_id': donation.voter.id}) + '?active_tab=donations') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.delete_donation') +def delete_donation(request, donation_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + donation = get_object_or_404(Donation, id=donation_id, voter__tenant=tenant) + voter_id = donation.voter.id + + if request.method == 'POST': + donation.delete() + messages.success(request, "Donation deleted.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter_id}) + '?active_tab=donations') + +def add_likelihood(request, voter_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = VoterLikelihoodForm(request.POST, tenant=tenant) + if form.is_valid(): + likelihood = form.save(commit=False) + likelihood.voter = voter + # Handle potential duplicate election_type + VoterLikelihood.objects.filter(voter=voter, election_type=likelihood.election_type).delete() + likelihood.save() + messages.success(request, "Likelihood updated.") + return redirect('voter_detail', voter_id=voter.id) + +def edit_likelihood(request, likelihood_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + likelihood = get_object_or_404(VoterLikelihood, id=likelihood_id, voter__tenant=tenant) + + if request.method == 'POST': + form = VoterLikelihoodForm(request.POST, instance=likelihood, tenant=tenant) + if form.is_valid(): + election_type = form.cleaned_data['election_type'] + if VoterLikelihood.objects.filter(voter=likelihood.voter, election_type=election_type).exclude(id=likelihood.id).exists(): + VoterLikelihood.objects.filter(voter=likelihood.voter, election_type=election_type).exclude(id=likelihood.id).delete() + form.save() + messages.success(request, "Likelihood updated.") + return redirect('voter_detail', voter_id=likelihood.voter.id) + +def delete_likelihood(request, likelihood_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + likelihood = get_object_or_404(VoterLikelihood, id=likelihood_id, voter__tenant=tenant) + voter_id = likelihood.voter.id + + if request.method == 'POST': + likelihood.delete() + messages.success(request, "Likelihood record deleted.") + return redirect('voter_detail', voter_id=voter_id) + +def add_event_participation(request, voter_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = EventParticipationForm(request.POST, tenant=tenant) + if form.is_valid(): + participation = form.save(commit=False) + participation.voter = voter + # Avoid duplicate participation + if not EventParticipation.objects.filter(voter=voter, event=participation.event).exists(): + participation.save() + messages.success(request, "Event participation added.") + else: + messages.warning(request, "Voter is already participating in this event.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter.id}) + '?active_tab=events') + +def edit_event_participation(request, participation_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant) + + if request.method == 'POST': + form = EventParticipationForm(request.POST, instance=participation, tenant=tenant) + if form.is_valid(): + event = form.cleaned_data['event'] + if EventParticipation.objects.filter(voter=participation.voter, event=event).exclude(id=participation.id).exists(): + messages.warning(request, "Voter is already participating in that event.") + else: + form.save() + messages.success(request, "Event participation updated.") + return redirect(reverse('voter_detail', kwargs={'voter_id': participation.voter.id}) + '?active_tab=events') + +def delete_event_participation(request, participation_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant) + voter_id = participation.voter.id + + if request.method == 'POST': + participation.delete() + messages.success(request, "Event participation removed.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter_id}) + '?active_tab=events') + +def voter_geocode(request, voter_id): + """ + Manually trigger geocoding for a voter, potentially using values from the request. + """ + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + street = request.POST.get('address_street', voter.address_street) + city = request.POST.get('city', voter.city) + state = request.POST.get('state', voter.state) + zip_code = request.POST.get('zip_code', voter.zip_code) + + parts = [street, city, state, zip_code] + full_address = ", ".join([p for p in parts if p]) + + # Use a temporary instance to avoid saving until the user clicks "Save" in the modal + temp_voter = Voter( + address_street=street, + city=city, + state=state, + zip_code=zip_code, + address=full_address + ) + success, error_msg = temp_voter.geocode_address() + + if success: + return JsonResponse({ + 'success': True, + 'latitude': str(temp_voter.latitude), + 'longitude': str(temp_voter.longitude), + 'address': full_address + }) + else: + return JsonResponse({ + 'success': False, + 'error': f"Geocoding failed: {error_msg or 'No results found.'}" + }) + + return JsonResponse({'success': False, 'error': 'Invalid request method.'}) + +@role_required(["admin", "campaign_manager", "campaign_staff", "system_admin", "campaign_admin"], permission="core.view_bulktask") +def bulk_task_list(request): + 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) + bulk_tasks = BulkTask.objects.filter(tenant=tenant).order_by("-created_at") + + paginator = Paginator(bulk_tasks, 20) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + return render(request, "core/bulk_task_list.html", { + "bulk_tasks": page_obj, + "selected_tenant": tenant, + }) + +@role_required(["admin", "campaign_manager", "campaign_staff", "system_admin", "campaign_admin"], permission="core.view_voter") +def voter_advanced_search(request): + """ + Advanced search for voters with multiple filters. + """ + 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) + voters = Voter.objects.filter(tenant=tenant, is_inactive=False).order_by("last_name", "first_name") + + form = AdvancedVoterSearchForm(request.GET) + 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('ever_had_yard_sign'): + voters = voters.filter(ever_had_yard_sign=(data['ever_had_yard_sign'] == 'True')) + if data.get('ever_had_large_sign'): + voters = voters.filter(ever_had_large_sign=(data['ever_had_large_sign'] == '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) + + paginator = Paginator(voters, 50) + page_number = request.GET.get('page') + voters_page = paginator.get_page(page_number) + + context = { + 'form': form, + 'voters': voters_page, + 'selected_tenant': tenant, + 'call_form': ScheduledCallForm(tenant=tenant), + } + return render(request, "core/voter_advanced_search.html", context) +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +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, is_inactive=False, id__in=voter_ids) + + 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', '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, + 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', + 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 + +def voter_delete(request, voter_id): + """ + Delete a voter profile. + """ + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + voter.delete() + messages.success(request, "Voter profile deleted successfully.") + return redirect('voter_list') + + return redirect('voter_detail', voter_id=voter.id) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def bulk_send_sms(request): + """ + Sends bulk SMS to selected voters using Twilio API in a background thread. + """ + 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) + 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' + voter_ids = request.POST.getlist('selected_voters') + + search_filters = {} + if select_all_results: + for key, value in request.POST.items(): + if key.startswith("filter_") and value: + search_filters[key.replace("filter_", "")] = value + + start_bulk_sms_task(tenant, message_body, voter_ids, select_all_results, search_filters, object_type='voter') + + messages.success(request, "Bulk SMS process started in the background. You can continue working while it processes.") + return redirect('voter_advanced_search') + +def event_list(request): + 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) + events = Event.objects.filter(tenant=tenant).order_by('-date') + + + context = { + 'tenant': tenant, + 'events': events, + 'selected_tenant': tenant, + } + return render(request, 'core/event_list.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_event') +def event_detail(request, event_id): + 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) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + participations = event.participations.all().select_related('voter', 'participation_status').order_by('voter__last_name', 'voter__first_name') + + # Get assigned volunteers + volunteers = event.volunteer_assignments.all().select_related('volunteer').order_by('volunteer__last_name', 'volunteer__first_name') + + # Form for adding a new participant + add_form = EventParticipantAddForm(tenant=tenant) + # Form for adding a new volunteer + default_role = event.default_volunteer_role + if not default_role and event.event_type: + default_role = event.event_type.default_volunteer_role + add_volunteer_form = VolunteerEventAddForm(tenant=tenant, initial={'role_type': default_role}) + + participation_statuses = ParticipationStatus.objects.filter(tenant=tenant, is_active=True) + + + context = { + 'tenant': tenant, + 'selected_tenant': tenant, + 'event': event, + 'participations': participations, + 'volunteers': volunteers, + 'campaign_settings': getattr(tenant, 'settings', None), + 'add_form': add_form, + 'add_volunteer_form': add_volunteer_form, + 'participation_statuses': participation_statuses, + } + return render(request, 'core/event_detail.html', context) + +def event_add_participant(request, event_id): + tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=tenant_id) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + if request.method == 'POST': + form = EventParticipantAddForm(request.POST, tenant=tenant) + if form.is_valid(): + participation = form.save(commit=False) + participation.event = event + if not EventParticipation.objects.filter(event=event, voter=participation.voter).exists(): + participation.save() + messages.success(request, f"{participation.voter} added to event.") + else: + messages.warning(request, "Voter is already a participant.") + else: + messages.error(request, "Error adding participant.") + + return redirect('event_detail', event_id=event.id) + +def event_edit_participant(request, participation_id): + tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=tenant_id) + participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant) + + if request.method == 'POST': + status_id = request.POST.get('participation_status') + if status_id: + status = get_object_or_404(ParticipationStatus, id=status_id, tenant=tenant) + participation.participation_status = status + participation.save() + messages.success(request, f"Participation updated for {participation.voter}.") + else: + messages.error(request, "Invalid status.") + + return redirect('event_detail', event_id=participation.event.id) + +def event_delete_participant(request, participation_id): + tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=tenant_id) + participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant) + event_id = participation.event.id + voter_name = str(participation.voter) + participation.delete() + messages.success(request, f"{voter_name} removed from event.") + return redirect('event_detail', event_id=event_id) + +def voter_search_json(request): + """ + JSON endpoint for voter search, used by autocomplete/search UI. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + return JsonResponse({"results": []}) + + query = request.GET.get("q", "").strip() + if len(query) < 2: + return JsonResponse({"results": []}) + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voters = Voter.objects.filter(tenant=tenant, is_inactive=False) + + search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(voter_id__iexact=query) | get_phone_search_filters(query) + + if "," in query: + parts = [p.strip() for p in query.split(",") ] + if len(parts) >= 2: + search_filter |= Q(last_name__icontains=parts[0], first_name__icontains=parts[1]) + + results = voters.filter(search_filter).order_by("last_name", "first_name")[:20] + + data = [] + for v in results: + data.append({ + "id": v.id, + "text": f"{v.last_name}, {v.first_name} ({v.voter_id})", + "address": v.address, + "phone": v.phone + }) + + return JsonResponse({"results": data}) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_volunteer') +def volunteer_list(request): + 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) + volunteers = Volunteer.objects.filter(tenant=tenant).order_by('last_name', 'first_name') + + # Simple search + query = request.GET.get("q") + if query: + volunteers = volunteers.filter( + Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(email__icontains=query) | get_phone_search_filters(query, secondary=False) + ) + + # Interest filter + interest_id = request.GET.get("interest") + if interest_id: + volunteers = volunteers.filter(interests__id=interest_id).distinct() + + interests = Interest.objects.filter(tenant=tenant).order_by('name') + + paginator = Paginator(volunteers, 50) + page_number = request.GET.get('page') + volunteers_page = paginator.get_page(page_number) + + context = { + 'tenant': tenant, + 'selected_tenant': tenant, + 'volunteers': volunteers_page, + 'query': query, + 'interests': interests, + 'selected_interest': interest_id, + } + return render(request, 'core/volunteer_list.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_volunteer') +def volunteer_add(request): + 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': + form = VolunteerForm(request.POST, tenant=tenant) + if form.is_valid(): + volunteer = form.save(commit=False) + volunteer.tenant = tenant + volunteer.save() + form.save_m2m() # Save interests + messages.success(request, f"Volunteer {volunteer} added successfully.") + return redirect('volunteer_detail', volunteer_id=volunteer.id) + else: + form = VolunteerForm(tenant=tenant) + + + context = { + 'form': form, + 'tenant': tenant, + 'selected_tenant': tenant, + 'is_create': True, + } + return render(request, 'core/volunteer_detail.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_volunteer') +def volunteer_detail(request, volunteer_id): + 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) + volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant) + + if request.method == 'POST': + form = VolunteerForm(request.POST, instance=volunteer, tenant=tenant) + if form.is_valid(): + form.save() + messages.success(request, f"Volunteer {volunteer} updated successfully.") + return redirect('volunteer_detail', volunteer_id=volunteer.id) + else: + form = VolunteerForm(instance=volunteer, tenant=tenant) + + assignments = volunteer.event_assignments.all().select_related('event') + assign_form = VolunteerEventForm(tenant=tenant) + + + context = { + 'volunteer': volunteer, + 'form': form, + 'assignments': assignments, + 'assign_form': assign_form, + 'tenant': tenant, + 'selected_tenant': tenant, + } + return render(request, 'core/volunteer_detail.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.delete_volunteer') +def volunteer_delete(request, volunteer_id): + 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) + volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant) + + if request.method == 'POST': + volunteer.delete() + messages.success(request, "Volunteer deleted.") + return redirect('volunteer_list') + return redirect('volunteer_detail', volunteer_id=volunteer.id) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_volunteer') +def volunteer_assign_event(request, volunteer_id): + 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) + volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant) + + if request.method == 'POST': + form = VolunteerEventForm(request.POST, tenant=tenant) + if form.is_valid(): + assignment = form.save(commit=False) + assignment.volunteer = volunteer + assignment.save() + messages.success(request, f"Assigned to {assignment.event}.") + else: + messages.error(request, "Error assigning to event.") + + return redirect('volunteer_detail', volunteer_id=volunteer.id) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_volunteer') +def volunteer_remove_event(request, assignment_id): + 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) + assignment = get_object_or_404(VolunteerEvent, id=assignment_id, volunteer__tenant=tenant) + volunteer_id = assignment.volunteer.id + assignment.delete() + messages.success(request, "Assignment removed.") + return redirect('volunteer_detail', volunteer_id=volunteer_id) + +def interest_add(request): + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + return JsonResponse({'success': False, 'error': 'No campaign selected.'}) + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + if request.method == 'POST': + name = request.POST.get('name', '').strip() + if name: + interest, created = Interest.objects.get_or_create(tenant=tenant, name=name) + if created: + return JsonResponse({'success': True, 'id': interest.id, 'name': interest.name}) + else: + return JsonResponse({'success': False, 'error': 'Interest already exists.'}) + return JsonResponse({'success': False, 'error': 'Invalid request.'}) + +def interest_delete(request, interest_id): + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + return JsonResponse({'success': False, 'error': 'No campaign selected.'}) + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + interest = get_object_or_404(Interest, id=interest_id, tenant=tenant) + + if request.method == 'POST': + interest.delete() + return JsonResponse({'success': True}) + return JsonResponse({'success': False, 'error': 'Invalid request.'}) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_event') +def event_create(request): + 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": + form = EventForm(request.POST, tenant=tenant) + if form.is_valid(): + event = form.save(commit=False) + event.tenant = tenant + event.save() + messages.success(request, "Event created successfully.") + return redirect("event_detail", event_id=event.id) + else: + form = EventForm(tenant=tenant) + + context = { + "form": form, + "tenant": tenant, + "selected_tenant": tenant, + "is_create": True, + } + return render(request, "core/event_edit.html", context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_event') +def event_edit(request, event_id): + 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) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + if request.method == 'POST': + form = EventForm(request.POST, instance=event, tenant=tenant) + if form.is_valid(): + form.save() + messages.success(request, "Event updated successfully.") + return redirect('event_detail', event_id=event.id) + else: + form = EventForm(instance=event, tenant=tenant) + + context = { + 'form': form, + 'event': event, + 'tenant': tenant, + 'selected_tenant': tenant, + } + return render(request, 'core/event_edit.html', context) + +@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event') +def import_participants(request, event_id): + 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) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + if request.method == 'POST': + form = EventParticipationImportForm(request.POST, request.FILES, event=event) + if form.is_valid(): + uploaded_file = form.cleaned_data['file'] + + if not uploaded_file.name.lower().endswith('.csv'): + messages.error(request, "Only CSV files are supported. Please save your file as CSV and try again.") + return redirect('event_detail', event_id=event.id) + + headers, file_path = _handle_uploaded_file(uploaded_file) + + if headers and file_path: + request.session['imported_participants_data'] = { + 'event_id': event.id, + 'headers': headers, + 'file_path': file_path, + 'file_name': uploaded_file.name + } + messages.info(request, f"File '{uploaded_file.name}' uploaded successfully. Now map the fields.") + return redirect('import_participants_map_fields', event_id=event.id) + else: + messages.error(request, "Could not read headers from the uploaded file. Please ensure it's a valid CSV.") + else: + messages.error(request, "No file was uploaded or an error occurred with the form.") + # For debugging, you might want to log form.errors + logger.error(f"EventParticipationImportForm errors: {form.errors}") + + return redirect('event_detail', event_id=event.id) + +@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event') +def import_participants_map_fields(request, event_id): + 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) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + imported_data = request.session.get('imported_participants_data') + if not imported_data or imported_data['event_id'] != event.id: + messages.error(request, "No data found to map. Please upload a file first.") + return redirect('event_detail', event_id=event.id) + + headers = imported_data['headers'] + file_name = imported_data['file_name'] + + if request.method == 'POST': + form = ParticipantMappingForm(request.POST, headers=headers, tenant=tenant) + if form.is_valid(): + email_column = form.cleaned_data['email_column'] + name_column = form.cleaned_data['name_column'] # Retrieve name column + phone_column = form.cleaned_data['phone_column'] # Retrieve phone column + participation_status_column = form.cleaned_data['participation_status_column'] + default_participation_status = form.cleaned_data['default_participation_status'] # Retrieve default status + + # Store mapping in session and proceed to processing + request.session['imported_participants_data']['email_column'] = email_column + request.session['imported_participants_data']['name_column'] = name_column # Store name column + request.session['imported_participants_data']['phone_column'] = phone_column # Store phone column + request.session['imported_participants_data']['participation_status_column'] = participation_status_column + request.session['imported_participants_data']['default_participation_status_id'] = default_participation_status.id if default_participation_status else None + request.session.modified = True # Ensure session is saved + logger.debug(f"Session after mapping: {request.session.get('imported_participants_data')}") # Added debug logging + + return redirect('process_participants_import', event_id=event.id) + else: + logger.error(f"ParticipantMappingForm errors: {form.errors}") # Added logging + messages.error(request, "Please correct the mapping errors.") + else: + form = ParticipantMappingForm(headers=headers, tenant=tenant) + + context = { + 'event': event, + 'form': form, + 'file_name': file_name, + 'headers': headers, + } + return render(request, 'core/event_participant_map_fields.html', context) + +@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event') +def process_participants_import(request, event_id): + logger.debug(f"Session at start of process_participants_import: {request.session.get('imported_participants_data')}") # Added debug logging + 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) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + imported_data = request.session.get('imported_participants_data') + if not imported_data or imported_data['event_id'] != event.id: + messages.error(request, "No data found to process. Please upload and map a file first.") + return redirect('event_detail', event_id=event.id) + + headers = imported_data["headers"] + file_path = imported_data.get("file_path") + + if not file_path or not os.path.exists(file_path): + messages.error(request, "No data found to process. Please upload and map a file first.") + return redirect("event_detail", event_id=event.id) + + data_rows = [] + try: + with open(file_path, "r", encoding="utf-8-sig", errors="replace") as f: + # Try to determine the CSV dialect + sample = f.read(2048) + f.seek(0) + try: + dialect = csv.Sniffer().sniff(sample) + reader = csv.reader(f, dialect) + except Exception: + reader = csv.reader(f) + + # Skip the header row as it is already stored in session + next(reader, None) + + for row in reader: + if any(row): # Skip empty rows + data_rows.append(row) + except Exception as e: + logger.error(f"Error reading import file: {e}") + messages.error(request, f"Error reading the uploaded file: {e}") + return redirect("event_detail", event_id=event.id) + + # Safely get column names from session, handle cases where they might be missing + email_column = imported_data.get('email_column') + name_column = imported_data.get('name_column') # Retrieve name column + phone_column = imported_data.get('phone_column') # Retrieve phone column + participation_status_column = imported_data.get('participation_status_column') + default_participation_status_id = imported_data.get('default_participation_status_id') + + logger.debug(f"process_participants_import - name_column from session: {name_column}") # DEBUG LOGGING + logger.debug(f"process_participants_import - phone_column from session: {phone_column}") # DEBUG LOGGING + + # Validate that required columns are present + if not email_column: + messages.error(request, "Email column mapping is missing. Please go back and map the fields.") + return redirect('import_participants_map_fields', event_id=event.id) + + matched_count = 0 + unmatched_participants = [] + + # Get all active participation statuses for the tenant + participation_statuses_map = {status.name.lower(): status for status in ParticipationStatus.objects.filter(tenant=tenant, is_active=True)} + default_status_obj = None + if default_participation_status_id: + default_status_obj = get_object_or_404(ParticipationStatus, id=default_participation_status_id, tenant=tenant) + + voters_by_email = {v.email.lower(): v for v in Voter.objects.filter(tenant=tenant).exclude(email="")} + existing_participations = set(EventParticipation.objects.filter(event=event).values_list("voter_id", flat=True)) + with transaction.atomic(): + for row_index, row in enumerate(data_rows): + row_dict = dict(zip(headers, row)) + email = row_dict.get(email_column) + phone = row_dict.get(phone_column) if phone_column else None + + # DEBUG LOGGING: Log the value of the name column for each row + if name_column: + logger.debug(f"process_participants_import - Row {row_index}: name_column='{name_column}', name_value='{row_dict.get(name_column)}'") + if phone_column: + logger.debug(f"process_participants_import - Row {row_index}: phone_column='{phone_column}', phone_value='{phone}'") + + participation_status_name = row_dict.get(participation_status_column) + + if not email: + logger.warning(f"Row {row_index+2}: Skipping due to missing email.") + continue + + voter = voters_by_email.get(email.lower()) if email else None + + if voter: + # If phone is mapped and present, and not already associated with voter, update it + if phone and voter.phone != phone and voter.secondary_phone != phone: + voter.phone = phone + voter.phone_type = 'cell' + voter.save() + + # Match found, add as participant if not already existing + status = participation_statuses_map.get(participation_status_name.lower()) if participation_status_name else default_status_obj + if not status: + status, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Unknown', is_active=True) # Fallback to unknown if no default and no match + + if voter.id not in existing_participations: + EventParticipation.objects.create( + event=event, + voter=voter, + participation_status=status + ) + matched_count += 1 + else: + logger.info(f"Voter {voter.email} is already a participant in event {event.name}. Skipping.") + else: + # No match found, add to unmatched list + unmatched_participants.append({ + 'row_data': row_dict, + 'original_row_index': row_index, # Keep original index for reference if needed + }) + + # Cleanup temporary file + if file_path and os.path.exists(file_path): + try: + os.remove(file_path) + except Exception as e: + logger.error(f"Error removing temporary import file {file_path}: {e}") + + if unmatched_participants: + # Store unmatched data in session for manual matching + request.session['unmatched_participants_data'] = { + 'event_id': event.id, + 'unmatched_rows': unmatched_participants, + 'file_name': imported_data['file_name'], + 'email_column': email_column, + 'name_column': name_column, # Pass name column to unmatched data + 'phone_column': phone_column, # Pass phone column to unmatched data + 'participation_status_column': participation_status_column, + 'default_participation_status_id': default_participation_status_id, + } + messages.warning(request, f"{len(unmatched_participants)} participants could not be automatically matched. Please match them manually.") + return redirect('match_participants', event_id=event.id) + else: + messages.success(request, f"Successfully imported {matched_count} participants for event {event.name}.") + del request.session['imported_participants_data'] # Clean up session + + return redirect('event_detail', event_id=event.id) + +@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event') +def match_participants(request, event_id): + 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) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + unmatched_data = request.session.get('unmatched_participants_data') + if not unmatched_data or unmatched_data['event_id'] != event.id: + messages.error(request, "No unmatched participant data found. Please try importing again.") + return redirect('event_detail', event_id=event.id) + + unmatched_rows = unmatched_data['unmatched_rows'] + file_name = unmatched_data['file_name'] + email_column = unmatched_data['email_column'] + name_column = unmatched_data['name_column'] # Retrieve name column + phone_column = unmatched_data.get('phone_column') # Retrieve phone column + participation_status_column = unmatched_data['participation_status_column'] + default_participation_status_id = unmatched_data.get('default_participation_status_id') + + logger.debug(f"match_participants context: email_column={email_column}, name_column={name_column}, phone_column={phone_column}, participation_status_column={participation_status_column}") # DEBUG LOGGING + + # DEBUG LOGGING: Log the value of the name column for each unmatched row + for index, row_data in enumerate(unmatched_rows): + name_value = row_data.get('row_data', {}).get(name_column) + phone_value = row_data.get('row_data', {}).get(phone_column) + logger.debug(f"match_participants - Unmatched row {index}: name_column='{name_column}', name_value='{name_value}', phone_column='{phone_column}', phone_value='{phone_value}'") + + if request.method == 'POST': + matched_count = 0 + current_unmatched_rows = [] # To store rows that are still unmatched after POST + + default_status_obj = None + if default_participation_status_id: + default_status_obj = get_object_or_404(ParticipationStatus, id=default_participation_status_id, tenant=tenant) + + for index, row_data in enumerate(unmatched_rows): + original_row_index = row_data['original_row_index'] + posted_voter_id = request.POST.get(f'voter_match_{original_row_index}') + + if posted_voter_id: + # Manual match provided + voter = get_object_or_404(Voter, id=posted_voter_id, tenant=tenant) + + # Update voter's email + voter_email_from_file = row_data['row_data'].get(email_column) + if voter_email_from_file and voter.email != voter_email_from_file: + voter.email = voter_email_from_file + voter.save() + + # Update voter's phone if mapped and different + voter_phone_from_file = row_data['row_data'].get(phone_column) + if voter_phone_from_file and voter.phone != voter_phone_from_file and voter.secondary_phone != voter_phone_from_file: + voter.phone = voter_phone_from_file + voter.phone_type = 'cell' + voter.save() + + # Add as participant if not already existing + participation_status_name = row_data['row_data'].get(participation_status_column) + status = None + if participation_status_name: + status = ParticipationStatus.objects.filter(tenant=tenant, name__iexact=participation_status_name).first() + + if not status: + status = default_status_obj + + if not status: + status, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Unknown', is_active=True) + + if not EventParticipation.objects.filter(event=event, voter=voter).exists(): + EventParticipation.objects.create( + event=event, + voter=voter, + participation_status=status + ) + matched_count += 1 + else: + messages.warning(request, f"Voter {voter.email} is already a participant in event {event.name}. Skipping manual match for this voter.") + else: + # Still unmatched, keep for re-display + current_unmatched_rows.append(row_data) + + if matched_count > 0: + messages.success(request, f"Successfully matched {matched_count} participants.") + + if current_unmatched_rows: + request.session['unmatched_participants_data']['unmatched_rows'] = current_unmatched_rows + messages.warning(request, f"{len(current_unmatched_rows)} participants still need manual matching.") + return redirect('match_participants', event_id=event.id) + else: + messages.success(request, "All participants have been matched.") + del request.session['unmatched_participants_data'] # Clean up session + del request.session['imported_participants_data'] # Also clean up this + + return redirect('event_detail', event_id=event.id) + + context = { + 'event': event, + 'unmatched_rows': unmatched_rows, + 'file_name': file_name, + 'email_column': email_column, + 'name_column': name_column, # Pass name column to template + 'phone_column': phone_column, # Pass phone column to template + 'participation_status_column': participation_status_column, + } + return render(request, 'core/event_participant_matching.html', context) + +def volunteer_search_json(request): + """ + JSON endpoint for volunteer search, used by autocomplete/search UI. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + return JsonResponse({"results": []}) + + query = request.GET.get("q", "").strip() + if len(query) < 2: + return JsonResponse({"results": []}) + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + volunteers = Volunteer.objects.filter(tenant=tenant) + + search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(email__icontains=query) | get_phone_search_filters(query, secondary=False) | get_phone_search_filters(query, secondary=False) + + results = volunteers.filter(search_filter).order_by("last_name", "first_name")[:20] + + data = [] + for v in results: + data.append({ + "id": v.id, + "text": f"{v.first_name} {v.last_name} ({v.email})", + "phone": v.phone + }) + + return JsonResponse({"results": data}) + +def event_add_volunteer(request, event_id): + tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=tenant_id) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + if request.method == 'POST': + form = VolunteerEventAddForm(request.POST, tenant=tenant) + if form.is_valid(): + assignment = form.save(commit=False) + assignment.event = event + if not VolunteerEvent.objects.filter(event=event, volunteer=assignment.volunteer).exists(): + assignment.save() + messages.success(request, f"{assignment.volunteer} added as volunteer.") + else: + messages.warning(request, "Volunteer is already assigned to this event.") + else: + messages.error(request, "Error adding volunteer.") + + return redirect('event_detail', event_id=event.id) + +def event_remove_volunteer(request, assignment_id): + tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=tenant_id) + assignment = get_object_or_404(VolunteerEvent, id=assignment_id, event__tenant=tenant) + event_id = assignment.event.id + volunteer_name = str(assignment.volunteer) + assignment.delete() + messages.success(request, f"{volunteer_name} removed from event volunteers.") + return redirect('event_detail', event_id=event_id) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_volunteer') +def volunteer_bulk_send_sms(request): + """ + Sends bulk SMS to selected volunteers using Twilio API in a background thread. + """ + if request.method != 'POST': + return redirect('volunteer_list') + + 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) + message_body = request.POST.get('message_body') + + if not message_body: + messages.error(request, "Message body cannot be empty.") + return redirect('volunteer_list') + + volunteer_ids = request.POST.getlist('selected_volunteers') + + start_bulk_sms_task(tenant, message_body, volunteer_ids, False, None, object_type='volunteer') + + messages.success(request, "Bulk SMS process started in the background.") + return redirect('volunteer_list') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def door_visits(request): + """ + Manage door knocking visits. Groups unvisited targeted voters by household. + Optimized to handle large datasets more efficiently. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + + city_filter = request.GET.get("city", "").strip() + district_filter = request.GET.get('district', '').strip() + neighborhood_filter = request.GET.get('neighborhood', '').strip() + address_filter = request.GET.get('address', '').strip() + + voters = Voter.objects.filter(tenant=tenant, is_inactive=False, door_visit=False, target_door_visit=True) + + if city_filter: + voters = voters.filter(city__icontains=city_filter) + if district_filter: + voters = voters.filter(district=district_filter) + if neighborhood_filter: + voters = voters.filter(neighborhood__icontains=neighborhood_filter) + if address_filter: + voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter)) + + # Optimization: Use iterator and lighter data structure + # We only fetch needed fields. Compound index helps here. + voters_iterator = voters.values( + 'id', 'first_name', 'last_name', 'address_street', 'city', 'state', + 'zip_code', 'neighborhood', 'district', 'latitude', 'longitude', 'phone' + ).iterator(chunk_size=2000) + + households_dict = {} + for v in voters_iterator: + street = (v['address_street'] or "").strip() + city = (v['city'] or "").strip() + state = (v['state'] or "").strip() + zip_code = (v['zip_code'] or "").strip() + + key = (street.lower(), city.lower(), state.lower(), zip_code.lower()) + + if key not in households_dict: + street_number = "" + street_name = street + match_street = re.match(r'^(\d+)\s+(.*)$', street) + if match_street: + street_number = match_street.group(1) + street_name = match_street.group(2) + + try: + street_number_sort = int(street_number) + except (ValueError, TypeError): + street_number_sort = 0 + + households_dict[key] = { + 'address_street': street, + 'city': city, + 'state': state, + 'zip_code': zip_code, + 'neighborhood': (v['neighborhood'] or "").strip(), + 'district': (v['district'] or "").strip(), + 'latitude': float(v['latitude']) if v['latitude'] else None, + 'longitude': float(v['longitude']) if v['longitude'] else None, + 'street_name_sort': street_name.lower(), + 'street_number_sort': street_number_sort, + 'target_voters': [], + 'voters_json': [] + } + else: + if not households_dict[key]['neighborhood'] and v['neighborhood']: + households_dict[key]['neighborhood'] = v['neighborhood'].strip() + if not households_dict[key]['district'] and v['district']: + households_dict[key]['district'] = v['district'].strip() + + households_dict[key]['target_voters'].append(v) + phone_display = format_phone_number(v['phone']) if v['phone'] else "" + households_dict[key]['voters_json'].append({'id': v['id'], 'name': f"{v['first_name']} {v['last_name']} - {phone_display}" }) + + households_list = list(households_dict.values()) + del households_dict # Free memory + + households_list.sort(key=lambda x: ( + not bool(x['neighborhood']), + (x['neighborhood'] or '').lower(), + x['street_name_sort'], + x['street_number_sort'] + )) + + # Optimization: Map data should only be for visible results or limited + # We still keep the limit of 3000 for map + map_data = [] + # Always try to show up to 3000 markers instead of showing 0 if count > 3000 + for h in households_list[:3000]: + if h['latitude'] and h['longitude']: + map_data.append({ + 'lat': h['latitude'], + 'lng': h['longitude'], + 'address_street': h['address_street'], + 'city': h['city'], + 'state': h['state'], + 'zip_code': h['zip_code'], + 'address': f"{h['address_street']}, {h['city']}", + 'voters': ", ".join([f"{v['first_name']} {v['last_name']}" for v in h['target_voters']]) + }) + + for h in households_list: + h['voters_json_str'] = json.dumps(h['voters_json']) + + paginator = Paginator(households_list, 50) + page_number = request.GET.get('page') + households_page = paginator.get_page(page_number) + + context = { + 'selected_tenant': tenant, + 'households': households_page, + 'district_filter': district_filter, + 'neighborhood_filter': neighborhood_filter, + 'address_filter': address_filter, + 'city_filter': city_filter, + 'map_data_json': json.dumps(map_data), + 'map_limit_reached': len(households_list) > 3000, + 'GOOGLE_MAPS_API_KEY': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''), + } + return render(request, 'core/door_visits.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def log_door_visit(request): + """ + Mark all targeted voters at a specific address as visited, update their flags, + and create interaction records. + Can also render a standalone page for logging a visit. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant) + + # Capture query string for redirecting back with filters + next_qs = request.POST.get("next_query_string", request.GET.get("next_query_string", "")) + source = request.POST.get("source", request.GET.get("source", "")) + + redirect_url = reverse("door_visits") + + # Build redirect URL + redirect_params = [] + if next_qs: + redirect_params.append(next_qs) + if source == "map": + redirect_params.append("open_map=1") + + if redirect_params: + redirect_url += "?" + "&".join(redirect_params) + + # Get address components from POST or GET + address_street = request.POST.get("address_street", request.GET.get("address_street")) + city = request.POST.get("city", request.GET.get("city")) + state = request.POST.get("state", request.GET.get("state")) + zip_code = request.POST.get("zip_code", request.GET.get("zip_code")) + + if not address_street: + messages.warning(request, "No address provided.") + return redirect(redirect_url) + + # Find targeted voters at this exact address + voters = Voter.objects.filter( + tenant=tenant, + address_street__iexact=address_street, + city__iexact=city, + state__iexact=state, + zip_code__iexact=zip_code, + target_door_visit=True + ) + + if not voters.exists() and request.method == "POST": + messages.warning(request, f"No targeted voters found at {address_street}.") + return redirect(redirect_url) + voter_choices = [(v.id, f'{v.first_name} {v.last_name} - {format_phone_number(v.phone) if v.phone else ""}') for v in voters] + + # Get the volunteer linked to the current user + volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first() + + if request.method == "POST": + form = DoorVisitLogForm(request.POST, voter_choices=voter_choices) + if form.is_valid(): + outcome = form.cleaned_data["outcome"] + notes = form.cleaned_data["notes"] + yard_sign_status = form.cleaned_data["yard_sign_status"] + candidate_support = form.cleaned_data["candidate_support"] + follow_up = form.cleaned_data["follow_up"] + follow_up_voter_id = form.cleaned_data.get("follow_up_voter") + call_notes = form.cleaned_data["call_notes"] + + # Determine date/time in campaign timezone + campaign_tz_name = campaign_settings.timezone or "America/Chicago" + try: + tz = zoneinfo.ZoneInfo(campaign_tz_name) + except: + tz = zoneinfo.ZoneInfo("America/Chicago") + + interaction_date = timezone.now().astimezone(tz) + + # Get or create InteractionType + interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Door Visit") + + # Get default caller for follow-ups + default_caller = None + if follow_up: + default_caller = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first() + + for voter in voters: + # 1) Update voter flags + voter.door_visit = True + + # 2) Update Yard Sign status if not "no_change": + if yard_sign_status != 'no_change': + voter.yard_sign = yard_sign_status + + # 3) Update support status if Supporting or Not Supporting + if candidate_support in ["supporting", "not_supporting"]: + voter.candidate_support = candidate_support + + voter.save() + + # 4) Create interaction + Interaction.objects.create( + voter=voter, + volunteer=volunteer, + type=interaction_type, + date=interaction_date, + description=outcome, + notes=notes + ) + + # 5) Create ScheduledCall if follow_up is checked and this is the selected voter + if follow_up and follow_up_voter_id and str(voter.id) == follow_up_voter_id: + ScheduledCall.objects.create( + tenant=tenant, + voter=voter, + volunteer=default_caller, + comments=call_notes, + status="pending" + ) + + if follow_up: + messages.success(request, f"Door visit logged and follow-up call scheduled for {address_street}.") + else: + messages.success(request, f"Door visit logged for {address_street}.") + return redirect(redirect_url) + else: + messages.error(request, f"There was an error in the visit log form: {form.errors.as_text()}") + return redirect(redirect_url) + else: + # GET request: render standalone page + form = DoorVisitLogForm(voter_choices=voter_choices) + context = { + 'selected_tenant': tenant, + 'visit_form': form, + 'address_street': address_street, + 'city': city, + 'state': state, + 'zip_code': zip_code, + 'voters': voters, + 'next_query_string': next_qs, + 'source': source, + 'redirect_url': redirect_url, + } + return render(request, 'core/log_door_visit.html', context) + +def door_visit_history(request): + """ + Shows a distinct list of Door visit interactions for addresses. + """ + 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) + + # Date filter + start_date = request.GET.get("start_date") + end_date = request.GET.get("end_date") + + # Get all "Door Visit" interactions for this tenant + interactions = Interaction.objects.filter( voter__tenant=tenant, + type__name="Door Visit" + ).select_related("voter", "volunteer") + + if start_date or end_date: + try: + if start_date: + d = parse_date(start_date) + if d: + start_dt = timezone.make_aware(datetime.combine(d, time.min)) + interactions = interactions.filter(date__gte=start_dt) + if end_date: + d = parse_date(end_date) + if d: + # Use lt with next day to capture everything on the end_date + end_dt = timezone.make_aware(datetime.combine(d + timedelta(days=1), time.min)) + interactions = interactions.filter(date__lt=end_dt) + except Exception as e: + logger.error(f"Error filtering door visit history by date: {e}") + + # Summary of counts per volunteer (before filtering by volunteer) + volunteer_id = request.GET.get("volunteer") + + volunteer_counts_dict = {} # (name, id) -> count + seen_households_counts = set() + for interaction in interactions.order_by("-date"): + v = interaction.voter + addr = v.address.strip() if v.address else f"{v.address_street}, {v.city}, {v.state} {v.zip_code}".strip(", ") + if not addr: continue + key = addr.lower() + if key not in seen_households_counts: + seen_households_counts.add(key) + v_obj = interaction.volunteer + v_name = f"{v_obj.first_name} {v_obj.last_name}".strip() or v_obj.email if v_obj else "N/A" + v_id = str(v_obj.id) if v_obj else "None" + volunteer_counts_dict[(v_name, v_id)] = volunteer_counts_dict.get((v_name, v_id), 0) + 1 + + # Apply volunteer filter for the table + if volunteer_id: + if volunteer_id == "None": + interactions = interactions.filter(volunteer__isnull=True) + else: + interactions = interactions.filter(volunteer_id=volunteer_id) + + visited_households = {} + for interaction in interactions.order_by("-date"): + v = interaction.voter + addr = v.address.strip() if v.address else f"{v.address_street}, {v.city}, {v.state} {v.zip_code}".strip(", ") + if not addr: + continue + + key = addr.lower() + + if key not in visited_households: + # Parse street name and number for sorting + street_number = "" + street_name = v.address_street or "" + match = re.search(r'^(\d+)\s+(.*)$', street_name) + if match: + street_number = match.group(1) + street_name = match.group(2) + + try: + street_number_sort = int(street_number) + except ValueError: + street_number_sort = 0 + + visited_households[key] = { + 'address_display': addr, + 'address_street': v.address_street, + 'city': v.city, + 'state': v.state, + 'zip_code': v.zip_code, + 'neighborhood': v.neighborhood, + 'district': v.district, + 'latitude': float(v.latitude) if v.latitude else None, + 'longitude': float(v.longitude) if v.longitude else None, + 'street_name_sort': street_name.lower(), + 'street_number_sort': street_number_sort, + 'last_visit_date': interaction.date, + 'last_volunteer': interaction.volunteer, + 'last_outcome': interaction.description, + 'notes': interaction.notes, + 'target_voters': [], + 'voters_at_address': [] + } + + phone_display = format_phone_number(v.phone) if v.phone else "" + visited_households[key]["voters_at_address"].append((v.id, f"{v.first_name} {v.last_name} - {phone_display}")) + visited_households[key]['target_voters'].append(v) + + # Sort volunteer counts by total (descending) + sorted_volunteer_counts = [ + {"name": k[0], "id": k[1], "count": v} + for k, v in sorted(volunteer_counts_dict.items(), key=lambda x: x[1], reverse=True) + ] + + history_list = list(visited_households.values()) + history_list.sort(key=lambda x: x["last_visit_date"], reverse=True) + + paginator = Paginator(history_list, 50) + page_number = request.GET.get("page") + history_page = paginator.get_page(page_number) + + context = { + "selected_tenant": tenant, + "history": history_page, + "start_date": start_date, "end_date": end_date, + "volunteer_counts": sorted_volunteer_counts, "selected_volunteer_id": volunteer_id, + } + return render(request, "core/door_visit_history.html", context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_scheduledcall') +def schedule_call(request, voter_id): + 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) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = ScheduledCallForm(request.POST, tenant=tenant) + if form.is_valid(): + call = form.save(commit=False) + call.tenant = tenant + call.voter = voter + call.save() + messages.success(request, f"Call for {voter} added to queue.") + else: + messages.error(request, "Error scheduling call.") + + referer = request.META.get('HTTP_REFERER') + if referer: + return redirect(referer) + return redirect('voter_detail', voter_id=voter.id) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_scheduledcall') +def bulk_schedule_calls(request): + if request.method != 'POST': + return redirect('voter_advanced_search') + + selected_tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + + voter_ids = request.POST.getlist('selected_voters') + volunteer_id = request.POST.get('volunteer') + comments = request.POST.get('comments', '') + + volunteer = None + if volunteer_id: + volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant) + else: + # Fallback to default caller if not specified in POST but available + volunteer = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first() + + voters = Voter.objects.filter(tenant=tenant, id__in=voter_ids) + + count = 0 + for voter in voters: + ScheduledCall.objects.create( + tenant=tenant, + voter=voter, + volunteer=volunteer, + comments=comments + ) + count += 1 + + messages.success(request, f"{count} calls added to queue.") + return redirect(request.META.get('HTTP_REFERER', 'voter_advanced_search')) + +@login_required +def call_queue(request): + if not can_access_call_queue(request.user): + messages.error(request, "You do not have permission to access the call queue.") + return redirect('index') + 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) + + # Determine if user is staff + user_role = get_user_role(request.user, tenant) + is_staff = request.user.is_superuser or user_role in STAFF_ROLES or request.user.groups.filter(name='Editor').exists() + + # Get volunteer profile + try: + current_volunteer = request.user.volunteer_profile + except: + current_volunteer = None + + calls = ScheduledCall.objects.filter(tenant=tenant, status='pending').order_by('created_at') + + volunteers = [] + selected_volunteer_id = request.GET.get('volunteer_filter') + + if is_staff: + volunteers = Volunteer.objects.filter(tenant=tenant, interests__name="Phone Banking").order_by("last_name", "first_name") + # Default to current user's volunteer if no filter selected + if not selected_volunteer_id and current_volunteer: + selected_volunteer_id = str(current_volunteer.id) + + if selected_volunteer_id and selected_volunteer_id != 'all': + calls = calls.filter(volunteer_id=selected_volunteer_id) + else: + # Non-staff: only show assigned calls + if current_volunteer: + calls = calls.filter(volunteer=current_volunteer) + selected_volunteer_id = str(current_volunteer.id) + else: + calls = calls.none() + + paginator = Paginator(calls, 50) + page_number = request.GET.get('page') + calls_page = paginator.get_page(page_number) + + context = { + 'selected_tenant': tenant, + 'calls': calls_page, + 'volunteers': volunteers, + 'campaign_settings': getattr(tenant, 'settings', None), + 'selected_volunteer_id': selected_volunteer_id, + 'is_staff': is_staff, + } + return render(request, 'core/call_queue.html', context) +@login_required +def complete_call(request, call_id): + if not can_access_call_queue(request.user): + messages.error(request, "You do not have permission to perform this action.") + return redirect('index') + 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) + call = get_object_or_404(ScheduledCall, id=call_id, tenant=tenant) + + if request.method == 'POST': + # Get notes from post data taken during the call + call_notes = request.POST.get('call_notes', '') + call_outcome = request.POST.get('call_outcome', 'Called Voter') + candidate_support = request.POST.get('candidate_support') + yard_sign = request.POST.get('yard_sign') + + # Update candidate support if provided + if candidate_support: + voter = call.voter + if voter.candidate_support != candidate_support: + voter.candidate_support = candidate_support + voter.save(update_fields=['candidate_support']) + + # Update yard sign status if provided and not 'no_change' + if yard_sign and yard_sign != 'no_change': + voter = call.voter + if voter.yard_sign != yard_sign: + voter.yard_sign = yard_sign + voter.save(update_fields=['yard_sign']) + + # Create interaction for the completed call + interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Phone Call") + + # Determine date/time in campaign timezone + campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant) + campaign_tz_name = campaign_settings.timezone or 'America/Chicago' + try: + tz = zoneinfo.ZoneInfo(campaign_tz_name) + except: + tz = zoneinfo.ZoneInfo('America/Chicago') + + interaction_date = timezone.now().astimezone(tz); + + Interaction.objects.create( + voter=call.voter, + volunteer=call.volunteer, + type=interaction_type, + date=interaction_date, + description=call_outcome, + notes=call_notes + ) + + call.status = 'completed'; + call.save() + if call_outcome == 'No Answer No Voice Mail': + voter = call.voter + # If no other pending calls, reset to 'to_be_called' instead of 'called' + if not ScheduledCall.objects.filter(voter=voter, status='pending').exists(): + Voter.objects.filter(pk=voter.pk).update(call_queue_status='to_be_called') + messages.success(request, f"Call for {call.voter} marked as completed and interaction logged.") + + return redirect('call_queue') + +@login_required +def delete_call(request, call_id): + if not can_access_call_queue(request.user): + messages.error(request, "You do not have permission to perform this action.") + return redirect('index') + 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) + call = get_object_or_404(ScheduledCall, id=call_id, tenant=tenant) + + if request.method == 'POST': + call.delete() + messages.success(request, "Call removed from queue.") + + return redirect('call_queue') + +@login_required +def profile(request): + try: + volunteer = request.user.volunteer_profile + except: + volunteer = None + + if request.method == 'POST': + u_form = UserUpdateForm(request.POST, instance=request.user) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def neighborhood_counts(request): + """ + Shows household counts by neighborhood after applying filters from door visits. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + + city_filter = request.GET.get("city", "").strip() + district_filter = request.GET.get('district', '').strip() + neighborhood_filter = request.GET.get('neighborhood', '').strip() + address_filter = request.GET.get('address', '').strip() + + voters = Voter.objects.filter(tenant=tenant, is_inactive=False, door_visit=False, target_door_visit=True) + + if city_filter: + voters = voters.filter(city__icontains=city_filter) + if district_filter: + voters = voters.filter(district=district_filter) + if neighborhood_filter: + voters = voters.filter(neighborhood__icontains=neighborhood_filter) + if address_filter: + voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter)) + + household_qs = voters.values('neighborhood', 'address_street', 'city', 'state', 'zip_code').distinct() + + neighborhood_counts_dict = {} + for h in household_qs: + nb = h['neighborhood'] + neighborhood_counts_dict[nb] = neighborhood_counts_dict.get(nb, 0) + 1 + + neighborhood_list = [ + {'neighborhood': nb, 'display_name': nb or "Unknown", 'count': count} + for nb, count in neighborhood_counts_dict.items() + ] + + neighborhood_list.sort(key=lambda x: x['count'], reverse=True) + + context = { + 'selected_tenant': tenant, + 'neighborhoods': neighborhood_list, + 'city_filter': city_filter, + 'district_filter': district_filter, + 'neighborhood_filter': neighborhood_filter, + 'address_filter': address_filter, + } + return render(request, 'core/neighborhood_counts.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def yard_sign_voters(request): + """ + Manage yard sign requests. Groups voters who want a yard sign by household. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + + city_filter = request.GET.get("city", "").strip() + district_filter = request.GET.get('district', '').strip() + neighborhood_filter = request.GET.get('neighborhood', '').strip() + address_filter = request.GET.get('address', '').strip() + sign_type_filter = request.GET.get('sign_type', '').strip() + + # Initial queryset: voters who want a yard sign for this tenant + voters = Voter.objects.filter(tenant=tenant, is_inactive=False, yard_sign__in=['wants', 'wants_large']) + + if city_filter: + voters = voters.filter(city__icontains=city_filter) + if district_filter: + voters = voters.filter(district=district_filter) + if neighborhood_filter: + voters = voters.filter(neighborhood__icontains=neighborhood_filter) + if address_filter: + voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter)) + + if sign_type_filter == 'yard': + voters = voters.filter(yard_sign='wants') + elif sign_type_filter == 'large': + voters = voters.filter(yard_sign='wants_large') + + # Grouping by household (unique address) + households_dict = {} + for voter in voters: + # Normalize address components for robust grouping + street = (voter.address_street or "").strip() + city = (voter.city or "").strip() + state = (voter.state or "").strip() + zip_code = (voter.zip_code or "").strip() + + key = (street.lower(), city.lower(), state.lower(), zip_code.lower()) + + if key not in households_dict: + street_number = "" + street_name = street + match = re.match(r'^(\d+)\s+(.*)$', street) + if match: + street_number = match.group(1) + street_name = match.group(2) + + try: + street_number_sort = int(street_number) + except (ValueError, TypeError): + street_number_sort = 0 + + households_dict[key] = { + 'address_street': street, + 'city': city, + 'state': state, + 'zip_code': zip_code, + 'neighborhood': (voter.neighborhood or "").strip(), + 'district': (voter.district or "").strip(), + 'latitude': float(voter.latitude) if voter.latitude else None, + 'longitude': float(voter.longitude) if voter.longitude else None, + 'street_name_sort': street_name.lower(), + 'street_number_sort': street_number_sort, + 'voters_who_want_sign': [], + 'sign_types': set(), + 'has_large_request': False, + } + else: + # Pick a non-empty neighborhood/district if the current one is empty + if not households_dict[key]['neighborhood'] and voter.neighborhood: + households_dict[key]['neighborhood'] = voter.neighborhood.strip() + if not households_dict[key]['district'] and voter.district: + households_dict[key]['district'] = voter.district.strip() + + households_dict[key]['voters_who_want_sign'].append(voter) + households_dict[key]['sign_types'].add('Large Sign' if voter.yard_sign == 'wants_large' else 'Yard Sign') + if voter.yard_sign == 'wants_large': + households_dict[key]['has_large_request'] = True + + households_list = list(households_dict.values()) + for h in households_list: + h['sign_types_display'] = ", ".join(sorted(list(h['sign_types']))) + + # Sort by neighborhood presence, then neighborhood name, then street name, then street number + households_list.sort(key=lambda x: ( + not bool(x['neighborhood']), + (x['neighborhood'] or '').lower(), + x['street_name_sort'], + x['street_number_sort'] + )) + + # Prepare data for Google Map + # Limit map markers to 3000 for performance + map_data = [ + { + "lat": h["latitude"], + "lng": h["longitude"], + "address": f"{h.get('address_street', '')}, {h.get('city', '')}, {h.get('state', '')}", + "voters": [{"id": v.id, "name": f"{v.first_name} {v.last_name}"} for v in h["voters_who_want_sign"]], + "notes": ", ".join([f"{v.first_name}: {v.notes}" for v in h["voters_who_want_sign"] if v.notes]), + "voter_ids": [v.id for v in h["voters_who_want_sign"]], + "is_large": h["has_large_request"] + } + for h in households_list[:3000] if h["latitude"] and h["longitude"] + ] + + paginator = Paginator(households_list, 50) + page_number = request.GET.get('page') + households_page = paginator.get_page(page_number) + + context = { + 'selected_tenant': tenant, + 'households': households_page, + 'district_filter': district_filter, + 'neighborhood_filter': neighborhood_filter, + 'address_filter': address_filter, + "city_filter": city_filter, + "sign_type_filter": sign_type_filter, + 'map_data_json': json.dumps(map_data), + 'GOOGLE_MAPS_API_KEY': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''), + 'map_limit_reached': len(households_list) > 3000, + } + return render(request, 'core/yard_sign_voters.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def view_signs(request): + """ + View voters who already have a yard sign. Groups them by household. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + + city_filter = request.GET.get("city", "").strip() + district_filter = request.GET.get('district', '').strip() + neighborhood_filter = request.GET.get('neighborhood', '').strip() + address_filter = request.GET.get('address', '').strip() + sign_type_filter = request.GET.get('sign_type', '').strip() + + # Initial queryset: voters who have a yard sign for this tenant + voters = Voter.objects.filter(tenant=tenant, is_inactive=False, yard_sign__in=['has', 'has_large']) + + if city_filter: + voters = voters.filter(city__icontains=city_filter) + if district_filter: + voters = voters.filter(district=district_filter) + if neighborhood_filter: + voters = voters.filter(neighborhood__icontains=neighborhood_filter) + if address_filter: + voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter)) + + if sign_type_filter == 'yard': + voters = voters.filter(yard_sign='has') + elif sign_type_filter == 'large': + voters = voters.filter(yard_sign='has_large') + + # Grouping by household (unique address) + households_dict = {} + for voter in voters: + # Normalize address components for robust grouping + street = (voter.address_street or "").strip() + city = (voter.city or "").strip() + state = (voter.state or "").strip() + zip_code = (voter.zip_code or "").strip() + + key = (street.lower(), city.lower(), state.lower(), zip_code.lower()) + + if key not in households_dict: + street_number = "" + street_name = street + match = re.match(r'^(\d+)\s+(.*)$', street) + if match: + street_number = match.group(1) + street_name = match.group(2) + + try: + street_number_sort = int(street_number) + except (ValueError, TypeError): + street_number_sort = 0 + + households_dict[key] = { + 'address_street': street, + 'city': city, + 'state': state, + 'zip_code': zip_code, + 'neighborhood': (voter.neighborhood or "").strip(), + 'district': (voter.district or "").strip(), + 'latitude': float(voter.latitude) if voter.latitude else None, + 'longitude': float(voter.longitude) if voter.longitude else None, + 'street_name_sort': street_name.lower(), + 'street_number_sort': street_number_sort, + 'voters_with_sign': [], + 'sign_types': set(), + 'has_large': False, + } + else: + # Pick a non-empty neighborhood/district if the current one is empty + if not households_dict[key]['neighborhood'] and voter.neighborhood: + households_dict[key]['neighborhood'] = voter.neighborhood.strip() + if not households_dict[key]['district'] and voter.district: + households_dict[key]['district'] = voter.district.strip() + + households_dict[key]['voters_with_sign'].append(voter) + households_dict[key]['sign_types'].add('Large Sign' if voter.yard_sign == 'has_large' else 'Yard Sign') + if voter.yard_sign == 'has_large': + households_dict[key]['has_large'] = True + + households_list = list(households_dict.values()) + for h in households_list: + h['sign_types_display'] = ", ".join(sorted(list(h['sign_types']))) + + # Sort + households_list.sort(key=lambda x: ( + not bool(x['neighborhood']), + (x['neighborhood'] or '').lower(), + x['street_name_sort'], + x['street_number_sort'] + )) + + # Prepare data for Google Map + map_data = [ + { + 'lat': h['latitude'], + 'lng': h['longitude'], + 'address': f"{h['address_street']}, {h['city']}, {h['state']}", + 'voters': [{"id": v.id, "name": f"{v.first_name} {v.last_name}"} for v in h['voters_with_sign']], + 'is_large': h['has_large'] + } + for h in households_list[:3000] if h['latitude'] and h['longitude'] + ] + + paginator = Paginator(households_list, 50) + page_number = request.GET.get('page') + households_page = paginator.get_page(page_number) + + context = { + 'selected_tenant': tenant, + 'households': households_page, + 'district_filter': district_filter, + 'neighborhood_filter': neighborhood_filter, + 'address_filter': address_filter, + "city_filter": city_filter, + "sign_type_filter": sign_type_filter, + 'map_data_json': json.dumps(map_data), + 'GOOGLE_MAPS_API_KEY': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''), + 'map_limit_reached': len(households_list) > 3000, + } + return render(request, 'core/view_signs.html', context) +def get_tenant_email_connection(campaign_settings): + use_tls = campaign_settings.smtp_use_tls + use_ssl = campaign_settings.smtp_use_ssl + + # EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive + if use_tls and use_ssl: + if campaign_settings.smtp_port == 465: + use_tls = False + else: + use_ssl = False + + return get_connection( + host=campaign_settings.smtp_host, + port=campaign_settings.smtp_port, + username=campaign_settings.smtp_username, + password=campaign_settings.smtp_password, + use_tls=use_tls, + use_ssl=use_ssl, + ) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin']) +def volunteer_bulk_send_email(request): + selected_tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + campaign_settings = CampaignSettings.objects.get(tenant=tenant) + + if request.method == 'POST': + volunteer_ids = request.POST.getlist('selected_volunteers') + subject = request.POST.get('subject') + body = request.POST.get('body') + is_html = request.POST.get("is_html") == "on" + + volunteers = Volunteer.objects.filter(id__in=volunteer_ids, tenant=tenant).exclude(email='') + if not volunteers.exists(): + messages.warning(request, "No volunteers with email addresses selected.") + return redirect('volunteer_list') + + connection = get_tenant_email_connection(campaign_settings) + if not connection: + messages.error(request, "SMTP settings are not configured. Please check Campaign Settings.") + return redirect('volunteer_list') + + from_email = campaign_settings.email_from_address or settings.DEFAULT_FROM_EMAIL + if campaign_settings.email_from_name: + from_email = f"{campaign_settings.email_from_name} <{from_email}>" + + sent_count = 0 + error_count = 0 + + for volunteer in volunteers: + try: + email = EmailMessage( + subject, + body, + from_email, + [volunteer.email], + connection=connection, + ) + if is_html: + email.content_subtype = "html" + email.send() + sent_count += 1 + except Exception as e: + logger.error(f"Error sending bulk email to volunteer {volunteer.email}: {e}") + error_count += 1 + + if sent_count > 0: + messages.success(request, f"Successfully sent {sent_count} emails.") + if error_count > 0: + messages.error(request, f"Failed to send {error_count} emails.") + + return redirect('volunteer_list') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin']) +def voter_bulk_send_email(request): + selected_tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + campaign_settings = CampaignSettings.objects.get(tenant=tenant) + + if request.method == 'POST': + select_all_results = request.POST.get('select_all_results') == 'true' + subject = request.POST.get('subject') + body = request.POST.get('body') + is_html = request.POST.get("is_html") == "on" + + 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: + import logging + logger = logging.getLogger(__name__) + 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') + +def mark_yard_sign_delivered(request): + """ + Action to mark yard signs as delivered for one or more voters. + """ + is_ajax = request.headers.get('x-requested-with') == 'XMLHttpRequest' or request.POST.get('is_ajax') == 'true' + + if request.method == "POST": + voter_ids = request.POST.getlist('voter_ids') + selected_tenant_id = request.session.get("tenant_id") + if voter_ids and selected_tenant_id: + voters_to_update = Voter.objects.filter( + id__in=voter_ids, + tenant_id=selected_tenant_id, + yard_sign__in=['wants', 'wants_large'] + ) + updated_count = 0 + for v in voters_to_update: + if v.yard_sign == 'wants': + v.yard_sign = 'has' + elif v.yard_sign == 'wants_large': + v.yard_sign = 'has_large' + v.save(update_fields=['yard_sign']) + updated_count += 1 + + if updated_count > 0: + msg = f"Successfully marked sign(s) as delivered for {updated_count} voter(s)." + if is_ajax: + return JsonResponse({'success': True, 'message': msg, 'updated_count': updated_count}) + messages.success(request, msg) + else: + msg = "No voter records were updated." + if is_ajax: + return JsonResponse({'success': False, 'message': msg}) + messages.warning(request, msg) + else: + msg = "Invalid request: No voters selected." + if is_ajax: + return JsonResponse({'success': False, 'message': msg}) + messages.error(request, msg) + + if is_ajax: + return JsonResponse({'success': False, 'message': 'Invalid request method.'}) + + return redirect(request.META.get('HTTP_REFERER', 'yard_sign_voters')) + +@login_required +def populate_call_queue(request): + if not can_access_call_queue(request.user): + messages.error(request, "You do not have permission to perform this action.") + return redirect("index") + + 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) + + # Determine if user is staff to allow assigning to others + user_role = get_user_role(request.user, tenant) + is_staff = request.user.is_superuser or user_role in STAFF_ROLES or request.user.groups.filter(name="Editor").exists() + + if request.method == "POST": + # Default to current user's volunteer profile + volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first() + + # If staff, they can override the volunteer + if is_staff: + target_volunteer_id = request.POST.get("volunteer_id") + if target_volunteer_id: + volunteer = Volunteer.objects.filter(id=target_volunteer_id, tenant=tenant).first() + + if not volunteer: + messages.error(request, "No volunteer profile found for the assignment.") + return redirect("call_queue") + + num_voters_raw = request.POST.get("num_voters", "5") + try: + num_voters = int(num_voters_raw) + except ValueError: + num_voters = 5 + + district = request.POST.get("district", "").strip() + include_door_visits = request.POST.get("include_door_visits") == "on" + + # Build queryset + voters = Voter.objects.filter( + tenant=tenant, + call_queue_status="to_be_called", + is_inactive=False + ).exclude( + Q(phone="") & Q(secondary_phone="") + ) + + if district: + voters = voters.filter(district__iexact=district) + + if not include_door_visits: + voters = voters.filter(door_visit=False) + + # Select the top N voters + voters_to_add = voters[:num_voters] + + added_count = 0 + with transaction.atomic(): + for voter in voters_to_add: + ScheduledCall.objects.create( + tenant=tenant, + voter=voter, + volunteer=volunteer, + status="pending" + ) + added_count += 1 + + if added_count > 0: + messages.success(request, f"Successfully added {added_count} voters to {volunteer}'s call queue.") + else: + messages.info(request, "No voters found matching your criteria.") + + return redirect("call_queue") + + +@login_required +def export_call_queue(request): + if not can_access_call_queue(request.user): + messages.error(request, "You do not have permission to access the call queue.") + return redirect('index') + + 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) + + # Determine if user is staff + user_role = get_user_role(request.user, tenant) + is_staff = request.user.is_superuser or user_role in STAFF_ROLES or request.user.groups.filter(name='Editor').exists() + + # Get volunteer profile + try: + current_volunteer = request.user.volunteer_profile + except: + current_volunteer = None + + calls = ScheduledCall.objects.filter(tenant=tenant, status='pending').order_by('created_at') + + selected_volunteer_id = request.GET.get('volunteer_filter') + + if is_staff: + if selected_volunteer_id and selected_volunteer_id != 'all': + calls = calls.filter(volunteer_id=selected_volunteer_id) + else: + # Non-staff: only export assigned calls + if current_volunteer: + calls = calls.filter(volunteer=current_volunteer) + else: + calls = calls.none() + + response = HttpResponse(content_type='text/csv') + response['Content-Disposition'] = f'attachment; filename="call_queue_export_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv"' + + writer = csv.writer(response) + writer.writerow([ + 'Voter ID', 'First Name', 'Last Name', 'Phone', 'Secondary Phone', + 'Address', 'City', 'Neighborhood', 'Assigned Volunteer', 'Comments', 'Created At' + ]) + + for call in calls.select_related('voter', 'volunteer'): + writer.writerow([ + call.voter.voter_id, + call.voter.first_name, + call.voter.last_name, + call.voter.phone, + call.voter.secondary_phone, + call.voter.address, + call.voter.city, + call.voter.neighborhood, + str(call.volunteer) if call.volunteer else 'Unassigned', + call.comments, + call.created_at.strftime('%Y-%m-%d %H:%M:%S') + ]) + + return response diff --git a/core/views.py.bak b/core/views.py.bak new file mode 100644 index 0000000..e87d071 --- /dev/null +++ b/core/views.py.bak @@ -0,0 +1,2700 @@ +import os +from django.contrib.auth.decorators import login_required +from django.contrib.auth.forms import PasswordChangeForm +from django.utils.dateparse import parse_date +from datetime import datetime, time, timedelta +import base64 +import re +import urllib.parse +import urllib.request +import csv +import io +import json +from django.http import JsonResponse, HttpResponse +from django.urls import reverse +from django.shortcuts import render, redirect, get_object_or_404 +from django.db import transaction +from django.db.models import Q, Sum, Value, DecimalField +from django.contrib import messages +from django.core.paginator import Paginator +from django.conf import settings +from django.db.models.functions import Coalesce +from .models import format_phone_number, Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest, VolunteerRole, ScheduledCall, BulkTask +from .filter_helper import get_filtered_voter_queryset, get_phone_search_filters +from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm, ScheduledCallForm, UserUpdateForm, EventParticipationImportForm, ParticipantMappingForm +from django.core.mail import get_connection, EmailMessage +import logging +import zoneinfo +from django.utils import timezone + +from .permissions import role_required, can_view_donations, can_edit_voter, can_view_volunteers, can_edit_volunteer, can_view_voters, get_user_role, STAFF_ROLES, can_access_call_queue +logger = logging.getLogger(__name__) + +from .task_runners import start_bulk_sms_task + +def _robust_decode(content): + if not content: return "" + for enc in ["utf-8-sig", "utf-8", "iso-8859-1", "windows-1252"]: + try: return content.decode(enc) + except UnicodeDecodeError: continue + return content.decode("utf-8", errors="replace") + + +def _handle_uploaded_file(uploaded_file): + """ + Handles uploaded CSV files, saves them to a temporary file, and extracts headers. + Returns (headers, temp_file_path) or (None, None) if an error occurs. + """ + import tempfile + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp: + for chunk in uploaded_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + + with open(file_path, "r", encoding="utf-8-sig", errors="replace") as f: + # Read first line for headers + content = f.readline() + io_string = io.StringIO(content) + try: + dialect = csv.Sniffer().sniff(content[:1024]) + io_string.seek(0) + reader = csv.reader(io_string, dialect) + except: + io_string.seek(0) + reader = csv.reader(io_string) + headers = [header.strip() for header in next(reader)] + + return headers, file_path + except Exception as e: + logger.error(f"Error processing uploaded file: {e}") + return None, None + except Exception as e: + logger.error(f"Error processing uploaded file: {e}") + return None, None + +def index(request): + """ + Main landing page for Grassroots Campaign Manager. + Displays a list of campaigns if the user is logged in but hasn't selected one. + """ + tenants = Tenant.objects.all() + selected_tenant_id = request.session.get('tenant_id') + selected_tenant = None + metrics = {} + recent_interactions = [] + upcoming_events = [] + + if selected_tenant_id: + selected_tenant = Tenant.objects.filter(id=selected_tenant_id).first() + if selected_tenant: + voters = selected_tenant.voters.filter(is_inactive=False) + total_donations = Donation.objects.filter(voter__tenant=selected_tenant).aggregate(total=Sum('amount'))['total'] or 0 + + # Get or create settings for the tenant + campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=selected_tenant) + donation_goal = campaign_settings.donation_goal + + donation_percentage = 0 + if donation_goal > 0: + donation_percentage = float(round((total_donations / donation_goal) * 100, 1)) + + metrics = { + 'total_registered_voters': voters.count(), + 'total_target_voters': voters.filter(is_targeted=True).count(), + 'total_supporting': voters.filter(candidate_support='supporting').count(), + 'total_target_households': voters.filter(is_targeted=True).exclude(address='').values('address_street').distinct().count(), + 'total_door_visits': voters.filter(door_visit=True).exclude(address='').values('address_street').distinct().count(), + 'total_target_door_visit_households': voters.filter(target_door_visit=True).exclude(address='').values('address_street').distinct().count(), + 'total_has_signs': voters.filter(yard_sign__in=['has', 'has_large']).exclude(address='').values('address_street').distinct().count(), + 'total_wants_signs': voters.filter(yard_sign__in=['wants', 'wants_large']).exclude(address='').values('address_street').distinct().count(), + 'total_has_window_stickers': voters.filter(window_sticker='has').count(), + 'total_wants_window_stickers': voters.filter(window_sticker='wants').count(), + 'total_donations': float(total_donations), + 'donation_goal': float(donation_goal), + 'donation_percentage': donation_percentage, + 'volunteers_count': Volunteer.objects.filter(tenant=selected_tenant).count(), + "total_to_be_called": voters.exclude(Q(phone="") & Q(secondary_phone="")).filter(call_queue_status__in=["to_be_called", "in_call_queue"]).count(), + "total_called": voters.exclude(Q(phone="") & Q(secondary_phone="")).filter(call_queue_status="called").count(), + "total_event_attendees": EventParticipation.objects.filter(event__tenant=selected_tenant).count(), + 'interactions_count': Interaction.objects.filter(voter__tenant=selected_tenant).count(), + 'events_count': Event.objects.filter(tenant=selected_tenant).count(), + 'pending_calls_count': ScheduledCall.objects.filter(tenant=selected_tenant, status='pending').count(), + } + + recent_interactions = Interaction.objects.filter(voter__tenant=selected_tenant).order_by('-date')[:5] + upcoming_events = Event.objects.filter(tenant=selected_tenant, date__gte=timezone.now().date()).order_by('date')[:5] + + context = { + 'tenants': tenants, + 'selected_tenant': selected_tenant, + 'metrics': metrics, + 'recent_interactions': recent_interactions, + 'upcoming_events': upcoming_events, + } + return render(request, 'core/index.html', context) + +def select_campaign(request, tenant_id): + """ + Sets the selected campaign in the session. + """ + tenant = get_object_or_404(Tenant, id=tenant_id) + request.session['tenant_id'] = tenant.id + messages.success(request, f"You are now managing: {tenant.name}") + return redirect('index') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def voter_list(request): + """ + List and search voters. Restricted to selected tenant. + """ + 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) + query = request.GET.get("q") + voters = Voter.objects.filter(tenant=tenant, is_inactive=False).order_by("last_name", "first_name") + + # Filtering based on dashboard metrics + if request.GET.get("is_targeted") == "true": + voters = voters.filter(is_targeted=True) + if request.GET.get("support") == "supporting": + voters = voters.filter(candidate_support="supporting") + if request.GET.get("has_address") == "true": + voters = voters.exclude(address__isnull=True).exclude(address="") + if request.GET.get("visited") == "true": + voters = voters.filter(door_visit=True) + if request.GET.get("yard_sign") == "true": + voters = voters.filter(yard_sign__in=['wants', 'has', 'wants_large', 'has_large']) + if request.GET.get("window_sticker") == "true": + voters = voters.filter(Q(window_sticker="wants") | Q(window_sticker="has")) + if request.GET.get("has_donations") == "true": + voters = voters.filter(donations__isnull=False).distinct() + + if query: + query = query.strip() + search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(voter_id__iexact=query) | get_phone_search_filters(query) + + if "," in query: + parts = [p.strip() for p in query.split(",")] + if len(parts) >= 2: + last_part = parts[0] + first_part = parts[1] + search_filter |= Q(last_name__icontains=last_part, first_name__icontains=first_part) + elif " " in query: + parts = query.split() + if len(parts) >= 2: + first_part = parts[0] + last_part = " ".join(parts[1:]) + search_filter |= Q(first_name__icontains=first_part, last_name__icontains=last_part) + + voters = voters.filter(search_filter).order_by("last_name", "first_name") + + paginator = Paginator(voters, 50) + page_number = request.GET.get('page') + voters_page = paginator.get_page(page_number) + + + context = { + "voters": voters_page, + "query": query, + "selected_tenant": tenant, + "call_form": ScheduledCallForm(tenant=tenant), + } + return render(request, "core/voter_list.html", context) + +@role_required(["admin", "campaign_manager", "campaign_staff", "system_admin", "campaign_admin"], permission="core.add_voter") +def voter_add(request): + """ + Add a new voter to the campaign. + """ + 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": + form = VoterForm(request.POST, user=request.user, tenant=tenant) + if form.is_valid(): + voter = form.save(commit=False) + voter.tenant = tenant + voter.save() + messages.success(request, f"Voter {voter.first_name} {voter.last_name} added successfully.") + return redirect("voter_detail", voter_id=voter.id) + else: + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f"Error in {field}: {error}") + else: + form = VoterForm(user=request.user, tenant=tenant) + + context = { + "form": form, + "selected_tenant": tenant, + "is_create": True, + } + return render(request, "core/voter_add.html", context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def voter_detail(request, voter_id): + """ + 360-degree view of a voter. + """ + 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) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + other_voters_at_address = [] + if voter.address_street and voter.city: + other_voters_at_address = Voter.objects.filter( + tenant=tenant, + address_street=voter.address_street, + city=voter.city, + state=voter.state, + zip_code=voter.zip_code + ).exclude(id=voter.id) + + context = { + 'voter': voter, + "other_voters_at_address": other_voters_at_address, + 'selected_tenant': tenant, + 'voting_records': voter.voting_records.all().order_by('-election_date'), + 'donations': voter.donations.all().order_by('-date'), + 'interactions': voter.interactions.all().order_by('-date'), + 'event_participations': voter.event_participations.all().order_by('-event__date'), + 'likelihoods': voter.likelihoods.all(), + 'voter_form': VoterForm(instance=voter, user=request.user, tenant=tenant), + 'interaction_form': InteractionForm(tenant=tenant), + 'donation_form': DonationForm(tenant=tenant), + 'likelihood_form': VoterLikelihoodForm(tenant=tenant), + 'event_participation_form': EventParticipationForm(tenant=tenant), + 'call_form': ScheduledCallForm(tenant=tenant), + } + return render(request, 'core/voter_detail.html', context) + +@role_required(["admin", "campaign_manager", "campaign_staff", "system_admin", "campaign_admin"], permission="core.change_voter") +def voter_edit(request, voter_id): + """ + Update voter core demographics. + """ + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + # Log incoming coordinate data for debugging + lat_raw = request.POST.get('latitude') + lon_raw = request.POST.get('longitude') + logger.info(f"Voter Edit POST: lat={lat_raw}, lon={lon_raw}") + + form = VoterForm(request.POST, instance=voter, user=request.user, tenant=tenant) + if form.is_valid(): + # If coordinates were provided in POST, ensure they are applied to the instance + # This handles cases where readonly or other widget settings might interfere + voter = form.save(commit=False); + if lat_raw: + try: + voter.latitude = lat_raw + except: pass + if lon_raw: + try: + voter.longitude = lon_raw + except: pass + + voter.save() + messages.success(request, "Voter profile updated successfully.") + else: + logger.warning(f"Voter Edit Form Invalid: {form.errors}") + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f"Error in {field}: {error}") + return redirect('voter_detail', voter_id=voter.id) + +def add_interaction(request, voter_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = InteractionForm(request.POST, tenant=tenant) + if form.is_valid(): + interaction = form.save(commit=False) + interaction.voter = voter + interaction.save() + messages.success(request, "Interaction added.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter.id}) + '?active_tab=interactions') + +def edit_interaction(request, interaction_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + interaction = get_object_or_404(Interaction, id=interaction_id, voter__tenant=tenant) + + if request.method == 'POST': + form = InteractionForm(request.POST, instance=interaction, tenant=tenant) + if form.is_valid(): + form.save() + messages.success(request, "Interaction updated.") + return redirect(reverse('voter_detail', kwargs={'voter_id': interaction.voter.id}) + '?active_tab=interactions') + +def delete_interaction(request, interaction_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + interaction = get_object_or_404(Interaction, id=interaction_id, voter__tenant=tenant) + voter_id = interaction.voter.id + + if request.method == 'POST': + interaction.delete() + messages.success(request, "Interaction deleted.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter_id}) + '?active_tab=interactions') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_donation') +def add_donation(request, voter_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = DonationForm(request.POST, tenant=tenant) + if form.is_valid(): + donation = form.save(commit=False) + donation.voter = voter + donation.save() + messages.success(request, "Donation recorded.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter.id}) + '?active_tab=donations') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_donation') +def edit_donation(request, donation_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + donation = get_object_or_404(Donation, id=donation_id, voter__tenant=tenant) + + if request.method == 'POST': + form = DonationForm(request.POST, instance=donation, tenant=tenant) + if form.is_valid(): + form.save() + messages.success(request, "Donation updated.") + return redirect(reverse('voter_detail', kwargs={'voter_id': donation.voter.id}) + '?active_tab=donations') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.delete_donation') +def delete_donation(request, donation_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + donation = get_object_or_404(Donation, id=donation_id, voter__tenant=tenant) + voter_id = donation.voter.id + + if request.method == 'POST': + donation.delete() + messages.success(request, "Donation deleted.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter_id}) + '?active_tab=donations') + +def add_likelihood(request, voter_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = VoterLikelihoodForm(request.POST, tenant=tenant) + if form.is_valid(): + likelihood = form.save(commit=False) + likelihood.voter = voter + # Handle potential duplicate election_type + VoterLikelihood.objects.filter(voter=voter, election_type=likelihood.election_type).delete() + likelihood.save() + messages.success(request, "Likelihood updated.") + return redirect('voter_detail', voter_id=voter.id) + +def edit_likelihood(request, likelihood_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + likelihood = get_object_or_404(VoterLikelihood, id=likelihood_id, voter__tenant=tenant) + + if request.method == 'POST': + form = VoterLikelihoodForm(request.POST, instance=likelihood, tenant=tenant) + if form.is_valid(): + election_type = form.cleaned_data['election_type'] + if VoterLikelihood.objects.filter(voter=likelihood.voter, election_type=election_type).exclude(id=likelihood.id).exists(): + VoterLikelihood.objects.filter(voter=likelihood.voter, election_type=election_type).exclude(id=likelihood.id).delete() + form.save() + messages.success(request, "Likelihood updated.") + return redirect('voter_detail', voter_id=likelihood.voter.id) + +def delete_likelihood(request, likelihood_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + likelihood = get_object_or_404(VoterLikelihood, id=likelihood_id, voter__tenant=tenant) + voter_id = likelihood.voter.id + + if request.method == 'POST': + likelihood.delete() + messages.success(request, "Likelihood record deleted.") + return redirect('voter_detail', voter_id=voter_id) + +def add_event_participation(request, voter_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = EventParticipationForm(request.POST, tenant=tenant) + if form.is_valid(): + participation = form.save(commit=False) + participation.voter = voter + # Avoid duplicate participation + if not EventParticipation.objects.filter(voter=voter, event=participation.event).exists(): + participation.save() + messages.success(request, "Event participation added.") + else: + messages.warning(request, "Voter is already participating in this event.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter.id}) + '?active_tab=events') + +def edit_event_participation(request, participation_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant) + + if request.method == 'POST': + form = EventParticipationForm(request.POST, instance=participation, tenant=tenant) + if form.is_valid(): + event = form.cleaned_data['event'] + if EventParticipation.objects.filter(voter=participation.voter, event=event).exclude(id=participation.id).exists(): + messages.warning(request, "Voter is already participating in that event.") + else: + form.save() + messages.success(request, "Event participation updated.") + return redirect(reverse('voter_detail', kwargs={'voter_id': participation.voter.id}) + '?active_tab=events') + +def delete_event_participation(request, participation_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant) + voter_id = participation.voter.id + + if request.method == 'POST': + participation.delete() + messages.success(request, "Event participation removed.") + return redirect(reverse('voter_detail', kwargs={'voter_id': voter_id}) + '?active_tab=events') + +def voter_geocode(request, voter_id): + """ + Manually trigger geocoding for a voter, potentially using values from the request. + """ + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + street = request.POST.get('address_street', voter.address_street) + city = request.POST.get('city', voter.city) + state = request.POST.get('state', voter.state) + zip_code = request.POST.get('zip_code', voter.zip_code) + + parts = [street, city, state, zip_code] + full_address = ", ".join([p for p in parts if p]) + + # Use a temporary instance to avoid saving until the user clicks "Save" in the modal + temp_voter = Voter( + address_street=street, + city=city, + state=state, + zip_code=zip_code, + address=full_address + ) + success, error_msg = temp_voter.geocode_address() + + if success: + return JsonResponse({ + 'success': True, + 'latitude': str(temp_voter.latitude), + 'longitude': str(temp_voter.longitude), + 'address': full_address + }) + else: + return JsonResponse({ + 'success': False, + 'error': f"Geocoding failed: {error_msg or 'No results found.'}" + }) + + return JsonResponse({'success': False, 'error': 'Invalid request method.'}) + +@role_required(["admin", "campaign_manager", "campaign_staff", "system_admin", "campaign_admin"], permission="core.view_bulktask") +def bulk_task_list(request): + 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) + bulk_tasks = BulkTask.objects.filter(tenant=tenant).order_by("-created_at") + + paginator = Paginator(bulk_tasks, 20) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + return render(request, "core/bulk_task_list.html", { + "bulk_tasks": page_obj, + "selected_tenant": tenant, + }) + +@role_required(["admin", "campaign_manager", "campaign_staff", "system_admin", "campaign_admin"], permission="core.view_voter") +def voter_advanced_search(request): + """ + Advanced search for voters with multiple filters. + """ + 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) + voters = Voter.objects.filter(tenant=tenant, is_inactive=False).order_by("last_name", "first_name") + + form = AdvancedVoterSearchForm(request.GET) + 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 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) + # Annotate each voter with their total donation amount, treating no donations as 0 + voters = voters.annotate(total_donation_amount=Coalesce(Sum('donations__amount'), Value(0), output_field=DecimalField())) + + if data.get("call_queue_status"): + voters = voters.filter(call_queue_status=data["call_queue_status"]) + 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) + + paginator = Paginator(voters, 50) + page_number = request.GET.get('page') + voters_page = paginator.get_page(page_number) + + context = { + 'form': form, + 'voters': voters_page, + 'selected_tenant': tenant, + 'call_form': ScheduledCallForm(tenant=tenant), + } + return render(request, "core/voter_advanced_search.html", context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +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, is_inactive=False, id__in=voter_ids) + + 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', '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, + 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', + 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 + +def voter_delete(request, voter_id): + """ + Delete a voter profile. + """ + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + voter.delete() + messages.success(request, "Voter profile deleted successfully.") + return redirect('voter_list') + + return redirect('voter_detail', voter_id=voter.id) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def bulk_send_sms(request): + """ + Sends bulk SMS to selected voters using Twilio API in a background thread. + """ + 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) + 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' + voter_ids = request.POST.getlist('selected_voters') + + search_filters = {} + if select_all_results: + for key, value in request.POST.items(): + if key.startswith("filter_") and value: + search_filters[key.replace("filter_", "")] = value + + start_bulk_sms_task(tenant, message_body, voter_ids, select_all_results, search_filters, object_type='voter') + + messages.success(request, "Bulk SMS process started in the background. You can continue working while it processes.") + return redirect('voter_advanced_search') + +def event_list(request): + 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) + events = Event.objects.filter(tenant=tenant).order_by('-date') + + + context = { + 'tenant': tenant, + 'events': events, + 'selected_tenant': tenant, + } + return render(request, 'core/event_list.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_event') +def event_detail(request, event_id): + 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) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + participations = event.participations.all().select_related('voter', 'participation_status').order_by('voter__last_name', 'voter__first_name') + + # Get assigned volunteers + volunteers = event.volunteer_assignments.all().select_related('volunteer').order_by('volunteer__last_name', 'volunteer__first_name') + + # Form for adding a new participant + add_form = EventParticipantAddForm(tenant=tenant) + # Form for adding a new volunteer + default_role = event.default_volunteer_role + if not default_role and event.event_type: + default_role = event.event_type.default_volunteer_role + add_volunteer_form = VolunteerEventAddForm(tenant=tenant, initial={'role_type': default_role}) + + participation_statuses = ParticipationStatus.objects.filter(tenant=tenant, is_active=True) + + + context = { + 'tenant': tenant, + 'selected_tenant': tenant, + 'event': event, + 'participations': participations, + 'volunteers': volunteers, + 'campaign_settings': getattr(tenant, 'settings', None), + 'add_form': add_form, + 'add_volunteer_form': add_volunteer_form, + 'participation_statuses': participation_statuses, + } + return render(request, 'core/event_detail.html', context) + +def event_add_participant(request, event_id): + tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=tenant_id) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + if request.method == 'POST': + form = EventParticipantAddForm(request.POST, tenant=tenant) + if form.is_valid(): + participation = form.save(commit=False) + participation.event = event + if not EventParticipation.objects.filter(event=event, voter=participation.voter).exists(): + participation.save() + messages.success(request, f"{participation.voter} added to event.") + else: + messages.warning(request, "Voter is already a participant.") + else: + messages.error(request, "Error adding participant.") + + return redirect('event_detail', event_id=event.id) + +def event_edit_participant(request, participation_id): + tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=tenant_id) + participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant) + + if request.method == 'POST': + status_id = request.POST.get('participation_status') + if status_id: + status = get_object_or_404(ParticipationStatus, id=status_id, tenant=tenant) + participation.participation_status = status + participation.save() + messages.success(request, f"Participation updated for {participation.voter}.") + else: + messages.error(request, "Invalid status.") + + return redirect('event_detail', event_id=participation.event.id) + +def event_delete_participant(request, participation_id): + tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=tenant_id) + participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant) + event_id = participation.event.id + voter_name = str(participation.voter) + participation.delete() + messages.success(request, f"{voter_name} removed from event.") + return redirect('event_detail', event_id=event_id) + +def voter_search_json(request): + """ + JSON endpoint for voter search, used by autocomplete/search UI. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + return JsonResponse({"results": []}) + + query = request.GET.get("q", "").strip() + if len(query) < 2: + return JsonResponse({"results": []}) + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voters = Voter.objects.filter(tenant=tenant, is_inactive=False) + + search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(voter_id__iexact=query) | get_phone_search_filters(query) + + if "," in query: + parts = [p.strip() for p in query.split(",") ] + if len(parts) >= 2: + search_filter |= Q(last_name__icontains=parts[0], first_name__icontains=parts[1]) + + results = voters.filter(search_filter).order_by("last_name", "first_name")[:20] + + data = [] + for v in results: + data.append({ + "id": v.id, + "text": f"{v.last_name}, {v.first_name} ({v.voter_id})", + "address": v.address, + "phone": v.phone + }) + + return JsonResponse({"results": data}) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_volunteer') +def volunteer_list(request): + 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) + volunteers = Volunteer.objects.filter(tenant=tenant).order_by('last_name', 'first_name') + + # Simple search + query = request.GET.get("q") + if query: + volunteers = volunteers.filter( + Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(email__icontains=query) | get_phone_search_filters(query, secondary=False) + ) + + # Interest filter + interest_id = request.GET.get("interest") + if interest_id: + volunteers = volunteers.filter(interests__id=interest_id).distinct() + + interests = Interest.objects.filter(tenant=tenant).order_by('name') + + paginator = Paginator(volunteers, 50) + page_number = request.GET.get('page') + volunteers_page = paginator.get_page(page_number) + + context = { + 'tenant': tenant, + 'selected_tenant': tenant, + 'volunteers': volunteers_page, + 'query': query, + 'interests': interests, + 'selected_interest': interest_id, + } + return render(request, 'core/volunteer_list.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_volunteer') +def volunteer_add(request): + 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': + form = VolunteerForm(request.POST, tenant=tenant) + if form.is_valid(): + volunteer = form.save(commit=False) + volunteer.tenant = tenant + volunteer.save() + form.save_m2m() # Save interests + messages.success(request, f"Volunteer {volunteer} added successfully.") + return redirect('volunteer_detail', volunteer_id=volunteer.id) + else: + form = VolunteerForm(tenant=tenant) + + + context = { + 'form': form, + 'tenant': tenant, + 'selected_tenant': tenant, + 'is_create': True, + } + return render(request, 'core/volunteer_detail.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_volunteer') +def volunteer_detail(request, volunteer_id): + 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) + volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant) + + if request.method == 'POST': + form = VolunteerForm(request.POST, instance=volunteer, tenant=tenant) + if form.is_valid(): + form.save() + messages.success(request, f"Volunteer {volunteer} updated successfully.") + return redirect('volunteer_detail', volunteer_id=volunteer.id) + else: + form = VolunteerForm(instance=volunteer, tenant=tenant) + + assignments = volunteer.event_assignments.all().select_related('event') + assign_form = VolunteerEventForm(tenant=tenant) + + + context = { + 'volunteer': volunteer, + 'form': form, + 'assignments': assignments, + 'assign_form': assign_form, + 'tenant': tenant, + 'selected_tenant': tenant, + } + return render(request, 'core/volunteer_detail.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.delete_volunteer') +def volunteer_delete(request, volunteer_id): + 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) + volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant) + + if request.method == 'POST': + volunteer.delete() + messages.success(request, "Volunteer deleted.") + return redirect('volunteer_list') + return redirect('volunteer_detail', volunteer_id=volunteer.id) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_volunteer') +def volunteer_assign_event(request, volunteer_id): + 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) + volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant) + + if request.method == 'POST': + form = VolunteerEventForm(request.POST, tenant=tenant) + if form.is_valid(): + assignment = form.save(commit=False) + assignment.volunteer = volunteer + assignment.save() + messages.success(request, f"Assigned to {assignment.event}.") + else: + messages.error(request, "Error assigning to event.") + + return redirect('volunteer_detail', volunteer_id=volunteer.id) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_volunteer') +def volunteer_remove_event(request, assignment_id): + 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) + assignment = get_object_or_404(VolunteerEvent, id=assignment_id, volunteer__tenant=tenant) + volunteer_id = assignment.volunteer.id + assignment.delete() + messages.success(request, "Assignment removed.") + return redirect('volunteer_detail', volunteer_id=volunteer_id) + +def interest_add(request): + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + return JsonResponse({'success': False, 'error': 'No campaign selected.'}) + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + if request.method == 'POST': + name = request.POST.get('name', '').strip() + if name: + interest, created = Interest.objects.get_or_create(tenant=tenant, name=name) + if created: + return JsonResponse({'success': True, 'id': interest.id, 'name': interest.name}) + else: + return JsonResponse({'success': False, 'error': 'Interest already exists.'}) + return JsonResponse({'success': False, 'error': 'Invalid request.'}) + +def interest_delete(request, interest_id): + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + return JsonResponse({'success': False, 'error': 'No campaign selected.'}) + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + interest = get_object_or_404(Interest, id=interest_id, tenant=tenant) + + if request.method == 'POST': + interest.delete() + return JsonResponse({'success': True}) + return JsonResponse({'success': False, 'error': 'Invalid request.'}) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_event') +def event_create(request): + 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": + form = EventForm(request.POST, tenant=tenant) + if form.is_valid(): + event = form.save(commit=False) + event.tenant = tenant + event.save() + messages.success(request, "Event created successfully.") + return redirect("event_detail", event_id=event.id) + else: + form = EventForm(tenant=tenant) + + context = { + "form": form, + "tenant": tenant, + "selected_tenant": tenant, + "is_create": True, + } + return render(request, "core/event_edit.html", context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.change_event') +def event_edit(request, event_id): + 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) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + if request.method == 'POST': + form = EventForm(request.POST, instance=event, tenant=tenant) + if form.is_valid(): + form.save() + messages.success(request, "Event updated successfully.") + return redirect('event_detail', event_id=event.id) + else: + form = EventForm(instance=event, tenant=tenant) + + context = { + 'form': form, + 'event': event, + 'tenant': tenant, + 'selected_tenant': tenant, + } + return render(request, 'core/event_edit.html', context) + +@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event') +def import_participants(request, event_id): + 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) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + if request.method == 'POST': + form = EventParticipationImportForm(request.POST, request.FILES, event=event) + if form.is_valid(): + uploaded_file = form.cleaned_data['file'] + + if not uploaded_file.name.lower().endswith('.csv'): + messages.error(request, "Only CSV files are supported. Please save your file as CSV and try again.") + return redirect('event_detail', event_id=event.id) + + headers, file_path = _handle_uploaded_file(uploaded_file) + + if headers and file_path: + request.session['imported_participants_data'] = { + 'event_id': event.id, + 'headers': headers, + 'file_path': file_path, + 'file_name': uploaded_file.name + } + messages.info(request, f"File '{uploaded_file.name}' uploaded successfully. Now map the fields.") + return redirect('import_participants_map_fields', event_id=event.id) + else: + messages.error(request, "Could not read headers from the uploaded file. Please ensure it's a valid CSV.") + else: + messages.error(request, "No file was uploaded or an error occurred with the form.") + # For debugging, you might want to log form.errors + logger.error(f"EventParticipationImportForm errors: {form.errors}") + + return redirect('event_detail', event_id=event.id) + +@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event') +def import_participants_map_fields(request, event_id): + 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) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + imported_data = request.session.get('imported_participants_data') + if not imported_data or imported_data['event_id'] != event.id: + messages.error(request, "No data found to map. Please upload a file first.") + return redirect('event_detail', event_id=event.id) + + headers = imported_data['headers'] + file_name = imported_data['file_name'] + + if request.method == 'POST': + form = ParticipantMappingForm(request.POST, headers=headers, tenant=tenant) + if form.is_valid(): + email_column = form.cleaned_data['email_column'] + name_column = form.cleaned_data['name_column'] # Retrieve name column + phone_column = form.cleaned_data['phone_column'] # Retrieve phone column + participation_status_column = form.cleaned_data['participation_status_column'] + default_participation_status = form.cleaned_data['default_participation_status'] # Retrieve default status + + # Store mapping in session and proceed to processing + request.session['imported_participants_data']['email_column'] = email_column + request.session['imported_participants_data']['name_column'] = name_column # Store name column + request.session['imported_participants_data']['phone_column'] = phone_column # Store phone column + request.session['imported_participants_data']['participation_status_column'] = participation_status_column + request.session['imported_participants_data']['default_participation_status_id'] = default_participation_status.id if default_participation_status else None + request.session.modified = True # Ensure session is saved + logger.debug(f"Session after mapping: {request.session.get('imported_participants_data')}") # Added debug logging + + return redirect('process_participants_import', event_id=event.id) + else: + logger.error(f"ParticipantMappingForm errors: {form.errors}") # Added logging + messages.error(request, "Please correct the mapping errors.") + else: + form = ParticipantMappingForm(headers=headers, tenant=tenant) + + context = { + 'event': event, + 'form': form, + 'file_name': file_name, + 'headers': headers, + } + return render(request, 'core/event_participant_map_fields.html', context) + +@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event') +def process_participants_import(request, event_id): + logger.debug(f"Session at start of process_participants_import: {request.session.get('imported_participants_data')}") # Added debug logging + 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) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + imported_data = request.session.get('imported_participants_data') + if not imported_data or imported_data['event_id'] != event.id: + messages.error(request, "No data found to process. Please upload and map a file first.") + return redirect('event_detail', event_id=event.id) + + headers = imported_data["headers"] + file_path = imported_data.get("file_path") + + if not file_path or not os.path.exists(file_path): + messages.error(request, "No data found to process. Please upload and map a file first.") + return redirect("event_detail", event_id=event.id) + + data_rows = [] + try: + with open(file_path, "r", encoding="utf-8-sig", errors="replace") as f: + # Try to determine the CSV dialect + sample = f.read(2048) + f.seek(0) + try: + dialect = csv.Sniffer().sniff(sample) + reader = csv.reader(f, dialect) + except Exception: + reader = csv.reader(f) + + # Skip the header row as it is already stored in session + next(reader, None) + + for row in reader: + if any(row): # Skip empty rows + data_rows.append(row) + except Exception as e: + logger.error(f"Error reading import file: {e}") + messages.error(request, f"Error reading the uploaded file: {e}") + return redirect("event_detail", event_id=event.id) + + # Safely get column names from session, handle cases where they might be missing + email_column = imported_data.get('email_column') + name_column = imported_data.get('name_column') # Retrieve name column + phone_column = imported_data.get('phone_column') # Retrieve phone column + participation_status_column = imported_data.get('participation_status_column') + default_participation_status_id = imported_data.get('default_participation_status_id') + + logger.debug(f"process_participants_import - name_column from session: {name_column}") # DEBUG LOGGING + logger.debug(f"process_participants_import - phone_column from session: {phone_column}") # DEBUG LOGGING + + # Validate that required columns are present + if not email_column: + messages.error(request, "Email column mapping is missing. Please go back and map the fields.") + return redirect('import_participants_map_fields', event_id=event.id) + + matched_count = 0 + unmatched_participants = [] + + # Get all active participation statuses for the tenant + participation_statuses_map = {status.name.lower(): status for status in ParticipationStatus.objects.filter(tenant=tenant, is_active=True)} + default_status_obj = None + if default_participation_status_id: + default_status_obj = get_object_or_404(ParticipationStatus, id=default_participation_status_id, tenant=tenant) + + voters_by_email = {v.email.lower(): v for v in Voter.objects.filter(tenant=tenant).exclude(email="")} + existing_participations = set(EventParticipation.objects.filter(event=event).values_list("voter_id", flat=True)) + with transaction.atomic(): + for row_index, row in enumerate(data_rows): + row_dict = dict(zip(headers, row)) + email = row_dict.get(email_column) + phone = row_dict.get(phone_column) if phone_column else None + + # DEBUG LOGGING: Log the value of the name column for each row + if name_column: + logger.debug(f"process_participants_import - Row {row_index}: name_column='{name_column}', name_value='{row_dict.get(name_column)}'") + if phone_column: + logger.debug(f"process_participants_import - Row {row_index}: phone_column='{phone_column}', phone_value='{phone}'") + + participation_status_name = row_dict.get(participation_status_column) + + if not email: + logger.warning(f"Row {row_index+2}: Skipping due to missing email.") + continue + + voter = voters_by_email.get(email.lower()) if email else None + + if voter: + # If phone is mapped and present, and not already associated with voter, update it + if phone and voter.phone != phone and voter.secondary_phone != phone: + voter.phone = phone + voter.phone_type = 'cell' + voter.save() + + # Match found, add as participant if not already existing + status = participation_statuses_map.get(participation_status_name.lower()) if participation_status_name else default_status_obj + if not status: + status, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Unknown', is_active=True) # Fallback to unknown if no default and no match + + if voter.id not in existing_participations: + EventParticipation.objects.create( + event=event, + voter=voter, + participation_status=status + ) + matched_count += 1 + else: + logger.info(f"Voter {voter.email} is already a participant in event {event.name}. Skipping.") + else: + # No match found, add to unmatched list + unmatched_participants.append({ + 'row_data': row_dict, + 'original_row_index': row_index, # Keep original index for reference if needed + }) + + # Cleanup temporary file + if file_path and os.path.exists(file_path): + try: + os.remove(file_path) + except Exception as e: + logger.error(f"Error removing temporary import file {file_path}: {e}") + + if unmatched_participants: + # Store unmatched data in session for manual matching + request.session['unmatched_participants_data'] = { + 'event_id': event.id, + 'unmatched_rows': unmatched_participants, + 'file_name': imported_data['file_name'], + 'email_column': email_column, + 'name_column': name_column, # Pass name column to unmatched data + 'phone_column': phone_column, # Pass phone column to unmatched data + 'participation_status_column': participation_status_column, + 'default_participation_status_id': default_participation_status_id, + } + messages.warning(request, f"{len(unmatched_participants)} participants could not be automatically matched. Please match them manually.") + return redirect('match_participants', event_id=event.id) + else: + messages.success(request, f"Successfully imported {matched_count} participants for event {event.name}.") + del request.session['imported_participants_data'] # Clean up session + + return redirect('event_detail', event_id=event.id) + +@role_required(['admin', 'campaign_manager', 'system_admin', 'campaign_admin'], permission='core.change_event') +def match_participants(request, event_id): + 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) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + unmatched_data = request.session.get('unmatched_participants_data') + if not unmatched_data or unmatched_data['event_id'] != event.id: + messages.error(request, "No unmatched participant data found. Please try importing again.") + return redirect('event_detail', event_id=event.id) + + unmatched_rows = unmatched_data['unmatched_rows'] + file_name = unmatched_data['file_name'] + email_column = unmatched_data['email_column'] + name_column = unmatched_data['name_column'] # Retrieve name column + phone_column = unmatched_data.get('phone_column') # Retrieve phone column + participation_status_column = unmatched_data['participation_status_column'] + default_participation_status_id = unmatched_data.get('default_participation_status_id') + + logger.debug(f"match_participants context: email_column={email_column}, name_column={name_column}, phone_column={phone_column}, participation_status_column={participation_status_column}") # DEBUG LOGGING + + # DEBUG LOGGING: Log the value of the name column for each unmatched row + for index, row_data in enumerate(unmatched_rows): + name_value = row_data.get('row_data', {}).get(name_column) + phone_value = row_data.get('row_data', {}).get(phone_column) + logger.debug(f"match_participants - Unmatched row {index}: name_column='{name_column}', name_value='{name_value}', phone_column='{phone_column}', phone_value='{phone_value}'") + + if request.method == 'POST': + matched_count = 0 + current_unmatched_rows = [] # To store rows that are still unmatched after POST + + default_status_obj = None + if default_participation_status_id: + default_status_obj = get_object_or_404(ParticipationStatus, id=default_participation_status_id, tenant=tenant) + + for index, row_data in enumerate(unmatched_rows): + original_row_index = row_data['original_row_index'] + posted_voter_id = request.POST.get(f'voter_match_{original_row_index}') + + if posted_voter_id: + # Manual match provided + voter = get_object_or_404(Voter, id=posted_voter_id, tenant=tenant) + + # Update voter's email + voter_email_from_file = row_data['row_data'].get(email_column) + if voter_email_from_file and voter.email != voter_email_from_file: + voter.email = voter_email_from_file + voter.save() + + # Update voter's phone if mapped and different + voter_phone_from_file = row_data['row_data'].get(phone_column) + if voter_phone_from_file and voter.phone != voter_phone_from_file and voter.secondary_phone != voter_phone_from_file: + voter.phone = voter_phone_from_file + voter.phone_type = 'cell' + voter.save() + + # Add as participant if not already existing + participation_status_name = row_data['row_data'].get(participation_status_column) + status = None + if participation_status_name: + status = ParticipationStatus.objects.filter(tenant=tenant, name__iexact=participation_status_name).first() + + if not status: + status = default_status_obj + + if not status: + status, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Unknown', is_active=True) + + if not EventParticipation.objects.filter(event=event, voter=voter).exists(): + EventParticipation.objects.create( + event=event, + voter=voter, + participation_status=status + ) + matched_count += 1 + else: + messages.warning(request, f"Voter {voter.email} is already a participant in event {event.name}. Skipping manual match for this voter.") + else: + # Still unmatched, keep for re-display + current_unmatched_rows.append(row_data) + + if matched_count > 0: + messages.success(request, f"Successfully matched {matched_count} participants.") + + if current_unmatched_rows: + request.session['unmatched_participants_data']['unmatched_rows'] = current_unmatched_rows + messages.warning(request, f"{len(current_unmatched_rows)} participants still need manual matching.") + return redirect('match_participants', event_id=event.id) + else: + messages.success(request, "All participants have been matched.") + del request.session['unmatched_participants_data'] # Clean up session + del request.session['imported_participants_data'] # Also clean up this + + return redirect('event_detail', event_id=event.id) + + context = { + 'event': event, + 'unmatched_rows': unmatched_rows, + 'file_name': file_name, + 'email_column': email_column, + 'name_column': name_column, # Pass name column to template + 'phone_column': phone_column, # Pass phone column to template + 'participation_status_column': participation_status_column, + } + return render(request, 'core/event_participant_matching.html', context) + +def volunteer_search_json(request): + """ + JSON endpoint for volunteer search, used by autocomplete/search UI. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + return JsonResponse({"results": []}) + + query = request.GET.get("q", "").strip() + if len(query) < 2: + return JsonResponse({"results": []}) + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + volunteers = Volunteer.objects.filter(tenant=tenant) + + search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(email__icontains=query) | get_phone_search_filters(query, secondary=False) | get_phone_search_filters(query, secondary=False) + + results = volunteers.filter(search_filter).order_by("last_name", "first_name")[:20] + + data = [] + for v in results: + data.append({ + "id": v.id, + "text": f"{v.first_name} {v.last_name} ({v.email})", + "phone": v.phone + }) + + return JsonResponse({"results": data}) + +def event_add_volunteer(request, event_id): + tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=tenant_id) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + if request.method == 'POST': + form = VolunteerEventAddForm(request.POST, tenant=tenant) + if form.is_valid(): + assignment = form.save(commit=False) + assignment.event = event + if not VolunteerEvent.objects.filter(event=event, volunteer=assignment.volunteer).exists(): + assignment.save() + messages.success(request, f"{assignment.volunteer} added as volunteer.") + else: + messages.warning(request, "Volunteer is already assigned to this event.") + else: + messages.error(request, "Error adding volunteer.") + + return redirect('event_detail', event_id=event.id) + +def event_remove_volunteer(request, assignment_id): + tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=tenant_id) + assignment = get_object_or_404(VolunteerEvent, id=assignment_id, event__tenant=tenant) + event_id = assignment.event.id + volunteer_name = str(assignment.volunteer) + assignment.delete() + messages.success(request, f"{volunteer_name} removed from event volunteers.") + return redirect('event_detail', event_id=event_id) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_volunteer') +def volunteer_bulk_send_sms(request): + """ + Sends bulk SMS to selected volunteers using Twilio API in a background thread. + """ + if request.method != 'POST': + return redirect('volunteer_list') + + 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) + message_body = request.POST.get('message_body') + + if not message_body: + messages.error(request, "Message body cannot be empty.") + return redirect('volunteer_list') + + volunteer_ids = request.POST.getlist('selected_volunteers') + + start_bulk_sms_task(tenant, message_body, volunteer_ids, False, None, object_type='volunteer') + + messages.success(request, "Bulk SMS process started in the background.") + return redirect('volunteer_list') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def door_visits(request): + """ + Manage door knocking visits. Groups unvisited targeted voters by household. + Optimized to handle large datasets more efficiently. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + + city_filter = request.GET.get("city", "").strip() + district_filter = request.GET.get('district', '').strip() + neighborhood_filter = request.GET.get('neighborhood', '').strip() + address_filter = request.GET.get('address', '').strip() + + voters = Voter.objects.filter(tenant=tenant, is_inactive=False, door_visit=False, target_door_visit=True) + + if city_filter: + voters = voters.filter(city__icontains=city_filter) + if district_filter: + voters = voters.filter(district=district_filter) + if neighborhood_filter: + voters = voters.filter(neighborhood__icontains=neighborhood_filter) + if address_filter: + voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter)) + + # Optimization: Use iterator and lighter data structure + # We only fetch needed fields. Compound index helps here. + voters_iterator = voters.values( + 'id', 'first_name', 'last_name', 'address_street', 'city', 'state', + 'zip_code', 'neighborhood', 'district', 'latitude', 'longitude', 'phone' + ).iterator(chunk_size=2000) + + households_dict = {} + for v in voters_iterator: + street = (v['address_street'] or "").strip() + city = (v['city'] or "").strip() + state = (v['state'] or "").strip() + zip_code = (v['zip_code'] or "").strip() + + key = (street.lower(), city.lower(), state.lower(), zip_code.lower()) + + if key not in households_dict: + street_number = "" + street_name = street + match_street = re.match(r'^(\d+)\s+(.*)$', street) + if match_street: + street_number = match_street.group(1) + street_name = match_street.group(2) + + try: + street_number_sort = int(street_number) + except (ValueError, TypeError): + street_number_sort = 0 + + households_dict[key] = { + 'address_street': street, + 'city': city, + 'state': state, + 'zip_code': zip_code, + 'neighborhood': (v['neighborhood'] or "").strip(), + 'district': (v['district'] or "").strip(), + 'latitude': float(v['latitude']) if v['latitude'] else None, + 'longitude': float(v['longitude']) if v['longitude'] else None, + 'street_name_sort': street_name.lower(), + 'street_number_sort': street_number_sort, + 'target_voters': [], + 'voters_json': [] + } + else: + if not households_dict[key]['neighborhood'] and v['neighborhood']: + households_dict[key]['neighborhood'] = v['neighborhood'].strip() + if not households_dict[key]['district'] and v['district']: + households_dict[key]['district'] = v['district'].strip() + + households_dict[key]['target_voters'].append(v) + phone_display = format_phone_number(v['phone']) if v['phone'] else "" + households_dict[key]['voters_json'].append({'id': v['id'], 'name': f"{v['first_name']} {v['last_name']} - {phone_display}" }) + + households_list = list(households_dict.values()) + del households_dict # Free memory + + households_list.sort(key=lambda x: ( + not bool(x['neighborhood']), + (x['neighborhood'] or '').lower(), + x['street_name_sort'], + x['street_number_sort'] + )) + + # Optimization: Map data should only be for visible results or limited + # We still keep the limit of 3000 for map + map_data = [] + # Always try to show up to 3000 markers instead of showing 0 if count > 3000 + for h in households_list[:3000]: + if h['latitude'] and h['longitude']: + map_data.append({ + 'lat': h['latitude'], + 'lng': h['longitude'], + 'address_street': h['address_street'], + 'city': h['city'], + 'state': h['state'], + 'zip_code': h['zip_code'], + 'address': f"{h['address_street']}, {h['city']}", + 'voters': ", ".join([f"{v['first_name']} {v['last_name']}" for v in h['target_voters']]) + }) + + for h in households_list: + h['voters_json_str'] = json.dumps(h['voters_json']) + + paginator = Paginator(households_list, 50) + page_number = request.GET.get('page') + households_page = paginator.get_page(page_number) + + context = { + 'selected_tenant': tenant, + 'households': households_page, + 'district_filter': district_filter, + 'neighborhood_filter': neighborhood_filter, + 'address_filter': address_filter, + 'city_filter': city_filter, + 'map_data_json': json.dumps(map_data), + 'map_limit_reached': len(households_list) > 3000, + 'GOOGLE_MAPS_API_KEY': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''), + } + return render(request, 'core/door_visits.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def log_door_visit(request): + """ + Mark all targeted voters at a specific address as visited, update their flags, + and create interaction records. + Can also render a standalone page for logging a visit. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant) + + # Capture query string for redirecting back with filters + next_qs = request.POST.get("next_query_string", request.GET.get("next_query_string", "")) + source = request.POST.get("source", request.GET.get("source", "")) + + redirect_url = reverse("door_visits") + + # Build redirect URL + redirect_params = [] + if next_qs: + redirect_params.append(next_qs) + if source == "map": + redirect_params.append("open_map=1") + + if redirect_params: + redirect_url += "?" + "&".join(redirect_params) + + # Get address components from POST or GET + address_street = request.POST.get("address_street", request.GET.get("address_street")) + city = request.POST.get("city", request.GET.get("city")) + state = request.POST.get("state", request.GET.get("state")) + zip_code = request.POST.get("zip_code", request.GET.get("zip_code")) + + if not address_street: + messages.warning(request, "No address provided.") + return redirect(redirect_url) + + # Find targeted voters at this exact address + voters = Voter.objects.filter( + tenant=tenant, + address_street__iexact=address_street, + city__iexact=city, + state__iexact=state, + zip_code__iexact=zip_code, + target_door_visit=True + ) + + if not voters.exists() and request.method == "POST": + messages.warning(request, f"No targeted voters found at {address_street}.") + return redirect(redirect_url) + voter_choices = [(v.id, f'{v.first_name} {v.last_name} - {format_phone_number(v.phone) if v.phone else ""}') for v in voters] + + # Get the volunteer linked to the current user + volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first() + + if request.method == "POST": + form = DoorVisitLogForm(request.POST, voter_choices=voter_choices) + if form.is_valid(): + outcome = form.cleaned_data["outcome"] + notes = form.cleaned_data["notes"] + yard_sign_status = form.cleaned_data["yard_sign_status"] + candidate_support = form.cleaned_data["candidate_support"] + follow_up = form.cleaned_data["follow_up"] + follow_up_voter_id = form.cleaned_data.get("follow_up_voter") + call_notes = form.cleaned_data["call_notes"] + + # Determine date/time in campaign timezone + campaign_tz_name = campaign_settings.timezone or "America/Chicago" + try: + tz = zoneinfo.ZoneInfo(campaign_tz_name) + except: + tz = zoneinfo.ZoneInfo("America/Chicago") + + interaction_date = timezone.now().astimezone(tz) + + # Get or create InteractionType + interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Door Visit") + + # Get default caller for follow-ups + default_caller = None + if follow_up: + default_caller = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first() + + for voter in voters: + # 1) Update voter flags + voter.door_visit = True + + # 2) Update Yard Sign status if not "no_change": + if yard_sign_status != 'no_change': + voter.yard_sign = yard_sign_status + + # 3) Update support status if Supporting or Not Supporting + if candidate_support in ["supporting", "not_supporting"]: + voter.candidate_support = candidate_support + + voter.save() + + # 4) Create interaction + Interaction.objects.create( + voter=voter, + volunteer=volunteer, + type=interaction_type, + date=interaction_date, + description=outcome, + notes=notes + ) + + # 5) Create ScheduledCall if follow_up is checked and this is the selected voter + if follow_up and follow_up_voter_id and str(voter.id) == follow_up_voter_id: + ScheduledCall.objects.create( + tenant=tenant, + voter=voter, + volunteer=default_caller, + comments=call_notes, + status="pending" + ) + + if follow_up: + messages.success(request, f"Door visit logged and follow-up call scheduled for {address_street}.") + else: + messages.success(request, f"Door visit logged for {address_street}.") + return redirect(redirect_url) + else: + messages.error(request, f"There was an error in the visit log form: {form.errors.as_text()}") + return redirect(redirect_url) + else: + # GET request: render standalone page + form = DoorVisitLogForm(voter_choices=voter_choices) + context = { + 'selected_tenant': tenant, + 'visit_form': form, + 'address_street': address_street, + 'city': city, + 'state': state, + 'zip_code': zip_code, + 'voters': voters, + 'next_query_string': next_qs, + 'source': source, + 'redirect_url': redirect_url, + } + return render(request, 'core/log_door_visit.html', context) + +def door_visit_history(request): + """ + Shows a distinct list of Door visit interactions for addresses. + """ + 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) + + # Date filter + start_date = request.GET.get("start_date") + end_date = request.GET.get("end_date") + + # Get all "Door Visit" interactions for this tenant + interactions = Interaction.objects.filter( voter__tenant=tenant, + type__name="Door Visit" + ).select_related("voter", "volunteer") + + if start_date or end_date: + try: + if start_date: + d = parse_date(start_date) + if d: + start_dt = timezone.make_aware(datetime.combine(d, time.min)) + interactions = interactions.filter(date__gte=start_dt) + if end_date: + d = parse_date(end_date) + if d: + # Use lt with next day to capture everything on the end_date + end_dt = timezone.make_aware(datetime.combine(d + timedelta(days=1), time.min)) + interactions = interactions.filter(date__lt=end_dt) + except Exception as e: + logger.error(f"Error filtering door visit history by date: {e}") + + # Summary of counts per volunteer (before filtering by volunteer) + volunteer_id = request.GET.get("volunteer") + + volunteer_counts_dict = {} # (name, id) -> count + seen_households_counts = set() + for interaction in interactions.order_by("-date"): + v = interaction.voter + addr = v.address.strip() if v.address else f"{v.address_street}, {v.city}, {v.state} {v.zip_code}".strip(", ") + if not addr: continue + key = addr.lower() + if key not in seen_households_counts: + seen_households_counts.add(key) + v_obj = interaction.volunteer + v_name = f"{v_obj.first_name} {v_obj.last_name}".strip() or v_obj.email if v_obj else "N/A" + v_id = str(v_obj.id) if v_obj else "None" + volunteer_counts_dict[(v_name, v_id)] = volunteer_counts_dict.get((v_name, v_id), 0) + 1 + + # Apply volunteer filter for the table + if volunteer_id: + if volunteer_id == "None": + interactions = interactions.filter(volunteer__isnull=True) + else: + interactions = interactions.filter(volunteer_id=volunteer_id) + + visited_households = {} + for interaction in interactions.order_by("-date"): + v = interaction.voter + addr = v.address.strip() if v.address else f"{v.address_street}, {v.city}, {v.state} {v.zip_code}".strip(", ") + if not addr: + continue + + key = addr.lower() + + if key not in visited_households: + # Parse street name and number for sorting + street_number = "" + street_name = v.address_street or "" + match = re.search(r'^(\d+)\s+(.*)$', street_name) + if match: + street_number = match.group(1) + street_name = match.group(2) + + try: + street_number_sort = int(street_number) + except ValueError: + street_number_sort = 0 + + visited_households[key] = { + 'address_display': addr, + 'address_street': v.address_street, + 'city': v.city, + 'state': v.state, + 'zip_code': v.zip_code, + 'neighborhood': v.neighborhood, + 'district': v.district, + 'latitude': float(v.latitude) if v.latitude else None, + 'longitude': float(v.longitude) if v.longitude else None, + 'street_name_sort': street_name.lower(), + 'street_number_sort': street_number_sort, + 'last_visit_date': interaction.date, + 'last_volunteer': interaction.volunteer, + 'last_outcome': interaction.description, + 'notes': interaction.notes, + 'target_voters': [], + 'voters_at_address': [] + } + + phone_display = format_phone_number(v.phone) if v.phone else "" + visited_households[key]["voters_at_address"].append((v.id, f"{v.first_name} {v.last_name} - {phone_display}")) + visited_households[key]['target_voters'].append(v) + + # Sort volunteer counts by total (descending) + sorted_volunteer_counts = [ + {"name": k[0], "id": k[1], "count": v} + for k, v in sorted(volunteer_counts_dict.items(), key=lambda x: x[1], reverse=True) + ] + + history_list = list(visited_households.values()) + history_list.sort(key=lambda x: x["last_visit_date"], reverse=True) + + paginator = Paginator(history_list, 50) + page_number = request.GET.get("page") + history_page = paginator.get_page(page_number) + + context = { + "selected_tenant": tenant, + "history": history_page, + "start_date": start_date, "end_date": end_date, + "volunteer_counts": sorted_volunteer_counts, "selected_volunteer_id": volunteer_id, + } + return render(request, "core/door_visit_history.html", context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_scheduledcall') +def schedule_call(request, voter_id): + 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) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = ScheduledCallForm(request.POST, tenant=tenant) + if form.is_valid(): + call = form.save(commit=False) + call.tenant = tenant + call.voter = voter + call.save() + messages.success(request, f"Call for {voter} added to queue.") + else: + messages.error(request, "Error scheduling call.") + + referer = request.META.get('HTTP_REFERER') + if referer: + return redirect(referer) + return redirect('voter_detail', voter_id=voter.id) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.add_scheduledcall') +def bulk_schedule_calls(request): + if request.method != 'POST': + return redirect('voter_advanced_search') + + selected_tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + + voter_ids = request.POST.getlist('selected_voters') + volunteer_id = request.POST.get('volunteer') + comments = request.POST.get('comments', '') + + volunteer = None + if volunteer_id: + volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant) + else: + # Fallback to default caller if not specified in POST but available + volunteer = Volunteer.objects.filter(tenant=tenant, is_default_caller=True).first() + + voters = Voter.objects.filter(tenant=tenant, id__in=voter_ids) + + count = 0 + for voter in voters: + ScheduledCall.objects.create( + tenant=tenant, + voter=voter, + volunteer=volunteer, + comments=comments + ) + count += 1 + + messages.success(request, f"{count} calls added to queue.") + return redirect(request.META.get('HTTP_REFERER', 'voter_advanced_search')) + +@login_required +def call_queue(request): + if not can_access_call_queue(request.user): + messages.error(request, "You do not have permission to access the call queue.") + return redirect('index') + 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) + + # Determine if user is staff + user_role = get_user_role(request.user, tenant) + is_staff = request.user.is_superuser or user_role in STAFF_ROLES or request.user.groups.filter(name='Editor').exists() + + # Get volunteer profile + try: + current_volunteer = request.user.volunteer_profile + except: + current_volunteer = None + + calls = ScheduledCall.objects.filter(tenant=tenant, status='pending').order_by('created_at') + + volunteers = [] + selected_volunteer_id = request.GET.get('volunteer_filter') + + if is_staff: + volunteers = Volunteer.objects.filter(tenant=tenant, interests__name="Phone Banking").order_by("last_name", "first_name") + # Default to current user's volunteer if no filter selected + if not selected_volunteer_id and current_volunteer: + selected_volunteer_id = str(current_volunteer.id) + + if selected_volunteer_id and selected_volunteer_id != 'all': + calls = calls.filter(volunteer_id=selected_volunteer_id) + else: + # Non-staff: only show assigned calls + if current_volunteer: + calls = calls.filter(volunteer=current_volunteer) + selected_volunteer_id = str(current_volunteer.id) + else: + calls = calls.none() + + paginator = Paginator(calls, 50) + page_number = request.GET.get('page') + calls_page = paginator.get_page(page_number) + + context = { + 'selected_tenant': tenant, + 'calls': calls_page, + 'volunteers': volunteers, + 'campaign_settings': getattr(tenant, 'settings', None), + 'selected_volunteer_id': selected_volunteer_id, + 'is_staff': is_staff, + } + return render(request, 'core/call_queue.html', context) +@login_required +def complete_call(request, call_id): + if not can_access_call_queue(request.user): + messages.error(request, "You do not have permission to perform this action.") + return redirect('index') + 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) + call = get_object_or_404(ScheduledCall, id=call_id, tenant=tenant) + + if request.method == 'POST': + # Get notes from post data taken during the call + call_notes = request.POST.get('call_notes', '') + call_outcome = request.POST.get('call_outcome', 'Called Voter') + candidate_support = request.POST.get('candidate_support') + yard_sign = request.POST.get('yard_sign') + + # Update candidate support if provided + if candidate_support: + voter = call.voter + if voter.candidate_support != candidate_support: + voter.candidate_support = candidate_support + voter.save(update_fields=['candidate_support']) + + # Update yard sign status if provided and not 'no_change' + if yard_sign and yard_sign != 'no_change': + voter = call.voter + if voter.yard_sign != yard_sign: + voter.yard_sign = yard_sign + voter.save(update_fields=['yard_sign']) + + # Create interaction for the completed call + interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Phone Call") + + # Determine date/time in campaign timezone + campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant) + campaign_tz_name = campaign_settings.timezone or 'America/Chicago' + try: + tz = zoneinfo.ZoneInfo(campaign_tz_name) + except: + tz = zoneinfo.ZoneInfo('America/Chicago') + + interaction_date = timezone.now().astimezone(tz); + + Interaction.objects.create( + voter=call.voter, + volunteer=call.volunteer, + type=interaction_type, + date=interaction_date, + description=call_outcome, + notes=call_notes + ) + + call.status = 'completed'; + call.save() + if call_outcome == 'No Answer No Voice Mail': + voter = call.voter + # If no other pending calls, reset to 'to_be_called' instead of 'called' + if not ScheduledCall.objects.filter(voter=voter, status='pending').exists(): + Voter.objects.filter(pk=voter.pk).update(call_queue_status='to_be_called') + messages.success(request, f"Call for {call.voter} marked as completed and interaction logged.") + + return redirect('call_queue') + +@login_required +def delete_call(request, call_id): + if not can_access_call_queue(request.user): + messages.error(request, "You do not have permission to perform this action.") + return redirect('index') + 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) + call = get_object_or_404(ScheduledCall, id=call_id, tenant=tenant) + + if request.method == 'POST': + call.delete() + messages.success(request, "Call removed from queue.") + + return redirect('call_queue') + +@login_required +def profile(request): + try: + volunteer = request.user.volunteer_profile + except: + volunteer = None + + if request.method == 'POST': + u_form = UserUpdateForm(request.POST, instance=request.user) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def neighborhood_counts(request): + """ + Shows household counts by neighborhood after applying filters from door visits. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + + city_filter = request.GET.get("city", "").strip() + district_filter = request.GET.get('district', '').strip() + neighborhood_filter = request.GET.get('neighborhood', '').strip() + address_filter = request.GET.get('address', '').strip() + + voters = Voter.objects.filter(tenant=tenant, is_inactive=False, door_visit=False, target_door_visit=True) + + if city_filter: + voters = voters.filter(city__icontains=city_filter) + if district_filter: + voters = voters.filter(district=district_filter) + if neighborhood_filter: + voters = voters.filter(neighborhood__icontains=neighborhood_filter) + if address_filter: + voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter)) + + household_qs = voters.values('neighborhood', 'address_street', 'city', 'state', 'zip_code').distinct() + + neighborhood_counts_dict = {} + for h in household_qs: + nb = h['neighborhood'] + neighborhood_counts_dict[nb] = neighborhood_counts_dict.get(nb, 0) + 1 + + neighborhood_list = [ + {'neighborhood': nb, 'display_name': nb or "Unknown", 'count': count} + for nb, count in neighborhood_counts_dict.items() + ] + + neighborhood_list.sort(key=lambda x: x['count'], reverse=True) + + context = { + 'selected_tenant': tenant, + 'neighborhoods': neighborhood_list, + 'city_filter': city_filter, + 'district_filter': district_filter, + 'neighborhood_filter': neighborhood_filter, + 'address_filter': address_filter, + } + return render(request, 'core/neighborhood_counts.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def yard_sign_voters(request): + """ + Manage yard sign requests. Groups voters who want a yard sign by household. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + + city_filter = request.GET.get("city", "").strip() + district_filter = request.GET.get('district', '').strip() + neighborhood_filter = request.GET.get('neighborhood', '').strip() + address_filter = request.GET.get('address', '').strip() + sign_type_filter = request.GET.get('sign_type', '').strip() + + # Initial queryset: voters who want a yard sign for this tenant + voters = Voter.objects.filter(tenant=tenant, is_inactive=False, yard_sign__in=['wants', 'wants_large']) + + if city_filter: + voters = voters.filter(city__icontains=city_filter) + if district_filter: + voters = voters.filter(district=district_filter) + if neighborhood_filter: + voters = voters.filter(neighborhood__icontains=neighborhood_filter) + if address_filter: + voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter)) + + if sign_type_filter == 'yard': + voters = voters.filter(yard_sign='wants') + elif sign_type_filter == 'large': + voters = voters.filter(yard_sign='wants_large') + + # Grouping by household (unique address) + households_dict = {} + for voter in voters: + # Normalize address components for robust grouping + street = (voter.address_street or "").strip() + city = (voter.city or "").strip() + state = (voter.state or "").strip() + zip_code = (voter.zip_code or "").strip() + + key = (street.lower(), city.lower(), state.lower(), zip_code.lower()) + + if key not in households_dict: + street_number = "" + street_name = street + match = re.match(r'^(\d+)\s+(.*)$', street) + if match: + street_number = match.group(1) + street_name = match.group(2) + + try: + street_number_sort = int(street_number) + except (ValueError, TypeError): + street_number_sort = 0 + + households_dict[key] = { + 'address_street': street, + 'city': city, + 'state': state, + 'zip_code': zip_code, + 'neighborhood': (voter.neighborhood or "").strip(), + 'district': (voter.district or "").strip(), + 'latitude': float(voter.latitude) if voter.latitude else None, + 'longitude': float(voter.longitude) if voter.longitude else None, + 'street_name_sort': street_name.lower(), + 'street_number_sort': street_number_sort, + 'voters_who_want_sign': [], + 'sign_types': set(), + 'has_large_request': False, + } + else: + # Pick a non-empty neighborhood/district if the current one is empty + if not households_dict[key]['neighborhood'] and voter.neighborhood: + households_dict[key]['neighborhood'] = voter.neighborhood.strip() + if not households_dict[key]['district'] and voter.district: + households_dict[key]['district'] = voter.district.strip() + + households_dict[key]['voters_who_want_sign'].append(voter) + households_dict[key]['sign_types'].add('Large Sign' if voter.yard_sign == 'wants_large' else 'Yard Sign') + if voter.yard_sign == 'wants_large': + households_dict[key]['has_large_request'] = True + + households_list = list(households_dict.values()) + for h in households_list: + h['sign_types_display'] = ", ".join(sorted(list(h['sign_types']))) + + # Sort by neighborhood presence, then neighborhood name, then street name, then street number + households_list.sort(key=lambda x: ( + not bool(x['neighborhood']), + (x['neighborhood'] or '').lower(), + x['street_name_sort'], + x['street_number_sort'] + )) + + # Prepare data for Google Map + # Limit map markers to 3000 for performance + map_data = [ + { + "lat": h["latitude"], + "lng": h["longitude"], + "address": f"{h.get('address_street', '')}, {h.get('city', '')}, {h.get('state', '')}", + "voters": [{"id": v.id, "name": f"{v.first_name} {v.last_name}"} for v in h["voters_who_want_sign"]], + "notes": ", ".join([f"{v.first_name}: {v.notes}" for v in h["voters_who_want_sign"] if v.notes]), + "voter_ids": [v.id for v in h["voters_who_want_sign"]], + "is_large": h["has_large_request"] + } + for h in households_list[:3000] if h["latitude"] and h["longitude"] + ] + + paginator = Paginator(households_list, 50) + page_number = request.GET.get('page') + households_page = paginator.get_page(page_number) + + context = { + 'selected_tenant': tenant, + 'households': households_page, + 'district_filter': district_filter, + 'neighborhood_filter': neighborhood_filter, + 'address_filter': address_filter, + "city_filter": city_filter, + "sign_type_filter": sign_type_filter, + 'map_data_json': json.dumps(map_data), + 'GOOGLE_MAPS_API_KEY': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''), + 'map_limit_reached': len(households_list) > 3000, + } + return render(request, 'core/yard_sign_voters.html', context) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def view_signs(request): + """ + View voters who already have a yard sign. Groups them by household. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + + city_filter = request.GET.get("city", "").strip() + district_filter = request.GET.get('district', '').strip() + neighborhood_filter = request.GET.get('neighborhood', '').strip() + address_filter = request.GET.get('address', '').strip() + sign_type_filter = request.GET.get('sign_type', '').strip() + + # Initial queryset: voters who have a yard sign for this tenant + voters = Voter.objects.filter(tenant=tenant, is_inactive=False, yard_sign__in=['has', 'has_large']) + + if city_filter: + voters = voters.filter(city__icontains=city_filter) + if district_filter: + voters = voters.filter(district=district_filter) + if neighborhood_filter: + voters = voters.filter(neighborhood__icontains=neighborhood_filter) + if address_filter: + voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter)) + + if sign_type_filter == 'yard': + voters = voters.filter(yard_sign='has') + elif sign_type_filter == 'large': + voters = voters.filter(yard_sign='has_large') + + # Grouping by household (unique address) + households_dict = {} + for voter in voters: + # Normalize address components for robust grouping + street = (voter.address_street or "").strip() + city = (voter.city or "").strip() + state = (voter.state or "").strip() + zip_code = (voter.zip_code or "").strip() + + key = (street.lower(), city.lower(), state.lower(), zip_code.lower()) + + if key not in households_dict: + street_number = "" + street_name = street + match = re.match(r'^(\d+)\s+(.*)$', street) + if match: + street_number = match.group(1) + street_name = match.group(2) + + try: + street_number_sort = int(street_number) + except (ValueError, TypeError): + street_number_sort = 0 + + households_dict[key] = { + 'address_street': street, + 'city': city, + 'state': state, + 'zip_code': zip_code, + 'neighborhood': (voter.neighborhood or "").strip(), + 'district': (voter.district or "").strip(), + 'latitude': float(voter.latitude) if voter.latitude else None, + 'longitude': float(voter.longitude) if voter.longitude else None, + 'street_name_sort': street_name.lower(), + 'street_number_sort': street_number_sort, + 'voters_with_sign': [], + 'sign_types': set(), + 'has_large': False, + } + else: + # Pick a non-empty neighborhood/district if the current one is empty + if not households_dict[key]['neighborhood'] and voter.neighborhood: + households_dict[key]['neighborhood'] = voter.neighborhood.strip() + if not households_dict[key]['district'] and voter.district: + households_dict[key]['district'] = voter.district.strip() + + households_dict[key]['voters_with_sign'].append(voter) + households_dict[key]['sign_types'].add('Large Sign' if voter.yard_sign == 'has_large' else 'Yard Sign') + if voter.yard_sign == 'has_large': + households_dict[key]['has_large'] = True + + households_list = list(households_dict.values()) + for h in households_list: + h['sign_types_display'] = ", ".join(sorted(list(h['sign_types']))) + + # Sort + households_list.sort(key=lambda x: ( + not bool(x['neighborhood']), + (x['neighborhood'] or '').lower(), + x['street_name_sort'], + x['street_number_sort'] + )) + + # Prepare data for Google Map + map_data = [ + { + 'lat': h['latitude'], + 'lng': h['longitude'], + 'address': f"{h['address_street']}, {h['city']}, {h['state']}", + 'voters': [{"id": v.id, "name": f"{v.first_name} {v.last_name}"} for v in h['voters_with_sign']], + 'is_large': h['has_large'] + } + for h in households_list[:3000] if h['latitude'] and h['longitude'] + ] + + paginator = Paginator(households_list, 50) + page_number = request.GET.get('page') + households_page = paginator.get_page(page_number) + + context = { + 'selected_tenant': tenant, + 'households': households_page, + 'district_filter': district_filter, + 'neighborhood_filter': neighborhood_filter, + 'address_filter': address_filter, + "city_filter": city_filter, + "sign_type_filter": sign_type_filter, + 'map_data_json': json.dumps(map_data), + 'GOOGLE_MAPS_API_KEY': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''), + 'map_limit_reached': len(households_list) > 3000, + } + return render(request, 'core/view_signs.html', context) +def get_tenant_email_connection(campaign_settings): + use_tls = campaign_settings.smtp_use_tls + use_ssl = campaign_settings.smtp_use_ssl + + # EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive + if use_tls and use_ssl: + if campaign_settings.smtp_port == 465: + use_tls = False + else: + use_ssl = False + + return get_connection( + host=campaign_settings.smtp_host, + port=campaign_settings.smtp_port, + username=campaign_settings.smtp_username, + password=campaign_settings.smtp_password, + use_tls=use_tls, + use_ssl=use_ssl, + ) + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin']) +def volunteer_bulk_send_email(request): + selected_tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + campaign_settings = CampaignSettings.objects.get(tenant=tenant) + + if request.method == 'POST': + volunteer_ids = request.POST.getlist('selected_volunteers') + subject = request.POST.get('subject') + body = request.POST.get('body') + is_html = request.POST.get("is_html") == "on" + + volunteers = Volunteer.objects.filter(id__in=volunteer_ids, tenant=tenant).exclude(email='') + if not volunteers.exists(): + messages.warning(request, "No volunteers with email addresses selected.") + return redirect('volunteer_list') + + connection = get_tenant_email_connection(campaign_settings) + if not connection: + messages.error(request, "SMTP settings are not configured. Please check Campaign Settings.") + return redirect('volunteer_list') + + from_email = campaign_settings.email_from_address or settings.DEFAULT_FROM_EMAIL + if campaign_settings.email_from_name: + from_email = f"{campaign_settings.email_from_name} <{from_email}>" + + sent_count = 0 + error_count = 0 + + for volunteer in volunteers: + try: + email = EmailMessage( + subject, + body, + from_email, + [volunteer.email], + connection=connection, + ) + if is_html: + email.content_subtype = "html" + email.send() + sent_count += 1 + except Exception as e: + logger.error(f"Error sending bulk email to volunteer {volunteer.email}: {e}") + error_count += 1 + + if sent_count > 0: + messages.success(request, f"Successfully sent {sent_count} emails.") + if error_count > 0: + messages.error(request, f"Failed to send {error_count} emails.") + + return redirect('volunteer_list') + +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin']) +def voter_bulk_send_email(request): + selected_tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + campaign_settings = CampaignSettings.objects.get(tenant=tenant) + + if request.method == 'POST': + select_all_results = request.POST.get('select_all_results') == 'true' + subject = request.POST.get('subject') + body = request.POST.get('body') + is_html = request.POST.get("is_html") == "on" + + 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: + import logging + logger = logging.getLogger(__name__) + 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') + +def mark_yard_sign_delivered(request): + """ + Action to mark yard signs as delivered for one or more voters. + """ + is_ajax = request.headers.get('x-requested-with') == 'XMLHttpRequest' or request.POST.get('is_ajax') == 'true' + + if request.method == "POST": + voter_ids = request.POST.getlist('voter_ids') + selected_tenant_id = request.session.get("tenant_id") + if voter_ids and selected_tenant_id: + voters_to_update = Voter.objects.filter( + id__in=voter_ids, + tenant_id=selected_tenant_id, + yard_sign__in=['wants', 'wants_large'] + ) + updated_count = 0 + for v in voters_to_update: + if v.yard_sign == 'wants': + v.yard_sign = 'has' + elif v.yard_sign == 'wants_large': + v.yard_sign = 'has_large' + v.save(update_fields=['yard_sign']) + updated_count += 1 + + if updated_count > 0: + msg = f"Successfully marked sign(s) as delivered for {updated_count} voter(s)." + if is_ajax: + return JsonResponse({'success': True, 'message': msg, 'updated_count': updated_count}) + messages.success(request, msg) + else: + msg = "No voter records were updated." + if is_ajax: + return JsonResponse({'success': False, 'message': msg}) + messages.warning(request, msg) + else: + msg = "Invalid request: No voters selected." + if is_ajax: + return JsonResponse({'success': False, 'message': msg}) + messages.error(request, msg) + + if is_ajax: + return JsonResponse({'success': False, 'message': 'Invalid request method.'}) + + return redirect(request.META.get('HTTP_REFERER', 'yard_sign_voters')) + +@login_required +def populate_call_queue(request): + if not can_access_call_queue(request.user): + messages.error(request, "You do not have permission to perform this action.") + return redirect('index') + + 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) + + # Get the volunteer profile for the current user + volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first() + if not volunteer: + messages.error(request, "No volunteer profile found for your user account.") + return redirect('call_queue') + + if request.method == 'POST': + num_voters_raw = request.POST.get('num_voters', '5') + try: + num_voters = int(num_voters_raw) + except ValueError: + num_voters = 5 + + district = request.POST.get('district', '').strip() + include_door_visits = request.POST.get('include_door_visits') == 'on' + + # Build queryset + voters = Voter.objects.filter( + tenant=tenant, + call_queue_status='to_be_called', + is_inactive=False + ).exclude( + Q(phone="") & Q(secondary_phone="") + ) + + if district: + voters = voters.filter(district__iexact=district) + + if not include_door_visits: + voters = voters.filter(door_visit=False) + + # Select the top N voters + voters_to_add = voters[:num_voters] + + added_count = 0 + with transaction.atomic(): + for voter in voters_to_add: + ScheduledCall.objects.create( + tenant=tenant, + voter=voter, + volunteer=volunteer, + status='pending' + ) + added_count += 1 + + if added_count > 0: + messages.success(request, f"Successfully added {added_count} voters to your call queue.") + else: + messages.info(request, "No voters found matching your criteria.") + + return redirect('call_queue') diff --git a/core/views.py_start.py b/core/views.py_start.py new file mode 100644 index 0000000..341e767 --- /dev/null +++ b/core/views.py_start.py @@ -0,0 +1,59 @@ +import os +from django.contrib.auth.decorators import login_required +from django.contrib.auth.forms import PasswordChangeForm +from django.utils.dateparse import parse_date +from datetime import datetime, time, timedelta +import base64 +import re +import urllib.parse +import urllib.request +import csv +import io +import json +from django.http import JsonResponse, HttpResponse +from django.urls import reverse +from django.shortcuts import render, redirect, get_object_or_404 +from django.db import transaction +from django.db.models import Q, Sum, Value, DecimalField +from django.contrib import messages +from django.core.paginator import Paginator +from django.conf import settings +from django.db.models.functions import Coalesce +from .models import format_phone_number, Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest, VolunteerRole, ScheduledCall, BulkTask +from .filter_helper import get_filtered_voter_queryset, get_phone_search_filters +from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm, ScheduledCallForm, UserUpdateForm, EventParticipationImportForm, ParticipantMappingForm +from django.core.mail import get_connection, EmailMessage +import logging +import zoneinfo +from django.utils import timezone + +from .permissions import role_required, can_view_donations, can_edit_voter, can_view_volunteers, can_edit_volunteer, can_view_voters, get_user_role, STAFF_ROLES, can_access_call_queue +logger = logging.getLogger(__name__) + +from .task_runners import start_bulk_sms_task + +def _robust_decode(content): + if not content: + return "" + for enc in ["utf-8-sig", "utf-8", "iso-8859-1", "windows-1252"]: + try: + return content.decode(enc) + except UnicodeDecodeError: + continue + return content.decode("utf-8", errors="replace") + + +def _handle_uploaded_file(uploaded_file): + """ + Handles uploaded CSV files, saves them to a temporary file, and extracts headers. + Returns (headers, temp_file_path) or (None, None) if an error occurs. + """ + import tempfile + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp: + for chunk in uploaded_file.chunks(): + tmp.write(chunk) + return tmp.name + except Exception as e: + logger.error(f"Error handling uploaded file: {e}") + return None diff --git a/core/views_new.py b/core/views_new.py new file mode 100644 index 0000000..6df3a0a --- /dev/null +++ b/core/views_new.py @@ -0,0 +1,39 @@ +@role_required(['admin', 'campaign_manager', 'campaign_staff', 'system_admin', 'campaign_admin'], permission='core.view_voter') +def voter_advanced_search(request): + """ + Advanced search for voters with multiple filters. + """ + 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) + voters, form = get_filtered_voter_queryset(request, tenant) + + paginator = Paginator(voters, 50) + page_number = request.GET.get('page') + voters_page = paginator.get_page(page_number) + + context = { + 'form': form, + 'voters': voters_page, + 'selected_tenant': tenant, + 'call_form': ScheduledCallForm(tenant=tenant), + 'can_edit_voter': can_edit_voter(request.user, tenant), + } + + # Pre-calculate markers for the map to avoid overhead in templates + map_data = [] + # For performance, only map voters on the current page + for voter in voters_page: + if voter.latitude and voter.longitude: + map_data.append({ + 'lat': float(voter.latitude), + 'lng': float(voter.longitude), + 'voters': f"{voter.first_name} {voter.last_name}", + 'address': voter.address + }) + context['map_data_json'] = json.dumps(map_data) + + return render(request, 'core/voter_advanced_search.html', context) \ No newline at end of file diff --git a/core/voter_import_logic.py b/core/voter_import_logic.py new file mode 100644 index 0000000..6f55bb9 --- /dev/null +++ b/core/voter_import_logic.py @@ -0,0 +1,182 @@ +import io +import csv +import os + +def _robust_decode(content): + if not content: return "" + for enc in ["utf-8-sig", "utf-8", "iso-8859-1", "windows-1252"]: + try: return content.decode(enc) + except UnicodeDecodeError: continue + return content.decode("utf-8", errors="replace") + +def _read_csv_robust(file_path): + with open(file_path, "rb") as f: + return io.StringIO(_robust_decode(f.read())) + + def import_voters(self, request): + if request.method == "POST": + if "_preview" in request.POST: + file_path = request.POST.get("file_path") + tenant_id = request.POST.get("tenant") + tenant = Tenant.objects.get(id=tenant_id) + + mapping = {} + for field_name, _ in VOTER_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f"map_{field_name}") + + try: + with _read_csv_robust(file_path) as f: + total_count = sum(1 for line in f) - 1 + f.seek(0) + reader = csv.DictReader(f) + preview_rows = [] + voter_ids_for_preview = [] + for i, row in enumerate(reader): + if i < 10: + preview_rows.append(row) + v_id = row.get(mapping.get("voter_id")) + if v_id: + voter_ids_for_preview.append(v_id.strip()) + else: + break + + existing_preview_ids = set(Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids_for_preview).values_list("voter_id", flat=True)) + + preview_data = [] + for row in preview_rows: + voter_id_val = row.get(mapping.get("voter_id")) + action = "update" if voter_id_val and voter_id_val.strip() in existing_preview_ids else "create" + preview_data.append({ + "action": action, + "identifier": f"Voter ID: {voter_id_val}", + "details": f"Name: {row.get(mapping.get('first_name', ''))} {row.get(mapping.get('last_name', ''))}" + }) + + context = self.admin_site.each_context(request) + context.update({ + "title": "Import Preview", + "total_count": total_count, + "create_count": sum(1 for d in preview_data if d['action'] == 'create'), + "update_count": sum(1 for d in preview_data if d['action'] == 'update'), + "preview_data": preview_data, + "mapping": mapping, + "file_path": file_path, + "tenant_id": tenant_id, + "action_url": request.path, + "opts": self.model._meta, + }) + return render(request, "admin/import_preview.html", context) + except Exception as e: + self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR) + return redirect("../") + elif "_import" in request.POST: + file_path = request.POST.get("file_path") + tenant_id = request.POST.get("tenant") + tenant = Tenant.objects.get(id=tenant_id) + + mapping = {} + for field_name, _ in VOTER_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f"map_{field_name}") + + try: + created_count = 0 + updated_count = 0 + errors = 0 + failed_rows = [] + + with _read_csv_robust(file_path) as f_read: + reader = csv.DictReader(f_read) + for row in reader: + try: + raw_voter_id = row.get(mapping.get("voter_id")) + voter_id = raw_voter_id.strip() if raw_voter_id else None + + if not voter_id: + row["Import Error"] = "Voter ID is required." + failed_rows.append(row) + errors += 1 + continue + + defaults = {} + for field_name, _ in VOTER_MAPPABLE_FIELDS: + if field_name == "voter_id": continue + csv_column = mapping.get(field_name) + if csv_column and csv_column in row: + field_value = row[csv_column].strip() + if field_name in ["birthdate", "registration_date"]: + defaults[field_name] = parse_any_date(field_value) + elif field_name in ["is_targeted", "is_inactive", "target_door_visit", "door_visit", "voted"]: + if field_value.lower() in ['true', '1', 'yes']: + defaults[field_name] = True + elif field_value.lower() in ['false', '0', 'no']: + defaults[field_name] = False + elif field_name == "phone": + defaults[field_name] = format_phone_number(field_value) + elif field_name == "email": + defaults[field_name] = field_value.lower() + else: + defaults[field_name] = field_value + + if defaults.get("voted") is True: + defaults["target_door_visit"] = False + defaults["call_queue_status"] = "no_call_required" + + voter, created = Voter.objects.update_or_create( + tenant=tenant, + voter_id=voter_id, + defaults=defaults + ) + if created: created_count += 1 + else: updated_count += 1 + + except Exception as e: + row["Import Error"] = str(e) + failed_rows.append(row) + errors += 1 + + from django.db.models import Exists, OuterRef + subquery = Voter.objects.filter(address_street=OuterRef('address_street'), city=OuterRef('city'), state=OuterRef('state'), zip_code=OuterRef('zip_code'), tenant=tenant, is_targeted=True) + Voter.objects.filter(tenant=tenant, door_visit=False, target_door_visit=True).annotate(has_targeted=Exists(subquery)).filter(has_targeted=False).update(target_door_visit=False) + if os.path.exists(file_path): os.remove(file_path) + self.message_user(request, f"Import complete: {created_count + updated_count} voters processed. ({created_count} created, {updated_count} updated, {errors} errors)") + request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows[:1000] + request.session.modified = True + + if errors > 0: + error_url = reverse("admin:voter-download-errors") + self.message_user(request, mark_safe(f"Failed to import {errors} rows. Download failed records"), level=messages.WARNING) + return redirect("../") + except Exception as e: + self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) + return redirect("../") + else: + form = VoterImportForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = request.FILES['file'] + tenant = form.cleaned_data['tenant'] + + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in csv_file.chunks(): tmp.write(chunk) + file_path = tmp.name + + with _read_csv_robust(file_path) as f: + reader = csv.reader(f) + headers = next(reader) + + context = self.admin_site.each_context(request) + context.update({ + "title": "Map Voter Fields", + "headers": headers, + "model_fields": VOTER_MAPPABLE_FIELDS, + "tenant_id": tenant.id, + "file_path": file_path, + "action_url": request.path, + "opts": self.model._meta, + }) + return render(request, "admin/import_mapping.html", context) + else: + form = VoterImportForm() + + context = self.admin_site.each_context(request) + context.update({'form': form, 'title': "Import Voters", 'opts': self.model._meta}) + return render(request, "admin/import_csv.html", context) diff --git a/db/config.php b/db/config.php new file mode 100644 index 0000000..40e9d76 --- /dev/null +++ b/db/config.php @@ -0,0 +1,17 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + } + return $pdo; +} diff --git a/fix_door_visits.py b/fix_door_visits.py new file mode 100644 index 0000000..4025523 --- /dev/null +++ b/fix_door_visits.py @@ -0,0 +1,48 @@ +import sys +import re + +file_path = 'core/views.py' +with open(file_path, 'r') as f: + content = f.read() + +# Fix door_visits which was accidentally patched +# Find door_visits function and remove the sign_types_display loop if it's there +# Actually, I'll just check where sign_types is NOT initialized but h['sign_types'] is used. + +# A more specific approach: +# Replace the bad part in door_visits. +bad_part = """ + households_list = list(households_dict.values()) + for h in households_list: + h['sign_types_display'] = ", ".join(sorted(list(h['sign_types']))) + del households_dict # Free memory""" + +good_part = """ + households_list = list(households_dict.values()) + del households_dict # Free memory""" + +# But wait, yard_sign_voters and view_signs DO need this loop. +# I need to distinguish them. + +# Let's use re.finditer to find all occurrences and check context. +# Or just use the fact that door_visits has 'voters_json' or 'target_voters' initialized. + +def fix_views(text): + # Match functions + functions = re.split(r'\n(?=def )', text) + new_functions = [] + for func in functions: + if 'def door_visits' in func: + # Remove the sign_types_display loop + func = func.replace( + " for h in households_list:\n h['sign_types_display'] = \", \".join(sorted(list(h['sign_types']))) ", + "" + ) + new_functions.append(func) + return '\n'.join(new_functions) + +content = fix_views(content) + +with open(file_path, 'w') as f: + f.write(content) + diff --git a/fix_duplication.py b/fix_duplication.py new file mode 100644 index 0000000..716b0f1 --- /dev/null +++ b/fix_duplication.py @@ -0,0 +1,21 @@ +with open('core/models.py', 'r') as f: + lines = f.readlines() + +new_lines = [] +skip = False +for i in range(len(lines)): + if skip: + if "return" in lines[i]: + skip = False + continue + + # Check for duplication of the Priority 0 block + if "PRIORITY 0: If they voted" in lines[i]: + # If we already added this block recently, skip the next one if it matches + if len(new_lines) > 0 and "PRIORITY 0: If they voted" in "".join(new_lines[-10:]): + skip = True + continue + new_lines.append(lines[i]) + +with open('core/models.py', 'w') as f: + f.writelines(new_lines) diff --git a/fix_redundancy.py b/fix_redundancy.py new file mode 100644 index 0000000..7460ebc --- /dev/null +++ b/fix_redundancy.py @@ -0,0 +1,36 @@ +import sys + +file_path = 'core/views.py' +with open(file_path, 'r') as f: + lines = f.readlines() + +new_lines = [] +seen_keys = set() +current_dict = False + +for line in lines: + if '{' in line: + current_dict = True + seen_keys = set() + new_lines.append(line) + continue + if '}' in line: + current_dict = False + new_lines.append(line) + continue + + if current_dict: + match = re.search(r"'([^']+)':", line) + if match: + key = match.group(1) + if key in seen_keys: + if key in ['sign_types', 'has_large_request', 'has_large_sign']: + continue # Skip redundant keys + seen_keys.add(key) + + new_lines.append(line) + +import re # needed in the loop above but imported here for the script + +with open(file_path, 'w') as f: + f.write("".join(new_lines)) diff --git a/fix_view.py b/fix_view.py new file mode 100644 index 0000000..ef8bbda --- /dev/null +++ b/fix_view.py @@ -0,0 +1,127 @@ +def door_visits(request): + """ + Manage door knocking visits. Groups unvisited targeted voters by household. + Optimized to handle large datasets more efficiently. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + + city_filter = request.GET.get("city", "").strip() + district_filter = request.GET.get('district', '').strip() + neighborhood_filter = request.GET.get('neighborhood', '').strip() + address_filter = request.GET.get('address', '').strip() + + voters = Voter.objects.filter(tenant=tenant, is_inactive=False, door_visit=False, target_door_visit=True) + + if city_filter: + voters = voters.filter(city__icontains=city_filter) + if district_filter: + voters = voters.filter(district=district_filter) + if neighborhood_filter: + voters = voters.filter(neighborhood__icontains=neighborhood_filter) + if address_filter: + voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter)) + + # Optimization: Use iterator and lighter data structure + # We only fetch needed fields. Compound index helps here. + voters_iterator = voters.values( + 'id', 'first_name', 'last_name', 'address_street', 'city', 'state', + 'zip_code', 'neighborhood', 'district', 'latitude', 'longitude', 'phone' + ).iterator(chunk_size=2000) + + households_dict = {} + for v in voters_iterator: + street = (v['address_street'] or "").strip() + city = (v['city'] or "").strip() + state = (v['state'] or "").strip() + zip_code = (v['zip_code'] or "").strip() + + key = (street.lower(), city.lower(), state.lower(), zip_code.lower()) + + if key not in households_dict: + street_number = "" + street_name = street + match_street = re.match(r'^(\d+)\s+(.*)$', street) + if match_street: + street_number = match_street.group(1) + street_name = match_street.group(2) + + try: + street_number_sort = int(street_number) + except (ValueError, TypeError): + street_number_sort = 0 + + households_dict[key] = { + 'address_street': street, + 'city': city, + 'state': state, + 'zip_code': zip_code, + 'neighborhood': (v['neighborhood'] or "").strip(), + 'district': (v['district'] or "").strip(), + 'latitude': float(v['latitude']) if v['latitude'] else None, + 'longitude': float(v['longitude']) if v['longitude'] else None, + 'street_name_sort': street_name.lower(), + 'street_number_sort': street_number_sort, + 'target_voters': [], + 'voters_json': [] + } + else: + if not households_dict[key]['neighborhood'] and v['neighborhood']: + households_dict[key]['neighborhood'] = v['neighborhood'].strip() + if not households_dict[key]['district'] and v['district']: + households_dict[key]['district'] = v['district'].strip() + + households_dict[key]['target_voters'].append(v) + phone_display = format_phone_number(v['phone']) if v['phone'] else "" + households_dict[key]['voters_json'].append({'id': v['id'], 'name': f"{v['first_name']} {v['last_name']} - {phone_display}" }) + + households_list = list(households_dict.values()) + del households_dict # Free memory + + households_list.sort(key=lambda x: ( + not bool(x['neighborhood']), + (x['neighborhood'] or '').lower(), + x['street_name_sort'], + x['street_number_sort'] + )) + + # Optimization: Map data should only be for visible results or limited + # We still keep the limit of 3000 for map + map_data = [] + # Always try to show up to 3000 markers instead of showing 0 if count > 3000 + for h in households_list[:3000]: + if h['latitude'] and h['longitude']: + map_data.append({ + 'lat': h['latitude'], + 'lng': h['longitude'], + 'address_street': h['address_street'], + 'city': h['city'], + 'state': h['state'], + 'zip_code': h['zip_code'], + 'address': f"{h['address_street']}, {h['city']}", + 'voters': ", ".join([f"{v['first_name']} {v['last_name']}" for v in h['target_voters']]) + }) + + for h in households_list: + h['voters_json_str'] = json.dumps(h['voters_json']) + + paginator = Paginator(households_list, 50) + page_number = request.GET.get('page') + households_page = paginator.get_page(page_number) + + context = { + 'selected_tenant': tenant, + 'households': households_page, + 'district_filter': district_filter, + 'neighborhood_filter': neighborhood_filter, + 'address_filter': address_filter, + 'city_filter': city_filter, + 'map_data_json': json.dumps(map_data), + 'map_limit_reached': len(households_list) > 3000, + 'GOOGLE_MAPS_API_KEY': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''), + } + return render(request, 'core/door_visits.html', context) diff --git a/fix_voted_voters.py b/fix_voted_voters.py new file mode 100644 index 0000000..3d92840 --- /dev/null +++ b/fix_voted_voters.py @@ -0,0 +1,24 @@ +from core.models import Voter, ScheduledCall +import django + +def fix_voter_stats(): + # Fix 1: Voted = True -> Target Door Visit = False + voters_to_fix = Voter.objects.filter(voted=True, target_door_visit=True) + count1 = voters_to_fix.count() + voters_to_fix.update(target_door_visit=False) + print(f"Fixed {count1} voters: voted=True, but had target_door_visit=True") + + # Fix 2: Voted = True -> Call Queue Status = no_call_required + voters_to_fix_calls = Voter.objects.filter(voted=True).exclude(call_queue_status='no_call_required') + count2 = voters_to_fix_calls.count() + voters_to_fix_calls.update(call_queue_status='no_call_required') + print(f"Fixed {count2} voters: voted=True, but had call_queue_status != 'no_call_required'") + + # Fix 3: Cancel pending calls for those who voted + pending_calls = ScheduledCall.objects.filter(voter__voted=True, status='pending') + count3 = pending_calls.count() + pending_calls.update(status='cancelled') + print(f"Cancelled {count3} pending calls for voters who have already voted.") + +if __name__ == "__main__": + fix_voter_stats() diff --git a/fix_voter_search.py b/fix_voter_search.py new file mode 100644 index 0000000..41869c5 --- /dev/null +++ b/fix_voter_search.py @@ -0,0 +1,174 @@ + +with open('core/views.py', 'r') as f: + lines = f.readlines() + +new_lines = [] +skip = False +for i, line in enumerate(lines): + if 'def voter_advanced_search(request):' in line: + new_lines.append(line) + # Reconstruct the whole function + new_lines.append(' """ +') + new_lines.append(' Advanced search for voters with multiple filters. +') + new_lines.append(' """ +') + new_lines.append(' selected_tenant_id = request.session.get("tenant_id") +') + new_lines.append(' if not selected_tenant_id: +') + new_lines.append(' messages.warning(request, "Please select a campaign first.") +') + new_lines.append(' return redirect("index") + +') + new_lines.append(' tenant = get_object_or_404(Tenant, id=selected_tenant_id) +') + new_lines.append(' voters = Voter.objects.filter(tenant=tenant, is_inactive=False).order_by("last_name", "first_name") + +') + new_lines.append(' form = AdvancedVoterSearchForm(request.GET) +') + new_lines.append(' if form.is_valid(): +') + new_lines.append(' data = form.cleaned_data +') + new_lines.append(' if data.get("first_name"): +') + new_lines.append(' voters = voters.filter(first_name__icontains=data["first_name"]) +') + new_lines.append(' if data.get("last_name"): +') + new_lines.append(' voters = voters.filter(last_name__icontains=data["last_name"]) +') + new_lines.append(' if data.get("address"): +') + new_lines.append(' voters = voters.filter(Q(address__icontains=data["address"]) | Q(address_street__icontains=data["address"])) +') + new_lines.append(' if data.get("voter_id"): +') + new_lines.append(' voters = voters.filter(voter_id__iexact=data["voter_id"]) +') + new_lines.append(' if data.get("birth_month"): +') + new_lines.append(' voters = voters.filter(birthdate__month=data["birth_month"]) +') + new_lines.append(' if data.get("city"): +') + new_lines.append(' voters = voters.filter(city__icontains=data["city"]) +') + new_lines.append(' if data.get("zip_code"): +') + new_lines.append(' voters = voters.filter(zip_code__icontains=data["zip_code"]) +') + new_lines.append(' if data.get("neighborhood"): +') + new_lines.append(' voters = voters.filter(neighborhood__icontains=data["neighborhood"]) +') + new_lines.append(' if data.get("district"): +') + new_lines.append(' voters = voters.filter(district=data["district"]) +') + new_lines.append(' if data.get("precinct"): +') + new_lines.append(' voters = voters.filter(precinct=data["precinct"]) +') + new_lines.append(' if data.get("email"): +') + new_lines.append(' voters = voters.filter(email__icontains=data["email"]) +') + new_lines.append(' if data.get("phone"): +') + new_lines.append(' voters = voters.filter(get_phone_search_filters(data["phone"])) +') + new_lines.append(' if data.get("phone_type"): +') + new_lines.append(' voters = voters.filter(phone_type=data["phone_type"]) +') + new_lines.append(' if data.get("is_targeted"): +') + new_lines.append(' voters = voters.filter(is_targeted=(data["is_targeted"] == "True")) +') + new_lines.append(' if data.get("target_door_visit"): +') + new_lines.append(' voters = voters.filter(target_door_visit=(data["target_door_visit"] == "True")) +') + new_lines.append(' if data.get("door_visit"): +') + new_lines.append(' voters = voters.filter(door_visit=(data["door_visit"] == "True")) +') + new_lines.append(' if data.get("voted"): +') + new_lines.append(' voters = voters.filter(voted=(data["voted"] == "True")) +') + new_lines.append(' if data.get("candidate_support"): +') + new_lines.append(' voters = voters.filter(candidate_support=data["candidate_support"]) +') + new_lines.append(' if data.get("yard_sign"): +') + new_lines.append(' voters = voters.filter(yard_sign=data["yard_sign"]) +') + new_lines.append(' if data.get("window_sticker"): +') + new_lines.append(' voters = voters.filter(window_sticker=data["window_sticker"]) +') + new_lines.append(' if data.get("call_queue_status"): +') + new_lines.append(' voters = voters.filter(call_queue_status=data["call_queue_status"]) + +') + new_lines.append(' # Add donation amount filters +') + new_lines.append(' min_total_donation = data.get("min_total_donation") +') + new_lines.append(' max_total_donation = data.get("max_total_donation") + +') + new_lines.append(' if min_total_donation is not None or max_total_donation is not None: +') + new_lines.append(' voters = voters.annotate(total_donation_amount=Coalesce(Sum("donations__amount"), Value(0), output_field=DecimalField())) +') + new_lines.append(' if min_total_donation is not None: +') + new_lines.append(' voters = voters.filter(total_donation_amount__gte=min_total_donation) +') + new_lines.append(' if max_total_donation is not None: +') + new_lines.append(' voters = voters.filter(total_donation_amount__lte=max_total_donation) + +') + new_lines.append(' paginator = Paginator(voters, 50) +') + new_lines.append(' page_number = request.GET.get("page") +') + new_lines.append(' voters_page = paginator.get_page(page_number) + +') + new_lines.append(' context = { +') + new_lines.append(' "form": form, +') + new_lines.append(' "voters": voters_page, +') + new_lines.append(' "selected_tenant": tenant, +') + new_lines.append(' "call_form": ScheduledCallForm(tenant=tenant), +') + new_lines.append(' } +') + new_lines.append(' return render(request, "core/voter_advanced_search.html", context) +') + new_lines.append(' skip = True +') + elif skip: + if line.startswith('@role_required') or line.startswith('def export_voters_csv'): + skip = False + new_lines.append('\n') + new_lines.append(line) + else: + new_lines.append(line) + +with open('core/views.py', 'w') as f: + f.writelines(new_lines) diff --git a/fix_voter_search_v2.py b/fix_voter_search_v2.py new file mode 100644 index 0000000..6acf5a3 --- /dev/null +++ b/fix_voter_search_v2.py @@ -0,0 +1,127 @@ + +import os + +with open('core/views.py', 'r') as f: + content = f.read() + +import_snippet = """from django.db.models import Q, Sum, Value, DecimalField +from django.db.models.functions import Coalesce""" + +# Make sure imports are there if they aren't +if "from django.db.models import Q, Sum, Value, DecimalField" not in content: + content = content.replace("from django.db.models import Q", "from django.db.models import Q, Sum, Value, DecimalField") +if "from django.db.models.functions import Coalesce" not in content: + content = "from django.db.models.functions import Coalesce\n" + content + +# Define the new voter_advanced_search function +new_voter_advanced_search = """@role_required(["admin", "campaign_manager", "campaign_staff", "system_admin", "campaign_admin"], permission="core.view_voter") +def voter_advanced_search(request): + """ + Advanced search for voters with multiple filters. + """ + 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) + voters = Voter.objects.filter(tenant=tenant, is_inactive=False).order_by("last_name", "first_name") + + form = AdvancedVoterSearchForm(request.GET) + 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: + # Annotate each voter with their total donation amount, treating no donations as 0 + voters = voters.annotate(total_donation_amount=Coalesce(Sum('donations__amount'), Value(0), 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) + + paginator = Paginator(voters, 50) + page_number = request.GET.get('page') + voters_page = paginator.get_page(page_number) + + context = { + 'form': form, + 'voters': voters_page, + 'selected_tenant': tenant, + 'call_form': ScheduledCallForm(tenant=tenant), + } + return render(request, "core/voter_advanced_search.html", context)""" + +# Identify the range to replace +start_marker = "@role_required([" +# Use a more specific marker to find the voter_advanced_search function +search_marker = "def voter_advanced_search(request):" +export_marker = "def export_voters_csv(request):" + +start_idx = content.find(search_marker) +if start_idx != -1: + # Go back to the decorator + decorator_idx = content.rfind("@role_required", 0, start_idx) + if decorator_idx != -1: + start_idx = decorator_idx + + end_idx = content.find(export_marker, start_idx) + if end_idx != -1: + # Go back to its decorator + export_decorator_idx = content.rfind("@role_required", start_idx, end_idx) + if export_decorator_idx != -1: + end_idx = export_decorator_idx + + new_content = content[:start_idx] + new_voter_advanced_search + "\n\n" + content[end_idx:] + with open('core/views.py', 'w') as f: + f.write(new_content) + print("Successfully updated voter_advanced_search in core/views.py") + else: + print("Could not find export_voters_csv marker") +else: + print("Could not find voter_advanced_search marker") diff --git a/fix_voter_search_v3.py b/fix_voter_search_v3.py new file mode 100644 index 0000000..68f8bdd --- /dev/null +++ b/fix_voter_search_v3.py @@ -0,0 +1,94 @@ + +import sys + +with open('core/views.py', 'r') as f: + content = f.read() + +# Define the target function content exactly as we want it +new_function = """@role_required(["admin", "campaign_manager", "campaign_staff", "system_admin", "campaign_admin"], permission="core.view_voter") +def voter_advanced_search(request): + """ + Advanced search for voters with multiple filters. + """ + 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) + voters = Voter.objects.filter(tenant=tenant, is_inactive=False).order_by("last_name", "first_name") + + form = AdvancedVoterSearchForm(request.GET) + 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: + # Annotate each voter with their total donation amount, treating no donations as 0 + voters = voters.annotate(total_donation_amount=Coalesce(Sum('donations__amount'), Value(0), 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) + + paginator = Paginator(voters, 50) + page_number = request.GET.get('page') + voters_page = paginator.get_page(page_number) + + context = { + 'form': form, + 'voters': voters_page, + 'selected_tenant': tenant, + 'call_form': ScheduledCallForm(tenant=tenant), + } + return render(request, "core/voter_advanced_search.html", context)""" + +import re +# Find the function and its decorator +# We search for the function name and then look for the role_required decorator above it +pattern = r'@role_required\[.*?\]\s*,?\s*permission\s*=\s* \ No newline at end of file diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..8e7ac79 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..12ae042 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,46 @@ +{ + "name": "workspace", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "workspace", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "bootstrap": "^5.3.8" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e12a2c6 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "workspace", + "version": "1.0.0", + "description": "This workspace houses the Django application scaffold used for Python-based templates.", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "bootstrap": "^5.3.8" + } +} diff --git a/patch_admin_import.py b/patch_admin_import.py new file mode 100644 index 0000000..76bf14a --- /dev/null +++ b/patch_admin_import.py @@ -0,0 +1,37 @@ +import re + +with open('core/admin.py', 'r') as f: + content = f.read() + +# Fix 1: Add voted logic to _process_voter_chunk +chunk_logic = """ + if defaults.get("voted") is True: + defaults["target_door_visit"] = False + defaults["call_queue_status"] = "no_call_required" + + voter = existing_voters.get(vid)""" + +content = content.replace(' voter = existing_voters.get(vid)', chunk_logic) + +# Fix 2: Add voted logic to _run_voter_post_import_cleanup +cleanup_start = """ def _run_voter_post_import_cleanup(self, tenant): + """ + Runs the logic that was previously in signals but optimized for bulk. + """ + from django.db.models import Exists, OuterRef + + # 0. Ensure consistency for voters who voted + Voter.objects.filter(tenant=tenant, voted=True).update( + target_door_visit=False, + call_queue_status='no_call_required' + ) + ScheduledCall.objects.filter(tenant=tenant, voter__voted=True, status='pending').update(status='cancelled') + + # 1. Update target_door_visit logic""" + +content = content.replace(' def _run_voter_post_import_cleanup(self, tenant):\n """\n Runs the logic that was previously in signals but optimized for bulk.\n """\n from django.db.models import Exists, OuterRef\n \n # 1. Update target_door_visit logic', cleanup_start) + +with open('core/admin.py', 'w') as f: + f.write(content) + +print("Updated core/admin.py with voted logic in import chunks and cleanup.") diff --git a/patch_admin_import_v2.py b/patch_admin_import_v2.py new file mode 100644 index 0000000..555cecd --- /dev/null +++ b/patch_admin_import_v2.py @@ -0,0 +1,37 @@ +import re + +with open('core/admin.py', 'r') as f: + lines = f.readlines() + +new_lines = [] +in_process_chunk = False +in_cleanup = False + +for line in lines: + # Fix 1: Chunk processing + if 'voter = existing_voters.get(vid)' in line and not in_cleanup: + new_lines.append(' if defaults.get("voted") is True:\n') + new_lines.append(' defaults["target_door_visit"] = False\n') + new_lines.append(' defaults["call_queue_status"] = "no_call_required"\n') + new_lines.append('\n') + new_lines.append(line) + continue + + # Fix 2: Cleanup + if '# 1. Update target_door_visit logic' in line: + new_lines.append(' # 0. Ensure consistency for voters who voted\n') + new_lines.append(' Voter.objects.filter(tenant=tenant, voted=True).update(\n') + new_lines.append(' target_door_visit=False,\n') + new_lines.append(' call_queue_status="no_call_required"\n') + new_lines.append(' )\n') + new_lines.append(' ScheduledCall.objects.filter(tenant=tenant, voter__voted=True, status="pending").update(status="cancelled")\n') + new_lines.append('\n') + new_lines.append(line) + continue + + new_lines.append(line) + +with open('core/admin.py', 'w') as f: + f.writelines(new_lines) + +print("Updated core/admin.py with voted logic in import chunks and cleanup.") diff --git a/patch_door_visits.py b/patch_door_visits.py new file mode 100644 index 0000000..80d017b --- /dev/null +++ b/patch_door_visits.py @@ -0,0 +1,141 @@ +import os +import sys +import django +import re +import json + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +django.setup() + +# Read the file +with open('core/views.py', 'r') as f: + content = f.read() + +lines = content.split('\n') +start_line = -1 +end_line = -1 +for i, line in enumerate(lines): + if line.startswith('def door_visits(request):'): + start_line = i + elif start_line != -1 and (line.startswith('@role_required([') or line.startswith('def log_door_visit')): + end_line = i + break + +optimized_code = """def door_visits(request): + \"\"\" + Manage door knocking visits. Groups unvisited targeted voters by household. + \"\"\" + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + + city_filter = request.GET.get("city", "").strip() + district_filter = request.GET.get('district', '').strip() + neighborhood_filter = request.GET.get('neighborhood', '').strip() + address_filter = request.GET.get('address', '').strip() + + voters = Voter.objects.filter(tenant=tenant, door_visit=False, is_targeted=True) + + if city_filter: + voters = voters.filter(city__icontains=city_filter) + if district_filter: + voters = voters.filter(district=district_filter) + if neighborhood_filter: + voters = voters.filter(neighborhood__icontains=neighborhood_filter) + if address_filter: + voters = voters.filter(Q(address__icontains=address_filter) | Q(address_street__icontains=address_filter)) + + voters_data = list(voters.values( + 'id', 'first_name', 'last_name', 'address_street', 'city', 'state', + 'zip_code', 'neighborhood', 'district', 'latitude', 'longitude' + )) + + households_dict = {} + for v in voters_data: + key = (v['address_street'], v['city'], v['state'], v['zip_code']) + if key not in households_dict: + street_number = "" + street_name = v['address_street'] or "" + match = re.match(r'^(\\d+)\\s+(.*)$', street_name) + if match: + street_number = match.group(1) + street_name = match.group(2) + + try: + street_number_sort = int(street_number) + except (ValueError, TypeError): + street_number_sort = 0 + + households_dict[key] = { + 'address_street': v['address_street'], + 'city': v['city'], + 'state': v['state'], + 'zip_code': v['zip_code'], + 'neighborhood': v['neighborhood'], + 'district': v['district'], + 'latitude': float(v['latitude']) if v['latitude'] else None, + 'longitude': float(v['longitude']) if v['longitude'] else None, + 'street_name_sort': street_name.lower(), + 'street_number_sort': street_number_sort, + 'target_voters': [], + 'voters_json': [] + } + households_dict[key]['target_voters'].append(v) + households_dict[key]['voters_json'].append({'id': v['id'], 'name': f"{v['first_name']} {v['last_name']}"}) + + households_list = list(households_dict.values()) + households_list.sort(key=lambda x: ( + (x['neighborhood'] or '').lower(), + x['street_name_sort'], + x['street_number_sort'] + )) + + map_data = [] + if len(households_list) < 3000: + map_data = [ + { + 'lat': h['latitude'], + 'lng': h['longitude'], + 'address_street': h['address_street'], + 'city': h['city'], + 'state': h['state'], + 'zip_code': h['zip_code'], + 'address': f"{h['address_street']}, {h['city']}", + 'voters': ", ".join([f"{v['first_name']} {v['last_name']}" for v in h['target_voters']]) + } + for h in households_list if h['latitude'] and h['longitude'] + ] + + for h in households_list: + h['voters_json_str'] = json.dumps(h['voters_json']) + + paginator = Paginator(households_list, 50) + page_number = request.GET.get('page') + households_page = paginator.get_page(page_number) + + from core.forms import DoorVisitLogForm + context = { + 'selected_tenant': tenant, + 'households': households_page, + 'district_filter': district_filter, + 'neighborhood_filter': neighborhood_filter, + 'address_filter': address_filter, + "city_filter": city_filter, + 'map_data_json': json.dumps(map_data), + 'GOOGLE_MAPS_API_KEY': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''), + 'visit_form': DoorVisitLogForm(), + } + return render(request, 'core/door_visits.html', context) +""" + +if start_line != -1 and end_line != -1: + lines[start_line:end_line] = [optimized_code] + with open('core/views.py', 'w') as f: + f.write('\n'.join(lines)) + print("Successfully patched door_visits in core/views.py") +else: + print(f"Failed to find door_visits function: start={start_line}, end={end_line}") + sys.exit(1) diff --git a/patch_door_visits_template.py b/patch_door_visits_template.py new file mode 100644 index 0000000..a8e0ed1 --- /dev/null +++ b/patch_door_visits_template.py @@ -0,0 +1,24 @@ +import sys +import os + +file_path = 'core/templates/core/door_visits.html' +with open(file_path, 'r') as f: + content = f.read() + +old_code = """