Autosave: 20260128-212108

This commit is contained in:
Flatlogic Bot 2026-01-28 21:21:09 +00:00
parent 9c3c5219c6
commit ae3d7f9f2e
10 changed files with 666 additions and 145 deletions

View File

@ -308,14 +308,16 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
batch_size = 500 batch_size = 500
support_choices = dict(Voter.SUPPORT_CHOICES) 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_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_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_choices = dict(Voter.PHONE_TYPE_CHOICES)
phone_type_reverse = {v.lower(): k for k, v in phone_type_choices.items()} 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()} valid_fields = {f.name for f in Voter._meta.get_fields()}
mapped_fields = {f for f in mapping.keys() if f in valid_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 # Ensure derived/special fields are in update_fields
update_fields = list(mapped_fields | {"address", "phone", "longitude", "latitude"}) update_fields = list(mapped_fields | {"address", "phone", "longitude", "latitude"})
if "voter_id" in update_fields: update_fields.remove("voter_id") 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)): for chunk_index, chunk in enumerate(chunk_reader(reader, batch_size)):
with transaction.atomic(): with transaction.atomic():
voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)] 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_create = []
to_update = [] to_update = []
@ -397,14 +399,20 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
# If parsing fails, keep original or skip? Let's skip updating this field. # If parsing fails, keep original or skip? Let's skip updating this field.
continue continue
elif field_name == "candidate_support": elif field_name == "candidate_support":
val = val.lower().replace(" ", "_") val_lower = val.lower()
if val not in support_choices: val = "unknown" 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": elif field_name == "yard_sign":
val = val.lower().replace(" ", "_") val_lower = val.lower()
if val not in yard_sign_choices: val = "none" 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": elif field_name == "window_sticker":
val = val.lower().replace(" ", "_") val_lower = val.lower()
if val not in window_sticker_choices: val = "none" 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": elif field_name == "phone_type":
val_lower = val.lower() val_lower = val.lower()
if val_lower in phone_type_choices: if val_lower in phone_type_choices:
@ -518,6 +526,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
context["title"] = "Import Voters" context["title"] = "Import Voters"
context["opts"] = self.model._meta context["opts"] = self.model._meta
return render(request, "admin/import_csv.html", context) return render(request, "admin/import_csv.html", context)
@admin.register(Event)
class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_display = ('id', 'name', 'event_type', 'date', 'start_time', 'end_time', 'tenant') list_display = ('id', 'name', 'event_type', 'date', 'start_time', 'end_time', 'tenant')
list_filter = ('tenant', 'date', 'event_type') list_filter = ('tenant', 'date', 'event_type')
@ -1505,49 +1514,63 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
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)
mapping = {} mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")}
for field_name, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS:
mapping[field_name] = request.POST.get(f'map_{field_name}')
try: 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) reader = csv.DictReader(f)
total_count = 0 preview_rows = []
create_count = 0 voter_ids_for_preview = set()
update_count = 0 election_types_for_preview = set()
preview_data = []
for row in reader: v_id_col = mapping.get('voter_id')
total_count += 1 et_col = mapping.get('election_type')
voter_id = row.get(mapping.get('voter_id'))
election_type_name = row.get(mapping.get('election_type')) if not v_id_col or not et_col:
exists = False raise ValueError("Missing mapping for Voter ID or Election Type")
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() for i, row in enumerate(reader):
if i < 10:
if exists: preview_rows.append(row)
update_count += 1 v_id = row.get(v_id_col)
action = 'update' 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: else:
create_count += 1 break
action = 'create'
existing_likelihoods = set(VoterLikelihood.objects.filter(
if len(preview_data) < 10: voter__tenant=tenant,
preview_data.append({ voter__voter_id__in=voter_ids_for_preview,
'action': action, election_type__name__in=election_types_for_preview
'identifier': f"Voter: {voter_id}", ).values_list("voter__voter_id", "election_type__name"))
'details': f"Election: {election_type_name}, Likelihood: {row.get(mapping.get('likelihood', '')) or ''}"
}) 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 = self.admin_site.each_context(request)
context.update({ context.update({
'title': "Import Preview", "title": "Import Preview",
'total_count': total_count, "total_count": total_count,
'create_count': create_count, "create_count": "N/A",
'update_count': update_count, "update_count": "N/A",
'preview_data': preview_data, "preview_data": preview_data,
'mapping': mapping, "mapping": mapping,
'file_path': file_path, "file_path": file_path,
'tenant_id': tenant_id, "tenant_id": tenant_id,
'action_url': request.path, "action_url": request.path,
'opts': self.model._meta, "opts": self.model._meta,
}) })
return render(request, "admin/import_preview.html", context) return render(request, "admin/import_preview.html", context)
except Exception as e: except Exception as e:
@ -1558,98 +1581,172 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
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)
mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")}
mapping = {}
for field_name, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS:
mapping[field_name] = request.POST.get(f'map_{field_name}')
try: try:
with open(file_path, 'r', encoding='UTF-8') as f: count = 0
reader = csv.DictReader(f) created_count = 0
count = 0 updated_count = 0
errors = 0 skipped_no_change = 0
failed_rows = [] skipped_no_id = 0
for row in reader: errors = 0
try: failed_rows = []
voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None batch_size = 500
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
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): if os.path.exists(file_path):
os.remove(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[f"{self.model._meta.model_name}_import_errors"] = failed_rows
request.session.modified = True 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:
error_url = reverse("admin:voterlikelihood-download-errors") 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) 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) print(f"DEBUG: Likelihood import failed: {e}")
return redirect("..")
except Exception as e:
print(f"DEBUG: Voter import failed: {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)
return redirect("..") return redirect("..")
else: else:
@ -1667,7 +1764,7 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
tmp.write(chunk) tmp.write(chunk)
file_path = tmp.name 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) reader = csv.reader(f)
headers = next(reader) headers = next(reader)
@ -1694,4 +1791,4 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
@admin.register(CampaignSettings) @admin.register(CampaignSettings)
class CampaignSettingsAdmin(admin.ModelAdmin): class CampaignSettingsAdmin(admin.ModelAdmin):
list_display = ('tenant', 'donation_goal') list_display = ('tenant', 'donation_goal')
list_filter = ('tenant',) list_filter = ('tenant',)

