2.0
This commit is contained in:
parent
ae3d7f9f2e
commit
d8bf0cd82c
0
assets/.gitkeep
Normal file
0
assets/.gitkeep
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -63,10 +63,8 @@ EVENT_MAPPABLE_FIELDS = [
|
||||
|
||||
EVENT_PARTICIPATION_MAPPABLE_FIELDS = [
|
||||
('voter_id', 'Voter ID'),
|
||||
('event_id', 'Event ID'),
|
||||
('event_date', 'Event Date'),
|
||||
('event_type', 'Event Type (Name)'),
|
||||
('participation_status', 'Participation Type'),
|
||||
('event_name', 'Event Name'),
|
||||
('participation_status', 'Participation Status'),
|
||||
]
|
||||
|
||||
DONATION_MAPPABLE_FIELDS = [
|
||||
@ -183,6 +181,7 @@ class ParticipationStatusAdmin(admin.ModelAdmin):
|
||||
class InterestAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'tenant')
|
||||
list_filter = ('tenant',)
|
||||
fields = ('tenant', 'donation_goal', 'twilio_account_sid', 'twilio_auth_token', 'twilio_from_number')
|
||||
search_fields = ('name',)
|
||||
|
||||
class VotingRecordInline(admin.TabularInline):
|
||||
@ -651,10 +650,10 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
if end_time and end_time.strip():
|
||||
defaults['end_time'] = end_time
|
||||
|
||||
defaults['date'] = date
|
||||
defaults['event_type'] = event_type
|
||||
Event.objects.update_or_create(
|
||||
tenant=tenant,
|
||||
date=date,
|
||||
event_type=event_type,
|
||||
name=name or '',
|
||||
defaults=defaults
|
||||
)
|
||||
@ -725,6 +724,7 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant', 'user')
|
||||
list_filter = ('tenant',)
|
||||
fields = ('tenant', 'donation_goal', 'twilio_account_sid', 'twilio_auth_token', 'twilio_from_number')
|
||||
search_fields = ('first_name', 'last_name', 'email', 'phone')
|
||||
inlines = [VolunteerEventInline, InteractionInline]
|
||||
filter_horizontal = ('interests',)
|
||||
@ -921,18 +921,14 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
for row in reader:
|
||||
total_count += 1
|
||||
voter_id = row.get(mapping.get('voter_id'))
|
||||
event_id = row.get(mapping.get('event_id'))
|
||||
event_date = row.get(mapping.get('event_date'))
|
||||
event_type_name = row.get(mapping.get('event_type'))
|
||||
event_name = row.get(mapping.get('event_name'))
|
||||
|
||||
exists = False
|
||||
if voter_id:
|
||||
try:
|
||||
voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)
|
||||
if event_id:
|
||||
exists = EventParticipation.objects.filter(voter=voter, event_id=event_id).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()
|
||||
if event_name:
|
||||
exists = EventParticipation.objects.filter(voter=voter, event__name=event_name).exists()
|
||||
except Voter.DoesNotExist:
|
||||
pass
|
||||
|
||||
@ -1004,25 +1000,15 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
continue
|
||||
|
||||
event = None
|
||||
event_id = row.get(mapping.get('event_id')) if mapping.get('event_id') else None
|
||||
if event_id:
|
||||
event_name = row.get(mapping.get('event_name')) if mapping.get('event_name') else None
|
||||
if event_name:
|
||||
try:
|
||||
event = Event.objects.get(id=event_id, tenant=tenant)
|
||||
event = Event.objects.get(tenant=tenant, name=event_name)
|
||||
except Event.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
if not event:
|
||||
event_date = row.get(mapping.get('event_date')) if mapping.get('event_date') else None
|
||||
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)"
|
||||
error_msg = "Event not found (check Event Name)"
|
||||
logger.error(error_msg)
|
||||
row["Import Error"] = error_msg
|
||||
failed_rows.append(row)
|
||||
@ -1031,11 +1017,12 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
|
||||
defaults = {}
|
||||
if participation_status_val and participation_status_val.strip():
|
||||
if participation_status_val in dict(EventParticipation.PARTICIPATION_TYPE_CHOICES):
|
||||
defaults['participation_status'] = participation_status_val
|
||||
else:
|
||||
defaults['participation_status'] = 'invited'
|
||||
|
||||
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
|
||||
EventParticipation.objects.update_or_create(
|
||||
event=event,
|
||||
voter=voter,
|
||||
@ -1790,5 +1777,6 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||
|
||||
@admin.register(CampaignSettings)
|
||||
class CampaignSettingsAdmin(admin.ModelAdmin):
|
||||
list_display = ('tenant', 'donation_goal')
|
||||
list_display = ('tenant', 'donation_goal', 'twilio_from_number')
|
||||
list_filter = ('tenant',)
|
||||
fields = ('tenant', 'donation_goal', 'twilio_account_sid', 'twilio_auth_token', 'twilio_from_number')
|
||||
|
||||
@ -86,7 +86,7 @@ class InteractionForm(forms.ModelForm):
|
||||
model = Interaction
|
||||
fields = ['type', 'date', 'description', 'notes']
|
||||
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}),
|
||||
}
|
||||
|
||||
@ -97,6 +97,8 @@ class InteractionForm(forms.ModelForm):
|
||||
for field in self.fields.values():
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
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 Meta:
|
||||
@ -223,4 +225,4 @@ class VolunteerImportForm(forms.Form):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
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'})
|
||||
|
||||
@ -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')},
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
18
core/migrations/0026_alter_interaction_date.py
Normal file
18
core/migrations/0026_alter_interaction_date.py
Normal 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(),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -280,13 +280,16 @@ class VotingRecord(models.Model):
|
||||
|
||||
class Event(models.Model):
|
||||
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()
|
||||
start_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)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('tenant', 'name')
|
||||
|
||||
def __str__(self):
|
||||
if self.name:
|
||||
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')
|
||||
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)
|
||||
date = models.DateField()
|
||||
date = models.DateTimeField()
|
||||
description = models.CharField(max_length=255)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
@ -364,10 +367,13 @@ class VoterLikelihood(models.Model):
|
||||
class CampaignSettings(models.Model):
|
||||
tenant = models.OneToOneField(Tenant, on_delete=models.CASCADE, related_name='settings')
|
||||
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:
|
||||
verbose_name = 'Campaign Settings'
|
||||
verbose_name_plural = 'Campaign Settings'
|
||||
|
||||
def __str__(self):
|
||||
return f'Settings for {self.tenant.name}'
|
||||
return f'Settings for {self.tenant.name}'
|
||||
|
||||
@ -171,7 +171,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<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 class="py-3 border-0 text-muted small">
|
||||
{{ interaction.description|truncatechars:50 }}
|
||||
|
||||
@ -88,12 +88,15 @@
|
||||
<h5 class="mb-0 fw-bold">Search Results ({{ voters.paginator.count }})</h5>
|
||||
<div class="d-flex align-items-center">
|
||||
<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
|
||||
</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>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@ -199,6 +202,36 @@
|
||||
</form>
|
||||
</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>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const selectAll = document.getElementById('select-all');
|
||||
@ -230,6 +263,32 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@ -231,7 +231,7 @@
|
||||
<tbody>
|
||||
{% for interaction in interactions %}
|
||||
<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>{{ interaction.description }}</td>
|
||||
<td class="small text-muted">{{ interaction.notes|truncatechars:30 }}</td>
|
||||
@ -338,6 +338,7 @@
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">Date</th>
|
||||
<th>Event Name</th>
|
||||
<th>Event Type</th>
|
||||
<th>Status</th>
|
||||
<th>Description</th>
|
||||
@ -348,6 +349,7 @@
|
||||
{% 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>
|
||||
<td>
|
||||
{% if participation.participation_status.name|lower == 'attended' %}
|
||||
@ -599,7 +601,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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 class="mb-3">
|
||||
<label class="form-label fw-medium">Description</label>
|
||||
|
||||
@ -7,6 +7,7 @@ urlpatterns = [
|
||||
path('voters/', views.voter_list, name='voter_list'),
|
||||
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/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>/edit/', views.voter_edit, name='voter_edit'),
|
||||
path('voters/<int:voter_id>/delete/', views.voter_delete, name='voter_delete'),
|
||||
|
||||
108
core/views.py
108
core/views.py
@ -1,3 +1,7 @@
|
||||
import base64
|
||||
import re
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import csv
|
||||
import io
|
||||
from django.http import JsonResponse, HttpResponse
|
||||
@ -540,6 +544,7 @@ def export_voters_csv(request):
|
||||
])
|
||||
|
||||
return response
|
||||
|
||||
def voter_delete(request, voter_id):
|
||||
"""
|
||||
Delete a voter profile.
|
||||
@ -554,3 +559,106 @@ def voter_delete(request, voter_id):
|
||||
return redirect('voter_list')
|
||||
|
||||
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')
|
||||
@ -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
32
core_admin_patch.py
Normal 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")
|
||||
44
patch_voter_detail_events.py
Normal file
44
patch_voter_detail_events.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user