diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 9619ec4..4bd0518 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index 48580fa..b895f06 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 5bcaf3e..438a443 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index be74b0a..50a0514 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 8695930..2d879dd 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 1350ddf..78b901e 100644 --- a/core/admin.py +++ b/core/admin.py @@ -39,6 +39,7 @@ VOTER_MAPPABLE_FIELDS = [ ('zip_code', 'Zip Code'), ('county', 'County'), ('phone', 'Phone'), + ('notes', 'Notes'), ('phone_type', 'Phone Type'), ('email', 'Email'), ('district', 'District'), @@ -50,6 +51,8 @@ VOTER_MAPPABLE_FIELDS = [ ('window_sticker', 'Window Sticker'), ('latitude', 'Latitude'), ('longitude', 'Longitude'), + ('secondary_phone', 'Secondary Phone'), + ('secondary_phone_type', 'Secondary Phone Type'), ] EVENT_MAPPABLE_FIELDS = [ @@ -59,6 +62,13 @@ EVENT_MAPPABLE_FIELDS = [ ('end_time', 'End Time'), ('event_type', 'Event Type (Name)'), ('description', 'Description'), + ('location_name', 'Location Name'), + ('address', 'Address'), + ('city', 'City'), + ('state', 'State'), + ('zip_code', 'Zip Code'), + ('latitude', 'Latitude'), + ('longitude', 'Longitude'), ] EVENT_PARTICIPATION_MAPPABLE_FIELDS = [ @@ -76,6 +86,7 @@ DONATION_MAPPABLE_FIELDS = [ INTERACTION_MAPPABLE_FIELDS = [ ('voter_id', 'Voter ID'), + ('volunteer_email', 'Volunteer Email'), ('date', 'Date'), ('type', 'Interaction Type (Name)'), ('description', 'Description'), @@ -88,6 +99,7 @@ VOLUNTEER_MAPPABLE_FIELDS = [ ('last_name', 'Last Name'), ('email', 'Email'), ('phone', 'Phone'), + ('notes', 'Notes'), ] VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [ @@ -318,7 +330,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): valid_fields = {f.name for f in Voter._meta.get_fields()} mapped_fields = {f for f in mapping.keys() if f in valid_fields} # Ensure derived/special fields are in update_fields - update_fields = list(mapped_fields | {"address", "phone", "longitude", "latitude"}) + update_fields = list(mapped_fields | {"address", "phone", "secondary_phone", "secondary_phone_type", "longitude", "latitude"}) if "voter_id" in update_fields: update_fields.remove("voter_id") def chunk_reader(reader, size): @@ -412,7 +424,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): if val_lower in window_sticker_choices: val = val_lower elif val_lower in window_sticker_reverse: val = window_sticker_reverse[val_lower] else: val = "none" - elif field_name == "phone_type": + elif field_name in ["phone_type", "secondary_phone_type"]: val_lower = val.lower() if val_lower in phone_type_choices: val = val_lower @@ -431,6 +443,11 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): if voter.phone != old_phone: changed = True + old_secondary_phone = voter.secondary_phone + voter.secondary_phone = format_phone_number(voter.secondary_phone) + if voter.secondary_phone != old_secondary_phone: + changed = True + if voter.longitude: try: new_lon = Decimal(str(voter.longitude)[:12]) @@ -527,9 +544,9 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): return render(request, "admin/import_csv.html", context) @admin.register(Event) class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): - list_display = ('id', 'name', 'event_type', 'date', 'start_time', 'end_time', 'tenant') - list_filter = ('tenant', 'date', 'event_type') - search_fields = ('name', 'description') + list_display = ('id', 'name', 'event_type', 'date', 'location_name', 'city', 'state', 'tenant') + list_filter = ('tenant', 'date', 'event_type', 'city', 'state') + search_fields = ('name', 'description', 'location_name', 'address', 'city', 'state', 'zip_code') change_list_template = "admin/event_change_list.html" def changelist_view(self, request, extra_context=None): @@ -585,7 +602,7 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): preview_data.append({ 'action': action, 'identifier': f"{event_name or 'No Name'} ({date} - {event_type_name})", - 'details': row.get(mapping.get('description', '')) or '' + 'details': f"{row.get(mapping.get('city', '')) or ''}, {row.get(mapping.get('state', '')) or ''}" }) context = self.admin_site.each_context(request) context.update({ @@ -625,9 +642,16 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): date = row.get(mapping.get('date')) if mapping.get('date') else None event_type_name = row.get(mapping.get('event_type')) if mapping.get('event_type') else None description = row.get(mapping.get('description')) if mapping.get('description') else None + location_name = row.get(mapping.get('location_name')) if mapping.get('location_name') else None name = row.get(mapping.get('name')) if mapping.get('name') else None start_time = row.get(mapping.get('start_time')) if mapping.get('start_time') else None end_time = row.get(mapping.get('end_time')) if mapping.get('end_time') else None + address = row.get(mapping.get('address')) if mapping.get('address') else None + city = row.get(mapping.get('city')) if mapping.get('city') else None + state = row.get(mapping.get('state')) if mapping.get('state') else None + zip_code = row.get(mapping.get('zip_code')) if mapping.get('zip_code') else None + latitude = row.get(mapping.get('latitude')) if mapping.get('latitude') else None + longitude = row.get(mapping.get('longitude')) if mapping.get('longitude') else None if not date or not event_type_name: row["Import Error"] = "Missing date or event type" @@ -643,12 +667,26 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): defaults = {} if description and description.strip(): defaults['description'] = description + if location_name and location_name.strip(): + defaults['location_name'] = location_name if name and name.strip(): defaults['name'] = name if start_time and start_time.strip(): defaults['start_time'] = start_time if end_time and end_time.strip(): defaults['end_time'] = end_time + if address and address.strip(): + defaults['address'] = address + if city and city.strip(): + defaults['city'] = city + if state and state.strip(): + defaults['state'] = state + if zip_code and zip_code.strip(): + defaults['zip_code'] = zip_code + if latitude and latitude.strip(): + defaults['latitude'] = latitude + if longitude and longitude.strip(): + defaults['longitude'] = longitude defaults['date'] = date defaults['event_type'] = event_type @@ -677,10 +715,6 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): except Exception as e: self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) return redirect("..") - except Exception as e: - print(f"DEBUG: Voter import failed: {e}") - self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) - return redirect("..") else: form = EventImportForm(request.POST, request.FILES) if form.is_valid(): @@ -724,7 +758,7 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant', 'user') list_filter = ('tenant',) - fields = ('tenant', 'user', 'first_name', 'last_name', 'email', 'phone', 'interests') + fields = ('tenant', 'user', 'first_name', 'last_name', 'email', 'phone', 'notes', 'interests') search_fields = ('first_name', 'last_name', 'email', 'phone') inlines = [VolunteerEventInline, InteractionInline] filter_horizontal = ('interests',) @@ -845,10 +879,6 @@ class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin): except Exception as e: self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) return redirect("..") - except Exception as e: - print(f"DEBUG: Voter import failed: {e}") - self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) - return redirect("..") else: form = VolunteerImportForm(request.POST, request.FILES) if form.is_valid(): @@ -1048,10 +1078,6 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin): except Exception as e: self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) return redirect("..") - except Exception as e: - print(f"DEBUG: Voter import failed: {e}") - self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) - return redirect("..") else: form = EventParticipationImportForm(request.POST, request.FILES) if form.is_valid(): @@ -1241,10 +1267,6 @@ class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin): except Exception as e: self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) return redirect("..") - except Exception as e: - print(f"DEBUG: Voter import failed: {e}") - self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) - return redirect("..") else: form = DonationImportForm(request.POST, request.FILES) if form.is_valid(): @@ -1388,6 +1410,7 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin): date = row.get(mapping.get('date')) type_name = row.get(mapping.get('type')) + volunteer_email = row.get(mapping.get('volunteer_email')) description = row.get(mapping.get('description')) notes = row.get(mapping.get('notes')) @@ -1397,6 +1420,12 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin): errors += 1 continue + volunteer = None + if volunteer_email and volunteer_email.strip(): + try: + volunteer = Volunteer.objects.get(tenant=tenant, email=volunteer_email.strip()) + except Volunteer.DoesNotExist: + pass interaction_type = None if type_name and type_name.strip(): interaction_type, _ = InteractionType.objects.get_or_create( @@ -1405,6 +1434,8 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin): ) defaults = {} + if volunteer: + defaults['volunteer'] = volunteer if interaction_type: defaults['type'] = interaction_type if description and description.strip(): @@ -1437,10 +1468,6 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin): except Exception as e: self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) return redirect("..") - except Exception as e: - print(f"DEBUG: Voter import failed: {e}") - self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) - return redirect("..") else: form = InteractionImportForm(request.POST, request.FILES) if form.is_valid(): diff --git a/core/forms.py b/core/forms.py index 74dc8ed..9072933 100644 --- a/core/forms.py +++ b/core/forms.py @@ -85,7 +85,7 @@ class AdvancedVoterSearchForm(forms.Form): class InteractionForm(forms.ModelForm): class Meta: model = Interaction - fields = ['type', 'date', 'description', 'notes'] + fields = ['type', 'volunteer', 'date', 'description', 'notes'] widgets = { 'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'), 'notes': forms.Textarea(attrs={'rows': 2}), @@ -95,9 +95,11 @@ class InteractionForm(forms.ModelForm): super().__init__(*args, **kwargs) if tenant: self.fields['type'].queryset = InteractionType.objects.filter(tenant=tenant, is_active=True) + self.fields['volunteer'].queryset = Volunteer.objects.filter(tenant=tenant) for field in self.fields.values(): field.widget.attrs.update({'class': 'form-control'}) self.fields['type'].widget.attrs.update({'class': 'form-select'}) + self.fields['volunteer'].widget.attrs.update({'class': 'form-select'}) if self.instance and self.instance.date: self.initial['date'] = self.instance.date.strftime('%Y-%m-%dT%H:%M') @@ -168,7 +170,7 @@ class EventParticipantAddForm(forms.ModelForm): class EventForm(forms.ModelForm): class Meta: model = Event - fields = ['name', 'date', 'start_time', 'end_time', 'event_type', 'description'] + fields = ['name', 'date', 'start_time', 'end_time', 'event_type', 'description', 'location_name', 'address', 'city', 'state', 'zip_code', 'latitude', 'longitude'] widgets = { 'date': forms.DateInput(attrs={'type': 'date'}), 'start_time': forms.TimeInput(attrs={'type': 'time'}), @@ -250,7 +252,8 @@ class VolunteerImportForm(forms.Form): class VolunteerForm(forms.ModelForm): class Meta: model = Volunteer - fields = ['first_name', 'last_name', 'email', 'phone', 'interests'] + fields = ['first_name', 'last_name', 'email', 'phone', 'notes', 'interests'] + widgets = {'notes': forms.Textarea(attrs={'rows': 3})} def __init__(self, *args, tenant=None, **kwargs): super().__init__(*args, **kwargs) @@ -276,3 +279,20 @@ class VolunteerEventForm(forms.ModelForm): for field in self.fields.values(): field.widget.attrs.update({'class': 'form-control'}) self.fields['event'].widget.attrs.update({'class': 'form-select'}) + +class VolunteerEventAddForm(forms.ModelForm): + class Meta: + model = VolunteerEvent + fields = ['volunteer', 'role'] + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + volunteer_id = self.data.get('volunteer') or self.initial.get('volunteer') + if volunteer_id: + self.fields['volunteer'].queryset = Volunteer.objects.filter(tenant=tenant, id=volunteer_id) + else: + self.fields['volunteer'].queryset = Volunteer.objects.none() + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['volunteer'].widget.attrs.update({'class': 'form-select'}) diff --git a/core/migrations/0028_volunteer_notes.py b/core/migrations/0028_volunteer_notes.py new file mode 100644 index 0000000..c02db8a --- /dev/null +++ b/core/migrations/0028_volunteer_notes.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-29 21:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0027_voter_secondary_phone_voter_secondary_phone_type'), + ] + + operations = [ + migrations.AddField( + model_name='volunteer', + name='notes', + field=models.TextField(blank=True), + ), + ] diff --git a/core/migrations/0029_event_address_event_city_event_latitude_and_more.py b/core/migrations/0029_event_address_event_city_event_latitude_and_more.py new file mode 100644 index 0000000..3c0d514 --- /dev/null +++ b/core/migrations/0029_event_address_event_city_event_latitude_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.7 on 2026-01-29 22:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0028_volunteer_notes'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='address', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='event', + name='city', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='event', + name='latitude', + field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True), + ), + migrations.AddField( + model_name='event', + name='longitude', + field=models.DecimalField(blank=True, decimal_places=9, max_digits=12, null=True), + ), + migrations.AddField( + model_name='event', + name='state', + field=models.CharField(blank=True, max_length=2), + ), + migrations.AddField( + model_name='event', + name='zip_code', + field=models.CharField(blank=True, max_length=20), + ), + ] diff --git a/core/migrations/0030_event_location_name.py b/core/migrations/0030_event_location_name.py new file mode 100644 index 0000000..b4c5122 --- /dev/null +++ b/core/migrations/0030_event_location_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-29 22:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0029_event_address_event_city_event_latitude_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='location_name', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/core/migrations/__pycache__/0028_volunteer_notes.cpython-311.pyc b/core/migrations/__pycache__/0028_volunteer_notes.cpython-311.pyc new file mode 100644 index 0000000..730fd1a Binary files /dev/null and b/core/migrations/__pycache__/0028_volunteer_notes.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0029_event_address_event_city_event_latitude_and_more.cpython-311.pyc b/core/migrations/__pycache__/0029_event_address_event_city_event_latitude_and_more.cpython-311.pyc new file mode 100644 index 0000000..8172ae5 Binary files /dev/null and b/core/migrations/__pycache__/0029_event_address_event_city_event_latitude_and_more.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0030_event_location_name.cpython-311.pyc b/core/migrations/__pycache__/0030_event_location_name.cpython-311.pyc new file mode 100644 index 0000000..65611fd Binary files /dev/null and b/core/migrations/__pycache__/0030_event_location_name.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index e86ca70..8e6a38e 100644 --- a/core/models.py +++ b/core/models.py @@ -289,6 +289,13 @@ class Event(models.Model): end_time = models.TimeField(null=True, blank=True) event_type = models.ForeignKey(EventType, on_delete=models.PROTECT, null=True) description = models.TextField(blank=True) + location_name = models.CharField(max_length=255, blank=True) + address = models.CharField(max_length=255, blank=True) + city = models.CharField(max_length=100, blank=True) + state = models.CharField(max_length=2, blank=True) + zip_code = models.CharField(max_length=20, blank=True) + latitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) + longitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) class Meta: unique_together = ('tenant', 'name') @@ -307,6 +314,7 @@ class Volunteer(models.Model): phone = models.CharField(max_length=20, blank=True) interests = models.ManyToManyField(Interest, blank=True, related_name='volunteers') assigned_events = models.ManyToManyField(Event, through='VolunteerEvent', related_name='assigned_volunteers') + notes = models.TextField(blank=True) def save(self, *args, **kwargs): # Auto-format phone number @@ -379,4 +387,4 @@ class CampaignSettings(models.Model): verbose_name_plural = 'Campaign Settings' def __str__(self): - return f'Settings for {self.tenant.name}' + return f'Settings for {self.tenant.name}' \ No newline at end of file diff --git a/core/templates/core/event_detail.html b/core/templates/core/event_detail.html index 3058175..2b5bda5 100644 --- a/core/templates/core/event_detail.html +++ b/core/templates/core/event_detail.html @@ -13,7 +13,10 @@

{{ event.name|default:event.event_type }}

- Edit Event Info + Edit Event Info + @@ -24,7 +27,7 @@
-
+
Event Details
@@ -46,12 +49,75 @@ {% endif %}
+
+ + {% if event.location_name %}{{ event.location_name }}
{% endif %} + + {% if event.address %} + {{ event.address }}
+ {{ event.city }}{% if event.city and event.state %}, {% endif %}{{ event.state }} {{ event.zip_code }} + {% elif event.city or event.state or event.zip_code %} + {{ event.city }}{% if event.city and event.state %}, {% endif %}{{ event.state }} {{ event.zip_code }} + {% else %} + No location provided. + {% endif %} +
+ {% if event.latitude and event.longitude %} +
+ {{ event.latitude }}, {{ event.longitude }} +
+ {% endif %} +

{{ event.description|default:"No description provided." }}

+ + +
+
+
Volunteers ({{ volunteers.count }})
+
+
+ + + + + + + + + + {% for v in volunteers %} + + + + + + {% empty %} + + + + {% endfor %} + +
VolunteerRole
+ + {{ v.volunteer.first_name }} {{ v.volunteer.last_name }} + + {{ v.role }} +
+ {% csrf_token %} + +
+
+ No volunteers assigned. +
+
+
@@ -171,8 +237,55 @@
+ + + diff --git a/core/templates/core/event_edit.html b/core/templates/core/event_edit.html new file mode 100644 index 0000000..e0815ef --- /dev/null +++ b/core/templates/core/event_edit.html @@ -0,0 +1,135 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
+
+ +

{% if is_create %}Create New Event{% else %}Edit Event{% endif %}

+
+ +
+
+
+
+
+ {% csrf_token %} +
+
+ + {{ form.name }} + {% if form.name.errors %} +
{{ form.name.errors }}
+ {% endif %} +
+
+ + {{ form.event_type }} + {% if form.event_type.errors %} +
{{ form.event_type.errors }}
+ {% endif %} +
+
+ + {{ form.date }} + {% if form.date.errors %} +
{{ form.date.errors }}
+ {% endif %} +
+
+ + {{ form.start_time }} + {% if form.start_time.errors %} +
{{ form.start_time.errors }}
+ {% endif %} +
+
+ + {{ form.end_time }} + {% if form.end_time.errors %} +
{{ form.end_time.errors }}
+ {% endif %} +
+
+ + {{ form.description }} + {% if form.description.errors %} +
{{ form.description.errors }}
+ {% endif %} +
+ +
+
Location Information
+ +
+ + {{ form.location_name }} + {% if form.location_name.errors %} +
{{ form.location_name.errors }}
+ {% endif %} +
+
+ + {{ form.address }} + {% if form.address.errors %} +
{{ form.address.errors }}
+ {% endif %} +
+
+ + {{ form.city }} + {% if form.city.errors %} +
{{ form.city.errors }}
+ {% endif %} +
+
+ + {{ form.state }} + {% if form.state.errors %} +
{{ form.state.errors }}
+ {% endif %} +
+
+ + {{ form.zip_code }} + {% if form.zip_code.errors %} +
{{ form.zip_code.errors }}
+ {% endif %} +
+
+ + {{ form.latitude }} + {% if form.latitude.errors %} +
{{ form.latitude.errors }}
+ {% endif %} +
+
+ + {{ form.longitude }} + {% if form.longitude.errors %} +
{{ form.longitude.errors }}
+ {% endif %} +
+ +
+ + Cancel +
+
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/core/templates/core/event_list.html b/core/templates/core/event_list.html index 369fd5d..f865152 100644 --- a/core/templates/core/event_list.html +++ b/core/templates/core/event_list.html @@ -5,7 +5,7 @@ @@ -18,6 +18,7 @@ Type Date Time + Location Actions @@ -39,13 +40,20 @@ - {% endif %} + + {% if event.city or event.state %} + {{ event.city }}{% if event.city and event.state %}, {% endif %}{{ event.state }} + {% else %} + - + {% endif %} + View Details {% empty %} - +

No events found for this campaign.

@@ -55,4 +63,4 @@
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/volunteer_detail.html b/core/templates/core/volunteer_detail.html index f31a821..f4c582c 100644 --- a/core/templates/core/volunteer_detail.html +++ b/core/templates/core/volunteer_detail.html @@ -89,6 +89,10 @@ {{ form.interests }} +
+ + {{ form.notes }} +
Cancel diff --git a/core/templates/core/volunteer_list.html b/core/templates/core/volunteer_list.html index a40995a..f37eb25 100644 --- a/core/templates/core/volunteer_list.html +++ b/core/templates/core/volunteer_list.html @@ -12,22 +12,43 @@
-
+
+
+ +
- +
+
+
Volunteers ({{ volunteers.paginator.count }})
+
+ +
+
- + + @@ -38,6 +59,9 @@ {% for volunteer in volunteers %} + {% empty %} - @@ -73,12 +97,12 @@
    {% if volunteers.has_previous %}
  • - +
  • - +
  • @@ -88,12 +112,12 @@ {% if volunteers.has_next %}
  • - +
  • - +
  • @@ -104,4 +128,84 @@ {% endif %} + + + + + {% endblock %} diff --git a/core/templates/core/voter_detail.html b/core/templates/core/voter_detail.html index 4e4c1d2..3081e2a 100644 --- a/core/templates/core/voter_detail.html +++ b/core/templates/core/voter_detail.html @@ -230,6 +230,7 @@
+ @@ -240,6 +241,7 @@ +
Name + + Name Email Phone Interests
+ + {{ volunteer.first_name }} {{ volunteer.last_name }} @@ -57,7 +81,7 @@
+

No volunteers found matching your search.

Add the first volunteer
Date TypeVolunteer Description Notes Actions
{{ interaction.date|date:"M d, Y H:i" }} {{ interaction.type.name }}{% if interaction.volunteer %}{{ interaction.volunteer }}{% else %}-{% endif %} {{ interaction.description }} {{ interaction.notes|truncatechars:30 }} @@ -572,6 +574,10 @@ {{ interaction_form.type }} +
+ + {{ interaction_form.volunteer }} +
{{ interaction_form.date }} @@ -614,6 +620,15 @@ {% endfor %}
+
+ + +
diff --git a/core/urls.py b/core/urls.py index aa0c522..c436512 100644 --- a/core/urls.py +++ b/core/urls.py @@ -32,6 +32,8 @@ urlpatterns = [ # Event Detail and Participant Management path('events/', views.event_list, name='event_list'), path('events//', views.event_detail, name='event_detail'), + path('events/add/', views.event_create, name='event_create'), + path('events//edit/', views.event_edit, name='event_edit'), path('events//participant/add/', views.event_add_participant, name='event_add_participant'), path('events/participant//edit/', views.event_edit_participant, name='event_edit_participant'), path('events/participant//delete/', views.event_delete_participant, name='event_delete_participant'), @@ -46,4 +48,8 @@ urlpatterns = [ path('volunteers//delete/', views.volunteer_delete, name='volunteer_delete'), path('volunteers//assign-event/', views.volunteer_assign_event, name='volunteer_assign_event'), path('volunteers/assignment//remove/', views.volunteer_remove_event, name='volunteer_remove_event'), -] + path('volunteers/search/json/', views.volunteer_search_json, name='volunteer_search_json'), + path('volunteers/bulk-sms/', views.volunteer_bulk_send_sms, name='volunteer_bulk_send_sms'), + path('events//volunteer/add/', views.event_add_volunteer, name='event_add_volunteer'), + path('events/volunteer//delete/', views.event_remove_volunteer, name='event_remove_volunteer'), +] \ No newline at end of file diff --git a/core/views.py b/core/views.py index f902649..74d8d4b 100644 --- a/core/views.py +++ b/core/views.py @@ -11,7 +11,7 @@ from django.db.models import Q, Sum from django.contrib import messages from django.core.paginator import Paginator from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest -from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm +from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm import logging from django.utils import timezone @@ -62,6 +62,7 @@ def index(request): recent_interactions = Interaction.objects.filter(voter__tenant=selected_tenant).order_by('-date')[:5] upcoming_events = Event.objects.filter(tenant=selected_tenant, date__gte=timezone.now().date()).order_by('date')[:5] + context = { 'tenants': tenants, 'selected_tenant': selected_tenant, @@ -132,6 +133,7 @@ def voter_list(request): page_number = request.GET.get('page') voters_page = paginator.get_page(page_number) + context = { "voters": voters_page, "query": query, @@ -152,6 +154,7 @@ def voter_detail(request, voter_id): tenant = get_object_or_404(Tenant, id=selected_tenant_id) voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + context = { 'voter': voter, 'selected_tenant': tenant, @@ -455,6 +458,7 @@ def voter_advanced_search(request): page_number = request.GET.get('page') voters_page = paginator.get_page(page_number) + context = { 'form': form, 'voters': voters_page, @@ -672,6 +676,7 @@ def event_list(request): tenant = get_object_or_404(Tenant, id=selected_tenant_id) events = Event.objects.filter(tenant=tenant).order_by('-date') + context = { 'tenant': tenant, 'events': events, @@ -689,16 +694,25 @@ def event_detail(request, event_id): event = get_object_or_404(Event, id=event_id, tenant=tenant) participations = event.participations.all().select_related('voter', 'participation_status').order_by('voter__last_name', 'voter__first_name') + # Get assigned volunteers + volunteers = event.volunteer_assignments.all().select_related('volunteer').order_by('volunteer__last_name', 'volunteer__first_name') + # Form for adding a new participant add_form = EventParticipantAddForm(tenant=tenant) + # Form for adding a new volunteer + add_volunteer_form = VolunteerEventAddForm(tenant=tenant) + participation_statuses = ParticipationStatus.objects.filter(tenant=tenant, is_active=True) + context = { 'tenant': tenant, 'selected_tenant': tenant, 'event': event, 'participations': participations, + 'volunteers': volunteers, 'add_form': add_form, + 'add_volunteer_form': add_volunteer_form, 'participation_statuses': participation_statuses, } return render(request, 'core/event_detail.html', context) @@ -801,15 +815,24 @@ def volunteer_list(request): Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(email__icontains=query) ) + # Interest filter + interest_id = request.GET.get("interest") + if interest_id: + volunteers = volunteers.filter(interests__id=interest_id) + paginator = Paginator(volunteers, 50) page_number = request.GET.get('page') volunteers_page = paginator.get_page(page_number) + interests = Interest.objects.filter(tenant=tenant).order_by('name') + context = { 'tenant': tenant, 'selected_tenant': tenant, 'volunteers': volunteers_page, 'query': query, + 'interests': interests, + 'selected_interest': interest_id, } return render(request, 'core/volunteer_list.html', context) @@ -832,6 +855,7 @@ def volunteer_add(request): else: form = VolunteerForm(tenant=tenant) + context = { 'form': form, 'tenant': tenant, @@ -859,6 +883,7 @@ def volunteer_detail(request, volunteer_id): assignments = volunteer.event_assignments.all().select_related('event') assign_form = VolunteerEventForm(tenant=tenant) + context = { 'volunteer': volunteer, 'form': form, @@ -943,3 +968,198 @@ def interest_delete(request, interest_id): interest.delete() return JsonResponse({'success': True}) return JsonResponse({'success': False, 'error': 'Invalid request.'}) + +def event_create(request): + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + + if request.method == "POST": + form = EventForm(request.POST, tenant=tenant) + if form.is_valid(): + event = form.save(commit=False) + event.tenant = tenant + event.save() + messages.success(request, "Event created successfully.") + return redirect("event_detail", event_id=event.id) + else: + form = EventForm(tenant=tenant) + + + context = { + "form": form, + "tenant": tenant, + "selected_tenant": tenant, + "is_create": True, + } + return render(request, "core/event_edit.html", context) + +def event_edit(request, event_id): + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + if request.method == 'POST': + form = EventForm(request.POST, instance=event, tenant=tenant) + if form.is_valid(): + form.save() + messages.success(request, "Event updated successfully.") + return redirect('event_detail', event_id=event.id) + else: + form = EventForm(instance=event, tenant=tenant) + + + context = { + 'form': form, + 'event': event, + 'tenant': tenant, + 'selected_tenant': tenant, + } + return render(request, 'core/event_edit.html', context) + +def volunteer_search_json(request): + """ + JSON endpoint for volunteer search, used by autocomplete/search UI. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + return JsonResponse({"results": []}) + + query = request.GET.get("q", "").strip() + if len(query) < 2: + return JsonResponse({"results": []}) + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + volunteers = Volunteer.objects.filter(tenant=tenant) + + search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(email__icontains=query) + + results = volunteers.filter(search_filter).order_by("last_name", "first_name")[:20] + + data = [] + for v in results: + data.append({ + "id": v.id, + "text": f"{v.first_name} {v.last_name} ({v.email})", + "phone": v.phone + }) + + return JsonResponse({"results": data}) + +def event_add_volunteer(request, event_id): + tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=tenant_id) + event = get_object_or_404(Event, id=event_id, tenant=tenant) + + if request.method == 'POST': + form = VolunteerEventAddForm(request.POST, tenant=tenant) + if form.is_valid(): + assignment = form.save(commit=False) + assignment.event = event + if not VolunteerEvent.objects.filter(event=event, volunteer=assignment.volunteer).exists(): + assignment.save() + messages.success(request, f"{assignment.volunteer} added as volunteer.") + else: + messages.warning(request, "Volunteer is already assigned to this event.") + else: + messages.error(request, "Error adding volunteer.") + + return redirect('event_detail', event_id=event.id) + +def event_remove_volunteer(request, assignment_id): + tenant_id = request.session.get("tenant_id") + tenant = get_object_or_404(Tenant, id=tenant_id) + assignment = get_object_or_404(VolunteerEvent, id=assignment_id, event__tenant=tenant) + event_id = assignment.event.id + volunteer_name = str(assignment.volunteer) + assignment.delete() + messages.success(request, f"{volunteer_name} removed from event volunteers.") + return redirect('event_detail', event_id=event_id) + +def volunteer_bulk_send_sms(request): + """ + Sends bulk SMS to selected volunteers using Twilio API. + """ + if request.method != 'POST': + return redirect('volunteer_list') + + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + settings = getattr(tenant, 'settings', None) + if not settings: + messages.error(request, "Campaign settings not found.") + return redirect('volunteer_list') + + account_sid = settings.twilio_account_sid + auth_token = settings.twilio_auth_token + from_number = settings.twilio_from_number + + if not account_sid or not auth_token or not from_number: + messages.error(request, "Twilio configuration is incomplete in Campaign Settings.") + return redirect('volunteer_list') + + volunteer_ids = request.POST.getlist('selected_volunteers') + message_body = request.POST.get('message_body') + + if not message_body: + messages.error(request, "Message body cannot be empty.") + return redirect('volunteer_list') + + volunteers = Volunteer.objects.filter(tenant=tenant, id__in=volunteer_ids).exclude(phone='') + + if not volunteers.exists(): + messages.warning(request, "No volunteers with a valid phone number were selected.") + return redirect('volunteer_list') + + success_count = 0 + fail_count = 0 + + auth_str = f"{account_sid}:{auth_token}" + auth_header = base64.b64encode(auth_str.encode()).decode() + url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json" + + for volunteer in volunteers: + # Format phone to E.164 (assume US +1) + digits = re.sub(r'\D', '', str(volunteer.phone)) + if len(digits) == 10: + to_number = f"+1{digits}" + elif len(digits) == 11 and digits.startswith('1'): + to_number = f"+{digits}" + else: + # Skip invalid phone numbers + fail_count += 1 + continue + + data_dict = { + 'To': to_number, + 'From': from_number, + 'Body': message_body + } + data = urllib.parse.urlencode(data_dict).encode() + + req = urllib.request.Request(url, data=data, method='POST') + req.add_header("Authorization", f"Basic {auth_header}") + + try: + with urllib.request.urlopen(req, timeout=10) as response: + if response.status in [200, 201]: + success_count += 1 + else: + fail_count += 1 + except Exception as e: + logger.error(f"Error sending SMS to volunteer {volunteer.phone}: {e}") + fail_count += 1 + + messages.success(request, f"Bulk SMS process completed: {success_count} successful, {fail_count} failed/skipped.") + return redirect('volunteer_list')