Autosave: 20260130-044056

This commit is contained in:
Flatlogic Bot 2026-01-30 04:40:56 +00:00
parent 6b464385a5
commit 181163257f
22 changed files with 855 additions and 47 deletions

View File

@ -39,6 +39,7 @@ VOTER_MAPPABLE_FIELDS = [
('zip_code', 'Zip Code'), ('zip_code', 'Zip Code'),
('county', 'County'), ('county', 'County'),
('phone', 'Phone'), ('phone', 'Phone'),
('notes', 'Notes'),
('phone_type', 'Phone Type'), ('phone_type', 'Phone Type'),
('email', 'Email'), ('email', 'Email'),
('district', 'District'), ('district', 'District'),
@ -50,6 +51,8 @@ VOTER_MAPPABLE_FIELDS = [
('window_sticker', 'Window Sticker'), ('window_sticker', 'Window Sticker'),
('latitude', 'Latitude'), ('latitude', 'Latitude'),
('longitude', 'Longitude'), ('longitude', 'Longitude'),
('secondary_phone', 'Secondary Phone'),
('secondary_phone_type', 'Secondary Phone Type'),
] ]
EVENT_MAPPABLE_FIELDS = [ EVENT_MAPPABLE_FIELDS = [
@ -59,6 +62,13 @@ EVENT_MAPPABLE_FIELDS = [
('end_time', 'End Time'), ('end_time', 'End Time'),
('event_type', 'Event Type (Name)'), ('event_type', 'Event Type (Name)'),
('description', 'Description'), ('description', 'Description'),
('location_name', 'Location Name'),
('address', 'Address'),
('city', 'City'),
('state', 'State'),
('zip_code', 'Zip Code'),
('latitude', 'Latitude'),
('longitude', 'Longitude'),
] ]
EVENT_PARTICIPATION_MAPPABLE_FIELDS = [ EVENT_PARTICIPATION_MAPPABLE_FIELDS = [
@ -76,6 +86,7 @@ DONATION_MAPPABLE_FIELDS = [
INTERACTION_MAPPABLE_FIELDS = [ INTERACTION_MAPPABLE_FIELDS = [
('voter_id', 'Voter ID'), ('voter_id', 'Voter ID'),
('volunteer_email', 'Volunteer Email'),
('date', 'Date'), ('date', 'Date'),
('type', 'Interaction Type (Name)'), ('type', 'Interaction Type (Name)'),
('description', 'Description'), ('description', 'Description'),
@ -88,6 +99,7 @@ VOLUNTEER_MAPPABLE_FIELDS = [
('last_name', 'Last Name'), ('last_name', 'Last Name'),
('email', 'Email'), ('email', 'Email'),
('phone', 'Phone'), ('phone', 'Phone'),
('notes', 'Notes'),
] ]
VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [ VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [
@ -318,7 +330,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
valid_fields = {f.name for f in Voter._meta.get_fields()} valid_fields = {f.name for f in Voter._meta.get_fields()}
mapped_fields = {f for f in mapping.keys() if f in valid_fields} mapped_fields = {f for f in mapping.keys() if f in valid_fields}
# Ensure derived/special fields are in update_fields # Ensure derived/special fields are in update_fields
update_fields = list(mapped_fields | {"address", "phone", "longitude", "latitude"}) update_fields = list(mapped_fields | {"address", "phone", "secondary_phone", "secondary_phone_type", "longitude", "latitude"})
if "voter_id" in update_fields: update_fields.remove("voter_id") if "voter_id" in update_fields: update_fields.remove("voter_id")
def chunk_reader(reader, size): def chunk_reader(reader, size):
@ -412,7 +424,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if val_lower in window_sticker_choices: val = val_lower if val_lower in window_sticker_choices: val = val_lower
elif val_lower in window_sticker_reverse: val = window_sticker_reverse[val_lower] elif val_lower in window_sticker_reverse: val = window_sticker_reverse[val_lower]
else: val = "none" else: val = "none"
elif field_name == "phone_type": elif field_name in ["phone_type", "secondary_phone_type"]:
val_lower = val.lower() val_lower = val.lower()
if val_lower in phone_type_choices: if val_lower in phone_type_choices:
val = val_lower val = val_lower
@ -431,6 +443,11 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if voter.phone != old_phone: if voter.phone != old_phone:
changed = True changed = True
old_secondary_phone = voter.secondary_phone
voter.secondary_phone = format_phone_number(voter.secondary_phone)
if voter.secondary_phone != old_secondary_phone:
changed = True
if voter.longitude: if voter.longitude:
try: try:
new_lon = Decimal(str(voter.longitude)[:12]) new_lon = Decimal(str(voter.longitude)[:12])
@ -527,9 +544,9 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
return render(request, "admin/import_csv.html", context) return render(request, "admin/import_csv.html", context)
@admin.register(Event) @admin.register(Event)
class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_display = ('id', 'name', 'event_type', 'date', 'start_time', 'end_time', 'tenant') list_display = ('id', 'name', 'event_type', 'date', 'location_name', 'city', 'state', 'tenant')
list_filter = ('tenant', 'date', 'event_type') list_filter = ('tenant', 'date', 'event_type', 'city', 'state')
search_fields = ('name', 'description') search_fields = ('name', 'description', 'location_name', 'address', 'city', 'state', 'zip_code')
change_list_template = "admin/event_change_list.html" change_list_template = "admin/event_change_list.html"
def changelist_view(self, request, extra_context=None): def changelist_view(self, request, extra_context=None):
@ -585,7 +602,7 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
preview_data.append({ preview_data.append({
'action': action, 'action': action,
'identifier': f"{event_name or 'No Name'} ({date} - {event_type_name})", 'identifier': f"{event_name or 'No Name'} ({date} - {event_type_name})",
'details': row.get(mapping.get('description', '')) or '' 'details': f"{row.get(mapping.get('city', '')) or ''}, {row.get(mapping.get('state', '')) or ''}"
}) })
context = self.admin_site.each_context(request) context = self.admin_site.each_context(request)
context.update({ context.update({
@ -625,9 +642,16 @@ 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
location_name = row.get(mapping.get('location_name')) if mapping.get('location_name') else None
name = row.get(mapping.get('name')) if mapping.get('name') 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 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 end_time = row.get(mapping.get('end_time')) if mapping.get('end_time') else None
address = row.get(mapping.get('address')) if mapping.get('address') else None
city = row.get(mapping.get('city')) if mapping.get('city') else None
state = row.get(mapping.get('state')) if mapping.get('state') else None
zip_code = row.get(mapping.get('zip_code')) if mapping.get('zip_code') else None
latitude = row.get(mapping.get('latitude')) if mapping.get('latitude') else None
longitude = row.get(mapping.get('longitude')) if mapping.get('longitude') 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"
@ -643,12 +667,26 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
defaults = {} defaults = {}
if description and description.strip(): if description and description.strip():
defaults['description'] = description defaults['description'] = description
if location_name and location_name.strip():
defaults['location_name'] = location_name
if name and name.strip(): if name and name.strip():
defaults['name'] = name defaults['name'] = name
if start_time and start_time.strip(): if start_time and start_time.strip():
defaults['start_time'] = start_time defaults['start_time'] = start_time
if end_time and end_time.strip(): if end_time and end_time.strip():
defaults['end_time'] = end_time defaults['end_time'] = end_time
if address and address.strip():
defaults['address'] = address
if city and city.strip():
defaults['city'] = city
if state and state.strip():
defaults['state'] = state
if zip_code and zip_code.strip():
defaults['zip_code'] = zip_code
if latitude and latitude.strip():
defaults['latitude'] = latitude
if longitude and longitude.strip():
defaults['longitude'] = longitude
defaults['date'] = date defaults['date'] = date
defaults['event_type'] = event_type defaults['event_type'] = event_type
@ -677,10 +715,6 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
except Exception as e: except Exception as e:
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
return redirect("..") return redirect("..")
except Exception as e:
print(f"DEBUG: Voter import failed: {e}")
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
return redirect("..")
else: else:
form = EventImportForm(request.POST, request.FILES) form = EventImportForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
@ -724,7 +758,7 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin): class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant', 'user') list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant', 'user')
list_filter = ('tenant',) list_filter = ('tenant',)
fields = ('tenant', 'user', 'first_name', 'last_name', 'email', 'phone', 'interests') fields = ('tenant', 'user', 'first_name', 'last_name', 'email', 'phone', 'notes', 'interests')
search_fields = ('first_name', 'last_name', 'email', 'phone') search_fields = ('first_name', 'last_name', 'email', 'phone')
inlines = [VolunteerEventInline, InteractionInline] inlines = [VolunteerEventInline, InteractionInline]
filter_horizontal = ('interests',) filter_horizontal = ('interests',)
@ -845,10 +879,6 @@ class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
except Exception as e: except Exception as e:
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
return redirect("..") return redirect("..")
except Exception as e:
print(f"DEBUG: Voter import failed: {e}")
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
return redirect("..")
else: else:
form = VolunteerImportForm(request.POST, request.FILES) form = VolunteerImportForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
@ -1048,10 +1078,6 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
except Exception as e: except Exception as e:
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
return redirect("..") return redirect("..")
except Exception as e:
print(f"DEBUG: Voter import failed: {e}")
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
return redirect("..")
else: else:
form = EventParticipationImportForm(request.POST, request.FILES) form = EventParticipationImportForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
@ -1241,10 +1267,6 @@ class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
except Exception as e: except Exception as e:
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
return redirect("..") return redirect("..")
except Exception as e:
print(f"DEBUG: Voter import failed: {e}")
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
return redirect("..")
else: else:
form = DonationImportForm(request.POST, request.FILES) form = DonationImportForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
@ -1388,6 +1410,7 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
date = row.get(mapping.get('date')) date = row.get(mapping.get('date'))
type_name = row.get(mapping.get('type')) type_name = row.get(mapping.get('type'))
volunteer_email = row.get(mapping.get('volunteer_email'))
description = row.get(mapping.get('description')) description = row.get(mapping.get('description'))
notes = row.get(mapping.get('notes')) notes = row.get(mapping.get('notes'))
@ -1397,6 +1420,12 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
errors += 1 errors += 1
continue continue
volunteer = None
if volunteer_email and volunteer_email.strip():
try:
volunteer = Volunteer.objects.get(tenant=tenant, email=volunteer_email.strip())
except Volunteer.DoesNotExist:
pass
interaction_type = None interaction_type = None
if type_name and type_name.strip(): if type_name and type_name.strip():
interaction_type, _ = InteractionType.objects.get_or_create( interaction_type, _ = InteractionType.objects.get_or_create(
@ -1405,6 +1434,8 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
) )
defaults = {} defaults = {}
if volunteer:
defaults['volunteer'] = volunteer
if interaction_type: if interaction_type:
defaults['type'] = interaction_type defaults['type'] = interaction_type
if description and description.strip(): if description and description.strip():
@ -1437,10 +1468,6 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
except Exception as e: except Exception as e:
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
return redirect("..") return redirect("..")
except Exception as e:
print(f"DEBUG: Voter import failed: {e}")
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
return redirect("..")
else: else:
form = InteractionImportForm(request.POST, request.FILES) form = InteractionImportForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():

View File

@ -85,7 +85,7 @@ class AdvancedVoterSearchForm(forms.Form):
class InteractionForm(forms.ModelForm): class InteractionForm(forms.ModelForm):
class Meta: class Meta:
model = Interaction model = Interaction
fields = ['type', 'date', 'description', 'notes'] fields = ['type', 'volunteer', 'date', 'description', 'notes']
widgets = { widgets = {
'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'), 'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'),
'notes': forms.Textarea(attrs={'rows': 2}), 'notes': forms.Textarea(attrs={'rows': 2}),
@ -95,9 +95,11 @@ class InteractionForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if tenant: if tenant:
self.fields['type'].queryset = InteractionType.objects.filter(tenant=tenant, is_active=True) self.fields['type'].queryset = InteractionType.objects.filter(tenant=tenant, is_active=True)
self.fields['volunteer'].queryset = Volunteer.objects.filter(tenant=tenant)
for field in self.fields.values(): for field in self.fields.values():
field.widget.attrs.update({'class': 'form-control'}) field.widget.attrs.update({'class': 'form-control'})
self.fields['type'].widget.attrs.update({'class': 'form-select'}) self.fields['type'].widget.attrs.update({'class': 'form-select'})
self.fields['volunteer'].widget.attrs.update({'class': 'form-select'})
if self.instance and self.instance.date: if self.instance and self.instance.date:
self.initial['date'] = self.instance.date.strftime('%Y-%m-%dT%H:%M') self.initial['date'] = self.instance.date.strftime('%Y-%m-%dT%H:%M')
@ -168,7 +170,7 @@ class EventParticipantAddForm(forms.ModelForm):
class EventForm(forms.ModelForm): class EventForm(forms.ModelForm):
class Meta: class Meta:
model = Event model = Event
fields = ['name', 'date', 'start_time', 'end_time', 'event_type', 'description'] fields = ['name', 'date', 'start_time', 'end_time', 'event_type', 'description', 'location_name', 'address', 'city', 'state', 'zip_code', 'latitude', 'longitude']
widgets = { widgets = {
'date': forms.DateInput(attrs={'type': 'date'}), 'date': forms.DateInput(attrs={'type': 'date'}),
'start_time': forms.TimeInput(attrs={'type': 'time'}), 'start_time': forms.TimeInput(attrs={'type': 'time'}),
@ -250,7 +252,8 @@ class VolunteerImportForm(forms.Form):
class VolunteerForm(forms.ModelForm): class VolunteerForm(forms.ModelForm):
class Meta: class Meta:
model = Volunteer model = Volunteer
fields = ['first_name', 'last_name', 'email', 'phone', 'interests'] fields = ['first_name', 'last_name', 'email', 'phone', 'notes', 'interests']
widgets = {'notes': forms.Textarea(attrs={'rows': 3})}
def __init__(self, *args, tenant=None, **kwargs): def __init__(self, *args, tenant=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -276,3 +279,20 @@ class VolunteerEventForm(forms.ModelForm):
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'})
class VolunteerEventAddForm(forms.ModelForm):
class Meta:
model = VolunteerEvent
fields = ['volunteer', 'role']
def __init__(self, *args, tenant=None, **kwargs):
super().__init__(*args, **kwargs)
if tenant:
volunteer_id = self.data.get('volunteer') or self.initial.get('volunteer')
if volunteer_id:
self.fields['volunteer'].queryset = Volunteer.objects.filter(tenant=tenant, id=volunteer_id)
else:
self.fields['volunteer'].queryset = Volunteer.objects.none()
for field in self.fields.values():
field.widget.attrs.update({'class': 'form-control'})
self.fields['volunteer'].widget.attrs.update({'class': 'form-select'})

View File

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

View File

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

View File

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

View File

@ -289,6 +289,13 @@ class Event(models.Model):
end_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)
location_name = models.CharField(max_length=255, blank=True)
address = models.CharField(max_length=255, blank=True)
city = models.CharField(max_length=100, blank=True)
state = models.CharField(max_length=2, blank=True)
zip_code = models.CharField(max_length=20, blank=True)
latitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True)
longitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True)
class Meta: class Meta:
unique_together = ('tenant', 'name') unique_together = ('tenant', 'name')
@ -307,6 +314,7 @@ class Volunteer(models.Model):
phone = models.CharField(max_length=20, blank=True) phone = models.CharField(max_length=20, blank=True)
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')
notes = models.TextField(blank=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Auto-format phone number # Auto-format phone number
@ -379,4 +387,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

@ -13,7 +13,10 @@
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h1 class="h2 mb-0">{{ event.name|default:event.event_type }}</h1> <h1 class="h2 mb-0">{{ event.name|default:event.event_type }}</h1>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="/admin/core/event/{{ event.id }}/change/" class="btn btn-outline-secondary btn-sm">Edit Event Info</a> <a href="{% url 'event_edit' event.id %}" class="btn btn-outline-secondary btn-sm">Edit Event Info</a>
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addVolunteerModal">
+ Add Volunteer
</button>
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addParticipantModal"> <button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addParticipantModal">
+ Add Participant + Add Participant
</button> </button>
@ -24,7 +27,7 @@
<div class="row g-4"> <div class="row g-4">
<!-- Event Details Column --> <!-- Event Details Column -->
<div class="col-lg-4"> <div class="col-lg-4">
<div class="card border-0 shadow-sm h-100"> <div class="card border-0 shadow-sm mb-4">
<div class="card-body"> <div class="card-body">
<h5 class="card-title fw-bold mb-4">Event Details</h5> <h5 class="card-title fw-bold mb-4">Event Details</h5>
<div class="mb-3"> <div class="mb-3">
@ -46,12 +49,75 @@
{% endif %} {% endif %}
</span> </span>
</div> </div>
<div class="mb-3">
<label class="small text-muted text-uppercase fw-bold d-block">Location</label>
{% if event.location_name %}<strong>{{ event.location_name }}</strong><br>{% endif %}
<span>
{% if event.address %}
{{ event.address }}<br>
{{ event.city }}{% if event.city and event.state %}, {% endif %}{{ event.state }} {{ event.zip_code }}
{% elif event.city or event.state or event.zip_code %}
{{ event.city }}{% if event.city and event.state %}, {% endif %}{{ event.state }} {{ event.zip_code }}
{% else %}
No location provided.
{% endif %}
</span>
{% if event.latitude and event.longitude %}
<div class="mt-2 small text-muted">
<i class="bi bi-geo-alt"></i> {{ event.latitude }}, {{ event.longitude }}
</div>
{% endif %}
</div>
<div class="mb-0"> <div class="mb-0">
<label class="small text-muted text-uppercase fw-bold d-block">Description</label> <label class="small text-muted text-uppercase fw-bold d-block">Description</label>
<p class="mb-0 text-muted">{{ event.description|default:"No description provided." }}</p> <p class="mb-0 text-muted">{{ event.description|default:"No description provided." }}</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Volunteers Card -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-bold">Volunteers ({{ volunteers.count }})</h5>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="bg-light">
<tr>
<th class="ps-4">Volunteer</th>
<th>Role</th>
<th class="pe-4 text-end"></th>
</tr>
</thead>
<tbody>
{% for v in volunteers %}
<tr>
<td class="ps-4">
<a href="{% url 'volunteer_detail' v.volunteer.id %}" class="fw-semibold text-primary text-decoration-none">
{{ v.volunteer.first_name }} {{ v.volunteer.last_name }}
</a>
</td>
<td><span class="small">{{ v.role }}</span></td>
<td class="pe-4 text-end">
<form action="{% url 'event_remove_volunteer' v.id %}" method="POST" onsubmit="return confirm('Remove this volunteer?')">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-link text-danger p-0">
<i class="bi bi-x-circle"></i>
</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center py-4 text-muted small">
No volunteers assigned.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div> </div>
<!-- Participants Column --> <!-- Participants Column -->
@ -171,8 +237,55 @@
</div> </div>
</div> </div>
<!-- Add Volunteer Modal -->
<div class="modal fade" id="addVolunteerModal" tabindex="-1" aria-labelledby="addVolunteerModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0 bg-primary text-white">
<h5 class="modal-title fw-bold" id="addVolunteerModalLabel">Add Volunteer</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'event_add_volunteer' event.id %}" method="POST" id="addVolunteerForm">
{% csrf_token %}
<div class="modal-body p-4">
<div class="mb-3">
<label class="form-label fw-bold">Search Volunteer</label>
<div class="input-group">
<input type="text" id="volunteerSearchInput" class="form-control" placeholder="Type name or email..." autocomplete="off">
<span class="input-group-text bg-white border-start-0">
<i class="bi bi-search text-muted"></i>
</span>
</div>
<div id="volunteerSearchResults" class="list-group mt-2 shadow-sm d-none" style="max-height: 200px; overflow-y: auto; position: absolute; width: calc(100% - 3rem); z-index: 1000;">
<!-- Results will appear here -->
</div>
<div id="selectedVolunteerDisplay" class="mt-2 d-none">
<div class="alert alert-primary py-2 px-3 mb-0 d-flex justify-content-between align-items-center rounded-3 border-0">
<span id="volunteerNameDisplay" class="fw-semibold"></span>
<button type="button" class="btn-close small" id="clearSelectedVolunteer"></button>
</div>
</div>
<!-- Hidden field for the actual volunteer ID -->
<input type="hidden" name="volunteer" id="volunteer_id_hidden" required>
</div>
<div class="mb-0">
<label for="{{ add_volunteer_form.role.id_for_label }}" class="form-label fw-bold">Role</label>
{{ add_volunteer_form.role }}
<div class="form-text small">e.g., Driver, Caller, Organizer</div>
</div>
</div>
<div class="modal-footer border-0 p-4 pt-0">
<button type="button" class="btn btn-outline-secondary rounded-pill px-4" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary rounded-pill px-4 shadow-sm" id="submitAddVolunteer" disabled>Add Volunteer</button>
</div>
</form>
</div>
</div>
</div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Voter Search Logic
const searchInput = document.getElementById('voterSearchInput'); const searchInput = document.getElementById('voterSearchInput');
const resultsContainer = document.getElementById('voterSearchResults'); const resultsContainer = document.getElementById('voterSearchResults');
const hiddenVoterId = document.getElementById('voter_id_hidden'); const hiddenVoterId = document.getElementById('voter_id_hidden');
@ -202,7 +315,7 @@ document.addEventListener('DOMContentLoaded', function() {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
btn.className = 'list-group-item list-group-item-action py-2'; btn.className = 'list-group-item list-group-item-action py-2';
btn.innerHTML = `<div class="fw-bold">${voter.text}</div><div class="small text-muted">${voter.address || "No address"} | ${voter.phone || "No phone"}</div>`; btn.innerHTML = `<div class="fw-bold text-dark">${voter.text}</div><div class="small text-muted">${voter.address || "No address"} | ${voter.phone || "No phone"}</div>`;
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
selectVoter(voter.id, voter.text, voter.address, voter.phone); selectVoter(voter.id, voter.text, voter.address, voter.phone);
}); });
@ -237,12 +350,81 @@ document.addEventListener('DOMContentLoaded', function() {
searchInput.value = ''; searchInput.value = '';
submitBtn.disabled = true; submitBtn.disabled = true;
}); });
// Volunteer Search Logic
const volSearchInput = document.getElementById('volunteerSearchInput');
const volResultsContainer = document.getElementById('volunteerSearchResults');
const volHiddenId = document.getElementById('volunteer_id_hidden');
const volSelectedDisplay = document.getElementById('selectedVolunteerDisplay');
const volNameDisplay = document.getElementById('volunteerNameDisplay');
const volClearBtn = document.getElementById('clearSelectedVolunteer');
const volSubmitBtn = document.getElementById('submitAddVolunteer');
let volDebounceTimer;
volSearchInput.addEventListener('input', function() {
clearTimeout(volDebounceTimer);
const query = this.value.trim();
if (query.length < 2) {
volResultsContainer.classList.add('d-none');
return;
}
volDebounceTimer = setTimeout(() => {
fetch(`/volunteers/search/json/?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
volResultsContainer.innerHTML = '';
if (data.results && data.results.length > 0) {
data.results.forEach(vol => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'list-group-item list-group-item-action py-2';
btn.innerHTML = `<div class="fw-bold text-dark">${vol.text}</div><div class="small text-muted">${vol.phone || "No phone"}</div>`;
btn.addEventListener('click', () => {
selectVolunteer(vol.id, vol.text, vol.phone);
});
volResultsContainer.appendChild(btn);
});
volResultsContainer.classList.remove('d-none');
} else {
const div = document.createElement('div');
div.className = 'list-group-item text-muted small py-2';
div.textContent = 'No results found';
volResultsContainer.appendChild(div);
volResultsContainer.classList.remove('d-none');
}
});
}, 300);
});
function selectVolunteer(id, text, phone) {
volHiddenId.value = id;
volNameDisplay.innerHTML = `<div>${text}</div><div class="small fw-normal text-muted">${phone || ""}</div>`;
volSelectedDisplay.classList.remove('d-none');
volSearchInput.parentElement.classList.add('d-none');
volResultsContainer.classList.add('d-none');
volSubmitBtn.disabled = false;
}
volClearBtn.addEventListener('click', () => {
volHiddenId.value = '';
volNameDisplay.textContent = '';
volSelectedDisplay.classList.add('d-none');
volSearchInput.parentElement.classList.remove('d-none');
volSearchInput.value = '';
volSubmitBtn.disabled = true;
});
// Close results when clicking outside // Close results when clicking outside
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
if (!resultsContainer.contains(e.target) && e.target !== searchInput) { if (!resultsContainer.contains(e.target) && e.target !== searchInput) {
resultsContainer.classList.add('d-none'); resultsContainer.classList.add('d-none');
} }
if (!volResultsContainer.contains(e.target) && e.target !== volSearchInput) {
volResultsContainer.classList.add('d-none');
}
}); });
}); });
</script> </script>

View File

@ -0,0 +1,135 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container py-5">
<div class="mb-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'event_list' %}">Events</a></li>
{% if not is_create %}
<li class="breadcrumb-item"><a href="{% url 'event_detail' event.id %}">{{ event.name|default:event.event_type }}</a></li>
<li class="breadcrumb-item active" aria-current="page">Edit</li>
{% else %}
<li class="breadcrumb-item active" aria-current="page">New Event</li>
{% endif %}
</ol>
</nav>
<h1 class="h2 mb-0">{% if is_create %}Create New Event{% else %}Edit Event{% endif %}</h1>
</div>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form method="POST">
{% csrf_token %}
<div class="row g-3">
<div class="col-md-12">
<label for="{{ form.name.id_for_label }}" class="form-label fw-bold">Event Name</label>
{{ form.name }}
{% if form.name.errors %}
<div class="text-danger small">{{ form.name.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.event_type.id_for_label }}" class="form-label fw-bold">Event Type</label>
{{ form.event_type }}
{% if form.event_type.errors %}
<div class="text-danger small">{{ form.event_type.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.date.id_for_label }}" class="form-label fw-bold">Date</label>
{{ form.date }}
{% if form.date.errors %}
<div class="text-danger small">{{ form.date.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.start_time.id_for_label }}" class="form-label fw-bold">Start Time</label>
{{ form.start_time }}
{% if form.start_time.errors %}
<div class="text-danger small">{{ form.start_time.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.end_time.id_for_label }}" class="form-label fw-bold">End Time</label>
{{ form.end_time }}
{% if form.end_time.errors %}
<div class="text-danger small">{{ form.end_time.errors }}</div>
{% endif %}
</div>
<div class="col-md-12">
<label for="{{ form.description.id_for_label }}" class="form-label fw-bold">Description</label>
{{ form.description }}
{% if form.description.errors %}
<div class="text-danger small">{{ form.description.errors }}</div>
{% endif %}
</div>
<hr class="my-4">
<h5 class="fw-bold mb-3">Location Information</h5>
<div class="col-md-12">
<label for="{{ form.location_name.id_for_label }}" class="form-label fw-bold">Location Name</label>
{{ form.location_name }}
{% if form.location_name.errors %}
<div class="text-danger small">{{ form.location_name.errors }}</div>
{% endif %}
</div>
<div class="col-md-12">
<label for="{{ form.address.id_for_label }}" class="form-label fw-bold">Street Address</label>
{{ form.address }}
{% if form.address.errors %}
<div class="text-danger small">{{ form.address.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.city.id_for_label }}" class="form-label fw-bold">City</label>
{{ form.city }}
{% if form.city.errors %}
<div class="text-danger small">{{ form.city.errors }}</div>
{% endif %}
</div>
<div class="col-md-3">
<label for="{{ form.state.id_for_label }}" class="form-label fw-bold">State</label>
{{ form.state }}
{% if form.state.errors %}
<div class="text-danger small">{{ form.state.errors }}</div>
{% endif %}
</div>
<div class="col-md-3">
<label for="{{ form.zip_code.id_for_label }}" class="form-label fw-bold">Zip Code</label>
{{ form.zip_code }}
{% if form.zip_code.errors %}
<div class="text-danger small">{{ form.zip_code.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.latitude.id_for_label }}" class="form-label fw-bold">Latitude</label>
{{ form.latitude }}
{% if form.latitude.errors %}
<div class="text-danger small">{{ form.latitude.errors }}</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.longitude.id_for_label }}" class="form-label fw-bold">Longitude</label>
{{ form.longitude }}
{% if form.longitude.errors %}
<div class="text-danger small">{{ form.longitude.errors }}</div>
{% endif %}
</div>
<div class="col-12 mt-4 d-flex gap-3">
<button type="submit" class="btn btn-primary px-5 rounded-pill shadow-sm">{% if is_create %}Create Event{% else %}Save Changes{% endif %}</button>
<a href="{% if is_create %}{% url 'event_list' %}{% else %}{% url 'event_detail' event.id %}{% endif %}" class="btn btn-outline-secondary px-5 rounded-pill">Cancel</a>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -5,7 +5,7 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Campaign Events</h1> <h1 class="h2">Campaign Events</h1>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="/admin/core/event/add/" class="btn btn-primary btn-sm">+ Create New Event</a> <a href="{% url 'event_create' %}" class="btn btn-primary btn-sm">+ Create New Event</a>
</div> </div>
</div> </div>
@ -18,6 +18,7 @@
<th>Type</th> <th>Type</th>
<th>Date</th> <th>Date</th>
<th>Time</th> <th>Time</th>
<th>Location</th>
<th class="pe-4 text-end">Actions</th> <th class="pe-4 text-end">Actions</th>
</tr> </tr>
</thead> </thead>
@ -39,13 +40,20 @@
- -
{% endif %} {% endif %}
</td> </td>
<td>
{% if event.city or event.state %}
{{ event.city }}{% if event.city and event.state %}, {% endif %}{{ event.state }}
{% else %}
-
{% endif %}
</td>
<td class="pe-4 text-end"> <td class="pe-4 text-end">
<a href="{% url 'event_detail' event.id %}" class="btn btn-sm btn-outline-primary">View Details</a> <a href="{% url 'event_detail' event.id %}" class="btn btn-sm btn-outline-primary">View Details</a>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5" class="text-center py-5 text-muted"> <td colspan="6" class="text-center py-5 text-muted">
<p class="mb-0">No events found for this campaign.</p> <p class="mb-0">No events found for this campaign.</p>
</td> </td>
</tr> </tr>
@ -55,4 +63,4 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -89,6 +89,10 @@
</div> </div>
{{ form.interests }} {{ form.interests }}
</div> </div>
<div class="col-12">
<label for="{{ form.notes.id_for_label }}" class="form-label small text-muted text-uppercase fw-bold">Notes</label>
{{ form.notes }}
</div>
</div> </div>
<div class="mt-4 pt-3 border-top text-end"> <div class="mt-4 pt-3 border-top text-end">
<a href="{% url 'volunteer_list' %}" class="btn btn-outline-secondary px-4 me-2">Cancel</a> <a href="{% url 'volunteer_list' %}" class="btn btn-outline-secondary px-4 me-2">Cancel</a>

View File

@ -12,22 +12,43 @@
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4"> <div class="card-body p-4">
<form action="." method="GET" class="row g-3"> <form action="." method="GET" class="row g-3">
<div class="col-md-10"> <div class="col-md-6">
<input type="text" name="q" class="form-control" placeholder="Search by name or email..." value="{{ query|default:'' }}"> <input type="text" name="q" class="form-control" placeholder="Search by name or email..." value="{{ query|default:'' }}">
</div> </div>
<div class="col-md-4">
<select name="interest" class="form-select">
<option value="">All Interests</option>
{% for interest in interests %}
<option value="{{ interest.id }}" {% if selected_interest == interest.id|stringformat:"s" %}selected{% endif %}>
{{ interest.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-2"> <div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">Search</button> <button type="submit" class="btn btn-primary w-100">Filter</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white py-3 border-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-bold">Volunteers ({{ volunteers.paginator.count }})</h5>
<div id="bulk-actions" class="d-none">
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#smsModal">
<i class="bi bi-chat-left-text me-1"></i> Send Bulk SMS
</button>
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0 align-middle"> <table class="table table-hover mb-0 align-middle">
<thead class="bg-light"> <thead class="bg-light">
<tr> <tr>
<th class="ps-4">Name</th> <th class="ps-4" style="width: 40px;">
<input type="checkbox" class="form-check-input" id="select-all">
</th>
<th>Name</th>
<th>Email</th> <th>Email</th>
<th>Phone</th> <th>Phone</th>
<th>Interests</th> <th>Interests</th>
@ -38,6 +59,9 @@
{% for volunteer in volunteers %} {% for volunteer in volunteers %}
<tr> <tr>
<td class="ps-4"> <td class="ps-4">
<input type="checkbox" name="selected_volunteers" value="{{ volunteer.id }}" class="form-check-input volunteer-checkbox">
</td>
<td>
<a href="{% url 'volunteer_detail' volunteer.id %}" class="fw-semibold text-primary text-decoration-none d-block"> <a href="{% url 'volunteer_detail' volunteer.id %}" class="fw-semibold text-primary text-decoration-none d-block">
{{ volunteer.first_name }} {{ volunteer.last_name }} {{ volunteer.first_name }} {{ volunteer.last_name }}
</a> </a>
@ -57,7 +81,7 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5" class="text-center py-5 text-muted"> <td colspan="6" class="text-center py-5 text-muted">
<p class="mb-0">No volunteers found matching your search.</p> <p class="mb-0">No volunteers found matching your search.</p>
<a href="{% url 'volunteer_add' %}" class="btn btn-link">Add the first volunteer</a> <a href="{% url 'volunteer_add' %}" class="btn btn-link">Add the first volunteer</a>
</td> </td>
@ -73,12 +97,12 @@
<ul class="pagination justify-content-center mb-0"> <ul class="pagination justify-content-center mb-0">
{% if volunteers.has_previous %} {% if volunteers.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page=1{% if query %}&q={{ query }}{% endif %}" aria-label="First"> <a class="page-link" href="?page=1{% if query %}&q={{ query }}{% endif %}{% if selected_interest %}&interest={{ selected_interest }}{% endif %}" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span> <span aria-hidden="true">&laquo;&laquo;</span>
</a> </a>
</li> </li>
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page={{ volunteers.previous_page_number }}{% if query %}&q={{ query }}{% endif %}" aria-label="Previous"> <a class="page-link" href="?page={{ volunteers.previous_page_number }}{% if query %}&q={{ query }}{% endif %}{% if selected_interest %}&interest={{ selected_interest }}{% endif %}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span> <span aria-hidden="true">&laquo;</span>
</a> </a>
</li> </li>
@ -88,12 +112,12 @@
{% if volunteers.has_next %} {% if volunteers.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page={{ volunteers.next_page_number }}{% if query %}&q={{ query }}{% endif %}" aria-label="Next"> <a class="page-link" href="?page={{ volunteers.next_page_number }}{% if query %}&q={{ query }}{% endif %}{% if selected_interest %}&interest={{ selected_interest }}{% endif %}" aria-label="Next">
<span aria-hidden="true">&raquo;</span> <span aria-hidden="true">&raquo;</span>
</a> </a>
</li> </li>
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page={{ volunteers.paginator.num_pages }}{% if query %}&q={{ query }}{% endif %}" aria-label="Last"> <a class="page-link" href="?page={{ volunteers.paginator.num_pages }}{% if query %}&q={{ query }}{% endif %}{% if selected_interest %}&interest={{ selected_interest }}{% endif %}" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span> <span aria-hidden="true">&raquo;&raquo;</span>
</a> </a>
</li> </li>
@ -104,4 +128,84 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- SMS Modal -->
<div class="modal fade" id="smsModal" tabindex="-1" aria-labelledby="smsModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold" id="smsModalLabel">Send Bulk SMS</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'volunteer_bulk_send_sms' %}" method="POST" id="sms-form">
{% csrf_token %}
<div class="modal-body py-4">
<p class="small text-muted mb-3">Messages will be sent to selected volunteers with a phone number on file.</p>
<div id="selected-volunteers-container">
<!-- Volunteer IDs will be injected here -->
</div>
<div class="mb-0">
<label for="message_body" class="form-label small fw-bold text-muted">Message Body</label>
<textarea class="form-control" id="message_body" name="message_body" rows="4" required placeholder="Type your message here..."></textarea>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-4">Send Bulk SMS</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const selectAll = document.getElementById('select-all');
const checkboxes = document.querySelectorAll('.volunteer-checkbox');
const bulkActions = document.getElementById('bulk-actions');
function updateBulkActionsVisibility() {
const checkedCount = document.querySelectorAll('.volunteer-checkbox:checked').length;
if (checkedCount > 0) {
bulkActions.classList.remove('d-none');
} else {
bulkActions.classList.add('d-none');
}
}
if (selectAll) {
selectAll.addEventListener('change', function() {
checkboxes.forEach(cb => {
cb.checked = selectAll.checked;
});
updateBulkActionsVisibility();
});
}
checkboxes.forEach(cb => {
cb.addEventListener('change', function() {
const allChecked = Array.from(checkboxes).every(c => c.checked);
if (selectAll) selectAll.checked = allChecked;
updateBulkActionsVisibility();
});
});
const smsModal = document.getElementById('smsModal');
if (smsModal) {
smsModal.addEventListener('show.bs.modal', function () {
const container = document.getElementById('selected-volunteers-container');
container.innerHTML = '';
checkboxes.forEach(cb => {
if (cb.checked) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_volunteers';
input.value = cb.value;
container.appendChild(input);
}
});
});
}
});
</script>
{% endblock %} {% endblock %}

View File

@ -230,6 +230,7 @@
<tr> <tr>
<th class="ps-4">Date</th> <th class="ps-4">Date</th>
<th>Type</th> <th>Type</th>
<th>Volunteer</th>
<th>Description</th> <th>Description</th>
<th>Notes</th> <th>Notes</th>
<th class="pe-4 text-end">Actions</th> <th class="pe-4 text-end">Actions</th>
@ -240,6 +241,7 @@
<tr> <tr>
<td class="ps-4 text-nowrap">{{ interaction.date|date:"M d, Y H:i" }}</td> <td class="ps-4 text-nowrap">{{ interaction.date|date:"M d, Y H:i" }}</td>
<td><span class="badge bg-light text-dark border">{{ interaction.type.name }}</span></td> <td><span class="badge bg-light text-dark border">{{ interaction.type.name }}</span></td>
<td>{% if interaction.volunteer %}{{ interaction.volunteer }}{% else %}<span class="text-muted small">-</span>{% endif %}</td>
<td>{{ interaction.description }}</td> <td>{{ interaction.description }}</td>
<td class="small text-muted">{{ interaction.notes|truncatechars:30 }}</td> <td class="small text-muted">{{ interaction.notes|truncatechars:30 }}</td>
<td class="pe-4 text-end"> <td class="pe-4 text-end">
@ -572,6 +574,10 @@
<label class="form-label fw-medium">{{ interaction_form.type.label }}</label> <label class="form-label fw-medium">{{ interaction_form.type.label }}</label>
{{ interaction_form.type }} {{ interaction_form.type }}
</div> </div>
<div class="mb-3">
<label class="form-label fw-medium">{{ interaction_form.volunteer.label }}</label>
{{ interaction_form.volunteer }}
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-medium">{{ interaction_form.date.label }}</label> <label class="form-label fw-medium">{{ interaction_form.date.label }}</label>
{{ interaction_form.date }} {{ interaction_form.date }}
@ -614,6 +620,15 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="mb-3">
<label class="form-label fw-medium">Volunteer</label>
<select name="volunteer" class="form-select">
<option value="">---------</option>
{% for vol in interaction_form.fields.volunteer.queryset %}
<option value="{{ vol.id }}" {% if vol.id == interaction.volunteer.id %}selected{% endif %}>{{ vol }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-medium">Date</label> <label class="form-label fw-medium">Date</label>
<input type="datetime-local" name="date" class="form-control" value="{{ interaction.date|date:'Y-m-d\TH:i' }}"> <input type="datetime-local" name="date" class="form-control" value="{{ interaction.date|date:'Y-m-d\TH:i' }}">

View File

@ -32,6 +32,8 @@ urlpatterns = [
# Event Detail and Participant Management # Event Detail and Participant Management
path('events/', views.event_list, name='event_list'), path('events/', views.event_list, name='event_list'),
path('events/<int:event_id>/', views.event_detail, name='event_detail'), path('events/<int:event_id>/', views.event_detail, name='event_detail'),
path('events/add/', views.event_create, name='event_create'),
path('events/<int:event_id>/edit/', views.event_edit, name='event_edit'),
path('events/<int:event_id>/participant/add/', views.event_add_participant, name='event_add_participant'), path('events/<int:event_id>/participant/add/', views.event_add_participant, name='event_add_participant'),
path('events/participant/<int:participation_id>/edit/', views.event_edit_participant, name='event_edit_participant'), path('events/participant/<int:participation_id>/edit/', views.event_edit_participant, name='event_edit_participant'),
path('events/participant/<int:participation_id>/delete/', views.event_delete_participant, name='event_delete_participant'), path('events/participant/<int:participation_id>/delete/', views.event_delete_participant, name='event_delete_participant'),
@ -46,4 +48,8 @@ urlpatterns = [
path('volunteers/<int:volunteer_id>/delete/', views.volunteer_delete, name='volunteer_delete'), path('volunteers/<int:volunteer_id>/delete/', views.volunteer_delete, name='volunteer_delete'),
path('volunteers/<int:volunteer_id>/assign-event/', views.volunteer_assign_event, name='volunteer_assign_event'), path('volunteers/<int:volunteer_id>/assign-event/', views.volunteer_assign_event, name='volunteer_assign_event'),
path('volunteers/assignment/<int:assignment_id>/remove/', views.volunteer_remove_event, name='volunteer_remove_event'), path('volunteers/assignment/<int:assignment_id>/remove/', views.volunteer_remove_event, name='volunteer_remove_event'),
] path('volunteers/search/json/', views.volunteer_search_json, name='volunteer_search_json'),
path('volunteers/bulk-sms/', views.volunteer_bulk_send_sms, name='volunteer_bulk_send_sms'),
path('events/<int:event_id>/volunteer/add/', views.event_add_volunteer, name='event_add_volunteer'),
path('events/volunteer/<int:assignment_id>/delete/', views.event_remove_volunteer, name='event_remove_volunteer'),
]

View File

@ -11,7 +11,7 @@ from django.db.models import Q, Sum
from django.contrib import messages from django.contrib import messages
from django.core.paginator import Paginator from django.core.paginator import Paginator
from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm
import logging import logging
from django.utils import timezone from django.utils import timezone
@ -62,6 +62,7 @@ def index(request):
recent_interactions = Interaction.objects.filter(voter__tenant=selected_tenant).order_by('-date')[:5] recent_interactions = Interaction.objects.filter(voter__tenant=selected_tenant).order_by('-date')[:5]
upcoming_events = Event.objects.filter(tenant=selected_tenant, date__gte=timezone.now().date()).order_by('date')[:5] upcoming_events = Event.objects.filter(tenant=selected_tenant, date__gte=timezone.now().date()).order_by('date')[:5]
context = { context = {
'tenants': tenants, 'tenants': tenants,
'selected_tenant': selected_tenant, 'selected_tenant': selected_tenant,
@ -132,6 +133,7 @@ def voter_list(request):
page_number = request.GET.get('page') page_number = request.GET.get('page')
voters_page = paginator.get_page(page_number) voters_page = paginator.get_page(page_number)
context = { context = {
"voters": voters_page, "voters": voters_page,
"query": query, "query": query,
@ -152,6 +154,7 @@ def voter_detail(request, voter_id):
tenant = get_object_or_404(Tenant, id=selected_tenant_id) tenant = get_object_or_404(Tenant, id=selected_tenant_id)
voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
context = { context = {
'voter': voter, 'voter': voter,
'selected_tenant': tenant, 'selected_tenant': tenant,
@ -455,6 +458,7 @@ def voter_advanced_search(request):
page_number = request.GET.get('page') page_number = request.GET.get('page')
voters_page = paginator.get_page(page_number) voters_page = paginator.get_page(page_number)
context = { context = {
'form': form, 'form': form,
'voters': voters_page, 'voters': voters_page,
@ -672,6 +676,7 @@ def event_list(request):
tenant = get_object_or_404(Tenant, id=selected_tenant_id) tenant = get_object_or_404(Tenant, id=selected_tenant_id)
events = Event.objects.filter(tenant=tenant).order_by('-date') events = Event.objects.filter(tenant=tenant).order_by('-date')
context = { context = {
'tenant': tenant, 'tenant': tenant,
'events': events, 'events': events,
@ -689,16 +694,25 @@ def event_detail(request, event_id):
event = get_object_or_404(Event, id=event_id, tenant=tenant) event = get_object_or_404(Event, id=event_id, tenant=tenant)
participations = event.participations.all().select_related('voter', 'participation_status').order_by('voter__last_name', 'voter__first_name') participations = event.participations.all().select_related('voter', 'participation_status').order_by('voter__last_name', 'voter__first_name')
# Get assigned volunteers
volunteers = event.volunteer_assignments.all().select_related('volunteer').order_by('volunteer__last_name', 'volunteer__first_name')
# Form for adding a new participant # Form for adding a new participant
add_form = EventParticipantAddForm(tenant=tenant) add_form = EventParticipantAddForm(tenant=tenant)
# Form for adding a new volunteer
add_volunteer_form = VolunteerEventAddForm(tenant=tenant)
participation_statuses = ParticipationStatus.objects.filter(tenant=tenant, is_active=True) participation_statuses = ParticipationStatus.objects.filter(tenant=tenant, is_active=True)
context = { context = {
'tenant': tenant, 'tenant': tenant,
'selected_tenant': tenant, 'selected_tenant': tenant,
'event': event, 'event': event,
'participations': participations, 'participations': participations,
'volunteers': volunteers,
'add_form': add_form, 'add_form': add_form,
'add_volunteer_form': add_volunteer_form,
'participation_statuses': participation_statuses, 'participation_statuses': participation_statuses,
} }
return render(request, 'core/event_detail.html', context) return render(request, 'core/event_detail.html', context)
@ -801,15 +815,24 @@ def volunteer_list(request):
Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(email__icontains=query) Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(email__icontains=query)
) )
# Interest filter
interest_id = request.GET.get("interest")
if interest_id:
volunteers = volunteers.filter(interests__id=interest_id)
paginator = Paginator(volunteers, 50) paginator = Paginator(volunteers, 50)
page_number = request.GET.get('page') page_number = request.GET.get('page')
volunteers_page = paginator.get_page(page_number) volunteers_page = paginator.get_page(page_number)
interests = Interest.objects.filter(tenant=tenant).order_by('name')
context = { context = {
'tenant': tenant, 'tenant': tenant,
'selected_tenant': tenant, 'selected_tenant': tenant,
'volunteers': volunteers_page, 'volunteers': volunteers_page,
'query': query, 'query': query,
'interests': interests,
'selected_interest': interest_id,
} }
return render(request, 'core/volunteer_list.html', context) return render(request, 'core/volunteer_list.html', context)
@ -832,6 +855,7 @@ def volunteer_add(request):
else: else:
form = VolunteerForm(tenant=tenant) form = VolunteerForm(tenant=tenant)
context = { context = {
'form': form, 'form': form,
'tenant': tenant, 'tenant': tenant,
@ -859,6 +883,7 @@ def volunteer_detail(request, volunteer_id):
assignments = volunteer.event_assignments.all().select_related('event') assignments = volunteer.event_assignments.all().select_related('event')
assign_form = VolunteerEventForm(tenant=tenant) assign_form = VolunteerEventForm(tenant=tenant)
context = { context = {
'volunteer': volunteer, 'volunteer': volunteer,
'form': form, 'form': form,
@ -943,3 +968,198 @@ def interest_delete(request, interest_id):
interest.delete() interest.delete()
return JsonResponse({'success': True}) return JsonResponse({'success': True})
return JsonResponse({'success': False, 'error': 'Invalid request.'}) return JsonResponse({'success': False, 'error': 'Invalid request.'})
def event_create(request):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
if request.method == "POST":
form = EventForm(request.POST, tenant=tenant)
if form.is_valid():
event = form.save(commit=False)
event.tenant = tenant
event.save()
messages.success(request, "Event created successfully.")
return redirect("event_detail", event_id=event.id)
else:
form = EventForm(tenant=tenant)
context = {
"form": form,
"tenant": tenant,
"selected_tenant": tenant,
"is_create": True,
}
return render(request, "core/event_edit.html", context)
def event_edit(request, event_id):
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
event = get_object_or_404(Event, id=event_id, tenant=tenant)
if request.method == 'POST':
form = EventForm(request.POST, instance=event, tenant=tenant)
if form.is_valid():
form.save()
messages.success(request, "Event updated successfully.")
return redirect('event_detail', event_id=event.id)
else:
form = EventForm(instance=event, tenant=tenant)
context = {
'form': form,
'event': event,
'tenant': tenant,
'selected_tenant': tenant,
}
return render(request, 'core/event_edit.html', context)
def volunteer_search_json(request):
"""
JSON endpoint for volunteer search, used by autocomplete/search UI.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
return JsonResponse({"results": []})
query = request.GET.get("q", "").strip()
if len(query) < 2:
return JsonResponse({"results": []})
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
volunteers = Volunteer.objects.filter(tenant=tenant)
search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(email__icontains=query)
results = volunteers.filter(search_filter).order_by("last_name", "first_name")[:20]
data = []
for v in results:
data.append({
"id": v.id,
"text": f"{v.first_name} {v.last_name} ({v.email})",
"phone": v.phone
})
return JsonResponse({"results": data})
def event_add_volunteer(request, event_id):
tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=tenant_id)
event = get_object_or_404(Event, id=event_id, tenant=tenant)
if request.method == 'POST':
form = VolunteerEventAddForm(request.POST, tenant=tenant)
if form.is_valid():
assignment = form.save(commit=False)
assignment.event = event
if not VolunteerEvent.objects.filter(event=event, volunteer=assignment.volunteer).exists():
assignment.save()
messages.success(request, f"{assignment.volunteer} added as volunteer.")
else:
messages.warning(request, "Volunteer is already assigned to this event.")
else:
messages.error(request, "Error adding volunteer.")
return redirect('event_detail', event_id=event.id)
def event_remove_volunteer(request, assignment_id):
tenant_id = request.session.get("tenant_id")
tenant = get_object_or_404(Tenant, id=tenant_id)
assignment = get_object_or_404(VolunteerEvent, id=assignment_id, event__tenant=tenant)
event_id = assignment.event.id
volunteer_name = str(assignment.volunteer)
assignment.delete()
messages.success(request, f"{volunteer_name} removed from event volunteers.")
return redirect('event_detail', event_id=event_id)
def volunteer_bulk_send_sms(request):
"""
Sends bulk SMS to selected volunteers using Twilio API.
"""
if request.method != 'POST':
return redirect('volunteer_list')
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
settings = getattr(tenant, 'settings', None)
if not settings:
messages.error(request, "Campaign settings not found.")
return redirect('volunteer_list')
account_sid = settings.twilio_account_sid
auth_token = settings.twilio_auth_token
from_number = settings.twilio_from_number
if not account_sid or not auth_token or not from_number:
messages.error(request, "Twilio configuration is incomplete in Campaign Settings.")
return redirect('volunteer_list')
volunteer_ids = request.POST.getlist('selected_volunteers')
message_body = request.POST.get('message_body')
if not message_body:
messages.error(request, "Message body cannot be empty.")
return redirect('volunteer_list')
volunteers = Volunteer.objects.filter(tenant=tenant, id__in=volunteer_ids).exclude(phone='')
if not volunteers.exists():
messages.warning(request, "No volunteers with a valid phone number were selected.")
return redirect('volunteer_list')
success_count = 0
fail_count = 0
auth_str = f"{account_sid}:{auth_token}"
auth_header = base64.b64encode(auth_str.encode()).decode()
url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
for volunteer in volunteers:
# Format phone to E.164 (assume US +1)
digits = re.sub(r'\D', '', str(volunteer.phone))
if len(digits) == 10:
to_number = f"+1{digits}"
elif len(digits) == 11 and digits.startswith('1'):
to_number = f"+{digits}"
else:
# Skip invalid phone numbers
fail_count += 1
continue
data_dict = {
'To': to_number,
'From': from_number,
'Body': message_body
}
data = urllib.parse.urlencode(data_dict).encode()
req = urllib.request.Request(url, data=data, method='POST')
req.add_header("Authorization", f"Basic {auth_header}")
try:
with urllib.request.urlopen(req, timeout=10) as response:
if response.status in [200, 201]:
success_count += 1
else:
fail_count += 1
except Exception as e:
logger.error(f"Error sending SMS to volunteer {volunteer.phone}: {e}")
fail_count += 1
messages.success(request, f"Bulk SMS process completed: {success_count} successful, {fail_count} failed/skipped.")
return redirect('volunteer_list')