from django import forms from decimal import Decimal, InvalidOperation from datetime import datetime, date import csv import io import logging import tempfile import os import zoneinfo from django.db import transaction from django.http import HttpResponse from django.utils.safestring import mark_safe from django.utils.dateparse import parse_date, parse_datetime from django.utils import timezone as django_timezone from django.contrib import admin, messages from django.urls import path, reverse from django.shortcuts import render, redirect from django.template.response import TemplateResponse from .models import ( format_phone_number, Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter, VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings, Interest, Volunteer, VolunteerEvent, ParticipationStatus, VolunteerRole, ScheduledCall ) from .forms import ( VoterImportForm, EventImportForm, EventParticipationImportForm, DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm, VolunteerImportForm, VotingRecordImportForm ) logger = logging.getLogger(__name__) def parse_any_date(date_str, tz_name=None): if not date_str or not isinstance(date_str, str): return None date_str = date_str.strip() if not date_str: return None dt = parse_datetime(date_str) if dt: if django_timezone.is_naive(dt) and tz_name: try: dt = django_timezone.make_aware(dt, zoneinfo.ZoneInfo(tz_name)) except: pass return dt d = parse_date(date_str) if d: return d formats = ["%m/%d/%Y", "%m/%d/%y", "%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d", "%m-%d-%Y", "%d-%m-%Y", "%Y/%m/%d", "%m/%d/%Y %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%m/%d/%Y %I:%M %p", "%m/%d/%Y %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ"] for fmt in formats: try: dt = datetime.strptime(date_str, fmt) if any(x in fmt for x in ["%H", "%I", "T"]): if django_timezone.is_naive(dt) and tz_name: try: dt = django_timezone.make_aware(dt, zoneinfo.ZoneInfo(tz_name)) except: pass return dt return dt.date() except ValueError: continue return None def _robust_decode(content): if not content: return "" for enc in ["utf-8-sig", "utf-8", "iso-8859-1", "windows-1252"]: try: return content.decode(enc) except UnicodeDecodeError: continue return content.decode("utf-8", errors="replace") def _read_csv_robust(file_path): """ Optimized version: Read and decode the file into memory once, but return a StringIO for stream-like processing. """ with open(file_path, "rb") as f: content = _robust_decode(f.read()) return io.StringIO(content) class BaseImportAdminMixin: actions = ["export_as_csv"] def export_as_csv(self, request, queryset): meta = self.model._meta field_names = [field.name for field in meta.fields] include_voter_id = "voter" in field_names and self.model != Voter response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = f"attachment; filename={meta.model_name}_export.csv" writer = csv.writer(response) headers = [] for name in field_names: headers.append(name) if name == "voter" and include_voter_id: headers.append("voter_id") writer.writerow(headers) for obj in queryset: row = [] for field in field_names: value = getattr(obj, field) if isinstance(value, (datetime, date)): value = value.strftime("%Y-%m-%d %H:%M:%S") if isinstance(value, datetime) else value.strftime("%Y-%m-%d") elif hasattr(value, "id"): value = str(value) row.append(value) if field == "voter" and include_voter_id: row.append(obj.voter.voter_id if obj.voter else "") writer.writerow(row) return response export_as_csv.short_description = "Export Selected as CSV" def download_errors(self, request): failed_rows = request.session.get(f"{self.model._meta.model_name}_import_errors", []) if not failed_rows: self.message_user(request, "No errors found.", level=messages.WARNING) return redirect("../") output = io.StringIO() if failed_rows: writer = csv.DictWriter(output, fieldnames=failed_rows[0].keys()) writer.writeheader() writer.writerows(failed_rows) response = HttpResponse(output.getvalue(), content_type='text/csv') response['Content-Disposition'] = f'attachment; filename="{self.model._meta.model_name}_import_errors.csv"' return response VOTER_MAPPABLE_FIELDS = [ ('voter_id', 'Voter ID'), ('first_name', 'First Name'), ('last_name', 'Last Name'), ('nickname', 'Nickname'), ('birthdate', 'Birthdate'), ('address_street', 'Street Address'), ('city', 'City'), ('state', 'State'), ('zip_code', 'Zip Code'), ('phone', 'Phone'), ('email', 'Email'), ('is_targeted', 'Is Targeted'), ('target_door_visit', 'Target Door Visit'), ('candidate_support', 'Candidate Support'), ('yard_sign', 'Yard Sign'), ('ever_had_yard_sign', 'Ever Had Yard Sign'), ('ever_had_large_sign', 'Ever Had Large Sign'), ('is_inactive', 'Is Inactive'), ('door_visit', 'Door Visit'), ('voted', 'Voted'), ('neighborhood', 'Neighborhood'), ('district', 'District'), ('precinct', 'Precinct'), ('registration_date', 'Registration Date'), ('call_queue_status', 'Call Queue Status'), ] INTERACTION_MAPPABLE_FIELDS = [('voter_id', 'Voter ID'), ('volunteer_email', 'Volunteer Email'), ('date', 'Date'), ('type', 'Type'), ('description', 'Description'), ('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'), ('likelihood', 'Likelihood')] VOTING_RECORD_MAPPABLE_FIELDS = [('voter_id', 'Voter ID'), ('election_date', 'Election Date'), ('election_description', 'Description'), ('primary_party', 'Primary Party')] EVENT_MAPPABLE_FIELDS = [('name', 'Name'), ('date', 'Date'), ('event_type', 'Event Type'), ('location_name', 'Location'), ('address', 'Address'), ('city', 'City'), ('state', 'State'), ('zip_code', 'Zip Code'), ('start_time', 'Start Time'), ('end_time', 'End Time')] @admin.register(Voter) class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('voter_id', 'first_name', 'last_name', 'city', 'state', 'is_inactive', 'target_door_visit', 'ever_had_yard_sign', 'ever_had_large_sign', 'tenant') list_filter = ('tenant', 'is_inactive', 'ever_had_yard_sign', 'ever_had_large_sign', 'target_door_visit', 'candidate_support', 'call_queue_status') search_fields = ('voter_id', 'first_name', 'last_name', 'email', 'phone') change_list_template = "admin/voter_change_list.html" def get_urls(self): return [ path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voter-download-errors'), path('import-voters/', self.admin_site.admin_view(self.import_voters), name='import-voters') ] + super().get_urls() def import_voters(self, request): if request.method == "POST": if "_preview" in request.POST: file_path, tenant_id = request.POST.get("file_path"), request.POST.get("tenant") tenant, mapping = Tenant.objects.get(id=tenant_id), {fn: request.POST.get(f"map_{fn}") for fn, _ in VOTER_MAPPABLE_FIELDS} try: with _read_csv_robust(file_path) as f: total_count = sum(1 for line in f) - 1 f.seek(0) reader = csv.DictReader(f) preview_rows, v_ids = [], [] for i, row in enumerate(reader): if i < 10: preview_rows.append(row) vid = row.get(mapping.get("voter_id")) if vid: v_ids.append(vid.strip()) else: break existing = set(Voter.objects.filter(tenant=tenant, voter_id__in=v_ids).values_list("voter_id", flat=True)) preview_data = [{ "action": "update" if r.get(mapping.get("voter_id"), "").strip() in existing else "create", "identifier": f"Voter ID: {r.get(mapping.get('voter_id'))}", "details": f"Name: {r.get(mapping.get('first_name', ''))} {r.get(mapping.get('last_name', ''))}" } for r in preview_rows] context = self.admin_site.each_context(request) context.update({ "title": "Import Preview", "total_count": total_count, "create_count": sum(1 for d in preview_data if d['action'] == 'create'), "update_count": sum(1 for d in preview_data if d['action'] == 'update'), "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: {e}", level=messages.ERROR) return redirect("../") elif "_import" in request.POST: file_path, tenant_id = request.POST.get("file_path"), request.POST.get("tenant") tenant, mapping = Tenant.objects.get(id=tenant_id), {fn: request.POST.get(f"map_{fn}") for fn, _ in VOTER_MAPPABLE_FIELDS} try: created, updated, errors, failed = 0, 0, 0, [] with _read_csv_robust(file_path) as f: reader = csv.DictReader(f) chunk_size = 500 chunk = [] for row in reader: chunk.append(row) if len(chunk) >= chunk_size: c, u, e, f_rows = self._process_voter_chunk(tenant, mapping, chunk) created += c; updated += u; errors += e; failed.extend(f_rows) chunk = [] if chunk: c, u, e, f_rows = self._process_voter_chunk(tenant, mapping, chunk) created += c; updated += u; errors += e; failed.extend(f_rows) # Efficient post-import cleanup for the entire tenant self._run_voter_post_import_cleanup(tenant) if os.path.exists(file_path): os.remove(file_path) self.message_user(request, f"Import complete: {created} created, {updated} updated, {errors} errors") request.session[f"{self.model._meta.model_name}_import_errors"] = failed[:1000] request.session.modified = True return redirect("../") except Exception as e: logger.error(f"Voter import failed: {e}", exc_info=True) self.message_user(request, f"Error: {e}", level=messages.ERROR) return redirect("../") else: form = VoterImportForm(request.POST, request.FILES) if form.is_valid(): with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: for chunk in request.FILES['file'].chunks(): tmp.write(chunk) file_path = tmp.name with _read_csv_robust(file_path) as f: headers = next(csv.reader(f)) context = self.admin_site.each_context(request) context.update({ "title": "Map Voter Fields", "headers": headers, "model_fields": VOTER_MAPPABLE_FIELDS, "tenant_id": form.cleaned_data['tenant'].id, "file_path": file_path, "action_url": request.path, "opts": self.model._meta }) return render(request, "admin/import_mapping.html", context) return render(request, "admin/import_csv.html", {'form': VoterImportForm(), 'title': "Import Voters", 'opts': self.model._meta, 'action_url': request.path}) def _process_voter_chunk(self, tenant, mapping, chunk): created, updated, errors = 0, 0, 0 failed = [] voter_ids = [row.get(mapping.get("voter_id"), "").strip() for row in chunk if row.get(mapping.get("voter_id"))] existing_voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids)} to_create = [] to_update = [] # We'll use a transaction for each chunk to keep it atomic but not lock the whole table for long with transaction.atomic(): for row in chunk: try: vid = row.get(mapping.get("voter_id"), "").strip() if not vid: row["Import Error"] = "Missing Voter ID" failed.append(row); errors += 1; continue defaults = {} for fn, _ in VOTER_MAPPABLE_FIELDS: if fn == "voter_id": continue val = row.get(mapping.get(fn), "").strip() if not val: continue if fn in ["birthdate", "registration_date"]: defaults[fn] = parse_any_date(val) elif fn in ["is_targeted", "is_inactive", "target_door_visit", "door_visit", "voted"]: defaults[fn] = val.lower() in ['true', '1', 'yes'] elif fn == "phone": defaults[fn] = format_phone_number(val) elif fn == "email": defaults[fn] = val.lower() elif fn == "call_queue_status": # Try to match label if it's not a valid internal value valid_keys = [c[0] for c in Voter.CALL_QUEUE_STATUS_CHOICES] if val not in valid_keys: label_map = {c[1].lower(): c[0] for c in Voter.CALL_QUEUE_STATUS_CHOICES} if val.lower() in label_map: defaults[fn] = label_map[val.lower()] else: defaults[fn] = val else: defaults[fn] = val else: defaults[fn] = val if defaults.get("voted") is True: defaults["target_door_visit"] = False defaults["call_queue_status"] = "no_call_required" voter = existing_voters.get(vid) if voter: for k, v in defaults.items(): setattr(voter, k, v) voter._skip_geocode = True # Important for performance to_update.append(voter) updated += 1 else: voter = Voter(tenant=tenant, voter_id=vid, **defaults) voter._skip_geocode = True to_create.append(voter) created += 1 except Exception as e: row["Import Error"] = str(e) failed.append(row); errors += 1 if to_create: Voter.objects.bulk_create(to_create) if to_update: # bulk_update requires specifying fields fields = [fn for fn, _ in VOTER_MAPPABLE_FIELDS if fn != 'voter_id'] Voter.objects.bulk_update(to_update, fields) return created, updated, errors, failed def _run_voter_post_import_cleanup(self, tenant): """ Runs the logic that was previously in signals but optimized for bulk. """ from django.db.models import Exists, OuterRef # 0. Ensure consistency for voters who voted Voter.objects.filter(tenant=tenant, voted=True).update( target_door_visit=False, call_queue_status="no_call_required" ) ScheduledCall.objects.filter(tenant=tenant, voter__voted=True, status="pending").update(status="cancelled") # 1. Update target_door_visit logic (based on signal logic) # Set target_door_visit = False if door_visit = False and someone in household is targeted or has support # This is a bit complex to do in one query, but let's do the most important parts. # Signal 1: Update target_door_visit = False if someone in household attended event or has support subquery = Voter.objects.filter( address_street=OuterRef('address_street'), city=OuterRef('city'), state=OuterRef('state'), zip_code=OuterRef('zip_code'), tenant=tenant, is_targeted=True ) # Set target_door_visit = False if NO ONE in household is targeted Voter.objects.filter( tenant=tenant, door_visit=False, target_door_visit=True ).annotate(has_targeted=Exists(subquery)).filter(has_targeted=False).update(target_door_visit=False) # Signal 2: Update candidate_support to 'supporting' if someone in household has yard sign AND voter is > 30 from datetime import date today = date.today() thirty_years_ago = today.replace(year=today.year - 30) if today.month != 2 or today.day != 29 else today.replace(year=today.year - 30, day=28) sign_subquery = Voter.objects.filter( address_street=OuterRef('address_street'), city=OuterRef('city'), state=OuterRef('state'), zip_code=OuterRef('zip_code'), tenant=tenant, yard_sign__in=['wants', 'has'] ) Voter.objects.filter( tenant=tenant, birthdate__lte=thirty_years_ago ).exclude( candidate_support='supporting' ).annotate(household_has_sign=Exists(sign_subquery)).filter(household_has_sign=True).update(candidate_support='supporting') class MassAssignVolunteerForm(forms.Form): volunteer = forms.ModelChoiceField(queryset=Volunteer.objects.none(), required=True) def __init__(self, *args, **kwargs): tenant_ids = kwargs.pop('tenant_ids', []) super().__init__(*args, **kwargs) if tenant_ids: self.fields['volunteer'].queryset = Volunteer.objects.filter(tenant_id__in=tenant_ids).order_by('first_name', 'last_name') else: self.fields['volunteer'].queryset = Volunteer.objects.all().order_by('first_name', 'last_name') @admin.register(Interaction) class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('voter', 'date', 'type', 'description', 'volunteer') list_filter = ('voter__tenant', 'type', 'volunteer') search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description') autocomplete_fields = ['voter', 'volunteer'] change_list_template = 'admin/interaction_change_list.html' actions = ['mass_assign_volunteer'] @admin.action(description="Assign selected interactions to a volunteer") def mass_assign_volunteer(self, request, queryset): tenant_ids = list(queryset.values_list('voter__tenant_id', flat=True).distinct()) if 'apply' in request.POST: form = MassAssignVolunteerForm(request.POST, tenant_ids=tenant_ids) if form.is_valid(): volunteer = form.cleaned_data['volunteer'] updated = queryset.update(volunteer=volunteer) self.message_user(request, f"Successfully assigned {updated} interactions to {volunteer}.", messages.SUCCESS) return None else: form = MassAssignVolunteerForm(tenant_ids=tenant_ids) return TemplateResponse(request, "admin/mass_assign_volunteer.html", { 'queryset': queryset, 'form': form, 'opts': self.model._meta, 'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME, }) def get_urls(self): return [ path('download-errors/', self.admin_site.admin_view(self.download_errors), name='interaction-download-errors'), path('import-interactions/', self.admin_site.admin_view(self.import_interactions), name='import-interactions') ] + super().get_urls() def import_interactions(self, request): if request.method == "POST": if "_preview" in request.POST: file_path, tenant_id = request.POST.get('file_path'), request.POST.get('tenant') tenant = Tenant.objects.get(id=tenant_id) campaign_tz = getattr(tenant.settings, 'timezone', 'UTC') mapping = {fn: request.POST.get(f'map_{fn}') for fn, _ in INTERACTION_MAPPABLE_FIELDS} try: with _read_csv_robust(file_path) as f: reader = csv.DictReader(f) total_count, create_count, update_count, preview_data = 0, 0, 0, [] for row in reader: total_count += 1 vid, type_name, date_str = row.get(mapping.get('voter_id')), row.get(mapping.get('type')), row.get(mapping.get('date')) parsed_date = parse_any_date(date_str, campaign_tz) exists = False if vid and type_name and parsed_date: try: voter = Voter.objects.get(tenant=tenant, voter_id=vid) exists = Interaction.objects.filter(voter=voter, type__name=type_name, date=parsed_date).exists() except: pass if exists: update_count += 1 else: create_count += 1 if len(preview_data) < 10: preview_data.append({'action': 'update' if exists else 'create', 'identifier': f"Voter ID: {vid}", 'details': f"Type: {type_name}, Date: {date_str}"}) 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: {e}", level=messages.ERROR) return redirect("../") elif "_import" in request.POST: file_path, tenant_id = request.POST.get('file_path'), request.POST.get('tenant') tenant = Tenant.objects.get(id=tenant_id) campaign_tz = getattr(tenant.settings, 'timezone', 'UTC') mapping = {fn: request.POST.get(f'map_{fn}') for fn, _ in INTERACTION_MAPPABLE_FIELDS} try: count, errors, failed = 0, 0, [] # Optimized to avoid loading ALL voters with _read_csv_robust(file_path) as f: reader = csv.DictReader(f) chunk_size = 500 chunk = [] for row in reader: chunk.append(row) if len(chunk) >= chunk_size: c, e, f_rows = self._process_interaction_chunk(tenant, mapping, chunk, campaign_tz) count += c; errors += e; failed.extend(f_rows) chunk = [] if chunk: c, e, f_rows = self._process_interaction_chunk(tenant, mapping, chunk, campaign_tz) count += c; errors += e; failed.extend(f_rows) if os.path.exists(file_path): os.remove(file_path) self.message_user(request, f"Imported {count} interactions, {errors} errors") request.session[f"{self.model._meta.model_name}_import_errors"] = failed[:1000] request.session.modified = True return redirect("../") except Exception as e: self.message_user(request, f"Error: {e}", level=messages.ERROR) return redirect("../") else: form = InteractionImportForm(request.POST, request.FILES) if form.is_valid(): with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: for chunk in request.FILES['file'].chunks(): tmp.write(chunk) file_path = tmp.name with _read_csv_robust(file_path) as f: headers = next(csv.reader(f)) context = self.admin_site.each_context(request) context.update({'title': "Map Interaction Fields", 'headers': headers, 'model_fields': INTERACTION_MAPPABLE_FIELDS, 'tenant_id': form.cleaned_data['tenant'].id, 'file_path': file_path, 'action_url': request.path, 'opts': self.model._meta}) return render(request, "admin/import_mapping.html", context) return render(request, "admin/import_csv.html", {'form': InteractionImportForm(), 'title': "Import Interactions", 'opts': self.model._meta, 'action_url': request.path}) def _process_interaction_chunk(self, tenant, mapping, chunk, campaign_tz): count, errors = 0, 0 failed = [] voter_ids = [row.get(mapping.get("voter_id"), "").strip() for row in chunk if row.get(mapping.get("voter_id"))] voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids)} # Pre-fetch interaction types type_names = [row.get(mapping.get("type"), "").strip() for row in chunk if row.get(mapping.get("type"))] types = {t.name: t for t in InteractionType.objects.filter(tenant=tenant, name__in=type_names)} to_create = [] with transaction.atomic(): for row in chunk: try: vid, type_name, date_str = row.get(mapping.get('voter_id'), "").strip(), row.get(mapping.get('type'), "").strip(), row.get(mapping.get('date'), "").strip() if not vid or not type_name or not date_str: row["Import Error"] = "Missing fields"; failed.append(row); errors += 1; continue voter = voters.get(vid) if not voter: row["Import Error"] = f"Voter {vid} not found"; failed.append(row); errors += 1; continue it_type = types.get(type_name) if not it_type: it_type, created = InteractionType.objects.get_or_create(tenant=tenant, name=type_name) types[type_name] = it_type parsed_date = parse_any_date(date_str, campaign_tz) if not parsed_date: row["Import Error"] = f"Invalid date: {date_str}"; failed.append(row); errors += 1; continue # Interaction model uses DateTimeField, so if we got a date, we should make it a datetime if isinstance(parsed_date, date) and not isinstance(parsed_date, datetime): parsed_date = datetime.combine(parsed_date, datetime.min.time()) if django_timezone.is_naive(parsed_date): parsed_date = django_timezone.make_aware(parsed_date, zoneinfo.ZoneInfo(campaign_tz)) to_create.append(Interaction( voter=voter, type=it_type, date=parsed_date, description=row.get(mapping.get('description'), "")[:255], notes=row.get(mapping.get('notes'), "") )) count += 1 except Exception as e: row["Import Error"] = str(e) failed.append(row); errors += 1 if to_create: Interaction.objects.bulk_create(to_create) return count, errors, failed @admin.register(DonationMethod) class DonationMethodAdmin(admin.ModelAdmin): list_display = ('name', 'tenant', 'is_active') list_filter = ('tenant', 'is_active') @admin.register(Donation) class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('voter', 'amount', 'date', 'method', 'tenant_name') list_filter = ('voter__tenant', 'method', 'date') search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id') autocomplete_fields = ['voter'] def tenant_name(self, obj): return obj.voter.tenant.name tenant_name.short_description = "Tenant" @admin.register(InteractionType) class InteractionTypeAdmin(admin.ModelAdmin): list_display = ('name', 'tenant', 'is_active') list_filter = ('tenant', 'is_active') @admin.register(ElectionType) class ElectionTypeAdmin(admin.ModelAdmin): list_display = ('name', 'tenant', 'is_active') list_filter = ('tenant', 'is_active') @admin.register(VoterLikelihood) class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('voter', 'election_type', 'likelihood') list_filter = ('voter__tenant', 'election_type', 'likelihood') autocomplete_fields = ['voter'] @admin.register(VotingRecord) class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('voter', 'election_date', 'election_description', 'primary_party') list_filter = ('voter__tenant', 'election_date', 'primary_party') autocomplete_fields = ['voter'] @admin.register(Event) class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('name', 'date', 'event_type', 'tenant') list_filter = ('tenant', 'event_type', 'date') search_fields = ('name', 'location_name') @admin.register(Volunteer) class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant') list_filter = ('tenant',) search_fields = ('first_name', 'last_name', 'email') @admin.register(CampaignSettings) class CampaignSettingsAdmin(admin.ModelAdmin): list_display = ('tenant', 'timezone', 'donation_goal') list_filter = ('tenant',) @admin.register(ScheduledCall) class ScheduledCallAdmin(admin.ModelAdmin): list_display = ('voter', 'volunteer', 'status', 'created_at', 'tenant') list_filter = ('tenant', 'status', 'volunteer') autocomplete_fields = ['voter', 'volunteer']