Autosave: 20260125-214643
This commit is contained in:
parent
dc2bd62142
commit
ac90cc59f4
13
ERD.md
13
ERD.md
@ -7,6 +7,7 @@ erDiagram
|
||||
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
|
||||
|
||||
@ -20,6 +21,7 @@ erDiagram
|
||||
|
||||
Event ||--o{ EventParticipation : includes
|
||||
EventType ||--o{ Event : categorizes
|
||||
ParticipationStatus ||--o{ EventParticipation : defines_status
|
||||
|
||||
InteractionType ||--o{ Interaction : categorizes
|
||||
DonationMethod ||--o{ Donation : categorizes
|
||||
@ -76,6 +78,13 @@ erDiagram
|
||||
boolean is_active
|
||||
}
|
||||
|
||||
ParticipationStatus {
|
||||
int id PK
|
||||
int tenant_id FK
|
||||
string name
|
||||
boolean is_active
|
||||
}
|
||||
|
||||
Voter {
|
||||
int id PK
|
||||
int tenant_id FK
|
||||
@ -121,7 +130,7 @@ erDiagram
|
||||
int id PK
|
||||
int event_id FK
|
||||
int voter_id FK
|
||||
string participation_type
|
||||
int participation_status_id FK
|
||||
}
|
||||
|
||||
Donation {
|
||||
@ -147,4 +156,4 @@ erDiagram
|
||||
int election_type_id FK
|
||||
string likelihood
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -12,7 +12,7 @@ from django.template.response import TemplateResponse
|
||||
from .models import (
|
||||
Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter,
|
||||
VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings,
|
||||
Interest, Volunteer, VolunteerEvent
|
||||
Interest, Volunteer, VolunteerEvent, ParticipationStatus
|
||||
)
|
||||
from .forms import (
|
||||
VoterImportForm, EventImportForm, EventParticipationImportForm,
|
||||
@ -47,7 +47,10 @@ VOTER_MAPPABLE_FIELDS = [
|
||||
]
|
||||
|
||||
EVENT_MAPPABLE_FIELDS = [
|
||||
('name', 'Name'),
|
||||
('date', 'Date'),
|
||||
('start_time', 'Start Time'),
|
||||
('end_time', 'End Time'),
|
||||
('event_type', 'Event Type (Name)'),
|
||||
('description', 'Description'),
|
||||
]
|
||||
@ -57,7 +60,7 @@ EVENT_PARTICIPATION_MAPPABLE_FIELDS = [
|
||||
('event_id', 'Event ID'),
|
||||
('event_date', 'Event Date'),
|
||||
('event_type', 'Event Type (Name)'),
|
||||
('participation_type', 'Participation Type'),
|
||||
('participation_status', 'Participation Type'),
|
||||
]
|
||||
|
||||
DONATION_MAPPABLE_FIELDS = [
|
||||
@ -148,6 +151,20 @@ class EventTypeAdmin(admin.ModelAdmin):
|
||||
list_filter = ('tenant', 'is_active')
|
||||
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)
|
||||
class InterestAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'tenant')
|
||||
@ -182,6 +199,12 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline]
|
||||
readonly_fields = ('address',)
|
||||
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):
|
||||
urls = super().get_urls()
|
||||
@ -352,9 +375,16 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
|
||||
@admin.register(Event)
|
||||
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')
|
||||
search_fields = ('name', 'description')
|
||||
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):
|
||||
urls = super().get_urls()
|
||||
@ -384,9 +414,13 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
total_count += 1
|
||||
date = row.get(mapping.get('date'))
|
||||
event_type_name = row.get(mapping.get('event_type'))
|
||||
event_name = row.get(mapping.get('name'))
|
||||
exists = False
|
||||
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:
|
||||
update_count += 1
|
||||
@ -398,7 +432,7 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
if len(preview_data) < 10:
|
||||
preview_data.append({
|
||||
'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 ''
|
||||
})
|
||||
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
|
||||
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
|
||||
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:
|
||||
row["Import Error"] = "Missing date or event type"
|
||||
@ -454,11 +491,18 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
defaults = {}
|
||||
if description and description.strip():
|
||||
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(
|
||||
tenant=tenant,
|
||||
date=date,
|
||||
event_type=event_type,
|
||||
name=name or '',
|
||||
defaults=defaults
|
||||
)
|
||||
count += 1
|
||||
@ -535,8 +579,8 @@ class VolunteerEventAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(EventParticipation)
|
||||
class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
list_display = ('voter', 'event', 'participation_type')
|
||||
list_filter = ('event__tenant', 'event', 'participation_type')
|
||||
list_display = ('voter', 'event', 'participation_status')
|
||||
list_filter = ('event__tenant', 'event', 'participation_status')
|
||||
change_list_template = "admin/eventparticipation_change_list.html"
|
||||
|
||||
def get_urls(self):
|
||||
@ -592,7 +636,7 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
preview_data.append({
|
||||
'action': action,
|
||||
'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.update({
|
||||
@ -630,7 +674,7 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
for row in reader:
|
||||
try:
|
||||
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:
|
||||
row["Import Error"] = "Missing voter ID"
|
||||
@ -675,11 +719,11 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
continue
|
||||
|
||||
defaults = {}
|
||||
if participation_type_val and participation_type_val.strip():
|
||||
if participation_type_val in dict(EventParticipation.PARTICIPATION_TYPE_CHOICES):
|
||||
defaults['participation_type'] = participation_type_val
|
||||
if participation_status_val and participation_status_val.strip():
|
||||
if participation_status_val in dict(EventParticipation.PARTICIPATION_TYPE_CHOICES):
|
||||
defaults['participation_status'] = participation_status_val
|
||||
else:
|
||||
defaults['participation_type'] = 'invited'
|
||||
defaults['participation_status'] = 'invited'
|
||||
|
||||
EventParticipation.objects.update_or_create(
|
||||
event=event,
|
||||
@ -1332,4 +1376,4 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
@admin.register(CampaignSettings)
|
||||
class CampaignSettingsAdmin(admin.ModelAdmin):
|
||||
list_display = ('tenant', 'donation_goal')
|
||||
list_filter = ('tenant',)
|
||||
list_filter = ('tenant',)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 Meta:
|
||||
@ -81,23 +81,26 @@ class VoterLikelihoodForm(forms.ModelForm):
|
||||
class EventParticipationForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = EventParticipation
|
||||
fields = ['event', 'participation_type']
|
||||
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_type'].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 = ['date', 'event_type', 'description']
|
||||
fields = ['name', 'date', 'start_time', 'end_time', 'event_type', 'description']
|
||||
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}),
|
||||
}
|
||||
|
||||
@ -161,4 +164,4 @@ class VoterLikelihoodImportForm(forms.Form):
|
||||
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'})
|
||||
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||
@ -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',
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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',
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -6,9 +6,21 @@ import urllib.request
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def format_phone_number(phone):
|
||||
"""Formats a phone number to (xxx) xxx-xxxx if it has 10 digits or 11 starting with 1."""
|
||||
if not phone:
|
||||
return phone
|
||||
digits = re.sub(r'\D', '', str(phone))
|
||||
if len(digits) == 10:
|
||||
return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
|
||||
elif len(digits) == 11 and digits.startswith('1'):
|
||||
return f"({digits[1:4]}) {digits[4:7]}-{digits[7:]}"
|
||||
return phone
|
||||
|
||||
class Tenant(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@ -76,6 +88,18 @@ class EventType(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class ParticipationStatus(models.Model):
|
||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='participation_statuses')
|
||||
name = models.CharField(max_length=100)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('tenant', 'name')
|
||||
verbose_name_plural = 'Participation Statuses'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Interest(models.Model):
|
||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='interests')
|
||||
name = models.CharField(max_length=100)
|
||||
@ -189,6 +213,9 @@ class Voter(models.Model):
|
||||
return False, err
|
||||
|
||||
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
|
||||
if self.longitude:
|
||||
self.longitude = Decimal(str(self.longitude)[:12])
|
||||
@ -243,11 +270,16 @@ class VotingRecord(models.Model):
|
||||
|
||||
class Event(models.Model):
|
||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='events')
|
||||
name = models.CharField(max_length=255, blank=True)
|
||||
date = models.DateField()
|
||||
start_time = models.TimeField(null=True, blank=True)
|
||||
end_time = models.TimeField(null=True, blank=True)
|
||||
event_type = models.ForeignKey(EventType, on_delete=models.PROTECT, null=True)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
def __str__(self):
|
||||
if self.name:
|
||||
return f"{self.name} ({self.date})"
|
||||
return f"{self.event_type} on {self.date}"
|
||||
|
||||
class Volunteer(models.Model):
|
||||
@ -259,6 +291,11 @@ class Volunteer(models.Model):
|
||||
interests = models.ManyToManyField(Interest, blank=True, related_name='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):
|
||||
return self.name
|
||||
|
||||
@ -271,18 +308,12 @@ class VolunteerEvent(models.Model):
|
||||
return f"{self.volunteer} at {self.event} as {self.role}"
|
||||
|
||||
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')
|
||||
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):
|
||||
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):
|
||||
voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='donations')
|
||||
@ -328,4 +359,4 @@ class CampaignSettings(models.Model):
|
||||
verbose_name_plural = 'Campaign Settings'
|
||||
|
||||
def __str__(self):
|
||||
return f'Settings for {self.tenant.name}'
|
||||
return f'Settings for {self.tenant.name}'
|
||||
@ -1,7 +1,38 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% load i18n admin_urls static admin_list %}
|
||||
|
||||
{% block object-tools-items %}
|
||||
<li>
|
||||
<a href="import-events/" class="addlink">Import Events</a>
|
||||
</li>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
|
||||
{% block search %}
|
||||
{{ block.super }}
|
||||
<div class="tenant-filter-container" style="margin: 10px 0; padding: 15px; background: var(--darkened-bg, #f8f9fa); border: 1px solid var(--border-color, #dee2e6); border-radius: 4px; display: flex; align-items: center; color: var(--body-fg, #333);">
|
||||
<label for="tenant-filter-select" style="font-weight: 600; margin-right: 15px; color: var(--body-fg, #333);">Filter by Tenant:</label>
|
||||
<select id="tenant-filter-select" onchange="filterTenant(this.value)" style="padding: 6px 12px; border-radius: 4px; border: 1px solid var(--border-color, #ced4da); background-color: var(--body-bg, #fff); color: var(--body-fg, #333); min-width: 200px;">
|
||||
<option value="" style="background-color: var(--body-bg); color: var(--body-fg);">-- All Tenants --</option>
|
||||
{% for tenant in tenants %}
|
||||
<option value="{{ tenant.id }}" {% if request.GET.tenant__id__exact == tenant.id|stringformat:"s" %}selected{% endif %} style="background-color: var(--body-bg); color: var(--body-fg);">
|
||||
{{ tenant.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterTenant(tenantId) {
|
||||
const url = new URL(window.location.href);
|
||||
if (tenantId) {
|
||||
url.searchParams.set('tenant__id__exact', tenantId);
|
||||
} else {
|
||||
url.searchParams.delete('tenant__id__exact');
|
||||
}
|
||||
// Reset to page 1 if filtering
|
||||
url.searchParams.delete('p');
|
||||
window.location.href = url.pathname + url.search;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
31
core/templates/admin/participationstatus_change_list.html
Normal file
31
core/templates/admin/participationstatus_change_list.html
Normal 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 %}
|
||||
@ -1,7 +1,38 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% load i18n admin_urls static admin_list %}
|
||||
|
||||
{% block object-tools-items %}
|
||||
<li>
|
||||
<a href="import-voters/" class="addlink">Import Voters</a>
|
||||
</li>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
|
||||
{% block search %}
|
||||
{{ block.super }}
|
||||
<div class="tenant-filter-container" style="margin: 10px 0; padding: 15px; background: var(--darkened-bg, #f8f9fa); border: 1px solid var(--border-color, #dee2e6); border-radius: 4px; display: flex; align-items: center; color: var(--body-fg, #333);">
|
||||
<label for="tenant-filter-select" style="font-weight: 600; margin-right: 15px; color: var(--body-fg, #333);">Filter by Tenant:</label>
|
||||
<select id="tenant-filter-select" onchange="filterTenant(this.value)" style="padding: 6px 12px; border-radius: 4px; border: 1px solid var(--border-color, #ced4da); background-color: var(--body-bg, #fff); color: var(--body-fg, #333); min-width: 200px;">
|
||||
<option value="" style="background-color: var(--body-bg); color: var(--body-fg);">-- All Tenants --</option>
|
||||
{% for tenant in tenants %}
|
||||
<option value="{{ tenant.id }}" {% if request.GET.tenant__id__exact == tenant.id|stringformat:"s" %}selected{% endif %} style="background-color: var(--body-bg); color: var(--body-fg);">
|
||||
{{ tenant.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterTenant(tenantId) {
|
||||
const url = new URL(window.location.href);
|
||||
if (tenantId) {
|
||||
url.searchParams.set('tenant__id__exact', tenantId);
|
||||
} else {
|
||||
url.searchParams.delete('tenant__id__exact');
|
||||
}
|
||||
// Reset to page 1 if filtering
|
||||
url.searchParams.delete('p');
|
||||
window.location.href = url.pathname + url.search;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -74,5 +74,35 @@
|
||||
|
||||
<!-- Bootstrap 5 JS Bundle -->
|
||||
<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>
|
||||
</html>
|
||||
@ -327,7 +327,7 @@
|
||||
<tr>
|
||||
<th class="ps-4">Date</th>
|
||||
<th>Event Type</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Description</th>
|
||||
<th class="pe-4 text-end">Actions</th>
|
||||
</tr>
|
||||
@ -338,12 +338,12 @@
|
||||
<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>
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td class="small text-muted">{{ participation.event.description|truncatechars:60 }}</td>
|
||||
@ -799,7 +799,7 @@
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-medium">Participation Status</label>
|
||||
{{ event_participation_form.participation_type }}
|
||||
{{ event_participation_form.participation_status }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 p-4 pt-0">
|
||||
@ -833,9 +833,9 @@
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-medium">Participation Status</label>
|
||||
<select name="participation_type" class="form-select">
|
||||
{% for val, label in event_participation_form.fields.participation_type.choices %}
|
||||
<option value="{{ val }}" {% if val == participation.participation_type %}selected{% endif %}>{{ label }}</option>
|
||||
<select name="participation_status" class="form-select">
|
||||
{% for status in event_participation_form.fields.participation_status.queryset %}
|
||||
<option value="{{ status.id }}" {% if status.id == participation.participation_status.id %}selected{% endif %}>{{ status.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@ -998,4 +998,4 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
Loading…
x
Reference in New Issue
Block a user