1.0
This commit is contained in:
parent
d756ed7a8a
commit
c95591245a
Binary file not shown.
Binary file not shown.
557
core/admin.py
557
core/admin.py
@ -1,10 +1,12 @@
|
|||||||
|
from django.http import HttpResponse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
from django.contrib import admin, messages
|
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.shortcuts import render, redirect
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from .models import (
|
from .models import (
|
||||||
@ -78,6 +80,29 @@ VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [
|
|||||||
('likelihood', 'Likelihood'),
|
('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):
|
class TenantUserRoleInline(admin.TabularInline):
|
||||||
model = TenantUserRole
|
model = TenantUserRole
|
||||||
extra = 1
|
extra = 1
|
||||||
@ -88,7 +113,7 @@ class CampaignSettingsInline(admin.StackedInline):
|
|||||||
|
|
||||||
@admin.register(Tenant)
|
@admin.register(Tenant)
|
||||||
class TenantAdmin(admin.ModelAdmin):
|
class TenantAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'slug', 'created_at')
|
list_display = ('name', 'created_at')
|
||||||
search_fields = ('name',)
|
search_fields = ('name',)
|
||||||
inlines = [TenantUserRoleInline, CampaignSettingsInline]
|
inlines = [TenantUserRoleInline, CampaignSettingsInline]
|
||||||
|
|
||||||
@ -139,23 +164,77 @@ class VoterLikelihoodInline(admin.TabularInline):
|
|||||||
extra = 1
|
extra = 1
|
||||||
|
|
||||||
@admin.register(Voter)
|
@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_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')
|
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')
|
search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'prior_state', 'zip_code', 'county')
|
||||||
inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline]
|
inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline]
|
||||||
|
readonly_fields = ('address',)
|
||||||
change_list_template = "admin/voter_change_list.html"
|
change_list_template = "admin/voter_change_list.html"
|
||||||
|
|
||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
urls = super().get_urls()
|
urls = super().get_urls()
|
||||||
my_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'),
|
path('import-voters/', self.admin_site.admin_view(self.import_voters), name='import-voters'),
|
||||||
]
|
]
|
||||||
return my_urls + urls
|
return my_urls + urls
|
||||||
|
|
||||||
def import_voters(self, request):
|
def import_voters(self, request):
|
||||||
if request.method == "POST":
|
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')
|
file_path = request.POST.get('file_path')
|
||||||
tenant_id = request.POST.get('tenant')
|
tenant_id = request.POST.get('tenant')
|
||||||
tenant = Tenant.objects.get(id=tenant_id)
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
@ -169,19 +248,23 @@ class VoterAdmin(admin.ModelAdmin):
|
|||||||
reader = csv.DictReader(f)
|
reader = csv.DictReader(f)
|
||||||
count = 0
|
count = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
|
failed_rows = []
|
||||||
for row in reader:
|
for row in reader:
|
||||||
try:
|
try:
|
||||||
voter_data = {}
|
voter_data = {}
|
||||||
|
voter_id = ''
|
||||||
for field_name, csv_col in mapping.items():
|
for field_name, csv_col in mapping.items():
|
||||||
if csv_col:
|
if csv_col:
|
||||||
val = row.get(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':
|
if field_name == 'is_targeted':
|
||||||
val = str(val).lower() in ['true', '1', 'yes']
|
val = str(val).lower() in ['true', '1', 'yes']
|
||||||
voter_data[field_name] = val
|
voter_data[field_name] = val
|
||||||
|
|
||||||
voter_id = voter_data.pop('voter_id', '')
|
|
||||||
|
|
||||||
if 'candidate_support' in voter_data:
|
if 'candidate_support' in voter_data:
|
||||||
if voter_data['candidate_support'] not in dict(Voter.SUPPORT_CHOICES):
|
if voter_data['candidate_support'] not in dict(Voter.SUPPORT_CHOICES):
|
||||||
voter_data['candidate_support'] = 'unknown'
|
voter_data['candidate_support'] = 'unknown'
|
||||||
@ -192,10 +275,6 @@ class VoterAdmin(admin.ModelAdmin):
|
|||||||
if voter_data['window_sticker'] not in dict(Voter.WINDOW_STICKER_CHOICES):
|
if voter_data['window_sticker'] not in dict(Voter.WINDOW_STICKER_CHOICES):
|
||||||
voter_data['window_sticker'] = 'none'
|
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(
|
Voter.objects.update_or_create(
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
voter_id=voter_id,
|
voter_id=voter_id,
|
||||||
@ -203,14 +282,20 @@ class VoterAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
count += 1
|
count += 1
|
||||||
except Exception as e:
|
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
|
errors += 1
|
||||||
|
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
self.message_user(request, f"Successfully imported {count} voters.")
|
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:
|
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. <a href='{error_url}' download>Download failed records</a>"), level=messages.WARNING)
|
||||||
return redirect("..")
|
return redirect("..")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
|
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)
|
return render(request, "admin/import_csv.html", context)
|
||||||
|
|
||||||
@admin.register(Event)
|
@admin.register(Event)
|
||||||
class EventAdmin(admin.ModelAdmin):
|
class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||||
list_display = ('id', 'event_type', 'date', 'tenant')
|
list_display = ('id', 'event_type', 'date', 'tenant')
|
||||||
list_filter = ('tenant', 'date', 'event_type')
|
list_filter = ('tenant', 'date', 'event_type')
|
||||||
change_list_template = "admin/event_change_list.html"
|
change_list_template = "admin/event_change_list.html"
|
||||||
@ -263,13 +348,67 @@ class EventAdmin(admin.ModelAdmin):
|
|||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
urls = super().get_urls()
|
urls = super().get_urls()
|
||||||
my_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'),
|
path('import-events/', self.admin_site.admin_view(self.import_events), name='import-events'),
|
||||||
]
|
]
|
||||||
return my_urls + urls
|
return my_urls + urls
|
||||||
|
|
||||||
def import_events(self, request):
|
def import_events(self, request):
|
||||||
if request.method == "POST":
|
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')
|
file_path = request.POST.get('file_path')
|
||||||
tenant_id = request.POST.get('tenant')
|
tenant_id = request.POST.get('tenant')
|
||||||
tenant = Tenant.objects.get(id=tenant_id)
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
@ -283,13 +422,16 @@ class EventAdmin(admin.ModelAdmin):
|
|||||||
reader = csv.DictReader(f)
|
reader = csv.DictReader(f)
|
||||||
count = 0
|
count = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
|
failed_rows = []
|
||||||
for row in reader:
|
for row in reader:
|
||||||
try:
|
try:
|
||||||
date = row.get(mapping.get('date')) if mapping.get('date') else None
|
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
|
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:
|
if not date or not event_type_name:
|
||||||
|
row["Import Error"] = "Missing date or event type"
|
||||||
|
failed_rows.append(row)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -298,22 +440,32 @@ class EventAdmin(admin.ModelAdmin):
|
|||||||
name=event_type_name
|
name=event_type_name
|
||||||
)
|
)
|
||||||
|
|
||||||
Event.objects.create(
|
defaults = {}
|
||||||
|
if description and description.strip():
|
||||||
|
defaults['description'] = description
|
||||||
|
|
||||||
|
Event.objects.update_or_create(
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
date=date,
|
date=date,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
description=description
|
defaults=defaults
|
||||||
)
|
)
|
||||||
count += 1
|
count += 1
|
||||||
except Exception as e:
|
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
|
errors += 1
|
||||||
|
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
self.message_user(request, f"Successfully imported {count} events.")
|
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:
|
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. <a href='{error_url}' download>Download failed records</a>"), level=messages.WARNING)
|
||||||
return redirect("..")
|
return redirect("..")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
|
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)
|
return render(request, "admin/import_csv.html", context)
|
||||||
|
|
||||||
@admin.register(EventParticipation)
|
@admin.register(EventParticipation)
|
||||||
class EventParticipationAdmin(admin.ModelAdmin):
|
class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||||
list_display = ('voter', 'event', 'participation_type')
|
list_display = ('voter', 'event', 'participation_type')
|
||||||
list_filter = ('event__tenant', 'event', 'participation_type')
|
list_filter = ('event__tenant', 'event', 'participation_type')
|
||||||
change_list_template = "admin/eventparticipation_change_list.html"
|
change_list_template = "admin/eventparticipation_change_list.html"
|
||||||
@ -366,13 +518,77 @@ class EventParticipationAdmin(admin.ModelAdmin):
|
|||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
urls = super().get_urls()
|
urls = super().get_urls()
|
||||||
my_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'),
|
path('import-event-participations/', self.admin_site.admin_view(self.import_event_participations), name='import-event-participations'),
|
||||||
]
|
]
|
||||||
return my_urls + urls
|
return my_urls + urls
|
||||||
|
|
||||||
def import_event_participations(self, request):
|
def import_event_participations(self, request):
|
||||||
if request.method == "POST":
|
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')
|
file_path = request.POST.get('file_path')
|
||||||
tenant_id = request.POST.get('tenant')
|
tenant_id = request.POST.get('tenant')
|
||||||
tenant = Tenant.objects.get(id=tenant_id)
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
@ -386,19 +602,25 @@ class EventParticipationAdmin(admin.ModelAdmin):
|
|||||||
reader = csv.DictReader(f)
|
reader = csv.DictReader(f)
|
||||||
count = 0
|
count = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
|
failed_rows = []
|
||||||
for row in reader:
|
for row in reader:
|
||||||
try:
|
try:
|
||||||
voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None
|
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:
|
if not voter_id:
|
||||||
|
row["Import Error"] = "Missing voter ID"
|
||||||
|
failed_rows.append(row)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)
|
voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)
|
||||||
except Voter.DoesNotExist:
|
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
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -421,28 +643,41 @@ class EventParticipationAdmin(admin.ModelAdmin):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if not event:
|
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
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if participation_type not in dict(EventParticipation.PARTICIPATION_TYPE_CHOICES):
|
defaults = {}
|
||||||
participation_type = 'invited'
|
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(
|
EventParticipation.objects.update_or_create(
|
||||||
event=event,
|
event=event,
|
||||||
voter=voter,
|
voter=voter,
|
||||||
defaults={'participation_type': participation_type}
|
defaults=defaults
|
||||||
)
|
)
|
||||||
count += 1
|
count += 1
|
||||||
except Exception as e:
|
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
|
errors += 1
|
||||||
|
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
self.message_user(request, f"Successfully imported {count} participations.")
|
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:
|
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. <a href='{error_url}' download>Download failed records</a>"), level=messages.WARNING)
|
||||||
return redirect("..")
|
return redirect("..")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
|
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)
|
return render(request, "admin/import_csv.html", context)
|
||||||
|
|
||||||
@admin.register(Donation)
|
@admin.register(Donation)
|
||||||
class DonationAdmin(admin.ModelAdmin):
|
class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||||
list_display = ('id', 'voter', 'date', 'amount', 'method')
|
list_display = ('id', 'voter', 'date', 'amount', 'method')
|
||||||
list_filter = ('voter__tenant', 'date', 'method')
|
list_filter = ('voter__tenant', 'date', 'method')
|
||||||
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id')
|
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id')
|
||||||
@ -496,13 +731,68 @@ class DonationAdmin(admin.ModelAdmin):
|
|||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
urls = super().get_urls()
|
urls = super().get_urls()
|
||||||
my_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'),
|
path('import-donations/', self.admin_site.admin_view(self.import_donations), name='import-donations'),
|
||||||
]
|
]
|
||||||
return my_urls + urls
|
return my_urls + urls
|
||||||
|
|
||||||
def import_donations(self, request):
|
def import_donations(self, request):
|
||||||
if request.method == "POST":
|
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')
|
file_path = request.POST.get('file_path')
|
||||||
tenant_id = request.POST.get('tenant')
|
tenant_id = request.POST.get('tenant')
|
||||||
tenant = Tenant.objects.get(id=tenant_id)
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
@ -516,16 +806,21 @@ class DonationAdmin(admin.ModelAdmin):
|
|||||||
reader = csv.DictReader(f)
|
reader = csv.DictReader(f)
|
||||||
count = 0
|
count = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
|
failed_rows = []
|
||||||
for row in reader:
|
for row in reader:
|
||||||
try:
|
try:
|
||||||
voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None
|
voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None
|
||||||
if not voter_id:
|
if not voter_id:
|
||||||
|
row["Import Error"] = "Missing voter ID"
|
||||||
|
failed_rows.append(row)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)
|
voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)
|
||||||
except Voter.DoesNotExist:
|
except Voter.DoesNotExist:
|
||||||
|
row["Import Error"] = f"Voter {voter_id} not found"
|
||||||
|
failed_rows.append(row)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -534,32 +829,44 @@ class DonationAdmin(admin.ModelAdmin):
|
|||||||
method_name = row.get(mapping.get('method'))
|
method_name = row.get(mapping.get('method'))
|
||||||
|
|
||||||
if not date or not amount:
|
if not date or not amount:
|
||||||
|
row["Import Error"] = "Missing date or amount"
|
||||||
|
failed_rows.append(row)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
method = None
|
method = None
|
||||||
if method_name:
|
if method_name and method_name.strip():
|
||||||
method, _ = DonationMethod.objects.get_or_create(
|
method, _ = DonationMethod.objects.get_or_create(
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
name=method_name
|
name=method_name
|
||||||
)
|
)
|
||||||
|
|
||||||
Donation.objects.create(
|
defaults = {}
|
||||||
|
if method:
|
||||||
|
defaults['method'] = method
|
||||||
|
|
||||||
|
Donation.objects.update_or_create(
|
||||||
voter=voter,
|
voter=voter,
|
||||||
date=date,
|
date=date,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
method=method
|
defaults=defaults
|
||||||
)
|
)
|
||||||
count += 1
|
count += 1
|
||||||
except Exception as e:
|
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
|
errors += 1
|
||||||
|
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
self.message_user(request, f"Successfully imported {count} donations.")
|
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:
|
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. <a href='{error_url}' download>Download failed records</a>"), level=messages.WARNING)
|
||||||
return redirect("..")
|
return redirect("..")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
|
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)
|
return render(request, "admin/import_csv.html", context)
|
||||||
|
|
||||||
@admin.register(Interaction)
|
@admin.register(Interaction)
|
||||||
class InteractionAdmin(admin.ModelAdmin):
|
class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||||
list_display = ('id', 'voter', 'type', 'date', 'description')
|
list_display = ('id', 'voter', 'type', 'date', 'description')
|
||||||
list_filter = ('voter__tenant', 'type', 'date')
|
list_filter = ('voter__tenant', 'type', 'date')
|
||||||
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description')
|
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description')
|
||||||
@ -613,13 +920,67 @@ class InteractionAdmin(admin.ModelAdmin):
|
|||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
urls = super().get_urls()
|
urls = super().get_urls()
|
||||||
my_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'),
|
path('import-interactions/', self.admin_site.admin_view(self.import_interactions), name='import-interactions'),
|
||||||
]
|
]
|
||||||
return my_urls + urls
|
return my_urls + urls
|
||||||
|
|
||||||
def import_interactions(self, request):
|
def import_interactions(self, request):
|
||||||
if request.method == "POST":
|
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')
|
file_path = request.POST.get('file_path')
|
||||||
tenant_id = request.POST.get('tenant')
|
tenant_id = request.POST.get('tenant')
|
||||||
tenant = Tenant.objects.get(id=tenant_id)
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
@ -633,52 +994,71 @@ class InteractionAdmin(admin.ModelAdmin):
|
|||||||
reader = csv.DictReader(f)
|
reader = csv.DictReader(f)
|
||||||
count = 0
|
count = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
|
failed_rows = []
|
||||||
for row in reader:
|
for row in reader:
|
||||||
try:
|
try:
|
||||||
voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None
|
voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None
|
||||||
if not voter_id:
|
if not voter_id:
|
||||||
|
row["Import Error"] = "Missing voter ID"
|
||||||
|
failed_rows.append(row)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)
|
voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)
|
||||||
except Voter.DoesNotExist:
|
except Voter.DoesNotExist:
|
||||||
|
row["Import Error"] = f"Voter {voter_id} not found"
|
||||||
|
failed_rows.append(row)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
date = row.get(mapping.get('date'))
|
date = row.get(mapping.get('date'))
|
||||||
type_name = row.get(mapping.get('type'))
|
type_name = row.get(mapping.get('type'))
|
||||||
description = row.get(mapping.get('description'))
|
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:
|
if not date or not description:
|
||||||
|
row["Import Error"] = "Missing date or description"
|
||||||
|
failed_rows.append(row)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
interaction_type = None
|
interaction_type = None
|
||||||
if type_name:
|
if type_name and type_name.strip():
|
||||||
interaction_type, _ = InteractionType.objects.get_or_create(
|
interaction_type, _ = InteractionType.objects.get_or_create(
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
name=type_name
|
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,
|
voter=voter,
|
||||||
date=date,
|
date=date,
|
||||||
type=interaction_type,
|
defaults=defaults
|
||||||
description=description,
|
|
||||||
notes=notes
|
|
||||||
)
|
)
|
||||||
count += 1
|
count += 1
|
||||||
except Exception as e:
|
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
|
errors += 1
|
||||||
|
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
self.message_user(request, f"Successfully imported {count} interactions.")
|
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:
|
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. <a href='{error_url}' download>Download failed records</a>"), level=messages.WARNING)
|
||||||
return redirect("..")
|
return redirect("..")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
|
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)
|
return render(request, "admin/import_csv.html", context)
|
||||||
|
|
||||||
@admin.register(VoterLikelihood)
|
@admin.register(VoterLikelihood)
|
||||||
class VoterLikelihoodAdmin(admin.ModelAdmin):
|
class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||||
list_display = ('id', 'voter', 'election_type', 'likelihood')
|
list_display = ('id', 'voter', 'election_type', 'likelihood')
|
||||||
list_filter = ('voter__tenant', 'election_type', 'likelihood')
|
list_filter = ('voter__tenant', 'election_type', 'likelihood')
|
||||||
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id')
|
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id')
|
||||||
@ -732,13 +1112,67 @@ class VoterLikelihoodAdmin(admin.ModelAdmin):
|
|||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
urls = super().get_urls()
|
urls = super().get_urls()
|
||||||
my_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'),
|
path('import-likelihoods/', self.admin_site.admin_view(self.import_likelihoods), name='import-likelihoods'),
|
||||||
]
|
]
|
||||||
return my_urls + urls
|
return my_urls + urls
|
||||||
|
|
||||||
def import_likelihoods(self, request):
|
def import_likelihoods(self, request):
|
||||||
if request.method == "POST":
|
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')
|
file_path = request.POST.get('file_path')
|
||||||
tenant_id = request.POST.get('tenant')
|
tenant_id = request.POST.get('tenant')
|
||||||
tenant = Tenant.objects.get(id=tenant_id)
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
@ -752,16 +1186,21 @@ class VoterLikelihoodAdmin(admin.ModelAdmin):
|
|||||||
reader = csv.DictReader(f)
|
reader = csv.DictReader(f)
|
||||||
count = 0
|
count = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
|
failed_rows = []
|
||||||
for row in reader:
|
for row in reader:
|
||||||
try:
|
try:
|
||||||
voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None
|
voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None
|
||||||
if not voter_id:
|
if not voter_id:
|
||||||
|
row["Import Error"] = "Missing voter ID"
|
||||||
|
failed_rows.append(row)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)
|
voter = Voter.objects.get(tenant=tenant, voter_id=voter_id)
|
||||||
except Voter.DoesNotExist:
|
except Voter.DoesNotExist:
|
||||||
|
row["Import Error"] = f"Voter {voter_id} not found"
|
||||||
|
failed_rows.append(row)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -769,6 +1208,8 @@ class VoterLikelihoodAdmin(admin.ModelAdmin):
|
|||||||
likelihood_val = row.get(mapping.get('likelihood'))
|
likelihood_val = row.get(mapping.get('likelihood'))
|
||||||
|
|
||||||
if not election_type_name or not likelihood_val:
|
if not election_type_name or not likelihood_val:
|
||||||
|
row["Import Error"] = "Missing election type or likelihood value"
|
||||||
|
failed_rows.append(row)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -791,24 +1232,36 @@ class VoterLikelihoodAdmin(admin.ModelAdmin):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not normalized_likelihood:
|
if not normalized_likelihood:
|
||||||
|
row["Import Error"] = f"Invalid likelihood value: {likelihood_val}"
|
||||||
|
failed_rows.append(row)
|
||||||
errors += 1
|
errors += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
defaults = {}
|
||||||
|
if normalized_likelihood and normalized_likelihood.strip():
|
||||||
|
defaults['likelihood'] = normalized_likelihood
|
||||||
|
|
||||||
VoterLikelihood.objects.update_or_create(
|
VoterLikelihood.objects.update_or_create(
|
||||||
voter=voter,
|
voter=voter,
|
||||||
election_type=election_type,
|
election_type=election_type,
|
||||||
defaults={'likelihood': normalized_likelihood}
|
defaults=defaults
|
||||||
)
|
)
|
||||||
count += 1
|
count += 1
|
||||||
except Exception as e:
|
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
|
errors += 1
|
||||||
|
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
self.message_user(request, f"Successfully imported {count} likelihoods.")
|
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:
|
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. <a href='{error_url}' download>Download failed records</a>"), level=messages.WARNING)
|
||||||
return redirect("..")
|
return redirect("..")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
|
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
|
||||||
|
|||||||
@ -1,33 +1,25 @@
|
|||||||
from decimal import Decimal
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.text import slugify
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.conf import settings
|
|
||||||
import urllib.request
|
|
||||||
import urllib.parse
|
|
||||||
import json
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
import logging
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class Tenant(models.Model):
|
class Tenant(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=100)
|
||||||
slug = models.SlugField(unique=True, blank=True)
|
|
||||||
description = models.TextField(blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
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):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
class TenantUserRole(models.Model):
|
class TenantUserRole(models.Model):
|
||||||
ROLE_CHOICES = [
|
ROLE_CHOICES = [
|
||||||
('system_admin', 'System Administrator'),
|
('admin', 'Admin'),
|
||||||
('campaign_admin', 'Campaign Administrator'),
|
('campaign_manager', 'Campaign Manager'),
|
||||||
('campaign_staff', 'Campaign Staff'),
|
('campaign_staff', 'Campaign Staff'),
|
||||||
]
|
]
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tenant_roles')
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tenant_roles')
|
||||||
@ -49,7 +41,7 @@ class InteractionType(models.Model):
|
|||||||
unique_together = ('tenant', 'name')
|
unique_together = ('tenant', 'name')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.tenant.name})"
|
return self.name
|
||||||
|
|
||||||
class DonationMethod(models.Model):
|
class DonationMethod(models.Model):
|
||||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='donation_methods')
|
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='donation_methods')
|
||||||
@ -60,7 +52,7 @@ class DonationMethod(models.Model):
|
|||||||
unique_together = ('tenant', 'name')
|
unique_together = ('tenant', 'name')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.tenant.name})"
|
return self.name
|
||||||
|
|
||||||
class ElectionType(models.Model):
|
class ElectionType(models.Model):
|
||||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='election_types')
|
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='election_types')
|
||||||
@ -71,7 +63,7 @@ class ElectionType(models.Model):
|
|||||||
unique_together = ('tenant', 'name')
|
unique_together = ('tenant', 'name')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.tenant.name})"
|
return self.name
|
||||||
|
|
||||||
class EventType(models.Model):
|
class EventType(models.Model):
|
||||||
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='event_types')
|
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='event_types')
|
||||||
@ -82,7 +74,7 @@ class EventType(models.Model):
|
|||||||
unique_together = ('tenant', 'name')
|
unique_together = ('tenant', 'name')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.tenant.name})"
|
return self.name
|
||||||
|
|
||||||
class Voter(models.Model):
|
class Voter(models.Model):
|
||||||
SUPPORT_CHOICES = [
|
SUPPORT_CHOICES = [
|
||||||
|
|||||||
@ -41,7 +41,7 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="submit-row">
|
<div class="submit-row">
|
||||||
<input type="submit" value="{% translate 'Import' %}" class="default" name="_import">
|
<input type="submit" value="{% translate 'Preview Import' %}" class="default" name="_preview">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
77
core/templates/admin/import_preview.html
Normal file
77
core/templates/admin/import_preview.html
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n admin_urls static %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
|
||||||
|
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||||
|
› {% translate 'Import Preview' %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div id="content-main">
|
||||||
|
<div class="module">
|
||||||
|
<h2>{% translate "Import Preview" %}</h2>
|
||||||
|
<p>
|
||||||
|
{% blocktranslate with total=total_count created=create_count updated=update_count %}
|
||||||
|
Found <strong>{{ total }}</strong> records in the CSV file.
|
||||||
|
<br>
|
||||||
|
- <strong>{{ created }}</strong> will be created.
|
||||||
|
<br>
|
||||||
|
- <strong>{{ updated }}</strong> will be updated.
|
||||||
|
{% endblocktranslate %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if preview_data %}
|
||||||
|
<div class="results">
|
||||||
|
<h3>{% translate "Sample Records" %}</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background: #f8f8f8; border-bottom: 1px solid #ccc;">
|
||||||
|
<th style="padding: 8px; text-align: left;">{% translate "Action" %}</th>
|
||||||
|
<th style="padding: 8px; text-align: left;">{% translate "Identifyer" %}</th>
|
||||||
|
<th style="padding: 8px; text-align: left;">{% translate "Details" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in preview_data %}
|
||||||
|
<tr style="border-bottom: 1px solid #eee;">
|
||||||
|
<td style="padding: 8px;">
|
||||||
|
{% if row.action == 'create' %}
|
||||||
|
<span style="color: green; font-weight: bold;">{% translate "CREATE" %}</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: blue; font-weight: bold;">{% translate "UPDATE" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px;">{{ row.identifier }}</td>
|
||||||
|
<td style="padding: 8px; font-size: 0.9em; color: #666;">{{ row.details }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if total_count > preview_data|length %}
|
||||||
|
<p><em>... and {{ total_count|add:"-10" }} more records.</em></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{{ action_url }}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="file_path" value="{{ file_path }}">
|
||||||
|
<input type="hidden" name="tenant" value="{{ tenant_id }}">
|
||||||
|
|
||||||
|
{# Pass mapping as hidden fields #}
|
||||||
|
{% for field_name, csv_col in mapping.items %}
|
||||||
|
<input type="hidden" name="map_{{ field_name }}" value="{{ csv_col }}">
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="submit-row">
|
||||||
|
<input type="submit" value="{% translate 'Confirm Import' %}" class="default" name="_import">
|
||||||
|
<a href="#" onclick="window.history.back(); return false;" class="closelink">{% translate "Cancel and go back" %}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -445,7 +445,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
<label class="form-label fw-medium">{{ voter_form.district.label }}</label>
|
<label class="form-label fw-medium">{{ voter_form.district.label }}</label>
|
||||||
{{ voter_form.voter_id }}
|
{{ voter_form.district }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
<label class="form-label fw-medium">{{ voter_form.precinct.label }}</label>
|
<label class="form-label fw-medium">{{ voter_form.precinct.label }}</label>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user