Compare commits

...

43 Commits

Author SHA1 Message Date
Flatlogic Bot
88ee4c2516 Autosave: 20260213-015538 2026-02-13 01:55:39 +00:00
Flatlogic Bot
fbcc2964bf 3.2 2026-02-08 22:15:25 +00:00
Flatlogic Bot
8de72675e5 Autosave: 20260208-213524 2026-02-08 21:35:24 +00:00
Flatlogic Bot
c92857d73b 3.1 2026-02-08 04:06:08 +00:00
Flatlogic Bot
01c62eb11d Revert "Autosave: 20260207-233919"
This reverts commit d3537b642704f6b5beb49fa78103cde48170eb77.
2026-02-08 00:11:53 +00:00
Flatlogic Bot
d3537b6427 Autosave: 20260207-233919 2026-02-07 23:39:19 +00:00
Flatlogic Bot
935ecf1b68 Autosave: 20260206-230153 2026-02-06 23:01:54 +00:00
Flatlogic Bot
0d11fc7d5d Autosave: 20260206-141042 2026-02-06 14:10:46 +00:00
Flatlogic Bot
d244ac9d3f Autosave: 20260205-190904 2026-02-05 19:09:04 +00:00
Flatlogic Bot
e39a0343c7 Autosave: 20260205-035225 2026-02-05 03:52:25 +00:00
Flatlogic Bot
e0f6e045f3 Autosave: 20260204-221954 2026-02-04 22:19:54 +00:00
Flatlogic Bot
bf2d558e03 Autosave: 20260203-184221 2026-02-03 18:42:21 +00:00
Flatlogic Bot
ac80c84fbd Autosave: 20260203-133539 2026-02-03 13:35:39 +00:00
Flatlogic Bot
c3568101a3 Autosave: 20260203-043854 2026-02-03 04:38:54 +00:00
Flatlogic Bot
63faa21a4f Autosave: 20260201-211719 2026-02-01 21:17:19 +00:00
Flatlogic Bot
442aec63b6 3.0 2026-02-01 06:28:41 +00:00
Flatlogic Bot
c5d42d341f Autosave: 20260201-053401 2026-02-01 05:34:01 +00:00
Flatlogic Bot
77709c3744 Autosave: 20260201-034149 2026-02-01 03:41:49 +00:00
Flatlogic Bot
f7bc2da356 Autosave: 20260131-125943 2026-01-31 12:59:43 +00:00
Flatlogic Bot
181163257f Autosave: 20260130-044056 2026-01-30 04:40:56 +00:00
Flatlogic Bot
6b464385a5 Autosave: 20260129-214513 2026-01-29 21:45:13 +00:00
Flatlogic Bot
3ac4dc73fb Autosave: 20260129-192127 2026-01-29 19:21:27 +00:00
Flatlogic Bot
d8bf0cd82c 2.0 2026-01-29 18:46:31 +00:00
Flatlogic Bot
ae3d7f9f2e Autosave: 20260128-212108 2026-01-28 21:21:09 +00:00
Flatlogic Bot
9c3c5219c6 .2 2026-01-28 13:41:32 +00:00
Flatlogic Bot
4056b17780 Autosave: 20260128-130611 2026-01-28 13:06:12 +00:00
Flatlogic Bot
2e087bcd88 Autosave: 20260126-175038 2026-01-26 17:50:38 +00:00
Flatlogic Bot
e0d1690e97 1.1 2026-01-26 14:47:48 +00:00
Flatlogic Bot
1dfb7ebbf1 Autosave: 20260126-142846 2026-01-26 14:28:46 +00:00
Flatlogic Bot
ac90cc59f4 Autosave: 20260125-214643 2026-01-25 21:46:44 +00:00
Flatlogic Bot
dc2bd62142 Autosave: 20260125-175045 2026-01-25 17:50:45 +00:00
Flatlogic Bot
c95591245a 1.0 2026-01-25 16:22:06 +00:00
Flatlogic Bot
d756ed7a8a Autosave: 20260125-050254 2026-01-25 05:02:55 +00:00
Flatlogic Bot
443505ace5 .9 2026-01-25 00:39:13 +00:00
Flatlogic Bot
0ef73ff181 Autosave: 20260125-000522 2026-01-25 00:05:22 +00:00
Flatlogic Bot
e4aeae1b74 .8 2026-01-24 22:51:48 +00:00
Flatlogic Bot
14a93b6b2b Autosave: 20260124-224650 2026-01-24 22:46:50 +00:00
Flatlogic Bot
7686b1143d Autosave: 20260124-205044 2026-01-24 20:50:44 +00:00
Flatlogic Bot
14bf8d7295 .7 2026-01-24 16:24:42 +00:00
Flatlogic Bot
e2ad03e446 Autosave: 20260124-155519 2026-01-24 15:55:19 +00:00
Flatlogic Bot
aade4cc131 .6 2026-01-24 15:09:45 +00:00
Flatlogic Bot
0ade1911eb .5 2026-01-24 06:37:11 +00:00
Flatlogic Bot
5073ef280c Autosave: 20260124-063234 2026-01-24 06:32:35 +00:00
168 changed files with 14548 additions and 208 deletions

