This commit is contained in:
Flatlogic Bot 2026-01-29 18:46:31 +00:00
parent ae3d7f9f2e
commit d8bf0cd82c
23 changed files with 355 additions and 49 deletions

0
assets/.gitkeep Normal file
View File

View File

@ -63,10 +63,8 @@ EVENT_MAPPABLE_FIELDS = [
EVENT_PARTICIPATION_MAPPABLE_FIELDS = [ EVENT_PARTICIPATION_MAPPABLE_FIELDS = [
('voter_id', 'Voter ID'), ('voter_id', 'Voter ID'),
('event_id', 'Event ID'), ('event_name', 'Event Name'),
('event_date', 'Event Date'), ('participation_status', 'Participation Status'),
('event_type', 'Event Type (Name)'),
('participation_status', 'Participation Type'),
] ]
DONATION_MAPPABLE_FIELDS = [ DONATION_MAPPABLE_FIELDS = [
@ -183,6 +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')
search_fields = ('name',) search_fields = ('name',)
class VotingRecordInline(admin.TabularInline): class VotingRecordInline(admin.TabularInline):
@ -651,10 +650,10 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if end_time and end_time.strip(): if end_time and end_time.strip():
defaults['end_time'] = end_time defaults['end_time'] = end_time
defaults['date'] = date
defaults['event_type'] = event_type
Event.objects.update_or_create( Event.objects.update_or_create(
tenant=tenant, tenant=tenant,
date=date,
event_type=event_type,
name=name or '', name=name or '',
defaults=defaults defaults=defaults
) )
@ -725,6 +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')
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',)
@ -921,18 +921,14 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
for row in reader: for row in reader:
total_count += 1 total_count += 1
voter_id = row.get(mapping.get('voter_id')) voter_id = row.get(mapping.get('voter_id'))
event_id = row.get(mapping.get('event_id')) event_name = row.get(mapping.get('event_name'))
event_date = row.get(mapping.get('event_date'))
event_type_name = row.get(mapping.get('event_type'))
exists = False exists = False
if voter_id: if voter_id:
try: try:
voter = Voter.objects.get(tenant=tenant, voter_id=voter_id) voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)
if event_id: if event_name:
exists = EventParticipation.objects.filter(voter=voter, event_id=event_id).exists() exists = EventParticipation.objects.filter(voter=voter, event__name=event_name).exists()
elif event_date and event_type_name:
exists = EventParticipation.objects.filter(voter=voter, event__date=event_date, event__event_type__name=event_type_name).exists()
except Voter.DoesNotExist: except Voter.DoesNotExist:
pass pass
@ -1004,25 +1000,15 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
continue continue
event = None event = None
event_id = row.get(mapping.get('event_id')) if mapping.get('event_id') else None event_name = row.get(mapping.get('event_name')) if mapping.get('event_name') else None
if event_id: if event_name:
try: try:
event = Event.objects.get(id=event_id, tenant=tenant) event = Event.objects.get(tenant=tenant, name=event_name)
except Event.DoesNotExist: except Event.DoesNotExist:
pass pass
if not event: if not event:
event_date = row.get(mapping.get('event_date')) if mapping.get('event_date') else None error_msg = "Event not found (check Event Name)"
event_type_name = row.get(mapping.get('event_type')) if mapping.get('event_type') else None
if event_date and event_type_name:
try:
event_type = EventType.objects.get(tenant=tenant, name=event_type_name)
event = Event.objects.get(tenant=tenant, date=event_date, event_type=event_type)
except (EventType.DoesNotExist, Event.DoesNotExist):
pass
if not event:
error_msg = "Event not found (check ID, date, or type)"
logger.error(error_msg) logger.error(error_msg)
row["Import Error"] = error_msg row["Import Error"] = error_msg
failed_rows.append(row) failed_rows.append(row)
@ -1031,11 +1017,12 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
defaults = {} defaults = {}
if participation_status_val and participation_status_val.strip(): if participation_status_val and participation_status_val.strip():
if participation_status_val in dict(EventParticipation.PARTICIPATION_TYPE_CHOICES): status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name=participation_status_val.strip())
defaults['participation_status'] = participation_status_val defaults['participation_status'] = status_obj
else: else:
defaults['participation_status'] = 'invited' # Default to 'Invited' if not specified
status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Invited')
defaults['participation_status'] = status_obj
EventParticipation.objects.update_or_create( EventParticipation.objects.update_or_create(
event=event, event=event,
voter=voter, voter=voter,
@ -1790,5 +1777,6 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
@admin.register(CampaignSettings) @admin.register(CampaignSettings)
class CampaignSettingsAdmin(admin.ModelAdmin): class CampaignSettingsAdmin(admin.ModelAdmin):
list_display = ('tenant', 'donation_goal') list_display = ('tenant', 'donation_goal', 'twilio_from_number')
list_filter = ('tenant',) list_filter = ('tenant',)
fields = ('tenant', 'donation_goal', 'twilio_account_sid', 'twilio_auth_token', 'twilio_from_number')

View File

@ -86,7 +86,7 @@ class InteractionForm(forms.ModelForm):
model = Interaction model = Interaction
fields = ['type', 'date', 'description', 'notes'] fields = ['type', 'date', 'description', 'notes']
widgets = { widgets = {
'date': forms.DateInput(attrs={'type': 'date'}), 'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'),
'notes': forms.Textarea(attrs={'rows': 2}), 'notes': forms.Textarea(attrs={'rows': 2}),
} }
@ -97,6 +97,8 @@ class InteractionForm(forms.ModelForm):
for field in self.fields.values(): for field in self.fields.values():
field.widget.attrs.update({'class': 'form-control'}) field.widget.attrs.update({'class': 'form-control'})
self.fields['type'].widget.attrs.update({'class': 'form-select'}) self.fields['type'].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')
class DonationForm(forms.ModelForm): class DonationForm(forms.ModelForm):
class Meta: class Meta:

View File

@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2026-01-28 21:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0023_alter_voter_address_street_alter_voter_birthdate_and_more'),
]
operations = [
migrations.AlterField(
model_name='event',
name='name',
field=models.CharField(db_index=True, max_length=255),
),
migrations.AlterUniqueTogether(
name='event',
unique_together={('tenant', 'name')},
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2026-01-29 01:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0024_alter_event_name_alter_event_unique_together'),
]
operations = [
migrations.AddField(
model_name='campaignsettings',
name='twilio_account_sid',
field=models.CharField(blank=True, default='ACcd11acb5095cec6477245d385a2bf127', max_length=100),
),
migrations.AddField(
model_name='campaignsettings',
name='twilio_auth_token',
field=models.CharField(blank=True, default='89ec830d0fa02ab0afa6c76084865713', max_length=100),
),
migrations.AddField(
model_name='campaignsettings',
name='twilio_from_number',
field=models.CharField(blank=True, default='+18556945903', max_length=20),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-01-29 03:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0025_campaignsettings_twilio_account_sid_and_more'),
]
operations = [
migrations.AlterField(
model_name='interaction',
name='date',
field=models.DateTimeField(),
),
]

View File

@ -280,13 +280,16 @@ class VotingRecord(models.Model):
class Event(models.Model): class Event(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='events') tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='events')
name = models.CharField(max_length=255, blank=True) name = models.CharField(max_length=255, db_index=True)
date = models.DateField() date = models.DateField()
start_time = models.TimeField(null=True, blank=True) start_time = models.TimeField(null=True, blank=True)
end_time = models.TimeField(null=True, blank=True) end_time = models.TimeField(null=True, blank=True)
event_type = models.ForeignKey(EventType, on_delete=models.PROTECT, null=True) event_type = models.ForeignKey(EventType, on_delete=models.PROTECT, null=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
class Meta:
unique_together = ('tenant', 'name')
def __str__(self): def __str__(self):
if self.name: if self.name:
return f"{self.name} ({self.date})" return f"{self.name} ({self.date})"
@ -339,7 +342,7 @@ class Interaction(models.Model):
voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='interactions') voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='interactions')
volunteer = models.ForeignKey(Volunteer, on_delete=models.SET_NULL, null=True, blank=True, related_name='interactions') volunteer = models.ForeignKey(Volunteer, on_delete=models.SET_NULL, null=True, blank=True, related_name='interactions')
type = models.ForeignKey(InteractionType, on_delete=models.SET_NULL, null=True) type = models.ForeignKey(InteractionType, on_delete=models.SET_NULL, null=True)
date = models.DateField() date = models.DateTimeField()
description = models.CharField(max_length=255) description = models.CharField(max_length=255)
notes = models.TextField(blank=True) notes = models.TextField(blank=True)
@ -364,6 +367,9 @@ class VoterLikelihood(models.Model):
class CampaignSettings(models.Model): class CampaignSettings(models.Model):
tenant = models.OneToOneField(Tenant, on_delete=models.CASCADE, related_name='settings') tenant = models.OneToOneField(Tenant, on_delete=models.CASCADE, related_name='settings')
donation_goal = models.DecimalField(max_digits=12, decimal_places=2, default=170000.00) donation_goal = models.DecimalField(max_digits=12, decimal_places=2, default=170000.00)
twilio_account_sid = models.CharField(max_length=100, blank=True, default='ACcd11acb5095cec6477245d385a2bf127')
twilio_auth_token = models.CharField(max_length=100, blank=True, default='89ec830d0fa02ab0afa6c76084865713')
twilio_from_number = models.CharField(max_length=20, blank=True, default='+18556945903')
class Meta: class Meta:
verbose_name = 'Campaign Settings' verbose_name = 'Campaign Settings'

View File

@ -171,7 +171,7 @@
</span> </span>
</td> </td>
<td class="py-3 border-0 text-muted small"> <td class="py-3 border-0 text-muted small">
{{ interaction.date|date:"M d, Y" }} {{ interaction.date|date:"M d, Y H:i" }}
</td> </td>
<td class="py-3 border-0 text-muted small"> <td class="py-3 border-0 text-muted small">
{{ interaction.description|truncatechars:50 }} {{ interaction.description|truncatechars:50 }}

View File

@ -88,12 +88,15 @@
<h5 class="mb-0 fw-bold">Search Results ({{ voters.paginator.count }})</h5> <h5 class="mb-0 fw-bold">Search Results ({{ voters.paginator.count }})</h5>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div id="bulk-actions" class="d-none me-2"> <div id="bulk-actions" class="d-none me-2">
<button type="submit" name="action" value="export_selected" class="btn btn-success btn-sm"> <button type="submit" name="action" value="export_selected" class="btn btn-primary btn-sm">
<i class="bi bi-file-earmark-spreadsheet me-1"></i> Export Selected <i class="bi bi-file-earmark-spreadsheet me-1"></i> Export Selected
</button> </button>
<button type="button" class="btn btn-primary btn-sm ms-2" 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> <div>
<button type="submit" name="action" value="export_all" class="btn btn-outline-success btn-sm"> <button type="submit" name="action" value="export_all" class="btn btn-primary btn-sm">
<i class="bi bi-file-earmark-spreadsheet me-1"></i> Export All Results <i class="bi bi-file-earmark-spreadsheet me-1"></i> Export All Results
</button> </button>
</div> </div>
@ -199,6 +202,36 @@
</form> </form>
</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 '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 only be sent to selected voters who have a <strong>Cell Phone</strong> entered. Others will be skipped.</p>
<div id="selected-voters-container">
<input type="hidden" name="client_time" id="client_time">
<!-- Voter 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> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const selectAll = document.getElementById('select-all'); const selectAll = document.getElementById('select-all');
@ -230,6 +263,32 @@ document.addEventListener('DOMContentLoaded', function() {
updateBulkActionsVisibility(); updateBulkActionsVisibility();
}); });
}); });
const smsModal = document.getElementById('smsModal');
if (smsModal) {
smsModal.addEventListener('show.bs.modal', function () {
const container = document.getElementById('selected-voters-container');
container.innerHTML = '';
checkboxes.forEach(cb => {
if (cb.checked) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_voters';
input.value = cb.value;
container.appendChild(input);
}
});
// Set current browser time
const clientTimeInput = document.getElementById("client_time");
if (clientTimeInput) {
const now = new Date();
// Format as YYYY-MM-DDTHH:mm:ss
const offset = now.getTimezoneOffset() * 60000;
const localISOTime = (new Date(now - offset)).toISOString().slice(0, 19);
clientTimeInput.value = localISOTime;
}
});
}
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -231,7 +231,7 @@
<tbody> <tbody>
{% for interaction in interactions %} {% for interaction in interactions %}
<tr> <tr>
<td class="ps-4 text-nowrap">{{ interaction.date|date:"M d, Y" }}</td> <td class="ps-4 text-nowrap">{{ interaction.date|date:"M d, Y H:i" }}</td>
<td><span class="badge bg-light text-dark border">{{ interaction.type.name }}</span></td> <td><span class="badge bg-light text-dark border">{{ interaction.type.name }}</span></td>
<td>{{ interaction.description }}</td> <td>{{ interaction.description }}</td>
<td class="small text-muted">{{ interaction.notes|truncatechars:30 }}</td> <td class="small text-muted">{{ interaction.notes|truncatechars:30 }}</td>
@ -338,6 +338,7 @@
<thead class="bg-light"> <thead class="bg-light">
<tr> <tr>
<th class="ps-4">Date</th> <th class="ps-4">Date</th>
<th>Event Name</th>
<th>Event Type</th> <th>Event Type</th>
<th>Status</th> <th>Status</th>
<th>Description</th> <th>Description</th>
@ -348,6 +349,7 @@
{% for participation in event_participations %} {% for participation in event_participations %}
<tr> <tr>
<td class="ps-4 text-nowrap">{{ participation.event.date|date:"M d, Y" }}</td> <td class="ps-4 text-nowrap">{{ participation.event.date|date:"M d, Y" }}</td>
<td><strong>{{ participation.event.name|default:"(No Name)" }}</strong></td>
<td><span class="badge bg-light text-dark border">{{ participation.event.event_type.name }}</span></td> <td><span class="badge bg-light text-dark border">{{ participation.event.event_type.name }}</span></td>
<td> <td>
{% if participation.participation_status.name|lower == 'attended' %} {% if participation.participation_status.name|lower == 'attended' %}
@ -599,7 +601,7 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-medium">Date</label> <label class="form-label fw-medium">Date</label>
<input type="date" name="date" class="form-control" value="{{ interaction.date|date:'Y-m-d' }}"> <input type="datetime-local" name="date" class="form-control" value="{{ interaction.date|date:'Y-m-d\TH:i' }}">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-medium">Description</label> <label class="form-label fw-medium">Description</label>

