Autosave: 20260129-214513
This commit is contained in:
parent
3ac4dc73fb
commit
6b464385a5
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -181,7 +181,7 @@ class ParticipationStatusAdmin(admin.ModelAdmin):
|
|||||||
class InterestAdmin(admin.ModelAdmin):
|
class InterestAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'tenant')
|
list_display = ('name', 'tenant')
|
||||||
list_filter = ('tenant',)
|
list_filter = ('tenant',)
|
||||||
fields = ('tenant', 'donation_goal', 'twilio_account_sid', 'twilio_auth_token', 'twilio_from_number')
|
fields = ('tenant', 'name')
|
||||||
search_fields = ('name',)
|
search_fields = ('name',)
|
||||||
|
|
||||||
class VotingRecordInline(admin.TabularInline):
|
class VotingRecordInline(admin.TabularInline):
|
||||||
@ -724,7 +724,7 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||||
list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant', 'user')
|
list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant', 'user')
|
||||||
list_filter = ('tenant',)
|
list_filter = ('tenant',)
|
||||||
fields = ('tenant', 'donation_goal', 'twilio_account_sid', 'twilio_auth_token', 'twilio_from_number')
|
fields = ('tenant', 'user', 'first_name', 'last_name', 'email', 'phone', 'interests')
|
||||||
search_fields = ('first_name', 'last_name', 'email', 'phone')
|
search_fields = ('first_name', 'last_name', 'email', 'phone')
|
||||||
inlines = [VolunteerEventInline, InteractionInline]
|
inlines = [VolunteerEventInline, InteractionInline]
|
||||||
filter_horizontal = ('interests',)
|
filter_horizontal = ('interests',)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType, Tenant, ParticipationStatus
|
from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType, Tenant, ParticipationStatus, Volunteer, VolunteerEvent
|
||||||
|
|
||||||
class VoterForm(forms.ModelForm):
|
class VoterForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -146,6 +146,25 @@ class EventParticipationForm(forms.ModelForm):
|
|||||||
self.fields['event'].widget.attrs.update({'class': 'form-select'})
|
self.fields['event'].widget.attrs.update({'class': 'form-select'})
|
||||||
self.fields['participation_status'].widget.attrs.update({'class': 'form-select'})
|
self.fields['participation_status'].widget.attrs.update({'class': 'form-select'})
|
||||||
|
|
||||||
|
class EventParticipantAddForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = EventParticipation
|
||||||
|
fields = ['voter', 'participation_status']
|
||||||
|
|
||||||
|
def __init__(self, *args, tenant=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if tenant:
|
||||||
|
voter_id = self.data.get('voter') or self.initial.get('voter')
|
||||||
|
if voter_id:
|
||||||
|
self.fields['voter'].queryset = Voter.objects.filter(tenant=tenant, id=voter_id)
|
||||||
|
else:
|
||||||
|
self.fields['voter'].queryset = Voter.objects.none()
|
||||||
|
self.fields['participation_status'].queryset = ParticipationStatus.objects.filter(tenant=tenant, is_active=True)
|
||||||
|
for field in self.fields.values():
|
||||||
|
field.widget.attrs.update({'class': 'form-control'})
|
||||||
|
self.fields['voter'].widget.attrs.update({'class': 'form-select'})
|
||||||
|
self.fields['participation_status'].widget.attrs.update({'class': 'form-select'})
|
||||||
|
|
||||||
class EventForm(forms.ModelForm):
|
class EventForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Event
|
model = Event
|
||||||
@ -227,3 +246,33 @@ class VolunteerImportForm(forms.Form):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
||||||
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||||
|
|
||||||
|
class VolunteerForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Volunteer
|
||||||
|
fields = ['first_name', 'last_name', 'email', 'phone', 'interests']
|
||||||
|
|
||||||
|
def __init__(self, *args, tenant=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if tenant:
|
||||||
|
from .models import Interest
|
||||||
|
self.fields['interests'].queryset = Interest.objects.filter(tenant=tenant)
|
||||||
|
for field in self.fields.values():
|
||||||
|
field.widget.attrs.update({'class': 'form-control'})
|
||||||
|
|
||||||
|
# self.fields['interests'].widget = forms.SelectMultiple()
|
||||||
|
# Re-apply class for checkbox
|
||||||
|
self.fields['interests'].widget.attrs.update({'class': 'form-select tom-select'})
|
||||||
|
|
||||||
|
class VolunteerEventForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = VolunteerEvent
|
||||||
|
fields = ['event', 'role']
|
||||||
|
|
||||||
|
def __init__(self, *args, tenant=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if tenant:
|
||||||
|
self.fields['event'].queryset = Event.objects.filter(tenant=tenant)
|
||||||
|
for field in self.fields.values():
|
||||||
|
field.widget.attrs.update({'class': 'form-control'})
|
||||||
|
self.fields['event'].widget.attrs.update({'class': 'form-select'})
|
||||||
|
|||||||
@ -40,6 +40,12 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/voters/">Voters</a>
|
<a class="nav-link" href="/voters/">Voters</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/events/">Events</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/volunteers/">Volunteers</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<a href="/admin/" class="btn btn-outline-primary btn-sm me-2">Admin Panel</a>
|
<a href="/admin/" class="btn btn-outline-primary btn-sm me-2">Admin Panel</a>
|
||||||
|
|||||||
249
core/templates/core/event_detail.html
Normal file
249
core/templates/core/event_detail.html
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
{% 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>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ event.name|default:event.event_type }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<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>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addParticipantModal">
|
||||||
|
+ Add Participant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-body">
|
||||||
|
<h5 class="card-title fw-bold mb-4">Event Details</h5>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="small text-muted text-uppercase fw-bold d-block">Type</label>
|
||||||
|
<span>{{ event.event_type }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="small text-muted text-uppercase fw-bold d-block">Date</label>
|
||||||
|
<span>{{ event.date|date:"F d, Y" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="small text-muted text-uppercase fw-bold d-block">Time</label>
|
||||||
|
<span>
|
||||||
|
{% if event.start_time %}
|
||||||
|
{{ event.start_time|time:"g:i A" }}
|
||||||
|
{% if event.end_time %} - {{ event.end_time|time:"g:i A" }}{% endif %}
|
||||||
|
{% else %}
|
||||||
|
Not specified
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Participants Column -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<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">Participants ({{ participations.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">Voter Name</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="pe-4 text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in participations %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-4">
|
||||||
|
<a href="{% url 'voter_detail' p.voter.id %}" class="fw-semibold text-primary text-decoration-none">
|
||||||
|
{{ p.voter.first_name }} {{ p.voter.last_name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {% if p.participation_status.name == 'Attended' %}bg-success{% elif p.participation_status.name == 'Canceled' %}bg-danger{% else %}bg-info{% endif %} bg-opacity-10 text-{% if p.participation_status.name == 'Attended' %}success{% elif p.participation_status.name == 'Canceled' %}danger{% else %}info{% endif %} border border-{% if p.participation_status.name == 'Attended' %}success{% elif p.participation_status.name == 'Canceled' %}danger{% else %}info{% endif %}">
|
||||||
|
{{ p.participation_status.name }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="pe-4 text-end">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
Actions
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
|
||||||
|
<li><h6 class="dropdown-header">Update Status</h6></li>
|
||||||
|
{% for status in participation_statuses %}
|
||||||
|
{% if status != p.participation_status %}
|
||||||
|
<li>
|
||||||
|
<form action="{% url 'event_edit_participant' p.id %}" method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="participation_status" value="{{ status.id }}">
|
||||||
|
<button type="submit" class="dropdown-item">{{ status.name }}</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<form action="{% url 'event_delete_participant' p.id %}" method="POST" onsubmit="return confirm('Remove this participant?')">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="dropdown-item text-danger">Remove from Event</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-center py-5 text-muted">
|
||||||
|
No participants yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Participant Modal -->
|
||||||
|
<div class="modal fade" id="addParticipantModal" tabindex="-1" aria-labelledby="addParticipantModalLabel" 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="addParticipantModalLabel">Add Participant</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form action="{% url 'event_add_participant' event.id %}" method="POST" id="addParticipantForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Search Voter</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="voterSearchInput" class="form-control" placeholder="Type name or ID..." autocomplete="off">
|
||||||
|
<span class="input-group-text bg-white border-start-0">
|
||||||
|
<i class="bi bi-search text-muted"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="voterSearchResults" 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="selectedVoterDisplay" class="mt-2 d-none">
|
||||||
|
<div class="alert alert-info py-2 px-3 mb-0 d-flex justify-content-between align-items-center rounded-3 border-0">
|
||||||
|
<span id="voterName" class="fw-semibold"></span>
|
||||||
|
<button type="button" class="btn-close small" id="clearSelectedVoter"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Hidden field for the actual voter ID -->
|
||||||
|
<input type="hidden" name="voter" id="voter_id_hidden" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-0">
|
||||||
|
<label for="{{ add_form.participation_status.id_for_label }}" class="form-label fw-bold">Participation Status</label>
|
||||||
|
{{ add_form.participation_status }}
|
||||||
|
</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="submitAddParticipant" disabled>Add Participant</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const searchInput = document.getElementById('voterSearchInput');
|
||||||
|
const resultsContainer = document.getElementById('voterSearchResults');
|
||||||
|
const hiddenVoterId = document.getElementById('voter_id_hidden');
|
||||||
|
const selectedDisplay = document.getElementById('selectedVoterDisplay');
|
||||||
|
const voterNameDisplay = document.getElementById('voterName');
|
||||||
|
const clearBtn = document.getElementById('clearSelectedVoter');
|
||||||
|
const submitBtn = document.getElementById('submitAddParticipant');
|
||||||
|
|
||||||
|
let debounceTimer;
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
const query = this.value.trim();
|
||||||
|
|
||||||
|
if (query.length < 2) {
|
||||||
|
resultsContainer.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
fetch(`/voters/search/json/?q=${encodeURIComponent(query)}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
resultsContainer.innerHTML = '';
|
||||||
|
if (data.results && data.results.length > 0) {
|
||||||
|
data.results.forEach(voter => {
|
||||||
|
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.addEventListener('click', () => {
|
||||||
|
selectVoter(voter.id, voter.text, voter.address, voter.phone);
|
||||||
|
});
|
||||||
|
resultsContainer.appendChild(btn);
|
||||||
|
});
|
||||||
|
resultsContainer.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';
|
||||||
|
resultsContainer.appendChild(div);
|
||||||
|
resultsContainer.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectVoter(id, text, address, phone) {
|
||||||
|
hiddenVoterId.value = id;
|
||||||
|
voterNameDisplay.innerHTML = `<div>${text}</div><div class="small fw-normal text-muted">${address || ""} ${phone ? "• " + phone : ""}</div>`;
|
||||||
|
selectedDisplay.classList.remove('d-none');
|
||||||
|
searchInput.parentElement.classList.add('d-none');
|
||||||
|
resultsContainer.classList.add('d-none');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
hiddenVoterId.value = '';
|
||||||
|
voterNameDisplay.textContent = '';
|
||||||
|
selectedDisplay.classList.add('d-none');
|
||||||
|
searchInput.parentElement.classList.remove('d-none');
|
||||||
|
searchInput.value = '';
|
||||||
|
submitBtn.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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
58
core/templates/core/event_list.html
Normal file
58
core/templates/core/event_list.html
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-4">Event Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th class="pe-4 text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for event in events %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-4">
|
||||||
|
<a href="{% url 'event_detail' event.id %}" class="fw-semibold text-primary text-decoration-none d-block">
|
||||||
|
{{ event.name|default:event.event_type }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td><span class="badge bg-light text-dark border">{{ event.event_type }}</span></td>
|
||||||
|
<td>{{ event.date|date:"M d, Y" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if event.start_time %}
|
||||||
|
{{ event.start_time|time:"g:i A" }}
|
||||||
|
{% if event.end_time %} - {{ event.end_time|time:"g:i A" }}{% endif %}
|
||||||
|
{% 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">
|
||||||
|
<p class="mb-0">No events found for this campaign.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -191,13 +191,14 @@
|
|||||||
|
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<div class="card border-0 shadow-sm mb-4">
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
<div class="card-header bg-white border-0 py-3">
|
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0 fw-bold">Upcoming Events</h5>
|
<h5 class="mb-0 fw-bold">Upcoming Events</h5>
|
||||||
|
<a href="{% url 'event_list' %}" class="btn btn-sm btn-outline-primary rounded-pill px-3">View All</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
{% for event in upcoming_events %}
|
{% for event in upcoming_events %}
|
||||||
<div class="list-group-item px-4 py-3 border-0 border-bottom">
|
<a href="{% url 'event_detail' event.id %}" class="list-group-item list-group-item-action px-4 py-3 border-0 border-bottom">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<h6 class="mb-0 fw-bold text-dark">{{ event.name|default:event.event_type }}</h6>
|
<h6 class="mb-0 fw-bold text-dark">{{ event.name|default:event.event_type }}</h6>
|
||||||
<span class="badge rounded-pill bg-primary bg-opacity-10 text-primary small">
|
<span class="badge rounded-pill bg-primary bg-opacity-10 text-primary small">
|
||||||
@ -207,7 +208,7 @@
|
|||||||
<div class="small fw-medium text-dark">
|
<div class="small fw-medium text-dark">
|
||||||
<i class="bi bi-calendar-event me-1 text-muted"></i>{{ event.date|date:"M d, Y" }}
|
<i class="bi bi-calendar-event me-1 text-muted"></i>{{ event.date|date:"M d, Y" }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<div class="text-center py-5 text-muted">
|
<div class="text-center py-5 text-muted">
|
||||||
No upcoming events.
|
No upcoming events.
|
||||||
|
|||||||
339
core/templates/core/volunteer_detail.html
Normal file
339
core/templates/core/volunteer_detail.html
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<!-- Tom Select CSS/JS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/css/tom-select.bootstrap5.min.css" rel="stylesheet">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js"></script>
|
||||||
|
<style>
|
||||||
|
.ts-control {
|
||||||
|
border: 1px solid #dee2e6 !important;
|
||||||
|
padding: 0.5rem 0.75rem !important;
|
||||||
|
border-radius: 0.375rem !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.ts-control .item {
|
||||||
|
background: #e9ecef !important;
|
||||||
|
border: 1px solid #dee2e6 !important;
|
||||||
|
color: #212529 !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 2px 8px !important;
|
||||||
|
}
|
||||||
|
.ts-dropdown {
|
||||||
|
border-radius: 0.375rem !important;
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||||
|
}
|
||||||
|
.ts-control.focus {
|
||||||
|
border-color: #86b7fe !important;
|
||||||
|
outline: 0 !important;
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% 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 'volunteer_list' %}">Volunteers</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{% if volunteer %}{{ volunteer.first_name }} {{ volunteer.last_name }}{% else %}Add New Volunteer{% endif %}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h1 class="h2 mb-0">{% if volunteer %}{{ volunteer.first_name }} {{ volunteer.last_name }}{% else %}New Volunteer{% endif %}</h1>
|
||||||
|
{% if volunteer %}
|
||||||
|
<form action="{% url 'volunteer_delete' volunteer.id %}" method="POST" onsubmit="return confirm('Are you sure you want to delete this volunteer?')">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm">Delete Volunteer</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<!-- Volunteer Info & Edit Form -->
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="card-title fw-bold mb-4">Volunteer Information</h5>
|
||||||
|
<form action="{% if volunteer %}{% url 'volunteer_detail' volunteer.id %}{% else %}{% url 'volunteer_add' %}{% endif %}" method="POST" id="volunteerForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="{{ form.first_name.id_for_label }}" class="form-label small text-muted text-uppercase fw-bold">First Name</label>
|
||||||
|
{{ form.first_name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="{{ form.last_name.id_for_label }}" class="form-label small text-muted text-uppercase fw-bold">Last Name</label>
|
||||||
|
{{ form.last_name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="{{ form.email.id_for_label }}" class="form-label small text-muted text-uppercase fw-bold">Email Address</label>
|
||||||
|
{{ form.email }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="{{ form.phone.id_for_label }}" class="form-label small text-muted text-uppercase fw-bold">Phone Number</label>
|
||||||
|
{{ form.phone }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<label for="{{ form.interests.id_for_label }}" class="form-label small text-muted text-uppercase fw-bold mb-0">Interests</label>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-sm btn-link text-decoration-none py-0 me-2" data-bs-toggle="modal" data-bs-target="#manageInterestsModal">
|
||||||
|
<small>Manage Types</small>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary py-0" data-bs-toggle="modal" data-bs-target="#addInterestModal">
|
||||||
|
<small>+ Add New Type</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ form.interests }}
|
||||||
|
</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>
|
||||||
|
<button type="submit" class="btn btn-primary px-4">{% if volunteer %}Save Changes{% else %}Create Volunteer{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if volunteer %}
|
||||||
|
<!-- Event Assignments -->
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0 fw-bold">Event Assignments</h5>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#assignEventModal">
|
||||||
|
+ Assign Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-4">Event</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th class="pe-4 text-end">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for assignment in assignments %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-4">
|
||||||
|
<a href="{% url 'event_detail' assignment.event.id %}" class="fw-semibold text-primary text-decoration-none">
|
||||||
|
{{ assignment.event.name|default:assignment.event.event_type }}
|
||||||
|
</a>
|
||||||
|
<div class="small text-muted">{{ assignment.event.date|date:"M d, Y" }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ assignment.role }}</td>
|
||||||
|
<td class="pe-4 text-end">
|
||||||
|
<form action="{% url 'volunteer_remove_event' assignment.id %}" method="POST" onsubmit="return confirm('Remove this assignment?')">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-link text-danger p-0">Remove</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-center py-5 text-muted">
|
||||||
|
No events assigned yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Interest Modal -->
|
||||||
|
<div class="modal fade" id="addInterestModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-sm">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<h6 class="modal-title fw-bold">New Interest Type</h6>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="text" id="newInterestName" class="form-control" placeholder="e.g. Phone Banking">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0 pt-0">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" id="saveInterestBtn" class="btn btn-sm btn-primary">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manage Interests Modal -->
|
||||||
|
<div class="modal fade" id="manageInterestsModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<div class="modal-header border-0 bg-light">
|
||||||
|
<h5 class="modal-title fw-bold">Manage Interest Types</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="small text-muted mb-3">Deleting an interest type will remove it from all volunteers in the campaign.</p>
|
||||||
|
<div class="list-group list-group-flush" id="manageInterestsList">
|
||||||
|
{% for interest in form.interests.field.queryset %}
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center px-0 interest-manage-item" data-id="{{ interest.id }}">
|
||||||
|
<span>{{ interest.name }}</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger border-0 delete-interest-btn" data-id="{{ interest.id }}">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if volunteer %}
|
||||||
|
<!-- Assign Event Modal -->
|
||||||
|
<div class="modal fade" id="assignEventModal" tabindex="-1" aria-labelledby="assignEventModalLabel" 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="assignEventModalLabel">Assign Volunteer to Event</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form action="{% url 'volunteer_assign_event' volunteer.id %}" method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ assign_form.event.id_for_label }}" class="form-label fw-bold">Select Event</label>
|
||||||
|
{{ assign_form.event }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-0">
|
||||||
|
<label for="{{ assign_form.role.id_for_label }}" class="form-label fw-bold">Role/Task</label>
|
||||||
|
{{ assign_form.role }}
|
||||||
|
<div class="form-text">e.g., Door knocker, Registration desk, Driver</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0 p-4 pt-0">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary px-4">Assign Event</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initialize Tom Select
|
||||||
|
const interestSelect = new TomSelect('#id_interests', {
|
||||||
|
plugins: ['remove_button'],
|
||||||
|
placeholder: 'Search for interests...',
|
||||||
|
create: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveInterestBtn = document.getElementById('saveInterestBtn');
|
||||||
|
const newInterestNameInput = document.getElementById('newInterestName');
|
||||||
|
const manageInterestsList = document.getElementById('manageInterestsList');
|
||||||
|
|
||||||
|
saveInterestBtn.addEventListener('click', function() {
|
||||||
|
const name = newInterestNameInput.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', name);
|
||||||
|
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
|
||||||
|
|
||||||
|
fetch('{% url "interest_add" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Add new option to Tom Select and select it
|
||||||
|
interestSelect.addOption({value: data.id, text: data.name});
|
||||||
|
interestSelect.addItem(data.id);
|
||||||
|
|
||||||
|
// Add to manage list
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'list-group-item d-flex justify-content-between align-items-center px-0 interest-manage-item';
|
||||||
|
item.dataset.id = data.id;
|
||||||
|
item.innerHTML = `
|
||||||
|
<span>${data.name}</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger border-0 delete-interest-btn" data-id="${data.id}">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
manageInterestsList.appendChild(item);
|
||||||
|
|
||||||
|
// Close modal and reset input
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('addInterestModal'));
|
||||||
|
modal.hide();
|
||||||
|
newInterestNameInput.value = '';
|
||||||
|
|
||||||
|
// Re-bind delete events
|
||||||
|
bindDeleteEvents();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Error adding interest.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('An error occurred.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function bindDeleteEvents() {
|
||||||
|
document.querySelectorAll('.delete-interest-btn').forEach(btn => {
|
||||||
|
btn.onclick = function() {
|
||||||
|
const id = this.dataset.id;
|
||||||
|
if (!confirm('Are you sure you want to delete this interest type from the campaign? This will remove it from all volunteers.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
|
||||||
|
|
||||||
|
fetch(`/interests/${id}/delete/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Remove from Tom Select
|
||||||
|
interestSelect.removeOption(id);
|
||||||
|
|
||||||
|
// Remove from manage list
|
||||||
|
const item = document.querySelector(`.interest-manage-item[data-id="${id}"]`);
|
||||||
|
if (item) item.remove();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Error deleting interest.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('An error occurred.');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindDeleteEvents();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
107
core/templates/core/volunteer_list.html
Normal file
107
core/templates/core/volunteer_list.html
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h2">Volunteer Directory</h1>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{% url 'volunteer_add' %}" class="btn btn-primary btn-sm">+ Add New Volunteer</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<input type="text" name="q" class="form-control" placeholder="Search by name or email..." value="{{ query|default:'' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<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>Email</th>
|
||||||
|
<th>Phone</th>
|
||||||
|
<th>Interests</th>
|
||||||
|
<th class="pe-4 text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for volunteer in volunteers %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-4">
|
||||||
|
<a href="{% url 'volunteer_detail' volunteer.id %}" class="fw-semibold text-primary text-decoration-none d-block">
|
||||||
|
{{ volunteer.first_name }} {{ volunteer.last_name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ volunteer.email }}</td>
|
||||||
|
<td>{{ volunteer.phone|default:"-" }}</td>
|
||||||
|
<td>
|
||||||
|
{% for interest in volunteer.interests.all %}
|
||||||
|
<span class="badge bg-info-subtle text-info border border-info-subtle">{{ interest.name }}</span>
|
||||||
|
{% empty %}
|
||||||
|
<span class="text-muted small">No interests listed</span>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="pe-4 text-end">
|
||||||
|
<a href="{% url 'volunteer_detail' volunteer.id %}" class="btn btn-outline-primary btn-sm">View & Edit</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" 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>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if volunteers.paginator.num_pages > 1 %}
|
||||||
|
<div class="card-footer bg-white border-0 py-3">
|
||||||
|
<nav aria-label="Page navigation">
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<span aria-hidden="true">«</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="page-item active"><span class="page-link">{{ volunteers.number }} of {{ volunteers.paginator.num_pages }}</span></li>
|
||||||
|
|
||||||
|
{% 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">
|
||||||
|
<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">
|
||||||
|
<span aria-hidden="true">»»</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -204,13 +204,13 @@
|
|||||||
<button class="nav-link active border-0 py-3 px-4" id="interactions-tab" data-bs-toggle="tab" data-bs-target="#interactions" type="button" role="tab">Interactions</button>
|
<button class="nav-link active border-0 py-3 px-4" id="interactions-tab" data-bs-toggle="tab" data-bs-target="#interactions" type="button" role="tab">Interactions</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link border-0 py-3 px-4" id="voting-tab" data-bs-toggle="tab" data-bs-target="#voting" type="button" role="tab">Voting History</button>
|
<button class="nav-link border-0 py-3 px-4" id="events-tab" data-bs-toggle="tab" data-bs-target="#events" type="button" role="tab">Events</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link border-0 py-3 px-4" id="donations-tab" data-bs-toggle="tab" data-bs-target="#donations" type="button" role="tab">Donations</button>
|
<button class="nav-link border-0 py-3 px-4" id="donations-tab" data-bs-toggle="tab" data-bs-target="#donations" type="button" role="tab">Donations</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link border-0 py-3 px-4" id="events-tab" data-bs-toggle="tab" data-bs-target="#events" type="button" role="tab">Events</button>
|
<button class="nav-link border-0 py-3 px-4" id="voting-tab" data-bs-toggle="tab" data-bs-target="#voting" type="button" role="tab">Voting History</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@ -260,77 +260,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Voting History Tab -->
|
|
||||||
<div class="tab-pane fade" id="voting" role="tabpanel">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table align-middle mb-0">
|
|
||||||
<thead class="bg-light">
|
|
||||||
<tr>
|
|
||||||
<th class="ps-4">Election Date</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th class="pe-4">Party</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for record in voting_records %}
|
|
||||||
<tr>
|
|
||||||
<td class="ps-4 text-nowrap">{{ record.election_date|date:"M d, Y" }}</td>
|
|
||||||
<td>{{ record.election_description }}</td>
|
|
||||||
<td class="pe-4">{{ record.primary_party|default:"-" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr><td colspan="3" class="text-center py-4 text-muted">No voting records found.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Donations Tab -->
|
|
||||||
<div class="tab-pane fade" id="donations" role="tabpanel">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
|
|
||||||
<h6 class="mb-0">Donation History</h6>
|
|
||||||
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addDonationModal">
|
|
||||||
<i class="bi bi-plus-lg me-1"></i>Add Donation
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table align-middle mb-0">
|
|
||||||
<thead class="bg-light">
|
|
||||||
<tr>
|
|
||||||
<th class="ps-4">Date</th>
|
|
||||||
<th>Method</th>
|
|
||||||
<th class="text-end">Amount</th>
|
|
||||||
<th class="pe-4 text-end">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for donation in donations %}
|
|
||||||
<tr>
|
|
||||||
<td class="ps-4 text-nowrap">{{ donation.date|date:"M d, Y" }}</td>
|
|
||||||
<td>{{ donation.method.name }}</td>
|
|
||||||
<td class="text-end fw-semibold text-success">${{ donation.amount }}</td>
|
|
||||||
<td class="pe-4 text-end">
|
|
||||||
<button class="btn btn-sm btn-link text-primary p-0 me-2" data-bs-toggle="modal" data-bs-target="#editDonationModal{{ donation.id }}">
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-link text-danger p-0" data-bs-toggle="modal" data-bs-target="#deleteDonationModal{{ donation.id }}">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr><td colspan="4" class="text-center py-4 text-muted">No donations recorded.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Events Tab -->
|
<!-- Events Tab -->
|
||||||
<div class="tab-pane fade" id="events" role="tabpanel">
|
<div class="tab-pane fade" id="events" role="tabpanel">
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
@ -385,6 +314,77 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Donations Tab -->
|
||||||
|
<div class="tab-pane fade" id="donations" role="tabpanel">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0">Donation History</h6>
|
||||||
|
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addDonationModal">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Add Donation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-4">Date</th>
|
||||||
|
<th>Method</th>
|
||||||
|
<th class="text-end">Amount</th>
|
||||||
|
<th class="pe-4 text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for donation in donations %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-4 text-nowrap">{{ donation.date|date:"M d, Y" }}</td>
|
||||||
|
<td>{{ donation.method.name }}</td>
|
||||||
|
<td class="text-end fw-semibold text-success">${{ donation.amount }}</td>
|
||||||
|
<td class="pe-4 text-end">
|
||||||
|
<button class="btn btn-sm btn-link text-primary p-0 me-2" data-bs-toggle="modal" data-bs-target="#editDonationModal{{ donation.id }}">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-link text-danger p-0" data-bs-toggle="modal" data-bs-target="#deleteDonationModal{{ donation.id }}">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="4" class="text-center py-4 text-muted">No donations recorded.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Voting History Tab -->
|
||||||
|
<div class="tab-pane fade" id="voting" role="tabpanel">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-4">Election Date</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="pe-4">Party</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for record in voting_records %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-4 text-nowrap">{{ record.election_date|date:"M d, Y" }}</td>
|
||||||
|
<td>{{ record.election_description }}</td>
|
||||||
|
<td class="pe-4">{{ record.primary_party|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="3" class="text-center py-4 text-muted">No voting records found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
18
core/urls.py
18
core/urls.py
@ -28,4 +28,22 @@ urlpatterns = [
|
|||||||
path('voters/<int:voter_id>/event-participation/add/', views.add_event_participation, name='add_event_participation'),
|
path('voters/<int:voter_id>/event-participation/add/', views.add_event_participation, name='add_event_participation'),
|
||||||
path('event-participation/<int:participation_id>/edit/', views.edit_event_participation, name='edit_event_participation'),
|
path('event-participation/<int:participation_id>/edit/', views.edit_event_participation, name='edit_event_participation'),
|
||||||
path('event-participation/<int:participation_id>/delete/', views.delete_event_participation, name='delete_event_participation'),
|
path('event-participation/<int:participation_id>/delete/', views.delete_event_participation, name='delete_event_participation'),
|
||||||
|
|
||||||
|
# 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/<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'),
|
||||||
|
path('voters/search/json/', views.voter_search_json, name='voter_search_json'),
|
||||||
|
|
||||||
|
# Volunteer Management
|
||||||
|
path('interests/add/', views.interest_add, name='interest_add'),
|
||||||
|
path('interests/<int:interest_id>/delete/', views.interest_delete, name='interest_delete'),
|
||||||
|
path('volunteers/', views.volunteer_list, name='volunteer_list'),
|
||||||
|
path('volunteers/add/', views.volunteer_add, name='volunteer_add'),
|
||||||
|
path('volunteers/<int:volunteer_id>/', views.volunteer_detail, name='volunteer_detail'),
|
||||||
|
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'),
|
||||||
]
|
]
|
||||||
|
|||||||
285
core/views.py
285
core/views.py
@ -10,8 +10,8 @@ from django.shortcuts import render, redirect, get_object_or_404
|
|||||||
from django.db.models import Q, Sum
|
from django.db.models import Q, Sum
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer
|
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
|
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm
|
||||||
import logging
|
import logging
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@ -662,3 +662,284 @@ def bulk_send_sms(request):
|
|||||||
|
|
||||||
messages.success(request, f"Bulk SMS process completed: {success_count} successful, {fail_count} failed/skipped.")
|
messages.success(request, f"Bulk SMS process completed: {success_count} successful, {fail_count} failed/skipped.")
|
||||||
return redirect('voter_advanced_search')
|
return redirect('voter_advanced_search')
|
||||||
|
|
||||||
|
def event_list(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)
|
||||||
|
events = Event.objects.filter(tenant=tenant).order_by('-date')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'tenant': tenant,
|
||||||
|
'events': events,
|
||||||
|
'selected_tenant': tenant,
|
||||||
|
}
|
||||||
|
return render(request, 'core/event_list.html', context)
|
||||||
|
|
||||||
|
def event_detail(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)
|
||||||
|
participations = event.participations.all().select_related('voter', 'participation_status').order_by('voter__last_name', 'voter__first_name')
|
||||||
|
|
||||||
|
# Form for adding a new participant
|
||||||
|
add_form = EventParticipantAddForm(tenant=tenant)
|
||||||
|
participation_statuses = ParticipationStatus.objects.filter(tenant=tenant, is_active=True)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'tenant': tenant,
|
||||||
|
'selected_tenant': tenant,
|
||||||
|
'event': event,
|
||||||
|
'participations': participations,
|
||||||
|
'add_form': add_form,
|
||||||
|
'participation_statuses': participation_statuses,
|
||||||
|
}
|
||||||
|
return render(request, 'core/event_detail.html', context)
|
||||||
|
|
||||||
|
def event_add_participant(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 = EventParticipantAddForm(request.POST, tenant=tenant)
|
||||||
|
if form.is_valid():
|
||||||
|
participation = form.save(commit=False)
|
||||||
|
participation.event = event
|
||||||
|
if not EventParticipation.objects.filter(event=event, voter=participation.voter).exists():
|
||||||
|
participation.save()
|
||||||
|
messages.success(request, f"{participation.voter} added to event.")
|
||||||
|
else:
|
||||||
|
messages.warning(request, "Voter is already a participant.")
|
||||||
|
else:
|
||||||
|
messages.error(request, "Error adding participant.")
|
||||||
|
|
||||||
|
return redirect('event_detail', event_id=event.id)
|
||||||
|
|
||||||
|
def event_edit_participant(request, participation_id):
|
||||||
|
tenant_id = request.session.get("tenant_id")
|
||||||
|
tenant = get_object_or_404(Tenant, id=tenant_id)
|
||||||
|
participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
status_id = request.POST.get('participation_status')
|
||||||
|
if status_id:
|
||||||
|
status = get_object_or_404(ParticipationStatus, id=status_id, tenant=tenant)
|
||||||
|
participation.participation_status = status
|
||||||
|
participation.save()
|
||||||
|
messages.success(request, f"Participation updated for {participation.voter}.")
|
||||||
|
else:
|
||||||
|
messages.error(request, "Invalid status.")
|
||||||
|
|
||||||
|
return redirect('event_detail', event_id=participation.event.id)
|
||||||
|
|
||||||
|
def event_delete_participant(request, participation_id):
|
||||||
|
tenant_id = request.session.get("tenant_id")
|
||||||
|
tenant = get_object_or_404(Tenant, id=tenant_id)
|
||||||
|
participation = get_object_or_404(EventParticipation, id=participation_id, event__tenant=tenant)
|
||||||
|
event_id = participation.event.id
|
||||||
|
voter_name = str(participation.voter)
|
||||||
|
participation.delete()
|
||||||
|
messages.success(request, f"{voter_name} removed from event.")
|
||||||
|
return redirect('event_detail', event_id=event_id)
|
||||||
|
|
||||||
|
def voter_search_json(request):
|
||||||
|
"""
|
||||||
|
JSON endpoint for voter 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)
|
||||||
|
voters = Voter.objects.filter(tenant=tenant)
|
||||||
|
|
||||||
|
search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(voter_id__icontains=query)
|
||||||
|
|
||||||
|
if "," in query:
|
||||||
|
parts = [p.strip() for p in query.split(",") ]
|
||||||
|
if len(parts) >= 2:
|
||||||
|
search_filter |= Q(last_name__icontains=parts[0], first_name__icontains=parts[1])
|
||||||
|
|
||||||
|
results = voters.filter(search_filter).order_by("last_name", "first_name")[:20]
|
||||||
|
|
||||||
|
data = []
|
||||||
|
for v in results:
|
||||||
|
data.append({
|
||||||
|
"id": v.id,
|
||||||
|
"text": f"{v.last_name}, {v.first_name} ({v.voter_id})",
|
||||||
|
"address": v.address,
|
||||||
|
"phone": v.phone
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({"results": data})
|
||||||
|
|
||||||
|
def volunteer_list(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)
|
||||||
|
volunteers = Volunteer.objects.filter(tenant=tenant).order_by('last_name', 'first_name')
|
||||||
|
|
||||||
|
# Simple search
|
||||||
|
query = request.GET.get("q")
|
||||||
|
if query:
|
||||||
|
volunteers = volunteers.filter(
|
||||||
|
Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(email__icontains=query)
|
||||||
|
)
|
||||||
|
|
||||||
|
paginator = Paginator(volunteers, 50)
|
||||||
|
page_number = request.GET.get('page')
|
||||||
|
volunteers_page = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'tenant': tenant,
|
||||||
|
'selected_tenant': tenant,
|
||||||
|
'volunteers': volunteers_page,
|
||||||
|
'query': query,
|
||||||
|
}
|
||||||
|
return render(request, 'core/volunteer_list.html', context)
|
||||||
|
|
||||||
|
def volunteer_add(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 = VolunteerForm(request.POST, tenant=tenant)
|
||||||
|
if form.is_valid():
|
||||||
|
volunteer = form.save(commit=False)
|
||||||
|
volunteer.tenant = tenant
|
||||||
|
volunteer.save()
|
||||||
|
form.save_m2m() # Save interests
|
||||||
|
messages.success(request, f"Volunteer {volunteer} added successfully.")
|
||||||
|
return redirect('volunteer_detail', volunteer_id=volunteer.id)
|
||||||
|
else:
|
||||||
|
form = VolunteerForm(tenant=tenant)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'form': form,
|
||||||
|
'tenant': tenant,
|
||||||
|
'selected_tenant': tenant,
|
||||||
|
}
|
||||||
|
return render(request, 'core/volunteer_detail.html', context)
|
||||||
|
|
||||||
|
def volunteer_detail(request, volunteer_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)
|
||||||
|
volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = VolunteerForm(request.POST, instance=volunteer, tenant=tenant)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, f"Volunteer {volunteer} updated successfully.")
|
||||||
|
return redirect('volunteer_detail', volunteer_id=volunteer.id)
|
||||||
|
else:
|
||||||
|
form = VolunteerForm(instance=volunteer, tenant=tenant)
|
||||||
|
|
||||||
|
assignments = volunteer.event_assignments.all().select_related('event')
|
||||||
|
assign_form = VolunteerEventForm(tenant=tenant)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'volunteer': volunteer,
|
||||||
|
'form': form,
|
||||||
|
'assignments': assignments,
|
||||||
|
'assign_form': assign_form,
|
||||||
|
'tenant': tenant,
|
||||||
|
'selected_tenant': tenant,
|
||||||
|
}
|
||||||
|
return render(request, 'core/volunteer_detail.html', context)
|
||||||
|
|
||||||
|
def volunteer_delete(request, volunteer_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)
|
||||||
|
volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
volunteer.delete()
|
||||||
|
messages.success(request, "Volunteer deleted.")
|
||||||
|
return redirect('volunteer_list')
|
||||||
|
return redirect('volunteer_detail', volunteer_id=volunteer_id)
|
||||||
|
|
||||||
|
def volunteer_assign_event(request, volunteer_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)
|
||||||
|
volunteer = get_object_or_404(Volunteer, id=volunteer_id, tenant=tenant)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = VolunteerEventForm(request.POST, tenant=tenant)
|
||||||
|
if form.is_valid():
|
||||||
|
assignment = form.save(commit=False)
|
||||||
|
assignment.volunteer = volunteer
|
||||||
|
assignment.save()
|
||||||
|
messages.success(request, f"Assigned to {assignment.event}.")
|
||||||
|
else:
|
||||||
|
messages.error(request, "Error assigning to event.")
|
||||||
|
|
||||||
|
return redirect('volunteer_detail', volunteer_id=volunteer.id)
|
||||||
|
|
||||||
|
def volunteer_remove_event(request, assignment_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)
|
||||||
|
assignment = get_object_or_404(VolunteerEvent, id=assignment_id, volunteer__tenant=tenant)
|
||||||
|
volunteer_id = assignment.volunteer.id
|
||||||
|
assignment.delete()
|
||||||
|
messages.success(request, "Assignment removed.")
|
||||||
|
return redirect('volunteer_detail', volunteer_id=volunteer_id)
|
||||||
|
|
||||||
|
def interest_add(request):
|
||||||
|
selected_tenant_id = request.session.get("tenant_id")
|
||||||
|
if not selected_tenant_id:
|
||||||
|
return JsonResponse({'success': False, 'error': 'No campaign selected.'})
|
||||||
|
|
||||||
|
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.POST.get('name', '').strip()
|
||||||
|
if name:
|
||||||
|
interest, created = Interest.objects.get_or_create(tenant=tenant, name=name)
|
||||||
|
if created:
|
||||||
|
return JsonResponse({'success': True, 'id': interest.id, 'name': interest.name})
|
||||||
|
else:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Interest already exists.'})
|
||||||
|
return JsonResponse({'success': False, 'error': 'Invalid request.'})
|
||||||
|
|
||||||
|
def interest_delete(request, interest_id):
|
||||||
|
selected_tenant_id = request.session.get("tenant_id")
|
||||||
|
if not selected_tenant_id:
|
||||||
|
return JsonResponse({'success': False, 'error': 'No campaign selected.'})
|
||||||
|
|
||||||
|
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
|
||||||
|
interest = get_object_or_404(Interest, id=interest_id, tenant=tenant)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
interest.delete()
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
return JsonResponse({'success': False, 'error': 'Invalid request.'})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user