159
ERD.md Normal file
View File

@ -0,0 +1,159 @@
# Entity Relationship Diagram
```mermaid
erDiagram
Tenant ||--o{ TenantUserRole : has
Tenant ||--o{ InteractionType : defines
Tenant ||--o{ DonationMethod : defines
Tenant ||--o{ ElectionType : defines
Tenant ||--o{ EventType : defines
Tenant ||--o{ ParticipationStatus : defines
Tenant ||--o{ Voter : belongs_to
Tenant ||--o{ Event : organizes
User ||--o{ TenantUserRole : assigned_to
Voter ||--o{ VotingRecord : has
Voter ||--o{ EventParticipation : participates
Voter ||--o{ Donation : makes
Voter ||--o{ Interaction : receives
Voter ||--o{ VoterLikelihood : has
Event ||--o{ EventParticipation : includes
EventType ||--o{ Event : categorizes
ParticipationStatus ||--o{ EventParticipation : defines_status
InteractionType ||--o{ Interaction : categorizes
DonationMethod ||--o{ Donation : categorizes
ElectionType ||--o{ VoterLikelihood : categorizes
Tenant {
int id PK
string name
string slug
text description
datetime created_at
}
User {
int id PK
string username
string email
string first_name
string last_name
}
TenantUserRole {
int id PK
int user_id FK
int tenant_id FK
string role
}
InteractionType {
int id PK
int tenant_id FK
string name
boolean is_active
}
DonationMethod {
int id PK
int tenant_id FK
string name
boolean is_active
}
ElectionType {
int id PK
int tenant_id FK
string name
boolean is_active
}
EventType {
int id PK
int tenant_id FK
string name
boolean is_active
}
ParticipationStatus {
int id PK
int tenant_id FK
string name
boolean is_active
}
Voter {
int id PK
int tenant_id FK
string voter_id
string first_name
string last_name
text address
string address_street
string city
string state
string zip_code
string county
decimal latitude
decimal longitude
string phone
string email
string district
string precinct
date registration_date
boolean is_targeted
string candidate_support
string yard_sign
datetime created_at
}
VotingRecord {
int id PK
int voter_id FK
date election_date
string election_description
string primary_party
}
Event {
int id PK
int tenant_id FK
date date
int event_type_id FK
text description
}
EventParticipation {
int id PK
int event_id FK
int voter_id FK
int participation_status_id FK
}
Donation {
int id PK
int voter_id FK
date date
int method_id FK
decimal amount
}
Interaction {
int id PK
int voter_id FK
int type_id FK
date date
string description
text notes
}
VoterLikelihood {
int id PK
int voter_id FK
int election_type_id FK
string likelihood
}
```

