diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc
index 257d103..eff3575 100644
Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ
diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc
index e220ca6..249a4d1 100644
Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ
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" %}
+
+
+
+ {% translate "Action" %}
+ {% translate "Identifyer" %}
+ {% translate "Details" %}
+
+
+
+ {% for row in preview_data %}
+
+
+ {% if row.action == 'create' %}
+ {% translate "CREATE" %}
+ {% else %}
+ {% translate "UPDATE" %}
+ {% endif %}
+
+ {{ row.identifier }}
+ {{ row.details }}
+
+ {% endfor %}
+
+
+ {% if total_count > preview_data|length %}
+
... and {{ total_count|add:"-10" }} more records.
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+{% 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.district.label }}
- {{ voter_form.voter_id }}
+ {{ voter_form.district }}
{{ voter_form.precinct.label }}
@@ -998,4 +998,4 @@
}
});
-{% endblock %}
\ No newline at end of file
+{% endblock %}