From ae3d7f9f2ea1dafa87c3f3b3771c77ecac2afe48 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 28 Jan 2026 21:21:09 +0000 Subject: [PATCH] Autosave: 20260128-212108 --- core/__pycache__/admin.cpython-311.pyc | Bin 94068 -> 101105 bytes core/__pycache__/urls.cpython-311.pyc | Bin 2638 -> 2748 bytes core/__pycache__/views.cpython-311.pyc | Bin 36543 -> 37303 bytes core/admin.py | 353 ++++++++++++++++--------- core/templates/core/voter_detail.html | 80 ++++-- core/urls.py | 3 +- core/views.py | 16 +- patch_voter_likelihood.py | 330 +++++++++++++++++++++++ test_phone_type.py | 18 ++ test_phone_type_choices.py | 11 + 10 files changed, 666 insertions(+), 145 deletions(-) create mode 100644 patch_voter_likelihood.py create mode 100644 test_phone_type.py create mode 100644 test_phone_type_choices.py diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 7a8a9d77669f3c9cb39d50fcb13eb84fc46b7b66..718ca547000f2115cc2ad69271bfe91db4492fe6 100644 GIT binary patch delta 18443 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

Eny$tS^@Q^kkc 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 diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index a73277518440658cae7fba3efc9ab710f8c252d7..839bd27da9b8e177f89253588b3310d92ab5734d 100644 GIT binary patch delta 205 zcmX>nvPV>7IWI340}!Z}Rb~EVXJB{?;=q6;l<`@Nd85V~rimL^HFZ;&vm{{hDSB(z zmN7FhtOjBTNEeNgOwkW!&@|XA$1KgrC_7n@MNB*;H7B(sRsR-GS$;`sQ9OiOB?pq* z9KiCCiBW2@6MF+wk<8@%>;YU#K%*FexY%{FB*$GrjTNd_v}_L8U0?~i$P#pgC1~?| UjsQk}V<~>725t~6QUEFj09Cgyd;kCd delta 122 zcmdlZdQL=RIWI340}ya`mS?VDXJB{?;=q6el=1l!(?*RqOkC+AQ4%S-!3>&uo1K`Y z85!j!^RtL;UdZy1iBWR$WcG&15*)r}4a)+y$sa>idHF$lAs~xma(|HQqC#1INotWoK~a8MW=^UeSZ=a_mxx)Fau~9V zLJC+zib8Q|a&l^Maaw6kPNiPaT%bKg>p;X-Afd@UxzI**@&Qw>$w%Ep*wjEmtdj#A zBsGg!fozby3PqVf)-4{eweb)`i)Xw`OwLXBHNLA`V1|VW6_SqcT=fW(&5Xi_V@&Qa1Wdp?lPKy?_ diff --git a/core/admin.py b/core/admin.py index 19ac800..b8aedb6 100644 --- a/core/admin.py +++ b/core/admin.py @@ -308,14 +308,16 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): batch_size = 500 support_choices = dict(Voter.SUPPORT_CHOICES) + support_reverse = {v.lower(): k for k, v in support_choices.items()} yard_sign_choices = dict(Voter.YARD_SIGN_CHOICES) + yard_sign_reverse = {v.lower(): k for k, v in yard_sign_choices.items()} window_sticker_choices = dict(Voter.WINDOW_STICKER_CHOICES) + window_sticker_reverse = {v.lower(): k for k, v in window_sticker_choices.items()} phone_type_choices = dict(Voter.PHONE_TYPE_CHOICES) phone_type_reverse = {v.lower(): k for k, v in phone_type_choices.items()} valid_fields = {f.name for f in Voter._meta.get_fields()} mapped_fields = {f for f in mapping.keys() if f in valid_fields} - fetch_fields = list(mapped_fields | {"voter_id", "address", "phone", "longitude", "latitude"}) # Ensure derived/special fields are in update_fields update_fields = list(mapped_fields | {"address", "phone", "longitude", "latitude"}) if "voter_id" in update_fields: update_fields.remove("voter_id") @@ -342,7 +344,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): for chunk_index, chunk in enumerate(chunk_reader(reader, batch_size)): with transaction.atomic(): voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)] - existing_voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids).only(*fetch_fields)} + existing_voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids)} to_create = [] to_update = [] @@ -397,14 +399,20 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): # If parsing fails, keep original or skip? Let's skip updating this field. continue elif field_name == "candidate_support": - val = val.lower().replace(" ", "_") - if val not in support_choices: val = "unknown" + val_lower = val.lower() + if val_lower in support_choices: val = val_lower + elif val_lower in support_reverse: val = support_reverse[val_lower] + else: val = "unknown" elif field_name == "yard_sign": - val = val.lower().replace(" ", "_") - if val not in yard_sign_choices: val = "none" + val_lower = val.lower() + if val_lower in yard_sign_choices: val = val_lower + elif val_lower in yard_sign_reverse: val = yard_sign_reverse[val_lower] + else: val = "none" elif field_name == "window_sticker": - val = val.lower().replace(" ", "_") - if val not in window_sticker_choices: val = "none" + val_lower = val.lower() + if val_lower in window_sticker_choices: val = val_lower + elif val_lower in window_sticker_reverse: val = window_sticker_reverse[val_lower] + else: val = "none" elif field_name == "phone_type": val_lower = val.lower() if val_lower in phone_type_choices: @@ -518,6 +526,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): context["title"] = "Import Voters" context["opts"] = self.model._meta return render(request, "admin/import_csv.html", context) +@admin.register(Event) class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('id', 'name', 'event_type', 'date', 'start_time', 'end_time', 'tenant') list_filter = ('tenant', 'date', 'event_type') @@ -1505,49 +1514,63 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): 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}') + mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")} + try: - with open(file_path, 'r', encoding='UTF-8') as f: + with open(file_path, 'r', encoding='utf-8-sig') as f: + # Fast count and partial preview + total_count = sum(1 for line in f) - 1 + f.seek(0) 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' + preview_rows = [] + voter_ids_for_preview = set() + election_types_for_preview = set() + + v_id_col = mapping.get('voter_id') + et_col = mapping.get('election_type') + + if not v_id_col or not et_col: + raise ValueError("Missing mapping for Voter ID or Election Type") + + for i, row in enumerate(reader): + if i < 10: + preview_rows.append(row) + v_id = row.get(v_id_col) + et_name = row.get(et_col) + if v_id: voter_ids_for_preview.add(str(v_id).strip()) + if et_name: election_types_for_preview.add(str(et_name).strip()) 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 ''}" - }) + break + + existing_likelihoods = set(VoterLikelihood.objects.filter( + voter__tenant=tenant, + voter__voter_id__in=voter_ids_for_preview, + election_type__name__in=election_types_for_preview + ).values_list("voter__voter_id", "election_type__name")) + + preview_data = [] + for row in preview_rows: + v_id = str(row.get(v_id_col, '')).strip() + et_name = str(row.get(et_col, '')).strip() + action = "update" if (v_id, et_name) in existing_likelihoods else "create" + preview_data.append({ + "action": action, + "identifier": f"Voter: {v_id}, Election: {et_name}", + "details": f"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, + "title": "Import Preview", + "total_count": total_count, + "create_count": "N/A", + "update_count": "N/A", + "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: @@ -1558,98 +1581,172 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): 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}') + mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")} try: - with open(file_path, 'r', encoding='UTF-8') as f: - reader = csv.DictReader(f) - count = 0 - errors = 0 - failed_rows = [] - for row in reader: - try: - 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 - - election_type_name = row.get(mapping.get('election_type')) - 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 - - election_type, _ = ElectionType.objects.get_or_create( - tenant=tenant, - name=election_type_name - ) - - # Normalize likelihood - likelihood_choices = dict(VoterLikelihood.LIKELIHOOD_CHOICES) - normalized_likelihood = None - likelihood_val_lower = likelihood_val.lower().replace(' ', '_') - if likelihood_val_lower in likelihood_choices: - normalized_likelihood = likelihood_val_lower - else: - # Try to find by display name - for k, v in likelihood_choices.items(): - if v.lower() == likelihood_val.lower(): - normalized_likelihood = k - 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=defaults - ) - count += 1 - except Exception as e: - logger.error(f"Error importing: {e}") - row["Import Error"] = str(e) - failed_rows.append(row) - errors += 1 + count = 0 + created_count = 0 + updated_count = 0 + skipped_no_change = 0 + skipped_no_id = 0 + errors = 0 + failed_rows = [] + batch_size = 500 + likelihood_choices = dict(VoterLikelihood.LIKELIHOOD_CHOICES) + likelihood_reverse = {v.lower(): k for k, v in likelihood_choices.items()} + + # Pre-fetch election types for this tenant + election_types = {et.name: et for et in ElectionType.objects.filter(tenant=tenant)} + + def chunk_reader(reader, size): + chunk = [] + for row in reader: + chunk.append(row) + if len(chunk) == size: + yield chunk + chunk = [] + if chunk: + yield chunk + + with open(file_path, "r", encoding="utf-8-sig") as f: + reader = csv.DictReader(f) + v_id_col = mapping.get("voter_id") + et_col = mapping.get("election_type") + l_col = mapping.get("likelihood") + + if not v_id_col or not et_col or not l_col: + raise ValueError("Missing mapping for Voter ID, Election Type, or Likelihood") + + print(f"DEBUG: Starting likelihood import. Tenant: {tenant.name}") + + total_processed = 0 + for chunk in chunk_reader(reader, batch_size): + with transaction.atomic(): + voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)] + et_names = [str(row.get(et_col)).strip() for row in chunk if row.get(et_col)] + + # Fetch existing voters + voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids).only("id", "voter_id")} + + # Fetch existing likelihoods + existing_likelihoods = { + (vl.voter.voter_id, vl.election_type.name): vl + for vl in VoterLikelihood.objects.filter( + voter__tenant=tenant, + voter__voter_id__in=voter_ids, + election_type__name__in=et_names + ).select_related("voter", "election_type") + } + + to_create = [] + to_update = [] + processed_in_batch = set() + + for row in chunk: + total_processed += 1 + try: + raw_v_id = row.get(v_id_col) + raw_et_name = row.get(et_col) + raw_l_val = row.get(l_col) + + if raw_v_id is None or raw_et_name is None or raw_l_val is None: + skipped_no_id += 1 + continue + + v_id = str(raw_v_id).strip() + et_name = str(raw_et_name).strip() + l_val = str(raw_l_val).strip() + + if not v_id or not et_name or not l_val: + skipped_no_id += 1 + continue + + if (v_id, et_name) in processed_in_batch: + continue + processed_in_batch.add((v_id, et_name)) + + voter = voters.get(v_id) + if not voter: + print(f"DEBUG: Voter {v_id} not found for likelihood import") + row["Import Error"] = f"Voter {v_id} not found" + failed_rows.append(row) + errors += 1 + continue + + # Get or create election type + if et_name not in election_types: + election_type, _ = ElectionType.objects.get_or_create(tenant=tenant, name=et_name) + election_types[et_name] = election_type + election_type = election_types[et_name] + + # Normalize likelihood + normalized_l = None + l_val_lower = l_val.lower().replace(' ', '_') + if l_val_lower in likelihood_choices: + normalized_l = l_val_lower + elif l_val_lower in likelihood_reverse: + normalized_l = likelihood_reverse[l_val_lower] + else: + # Try to find by display name more broadly + for k, v in likelihood_choices.items(): + if v.lower() == l_val.lower(): + normalized_l = k + break + + if not normalized_l: + row["Import Error"] = f"Invalid likelihood value: {l_val}" + failed_rows.append(row) + errors += 1 + continue + + vl = existing_likelihoods.get((v_id, et_name)) + created = False + if not vl: + vl = VoterLikelihood(voter=voter, election_type=election_type, likelihood=normalized_l) + created = True + + if not created and vl.likelihood == normalized_l: + skipped_no_change += 1 + continue + + vl.likelihood = normalized_l + + if created: + to_create.append(vl) + created_count += 1 + else: + to_update.append(vl) + updated_count += 1 + + count += 1 + except Exception as e: + print(f"DEBUG: Error importing row {total_processed}: {e}") + row["Import Error"] = str(e) + failed_rows.append(row) + errors += 1 + + if to_create: + VoterLikelihood.objects.bulk_create(to_create) + if to_update: + VoterLikelihood.objects.bulk_update(to_update, ["likelihood"], batch_size=250) + + print(f"DEBUG: Likelihood import progress: {total_processed} processed. {count} created/updated. {skipped_no_change} skipped (no change). {skipped_no_id} skipped (no ID). {errors} errors.") + if os.path.exists(file_path): os.remove(file_path) - self.message_user(request, f"Successfully imported {count} likelihoods.") + + success_msg = f"Import complete: {count} likelihoods created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped with no changes, {skipped_no_id} skipped missing data, {errors} errors)" + self.message_user(success_msg) + 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: 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) - return redirect("..") - except Exception as e: - print(f"DEBUG: Voter import failed: {e}") + print(f"DEBUG: Likelihood import failed: {e}") self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) return redirect("..") else: @@ -1667,7 +1764,7 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): tmp.write(chunk) file_path = tmp.name - with open(file_path, 'r', encoding='UTF-8') as f: + with open(file_path, 'r', encoding='utf-8-sig') as f: reader = csv.reader(f) headers = next(reader) @@ -1694,4 +1791,4 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): @admin.register(CampaignSettings) class CampaignSettingsAdmin(admin.ModelAdmin): list_display = ('tenant', 'donation_goal') - list_filter = ('tenant',) \ No newline at end of file + list_filter = ('tenant',) diff --git a/core/templates/core/voter_detail.html b/core/templates/core/voter_detail.html index 2dc1bb3..3e97371 100644 --- a/core/templates/core/voter_detail.html +++ b/core/templates/core/voter_detail.html @@ -501,15 +501,45 @@ -

+ + + + - @@ -680,9 +715,14 @@ - @@ -770,9 +810,14 @@ - @@ -860,9 +905,14 @@ - diff --git a/core/urls.py b/core/urls.py index 0c1423e..0bcde88 100644 --- a/core/urls.py +++ b/core/urls.py @@ -9,6 +9,7 @@ urlpatterns = [ path('voters/export-csv/', views.export_voters_csv, name='export_voters_csv'), path('voters//', views.voter_detail, name='voter_detail'), path('voters//edit/', views.voter_edit, name='voter_edit'), + path('voters//delete/', views.voter_delete, name='voter_delete'), path('voters//geocode/', views.voter_geocode, name='voter_geocode'), path('voters//interaction/add/', views.add_interaction, name='add_interaction'), @@ -26,4 +27,4 @@ urlpatterns = [ path('voters//event-participation/add/', views.add_event_participation, name='add_event_participation'), path('event-participation//edit/', views.edit_event_participation, name='edit_event_participation'), path('event-participation//delete/', views.delete_event_participation, name='delete_event_participation'), -] \ No newline at end of file +] diff --git a/core/views.py b/core/views.py index b60b666..de09425 100644 --- a/core/views.py +++ b/core/views.py @@ -539,4 +539,18 @@ def export_voters_csv(request): voter.get_candidate_support_display(), voter.get_yard_sign_display(), voter.get_window_sticker_display(), voter.notes ]) - return response \ No newline at end of file + return response +def voter_delete(request, voter_id): + """ + Delete a voter profile. + """ + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + voter.delete() + messages.success(request, "Voter profile deleted successfully.") + return redirect('voter_list') + + return redirect('voter_detail', voter_id=voter.id) diff --git a/patch_voter_likelihood.py b/patch_voter_likelihood.py new file mode 100644 index 0000000..fa5af1e --- /dev/null +++ b/patch_voter_likelihood.py @@ -0,0 +1,330 @@ +import sys + +file_path = 'core/admin.py' +with open(file_path, 'r') as f: + lines = f.readlines() + +start_line = -1 +for i, line in enumerate(lines): + if 'def import_likelihoods(self, request):' in line and 'class VoterLikelihoodAdmin' in lines[i-1]: + start_line = i + break + # Also check if it's just after search_fields or something + if 'def import_likelihoods(self, request):' in line: + # Check if we are inside VoterLikelihoodAdmin + # We can look back for @admin.register(VoterLikelihood) + j = i + while j > 0: + if '@admin.register(VoterLikelihood)' in lines[j]: + start_line = i + break + if '@admin.register' in lines[j] and j < i - 50: # Too far back + break + j -= 1 + if start_line != -1: + break + +if start_line == -1: + print("Could not find import_likelihoods in VoterLikelihoodAdmin") + sys.exit(1) + +# Find the end of the method +# The method ends before @admin.register(CampaignSettings) +end_line = -1 +for i in range(start_line, len(lines)): + if '@admin.register(CampaignSettings)' in lines[i]: + end_line = i + break + +if end_line == -1: + print("Could not find end of import_likelihoods") + sys.exit(1) + +new_method = """ def import_likelihoods(self, request): + if request.method == "POST": + if "_preview" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")} + + try: + with open(file_path, 'r', encoding='utf-8-sig') as f: + # Fast count and partial preview + total_count = sum(1 for line in f) - 1 + f.seek(0) + reader = csv.DictReader(f) + preview_rows = [] + voter_ids_for_preview = set() + election_types_for_preview = set() + + v_id_col = mapping.get('voter_id') + et_col = mapping.get('election_type') + + if not v_id_col or not et_col: + raise ValueError("Missing mapping for Voter ID or Election Type") + + for i, row in enumerate(reader): + if i < 10: + preview_rows.append(row) + v_id = row.get(v_id_col) + et_name = row.get(et_col) + if v_id: voter_ids_for_preview.add(str(v_id).strip()) + if et_name: election_types_for_preview.add(str(et_name).strip()) + else: + break + + existing_likelihoods = set(VoterLikelihood.objects.filter( + voter__tenant=tenant, + voter__voter_id__in=voter_ids_for_preview, + election_type__name__in=election_types_for_preview + ).values_list("voter__voter_id", "election_type__name")) + + preview_data = [] + for row in preview_rows: + v_id = str(row.get(v_id_col, '')).strip() + et_name = str(row.get(et_col, '')).strip() + action = "update" if (v_id, et_name) in existing_likelihoods else "create" + preview_data.append({ + "action": action, + "identifier": f"Voter: {v_id}, Election: {et_name}", + "details": f"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": "N/A", + "update_count": "N/A", + "preview_data": preview_data, + "mapping": mapping, + "file_path": file_path, + "tenant_id": tenant_id, + "action_url": request.path, + "opts": self.model._meta, + }) + return render(request, "admin/import_preview.html", context) + except Exception as e: + self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR) + return redirect("..") + + elif "_import" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")} + + try: + count = 0 + created_count = 0 + updated_count = 0 + skipped_no_change = 0 + skipped_no_id = 0 + errors = 0 + failed_rows = [] + batch_size = 500 + + likelihood_choices = dict(VoterLikelihood.LIKELIHOOD_CHOICES) + likelihood_reverse = {v.lower(): k for k, v in likelihood_choices.items()} + + # Pre-fetch election types for this tenant + election_types = {et.name: et for et in ElectionType.objects.filter(tenant=tenant)} + + def chunk_reader(reader, size): + chunk = [] + for row in reader: + chunk.append(row) + if len(chunk) == size: + yield chunk + chunk = [] + if chunk: + yield chunk + + with open(file_path, "r", encoding="utf-8-sig") as f: + reader = csv.DictReader(f) + v_id_col = mapping.get("voter_id") + et_col = mapping.get("election_type") + l_col = mapping.get("likelihood") + + if not v_id_col or not et_col or not l_col: + raise ValueError("Missing mapping for Voter ID, Election Type, or Likelihood") + + print(f"DEBUG: Starting likelihood import. Tenant: {tenant.name}") + + total_processed = 0 + for chunk in chunk_reader(reader, batch_size): + with transaction.atomic(): + voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)] + et_names = [str(row.get(et_col)).strip() for row in chunk if row.get(et_col)] + + # Fetch existing voters + voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids).only("id", "voter_id")} + + # Fetch existing likelihoods + existing_likelihoods = { + (vl.voter.voter_id, vl.election_type.name): vl + for vl in VoterLikelihood.objects.filter( + voter__tenant=tenant, + voter__voter_id__in=voter_ids, + election_type__name__in=et_names + ).select_related("voter", "election_type") + } + + to_create = [] + to_update = [] + processed_in_batch = set() + + for row in chunk: + total_processed += 1 + try: + raw_v_id = row.get(v_id_col) + raw_et_name = row.get(et_col) + raw_l_val = row.get(l_col) + + if raw_v_id is None or raw_et_name is None or raw_l_val is None: + skipped_no_id += 1 + continue + + v_id = str(raw_v_id).strip() + et_name = str(raw_et_name).strip() + l_val = str(raw_l_val).strip() + + if not v_id or not et_name or not l_val: + skipped_no_id += 1 + continue + + if (v_id, et_name) in processed_in_batch: + continue + processed_in_batch.add((v_id, et_name)) + + voter = voters.get(v_id) + if not voter: + print(f"DEBUG: Voter {v_id} not found for likelihood import") + row["Import Error"] = f"Voter {v_id} not found" + failed_rows.append(row) + errors += 1 + continue + + # Get or create election type + if et_name not in election_types: + election_type, _ = ElectionType.objects.get_or_create(tenant=tenant, name=et_name) + election_types[et_name] = election_type + election_type = election_types[et_name] + + # Normalize likelihood + normalized_l = None + l_val_lower = l_val.lower().replace(' ', '_') + if l_val_lower in likelihood_choices: + normalized_l = l_val_lower + elif l_val_lower in likelihood_reverse: + normalized_l = likelihood_reverse[l_val_lower] + else: + # Try to find by display name more broadly + for k, v in likelihood_choices.items(): + if v.lower() == l_val.lower(): + normalized_l = k + break + + if not normalized_l: + row["Import Error"] = f"Invalid likelihood value: {l_val}" + failed_rows.append(row) + errors += 1 + continue + + vl = existing_likelihoods.get((v_id, et_name)) + created = False + if not vl: + vl = VoterLikelihood(voter=voter, election_type=election_type, likelihood=normalized_l) + created = True + + if not created and vl.likelihood == normalized_l: + skipped_no_change += 1 + continue + + vl.likelihood = normalized_l + + if created: + to_create.append(vl) + created_count += 1 + else: + to_update.append(vl) + updated_count += 1 + + count += 1 + except Exception as e: + print(f"DEBUG: Error importing row {total_processed}: {e}") + row["Import Error"] = str(e) + failed_rows.append(row) + errors += 1 + + if to_create: + VoterLikelihood.objects.bulk_create(to_create) + if to_update: + VoterLikelihood.objects.bulk_update(to_update, ["likelihood"], batch_size=250) + + print(f"DEBUG: Likelihood import progress: {total_processed} processed. {count} created/updated. {skipped_no_change} skipped (no change). {skipped_no_id} skipped (no ID). {errors} errors.") + + if os.path.exists(file_path): + os.remove(file_path) + + success_msg = f"Import complete: {count} likelihoods created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped with no changes, {skipped_no_id} skipped missing data, {errors} errors)" + self.message_user(request, success_msg) + + request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows + request.session.modified = True + if errors > 0: + 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: + print(f"DEBUG: Likelihood import failed: {e}") + self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) + return redirect("..") + else: + form = VoterLikelihoodImportForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = request.FILES['file'] + tenant = form.cleaned_data['tenant'] + + if not csv_file.name.endswith('.csv'): + self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) + return redirect("..") + + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in csv_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + + with open(file_path, 'r', encoding='utf-8-sig') as f: + reader = csv.reader(f) + headers = next(reader) + + context = self.admin_site.each_context(request) + context.update({ + 'title': "Map Likelihood Fields", + 'headers': headers, + 'model_fields': VOTER_LIKELIHOOD_MAPPABLE_FIELDS, + 'tenant_id': tenant.id, + 'file_path': file_path, + 'action_url': request.path, + 'opts': self.model._meta, + }) + return render(request, "admin/import_mapping.html", context) + else: + form = VoterLikelihoodImportForm() + + context = self.admin_site.each_context(request) + context['form'] = form + context['title'] = "Import Likelihoods" + context['opts'] = self.model._meta + return render(request, "admin/import_csv.html", context) + +""" + +lines[start_line:end_line] = [new_method] + +with open(file_path, 'w') as f: + f.writelines(lines) + +print(f"Successfully patched {file_path}") diff --git a/test_phone_type.py b/test_phone_type.py new file mode 100644 index 0000000..9c71bfc --- /dev/null +++ b/test_phone_type.py @@ -0,0 +1,18 @@ +import os +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +django.setup() + +from core.models import Voter +from core.forms import VoterForm + +voter = Voter.objects.first() +if voter: + voter.phone_type = 'home' + voter.save() + form = VoterForm(instance=voter) + print(f"Voter ID: {voter.id}, Phone Type: {voter.phone_type}") + print(form['phone_type'].as_widget()) +else: + print("No voters found.") diff --git a/test_phone_type_choices.py b/test_phone_type_choices.py new file mode 100644 index 0000000..fbe1fcf --- /dev/null +++ b/test_phone_type_choices.py @@ -0,0 +1,11 @@ +import os +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +django.setup() + +from core.models import Voter +from core.forms import VoterForm + +form = VoterForm() +print(f"Choices: {form.fields['phone_type'].choices}")