0
assets/.gitkeep Normal file
View File

View File

@ -23,10 +23,14 @@ 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", "")
@ -64,6 +68,8 @@ 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',
@ -180,3 +186,7 @@ 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,16 +12,20 @@ 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.

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,31 @@
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
def project_context(request):
"""
Adds project-specific environment variables to the template context globally.
"""
return {
context = {
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
# Used for cache-busting static assets
"deployment_timestamp": int(time.time()),
"GOOGLE_MAPS_API_KEY": getattr(settings, 'GOOGLE_MAPS_API_KEY', ''),
}
if request.user.is_authenticated:
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)
context['user_role'] = get_user_role(request.user, tenant)
return context

498
core/forms.py Normal file
View File

@ -0,0 +1,498 @@
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', 'door_visit', 'candidate_support', 'yard_sign', 'window_sticker', 'notes'
]
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}),
}
def __init__(self, *args, user=None, tenant=None, **kwargs):
self.user = user
self.tenant = tenant
super().__init__(*args, **kwargs)
# 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'})
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)
# We need to set these on the form instance if we want to use them in clean
# or we can pass them in __init__ and store them
if self.user:
if self.user.is_superuser:
is_admin = True
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')
]
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) # Added email field
phone_type = forms.ChoiceField(
choices=[('', 'Any')] + Voter.PHONE_TYPE_CHOICES,
required=False
)
is_targeted = forms.BooleanField(required=False, label="Targeted Only")
door_visit = forms.BooleanField(required=False, label="Visited Only")
candidate_support = forms.ChoiceField(
choices=[('', 'Any')] + Voter.CANDIDATE_SUPPORT_CHOICES,
required=False
)
yard_sign = forms.ChoiceField(
choices=[('', 'Any')] + Voter.YARD_SIGN_CHOICES,
required=False
)
window_sticker = forms.ChoiceField(
choices=[('', 'Any')] + Voter.WINDOW_STICKER_CHOICES,
required=False
)
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'})
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/Excel 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"
)
wants_yard_sign = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
label="Wants a Yard Sign"
)
candidate_support = forms.ChoiceField(
choices=Voter.CANDIDATE_SUPPORT_CHOICES,
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=[], 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 = 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'})

68
core/middleware.py Normal file
View File

@ -0,0 +1,68 @@
import zoneinfo
from django.shortcuts import redirect
from django.urls import reverse
from django.conf import settings
from django.utils import timezone
from core.models import CampaignSettings, Tenant
class LoginRequiredMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not request.user.is_authenticated:
path = request.path_info
# Allow access to login, logout, admin, and any other exempted paths
try:
login_url = reverse('login')
logout_url = reverse('logout')
except:
login_url = '/accounts/login/'
logout_url = '/accounts/logout/'
exempt_urls = [
login_url,
logout_url,
'/admin/',
]
# Check if path starts with any of the exempt URLs
is_exempt = any(path.startswith(url) for url in exempt_urls)
if not is_exempt:
return redirect(f"{login_url}?next={path}")
response = self.get_response(request)
return response
class TimezoneMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
tzname = None
# 1. Try to get tenant from session
tenant_id = request.session.get("tenant_id")
if tenant_id:
try:
campaign_settings = CampaignSettings.objects.get(tenant_id=tenant_id)
tzname = campaign_settings.timezone
except CampaignSettings.DoesNotExist:
pass
# 2. If not found and user is authenticated, maybe they are in admin?
# In admin, we might not have tenant_id in session if they went directly there.
# But this is a multi-tenant app, usually they select a campaign first.
# If they are superuser in admin, we might want to default to something or let them see UTC.
if tzname:
try:
timezone.activate(zoneinfo.ZoneInfo(tzname))
except:
timezone.deactivate()
else:
timezone.deactivate()
return self.get_response(request)

View File

@ -0,0 +1,78 @@
# Generated by Django 5.2.7 on 2026-01-24 05:12
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Tenant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(blank=True, unique=True)),
('description', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Voter',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('voter_id', models.CharField(blank=True, max_length=50)),
('first_name', models.CharField(max_length=100)),
('last_name', models.CharField(max_length=100)),
('address', models.TextField(blank=True)),
('phone', models.CharField(blank=True, max_length=20)),
('email', models.EmailField(blank=True, max_length=254)),
('geocode', models.CharField(blank=True, max_length=100)),
('district', models.CharField(blank=True, max_length=100)),
('precinct', models.CharField(blank=True, max_length=100)),
('registration_date', models.DateField(blank=True, null=True)),
('candidate_support', models.CharField(choices=[('unknown', 'Unknown'), ('supporting', 'Supporting'), ('not_supporting', 'Not Supporting')], default='unknown', max_length=20)),
('yard_sign', models.CharField(choices=[('none', 'None'), ('wants', 'Wants a yard sign'), ('has', 'Has a yard sign')], default='none', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='voters', to='core.tenant')),
],
),
migrations.CreateModel(
name='InteractionType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interaction_types', to='core.tenant')),
],
options={
'unique_together': {('tenant', 'name')},
},
),
migrations.CreateModel(
name='ElectionType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='election_types', to='core.tenant')),
],
options={
'unique_together': {('tenant', 'name')},
},
),
migrations.CreateModel(
name='DonationMethod',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='donation_methods', to='core.tenant')),
],
options={
'unique_together': {('tenant', 'name')},
},
),
]

