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 %} +
  • + Import Volunteers +
  • + {{ block.super }} +{% endblock %} + +{% block search %} + {{ block.super }} +
    + + +
    + + +{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index f689bb2..e9b1663 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -2,157 +2,228 @@ {% load static %} {% block content %} -
    +
    {% if not selected_tenant %} -
    -
    -

    Welcome to Grassroots

    -

    Select a campaign to begin managing your voter database and organizing your efforts.

    - -
    - {% for tenant in tenants %} -
    -
    -
    -

    {{ tenant.name }}

    -

    {{ tenant.description|truncatewords:20 }}

    - Select Campaign -
    +
    +
    +

    Welcome to Campaign Manager

    +

    Select a campaign to view the dashboard.

    +
    + {% for tenant in tenants %} +
    +
    +
    +
    {{ tenant.name }}
    + Manage Campaign
    - {% empty %} -
    -
    No campaigns found. Please create one in the admin.
    -
    - {% endfor %}
    + {% endfor %}
    +
    {% else %} -
    -
    -
    -

    Dashboard: {{ selected_tenant.name }}

    - Switch Campaign +
    +
    +

    {{ selected_tenant.name }} Dashboard

    +

    Overview of voter engagement and field operations.

    +
    +
    + + +
    +
    +
    +
    +
    Active Voters
    +

    {{ metrics.total_registered_voters }}

    + View All →
    - - -
    -
    -
    - - -
    +
    +
    +
    +
    +
    +
    Target Voters
    +

    {{ metrics.total_target_voters }}

    + View Targets → +
    +
    +
    +
    +
    +
    +
    Supporting
    +

    {{ metrics.total_supporting }}

    + View Supporters → +
    +
    +
    +
    +
    +
    +
    Target Households
    +

    {{ metrics.total_target_households }}

    + View Map → +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    Door Visits
    +

    {{ metrics.total_door_visits }}

    - - -
    - - - - - - - - +
    +
    +
    +
    + +
    +
    Signs
    +

    {{ metrics.total_signs }}

    - -
    -
    -
    -
    -
    Voter Management
    -

    Access the full registry to manage individual voter profiles.

    -
    - View Registry +
    +
    +
    +
    +
    + +
    +
    Window Stickers
    +

    {{ metrics.total_window_stickers }}

    +
    +
    +
    +
    +
    +
    +
    Donation Goal
    +
    +

    ${{ metrics.total_donations|floatformat:0 }}

    + of ${{ metrics.donation_goal|floatformat:0 }} +
    +
    +
    +
    +
    + {{ metrics.donation_percentage }}%
    +
    + + +
    +
    +
    +
    +
    Recent Interactions
    + View All +
    +
    +
    + + + + + + + + + + + {% for interaction in recent_interactions %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    VoterTypeDateNotes
    +
    {{ interaction.voter }}
    +
    {{ interaction.volunteer|default:"Staff" }}
    +
    + + {{ interaction.type }} + + + {{ interaction.date|date:"M d, Y" }} + + {{ interaction.description|truncatechars:50 }} +
    No recent interactions found.
    +
    +
    +
    +
    + +
    +
    +
    +
    Upcoming Events
    +
    +
    +
    + {% for event in upcoming_events %} +
    +
    +
    {{ event.name|default:event.event_type }}
    + + {{ event.event_type }} + +
    +
    + {{ event.date|date:"M d, Y" }} +
    +
    + {% empty %} +
    + No upcoming events. +
    + {% endfor %} +
    +
    +
    + + +
    +
    +
    Field Operations
    +
    +
    +
    + Total Volunteers + {{ metrics.volunteers_count }} +
    +
    + Total Interactions + {{ metrics.interactions_count }} +
    +
    + Total Events + {{ metrics.events_count }} +
    +
    +
    +
    +
    {% endif %}
    - - {% endblock %} \ No newline at end of file diff --git a/core/views.py b/core/views.py index 7cf3401..bb4a0e2 100644 --- a/core/views.py +++ b/core/views.py @@ -5,9 +5,10 @@ from django.urls import reverse from django.shortcuts import render, redirect, get_object_or_404 from django.db.models import Q, Sum from django.contrib import messages -from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings +from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm import logging +from django.utils import timezone logger = logging.getLogger(__name__) @@ -20,6 +21,8 @@ def index(request): selected_tenant_id = request.session.get('tenant_id') selected_tenant = None metrics = {} + recent_interactions = [] + upcoming_events = [] if selected_tenant_id: selected_tenant = Tenant.objects.filter(id=selected_tenant_id).first() @@ -46,12 +49,20 @@ def index(request): 'total_donations': float(total_donations), 'donation_goal': float(donation_goal), 'donation_percentage': donation_percentage, + 'volunteers_count': Volunteer.objects.filter(tenant=selected_tenant).count(), + 'interactions_count': Interaction.objects.filter(voter__tenant=selected_tenant).count(), + 'events_count': Event.objects.filter(tenant=selected_tenant).count(), } + + 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, 'metrics': metrics, + 'recent_interactions': recent_interactions, + 'upcoming_events': upcoming_events, } return render(request, 'core/index.html', context) @@ -387,4 +398,4 @@ def voter_geocode(request, voter_id): 'error': f"Geocoding failed: {error_msg or 'No results found.'}" }) - return JsonResponse({'success': False, 'error': 'Invalid request method.'}) \ No newline at end of file + return JsonResponse({'success': False, 'error': 'Invalid request method.'})