Autosave: 20260201-053401

This commit is contained in:
Flatlogic Bot 2026-02-01 05:34:01 +00:00
parent 77709c3744
commit c5d42d341f
12 changed files with 640 additions and 339 deletions

View File

@ -38,6 +38,7 @@ VOTER_MAPPABLE_FIELDS = [
('prior_state', 'Prior State'),
('zip_code', 'Zip Code'),
('county', 'County'),
('neighborhood', 'Neighborhood'),
('phone', 'Phone'),
('notes', 'Notes'),
('phone_type', 'Phone Type'),
@ -263,6 +264,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
]
return my_urls + urls
def import_voters(self, request):
if request.method == "POST":
if "_preview" in request.POST:
@ -276,7 +278,9 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
try:
with open(file_path, "r", encoding="utf-8-sig") as f:
# Optimization: Fast count and partial preview
# Optimization: Skip full count for very large files in preview if needed,
# but here we'll keep it for accuracy unless it's a known bottleneck.
# For now, let's just do a fast line count.
total_count = sum(1 for line in f) - 1
f.seek(0)
reader = csv.DictReader(f)
@ -303,15 +307,12 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
"details": f"{row.get(mapping.get('first_name', '')) or ''} {row.get(mapping.get('last_name', '')) or ''}".strip()
})
update_count = "N/A"
create_count = "N/A"
context = self.admin_site.each_context(request)
context.update({
"title": "Import Preview",
"total_count": total_count,
"create_count": create_count,
"update_count": update_count,
"create_count": "N/A",
"update_count": "N/A",
"preview_data": preview_data,
"mapping": mapping,
"file_path": file_path,
@ -324,7 +325,6 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
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")
@ -340,48 +340,75 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
skipped_no_id = 0
errors = 0
failed_rows = []
batch_size = 500
batch_size = 2000 # Increased batch size
# Pre-calculate choices and reverse mappings
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()}
# Identify what type of data is being imported to skip unnecessary logic
mapped_fields = set(mapping.keys())
is_address_related = any(f in mapped_fields for f in ["address_street", "city", "state", "zip_code"])
is_phone_related = any(f in mapped_fields for f in ["phone", "secondary_phone", "phone_type", "secondary_phone_type"])
is_coords_related = any(f in mapped_fields for f in ["latitude", "longitude"])
with open(file_path, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
v_id_col = mapping.get("voter_id")
if not v_id_col:
raise ValueError("Voter ID mapping is missing")
# Optimization: Use csv.reader instead of DictReader for performance
raw_reader = csv.reader(f)
headers = next(raw_reader)
header_to_idx = {h: i for i, h in enumerate(headers)}
print(f"DEBUG: Starting voter import. Tenant: {tenant.name}. Voter ID column: {v_id_col}")
v_id_col_name = mapping.get("voter_id")
if not v_id_col_name or v_id_col_name not in header_to_idx:
raise ValueError(f"Voter ID mapping '{v_id_col_name}' is missing or invalid")
v_id_idx = header_to_idx[v_id_col_name]
# Map internal field names to CSV column indices
mapping_indices = {k: header_to_idx[v] for k, v in mapping.items() if v in header_to_idx}
# Optimization: Only fetch needed fields
fields_to_fetch = {"id", "voter_id"} | mapped_fields
if is_address_related: fields_to_fetch.add("address")
print(f"DEBUG: Starting optimized voter import. Tenant: {tenant.name}. Fields: {mapped_fields}")
total_processed = 0
for chunk_index, chunk in enumerate(self.chunk_reader(reader, batch_size)):
# Use chunk_reader with the raw_reader
for chunk in self.chunk_reader(raw_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)}
voter_ids = []
chunk_data = []
for row in chunk:
if len(row) <= v_id_idx: continue
v_id = row[v_id_idx].strip()
if v_id:
voter_ids.append(v_id)
chunk_data.append((v_id, row))
else:
skipped_no_id += 1
# Fetch existing voters in one query
existing_voters = {
v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids)
.only(*fields_to_fetch)
}
to_create = []
to_update = []
batch_updated_fields = set()
processed_in_batch = set()
for row in chunk:
for voter_id, row in chunk_data:
total_processed += 1
try:
raw_voter_id = row.get(v_id_col)
if raw_voter_id is None:
skipped_no_id += 1
continue
voter_id = str(raw_voter_id).strip()
if not voter_id:
skipped_no_id += 1
continue
if voter_id in processed_in_batch:
continue
if voter_id in processed_in_batch: continue
processed_in_batch.add(voter_id)
voter = existing_voters.get(voter_id)
@ -391,30 +418,27 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
created = True
changed = created
record_updated_fields = set()
for field_name, csv_col in mapping.items():
# Process mapped fields
for field_name, idx in mapping_indices.items():
if field_name == "voter_id": continue
val = row.get(csv_col)
if val is None: continue
val = str(val).strip()
if val == "": continue
if idx >= len(row): continue
val = row[idx].strip()
if val == "" and not created: continue # Skip empty updates for existing records unless specifically desired?
# Type conversion and normalization
if field_name == "is_targeted":
val = str(val).lower() in ["true", "1", "yes"]
val = val.lower() in ["true", "1", "yes"]
elif field_name in ["birthdate", "registration_date"]:
orig_val = val
parsed_date = None
for fmt in ["%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d"]:
try:
parsed_date = datetime.strptime(val, fmt).date()
break
except:
continue
if parsed_date:
val = parsed_date
else:
# If parsing fails, keep original or skip? Let's skip updating this field.
continue
except: continue
if parsed_date: val = parsed_date
else: continue
elif field_name == "candidate_support":
val_lower = val.lower()
if val_lower in support_choices: val = val_lower
@ -432,42 +456,45 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
else: val = "none"
elif field_name in ["phone_type", "secondary_phone_type"]:
val_lower = val.lower()
if val_lower in phone_type_choices:
val = val_lower
elif val_lower in phone_type_reverse:
val = phone_type_reverse[val_lower]
else:
val = "cell"
if val_lower in phone_type_choices: val = val_lower
elif val_lower in phone_type_reverse: val = phone_type_reverse[val_lower]
else: val = "cell"
current_val = getattr(voter, field_name)
if current_val != val:
if getattr(voter, field_name) != val:
setattr(voter, field_name, val)
changed = True
record_updated_fields.add(field_name)
old_phone = voter.phone
voter.phone = format_phone_number(voter.phone)
if voter.phone != old_phone:
changed = True
# Optimization: Only perform transformations if related fields are mapped
if is_phone_related or created:
old_p = voter.phone
voter.phone = format_phone_number(voter.phone)
if voter.phone != old_p:
changed = True
record_updated_fields.add("phone")
old_sp = voter.secondary_phone
voter.secondary_phone = format_phone_number(voter.secondary_phone)
if voter.secondary_phone != old_sp:
changed = True
record_updated_fields.add("secondary_phone")
old_secondary_phone = voter.secondary_phone
voter.secondary_phone = format_phone_number(voter.secondary_phone)
if voter.secondary_phone != old_secondary_phone:
changed = True
if voter.longitude:
if (is_coords_related or created) and voter.longitude:
try:
new_lon = Decimal(str(voter.longitude)[:12])
if voter.longitude != new_lon:
voter.longitude = new_lon
changed = True
except:
pass
record_updated_fields.add("longitude")
except: pass
old_address = voter.address
parts = [voter.address_street, voter.city, voter.state, voter.zip_code]
voter.address = ", ".join([p for p in parts if p])
if voter.address != old_address:
changed = True
if is_address_related or created:
old_addr = voter.address
parts = [voter.address_street, voter.city, voter.state, voter.zip_code]
voter.address = ", ".join([p for p in parts if p])
if voter.address != old_addr:
changed = True
record_updated_fields.add("address")
if not changed:
skipped_no_change += 1
@ -478,27 +505,28 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
created_count += 1
else:
to_update.append(voter)
batch_updated_fields.update(record_updated_fields)
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 len(failed_rows) < 1000:
row_dict = dict(zip(headers, row))
row_dict["Import Error"] = str(e)
failed_rows.append(row_dict)
if to_create:
Voter.objects.bulk_create(to_create)
Voter.objects.bulk_create(to_create, batch_size=batch_size)
if to_update:
Voter.objects.bulk_update(to_update, update_fields, batch_size=250)
Voter.objects.bulk_update(to_update, list(batch_updated_fields), batch_size=batch_size)
print(f"DEBUG: Voter import progress: {total_processed} processed. {count} created/updated. {skipped_no_change} skipped (no change). {skipped_no_id} skipped (no ID). {errors} errors.")
print(f"DEBUG: Voter import progress: {total_processed} processed. {count} created/updated. Errors: {errors}")
if os.path.exists(file_path):
os.remove(file_path)
success_msg = f"Import complete: {count} voters created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped with no changes, {skipped_no_id} skipped missing ID, {errors} errors)"
self.message_user(request, success_msg)
self.message_user(request, f"Import complete: {count} voters created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped with no changes, {skipped_no_id} skipped missing ID, {errors} errors)")
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows
request.session.modified = True
@ -515,14 +543,12 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
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)
for chunk in csv_file.chunks(): tmp.write(chunk)
file_path = tmp.name
with open(file_path, "r", encoding="utf-8-sig") as f:
@ -711,7 +737,8 @@ class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if os.path.exists(file_path):
os.remove(file_path)
self.message_user(request, f"Successfully imported {count} events.")
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows
# Optimization: Limit error log size in session to avoid overflow
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows[:1000]
request.session.modified = True
logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}")
if errors > 0:
@ -876,7 +903,8 @@ class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if os.path.exists(file_path):
os.remove(file_path)
self.message_user(request, f"Successfully imported {count} volunteers.")
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows
# Optimization: Limit error log size in session to avoid overflow
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows[:1000]
request.session.modified = True
if errors > 0:
error_url = reverse("admin:volunteer-download-errors")
@ -1076,7 +1104,8 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if os.path.exists(file_path):
os.remove(file_path)
self.message_user(request, f"Successfully imported {count} participations.")
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows
# Optimization: Limit error log size in session to avoid overflow
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows[:1000]
request.session.modified = True
logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}")
if errors > 0:
@ -1266,7 +1295,8 @@ class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if os.path.exists(file_path):
os.remove(file_path)
self.message_user(request, f"Successfully imported {count} donations.")
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows
# Optimization: Limit error log size in session to avoid overflow
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows[:1000]
request.session.modified = True
logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}")
if errors > 0:
@ -1468,7 +1498,8 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if os.path.exists(file_path):
os.remove(file_path)
self.message_user(request, f"Successfully imported {count} interactions.")
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows
# Optimization: Limit error log size in session to avoid overflow
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows[:1000]
request.session.modified = True
logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}")
if errors > 0:
@ -1533,6 +1564,7 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
]
return my_urls + urls
def import_likelihoods(self, request):
if request.method == "POST":
if "_preview" in request.POST:
@ -1543,7 +1575,6 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
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)
@ -1616,17 +1647,17 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
skipped_no_id = 0
errors = 0
failed_rows = []
batch_size = 500
batch_size = 2000
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)}
with open(file_path, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
raw_reader = csv.reader(f)
headers = next(raw_reader)
h_idx = {h: i for i, h in enumerate(headers)}
v_id_col = mapping.get("voter_id")
et_col = mapping.get("election_type")
l_col = mapping.get("likelihood")
@ -1634,135 +1665,97 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
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}")
v_idx = h_idx[v_id_col]
e_idx = h_idx[et_col]
l_idx = h_idx[l_col]
total_processed = 0
for chunk in self.chunk_reader(reader, batch_size):
for chunk in self.chunk_reader(raw_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
voter_ids = []
chunk_data = []
for row in chunk:
if len(row) <= max(v_idx, e_idx, l_idx): continue
v_id = row[v_idx].strip()
et_name = row[e_idx].strip()
l_val = row[l_idx].strip()
if v_id and et_name and l_val:
voter_ids.append(v_id)
chunk_data.append((v_id, et_name, l_val, row))
else:
skipped_no_id += 1
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
et_names = [d[1] for d in chunk_data]
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")
).only("id", "likelihood", "voter__voter_id", "election_type__name").select_related("voter", "election_type")
}
to_create = []
to_update = []
processed_in_batch = set()
for row in chunk:
for v_id, et_name, l_val, row in chunk_data:
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
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]
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)
to_create.append(VoterLikelihood(voter=voter, election_type=election_type, likelihood=normalized_l))
created_count += 1
else:
elif vl.likelihood != normalized_l:
vl.likelihood = normalized_l
to_update.append(vl)
updated_count += 1
else:
skipped_no_change += 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)
if to_create: VoterLikelihood.objects.bulk_create(to_create, batch_size=batch_size)
if to_update: VoterLikelihood.objects.bulk_update(to_update, ["likelihood"], batch_size=batch_size)
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.")
print(f"DEBUG: Likelihood import progress: {total_processed} processed. {count} created/updated.")
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)
self.message_user(request, f"Import complete: {count} likelihoods created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped, {errors} errors)")
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:
@ -1770,20 +1763,15 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
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)
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",
@ -1797,7 +1785,6 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
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"
@ -1832,6 +1819,7 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
]
return my_urls + urls
def import_voting_records(self, request):
if request.method == "POST":
if "_preview" in request.POST:
@ -1842,7 +1830,6 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
try:
with open(file_path, 'r', encoding='utf-8-sig') as f:
# Optimization: Fast count and partial preview
total_count = sum(1 for line in f) - 1
f.seek(0)
reader = csv.DictReader(f)
@ -1875,15 +1862,13 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
e_date_raw = row.get(ed_col)
e_desc = str(row.get(desc_col, '')).strip()
# Try to parse date for accurate comparison in preview
e_date = None
if e_date_raw:
for fmt in ["%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d"]:
try:
e_date = datetime.strptime(str(e_date_raw).strip(), fmt).date()
break
except:
continue
except: continue
action = "update" if (v_id, e_date, e_desc) in existing_records else "create"
preview_data.append({
@ -1921,13 +1906,14 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
created_count = 0
updated_count = 0
skipped_no_change = 0
skipped_no_id = 0
errors = 0
failed_rows = []
batch_size = 500
batch_size = 2000
with open(file_path, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
raw_reader = csv.reader(f)
headers = next(raw_reader)
h_idx = {h: i for i, h in enumerate(headers)}
v_id_col = mapping.get("voter_id")
ed_col = mapping.get("election_date")
desc_col = mapping.get("election_description")
@ -1936,23 +1922,23 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if not v_id_col or not ed_col or not desc_col:
raise ValueError("Missing mapping for Voter ID, Election Date, or Description")
print(f"DEBUG: Starting voting record import. Tenant: {tenant.name}")
v_idx = h_idx[v_id_col]
ed_idx = h_idx[ed_col]
desc_idx = h_idx[desc_col]
p_idx = h_idx.get(party_col)
total_processed = 0
for chunk in self.chunk_reader(reader, batch_size):
for chunk in self.chunk_reader(raw_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)]
# Fetch existing voters
voter_ids = [row[v_idx].strip() for row in chunk if len(row) > v_idx and row[v_idx].strip()]
voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids).only("id", "voter_id")}
# Fetch existing records
existing_records = {
(vr.voter.voter_id, vr.election_date, vr.election_description): vr
for vr in VotingRecord.objects.filter(
voter__tenant=tenant,
voter__voter_id__in=voter_ids
).select_related("voter")
).only("id", "election_date", "election_description", "voter__voter_id").select_related("voter")
}
to_create = []
@ -1962,92 +1948,59 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
for row in chunk:
total_processed += 1
try:
raw_v_id = row.get(v_id_col)
raw_ed = row.get(ed_col)
raw_desc = row.get(desc_col)
party = str(row.get(party_col, '')).strip() if party_col else ""
if len(row) <= max(v_idx, ed_idx, desc_idx): continue
v_id = row[v_idx].strip()
raw_ed = row[ed_idx].strip()
desc = row[desc_idx].strip()
party = row[p_idx].strip() if p_idx is not None and len(row) > p_idx else ""
if not raw_v_id or not raw_ed or not raw_desc:
skipped_no_id += 1
continue
v_id = str(raw_v_id).strip()
desc = str(raw_desc).strip()
if not v_id or not raw_ed or not desc: continue
# Parse date
e_date = None
val = str(raw_ed).strip()
for fmt in ["%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d"]:
try:
e_date = datetime.strptime(val, fmt).date()
break
except:
continue
if not e_date:
row["Import Error"] = f"Invalid date format: {val}"
failed_rows.append(row)
errors += 1
continue
if (v_id, e_date, desc) in processed_in_batch:
continue
processed_in_batch.add((v_id, e_date, desc))
if (v_id, raw_ed, desc) in processed_in_batch: continue
processed_in_batch.add((v_id, raw_ed, desc))
voter = voters.get(v_id)
if not voter:
row["Import Error"] = f"Voter {v_id} not found"
failed_rows.append(row)
errors += 1
continue
e_date = None
for fmt in ["%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d"]:
try:
e_date = datetime.strptime(raw_ed, fmt).date()
break
except: continue
if not e_date:
errors += 1
continue
vr = existing_records.get((v_id, e_date, desc))
created = False
if not vr:
vr = VotingRecord(voter=voter, election_date=e_date, election_description=desc, primary_party=party)
created = True
if not created and vr.primary_party == party:
skipped_no_change += 1
continue
vr.primary_party = party
if created:
to_create.append(vr)
to_create.append(VotingRecord(voter=voter, election_date=e_date, election_description=desc, primary_party=party))
created_count += 1
else:
elif vr.primary_party != party:
vr.primary_party = party
to_update.append(vr)
updated_count += 1
else:
skipped_no_change += 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:
VotingRecord.objects.bulk_create(to_create)
if to_update:
VotingRecord.objects.bulk_update(to_update, ["primary_party"], batch_size=250)
if to_create: VotingRecord.objects.bulk_create(to_create, batch_size=batch_size)
if to_update: VotingRecord.objects.bulk_update(to_update, ["primary_party"], batch_size=batch_size)
print(f"DEBUG: Voting record import progress: {total_processed} processed. {count} created/updated. {skipped_no_change} skipped (no change). {skipped_no_id} skipped (no ID/Data). {errors} errors.")
print(f"DEBUG: Voting record import progress: {total_processed} processed. {count} created/updated.")
if os.path.exists(file_path):
os.remove(file_path)
success_msg = f"Import complete: {count} voting records 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:votingrecord-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, f"Import complete: {count} voting records created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped, {errors} errors)")
return redirect("..")
except Exception as e:
print(f"DEBUG: Voting record import failed: {e}")
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
return redirect("..")
else:
@ -2055,20 +2008,15 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
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)
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 Voting Record Fields",
@ -2082,7 +2030,6 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
return render(request, "admin/import_mapping.html", context)
else:
form = VotingRecordImportForm()
context = self.admin_site.each_context(request)
context['form'] = form
context['title'] = "Import Voting Records"