View File

@ -0,0 +1,75 @@
# Generated by Django 5.2.7 on 2026-01-24 05:18
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Donation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
('method', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.donationmethod')),
('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='donations', to='core.voter')),
],
),
migrations.CreateModel(
name='Event',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('event_type', models.CharField(max_length=100)),
('description', models.TextField(blank=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='core.tenant')),
],
),
migrations.CreateModel(
name='EventParticipation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participations', to='core.event')),
('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_participations', to='core.voter')),
],
),
migrations.CreateModel(
name='Interaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('description', models.CharField(max_length=255)),
('notes', models.TextField(blank=True)),
('type', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.interactiontype')),
('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interactions', to='core.voter')),
],
),
migrations.CreateModel(
name='VotingRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('election_date', models.DateField()),
('election_description', models.CharField(max_length=255)),
('primary_party', models.CharField(blank=True, max_length=100)),
('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='voting_records', to='core.voter')),
],
),
migrations.CreateModel(
name='VoterLikelihood',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('likelihood', models.CharField(choices=[('not_likely', 'Not Likely'), ('somewhat_likely', 'Somewhat Likely'), ('very_likely', 'Very Likely')], max_length=20)),
('election_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.electiontype')),
('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likelihoods', to='core.voter')),
],
options={
'unique_together': {('voter', 'election_type')},
},
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2026-01-24 05:27
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_donation_event_eventparticipation_interaction_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TenantUserRole',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('system_admin', 'System Administrator'), ('campaign_admin', 'Campaign Administrator'), ('campaign_staff', 'Campaign Staff')], max_length=20)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_roles', to='core.tenant')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tenant_roles', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'tenant', 'role')},
},
),
]

View File

