Autosave: 20260130-044056
This commit is contained in:
parent
6b464385a5
commit
181163257f
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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():
|
||||||
|
|||||||
@ -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'})
|
||||||
|
|||||||
18
core/migrations/0028_volunteer_notes.py
Normal file
18
core/migrations/0028_volunteer_notes.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0030_event_location_name.py
Normal file
18
core/migrations/0030_event_location_name.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0028_volunteer_notes.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0028_volunteer_notes.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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}'
|
||||||
@ -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>
|
||||||
|
|||||||
135
core/templates/core/event_edit.html
Normal file
135
core/templates/core/event_edit.html
Normal 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 %}
|
||||||
@ -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 %}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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">««</span>
|
<span aria-hidden="true">««</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">«</span>
|
<span aria-hidden="true">«</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">»</span>
|
<span aria-hidden="true">»</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">»»</span>
|
<span aria-hidden="true">»»</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 %}
|
||||||
|
|||||||
@ -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' }}">
|
||||||
|
|||||||
@ -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'),
|
||||||
|
]
|
||||||
222
core/views.py
222
core/views.py
@ -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')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user