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'),
|
||||
('county', 'County'),
|
||||
('phone', 'Phone'),
|
||||
('notes', 'Notes'),
|
||||
('phone_type', 'Phone Type'),
|
||||
('email', 'Email'),
|
||||
('district', 'District'),
|
||||
@ -50,6 +51,8 @@ VOTER_MAPPABLE_FIELDS = [
|
||||
('window_sticker', 'Window Sticker'),
|
||||
('latitude', 'Latitude'),
|
||||
('longitude', 'Longitude'),
|
||||
('secondary_phone', 'Secondary Phone'),
|
||||
('secondary_phone_type', 'Secondary Phone Type'),
|
||||
]
|
||||
|
||||
EVENT_MAPPABLE_FIELDS = [
|
||||
@ -59,6 +62,13 @@ EVENT_MAPPABLE_FIELDS = [
|
||||
('end_time', 'End Time'),
|
||||
('event_type', 'Event Type (Name)'),
|
||||
('description', 'Description'),
|
||||
('location_name', 'Location Name'),
|
||||
('address', 'Address'),
|
||||
('city', 'City'),
|
||||
('state', 'State'),
|
||||
('zip_code', 'Zip Code'),
|
||||
('latitude', 'Latitude'),
|
||||
('longitude', 'Longitude'),
|
||||
]
|
||||
|
||||
EVENT_PARTICIPATION_MAPPABLE_FIELDS = [
|
||||
@ -76,6 +86,7 @@ DONATION_MAPPABLE_FIELDS = [
|
||||
|
||||
INTERACTION_MAPPABLE_FIELDS = [
|
||||
('voter_id', 'Voter ID'),
|
||||
('volunteer_email', 'Volunteer Email'),
|
||||
('date', 'Date'),
|
||||
('type', 'Interaction Type (Name)'),
|
||||
('description', 'Description'),
|
||||
@ -88,6 +99,7 @@ VOLUNTEER_MAPPABLE_FIELDS = [
|
||||
('last_name', 'Last Name'),
|
||||
('email', 'Email'),
|
||||
('phone', 'Phone'),
|
||||
('notes', 'Notes'),
|
||||
]
|
||||
|
||||
VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [
|
||||
@ -318,7 +330,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
valid_fields = {f.name for f in Voter._meta.get_fields()}
|
||||
mapped_fields = {f for f in mapping.keys() if f in valid_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")
|
||||
|
||||
def chunk_reader(reader, size):
|
||||
@ -412,7 +424,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
if val_lower in window_sticker_choices: val = val_lower
|
||||
elif val_lower in window_sticker_reverse: val = window_sticker_reverse[val_lower]
|
||||
else: val = "none"
|
||||
elif field_name == "phone_type":
|
||||
elif field_name in ["phone_type", "secondary_phone_type"]:
|
||||
val_lower = val.lower()
|
||||
if val_lower in phone_type_choices:
|
||||
val = val_lower
|
||||
@ -431,6 +443,11 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
if voter.phone != old_phone:
|
||||
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:
|
||||
try:
|
||||
new_lon = Decimal(str(voter.longitude)[:12])
|
||||
@ -527,9 +544,9 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
return render(request, "admin/import_csv.html", context)
|
||||
@admin.register(Event)
|
||||
class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
list_display = ('id', 'name', 'event_type', 'date', 'start_time', 'end_time', 'tenant')
|
||||
list_filter = ('tenant', 'date', 'event_type')
|
||||
search_fields = ('name', 'description')
|
||||
list_display = ('id', 'name', 'event_type', 'date', 'location_name', 'city', 'state', 'tenant')
|
||||
list_filter = ('tenant', 'date', 'event_type', 'city', 'state')
|
||||
search_fields = ('name', 'description', 'location_name', 'address', 'city', 'state', 'zip_code')
|
||||
change_list_template = "admin/event_change_list.html"
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
@ -585,7 +602,7 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
preview_data.append({
|
||||
'action': action,
|
||||
'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.update({
|
||||
@ -625,9 +642,16 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
date = row.get(mapping.get('date')) if mapping.get('date') else None
|
||||
event_type_name = row.get(mapping.get('event_type')) if mapping.get('event_type') else None
|
||||
description = row.get(mapping.get('description')) if mapping.get('description') else None
|
||||
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
|
||||
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
|
||||
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:
|
||||
row["Import Error"] = "Missing date or event type"
|
||||
@ -643,12 +667,26 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
defaults = {}
|
||||
if description and description.strip():
|
||||
defaults['description'] = description
|
||||
if location_name and location_name.strip():
|
||||
defaults['location_name'] = location_name
|
||||
if name and name.strip():
|
||||
defaults['name'] = name
|
||||
if start_time and start_time.strip():
|
||||
defaults['start_time'] = start_time
|
||||
if end_time and end_time.strip():
|
||||
defaults['end_time'] = end_time
|
||||
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['event_type'] = event_type
|
||||
@ -677,10 +715,6 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
|
||||
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:
|
||||
form = EventImportForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
@ -724,7 +758,7 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant', 'user')
|
||||
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')
|
||||
inlines = [VolunteerEventInline, InteractionInline]
|
||||
filter_horizontal = ('interests',)
|
||||
@ -845,10 +879,6 @@ class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
|
||||
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:
|
||||
form = VolunteerImportForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
@ -1048,10 +1078,6 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
|
||||
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:
|
||||
form = EventParticipationImportForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
@ -1241,10 +1267,6 @@ class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
|
||||
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:
|
||||
form = DonationImportForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
@ -1388,6 +1410,7 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
|
||||
date = row.get(mapping.get('date'))
|
||||
type_name = row.get(mapping.get('type'))
|
||||
volunteer_email = row.get(mapping.get('volunteer_email'))
|
||||
description = row.get(mapping.get('description'))
|
||||
notes = row.get(mapping.get('notes'))
|
||||
|
||||
@ -1397,6 +1420,12 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
errors += 1
|
||||
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
|
||||
if type_name and type_name.strip():
|
||||
interaction_type, _ = InteractionType.objects.get_or_create(
|
||||
@ -1405,6 +1434,8 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
)
|
||||
|
||||
defaults = {}
|
||||
if volunteer:
|
||||
defaults['volunteer'] = volunteer
|
||||
if interaction_type:
|
||||
defaults['type'] = interaction_type
|
||||
if description and description.strip():
|
||||
@ -1437,10 +1468,6 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
except Exception as e:
|
||||
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
|
||||
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:
|
||||
form = InteractionImportForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
|
||||
@ -85,7 +85,7 @@ class AdvancedVoterSearchForm(forms.Form):
|
||||
class InteractionForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Interaction
|
||||
fields = ['type', 'date', 'description', 'notes']
|
||||
fields = ['type', 'volunteer', 'date', 'description', 'notes']
|
||||
widgets = {
|
||||
'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'),
|
||||
'notes': forms.Textarea(attrs={'rows': 2}),
|
||||
@ -95,9 +95,11 @@ class InteractionForm(forms.ModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
if tenant:
|
||||
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():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
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:
|
||||
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 Meta:
|
||||
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 = {
|
||||
'date': forms.DateInput(attrs={'type': 'date'}),
|
||||
'start_time': forms.TimeInput(attrs={'type': 'time'}),
|
||||
@ -250,7 +252,8 @@ class VolunteerImportForm(forms.Form):
|
||||
class VolunteerForm(forms.ModelForm):
|
||||
class Meta:
|
||||
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):
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -276,3 +279,20 @@ class VolunteerEventForm(forms.ModelForm):
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
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)
|
||||
event_type = models.ForeignKey(EventType, on_delete=models.PROTECT, null=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:
|
||||
unique_together = ('tenant', 'name')
|
||||
@ -307,6 +314,7 @@ class Volunteer(models.Model):
|
||||
phone = models.CharField(max_length=20, blank=True)
|
||||
interests = models.ManyToManyField(Interest, blank=True, related_name='volunteers')
|
||||
assigned_events = models.ManyToManyField(Event, through='VolunteerEvent', related_name='assigned_volunteers')
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Auto-format phone number
|
||||
@ -379,4 +387,4 @@ class CampaignSettings(models.Model):
|
||||
verbose_name_plural = 'Campaign Settings'
|
||||
|
||||
def __str__(self):
|
||||
return f'Settings for {self.tenant.name}'
|
||||
return f'Settings for {self.tenant.name}'
|
||||
@ -13,7 +13,10 @@
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h2 mb-0">{{ event.name|default:event.event_type }}</h1>
|
||||
<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">
|
||||
+ Add Participant
|
||||
</button>
|
||||
@ -24,7 +27,7 @@
|
||||
<div class="row g-4">
|
||||
<!-- Event Details Column -->
|
||||
<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">
|
||||
<h5 class="card-title fw-bold mb-4">Event Details</h5>
|
||||
<div class="mb-3">
|
||||
@ -46,12 +49,75 @@
|
||||
{% endif %}
|
||||
</span>
|
||||
</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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- Participants Column -->
|
||||
@ -171,8 +237,55 @@
|
||||
</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>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Voter Search Logic
|
||||
const searchInput = document.getElementById('voterSearchInput');
|
||||
const resultsContainer = document.getElementById('voterSearchResults');
|
||||
const hiddenVoterId = document.getElementById('voter_id_hidden');
|
||||
@ -202,7 +315,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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">${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', () => {
|
||||
selectVoter(voter.id, voter.text, voter.address, voter.phone);
|
||||
});
|
||||
@ -237,12 +350,81 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
searchInput.value = '';
|
||||
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
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!resultsContainer.contains(e.target) && e.target !== searchInput) {
|
||||
resultsContainer.classList.add('d-none');
|
||||
}
|
||||
if (!volResultsContainer.contains(e.target) && e.target !== volSearchInput) {
|
||||
volResultsContainer.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
});
|
||||
</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">
|
||||
<h1 class="h2">Campaign Events</h1>
|
||||
<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>
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
<th>Type</th>
|
||||
<th>Date</th>
|
||||
<th>Time</th>
|
||||
<th>Location</th>
|
||||
<th class="pe-4 text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -39,13 +40,20 @@
|
||||
-
|
||||
{% endif %}
|
||||
</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">
|
||||
<a href="{% url 'event_detail' event.id %}" class="btn btn-sm btn-outline-primary">View Details</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
@ -55,4 +63,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@ -89,6 +89,10 @@
|
||||
</div>
|
||||
{{ form.interests }}
|
||||
</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 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>
|
||||
|
||||
@ -12,22 +12,43 @@
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<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:'' }}">
|
||||
</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">
|
||||
<button type="submit" class="btn btn-primary w-100">Search</button>
|
||||
<button type="submit" class="btn btn-primary w-100">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="bg-light">
|
||||
<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>Phone</th>
|
||||
<th>Interests</th>
|
||||
@ -38,6 +59,9 @@
|
||||
{% for volunteer in volunteers %}
|
||||
<tr>
|
||||
<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">
|
||||
{{ volunteer.first_name }} {{ volunteer.last_name }}
|
||||
</a>
|
||||
@ -57,7 +81,7 @@
|
||||
</tr>
|
||||
{% empty %}
|
||||
<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>
|
||||
<a href="{% url 'volunteer_add' %}" class="btn btn-link">Add the first volunteer</a>
|
||||
</td>
|
||||
@ -73,12 +97,12 @@
|
||||
<ul class="pagination justify-content-center mb-0">
|
||||
{% if volunteers.has_previous %}
|
||||
<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>
|
||||
</a>
|
||||
</li>
|
||||
<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>
|
||||
</a>
|
||||
</li>
|
||||
@ -88,12 +112,12 @@
|
||||
|
||||
{% if volunteers.has_next %}
|
||||
<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>
|
||||
</a>
|
||||
</li>
|
||||
<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>
|
||||
</a>
|
||||
</li>
|
||||
@ -104,4 +128,84 @@
|
||||
{% endif %}
|
||||
</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 %}
|
||||
|
||||
@ -230,6 +230,7 @@
|
||||
<tr>
|
||||
<th class="ps-4">Date</th>
|
||||
<th>Type</th>
|
||||
<th>Volunteer</th>
|
||||
<th>Description</th>
|
||||
<th>Notes</th>
|
||||
<th class="pe-4 text-end">Actions</th>
|
||||
@ -240,6 +241,7 @@
|
||||
<tr>
|
||||
<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>{% if interaction.volunteer %}{{ interaction.volunteer }}{% else %}<span class="text-muted small">-</span>{% endif %}</td>
|
||||
<td>{{ interaction.description }}</td>
|
||||
<td class="small text-muted">{{ interaction.notes|truncatechars:30 }}</td>
|
||||
<td class="pe-4 text-end">
|
||||
@ -572,6 +574,10 @@
|
||||
<label class="form-label fw-medium">{{ interaction_form.type.label }}</label>
|
||||
{{ interaction_form.type }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">{{ interaction_form.volunteer.label }}</label>
|
||||
{{ interaction_form.volunteer }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">{{ interaction_form.date.label }}</label>
|
||||
{{ interaction_form.date }}
|
||||
@ -614,6 +620,15 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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">
|
||||
<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' }}">
|
||||
|
||||
@ -32,6 +32,8 @@ urlpatterns = [
|
||||
# Event Detail and Participant Management
|
||||
path('events/', views.event_list, name='event_list'),
|
||||
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/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'),
|
||||
@ -46,4 +48,8 @@ urlpatterns = [
|
||||
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/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.core.paginator import Paginator
|
||||
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
|
||||
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]
|
||||
upcoming_events = Event.objects.filter(tenant=selected_tenant, date__gte=timezone.now().date()).order_by('date')[:5]
|
||||
|
||||
|
||||
context = {
|
||||
'tenants': tenants,
|
||||
'selected_tenant': selected_tenant,
|
||||
@ -132,6 +133,7 @@ def voter_list(request):
|
||||
page_number = request.GET.get('page')
|
||||
voters_page = paginator.get_page(page_number)
|
||||
|
||||
|
||||
context = {
|
||||
"voters": voters_page,
|
||||
"query": query,
|
||||
@ -152,6 +154,7 @@ def voter_detail(request, voter_id):
|
||||
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
|
||||
voter = get_object_or_404(Voter, id=voter_id, tenant=tenant)
|
||||
|
||||
|
||||
context = {
|
||||
'voter': voter,
|
||||
'selected_tenant': tenant,
|
||||
@ -455,6 +458,7 @@ def voter_advanced_search(request):
|
||||
page_number = request.GET.get('page')
|
||||
voters_page = paginator.get_page(page_number)
|
||||
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'voters': voters_page,
|
||||
@ -672,6 +676,7 @@ def event_list(request):
|
||||
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
|
||||
events = Event.objects.filter(tenant=tenant).order_by('-date')
|
||||
|
||||
|
||||
context = {
|
||||
'tenant': tenant,
|
||||
'events': events,
|
||||
@ -689,16 +694,25 @@ def event_detail(request, event_id):
|
||||
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')
|
||||
|
||||
# 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
|
||||
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)
|
||||
|
||||
|
||||
context = {
|
||||
'tenant': tenant,
|
||||
'selected_tenant': tenant,
|
||||
'event': event,
|
||||
'participations': participations,
|
||||
'volunteers': volunteers,
|
||||
'add_form': add_form,
|
||||
'add_volunteer_form': add_volunteer_form,
|
||||
'participation_statuses': participation_statuses,
|
||||
}
|
||||
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)
|
||||
)
|
||||
|
||||
# Interest filter
|
||||
interest_id = request.GET.get("interest")
|
||||
if interest_id:
|
||||
volunteers = volunteers.filter(interests__id=interest_id)
|
||||
|
||||
paginator = Paginator(volunteers, 50)
|
||||
page_number = request.GET.get('page')
|
||||
volunteers_page = paginator.get_page(page_number)
|
||||
|
||||
interests = Interest.objects.filter(tenant=tenant).order_by('name')
|
||||
|
||||
context = {
|
||||
'tenant': tenant,
|
||||
'selected_tenant': tenant,
|
||||
'volunteers': volunteers_page,
|
||||
'query': query,
|
||||
'interests': interests,
|
||||
'selected_interest': interest_id,
|
||||
}
|
||||
return render(request, 'core/volunteer_list.html', context)
|
||||
|
||||
@ -832,6 +855,7 @@ def volunteer_add(request):
|
||||
else:
|
||||
form = VolunteerForm(tenant=tenant)
|
||||
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'tenant': tenant,
|
||||
@ -859,6 +883,7 @@ def volunteer_detail(request, volunteer_id):
|
||||
assignments = volunteer.event_assignments.all().select_related('event')
|
||||
assign_form = VolunteerEventForm(tenant=tenant)
|
||||
|
||||
|
||||
context = {
|
||||
'volunteer': volunteer,
|
||||
'form': form,
|
||||
@ -943,3 +968,198 @@ def interest_delete(request, interest_id):
|
||||
interest.delete()
|
||||
return JsonResponse({'success': True})
|
||||
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