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.description|default:"No description provided." }}
| Volunteer | +Role | ++ |
|---|---|---|
| + + {{ v.volunteer.first_name }} {{ v.volunteer.last_name }} + + | +{{ v.role }} | ++ + | +
| + No volunteers assigned. + | +||
No events found for this campaign.
| Name | ++ + | +Name | Phone | Interests | @@ -38,6 +59,9 @@ {% for volunteer in volunteers %}||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| + + | +{{ volunteer.first_name }} {{ volunteer.last_name }} @@ -57,7 +81,7 @@ | |||||||||||||||||||||
| + |
No volunteers found matching your search. Add the first volunteer |
@@ -73,12 +97,12 @@
|||||||||||||||||||||
| Date | Type | +Volunteer | Description | Notes | Actions | @@ -240,6 +241,7 @@|||||||||||||||||
| {{ 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/ | |||||||||||||||||