Autosave: 20260130-044056

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

View File

@ -39,6 +39,7 @@ VOTER_MAPPABLE_FIELDS = [
('zip_code', 'Zip Code'),
('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():

View File

@ -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'})

View File

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

View File

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

View File

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

View File

@ -289,6 +289,13 @@ class Event(models.Model):
end_time = models.TimeField(null=True, blank=True)
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}'

View File

@ -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>

View File

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

View File

@ -5,7 +5,7 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<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 %}

View File

@ -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>

View File

@ -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">&laquo;&laquo;</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">&laquo;</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">&raquo;</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">&raquo;&raquo;</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 %}

View File

@ -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' }}">

View File

@ -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'),
]

View File

@ -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')