@ -0,0 +1,50 @@
# Generated by Django 5.2.7 on 2026-01-24 14:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_tenantuserrole'),
]
operations = [
migrations.RemoveField(
model_name='voter',
name='geocode',
),
migrations.AddField(
model_name='donationmethod',
name='is_active',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='electiontype',
name='is_active',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='interactiontype',
name='is_active',
field=models.BooleanField(default=True),
),
migrations.CreateModel(
name='EventType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('is_active', models.BooleanField(default=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_types', to='core.tenant')),
],
options={
'unique_together': {('tenant', 'name')},
},
),
migrations.AlterField(
model_name='event',
name='event_type',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='core.eventtype'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-01-24 16:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_remove_voter_geocode_donationmethod_is_active_and_more'),
]
operations = [
migrations.AddField(
model_name='eventparticipation',
name='participation_type',
field=models.CharField(choices=[('invited', 'Invited'), ('invited_not_attended', "Invited but didn't attend"), ('attended', 'Attended')], default='invited', max_length=50),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-01-24 16:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_eventparticipation_participation_type'),
]
operations = [
migrations.AddField(
model_name='voter',
name='is_targeted',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,48 @@
# Generated by Django 5.2.7 on 2026-01-24 16:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0006_voter_is_targeted'),
]
operations = [
migrations.AddField(
model_name='voter',
name='address_street',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='voter',
name='city',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='voter',
name='county',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='voter',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
migrations.AddField(
model_name='voter',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
migrations.AddField(
model_name='voter',
name='state',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='voter',
name='zip_code',
field=models.CharField(blank=True, max_length=20),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-01-24 21:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_voter_address_street_voter_city_voter_county_and_more'),
]
operations = [
migrations.AlterField(
model_name='voter',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True),
),
migrations.AlterField(
model_name='voter',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-01-24 23:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_alter_voter_latitude_alter_voter_longitude'),
]
operations = [
migrations.AddField(
model_name='voter',
name='window_sticker',
field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker')], default='none', max_length=20),
),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 5.2.7 on 2026-01-24 23:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_voter_window_sticker'),
]
operations = [
migrations.AlterField(
model_name='voter',
name='window_sticker',
field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker')], default='none', max_length=20, verbose_name='Window Sticker Status'),
),
migrations.CreateModel(
name='CampaignSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('donation_goal', models.DecimalField(decimal_places=2, default=170000.0, max_digits=12)),
('tenant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='core.tenant')),
],
options={
'verbose_name': 'Campaign Settings',
'verbose_name_plural': 'Campaign Settings',
},
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-01-25 00:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0010_alter_voter_window_sticker_campaignsettings'),
]
operations = [
migrations.AddField(
model_name='voter',
name='birthdate',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='voter',
name='nickname',
field=models.CharField(blank=True, max_length=100),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-01-25 01:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0011_voter_birthdate_voter_nickname'),
]
operations = [
migrations.AddField(
model_name='voter',
name='prior_state',
field=models.CharField(blank=True, max_length=2),
),
migrations.AlterField(
model_name='voter',
name='state',
field=models.CharField(blank=True, max_length=2),
),
]

View File

@ -0,0 +1,71 @@
# Generated by Django 5.2.7 on 2026-01-25 16:33
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_voter_prior_state_alter_voter_state'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name='tenant',
name='description',
),
migrations.RemoveField(
model_name='tenant',
name='slug',
),
migrations.AlterField(
model_name='tenant',
name='name',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='tenantuserrole',
name='role',
field=models.CharField(choices=[('admin', 'Admin'), ('campaign_manager', 'Campaign Manager'), ('campaign_staff', 'Campaign Staff')], max_length=20),
),
migrations.CreateModel(
name='Interest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interests', to='core.tenant')),
],
options={
'unique_together': {('tenant', 'name')},
},
),
migrations.CreateModel(
name='Volunteer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('email', models.EmailField(max_length=254)),
('phone', models.CharField(blank=True, max_length=20)),
('interests', models.ManyToManyField(blank=True, related_name='volunteers', to='core.interest')),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteers', to='core.tenant')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='volunteer_profile', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='interaction',
name='volunteer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interactions', to='core.volunteer'),
),
migrations.CreateModel(
name='VolunteerEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(max_length=100)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteers', to='core.event')),
('volunteer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_assignments', to='core.volunteer')),
],
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 5.2.7 on 2026-01-25 16:34
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0013_remove_tenant_description_remove_tenant_slug_and_more'),
]
operations = [
migrations.AddField(
model_name='volunteer',
name='assigned_events',
field=models.ManyToManyField(related_name='assigned_volunteers', through='core.VolunteerEvent', to='core.event'),
),
migrations.AlterField(
model_name='volunteerevent',
name='event',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteer_assignments', to='core.event'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-01-25 18:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0014_volunteer_assigned_events_alter_volunteerevent_event'),
]
operations = [
migrations.RenameField(
model_name='eventparticipation',
old_name='participation_type',
new_name='participation_status',
),
]