View File

@ -317,7 +317,7 @@ class DoorVisitLogForm(forms.Form):
]
outcome = forms.ChoiceField(
choices=OUTCOME_CHOICES,
widget=forms.RadioSelect(attrs={"class": "form-check-input"}),
widget=forms.RadioSelect(attrs={"class": "btn-check"}),
label="Outcome"
)
notes = forms.CharField(

View File

@ -0,0 +1,150 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container-fluid py-5 px-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
<div>
<h1 class="h2 fw-bold text-dark mb-1">Door Visit History</h1>
<p class="text-muted mb-0">Review completed door-to-door visits and outcomes.</p>
</div>
<div class="d-flex gap-2">
<a href="{% url 'door_visits' %}" class="btn btn-outline-primary shadow-sm">
<i class="bi bi-door-open-fill me-1"></i> Planned Visits
</a>
<a href="{% url 'voter_list' %}" class="btn btn-primary shadow-sm">
<i class="bi bi-person-lines-fill me-1"></i> Voter Registry
</a>
</div>
</div>
<!-- Households List -->
<div class="card border-0 shadow-sm rounded-4 overflow-hidden">
<div class="card-header bg-white py-3 border-bottom d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0 fw-bold">Visited Households</h5>
<span class="badge bg-success-subtle text-success px-3 py-2 rounded-pill">
{{ history.paginator.count }} Households Visited
</span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light text-muted">
<tr>
<th class="ps-4 py-3 text-uppercase small ls-1">Household Address</th>
<th class="py-3 text-uppercase small ls-1">Voters Visited</th>
<th class="py-3 text-uppercase small ls-1">Last Visit</th>
<th class="py-3 text-uppercase small ls-1">Outcome</th>
<th class="pe-4 py-3 text-uppercase small ls-1">Interactions</th>
</tr>
</thead>
<tbody>
{% for household in history %}
<tr>
<td class="ps-4">
<div class="fw-bold text-dark">{{ household.address_display }}</div>
{% if household.neighborhood %}
<span class="badge bg-light text-primary border border-primary-subtle small mt-1">
{{ household.neighborhood }}
</span>
{% endif %}
{% if household.district %}
<span class="badge bg-light text-secondary border small mt-1">
District: {{ household.district }}
</span>
{% endif %}
</td>
<td>
<div class="d-flex flex-wrap gap-1">
{% for voter_name in household.voters_at_address %}
<span class="badge bg-light text-dark border fw-normal">
{{ voter_name }}
</span>
{% endfor %}
</div>
</td>
<td>
<div class="fw-bold text-dark">{{ household.last_visit_date|date:"M d, Y" }}</div>
<div class="small text-muted">{{ household.last_visit_date|date:"H:i" }}</div>
</td>
<td>
<span class="badge {% if 'Spoke' in household.last_outcome %}bg-success{% elif 'Literature' in household.last_outcome %}bg-info{% else %}bg-secondary{% endif %} bg-opacity-10 {% if 'Spoke' in household.last_outcome %}text-success{% elif 'Literature' in household.last_outcome %}text-info{% else %}text-secondary{% endif %} px-2 py-1">
{{ household.last_outcome }}
</span>
</td>
<td class="pe-4">
<span class="badge rounded-pill bg-light text-dark border">
{{ household.interaction_count }} Visit{{ household.interaction_count|pluralize }}
</span>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center py-5">
<div class="text-muted mb-2">
<i class="bi bi-calendar-x mb-2" style="font-size: 3rem; opacity: 0.3;"></i>
</div>
<p class="mb-0 fw-medium">No door visits logged yet.</p>
<p class="small text-muted">Visit the <a href="{% url 'door_visits' %}">Planned Visits</a> page to start logging visits.</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if history.paginator.num_pages > 1 %}
<div class="card-footer bg-white border-0 py-4">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0">
{% if history.has_previous %}
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page=1" aria-label="First">
<i class="bi bi-chevron-double-left small"></i>
</a>
</li>
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ history.previous_page_number }}" aria-label="Previous">
<i class="bi bi-chevron-left small"></i>
</a>
</li>
{% endif %}
<li class="page-item active mx-2"><span class="page-link rounded-pill px-3 border-0">Page {{ history.number }} of {{ history.paginator.num_pages }}</span></li>
{% if history.has_next %}
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ history.next_page_number }}" aria-label="Next">
<i class="bi bi-chevron-right small"></i>
</a>
</li>
<li class="page-item">
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ history.paginator.num_pages }}" aria-label="Last">
<i class="bi bi-chevron-double-right small"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
</div>
<style>
.ls-1 {
letter-spacing: 1px;
}
.bg-success-subtle {
background-color: #d1e7dd;
}
.text-success {
color: #0f5132 !important;
}
.bg-info-subtle {
background-color: #cff4fc;
}
.text-info {
color: #055160 !important;
}
</style>
{% endblock %}