View File

@ -501,15 +501,45 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer border-0 p-4 pt-0"> <div class="modal-footer border-0 p-4 pt-0 justify-content-between">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteVoterModal">
<button type="submit" class="btn btn-primary px-4">Save Changes</button> <i class="bi bi-trash me-1"></i>Delete Profile
</button>
<div>
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-4">Save Changes</button>
</div>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<!-- Delete Voter Confirmation Modal -->
<div class="modal fade" id="deleteVoterModal" tabindex="-1" aria-labelledby="deleteVoterModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-0">
<div class="modal-header border-0 bg-danger text-white">
<h5 class="modal-title" id="deleteVoterModalLabel">Delete Voter Profile</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4 text-center">
<i class="bi bi-exclamation-triangle text-danger mb-3" style="font-size: 3rem;"></i>
<h4 class="mb-3">Are you sure?</h4>
<p class="text-muted">This action will permanently delete <strong>{% if voter.nickname %}{{ voter.nickname }}{% else %}{{ voter.first_name }}{% endif %} {{ voter.last_name }}</strong> and all associated data. This cannot be undone.</p>
</div>
<div class="modal-footer border-0 p-4 pt-0 justify-content-center">
<button type="button" class="btn btn-light px-4" data-bs-dismiss="modal">No, Keep Profile</button>
<form action="{% url 'voter_delete' voter.id %}" method="POST" class="ms-2">
{% csrf_token %}
<button type="submit" class="btn btn-danger px-4">Yes, Delete Permanently</button>
</form>
</div>
</div>
</div>
</div>
<!-- Add Interaction Modal --> <!-- Add Interaction Modal -->
<div class="modal fade" id="addInteractionModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="addInteractionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
@ -580,9 +610,14 @@
<textarea name="notes" class="form-control" rows="2">{{ interaction.notes }}</textarea> <textarea name="notes" class="form-control" rows="2">{{ interaction.notes }}</textarea>
</div> </div>
</div> </div>
<div class="modal-footer border-0 p-4 pt-0"> <div class="modal-footer border-0 p-4 pt-0 justify-content-between">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteVoterModal">
<button type="submit" class="btn btn-primary px-4">Save Changes</button> <i class="bi bi-trash me-1"></i>Delete Profile
</button>
<div>
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-4">Save Changes</button>
</div>
</div> </div>
</form> </form>
</div> </div>
@ -680,9 +715,14 @@
</select> </select>
</div> </div>
</div> </div>
<div class="modal-footer border-0 p-4 pt-0"> <div class="modal-footer border-0 p-4 pt-0 justify-content-between">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteVoterModal">
<button type="submit" class="btn btn-primary px-4">Save Changes</button> <i class="bi bi-trash me-1"></i>Delete Profile
</button>
<div>
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-4">Save Changes</button>
</div>
</div> </div>
</form> </form>
</div> </div>
@ -770,9 +810,14 @@
</select> </select>
</div> </div>
</div> </div>
<div class="modal-footer border-0 p-4 pt-0"> <div class="modal-footer border-0 p-4 pt-0 justify-content-between">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteVoterModal">
<button type="submit" class="btn btn-primary px-4">Save Changes</button> <i class="bi bi-trash me-1"></i>Delete Profile
</button>
<div>
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-4">Save Changes</button>
</div>
</div> </div>
</form> </form>
</div> </div>
@ -860,9 +905,14 @@
</select> </select>
</div> </div>
</div> </div>
<div class="modal-footer border-0 p-4 pt-0"> <div class="modal-footer border-0 p-4 pt-0 justify-content-between">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteVoterModal">
<button type="submit" class="btn btn-primary px-4">Save Changes</button> <i class="bi bi-trash me-1"></i>Delete Profile
</button>
<div>
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary px-4">Save Changes</button>
</div>
</div> </div>
</form> </form>
</div> </div>