View File

@ -7,6 +7,7 @@ urlpatterns = [
path('voters/', views.voter_list, name='voter_list'), path('voters/', views.voter_list, name='voter_list'),
path('voters/advanced-search/', views.voter_advanced_search, name='voter_advanced_search'), path('voters/advanced-search/', views.voter_advanced_search, name='voter_advanced_search'),
path('voters/export-csv/', views.export_voters_csv, name='export_voters_csv'), path('voters/export-csv/', views.export_voters_csv, name='export_voters_csv'),
path('voters/bulk-sms/', views.bulk_send_sms, name='bulk_send_sms'),
path('voters/<int:voter_id>/', views.voter_detail, name='voter_detail'), path('voters/<int:voter_id>/', views.voter_detail, name='voter_detail'),
path('voters/<int:voter_id>/edit/', views.voter_edit, name='voter_edit'), path('voters/<int:voter_id>/edit/', views.voter_edit, name='voter_edit'),
path('voters/<int:voter_id>/delete/', views.voter_delete, name='voter_delete'), path('voters/<int:voter_id>/delete/', views.voter_delete, name='voter_delete'),

View File

@ -1,3 +1,7 @@
import base64
import re
import urllib.parse
import urllib.request
import csv import csv
import io import io
from django.http import JsonResponse, HttpResponse from django.http import JsonResponse, HttpResponse
@ -540,6 +544,7 @@ def export_voters_csv(request):
]) ])
return response return response
def voter_delete(request, voter_id): def voter_delete(request, voter_id):
""" """
Delete a voter profile. Delete a voter profile.
@ -554,3 +559,106 @@ def voter_delete(request, voter_id):
return redirect('voter_list') return redirect('voter_list')
return redirect('voter_detail', voter_id=voter.id) return redirect('voter_detail', voter_id=voter.id)
def bulk_send_sms(request):
"""
Sends bulk SMS to selected voters using Twilio API.
"""
if request.method != 'POST':
return redirect('voter_advanced_search')
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('voter_advanced_search')
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('voter_advanced_search')
voter_ids = request.POST.getlist('selected_voters')
message_body = request.POST.get('message_body')
client_time_str = request.POST.get('client_time')
interaction_date = timezone.now()
if client_time_str:
try:
from datetime import datetime
interaction_date = datetime.fromisoformat(client_time_str)
if timezone.is_naive(interaction_date):
interaction_date = timezone.make_aware(interaction_date)
except Exception as e:
logger.warning(f'Failed to parse client_time {client_time_str}: {e}')
if not message_body:
messages.error(request, "Message body cannot be empty.")
return redirect('voter_advanced_search')
voters = Voter.objects.filter(tenant=tenant, id__in=voter_ids, phone_type='cell').exclude(phone='')
if not voters.exists():
messages.warning(request, "No voters with a valid cell phone number were selected.")
return redirect('voter_advanced_search')
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"
# Get or create interaction type for SMS
interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="SMS Text")
for voter in voters:
# Format phone to E.164 (assume US +1)
digits = re.sub(r'\D', '', str(voter.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
# Log interaction
Interaction.objects.create(
voter=voter,
type=interaction_type,
date=interaction_date,
description='Mass SMS Text',
notes=message_body
)
else:
fail_count += 1
except Exception as e:
logger.error(f"Error sending SMS to {voter.phone}: {e}")
fail_count += 1
messages.success(request, f"Bulk SMS process completed: {success_count} successful, {fail_count} failed/skipped.")
return redirect('voter_advanced_search')

View File

@ -1,4 +0,0 @@
if request.GET.get("yard_sign") == "true":
voters = voters.filter(Q(yard_sign="wants") | Q(yard_sign="has"))
if request.GET.get("window_sticker") == "true":
voters = voters.filter(Q(window_sticker="wants") | Q(window_sticker="has"))

32
core_admin_patch.py Normal file
View File

@ -0,0 +1,32 @@
import sys
with open('core/admin.py', 'r') as f:
content = f.read()
old_block = """ defaults = {}
if participation_status_val and participation_status_val.strip():
defaults = {}
if participation_status_val and participation_status_val.strip():
status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name=participation_status_val.strip())
defaults['participation_status'] = status_obj
else:
# Default to 'Invited' if not specified
status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Invited')
defaults['participation_status'] = status_obj"""
new_block = """ defaults = {}
if participation_status_val and participation_status_val.strip():
status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name=participation_status_val.strip())
defaults['participation_status'] = status_obj
else:
# Default to 'Invited' if not specified
status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Invited')
defaults['participation_status'] = status_obj"""
if old_block in content:
new_content = content.replace(old_block, new_block)
with open('core/admin.py', 'w') as f:
f.write(new_content)
print("Patch applied successfully")
else:
print("Old block not found")

View File

@ -0,0 +1,44 @@
import sys
with open('core/templates/core/voter_detail.html', 'r') as f:
content = f.read()
old_thead = ''' <tr>
<th class="ps-4">Date</th>
<th>Event Type</th>
<th>Status</th>
<th>Description</th>
<th class="pe-4 text-end">Actions</th>
</tr>'''
new_thead = ''' <tr>
<th class="ps-4">Date</th>
<th>Event Name</th>
<th>Event Type</th>
<th>Status</th>
<th>Description</th>
<th class="pe-4 text-end">Actions</th>
</tr>'''
if old_thead in content:
content = content.replace(old_thead, new_thead)
else:
print("Warning: old_thead not found")
old_tbody = ''' {% for participation in event_participations %}
<tr>
<td class="ps-4 text-nowrap">{{ participation.event.date|date:"M d, Y" }}</td>
<td><span class="badge bg-light text-dark border">{{ participation.event.event_type.name }}</span></td>'''
new_tbody = ''' {% for participation in event_participations %}
<tr>
<td class="ps-4 text-nowrap">{{ participation.event.date|date:"M d, Y" }}</td>
<td><strong>{{ participation.event.name|default:"(No Name)" }}</strong></td>
<td><span class="badge bg-light text-dark border">{{ participation.event.event_type.name }}</span></td>'''
if old_tbody in content:
content = content.replace(old_tbody, new_tbody)
else:
print("Warning: old_tbody not found")
with open('core/templates/core/voter_detail.html', 'w') as f:
f.write(content)