From c95591245a73a0846d537891f177235a51e91bcf Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 25 Jan 2026 16:22:06 +0000 Subject: [PATCH] 1.0 --- core/__pycache__/admin.cpython-311.pyc | Bin 46823 -> 70578 bytes core/__pycache__/models.cpython-311.pyc | Bin 21728 -> 20928 bytes core/admin.py | 557 ++++++++++++++++++++--- core/models.py | 32 +- core/templates/admin/import_mapping.html | 4 +- core/templates/admin/import_preview.html | 77 ++++ core/templates/core/voter_detail.html | 4 +- 7 files changed, 598 insertions(+), 76 deletions(-) create mode 100644 core/templates/admin/import_preview.html diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 257d10335fed23df5a02915ff8d56672be0c0fcd..eff357576f95d46b6e2c47df0ed3f769def56fd5 100644 GIT binary patch literal 70578 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 diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index e220ca6ac4c7450d1e0889eb2e8acaae6d067ba6..249a4d15030cb21769378f1cc985a36e0ea5825a 100644 GIT binary patch delta 5296 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 diff --git a/core/admin.py b/core/admin.py index 46d66ff..a166bb4 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,10 +1,12 @@ +from django.http import HttpResponse +from django.utils.safestring import mark_safe import csv import io import logging import tempfile import os from django.contrib import admin, messages -from django.urls import path +from django.urls import path, reverse from django.shortcuts import render, redirect from django.template.response import TemplateResponse from .models import ( @@ -78,6 +80,29 @@ VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [ ('likelihood', 'Likelihood'), ] +class BaseImportAdminMixin: + def download_errors(self, request): + logger.info(f"download_errors called for {self.model._meta.model_name}") + session_key = f"{self.model._meta.model_name}_import_errors" + failed_rows = request.session.get(session_key, []) + if not failed_rows: + self.message_user(request, "No error log found in session.", level=messages.WARNING) + return redirect("..") + + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f"attachment; filename={self.model._meta.model_name}_import_errors.csv" + + if failed_rows: + all_keys = set() + for r in failed_rows: + all_keys.update(r.keys()) + + writer = csv.DictWriter(response, fieldnames=sorted(list(all_keys))) + writer.writeheader() + writer.writerows(failed_rows) + + return response + class TenantUserRoleInline(admin.TabularInline): model = TenantUserRole extra = 1 @@ -88,7 +113,7 @@ class CampaignSettingsInline(admin.StackedInline): @admin.register(Tenant) class TenantAdmin(admin.ModelAdmin): - list_display = ('name', 'slug', 'created_at') + list_display = ('name', 'created_at') search_fields = ('name',) inlines = [TenantUserRoleInline, CampaignSettingsInline] @@ -139,23 +164,77 @@ class VoterLikelihoodInline(admin.TabularInline): extra = 1 @admin.register(Voter) -class VoterAdmin(admin.ModelAdmin): +class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('first_name', 'last_name', 'nickname', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state', 'prior_state') list_filter = ('tenant', 'candidate_support', 'is_targeted', 'yard_sign', 'district', 'city', 'state', 'prior_state') search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'prior_state', 'zip_code', 'county') inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline] + readonly_fields = ('address',) change_list_template = "admin/voter_change_list.html" def get_urls(self): urls = super().get_urls() my_urls = [ + 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'), ] return my_urls + urls def import_voters(self, request): if request.method == "POST": - if "_import" in request.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 VOTER_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 + voter_id = row.get(mapping.get('voter_id')) + exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).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': voter_id, + '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) @@ -169,19 +248,23 @@ class VoterAdmin(admin.ModelAdmin): reader = csv.DictReader(f) count = 0 errors = 0 + failed_rows = [] for row in reader: try: voter_data = {} + voter_id = '' for field_name, csv_col in mapping.items(): if csv_col: val = row.get(csv_col) - if val is not None: + if val is not None and str(val).strip() != '': + if field_name == 'voter_id': + voter_id = val + continue + if field_name == 'is_targeted': val = str(val).lower() in ['true', '1', 'yes'] voter_data[field_name] = val - voter_id = voter_data.pop('voter_id', '') - if 'candidate_support' in voter_data: if voter_data['candidate_support'] not in dict(Voter.SUPPORT_CHOICES): voter_data['candidate_support'] = 'unknown' @@ -191,10 +274,6 @@ class VoterAdmin(admin.ModelAdmin): if 'window_sticker' in voter_data: if voter_data['window_sticker'] not in dict(Voter.WINDOW_STICKER_CHOICES): voter_data['window_sticker'] = 'none' - - for d_field in ['registration_date', 'birthdate', 'latitude', 'longitude']: - if d_field in voter_data and not voter_data[d_field]: - del voter_data[d_field] Voter.objects.update_or_create( tenant=tenant, @@ -203,14 +282,20 @@ class VoterAdmin(admin.ModelAdmin): ) count += 1 except Exception as e: - logger.error(f"Error importing voter row: {e}") + logger.error(f"Error importing: {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} voters.") + request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows + request.session.modified = True + logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}") if errors > 0: - self.message_user(request, f"Failed to import {errors} rows.", level=messages.WARNING) + error_url = reverse("admin:voter-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) @@ -255,7 +340,7 @@ class VoterAdmin(admin.ModelAdmin): return render(request, "admin/import_csv.html", context) @admin.register(Event) -class EventAdmin(admin.ModelAdmin): +class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('id', 'event_type', 'date', 'tenant') list_filter = ('tenant', 'date', 'event_type') change_list_template = "admin/event_change_list.html" @@ -263,13 +348,67 @@ class EventAdmin(admin.ModelAdmin): def get_urls(self): urls = super().get_urls() my_urls = [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='event-download-errors'), path('import-events/', self.admin_site.admin_view(self.import_events), name='import-events'), ] return my_urls + urls def import_events(self, request): if request.method == "POST": - if "_import" in request.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 EVENT_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 + date = row.get(mapping.get('date')) + event_type_name = row.get(mapping.get('event_type')) + exists = False + if date and event_type_name: + exists = Event.objects.filter(tenant=tenant, date=date, event_type__name=event_type_name).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': f"{date} - {event_type_name}", + 'details': row.get(mapping.get('description', '')) or '' + }) + 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) @@ -283,13 +422,16 @@ class EventAdmin(admin.ModelAdmin): reader = csv.DictReader(f) count = 0 errors = 0 + failed_rows = [] for row in reader: try: 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 '' + description = row.get(mapping.get('description')) if mapping.get('description') else None if not date or not event_type_name: + row["Import Error"] = "Missing date or event type" + failed_rows.append(row) errors += 1 continue @@ -298,22 +440,32 @@ class EventAdmin(admin.ModelAdmin): name=event_type_name ) - Event.objects.create( + defaults = {} + if description and description.strip(): + defaults['description'] = description + + Event.objects.update_or_create( tenant=tenant, date=date, event_type=event_type, - description=description + defaults=defaults ) count += 1 except Exception as e: - logger.error(f"Error importing event row: {e}") + logger.error(f"Error importing: {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} events.") + request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows + request.session.modified = True + logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}") if errors > 0: - self.message_user(request, f"Failed to import {errors} rows.", level=messages.WARNING) + error_url = reverse("admin:event-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) @@ -358,7 +510,7 @@ class EventAdmin(admin.ModelAdmin): return render(request, "admin/import_csv.html", context) @admin.register(EventParticipation) -class EventParticipationAdmin(admin.ModelAdmin): +class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('voter', 'event', 'participation_type') list_filter = ('event__tenant', 'event', 'participation_type') change_list_template = "admin/eventparticipation_change_list.html" @@ -366,13 +518,77 @@ class EventParticipationAdmin(admin.ModelAdmin): def get_urls(self): urls = super().get_urls() my_urls = [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='eventparticipation-download-errors'), path('import-event-participations/', self.admin_site.admin_view(self.import_event_participations), name='import-event-participations'), ] return my_urls + urls def import_event_participations(self, request): if request.method == "POST": - if "_import" in request.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 EVENT_PARTICIPATION_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 + voter_id = row.get(mapping.get('voter_id')) + event_id = row.get(mapping.get('event_id')) + event_date = row.get(mapping.get('event_date')) + event_type_name = row.get(mapping.get('event_type')) + + exists = False + if voter_id: + try: + voter = Voter.objects.get(tenant=tenant, voter_id=voter_id) + if event_id: + exists = EventParticipation.objects.filter(voter=voter, event_id=event_id).exists() + elif event_date and event_type_name: + exists = EventParticipation.objects.filter(voter=voter, event__date=event_date, event__event_type__name=event_type_name).exists() + except Voter.DoesNotExist: + pass + + if exists: + update_count += 1 + action = 'update' + else: + create_count += 1 + action = 'create' + + if len(preview_data) < 10: + preview_data.append({ + 'action': action, + 'identifier': f"Voter: {voter_id}", + 'details': f"Participation: {row.get(mapping.get('participation_type', '')) or ''}" + }) + 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) @@ -386,19 +602,25 @@ class EventParticipationAdmin(admin.ModelAdmin): reader = csv.DictReader(f) count = 0 errors = 0 + failed_rows = [] for row in reader: try: voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None - participation_type = row.get(mapping.get('participation_type')) if mapping.get('participation_type') else 'invited' + participation_type_val = row.get(mapping.get('participation_type')) if mapping.get('participation_type') else None if not voter_id: + row["Import Error"] = "Missing voter ID" + failed_rows.append(row) errors += 1 continue try: voter = Voter.objects.get(tenant=tenant, voter_id=voter_id) except Voter.DoesNotExist: - logger.error(f"Voter not found: {voter_id} in tenant {tenant.name}") + error_msg = f"Voter with ID {voter_id} not found" + logger.error(error_msg) + row["Import Error"] = error_msg + failed_rows.append(row) errors += 1 continue @@ -421,28 +643,41 @@ class EventParticipationAdmin(admin.ModelAdmin): pass if not event: - logger.error(f"Event not found for row") + error_msg = "Event not found (check ID, date, or type)" + logger.error(error_msg) + row["Import Error"] = error_msg + failed_rows.append(row) errors += 1 continue - if participation_type not in dict(EventParticipation.PARTICIPATION_TYPE_CHOICES): - participation_type = 'invited' + defaults = {} + if participation_type_val and participation_type_val.strip(): + if participation_type_val in dict(EventParticipation.PARTICIPATION_TYPE_CHOICES): + defaults['participation_type'] = participation_type_val + else: + defaults['participation_type'] = 'invited' EventParticipation.objects.update_or_create( event=event, voter=voter, - defaults={'participation_type': participation_type} + defaults=defaults ) count += 1 except Exception as e: - logger.error(f"Error importing participation row: {e}") + logger.error(f"Error importing: {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} participations.") + request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows + request.session.modified = True + logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}") if errors > 0: - self.message_user(request, f"Failed to import {errors} rows.", level=messages.WARNING) + error_url = reverse("admin:eventparticipation-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) @@ -487,7 +722,7 @@ class EventParticipationAdmin(admin.ModelAdmin): return render(request, "admin/import_csv.html", context) @admin.register(Donation) -class DonationAdmin(admin.ModelAdmin): +class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('id', 'voter', 'date', 'amount', 'method') list_filter = ('voter__tenant', 'date', 'method') search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id') @@ -496,13 +731,68 @@ class DonationAdmin(admin.ModelAdmin): def get_urls(self): urls = super().get_urls() my_urls = [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='donation-download-errors'), path('import-donations/', self.admin_site.admin_view(self.import_donations), name='import-donations'), ] return my_urls + urls def import_donations(self, request): if request.method == "POST": - if "_import" in request.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 DONATION_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 + voter_id = row.get(mapping.get('voter_id')) + date = row.get(mapping.get('date')) + amount = row.get(mapping.get('amount')) + exists = False + if voter_id and date and amount: + exists = Donation.objects.filter(voter__tenant=tenant, voter__voter_id=voter_id, date=date, amount=amount).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': f"Voter: {voter_id}", + 'details': f"Date: {date}, Amount: {amount}" + }) + 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) @@ -516,16 +806,21 @@ class DonationAdmin(admin.ModelAdmin): reader = csv.DictReader(f) count = 0 errors = 0 + failed_rows = [] for row in reader: try: voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None if not voter_id: + row["Import Error"] = "Missing voter ID" + failed_rows.append(row) errors += 1 continue try: voter = Voter.objects.get(tenant=tenant, voter_id=voter_id) except Voter.DoesNotExist: + row["Import Error"] = f"Voter {voter_id} not found" + failed_rows.append(row) errors += 1 continue @@ -534,32 +829,44 @@ class DonationAdmin(admin.ModelAdmin): method_name = row.get(mapping.get('method')) if not date or not amount: + row["Import Error"] = "Missing date or amount" + failed_rows.append(row) errors += 1 continue method = None - if method_name: + if method_name and method_name.strip(): method, _ = DonationMethod.objects.get_or_create( tenant=tenant, name=method_name ) - Donation.objects.create( + defaults = {} + if method: + defaults['method'] = method + + Donation.objects.update_or_create( voter=voter, date=date, amount=amount, - method=method + defaults=defaults ) count += 1 except Exception as e: - logger.error(f"Error importing donation row: {e}") + logger.error(f"Error importing: {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} donations.") + request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows + request.session.modified = True + logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}") if errors > 0: - self.message_user(request, f"Failed to import {errors} rows.", level=messages.WARNING) + error_url = reverse("admin:donation-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) @@ -604,7 +911,7 @@ class DonationAdmin(admin.ModelAdmin): return render(request, "admin/import_csv.html", context) @admin.register(Interaction) -class InteractionAdmin(admin.ModelAdmin): +class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('id', 'voter', 'type', 'date', 'description') list_filter = ('voter__tenant', 'type', 'date') search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description') @@ -613,13 +920,67 @@ class InteractionAdmin(admin.ModelAdmin): def get_urls(self): urls = super().get_urls() my_urls = [ + 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'), ] return my_urls + urls def import_interactions(self, request): if request.method == "POST": - if "_import" in request.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 INTERACTION_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 + voter_id = row.get(mapping.get('voter_id')) + date = row.get(mapping.get('date')) + exists = False + if voter_id and date: + exists = Interaction.objects.filter(voter__tenant=tenant, voter__voter_id=voter_id, date=date).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': f"Voter: {voter_id}", + 'details': f"Date: {date}, Desc: {row.get(mapping.get('description', '')) or ''}" + }) + 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) @@ -633,52 +994,71 @@ class InteractionAdmin(admin.ModelAdmin): reader = csv.DictReader(f) count = 0 errors = 0 + failed_rows = [] for row in reader: try: voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None if not voter_id: + row["Import Error"] = "Missing voter ID" + failed_rows.append(row) errors += 1 continue try: voter = Voter.objects.get(tenant=tenant, voter_id=voter_id) except Voter.DoesNotExist: + row["Import Error"] = f"Voter {voter_id} not found" + failed_rows.append(row) errors += 1 continue date = row.get(mapping.get('date')) type_name = row.get(mapping.get('type')) description = row.get(mapping.get('description')) - notes = row.get(mapping.get('notes')) if mapping.get('notes') else '' + notes = row.get(mapping.get('notes')) if not date or not description: + row["Import Error"] = "Missing date or description" + failed_rows.append(row) errors += 1 continue interaction_type = None - if type_name: + if type_name and type_name.strip(): interaction_type, _ = InteractionType.objects.get_or_create( tenant=tenant, name=type_name ) - Interaction.objects.create( + defaults = {} + if interaction_type: + defaults['type'] = interaction_type + if description and description.strip(): + defaults['description'] = description + if notes and notes.strip(): + defaults['notes'] = notes + + Interaction.objects.update_or_create( voter=voter, date=date, - type=interaction_type, - description=description, - notes=notes + defaults=defaults ) count += 1 except Exception as e: - logger.error(f"Error importing interaction row: {e}") + logger.error(f"Error importing: {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} interactions.") + request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows + request.session.modified = True + logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}") if errors > 0: - self.message_user(request, f"Failed to import {errors} rows.", level=messages.WARNING) + error_url = reverse("admin:interaction-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) @@ -723,7 +1103,7 @@ class InteractionAdmin(admin.ModelAdmin): return render(request, "admin/import_csv.html", context) @admin.register(VoterLikelihood) -class VoterLikelihoodAdmin(admin.ModelAdmin): +class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('id', 'voter', 'election_type', 'likelihood') list_filter = ('voter__tenant', 'election_type', 'likelihood') search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id') @@ -732,13 +1112,67 @@ class VoterLikelihoodAdmin(admin.ModelAdmin): def get_urls(self): urls = super().get_urls() my_urls = [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voterlikelihood-download-errors'), path('import-likelihoods/', self.admin_site.admin_view(self.import_likelihoods), name='import-likelihoods'), ] return my_urls + urls def import_likelihoods(self, request): if request.method == "POST": - if "_import" in request.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 VOTER_LIKELIHOOD_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 + voter_id = row.get(mapping.get('voter_id')) + election_type_name = row.get(mapping.get('election_type')) + exists = False + if voter_id and election_type_name: + exists = VoterLikelihood.objects.filter(voter__tenant=tenant, voter__voter_id=voter_id, election_type__name=election_type_name).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': f"Voter: {voter_id}", + 'details': f"Election: {election_type_name}, Likelihood: {row.get(mapping.get('likelihood', '')) or ''}" + }) + 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) @@ -752,16 +1186,21 @@ class VoterLikelihoodAdmin(admin.ModelAdmin): reader = csv.DictReader(f) count = 0 errors = 0 + failed_rows = [] for row in reader: try: voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None if not voter_id: + row["Import Error"] = "Missing voter ID" + failed_rows.append(row) errors += 1 continue try: voter = Voter.objects.get(tenant=tenant, voter_id=voter_id) except Voter.DoesNotExist: + row["Import Error"] = f"Voter {voter_id} not found" + failed_rows.append(row) errors += 1 continue @@ -769,6 +1208,8 @@ class VoterLikelihoodAdmin(admin.ModelAdmin): likelihood_val = row.get(mapping.get('likelihood')) if not election_type_name or not likelihood_val: + row["Import Error"] = "Missing election type or likelihood value" + failed_rows.append(row) errors += 1 continue @@ -791,24 +1232,36 @@ class VoterLikelihoodAdmin(admin.ModelAdmin): break if not normalized_likelihood: + row["Import Error"] = f"Invalid likelihood value: {likelihood_val}" + failed_rows.append(row) errors += 1 continue + defaults = {} + if normalized_likelihood and normalized_likelihood.strip(): + defaults['likelihood'] = normalized_likelihood + VoterLikelihood.objects.update_or_create( voter=voter, election_type=election_type, - defaults={'likelihood': normalized_likelihood} + defaults=defaults ) count += 1 except Exception as e: - logger.error(f"Error importing likelihood row: {e}") + logger.error(f"Error importing: {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} likelihoods.") + request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows + request.session.modified = True + logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}") if errors > 0: - self.message_user(request, f"Failed to import {errors} rows.", level=messages.WARNING) + error_url = reverse("admin:voterlikelihood-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) diff --git a/core/models.py b/core/models.py index d77bba5..97216d5 100644 --- a/core/models.py +++ b/core/models.py @@ -1,33 +1,25 @@ -from decimal import Decimal from django.db import models -from django.utils.text import slugify from django.contrib.auth.models import User -from django.conf import settings -import urllib.request -import urllib.parse import json +import urllib.parse +import urllib.request import logging +from decimal import Decimal +from django.conf import settings logger = logging.getLogger(__name__) class Tenant(models.Model): - name = models.CharField(max_length=255) - slug = models.SlugField(unique=True, blank=True) - description = models.TextField(blank=True) + name = models.CharField(max_length=100) created_at = models.DateTimeField(auto_now_add=True) - def save(self, *args, **kwargs): - if not self.slug: - self.slug = slugify(self.name) - super().save(*args, **kwargs) - def __str__(self): return self.name class TenantUserRole(models.Model): ROLE_CHOICES = [ - ('system_admin', 'System Administrator'), - ('campaign_admin', 'Campaign Administrator'), + ('admin', 'Admin'), + ('campaign_manager', 'Campaign Manager'), ('campaign_staff', 'Campaign Staff'), ] user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tenant_roles') @@ -49,7 +41,7 @@ class InteractionType(models.Model): unique_together = ('tenant', 'name') def __str__(self): - return f"{self.name} ({self.tenant.name})" + return self.name class DonationMethod(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='donation_methods') @@ -60,7 +52,7 @@ class DonationMethod(models.Model): unique_together = ('tenant', 'name') def __str__(self): - return f"{self.name} ({self.tenant.name})" + return self.name class ElectionType(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='election_types') @@ -71,7 +63,7 @@ class ElectionType(models.Model): unique_together = ('tenant', 'name') def __str__(self): - return f"{self.name} ({self.tenant.name})" + return self.name class EventType(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='event_types') @@ -82,7 +74,7 @@ class EventType(models.Model): unique_together = ('tenant', 'name') def __str__(self): - return f"{self.name} ({self.tenant.name})" + return self.name class Voter(models.Model): SUPPORT_CHOICES = [ @@ -305,4 +297,4 @@ class CampaignSettings(models.Model): verbose_name_plural = 'Campaign Settings' def __str__(self): - return f'Settings for {self.tenant.name}' \ No newline at end of file + return f'Settings for {self.tenant.name}' diff --git a/core/templates/admin/import_mapping.html b/core/templates/admin/import_mapping.html index c07c7a1..b9ed1d7 100644 --- a/core/templates/admin/import_mapping.html +++ b/core/templates/admin/import_mapping.html @@ -41,8 +41,8 @@
- +
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/core/templates/admin/import_preview.html b/core/templates/admin/import_preview.html new file mode 100644 index 0000000..1bf9ca8 --- /dev/null +++ b/core/templates/admin/import_preview.html @@ -0,0 +1,77 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+

{% translate "Import Preview" %}

+

+ {% blocktranslate with total=total_count created=create_count updated=update_count %} + Found {{ total }} records in the CSV file. +
+ - {{ created }} will be created. +
+ - {{ updated }} will be updated. + {% endblocktranslate %} +

+ + {% if preview_data %} +
+

{% translate "Sample Records" %}

+ + + + + + + + + + {% for row in preview_data %} + + + + + + {% endfor %} + +
{% translate "Action" %}{% translate "Identifyer" %}{% translate "Details" %}
+ {% if row.action == 'create' %} + {% translate "CREATE" %} + {% else %} + {% translate "UPDATE" %} + {% endif %} + {{ row.identifier }}{{ row.details }}
+ {% if total_count > preview_data|length %} +

... and {{ total_count|add:"-10" }} more records.

+ {% endif %} +
+ {% endif %} +
+ +
+ {% csrf_token %} + + + + {# Pass mapping as hidden fields #} + {% for field_name, csv_col in mapping.items %} + + {% endfor %} + + +
+
+{% endblock %} diff --git a/core/templates/core/voter_detail.html b/core/templates/core/voter_detail.html index 5955dd9..2dab959 100644 --- a/core/templates/core/voter_detail.html +++ b/core/templates/core/voter_detail.html @@ -445,7 +445,7 @@
- {{ voter_form.voter_id }} + {{ voter_form.district }}
@@ -998,4 +998,4 @@ } }); -{% endblock %} \ No newline at end of file +{% endblock %}