View File

@ -1,91 +1,119 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Door Visits</h1>
<div class="container-fluid py-5 px-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
<div>
<span class="badge bg-primary rounded-pill px-3">{{ households.paginator.count }} Unvisited Households</span>
<h1 class="h2 fw-bold text-dark mb-1">Door Visits</h1>
<p class="text-muted mb-0">Manage and track your door-to-door campaign progress.</p>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-primary shadow-sm" data-bs-toggle="modal" data-bs-target="#mapModal">
<i class="bi bi-map-fill me-1"></i> View Map
</button>
<a href="{% url 'door_visit_history' %}" class="btn btn-outline-success shadow-sm me-2"><i class="bi bi-clock-history me-1"></i> Visit History</a>
<a href="{% url 'voter_list' %}" class="btn btn-primary shadow-sm">
<i class="bi bi-person-lines-fill me-1"></i> Voter Registry
</a>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<!-- Filters Card -->
<div class="card border-0 shadow-sm rounded-4 mb-5 overflow-hidden">
<div class="card-header bg-white py-3 border-0">
<h5 class="card-title mb-0 fw-bold text-primary">Filters</h5>
</div>
<div class="card-body p-4">
<h5 class="card-title mb-3 text-primary">Filters</h5>
<form action="." method="GET" class="row g-3">
<form method="GET" action="." class="row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label small text-muted fw-bold">District</label>
<input type="text" name="district" class="form-control" placeholder="Filter by district..." value="{{ district_filter }}">
<label class="form-label small fw-bold text-uppercase text-muted">District</label>
<input type="text" name="district" class="form-control rounded-3" placeholder="Filter by district..." value="{{ district_filter }}">
</div>
<div class="col-md-3">
<label class="form-label small text-muted fw-bold">Neighborhood</label>
<input type="text" name="neighborhood" class="form-control" placeholder="Partial neighborhood..." value="{{ neighborhood_filter }}">
<label class="form-label small fw-bold text-uppercase text-muted">Neighborhood</label>
<input type="text" name="neighborhood" class="form-control rounded-3" placeholder="Filter by neighborhood..." value="{{ neighborhood_filter }}">
</div>
<div class="col-md-4">
<label class="form-label small text-muted fw-bold">Address</label>
<input type="text" name="address" class="form-control" placeholder="Partial address..." value="{{ address_filter }}">
<label class="form-label small fw-bold text-uppercase text-muted">Address Search</label>
<input type="text" name="address" class="form-control rounded-3" placeholder="Filter by street address..." value="{{ address_filter }}">
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100 py-2">Apply Filters</button>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary w-100 rounded-3">Filter</button>
<a href="." class="btn btn-light w-100 rounded-3">Reset</a>
</div>
</form>
</div>
</div>
<div class="card border-0 shadow-sm overflow-hidden">
<div class="card-header bg-white py-3 border-bottom">
<h5 class="mb-0">Unvisited Households</h5>
<!-- Households List -->
<div class="card border-0 shadow-sm rounded-4 overflow-hidden">
<div class="card-header bg-white py-3 border-bottom d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0 fw-bold">Unvisited Targeted Households</h5>
<span class="badge bg-primary-subtle text-primary px-3 py-2 rounded-pill">
{{ households.paginator.count }} Households Found
</span>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="bg-light">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light text-muted">
<tr>
<th class="ps-4" style="min-width: 250px;">Target Voters</th>
<th>Neighborhood</th>
<th>Address</th>
<th>City, State</th>
<th class="text-end pe-4">Action</th>
<th class="ps-4 py-3 text-uppercase small ls-1">Action</th>
<th class="py-3 text-uppercase small ls-1">Household Address</th>
<th class="py-3 text-uppercase small ls-1">Targeted Voters</th>
<th class="py-3 text-uppercase small ls-1">Neighborhood</th>
<th class="pe-4 py-3 text-uppercase small ls-1">District</th>
</tr>
</thead>
<tbody>
{% for household in households %}
<tr>
<td class="ps-4">
{% for voter in household.target_voters %}
<a href="{% url 'voter_detail' voter.id %}" class="fw-semibold text-primary text-decoration-none hover-underline">
{{ voter.first_name }} {{ voter.last_name }}
</a>{% if not forloop.last %}<span class="text-muted">, </span>{% endif %}
{% endfor %}
</td>
<td>
{% if household.neighborhood %}
<span class="badge bg-primary-subtle text-primary border border-primary-subtle px-2 py-1">{{ household.neighborhood }}</span>
{% else %}
<span class="text-muted small italic">None</span>
{% endif %}
</td>
<td><span class="fw-medium text-dark">{{ household.address_street }}</span></td>
<td>{{ household.city }}, {{ household.state }}</td>
<td class="text-end pe-4">
<button type="button" class="btn btn-success btn-sm px-3 shadow-sm"
data-bs-toggle="modal"
data-bs-target="#logVisitModal"
<button type="button" class="btn btn-sm btn-primary px-3 shadow-sm"
data-bs-toggle="modal" data-bs-target="#logVisitModal"
data-address="{{ household.address_street }}"
data-city="{{ household.city }}"
data-state="{{ household.state }}"
data-zip="{{ household.zip_code }}">
<i class="bi bi-journal-check me-1"></i> Log Visit
Log Visit
</button>
</td>
<td>
<div class="fw-bold text-dark">{{ household.address_street }}</div>
<div class="small text-muted">{{ household.city }}, {{ household.state }} {{ household.zip_code }}</div>
</td>
<td>
<div class="d-flex flex-wrap gap-1">
{% for voter in household.target_voters %}
<a href="{% url 'voter_detail' voter.id %}" class="badge bg-light text-primary border border-primary-subtle text-decoration-none hover-underline">
{{ voter.first_name }} {{ voter.last_name }}
</a>
{% endfor %}
</div>
</td>
<td>
{% if household.neighborhood %}
<span class="badge border border-primary-subtle bg-primary-subtle text-primary fw-medium px-2 py-1">
{{ household.neighborhood }}
</span>
{% else %}
<span class="text-muted italic small">Not assigned</span>
{% endif %}
</td>
<td class="pe-4">
<span class="badge bg-light text-dark border fw-medium">
{{ household.district|default:"-" }}
</span>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center py-5">
<div class="text-muted">
<i class="bi bi-house-door fs-1 text-secondary opacity-25 mb-3 d-block"></i>
<p class="mb-0 fs-5">No unvisited households found.</p>
<p class="small text-muted">Try adjusting your filters or targeting more voters.</p>
<div class="text-muted mb-2">
<i class="bi bi-house-dash mb-2" style="font-size: 3rem; opacity: 0.3;"></i>
</div>
<p class="mb-0 fw-medium">No unvisited households found.</p>
<p class="small text-muted">Try adjusting your filters or target more voters.</p>
</td>
</tr>
{% endfor %}
@ -94,41 +122,33 @@
</div>
{% if households.paginator.num_pages > 1 %}
<div class="card-footer bg-white border-top py-4">
<div class="card-footer bg-white border-0 py-4">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0">
{% if households.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if district_filter %}&district={{ district_filter }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter }}{% endif %}{% if address_filter %}&address={{ address_filter }}{% endif %}" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page=1{% if district_filter %}&district={{ district_filter }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter }}{% endif %}{% if address_filter %}&address={{ address_filter }}{% endif %}" aria-label="First">
<i class="bi bi-chevron-double-left small"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ households.previous_page_number }}{% if district_filter %}&district={{ district_filter }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter }}{% endif %}{% if address_filter %}&address={{ address_filter }}{% endif %}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ households.previous_page_number }}{% if district_filter %}&district={{ district_filter }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter }}{% endif %}{% if address_filter %}&address={{ address_filter }}{% endif %}" aria-label="Previous">
<i class="bi bi-chevron-left small"></i>
</a>
</li>
{% endif %}
{% for num in households.paginator.page_range %}
{% if households.number == num %}
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
{% elif num > households.number|add:'-3' and num < households.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% if district_filter %}&district={{ district_filter }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter }}{% endif %}{% if address_filter %}&address={{ address_filter }}{% endif %}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
<li class="page-item active mx-2"><span class="page-link rounded-pill px-3 border-0">Page {{ households.number }} of {{ households.paginator.num_pages }}</span></li>
{% if households.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ households.next_page_number }}{% if district_filter %}&district={{ district_filter }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter }}{% endif %}{% if address_filter %}&address={{ address_filter }}{% endif %}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ households.next_page_number }}{% if district_filter %}&district={{ district_filter }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter }}{% endif %}{% if address_filter %}&address={{ address_filter }}{% endif %}" aria-label="Next">
<i class="bi bi-chevron-right small"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ households.paginator.num_pages }}{% if district_filter %}&district={{ district_filter }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter }}{% endif %}{% if address_filter %}&address={{ address_filter }}{% endif %}" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
<a class="page-link rounded-circle mx-1 border-0 bg-light text-dark" href="?page={{ households.paginator.num_pages }}{% if district_filter %}&district={{ district_filter }}{% endif %}{% if neighborhood_filter %}&neighborhood={{ neighborhood_filter }}{% endif %}{% if address_filter %}&address={{ address_filter }}{% endif %}" aria-label="Last">
<i class="bi bi-chevron-double-right small"></i>
</a>
</li>
{% endif %}
@ -139,38 +159,54 @@
</div>
</div>
<!-- Map Modal -->
<div class="modal fade" id="mapModal" tabindex="-1" aria-labelledby="mapModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen p-4">
<div class="modal-content rounded-4 border-0 shadow">
<div class="modal-header border-0 bg-primary text-white p-4">
<h5 class="modal-title d-flex align-items-center" id="mapModalLabel">
<i class="bi bi-map-fill me-2"></i> Targeted Households Map
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<div id="map" style="width: 100%; height: 100%; min-height: 500px;"></div>
</div>
</div>
</div>
</div>
<!-- Log Visit Modal -->
<div class="modal fade" id="logVisitModal" tabindex="-1" aria-labelledby="logVisitModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-0 shadow-lg">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content rounded-4 border-0 shadow-lg">
<div class="modal-header bg-light border-0 py-3">
<h5 class="modal-title fw-bold text-dark" id="logVisitModalLabel">Log Door Visit</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'log_door_visit' %}" method="POST">
{% csrf_token %}
<div class="modal-header bg-primary text-white border-0">
<h5 class="modal-title" id="logVisitModalLabel"><i class="bi bi-journal-plus me-2"></i>Log Door Visit</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">
<div class="mb-4 bg-light p-3 rounded border">
<p class="mb-0 small text-muted text-uppercase fw-bold ls-1">Address</p>
<p id="modalAddressDisplay" class="mb-0 fw-bold text-dark fs-5"></p>
<div class="bg-primary-subtle p-3 rounded-3 mb-4 border border-primary-subtle">
<div class="small text-uppercase fw-bold text-primary mb-1">Household Address</div>
<div id="modalAddressDisplay" class="h5 mb-0 fw-bold text-dark"></div>
</div>
<!-- Hidden fields for address -->
<input type="hidden" name="address_street" id="modal_address_street">
<input type="hidden" name="city" id="modal_city">
<input type="hidden" name="state" id="modal_state">
<input type="hidden" name="zip_code" id="modal_zip_code">
<div class="mb-4">
<label class="form-label fw-bold text-primary small text-uppercase">Outcome</label>
<div class="bg-light p-3 rounded border">
<label class="form-label fw-bold text-primary small text-uppercase">Visit Outcome</label>
<div class="row g-2">
{% for radio in visit_form.outcome %}
<div class="form-check mb-2 last-child-mb-0">
{{ radio.tag }}
<label class="form-check-label fw-medium" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
<div class="col-md-4">
{{ radio.tag }}
<label class="btn btn-outline-primary w-100 h-100 d-flex align-items-center justify-content-center text-center py-2 px-3" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
@ -204,8 +240,78 @@
</div>
</div>
<!-- Google Maps JS - Using global config matching voter page -->
{% if GOOGLE_MAPS_API_KEY %}
<script src="https://maps.googleapis.com/maps/api/js?key={{ GOOGLE_MAPS_API_KEY }}"></script>
{% endif %}
<script>
var map;
var markers = [];
var mapData = {{ map_data_json|safe }};
function initMap() {
if (!window.google || !window.google.maps) {
console.error("Google Maps API not loaded");
return;
}
var mapOptions = {
zoom: 12,
center: { lat: 41.8781, lng: -87.6298 }, // Default to Chicago if no data
mapTypeControl: true,
streetViewControl: true,
fullscreenControl: true
};
map = new google.maps.Map(document.getElementById('map'), mapOptions);
var bounds = new google.maps.LatLngBounds();
var infowindow = new google.maps.InfoWindow();
mapData.forEach(function(item) {
if (item.lat && item.lng) {
var position = { lat: parseFloat(item.lat), lng: parseFloat(item.lng) };
var marker = new google.maps.Marker({
position: position,
map: map,
title: item.address
});
marker.addListener('click', function() {
infowindow.setContent('<strong>' + item.address + '</strong><br>' + item.voters);
infowindow.open(map, marker);
});
markers.push(marker);
bounds.extend(position);
}
});
if (markers.length > 0) {
map.fitBounds(bounds);
}
}
document.addEventListener('DOMContentLoaded', function() {
// Trigger map initialization when modal is shown
var mapModal = document.getElementById('mapModal');
if (mapModal) {
mapModal.addEventListener('shown.bs.modal', function () {
if (!map) {
initMap();
} else {
google.maps.event.trigger(map, 'resize');
if (markers.length > 0) {
var bounds = new google.maps.LatLngBounds();
markers.forEach(function(marker) {
bounds.extend(marker.getPosition());
});
map.fitBounds(bounds);
}
}
});
}
var logVisitModal = document.getElementById('logVisitModal');
if (logVisitModal) {
logVisitModal.addEventListener('show.bs.modal', function (event) {
@ -226,6 +332,15 @@
</script>
<style>
.btn-outline-primary {
color: #059669 !important;
border-color: #059669 !important;
}
.btn-outline-primary:hover, .btn-check:checked + .btn-outline-primary {
background-color: #059669 !important;
color: #ffffff !important;
}
.hover-underline:hover {
text-decoration: underline !important;
}

View File

@ -99,6 +99,7 @@
</div>
<h6 class="text-uppercase fw-bold small text-muted mb-1">Door Visits</h6>
<h3 class="mb-0 fw-bold">{{ metrics.total_door_visits }}</h3>
<a href="{% url 'door_visit_history' %}" class="small text-decoration-none mt-2 d-inline-block text-primary">View Visits &rarr;</a>
</div>
</div>
</div>

View File

@ -37,6 +37,7 @@
<div class="mt-2">
<span class="badge bg-light text-dark border me-1">Voter ID: {{ voter.voter_id|default:"N/A" }}</span>
<span class="badge bg-light text-dark border me-1">District: {{ voter.district|default:"-" }}</span>
<span class="badge bg-light text-dark border me-1">Neighborhood: {{ voter.neighborhood|default:"-" }}</span>
<span class="badge bg-light text-dark border">Precinct: {{ voter.precinct|default:"-" }}</span>
</div>
</div>
@ -478,6 +479,10 @@
<label class="form-label fw-medium">{{ voter_form.voter_id.label }}</label>
{{ voter_form.voter_id }}
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ voter_form.neighborhood.label }}</label>
{{ voter_form.neighborhood }}
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ voter_form.district.label }}</label>
{{ voter_form.district }}

View File

@ -56,4 +56,5 @@ urlpatterns = [
# Door Visits
path('door-visits/', views.door_visits, name='door_visits'),
path('door-visits/log/', views.log_door_visit, name='log_door_visit'),
path('door-visits/history/', views.door_visit_history, name='door_visit_history'),
]

View File

@ -4,12 +4,14 @@ import urllib.parse
import urllib.request
import csv
import io
import json
from django.http import JsonResponse, HttpResponse
from django.urls import reverse
from django.shortcuts import render, redirect, get_object_or_404
from django.db.models import Q, Sum
from django.contrib import messages
from django.core.paginator import Paginator
from django.conf import settings
from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest, VolunteerRole
from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm
import logging
@ -49,7 +51,7 @@ def index(request):
'total_target_voters': voters.filter(is_targeted=True).count(),
'total_supporting': voters.filter(candidate_support='supporting').count(),
'total_target_households': voters.filter(is_targeted=True).exclude(address='').values('address').distinct().count(),
'total_door_visits': Interaction.objects.filter(voter__tenant=selected_tenant, type__name='Door Visit').count(),
'total_door_visits': voters.filter(door_visit=True).exclude(address='').values('address').distinct().count(),
'total_signs': voters.filter(Q(yard_sign='wants') | Q(yard_sign='has')).count(),
'total_window_stickers': voters.filter(Q(window_sticker='wants') | Q(window_sticker='has')).count(),
'total_donations': float(total_donations),
@ -103,7 +105,7 @@ def voter_list(request):
if request.GET.get("has_address") == "true":
voters = voters.exclude(address__isnull=True).exclude(address="")
if request.GET.get("visited") == "true":
voters = voters.filter(interactions__type__name="Door Visit").distinct()
voters = voters.filter(door_visit=True)
if request.GET.get("yard_sign") == "true":
voters = voters.filter(Q(yard_sign="wants") | Q(yard_sign="has"))
if request.GET.get("window_sticker") == "true":
@ -1221,6 +1223,8 @@ def door_visits(request):
'zip_code': voter.zip_code,
'neighborhood': voter.neighborhood,
'district': voter.district,
'latitude': float(voter.latitude) if voter.latitude else None,
'longitude': float(voter.longitude) if voter.longitude else None,
'street_name_sort': street_name.lower(),
'street_number_sort': street_number_sort,
'target_voters': []
@ -1234,6 +1238,17 @@ def door_visits(request):
x['street_number_sort']
))
# Prepare data for Google Map (all filtered households with coordinates)
map_data = [
{
'lat': h['latitude'],
'lng': h['longitude'],
'address': f"{h['address_street']}, {h['city']}, {h['state']}",
'voters': ", ".join([f"{v.first_name} {v.last_name}" for v in h['target_voters']])
}
for h in households_list if h['latitude'] and h['longitude']
]
paginator = Paginator(households_list, 50)
page_number = request.GET.get('page')
households_page = paginator.get_page(page_number)
@ -1244,6 +1259,8 @@ def door_visits(request):
'district_filter': district_filter,
'neighborhood_filter': neighborhood_filter,
'address_filter': address_filter,
'map_data_json': json.dumps(map_data),
'google_maps_api_key': getattr(settings, 'GOOGLE_MAPS_API_KEY', ''),
'visit_form': DoorVisitLogForm(),
}
return render(request, 'core/door_visits.html', context)
@ -1331,3 +1348,68 @@ def log_door_visit(request):
messages.error(request, "There was an error in the visit log form.")
return redirect('door_visits')
def door_visit_history(request):
"""
Shows a distinct list of Door visit interactions for addresses.
"""
selected_tenant_id = request.session.get("tenant_id")
if not selected_tenant_id:
messages.warning(request, "Please select a campaign first.")
return redirect("index")
tenant = get_object_or_404(Tenant, id=selected_tenant_id)
# Get all "Door Visit" interactions for this tenant, ordered by date desc
interactions = Interaction.objects.filter(
voter__tenant=tenant,
type__name="Door Visit"
).select_related('voter', 'volunteer').order_by('-date')
# Grouping by household (unique address)
visited_households = {}
for interaction in interactions:
v = interaction.voter
# Use concatenated address if available, otherwise build it
addr = v.address.strip() if v.address else f"{v.address_street}, {v.city}, {v.state} {v.zip_code}".strip(", ")
if not addr:
continue
key = addr.lower()
if key not in visited_households:
visited_households[key] = {
'address_display': addr,
'address_street': v.address_street,
'city': v.city,
'state': v.state,
'zip_code': v.zip_code,
'neighborhood': v.neighborhood,
'district': v.district,
'last_visit_date': interaction.date,
'last_outcome': interaction.description,
'voters_at_address': set(),
'interaction_count': 0,
'latest_interaction': interaction
}
visited_households[key]['voters_at_address'].add(f"{v.first_name} {v.last_name}")
visited_households[key]['interaction_count'] += 1
if interaction.date > visited_households[key]['last_visit_date']:
visited_households[key]['last_visit_date'] = interaction.date
visited_households[key]['last_outcome'] = interaction.description
visited_households[key]['latest_interaction'] = interaction
history_list = list(visited_households.values())
history_list.sort(key=lambda x: x['last_visit_date'], reverse=True)
paginator = Paginator(history_list, 50)
page_number = request.GET.get('page')
history_page = paginator.get_page(page_number)
context = {
'selected_tenant': tenant,
'history': history_page,
}
return render(request, 'core/door_visit_history.html', context)