Autosave: 20260203-184221

This commit is contained in:
Flatlogic Bot 2026-02-03 18:42:21 +00:00
parent ac80c84fbd
commit bf2d558e03
8 changed files with 204 additions and 143 deletions

View File

@ -12,15 +12,18 @@ Class-based views
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
from django.views.i18n import JavaScriptCatalog
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import include, path
from django.views.i18n import JavaScriptCatalog
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path("admin/", admin.site.urls),
path("jsi18n/", JavaScriptCatalog.as_view(), name="jsi18n"),
path("", include("core.urls")),
path("accounts/", include("django.contrib.auth.urls")),
]

View File

@ -1,6 +1,17 @@
from django import forms
from django.contrib.auth.models import User
from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType, Tenant, ParticipationStatus, Volunteer, VolunteerEvent, VolunteerRole, ScheduledCall
from .permissions import get_user_role
class Select2MultipleWidget(forms.SelectMultiple):
"""
Custom widget to mark fields for Select2 initialization in the template.
"""
def __init__(self, attrs=None, choices=()):
default_attrs = {"multiple": "multiple"}
if attrs:
default_attrs.update(attrs)
super().__init__(attrs=default_attrs, choices=choices)
class VoterForm(forms.ModelForm):
class Meta:
@ -19,8 +30,30 @@ class VoterForm(forms.ModelForm):
'notes': forms.Textarea(attrs={'rows': 3}),
}
def __init__(self, *args, **kwargs):
def __init__(self, *args, user=None, tenant=None, **kwargs):
self.user = user
self.tenant = tenant
super().__init__(*args, **kwargs)
# Restrict fields for non-admin users
is_admin = False
if user:
if user.is_superuser:
is_admin = True
elif tenant:
role = get_user_role(user, tenant)
if role in ["admin", "system_admin", "campaign_admin"]:
is_admin = True
if not is_admin:
restricted_fields = [
"first_name", "last_name", "voter_id", "district", "precinct",
"registration_date", "address_street", "city", "state", "zip_code"
]
for field_name in restricted_fields:
if field_name in self.fields:
self.fields[field_name].widget.attrs["readonly"] = True
self.fields[field_name].widget.attrs["class"] = self.fields[field_name].widget.attrs.get("class", "") + " bg-light"
for name, field in self.fields.items():
if name in ['latitude', 'longitude']:
continue
@ -35,6 +68,39 @@ class VoterForm(forms.ModelForm):
self.fields['phone_type'].widget.attrs.update({'class': 'form-select'})
self.fields['secondary_phone_type'].widget.attrs.update({'class': 'form-select'})
def clean(self):
cleaned_data = super().clean()
# Backend protection for restricted fields
is_admin = False
user = getattr(self, "user", None)
tenant = getattr(self, "tenant", None)
# We need to set these on the form instance if we want to use them in clean
# or we can pass them in __init__ and store them
if self.user:
if self.user.is_superuser:
is_admin = True
elif self.tenant:
from .permissions import get_user_role
role = get_user_role(self.user, self.tenant)
if role in ["admin", "system_admin", "campaign_admin"]:
is_admin = True
if not is_admin and self.instance.pk:
restricted_fields = [
"first_name", "last_name", "voter_id", "district", "precinct",
"registration_date", "address_street", "city", "state", "zip_code"
]
for field in restricted_fields:
if field in self.changed_data:
# Revert to original value
cleaned_data[field] = getattr(self.instance, field)
return cleaned_data
class AdvancedVoterSearchForm(forms.Form):
MONTH_CHOICES = [
('', 'Any Month'),
@ -259,7 +325,10 @@ class VolunteerForm(forms.ModelForm):
class Meta:
model = Volunteer
fields = ['first_name', 'last_name', 'email', 'phone', 'is_default_caller', 'notes', 'interests']
widgets = {'notes': forms.Textarea(attrs={'rows': 3})}
widgets = {
'notes': forms.Textarea(attrs={'rows': 3}),
'interests': Select2MultipleWidget(),
}
def __init__(self, *args, tenant=None, **kwargs):
super().__init__(*args, **kwargs)
@ -271,8 +340,6 @@ class VolunteerForm(forms.ModelForm):
field.widget.attrs.update({'class': 'form-control'})
else:
field.widget.attrs.update({'class': 'form-check-input'})
self.fields['interests'].widget.attrs.update({'class': 'form-select tom-select'})
class VolunteerEventForm(forms.ModelForm):
class Meta:
@ -389,4 +456,4 @@ class VolunteerProfileForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs.update({'class': 'form-control'})
field.widget.attrs.update({'class': 'form-control'})

View File

@ -1,31 +1,45 @@
{% extends "base.html" %}
{% load static %}
{% 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>
{% block extra_css %}
<link rel="stylesheet" href="{% static 'admin/css/vendor/select2/select2.min.css' %}">
<style>
.ts-control {
border: 1px solid #dee2e6 !important;
padding: 0.5rem 0.75rem !important;
border-radius: 0.375rem !important;
box-shadow: none !important;
/* Select2 Bootstrap 5 compatibility or similar overrides */
.select2-container--default .select2-selection--multiple {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
min-height: 45px;
padding: 4px 8px;
}
.ts-control .item {
background: #e9ecef !important;
border: 1px solid #dee2e6 !important;
color: #212529 !important;
border-radius: 4px !important;
padding: 2px 8px !important;
.select2-container--default.select2-container--focus .select2-selection--multiple {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
.ts-dropdown {
border-radius: 0.375rem !important;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
.select2-container--default .select2-selection--multiple .select2-selection__choice {
background-color: #0d6efd;
border: none;
color: white;
border-radius: 4px;
padding: 2px 8px;
margin-top: 4px;
}
.ts-control.focus {
border-color: #059669 !important;
outline: 0 !important;
box-shadow: 0 0 0 0.25rem rgba(5, 150, 105, 0.1) !important;
.select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
color: white;
margin-right: 5px;
border: none;
background: transparent;
cursor: pointer;
}
.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover {
color: #f8f9fa;
background: transparent;
}
.select2-container {
width: 100% !important;
}
.select2-search__field {
margin-top: 4px !important;
width: 100% !important;
}
</style>
{% endblock %}
@ -35,7 +49,7 @@
<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"><a href="{% url 'volunteer_list' %}" class="text-decoration-none">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>
@ -52,13 +66,13 @@
<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">
<div class="col-lg-12">
<div class="card border-0 shadow-sm">
<div class="card-body p-4 p-md-5">
<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="row g-4">
<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 }}
@ -92,8 +106,8 @@
<div class="form-text">If enabled, this volunteer will be the default assigned person for new call queue entries.</div>
</div>
</div>
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="col-12 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<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">
@ -104,14 +118,17 @@
</button>
</div>
</div>
{{ form.interests }}
<div class="select2-container-wrapper">
{{ form.interests }}
</div>
<div class="form-text small text-muted mt-2">Search and select multiple interest types for this volunteer.</div>
</div>
<div class="col-12">
<label for="{{ form.notes.id_for_label }}" class="form-label small text-muted text-uppercase fw-bold">Notes</label>
{{ form.notes }}
</div>
</div>
<div class="mt-4 pt-3 border-top text-end">
<div class="mt-5 pt-4 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>
@ -122,8 +139,8 @@
{% if volunteer %}
<!-- Event Assignments -->
<div class="col-lg-5">
<div class="card border-0 shadow-sm h-100">
<div class="col-lg-12 mt-4">
<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">Event Assignments</h5>
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#assignEventModal">
@ -250,70 +267,78 @@
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script src="{% static 'admin/js/vendor/select2/select2.full.min.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Tom Select
const interestSelect = new TomSelect('#id_interests', {
plugins: ['remove_button'],
placeholder: 'Search for interests...',
create: false,
$(document).ready(function() {
const $interestsSelect = $('#id_interests');
// Initialize Select2
$interestsSelect.select2({
placeholder: "Select interests...",
allowClear: true,
multiple: true,
width: '100%'
});
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;
if (saveInterestBtn) {
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 }}');
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.');
fetch('{% url "interest_add" %}', {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Add to Select2
const newOption = new Option(data.name, data.id, false, false);
$interestsSelect.append(newOption).trigger('change');
// 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 => {
@ -336,8 +361,9 @@ document.addEventListener('DOMContentLoaded', function() {
.then(response => response.json())
.then(data => {
if (data.success) {
// Remove from Tom Select
interestSelect.removeOption(id);
// Remove from Select2
$interestsSelect.find(`option[value="${id}"]`).remove();
$interestsSelect.trigger('change');
// Remove from manage list
const item = document.querySelector(`.interest-manage-item[data-id="${id}"]`);

View File

@ -1,35 +1,5 @@
{% 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: #0d6efd !important;
outline: 0 !important;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.1) !important;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
@ -46,9 +16,10 @@
<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" id="interest-filter" class="form-select" multiple>
<select name="interest" id="interest-filter" class="form-select">
<option value="">All Interests</option>
{% for interest in interests %}
<option value="{{ interest.id }}" {% if interest.id|stringformat:"s" in selected_interests %}selected{% endif %}>
<option value="{{ interest.id }}" {% if interest.id|stringformat:"s" == selected_interest %}selected{% endif %}>
{{ interest.name }}
</option>
{% endfor %}
@ -139,12 +110,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 %}{% for i in selected_interests %}&interest={{ i }}{% endfor %}" 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 %}{% for i in selected_interests %}&interest={{ i }}{% endfor %}" 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>
@ -154,12 +125,12 @@
{% if volunteers.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ volunteers.next_page_number }}{% if query %}&q={{ query }}{% endif %}{% for i in selected_interests %}&interest={{ i }}{% endfor %}" 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 %}{% for i in selected_interests %}&interest={{ i }}{% endfor %}" 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>
@ -202,13 +173,6 @@
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Tom Select for interest filter
new TomSelect('#interest-filter', {
plugins: ['remove_button'],
placeholder: 'All Interests',
allowEmptyOption: true,
});
const selectAll = document.getElementById('select-all');
const checkboxes = document.querySelectorAll('.volunteer-checkbox');
const bulkActions = document.getElementById('bulk-actions');
@ -259,4 +223,4 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
</script>
{% endblock %}
{% endblock %}

View File

@ -175,7 +175,7 @@ def voter_detail(request, voter_id):
'interactions': voter.interactions.all().order_by('-date'),
'event_participations': voter.event_participations.all().order_by('-event__date'),
'likelihoods': voter.likelihoods.all(),
'voter_form': VoterForm(instance=voter),
'voter_form': VoterForm(instance=voter, user=request.user, tenant=tenant),
'interaction_form': InteractionForm(tenant=tenant),
'donation_form': DonationForm(tenant=tenant),
'likelihood_form': VoterLikelihoodForm(tenant=tenant),
@ -184,6 +184,7 @@ def voter_detail(request, voter_id):
}
return render(request, 'core/voter_detail.html', context)
@role_required(["admin", "campaign_manager", "campaign_staff", "system_admin", "campaign_admin"], permission="core.change_voter")
def voter_edit(request, voter_id):
"""
Update voter core demographics.
@ -198,7 +199,7 @@ def voter_edit(request, voter_id):
lon_raw = request.POST.get('longitude')
logger.info(f"Voter Edit POST: lat={lat_raw}, lon={lon_raw}")
form = VoterForm(request.POST, instance=voter)
form = VoterForm(request.POST, instance=voter, user=request.user, tenant=tenant)
if form.is_valid():
# If coordinates were provided in POST, ensure they are applied to the instance
# This handles cases where readonly or other widget settings might interfere
@ -845,9 +846,9 @@ def volunteer_list(request):
)
# Interest filter
interest_ids = request.GET.getlist("interest")
if interest_ids:
volunteers = volunteers.filter(interests__id__in=interest_ids).distinct()
interest_id = request.GET.get("interest")
if interest_id:
volunteers = volunteers.filter(interests__id=interest_id).distinct()
interests = Interest.objects.filter(tenant=tenant).order_by('name')
@ -861,7 +862,7 @@ def volunteer_list(request):
'volunteers': volunteers_page,
'query': query,
'interests': interests,
'selected_interests': interest_ids,
'selected_interest': interest_id,
}
return render(request, 'core/volunteer_list.html', context)