diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5ea9b26..bcabe2c 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 73a5120..d062e20 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 42a3f85..fcae47b 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index e1e7dd1..51419e3 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 02ac347..40b43cc 100644 --- a/core/admin.py +++ b/core/admin.py @@ -16,7 +16,8 @@ from .models import ( ) from .forms import ( VoterImportForm, EventImportForm, EventParticipationImportForm, - DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm + DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm, + VolunteerImportForm ) logger = logging.getLogger(__name__) @@ -78,6 +79,14 @@ INTERACTION_MAPPABLE_FIELDS = [ ('notes', 'Notes'), ] + +VOLUNTEER_MAPPABLE_FIELDS = [ + ('first_name', 'First Name'), + ('last_name', 'Last Name'), + ('email', 'Email'), + ('phone', 'Phone'), +] + VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [ ('voter_id', 'Voter ID'), ('election_type', 'Election Type (Name)'), @@ -565,12 +574,162 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): return render(request, "admin/import_csv.html", context) @admin.register(Volunteer) -class VolunteerAdmin(admin.ModelAdmin): - list_display = ('name', 'email', 'phone', 'tenant', 'user') +class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant', 'user') list_filter = ('tenant',) - search_fields = ('name', 'email', 'phone') + search_fields = ('first_name', 'last_name', 'email', 'phone') inlines = [VolunteerEventInline, InteractionInline] filter_horizontal = ('interests',) + change_list_template = "admin/volunteer_change_list.html" + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + from core.models import Tenant + extra_context["tenants"] = Tenant.objects.all() + return super().changelist_view(request, extra_context=extra_context) + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='volunteer-download-errors'), + path('import-volunteers/', self.admin_site.admin_view(self.import_volunteers), name='import-volunteers'), + ] + return my_urls + urls + + def import_volunteers(self, request): + if request.method == "POST": + if "_preview" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + mapping = {} + for field_name, _ in VOLUNTEER_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + try: + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.DictReader(f) + total_count = 0 + create_count = 0 + update_count = 0 + preview_data = [] + for row in reader: + total_count += 1 + email = row.get(mapping.get('email')) + exists = Volunteer.objects.filter(tenant=tenant, email=email).exists() + if exists: + update_count += 1 + action = 'update' + else: + create_count += 1 + action = 'create' + if len(preview_data) < 10: + preview_data.append({ + 'action': action, + 'identifier': email, + 'details': f"{row.get(mapping.get('first_name', '')) or ''} {row.get(mapping.get('last_name', '')) or ''}".strip() + }) + context = self.admin_site.each_context(request) + context.update({ + 'title': "Import Preview", + 'total_count': total_count, + 'create_count': create_count, + 'update_count': update_count, + 'preview_data': preview_data, + 'mapping': mapping, + 'file_path': file_path, + 'tenant_id': tenant_id, + 'action_url': request.path, + 'opts': self.model._meta, + }) + return render(request, "admin/import_preview.html", context) + except Exception as e: + self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR) + return redirect("..") + + elif "_import" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + mapping = {} + for field_name, _ in VOLUNTEER_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + try: + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.DictReader(f) + count = 0 + errors = 0 + failed_rows = [] + for row in reader: + try: + email = row.get(mapping.get('email')) + if not email: + row["Import Error"] = "Missing email" + failed_rows.append(row) + errors += 1 + continue + volunteer_data = {} + for field_name, csv_col in mapping.items(): + if csv_col: + val = row.get(csv_col) + if val is not None and str(val).strip() != '': + if field_name == 'email': continue + volunteer_data[field_name] = val + Volunteer.objects.update_or_create( + tenant=tenant, + email=email, + defaults=volunteer_data + ) + count += 1 + except Exception as e: + logger.error(f"Error importing volunteer: {e}") + row["Import Error"] = str(e) + failed_rows.append(row) + errors += 1 + if os.path.exists(file_path): + os.remove(file_path) + self.message_user(request, f"Successfully imported {count} volunteers.") + request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows + request.session.modified = True + if errors > 0: + error_url = reverse("admin:volunteer-download-errors") + self.message_user(request, mark_safe(f"Failed to import {errors} rows. Download failed records"), level=messages.WARNING) + return redirect("..") + except Exception as 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(): + csv_file = request.FILES['file'] + tenant = form.cleaned_data['tenant'] + if not csv_file.name.endswith('.csv'): + self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) + return redirect("..") + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in csv_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.reader(f) + headers = next(reader) + context = self.admin_site.each_context(request) + context.update({ + 'title': "Map Volunteer Fields", + 'headers': headers, + 'model_fields': VOLUNTEER_MAPPABLE_FIELDS, + 'tenant_id': tenant.id, + 'file_path': file_path, + 'action_url': request.path, + 'opts': self.model._meta, + }) + return render(request, "admin/import_mapping.html", context) + else: + form = VolunteerImportForm() + context = self.admin_site.each_context(request) + context['form'] = form + context['title'] = "Import Volunteers" + context['opts'] = self.model._meta + return render(request, "admin/import_csv.html", context) @admin.register(VolunteerEvent) class VolunteerEventAdmin(admin.ModelAdmin): @@ -982,7 +1141,7 @@ class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin): class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('id', 'voter', 'volunteer', 'type', 'date', 'description') list_filter = ('voter__tenant', 'type', 'date', 'volunteer') - search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description', 'volunteer__name') + search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description', 'volunteer__first_name', 'volunteer__last_name') change_list_template = "admin/interaction_change_list.html" def get_urls(self): @@ -1376,4 +1535,4 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): @admin.register(CampaignSettings) class CampaignSettingsAdmin(admin.ModelAdmin): list_display = ('tenant', 'donation_goal') - list_filter = ('tenant',) + list_filter = ('tenant',) \ No newline at end of file diff --git a/core/forms.py b/core/forms.py index 9afddb6..d9ca81b 100644 --- a/core/forms.py +++ b/core/forms.py @@ -164,4 +164,13 @@ class VoterLikelihoodImportForm(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'}) \ No newline at end of file + self.fields['file'].widget.attrs.update({'class': 'form-control'}) + +class VolunteerImportForm(forms.Form): + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign") + file = forms.FileField(label="Select CSV file") + + 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'}) diff --git a/core/migrations/0019_volunteer_first_name_volunteer_last_name.py b/core/migrations/0019_volunteer_first_name_volunteer_last_name.py new file mode 100644 index 0000000..313fb1e --- /dev/null +++ b/core/migrations/0019_volunteer_first_name_volunteer_last_name.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-01-26 05:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0018_event_end_time_event_name_event_start_time'), + ] + + operations = [ + migrations.AddField( + model_name='volunteer', + name='first_name', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='volunteer', + name='last_name', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/core/migrations/0020_remove_volunteer_name.py b/core/migrations/0020_remove_volunteer_name.py new file mode 100644 index 0000000..b38b88b --- /dev/null +++ b/core/migrations/0020_remove_volunteer_name.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.7 on 2026-01-26 13:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0019_volunteer_first_name_volunteer_last_name'), + ] + + operations = [ + migrations.RemoveField( + model_name='volunteer', + name='name', + ), + ] diff --git a/core/migrations/__pycache__/0019_volunteer_first_name_volunteer_last_name.cpython-311.pyc b/core/migrations/__pycache__/0019_volunteer_first_name_volunteer_last_name.cpython-311.pyc new file mode 100644 index 0000000..30e9156 Binary files /dev/null and b/core/migrations/__pycache__/0019_volunteer_first_name_volunteer_last_name.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0020_remove_volunteer_name.cpython-311.pyc b/core/migrations/__pycache__/0020_remove_volunteer_name.cpython-311.pyc new file mode 100644 index 0000000..be774b4 Binary files /dev/null and b/core/migrations/__pycache__/0020_remove_volunteer_name.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 5c0a55b..9198711 100644 --- a/core/models.py +++ b/core/models.py @@ -285,7 +285,8 @@ class Event(models.Model): class Volunteer(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='volunteers') user = models.OneToOneField(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='volunteer_profile') - name = models.CharField(max_length=255) + first_name = models.CharField(max_length=100, blank=True) + last_name = models.CharField(max_length=100, blank=True) email = models.EmailField() phone = models.CharField(max_length=20, blank=True) interests = models.ManyToManyField(Interest, blank=True, related_name='volunteers') @@ -297,11 +298,11 @@ class Volunteer(models.Model): super().save(*args, **kwargs) def __str__(self): - return self.name + return f"{self.first_name} {self.last_name}".strip() or self.email class VolunteerEvent(models.Model): - volunteer = models.ForeignKey(Volunteer, on_delete=models.CASCADE, related_name='event_assignments') - event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='volunteer_assignments') + volunteer = models.ForeignKey(Volunteer, on_delete=models.CASCADE, related_name="event_assignments") + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="volunteer_assignments") role = models.CharField(max_length=100) def __str__(self): diff --git a/core/templates/admin/volunteer_change_list.html b/core/templates/admin/volunteer_change_list.html new file mode 100644 index 0000000..0fe92f4 --- /dev/null +++ b/core/templates/admin/volunteer_change_list.html @@ -0,0 +1,38 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls static admin_list %} + +{% block object-tools-items %} +
Select a campaign to begin managing your voter database and organizing your efforts.
- -Select a campaign to view the dashboard.
+Overview of voter engagement and field operations.
+Access the full registry to manage individual voter profiles.
-| Voter | +Type | +Date | +Notes | +
|---|---|---|---|
|
+ {{ interaction.voter }}
+ {{ interaction.volunteer|default:"Staff" }}
+ |
+ + + {{ interaction.type }} + + | ++ {{ interaction.date|date:"M d, Y" }} + | ++ {{ interaction.description|truncatechars:50 }} + | +
| No recent interactions found. | +|||