View File

@ -9,6 +9,7 @@ urlpatterns = [
path('voters/export-csv/', views.export_voters_csv, name='export_voters_csv'), path('voters/export-csv/', views.export_voters_csv, name='export_voters_csv'),
path('voters/<int:voter_id>/', views.voter_detail, name='voter_detail'), path('voters/<int:voter_id>/', views.voter_detail, name='voter_detail'),
path('voters/<int:voter_id>/edit/', views.voter_edit, name='voter_edit'), path('voters/<int:voter_id>/edit/', views.voter_edit, name='voter_edit'),
path('voters/<int:voter_id>/delete/', views.voter_delete, name='voter_delete'),
path('voters/<int:voter_id>/geocode/', views.voter_geocode, name='voter_geocode'), path('voters/<int:voter_id>/geocode/', views.voter_geocode, name='voter_geocode'),
path('voters/<int:voter_id>/interaction/add/', views.add_interaction, name='add_interaction'), path('voters/<int:voter_id>/interaction/add/', views.add_interaction, name='add_interaction'),
@ -26,4 +27,4 @@ urlpatterns = [
path('voters/<int:voter_id>/event-participation/add/', views.add_event_participation, name='add_event_participation'), path('voters/<int:voter_id>/event-participation/add/', views.add_event_participation, name='add_event_participation'),
path('event-participation/<int:participation_id>/edit/', views.edit_event_participation, name='edit_event_participation'), path('event-participation/<int:participation_id>/edit/', views.edit_event_participation, name='edit_event_participation'),
path('event-participation/<int:participation_id>/delete/', views.delete_event_participation, name='delete_event_participation'), path('event-participation/<int:participation_id>/delete/', views.delete_event_participation, name='delete_event_participation'),
] ]

View File

@ -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 voter.get_candidate_support_display(), voter.get_yard_sign_display(), voter.get_window_sticker_display(), voter.notes
]) ])
return response 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)

330
patch_voter_likelihood.py Normal file
View File

@ -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. <a href='{error_url}' download>Download failed records</a>"), 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}")

18
test_phone_type.py Normal file
View File

@ -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.")

View File

@ -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}")