Autosave: 20260125-214643

This commit is contained in:
Flatlogic Bot 2026-01-25 21:46:44 +00:00
parent dc2bd62142
commit ac90cc59f4
21 changed files with 355 additions and 39 deletions

13
ERD.md
View File

@ -7,6 +7,7 @@ erDiagram
Tenant ||--o{ DonationMethod : defines Tenant ||--o{ DonationMethod : defines
Tenant ||--o{ ElectionType : defines Tenant ||--o{ ElectionType : defines
Tenant ||--o{ EventType : defines Tenant ||--o{ EventType : defines
Tenant ||--o{ ParticipationStatus : defines
Tenant ||--o{ Voter : belongs_to Tenant ||--o{ Voter : belongs_to
Tenant ||--o{ Event : organizes Tenant ||--o{ Event : organizes
@ -20,6 +21,7 @@ erDiagram
Event ||--o{ EventParticipation : includes Event ||--o{ EventParticipation : includes
EventType ||--o{ Event : categorizes EventType ||--o{ Event : categorizes
ParticipationStatus ||--o{ EventParticipation : defines_status
InteractionType ||--o{ Interaction : categorizes InteractionType ||--o{ Interaction : categorizes
DonationMethod ||--o{ Donation : categorizes DonationMethod ||--o{ Donation : categorizes
@ -76,6 +78,13 @@ erDiagram
boolean is_active boolean is_active
} }
ParticipationStatus {
int id PK
int tenant_id FK
string name
boolean is_active
}
Voter { Voter {
int id PK int id PK
int tenant_id FK int tenant_id FK
@ -121,7 +130,7 @@ erDiagram
int id PK int id PK
int event_id FK int event_id FK
int voter_id FK int voter_id FK
string participation_type int participation_status_id FK
} }
Donation { Donation {
@ -147,4 +156,4 @@ erDiagram
int election_type_id FK int election_type_id FK
string likelihood string likelihood
} }
``` ```

View File

@ -12,7 +12,7 @@ from django.template.response import TemplateResponse
from .models import ( from .models import (
Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter, Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,
VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings, VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings,
Interest, Volunteer, VolunteerEvent Interest, Volunteer, VolunteerEvent, ParticipationStatus
) )
from .forms import ( from .forms import (
VoterImportForm, EventImportForm, EventParticipationImportForm, VoterImportForm, EventImportForm, EventParticipationImportForm,
@ -47,7 +47,10 @@ VOTER_MAPPABLE_FIELDS = [
] ]
EVENT_MAPPABLE_FIELDS = [ EVENT_MAPPABLE_FIELDS = [
('name', 'Name'),
('date', 'Date'), ('date', 'Date'),
('start_time', 'Start Time'),
('end_time', 'End Time'),
('event_type', 'Event Type (Name)'), ('event_type', 'Event Type (Name)'),
('description', 'Description'), ('description', 'Description'),
] ]
@ -57,7 +60,7 @@ EVENT_PARTICIPATION_MAPPABLE_FIELDS = [
('event_id', 'Event ID'), ('event_id', 'Event ID'),
('event_date', 'Event Date'), ('event_date', 'Event Date'),
('event_type', 'Event Type (Name)'), ('event_type', 'Event Type (Name)'),
('participation_type', 'Participation Type'), ('participation_status', 'Participation Type'),
] ]
DONATION_MAPPABLE_FIELDS = [ DONATION_MAPPABLE_FIELDS = [
@ -148,6 +151,20 @@ class EventTypeAdmin(admin.ModelAdmin):
list_filter = ('tenant', 'is_active') list_filter = ('tenant', 'is_active')
search_fields = ('name',) search_fields = ('name',)
@admin.register(ParticipationStatus)
class ParticipationStatusAdmin(admin.ModelAdmin):
list_display = ('name', 'tenant', 'is_active')
list_filter = ('tenant', 'is_active')
search_fields = ('name',)
change_list_template = 'admin/participationstatus_change_list.html'
def changelist_view(self, request, extra_context=None):
extra_context = extra_context or {}
from core.models import Tenant
extra_context['tenants'] = Tenant.objects.all()
return super().changelist_view(request, extra_context=extra_context)
@admin.register(Interest) @admin.register(Interest)
class InterestAdmin(admin.ModelAdmin): class InterestAdmin(admin.ModelAdmin):
list_display = ('name', 'tenant') list_display = ('name', 'tenant')
@ -182,6 +199,12 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline] inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline]
readonly_fields = ('address',) readonly_fields = ('address',)
change_list_template = "admin/voter_change_list.html" change_list_template = "admin/voter_change_list.html"
def changelist_view(self, request, extra_context=None):
extra_context = extra_context or {}
from core.models import Tenant
extra_context["tenants"] = Tenant.objects.all()
return super().changelist_view(request, extra_context=extra_context)
def get_urls(self): def get_urls(self):
urls = super().get_urls() urls = super().get_urls()
@ -352,9 +375,16 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
@admin.register(Event) @admin.register(Event)
class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_display = ('id', 'event_type', 'date', 'tenant') list_display = ('id', 'name', 'event_type', 'date', 'start_time', 'end_time', 'tenant')
list_filter = ('tenant', 'date', 'event_type') list_filter = ('tenant', 'date', 'event_type')
search_fields = ('name', 'description')
change_list_template = "admin/event_change_list.html" change_list_template = "admin/event_change_list.html"
def changelist_view(self, request, extra_context=None):
extra_context = extra_context or {}
from core.models import Tenant
extra_context["tenants"] = Tenant.objects.all()
return super().changelist_view(request, extra_context=extra_context)
def get_urls(self): def get_urls(self):
urls = super().get_urls() urls = super().get_urls()
@ -384,9 +414,13 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
total_count += 1 total_count += 1
date = row.get(mapping.get('date')) date = row.get(mapping.get('date'))
event_type_name = row.get(mapping.get('event_type')) event_type_name = row.get(mapping.get('event_type'))
event_name = row.get(mapping.get('name'))
exists = False exists = False
if date and event_type_name: if date and event_type_name:
exists = Event.objects.filter(tenant=tenant, date=date, event_type__name=event_type_name).exists() q = Event.objects.filter(tenant=tenant, date=date, event_type__name=event_type_name)
if event_name:
q = q.filter(name=event_name)
exists = q.exists()
if exists: if exists:
update_count += 1 update_count += 1
@ -398,7 +432,7 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if len(preview_data) < 10: if len(preview_data) < 10:
preview_data.append({ preview_data.append({
'action': action, 'action': action,
'identifier': f"{date} - {event_type_name}", 'identifier': f"{event_name or 'No Name'} ({date} - {event_type_name})",
'details': row.get(mapping.get('description', '')) or '' 'details': row.get(mapping.get('description', '')) or ''
}) })
context = self.admin_site.each_context(request) context = self.admin_site.each_context(request)
@ -439,6 +473,9 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
date = row.get(mapping.get('date')) if mapping.get('date') else None date = row.get(mapping.get('date')) if mapping.get('date') else None
event_type_name = row.get(mapping.get('event_type')) if mapping.get('event_type') else None event_type_name = row.get(mapping.get('event_type')) if mapping.get('event_type') else None
description = row.get(mapping.get('description')) if mapping.get('description') else None description = row.get(mapping.get('description')) if mapping.get('description') else None
name = row.get(mapping.get('name')) if mapping.get('name') else None
start_time = row.get(mapping.get('start_time')) if mapping.get('start_time') else None
end_time = row.get(mapping.get('end_time')) if mapping.get('end_time') else None
if not date or not event_type_name: if not date or not event_type_name:
row["Import Error"] = "Missing date or event type" row["Import Error"] = "Missing date or event type"
@ -454,11 +491,18 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
defaults = {} defaults = {}
if description and description.strip(): if description and description.strip():
defaults['description'] = description defaults['description'] = description
if name and name.strip():
defaults['name'] = name
if start_time and start_time.strip():
defaults['start_time'] = start_time
if end_time and end_time.strip():
defaults['end_time'] = end_time
Event.objects.update_or_create( Event.objects.update_or_create(
tenant=tenant, tenant=tenant,
date=date, date=date,
event_type=event_type, event_type=event_type,
name=name or '',
defaults=defaults defaults=defaults
) )
count += 1 count += 1
@ -535,8 +579,8 @@ class VolunteerEventAdmin(admin.ModelAdmin):
@admin.register(EventParticipation) @admin.register(EventParticipation)
class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin): class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_display = ('voter', 'event', 'participation_type') list_display = ('voter', 'event', 'participation_status')
list_filter = ('event__tenant', 'event', 'participation_type') list_filter = ('event__tenant', 'event', 'participation_status')
change_list_template = "admin/eventparticipation_change_list.html" change_list_template = "admin/eventparticipation_change_list.html"
def get_urls(self): def get_urls(self):
@ -592,7 +636,7 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
preview_data.append({ preview_data.append({
'action': action, 'action': action,
'identifier': f"Voter: {voter_id}", 'identifier': f"Voter: {voter_id}",
'details': f"Participation: {row.get(mapping.get('participation_type', '')) or ''}" 'details': f"Participation: {row.get(mapping.get('participation_status', '')) or ''}"
}) })
context = self.admin_site.each_context(request) context = self.admin_site.each_context(request)
context.update({ context.update({
@ -630,7 +674,7 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
for row in reader: for row in reader:
try: try:
voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None
participation_type_val = row.get(mapping.get('participation_type')) if mapping.get('participation_type') else None participation_status_val = row.get(mapping.get('participation_status')) if mapping.get('participation_status') else None
if not voter_id: if not voter_id:
row["Import Error"] = "Missing voter ID" row["Import Error"] = "Missing voter ID"
@ -675,11 +719,11 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
continue continue
defaults = {} defaults = {}
if participation_type_val and participation_type_val.strip(): if participation_status_val and participation_status_val.strip():
if participation_type_val in dict(EventParticipation.PARTICIPATION_TYPE_CHOICES): if participation_status_val in dict(EventParticipation.PARTICIPATION_TYPE_CHOICES):
defaults['participation_type'] = participation_type_val defaults['participation_status'] = participation_status_val
else: else:
defaults['participation_type'] = 'invited' defaults['participation_status'] = 'invited'
EventParticipation.objects.update_or_create( EventParticipation.objects.update_or_create(
event=event, event=event,
@ -1332,4 +1376,4 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
@admin.register(CampaignSettings) @admin.register(CampaignSettings)
class CampaignSettingsAdmin(admin.ModelAdmin): class CampaignSettingsAdmin(admin.ModelAdmin):
list_display = ('tenant', 'donation_goal') list_display = ('tenant', 'donation_goal')
list_filter = ('tenant',) list_filter = ('tenant',)

View File

@ -1,5 +1,5 @@
from django import forms from django import forms
from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType, Tenant from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType, Tenant, ParticipationStatus
class VoterForm(forms.ModelForm): class VoterForm(forms.ModelForm):
class Meta: class Meta:
@ -81,23 +81,26 @@ class VoterLikelihoodForm(forms.ModelForm):
class EventParticipationForm(forms.ModelForm): class EventParticipationForm(forms.ModelForm):
class Meta: class Meta:
model = EventParticipation model = EventParticipation
fields = ['event', 'participation_type'] fields = ['event', 'participation_status']
def __init__(self, *args, tenant=None, **kwargs): def __init__(self, *args, tenant=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if tenant: if tenant:
self.fields['event'].queryset = Event.objects.filter(tenant=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(): for field in self.fields.values():
field.widget.attrs.update({'class': 'form-control'}) field.widget.attrs.update({'class': 'form-control'})
self.fields['event'].widget.attrs.update({'class': 'form-select'}) self.fields['event'].widget.attrs.update({'class': 'form-select'})
self.fields['participation_type'].widget.attrs.update({'class': 'form-select'}) self.fields['participation_status'].widget.attrs.update({'class': 'form-select'})
class EventForm(forms.ModelForm): class EventForm(forms.ModelForm):
class Meta: class Meta:
model = Event model = Event
fields = ['date', 'event_type', 'description'] fields = ['name', 'date', 'start_time', 'end_time', 'event_type', 'description']
widgets = { widgets = {
'date': forms.DateInput(attrs={'type': 'date'}), '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}), 'description': forms.Textarea(attrs={'rows': 2}),
} }
@ -161,4 +164,4 @@ class VoterLikelihoodImportForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'}) self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
self.fields['file'].widget.attrs.update({'class': 'form-control'}) self.fields['file'].widget.attrs.update({'class': 'form-control'})

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

@ -6,9 +6,21 @@ import urllib.request
import logging import logging
from decimal import Decimal from decimal import Decimal
from django.conf import settings from django.conf import settings
import re
logger = logging.getLogger(__name__) 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): class Tenant(models.Model):
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@ -76,6 +88,18 @@ class EventType(models.Model):
def __str__(self): def __str__(self):
return self.name 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 Interest(models.Model): class Interest(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='interests') tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='interests')
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
@ -189,6 +213,9 @@ class Voter(models.Model):
return False, err return False, err
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Auto-format phone number
self.phone = format_phone_number(self.phone)
# Ensure longitude is truncated to 12 characters before saving # Ensure longitude is truncated to 12 characters before saving
if self.longitude: if self.longitude:
self.longitude = Decimal(str(self.longitude)[:12]) self.longitude = Decimal(str(self.longitude)[:12])
@ -243,11 +270,16 @@ class VotingRecord(models.Model):
class Event(models.Model): class Event(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='events') tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='events')
name = models.CharField(max_length=255, blank=True)
date = models.DateField() 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) event_type = models.ForeignKey(EventType, on_delete=models.PROTECT, null=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
def __str__(self): def __str__(self):
if self.name:
return f"{self.name} ({self.date})"
return f"{self.event_type} on {self.date}" return f"{self.event_type} on {self.date}"
class Volunteer(models.Model): class Volunteer(models.Model):
@ -259,6 +291,11 @@ class Volunteer(models.Model):
interests = models.ManyToManyField(Interest, blank=True, related_name='volunteers') interests = models.ManyToManyField(Interest, blank=True, related_name='volunteers')
assigned_events = models.ManyToManyField(Event, through='VolunteerEvent', related_name='assigned_volunteers') assigned_events = models.ManyToManyField(Event, through='VolunteerEvent', related_name='assigned_volunteers')
def save(self, *args, **kwargs):
# Auto-format phone number
self.phone = format_phone_number(self.phone)
super().save(*args, **kwargs)
def __str__(self): def __str__(self):
return self.name return self.name
@ -271,18 +308,12 @@ class VolunteerEvent(models.Model):
return f"{self.volunteer} at {self.event} as {self.role}" return f"{self.volunteer} at {self.event} as {self.role}"
class EventParticipation(models.Model): class EventParticipation(models.Model):
PARTICIPATION_TYPE_CHOICES = [
("invited", "Invited"),
("invited_not_attended", "Invited but didn't attend"),
("attended", "Attended"),
]
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='participations') event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='participations')
voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='event_participations') voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='event_participations')
participation_type = models.CharField(max_length=50, choices=PARTICIPATION_TYPE_CHOICES, default='invited') participation_status = models.ForeignKey(ParticipationStatus, on_delete=models.PROTECT, null=True)
def __str__(self): def __str__(self):
return f"{self.voter} at {self.event} ({self.get_participation_type_display()})" return f"{self.voter} at {self.event} ({self.participation_status})"
class Donation(models.Model): class Donation(models.Model):
voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='donations') voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='donations')
@ -328,4 +359,4 @@ class CampaignSettings(models.Model):
verbose_name_plural = 'Campaign Settings' verbose_name_plural = 'Campaign Settings'
def __str__(self): def __str__(self):
return f'Settings for {self.tenant.name}' return f'Settings for {self.tenant.name}'

View File

@ -1,7 +1,38 @@
{% extends "admin/change_list.html" %} {% extends "admin/change_list.html" %}
{% load i18n admin_urls static admin_list %}
{% block object-tools-items %} {% block object-tools-items %}
<li> <li>
<a href="import-events/" class="addlink">Import Events</a> <a href="import-events/" class="addlink">Import Events</a>
</li> </li>
{{ block.super }} {{ block.super }}
{% endblock %} {% 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

@ -0,0 +1,31 @@
{% 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,7 +1,38 @@
{% extends "admin/change_list.html" %} {% extends "admin/change_list.html" %}
{% load i18n admin_urls static admin_list %}
{% block object-tools-items %} {% block object-tools-items %}
<li> <li>
<a href="import-voters/" class="addlink">Import Voters</a> <a href="import-voters/" class="addlink">Import Voters</a>
</li> </li>
{{ block.super }} {{ block.super }}
{% endblock %} {% 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

@ -74,5 +74,35 @@
<!-- Bootstrap 5 JS Bundle --> <!-- Bootstrap 5 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
function formatPhoneNumber(value) {
if (!value) return value;
const phoneNumber = value.replace(/[^\d]/g, '');
const phoneNumberLength = phoneNumber.length;
if (phoneNumberLength < 4) return phoneNumber;
if (phoneNumberLength < 7) {
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3)}`;
}
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 10)}`;
}
function phoneNumberFormatter() {
const inputField = this;
const formattedFieldValue = formatPhoneNumber(inputField.value);
inputField.value = formattedFieldValue;
}
const phoneInputs = document.querySelectorAll('input[name="phone"], input[type="tel"]');
phoneInputs.forEach(input => {
input.addEventListener('input', phoneNumberFormatter);
// Also format on load if it has a value
if (input.value) {
input.value = formatPhoneNumber(input.value);
}
});
});
</script>
</body> </body>
</html> </html>

View File

@ -327,7 +327,7 @@
<tr> <tr>
<th class="ps-4">Date</th> <th class="ps-4">Date</th>
<th>Event Type</th> <th>Event Type</th>
<th>Type</th> <th>Status</th>
<th>Description</th> <th>Description</th>
<th class="pe-4 text-end">Actions</th> <th class="pe-4 text-end">Actions</th>
</tr> </tr>
@ -338,12 +338,12 @@
<td class="ps-4 text-nowrap">{{ participation.event.date|date:"M d, Y" }}</td> <td class="ps-4 text-nowrap">{{ participation.event.date|date:"M d, Y" }}</td>
<td><span class="badge bg-light text-dark border">{{ participation.event.event_type.name }}</span></td> <td><span class="badge bg-light text-dark border">{{ participation.event.event_type.name }}</span></td>
<td> <td>
{% if participation.participation_type == 'attended' %} {% if participation.participation_status.name|lower == 'attended' %}
<span class="badge bg-success-subtle text-success border border-success-subtle">Attended</span> <span class="badge bg-success-subtle text-success border border-success-subtle">Attended</span>
{% elif participation.participation_type == 'invited_not_attended' %} {% elif participation.participation_status.name|lower == "invited but didn't attend" or participation.participation_status.name|lower == "invited but didn't attend" %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">Did Not Attend</span> <span class="badge bg-danger-subtle text-danger border border-danger-subtle">Did Not Attend</span>
{% else %} {% else %}
<span class="badge bg-info-subtle text-info border border-info-subtle">Invited</span> <span class="badge bg-info-subtle text-info border border-info-subtle">{{ participation.participation_status.name }}</span>
{% endif %} {% endif %}
</td> </td>
<td class="small text-muted">{{ participation.event.description|truncatechars:60 }}</td> <td class="small text-muted">{{ participation.event.description|truncatechars:60 }}</td>
@ -799,7 +799,7 @@
</div> </div>
<div class="mb-0"> <div class="mb-0">
<label class="form-label fw-medium">Participation Status</label> <label class="form-label fw-medium">Participation Status</label>
{{ event_participation_form.participation_type }} {{ event_participation_form.participation_status }}
</div> </div>
</div> </div>
<div class="modal-footer border-0 p-4 pt-0"> <div class="modal-footer border-0 p-4 pt-0">
@ -833,9 +833,9 @@
</div> </div>
<div class="mb-0"> <div class="mb-0">
<label class="form-label fw-medium">Participation Status</label> <label class="form-label fw-medium">Participation Status</label>
<select name="participation_type" class="form-select"> <select name="participation_status" class="form-select">
{% for val, label in event_participation_form.fields.participation_type.choices %} {% for status in event_participation_form.fields.participation_status.queryset %}
<option value="{{ val }}" {% if val == participation.participation_type %}selected{% endif %}>{{ label }}</option> <option value="{{ status.id }}" {% if status.id == participation.participation_status.id %}selected{% endif %}>{{ status.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@ -998,4 +998,4 @@
} }
}); });
</script> </script>
{% endblock %} {% endblock %}