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{ 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
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
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 (
|
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',)
|
||||||
|
|||||||
@ -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'})
|
||||||
@ -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
|
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}'
|
||||||
@ -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 %}
|
||||||
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" %}
|
{% 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 %}
|
||||||
@ -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>
|
||||||
@ -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 %}
|
||||||
Loading…
x
Reference in New Issue
Block a user