View File

@ -0,0 +1,37 @@
# Generated by Django 5.2.7 on 2026-01-25 18:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0015_remove_eventparticipation_participation_type_and_more'),
]
operations = [
migrations.AlterField(
model_name='eventparticipation',
name='participation_status',
field=models.CharField(blank=True, max_length=50),
),
migrations.CreateModel(
name='ParticipationStatus',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('is_active', models.BooleanField(default=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participation_statuses', to='core.tenant')),
],
options={
'verbose_name_plural': 'Participation Statuses',
'unique_together': {('tenant', 'name')},
},
),
migrations.AddField(
model_name='eventparticipation',
name='participation_status_link',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='core.participationstatus'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-01-25 18:52
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_alter_eventparticipation_participation_status_and_more'),
]
operations = [
migrations.RemoveField(
model_name='eventparticipation',
name='participation_status',
),
migrations.RenameField(
model_name='eventparticipation',
old_name='participation_status_link',
new_name='participation_status',
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2026-01-25 19:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0017_remove_eventparticipation_participation_status_link_and_more'),
]
operations = [
migrations.AddField(
model_name='event',
name='end_time',
field=models.TimeField(blank=True, null=True),
),
migrations.AddField(
model_name='event',
name='name',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='event',
name='start_time',
field=models.TimeField(blank=True, null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-01-26 05:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0018_event_end_time_event_name_event_start_time'),
]
operations = [
migrations.AddField(
model_name='volunteer',
name='first_name',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='volunteer',
name='last_name',
field=models.CharField(blank=True, max_length=100),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-01-26 13:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0019_volunteer_first_name_volunteer_last_name'),
]
operations = [
migrations.RemoveField(
model_name='volunteer',
name='name',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-01-26 16:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0020_remove_volunteer_name'),
]
operations = [
migrations.AddField(
model_name='voter',
name='phone_type',
field=models.CharField(choices=[('home', 'Home Phone'), ('cell', 'Cell Phone'), ('work', 'Work Phone')], default='cell', max_length=10),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-01-26 17:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0021_voter_phone_type'),
]
operations = [
migrations.AddField(
model_name='voter',
name='notes',
field=models.TextField(blank=True),
),
]

View File

@ -0,0 +1,83 @@
# Generated by Django 5.2.7 on 2026-01-28 04:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0022_voter_notes'),
]
operations = [
migrations.AlterField(
model_name='voter',
name='address_street',
field=models.CharField(blank=True, db_index=True, max_length=255),
),
migrations.AlterField(
model_name='voter',
name='birthdate',
field=models.DateField(blank=True, db_index=True, null=True),
),
migrations.AlterField(
model_name='voter',
name='candidate_support',
field=models.CharField(choices=[('unknown', 'Unknown'), ('supporting', 'Supporting'), ('not_supporting', 'Not Supporting')], db_index=True, default='unknown', max_length=20),
),
migrations.AlterField(
model_name='voter',
name='city',
field=models.CharField(blank=True, db_index=True, max_length=100),
),
migrations.AlterField(
model_name='voter',
name='district',
field=models.CharField(blank=True, db_index=True, max_length=100),
),
migrations.AlterField(
model_name='voter',
name='first_name',
field=models.CharField(db_index=True, max_length=100),
),
migrations.AlterField(
model_name='voter',
name='is_targeted',
field=models.BooleanField(db_index=True, default=False),
),
migrations.AlterField(
model_name='voter',
name='last_name',
field=models.CharField(db_index=True, max_length=100),
),
migrations.AlterField(
model_name='voter',
name='precinct',
field=models.CharField(blank=True, db_index=True, max_length=100),
),
migrations.AlterField(
model_name='voter',
name='state',
field=models.CharField(blank=True, db_index=True, max_length=2),
),
migrations.AlterField(
model_name='voter',
name='voter_id',
field=models.CharField(blank=True, db_index=True, max_length=50),
),
migrations.AlterField(
model_name='voter',
name='window_sticker',
field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker')], db_index=True, default='none', max_length=20, verbose_name='Window Sticker Status'),
),
migrations.AlterField(
model_name='voter',
name='yard_sign',
field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants a yard sign'), ('has', 'Has a yard sign')], db_index=True, default='none', max_length=20),
),
migrations.AlterField(
model_name='voter',
name='zip_code',
field=models.CharField(blank=True, db_index=True, max_length=20),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2026-01-28 21:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0023_alter_voter_address_street_alter_voter_birthdate_and_more'),
]
operations = [
migrations.AlterField(
model_name='event',
name='name',
field=models.CharField(db_index=True, max_length=255),
),
migrations.AlterUniqueTogether(
name='event',
unique_together={('tenant', 'name')},
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2026-01-29 01:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0024_alter_event_name_alter_event_unique_together'),
]
operations = [
migrations.AddField(
model_name='campaignsettings',
name='twilio_account_sid',
field=models.CharField(blank=True, default='ACcd11acb5095cec6477245d385a2bf127', max_length=100),
),
migrations.AddField(
model_name='campaignsettings',
name='twilio_auth_token',
field=models.CharField(blank=True, default='89ec830d0fa02ab0afa6c76084865713', max_length=100),
),
migrations.AddField(
model_name='campaignsettings',
name='twilio_from_number',
field=models.CharField(blank=True, default='+18556945903', max_length=20),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-01-29 03:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0025_campaignsettings_twilio_account_sid_and_more'),
]
operations = [
migrations.AlterField(
model_name='interaction',
name='date',
field=models.DateTimeField(),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-01-29 18:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0026_alter_interaction_date'),
]
operations = [
migrations.AddField(
model_name='voter',
name='secondary_phone',
field=models.CharField(blank=True, max_length=20),
),
migrations.AddField(
model_name='voter',
name='secondary_phone_type',
field=models.CharField(choices=[('home', 'Home Phone'), ('cell', 'Cell Phone'), ('work', 'Work Phone')], default='cell', max_length=10),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-01-29 21:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0027_voter_secondary_phone_voter_secondary_phone_type'),
]
operations = [
migrations.AddField(
model_name='volunteer',
name='notes',
field=models.TextField(blank=True),
),
]

View File

@ -0,0 +1,43 @@
# Generated by Django 5.2.7 on 2026-01-29 22:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0028_volunteer_notes'),
]
operations = [
migrations.AddField(
model_name='event',
name='address',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='event',
name='city',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='event',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True),
),
migrations.AddField(
model_name='event',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True),
),
migrations.AddField(
model_name='event',
name='state',
field=models.CharField(blank=True, max_length=2),
),
migrations.AddField(
model_name='event',
name='zip_code',
field=models.CharField(blank=True, max_length=20),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-01-29 22:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0029_event_address_event_city_event_latitude_and_more'),
]
operations = [
migrations.AddField(
model_name='event',
name='location_name',
field=models.CharField(blank=True, max_length=255),
),
]

View File

@ -0,0 +1,41 @@
# Generated by Django 5.2.7 on 2026-01-31 13:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0030_event_location_name'),
]
operations = [
migrations.CreateModel(
name='VolunteerRole',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('is_active', models.BooleanField(default=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteer_roles', to='core.tenant')),
],
options={
'unique_together': {('tenant', 'name')},
},
),
migrations.AddField(
model_name='event',
name='default_volunteer_role',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_events', to='core.volunteerrole'),
),
migrations.AddField(
model_name='eventtype',
name='available_roles',
field=models.ManyToManyField(blank=True, related_name='event_types', to='core.volunteerrole'),
),
migrations.AddField(
model_name='volunteerevent',
name='role_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='volunteer_assignments', to='core.volunteerrole'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-02-01 00:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0031_volunteerrole_event_default_volunteer_role_and_more'),
]
operations = [
migrations.AlterField(
model_name='volunteerevent',
name='role',
field=models.CharField(blank=True, max_length=100),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-02-01 00:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0032_alter_volunteerevent_role'),
]
operations = [
migrations.RemoveField(
model_name='volunteerevent',
name='role',
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.2.7 on 2026-02-01 01:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0033_remove_volunteerevent_role'),
]
operations = [
migrations.AddField(
model_name='eventtype',
name='default_volunteer_role',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_event_types', to='core.volunteerrole'),
),
]

View File

@ -0,0 +1,43 @@
# Generated by Django 5.2.7 on 2026-02-01 01:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0034_eventtype_default_volunteer_role'),
]
operations = [
migrations.AddField(
model_name='interaction',
name='door_visit',
field=models.BooleanField(db_index=True, default=False),
),
migrations.AddField(
model_name='interaction',
name='neighborhood',
field=models.CharField(blank=True, db_index=True, max_length=100),
),
migrations.AddField(
model_name='volunteer',
name='door_visit',
field=models.BooleanField(db_index=True, default=False),
),
migrations.AddField(
model_name='volunteer',
name='neighborhood',
field=models.CharField(blank=True, db_index=True, max_length=100),
),
migrations.AddField(
model_name='voter',
name='door_visit',
field=models.BooleanField(db_index=True, default=False),
),
migrations.AddField(
model_name='voter',
name='neighborhood',
field=models.CharField(blank=True, db_index=True, max_length=100),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 5.2.7 on 2026-02-01 01:55
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0035_interaction_door_visit_interaction_neighborhood_and_more'),
]
operations = [
migrations.RemoveField(
model_name='interaction',
name='door_visit',
),
migrations.RemoveField(
model_name='interaction',
name='neighborhood',
),
migrations.RemoveField(
model_name='volunteer',
name='door_visit',
),
migrations.RemoveField(
model_name='volunteer',
name='neighborhood',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-02-01 03:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0036_remove_interaction_door_visit_and_more'),
]
operations = [
migrations.AddField(
model_name='campaignsettings',
name='timezone',
field=models.CharField(default='America/Chicago', max_length=50),
),
]

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-02-01 15:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0038_alter_campaignsettings_timezone'),
]
operations = [
migrations.AlterField(
model_name='tenantuserrole',
name='role',
field=models.CharField(choices=[('system_admin', 'System Administrator'), ('campaign_admin', 'Campaign Administrator'), ('campaign_staff', 'Campaign Staff')], max_length=20),
),
]

View File

@ -0,0 +1,32 @@
# Generated by Django 5.2.7 on 2026-02-03 01:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0039_alter_tenantuserrole_role'),
]
operations = [
migrations.AddField(
model_name='volunteer',
name='is_default_caller',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='ScheduledCall',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('comments', models.TextField(blank=True)),
('status', models.CharField(choices=[('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_calls', to='core.tenant')),
('volunteer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_calls', to='core.volunteer')),
('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_calls', to='core.voter')),
],
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-02-03 03:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0040_volunteer_is_default_caller_scheduledcall'),
]
operations = [
migrations.AlterModelOptions(
name='volunteer',
options={'ordering': ('last_name', 'first_name')},
),
]

View File

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

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