Compare commits

...

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

810 changed files with 128887 additions and 20531 deletions

11
.gitignore vendored
View File

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

159
ERD.md
View File

@ -1,159 +0,0 @@
# 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
}
```

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -23,14 +23,10 @@ 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", "")
@ -68,8 +64,6 @@ MIDDLEWARE = [
'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',
@ -186,7 +180,3 @@ if EMAIL_USE_SSL:
# 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'

View File

@ -12,20 +12,16 @@ Class-based views
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:

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,620 +1,3 @@
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 django.contrib import admin
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']
# Register your models here.

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
NONE

View File

@ -1 +0,0 @@
NONE

View File

@ -1,67 +0,0 @@
def voter_bulk_send_email(request):
selected_tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
campaign_settings = CampaignSettings.objects.get(tenant=tenant)
if request.method == 'POST':
subject = request.POST.get('subject')
body = request.POST.get('body')
is_html = request.POST.get("is_html") == "on"
select_all_results = request.POST.get('select_all_results') == 'true'
if select_all_results:
voters, _ = get_filtered_voter_queryset(request, tenant, data_source='POST')
voters = voters.exclude(email='')
else:
voter_ids = request.POST.getlist('selected_voters')
voters = Voter.objects.filter(id__in=voter_ids, tenant=tenant).exclude(email='')
if not voters.exists():
messages.warning(request, "No voters with email addresses selected.")
return redirect('voter_advanced_search')
connection = get_tenant_email_connection(campaign_settings)
if not connection:
messages.error(request, "SMTP settings are not configured. Please check Campaign Settings.")
return redirect('voter_advanced_search')
from_email = campaign_settings.email_from_address or settings.DEFAULT_FROM_EMAIL
if campaign_settings.email_from_name:
from_email = f"{campaign_settings.email_from_name} <{from_email}>"
email_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name='Email')
sent_count = 0
error_count = 0
for voter in voters:
try:
email = EmailMessage(
subject,
body,
from_email,
[voter.email],
connection=connection,
)
if is_html:
email.content_subtype = "html"
email.send()
sent_count += 1
# Log interaction
Interaction.objects.create(
voter=voter,
type=email_type,
date=timezone.now(),
description=subject,
notes=body
)
except Exception as e:
logger.error(f"Error sending bulk email to voter {voter.email}: {e}")
error_count += 1
if sent_count > 0:
messages.success(request, f"Successfully sent {sent_count} emails.")
if error_count > 0:
messages.error(request, f"Failed to send {error_count} emails.")
return redirect('voter_advanced_search')

View File

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

View File

@ -1,43 +1,13 @@
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 = {
return {
"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

View File

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

View File

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

View File

@ -1,518 +0,0 @@
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'})

File diff suppressed because one or more lines are too long

View File

@ -1,68 +0,0 @@
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)

View File

@ -1,78 +0,0 @@
# 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')},
},
),
]

View File

@ -1,75 +0,0 @@
# 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')},
},
),
]

View File

@ -1,28 +0,0 @@
# 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')},
},
),
]

View File

@ -1,50 +0,0 @@
# 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'),
),
]

View File

@ -1,18 +0,0 @@
# 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),
),
]

View File

@ -1,18 +0,0 @@
# 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),
),
]

View File

@ -1,48 +0,0 @@
# 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),
),
]

View File

@ -1,23 +0,0 @@
# 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),
),
]

View File

@ -1,18 +0,0 @@
# 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),
),
]

View File

@ -1,31 +0,0 @@
# 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',
},
),
]

View File

@ -1,23 +0,0 @@
# 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),
),
]

View File

@ -1,23 +0,0 @@
# 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),
),
]

View File

@ -1,71 +0,0 @@
# 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')),
],
),
]

View File

@ -1,24 +0,0 @@
# 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'),
),
]

View File

@ -1,18 +0,0 @@
# 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',
),
]

View File

@ -1,37 +0,0 @@
# 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'),
),
]

View File

@ -1,23 +0,0 @@
# 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',
),
]

View File

@ -1,28 +0,0 @@
# 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),
),
]

View File

@ -1,23 +0,0 @@
# 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),
),
]

View File

@ -1,17 +0,0 @@
# 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',
),
]

View File

@ -1,18 +0,0 @@
# 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),
),
]

View File

@ -1,18 +0,0 @@
# 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),
),
]

View File

@ -1,83 +0,0 @@
# 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),
),
]

View File

@ -1,22 +0,0 @@
# 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')},
),
]

View File

@ -1,28 +0,0 @@
# 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),
),
]

View File

@ -1,18 +0,0 @@
# 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(),
),
]

View File

@ -1,23 +0,0 @@
# 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),
),
]

View File

@ -1,18 +0,0 @@
# 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),
),
]

View File

@ -1,43 +0,0 @@
# 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),
),
]

View File

@ -1,18 +0,0 @@
# 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),
),
]

View File

@ -1,41 +0,0 @@
# 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'),
),
]

View File

@ -1,18 +0,0 @@
# 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),
),
]

View File

@ -1,17 +0,0 @@
# 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',
),
]

View File

@ -1,19 +0,0 @@
# 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'),
),
]

View File

@ -1,43 +0,0 @@
# 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),
),
]

View File

@ -1,29 +0,0 @@
# 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',
),
]

View File

@ -1,18 +0,0 @@
# 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),
),
]

File diff suppressed because one or more lines are too long

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2026-02-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),
),
]

View File

@ -1,32 +0,0 @@
# 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')),
],
),
]

View File

@ -1,17 +0,0 @@
# 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')},
),
]

View File

@ -1,48 +0,0 @@
# Generated by Django 5.2.7 on 2026-02-11 15:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0041_alter_volunteer_options'),
]
operations = [
migrations.AddField(
model_name='campaignsettings',
name='email_from_address',
field=models.EmailField(blank=True, max_length=254),
),
migrations.AddField(
model_name='campaignsettings',
name='smtp_host',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='campaignsettings',
name='smtp_password',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='campaignsettings',
name='smtp_port',
field=models.IntegerField(default=587),
),
migrations.AddField(
model_name='campaignsettings',
name='smtp_use_ssl',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='campaignsettings',
name='smtp_use_tls',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='campaignsettings',
name='smtp_username',
field=models.CharField(blank=True, max_length=255),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

@ -1,883 +1,3 @@
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'])
# Create your models here.

View File

@ -1,127 +0,0 @@
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

View File

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

View File

@ -1,9 +0,0 @@
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
<li>
<a href="import-donations/" class="addlink">
Import Donations
</a>
</li>
{{ block.super }}
{% endblock %}

View File

@ -1,38 +0,0 @@
{% extends "admin/change_list.html" %}
{% load i18n admin_urls static admin_list %}
{% block object-tools-items %}
<li>
<a href="import-events/" class="addlink">Import Events</a>
</li>
{{ block.super }}
{% endblock %}
{% block search %}
{{ block.super }}
<div class="tenant-filter-container" style="margin: 10px 0; padding: 15px; background: var(--darkened-bg, #f8f9fa); border: 1px solid var(--border-color, #dee2e6); border-radius: 4px; display: flex; align-items: center; color: var(--body-fg, #333);">
<label for="tenant-filter-select" style="font-weight: 600; margin-right: 15px; color: var(--body-fg, #333);">Filter by Tenant:</label>
<select id="tenant-filter-select" onchange="filterTenant(this.value)" style="padding: 6px 12px; border-radius: 4px; border: 1px solid var(--border-color, #ced4da); background-color: var(--body-bg, #fff); color: var(--body-fg, #333); min-width: 200px;">
<option value="" style="background-color: var(--body-bg); color: var(--body-fg);">-- All Tenants --</option>
{% for tenant in tenants %}
<option value="{{ tenant.id }}" {% if request.GET.tenant__id__exact == tenant.id|stringformat:"s" %}selected{% endif %} style="background-color: var(--body-bg); color: var(--body-fg);">
{{ tenant.name }}
</option>
{% endfor %}
</select>
</div>
<script>
function filterTenant(tenantId) {
const url = new URL(window.location.href);
if (tenantId) {
url.searchParams.set('tenant__id__exact', tenantId);
} else {
url.searchParams.delete('tenant__id__exact');
}
// Reset to page 1 if filtering
url.searchParams.delete('p');
window.location.href = url.pathname + url.search;
}
</script>
{% endblock %}

View File

@ -1,7 +0,0 @@
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
<li>
<a href="import-event-participations/" class="addlink">Import Participants</a>
</li>
{{ block.super }}
{% endblock %}

View File

@ -1,42 +0,0 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {{ title }}
</div>
{% endblock %}
{% block content %}
<div id="content-main">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<fieldset class="module aligned">
<div class="description">
<p>Upload a CSV file to import {{ opts.verbose_name_plural }}.</p>
{% if title == "Import Voters" %}
<p>Expected columns (header mandatory): <strong>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</strong></p>
{% else %}
<p>Expected columns (header mandatory): <strong>date, event_type, description</strong></p>
{% endif %}
</div>
{% for field in form %}
<div class="form-row">
{{ field.errors }}
<label class="required" for="{{ field.id_for_label }}">{{ field.label }}:</label>
{{ field }}
{% if field.help_text %}
<div class="help">{{ field.help_text|safe }}</div>
{% endif %}
</div>
{% endfor %}
</fieldset>
<div class="submit-row">
<input type="submit" value="Upload" class="default" name="_save">
</div>
</form>
</div>
{% endblock %}

View File

@ -1,48 +0,0 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {% translate 'Import Mapping' %}
</div>
{% endblock %}
{% block content %}
<div id="content-main">
<form method="post" action="{{ action_url }}">
{% csrf_token %}
<input type="hidden" name="file_path" value="{{ file_path }}">
<input type="hidden" name="tenant" value="{{ tenant_id }}">
<fieldset class="module aligned">
<h2>{% translate "Map CSV Columns to Model Fields" %}</h2>
<div class="description">
Select which CSV column matches each model field. Leave blank to skip.
</div>
{% for field_name, verbose_name in model_fields %}
<div class="form-row">
<div>
<label for="id_map_{{ field_name }}">{{ verbose_name }}:</label>
<select name="map_{{ field_name }}" id="id_map_{{ field_name }}">
<option value="">-- {% translate "Skip" %} --</option>
{% for header in headers %}
<option value="{{ header }}" {% if header|lower == field_name|lower or header|lower == verbose_name|lower %}selected{% endif %}>
{{ header }}
</option>
{% endfor %}
</select>
</div>
</div>
{% endfor %}
</fieldset>
<div class="submit-row">
<input type="submit" value="{% translate 'Preview Import' %}" class="default" name="_preview">
</div>
</form>
</div>
{% endblock %}

View File

@ -1,87 +0,0 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {% translate 'Import Preview' %}
</div>
{% endblock %}
{% block content %}
<div id="content-main">
<div class="module">
<h2>{% translate "Import Preview" %}</h2>
<p>
{% blocktranslate with total=total_count created=create_count updated=update_count %}
Found <strong>{{ total }}</strong> records in the CSV file.
<br>
- <strong>{{ created }}</strong> will be created.
<br>
- <strong>{{ updated }}</strong> will be updated.
{% endblocktranslate %}
</p>
{% if preview_data %}
<div class="results">
<h3>{% translate "Sample Records" %}</h3>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f8f8; border-bottom: 1px solid #ccc;">
<th style="padding: 8px; text-align: left;">{% translate "Action" %}</th>
<th style="padding: 8px; text-align: left;">{% translate "CSV Name / Matched Voter" %}</th>
<th style="padding: 8px; text-align: left;">{% translate "Details" %}</th>
</tr>
</thead>
<tbody>
{% for row in preview_data %}
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 8px;">
{% if row.action == 'create' %}
<span style="color: green; font-weight: bold;">{% translate "CREATE" %}</span>
{% else %}
<span style="color: blue; font-weight: bold;">{% translate "UPDATE" %}</span>
{% endif %}
</td>
<td style="padding: 8px;">
{% if row.csv_full_name %}
<strong>CSV:</strong> {{ row.csv_full_name }}
{% if "Voter: N/A" not in row.identifier %}<br>{% endif %}
{% endif %}
{% if "Voter: N/A" not in row.identifier %}
<strong>Matched:</strong> {{ row.identifier|cut:"Voter: " }}
{% else %}
{% if not row.csv_full_name %}N/A{% endif %}
{% endif %}
</td>
<td style="padding: 8px; font-size: 0.9em; color: #666;">{{ row.details }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if total_count > preview_data|length %}
<p><em>... and {{ total_count|add:"-10" }} more records.</em></p>
{% endif %}
</div>
{% endif %}
</div>
<form method="post" action="{{ action_url }}">
{% csrf_token %}
<input type="hidden" name="file_path" value="{{ file_path }}">
<input type="hidden" name="tenant" value="{{ tenant_id }}">
{# Pass mapping as hidden fields #}
{% for field_name, csv_col in mapping.items %}
<input type="hidden" name="map_{{ field_name }}" value="{{ csv_col }}">
{% endfor %}
<div class="submit-row">
<input type="submit" value="{% translate 'Confirm Import' %}" class="default" name="_import">
<a href="#" onclick="window.history.back(); return false;" class="closelink">{% translate "Cancel and go back" %}</a>
</div>
</form>
</div>
{% endblock %}

View File

@ -1,9 +0,0 @@
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
<li>
<a href="import-interactions/" class="addlink">
Import Interactions
</a>
</li>
{{ block.super }}
{% endblock %}

View File

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

View File

@ -1,31 +0,0 @@
{% extends "admin/change_list.html" %}
{% load i18n admin_urls static admin_list %}
{% block search %}
{{ block.super }}
<div class="tenant-filter-container" style="margin: 10px 0; padding: 15px; background: var(--darkened-bg, #f8f9fa); border: 1px solid var(--border-color, #dee2e6); border-radius: 4px; display: flex; align-items: center; color: var(--body-fg, #333);">
<label for="tenant-filter-select" style="font-weight: 600; margin-right: 15px; color: var(--body-fg, #333);">Filter by Tenant:</label>
<select id="tenant-filter-select" onchange="filterTenant(this.value)" style="padding: 6px 12px; border-radius: 4px; border: 1px solid var(--border-color, #ced4da); background-color: var(--body-bg, #fff); color: var(--body-fg, #333); min-width: 200px;">
<option value="" style="background-color: var(--body-bg); color: var(--body-fg);">-- All Tenants --</option>
{% for tenant in tenants %}
<option value="{{ tenant.id }}" {% if request.GET.tenant__id__exact == tenant.id|stringformat:"s" %}selected{% endif %} style="background-color: var(--body-bg); color: var(--body-fg);">
{{ tenant.name }}
</option>
{% endfor %}
</select>
</div>
<script>
function filterTenant(tenantId) {
const url = new URL(window.location.href);
if (tenantId) {
url.searchParams.set('tenant__id__exact', tenantId);
} else {
url.searchParams.delete('tenant__id__exact');
}
// Reset to page 1 if filtering
url.searchParams.delete('p');
window.location.href = url.pathname + url.search;
}
</script>
{% endblock %}

View File

@ -1,38 +0,0 @@
{% extends "admin/change_list.html" %}
{% load i18n admin_urls static admin_list %}
{% block object-tools-items %}
<li>
<a href="import-volunteers/" class="addlink">Import Volunteers</a>
</li>
{{ block.super }}
{% endblock %}
{% block search %}
{{ block.super }}
<div class="tenant-filter-container" style="margin: 10px 0; padding: 15px; background: var(--darkened-bg, #f8f9fa); border: 1px solid var(--border-color, #dee2e6); border-radius: 4px; display: flex; align-items: center; color: var(--body-fg, #333);">
<label for="tenant-filter-select" style="font-weight: 600; margin-right: 15px; color: var(--body-fg, #333);">Filter by Tenant:</label>
<select id="tenant-filter-select" onchange="filterTenant(this.value)" style="padding: 6px 12px; border-radius: 4px; border: 1px solid var(--border-color, #ced4da); background-color: var(--body-bg, #fff); color: var(--body-fg, #333); min-width: 200px;">
<option value="" style="background-color: var(--body-bg); color: var(--body-fg);">-- All Tenants --</option>
{% for tenant in tenants %}
<option value="{{ tenant.id }}" {% if request.GET.tenant__id__exact == tenant.id|stringformat:"s" %}selected{% endif %} style="background-color: var(--body-bg); color: var(--body-fg);">
{{ tenant.name }}
</option>
{% endfor %}
</select>
</div>
<script>
function filterTenant(tenantId) {
const url = new URL(window.location.href);
if (tenantId) {
url.searchParams.set('tenant__id__exact', tenantId);
} else {
url.searchParams.delete('tenant__id__exact');
}
// Reset to page 1 if filtering
url.searchParams.delete('p');
window.location.href = url.pathname + url.search;
}
</script>
{% endblock %}

View File

@ -1,38 +0,0 @@
{% extends "admin/change_list.html" %}
{% load i18n admin_urls static admin_list %}
{% block object-tools-items %}
<li>
<a href="import-voters/" class="addlink">Import Voters</a>
</li>
{{ block.super }}
{% endblock %}
{% block search %}
{{ block.super }}
<div class="tenant-filter-container" style="margin: 10px 0; padding: 15px; background: var(--darkened-bg, #f8f9fa); border: 1px solid var(--border-color, #dee2e6); border-radius: 4px; display: flex; align-items: center; color: var(--body-fg, #333);">
<label for="tenant-filter-select" style="font-weight: 600; margin-right: 15px; color: var(--body-fg, #333);">Filter by Tenant:</label>
<select id="tenant-filter-select" onchange="filterTenant(this.value)" style="padding: 6px 12px; border-radius: 4px; border: 1px solid var(--border-color, #ced4da); background-color: var(--body-bg, #fff); color: var(--body-fg, #333); min-width: 200px;">
<option value="" style="background-color: var(--body-bg); color: var(--body-fg);">-- All Tenants --</option>
{% for tenant in tenants %}
<option value="{{ tenant.id }}" {% if request.GET.tenant__id__exact == tenant.id|stringformat:"s" %}selected{% endif %} style="background-color: var(--body-bg); color: var(--body-fg);">
{{ tenant.name }}
</option>
{% endfor %}
</select>
</div>
<script>
function filterTenant(tenantId) {
const url = new URL(window.location.href);
if (tenantId) {
url.searchParams.set('tenant__id__exact', tenantId);
} else {
url.searchParams.delete('tenant__id__exact');
}
// Reset to page 1 if filtering
url.searchParams.delete('p');
window.location.href = url.pathname + url.search;
}
</script>
{% endblock %}

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