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'), ('prior_state', 'Prior State'),
('zip_code', 'Zip Code'), ('zip_code', 'Zip Code'),
('county', 'County'), ('county', 'County'),
('neighborhood', 'Neighborhood'),
('phone', 'Phone'), ('phone', 'Phone'),
('notes', 'Notes'), ('notes', 'Notes'),
('phone_type', 'Phone Type'), ('phone_type', 'Phone Type'),
@ -263,6 +264,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
] ]
return my_urls + urls return my_urls + urls
def import_voters(self, request): def import_voters(self, request):
if request.method == "POST": if request.method == "POST":
if "_preview" in request.POST: if "_preview" in request.POST:
@ -276,7 +278,9 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
try: try:
with open(file_path, "r", encoding="utf-8-sig") as f: 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 total_count = sum(1 for line in f) - 1
f.seek(0) f.seek(0)
reader = csv.DictReader(f) 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() "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 = 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,
@ -324,7 +325,6 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR) self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR)
return redirect("..") return redirect("..")
elif "_import" in request.POST: elif "_import" in request.POST:
file_path = request.POST.get("file_path") file_path = request.POST.get("file_path")
tenant_id = request.POST.get("tenant") tenant_id = request.POST.get("tenant")
@ -340,48 +340,75 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
skipped_no_id = 0 skipped_no_id = 0
errors = 0 errors = 0
failed_rows = [] failed_rows = []
batch_size = 500 batch_size = 2000 # Increased batch size
# Pre-calculate choices and reverse mappings
support_choices = dict(Voter.SUPPORT_CHOICES) support_choices = dict(Voter.SUPPORT_CHOICES)
support_reverse = {v.lower(): k for k, v in support_choices.items()} 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()} 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_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: with open(file_path, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f) # Optimization: Use csv.reader instead of DictReader for performance
v_id_col = mapping.get("voter_id") raw_reader = csv.reader(f)
if not v_id_col: headers = next(raw_reader)
raise ValueError("Voter ID mapping is missing") 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 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(): with transaction.atomic():
voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)] voter_ids = []
existing_voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=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_create = []
to_update = [] to_update = []
batch_updated_fields = set()
processed_in_batch = set() processed_in_batch = set()
for row in chunk: for voter_id, row in chunk_data:
total_processed += 1 total_processed += 1
try: try:
raw_voter_id = row.get(v_id_col) if voter_id in processed_in_batch: continue
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
processed_in_batch.add(voter_id) processed_in_batch.add(voter_id)
voter = existing_voters.get(voter_id) voter = existing_voters.get(voter_id)
@ -391,30 +418,27 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
created = True created = True
changed = created 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 if field_name == "voter_id": continue
val = row.get(csv_col) if idx >= len(row): continue
if val is None: continue val = row[idx].strip()
val = str(val).strip() if val == "" and not created: continue # Skip empty updates for existing records unless specifically desired?
if val == "": continue
# Type conversion and normalization
if field_name == "is_targeted": 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"]: elif field_name in ["birthdate", "registration_date"]:
orig_val = val
parsed_date = None parsed_date = None
for fmt in ["%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d"]: for fmt in ["%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d"]:
try: try:
parsed_date = datetime.strptime(val, fmt).date() parsed_date = datetime.strptime(val, fmt).date()
break break
except: except: continue
continue if parsed_date: val = parsed_date
if parsed_date: else: continue
val = parsed_date
else:
# If parsing fails, keep original or skip? Let's skip updating this field.
continue
elif field_name == "candidate_support": elif field_name == "candidate_support":
val_lower = val.lower() val_lower = val.lower()
if val_lower in support_choices: val = val_lower if val_lower in support_choices: val = val_lower
@ -432,42 +456,45 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
else: val = "none" else: val = "none"
elif field_name in ["phone_type", "secondary_phone_type"]: elif field_name in ["phone_type", "secondary_phone_type"]:
val_lower = val.lower() val_lower = val.lower()
if val_lower in phone_type_choices: if val_lower in phone_type_choices: val = val_lower
val = val_lower elif val_lower in phone_type_reverse: val = phone_type_reverse[val_lower]
elif val_lower in phone_type_reverse: else: val = "cell"
val = phone_type_reverse[val_lower]
else:
val = "cell"
current_val = getattr(voter, field_name) if getattr(voter, field_name) != val:
if current_val != val:
setattr(voter, field_name, val) setattr(voter, field_name, val)
changed = True changed = True
record_updated_fields.add(field_name)
old_phone = voter.phone # Optimization: Only perform transformations if related fields are mapped
voter.phone = format_phone_number(voter.phone) if is_phone_related or created:
if voter.phone != old_phone: old_p = voter.phone
changed = True 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 if (is_coords_related or created) and voter.longitude:
voter.secondary_phone = format_phone_number(voter.secondary_phone)
if voter.secondary_phone != old_secondary_phone:
changed = True
if voter.longitude:
try: try:
new_lon = Decimal(str(voter.longitude)[:12]) new_lon = Decimal(str(voter.longitude)[:12])
if voter.longitude != new_lon: if voter.longitude != new_lon:
voter.longitude = new_lon voter.longitude = new_lon
changed = True changed = True
except: record_updated_fields.add("longitude")
pass except: pass
old_address = voter.address if is_address_related or created:
parts = [voter.address_street, voter.city, voter.state, voter.zip_code] old_addr = voter.address
voter.address = ", ".join([p for p in parts if p]) parts = [voter.address_street, voter.city, voter.state, voter.zip_code]
if voter.address != old_address: voter.address = ", ".join([p for p in parts if p])
changed = True if voter.address != old_addr:
changed = True
record_updated_fields.add("address")
if not changed: if not changed:
skipped_no_change += 1 skipped_no_change += 1
@ -478,27 +505,28 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
created_count += 1 created_count += 1
else: else:
to_update.append(voter) to_update.append(voter)
batch_updated_fields.update(record_updated_fields)
updated_count += 1 updated_count += 1
count += 1 count += 1
except Exception as e: except Exception as e:
print(f"DEBUG: Error importing row {total_processed}: {e}")
row["Import Error"] = str(e)
failed_rows.append(row)
errors += 1 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: if to_create:
Voter.objects.bulk_create(to_create) Voter.objects.bulk_create(to_create, batch_size=batch_size)
if to_update: 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): if os.path.exists(file_path):
os.remove(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, 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)
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
@ -515,14 +543,12 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if form.is_valid(): if form.is_valid():
csv_file = request.FILES["file"] csv_file = request.FILES["file"]
tenant = form.cleaned_data["tenant"] tenant = form.cleaned_data["tenant"]
if not csv_file.name.endswith(".csv"): if not csv_file.name.endswith(".csv"):
self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) self.message_user(request, "Please upload a CSV file.", level=messages.ERROR)
return redirect("..") return redirect("..")
with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp: with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp:
for chunk in csv_file.chunks(): for chunk in csv_file.chunks(): tmp.write(chunk)
tmp.write(chunk)
file_path = tmp.name file_path = tmp.name
with open(file_path, "r", encoding="utf-8-sig") as f: 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): if os.path.exists(file_path):
os.remove(file_path) os.remove(file_path)
self.message_user(request, f"Successfully imported {count} events.") self.message_user(request, f"Successfully imported {count} events.")
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows # 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 request.session.modified = True
logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}") logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}")
if errors > 0: if errors > 0:
@ -876,7 +903,8 @@ class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
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} volunteers.") 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 request.session.modified = True
if errors > 0: if errors > 0:
error_url = reverse("admin:volunteer-download-errors") error_url = reverse("admin:volunteer-download-errors")
@ -1076,7 +1104,8 @@ class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if os.path.exists(file_path): if os.path.exists(file_path):
os.remove(file_path) os.remove(file_path)
self.message_user(request, f"Successfully imported {count} participations.") self.message_user(request, f"Successfully imported {count} participations.")
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows # 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 request.session.modified = True
logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}") logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}")
if errors > 0: if errors > 0:
@ -1266,7 +1295,8 @@ class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if os.path.exists(file_path): if os.path.exists(file_path):
os.remove(file_path) os.remove(file_path)
self.message_user(request, f"Successfully imported {count} donations.") self.message_user(request, f"Successfully imported {count} donations.")
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows # 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 request.session.modified = True
logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}") logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}")
if errors > 0: if errors > 0:
@ -1468,7 +1498,8 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if os.path.exists(file_path): if os.path.exists(file_path):
os.remove(file_path) os.remove(file_path)
self.message_user(request, f"Successfully imported {count} interactions.") self.message_user(request, f"Successfully imported {count} interactions.")
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows # 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 request.session.modified = True
logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}") logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}")
if errors > 0: if errors > 0:
@ -1533,6 +1564,7 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
] ]
return my_urls + urls return my_urls + urls
def import_likelihoods(self, request): def import_likelihoods(self, request):
if request.method == "POST": if request.method == "POST":
if "_preview" in request.POST: if "_preview" in request.POST:
@ -1543,7 +1575,6 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
try: try:
with open(file_path, 'r', encoding='utf-8-sig') 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 total_count = sum(1 for line in f) - 1
f.seek(0) f.seek(0)
reader = csv.DictReader(f) reader = csv.DictReader(f)
@ -1616,17 +1647,17 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
skipped_no_id = 0 skipped_no_id = 0
errors = 0 errors = 0
failed_rows = [] failed_rows = []
batch_size = 500 batch_size = 2000
likelihood_choices = dict(VoterLikelihood.LIKELIHOOD_CHOICES) likelihood_choices = dict(VoterLikelihood.LIKELIHOOD_CHOICES)
likelihood_reverse = {v.lower(): k for k, v in likelihood_choices.items()} 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)} election_types = {et.name: et for et in ElectionType.objects.filter(tenant=tenant)}
with open(file_path, "r", encoding="utf-8-sig") as f: 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") v_id_col = mapping.get("voter_id")
et_col = mapping.get("election_type") et_col = mapping.get("election_type")
l_col = mapping.get("likelihood") 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: if not v_id_col or not et_col or not l_col:
raise ValueError("Missing mapping for Voter ID, Election Type, or Likelihood") 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 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(): with transaction.atomic():
voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)] voter_ids = []
et_names = [str(row.get(et_col)).strip() for row in chunk if row.get(et_col)] chunk_data = []
for row in chunk:
# Fetch existing voters 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")} voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids).only("id", "voter_id")}
et_names = [d[1] for d in chunk_data]
# Fetch existing likelihoods
existing_likelihoods = { existing_likelihoods = {
(vl.voter.voter_id, vl.election_type.name): vl (vl.voter.voter_id, vl.election_type.name): vl
for vl in VoterLikelihood.objects.filter( for vl in VoterLikelihood.objects.filter(
voter__tenant=tenant, voter__tenant=tenant,
voter__voter_id__in=voter_ids, voter__voter_id__in=voter_ids,
election_type__name__in=et_names 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_create = []
to_update = [] to_update = []
processed_in_batch = set() processed_in_batch = set()
for row in chunk: for v_id, et_name, l_val, row in chunk_data:
total_processed += 1 total_processed += 1
try: try:
raw_v_id = row.get(v_id_col) if (v_id, et_name) in processed_in_batch: continue
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)) processed_in_batch.add((v_id, et_name))
voter = voters.get(v_id) voter = voters.get(v_id)
if not voter: 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 errors += 1
continue continue
# Get or create election type
if et_name not in election_types: if et_name not in election_types:
election_type, _ = ElectionType.objects.get_or_create(tenant=tenant, name=et_name) election_type, _ = ElectionType.objects.get_or_create(tenant=tenant, name=et_name)
election_types[et_name] = election_type election_types[et_name] = election_type
election_type = election_types[et_name] election_type = election_types[et_name]
# Normalize likelihood
normalized_l = None normalized_l = None
l_val_lower = l_val.lower().replace(' ', '_') l_val_lower = l_val.lower().replace(' ', '_')
if l_val_lower in likelihood_choices: if l_val_lower in likelihood_choices: normalized_l = l_val_lower
normalized_l = l_val_lower elif l_val_lower in likelihood_reverse: normalized_l = likelihood_reverse[l_val_lower]
elif l_val_lower in likelihood_reverse:
normalized_l = likelihood_reverse[l_val_lower]
else: else:
# Try to find by display name more broadly
for k, v in likelihood_choices.items(): for k, v in likelihood_choices.items():
if v.lower() == l_val.lower(): if v.lower() == l_val.lower():
normalized_l = k normalized_l = k
break break
if not normalized_l: if not normalized_l:
row["Import Error"] = f"Invalid likelihood value: {l_val}"
failed_rows.append(row)
errors += 1 errors += 1
continue continue
vl = existing_likelihoods.get((v_id, et_name)) vl = existing_likelihoods.get((v_id, et_name))
created = False
if not vl: if not vl:
vl = VoterLikelihood(voter=voter, election_type=election_type, likelihood=normalized_l) to_create.append(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 created_count += 1
else: elif vl.likelihood != normalized_l:
vl.likelihood = normalized_l
to_update.append(vl) to_update.append(vl)
updated_count += 1 updated_count += 1
else:
skipped_no_change += 1
count += 1 count += 1
except Exception as e: except Exception as e:
print(f"DEBUG: Error importing row {total_processed}: {e}")
row["Import Error"] = str(e)
failed_rows.append(row)
errors += 1 errors += 1
if to_create: if to_create: VoterLikelihood.objects.bulk_create(to_create, batch_size=batch_size)
VoterLikelihood.objects.bulk_create(to_create) if to_update: VoterLikelihood.objects.bulk_update(to_update, ["likelihood"], batch_size=batch_size)
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.") print(f"DEBUG: Likelihood import progress: {total_processed} processed. {count} created/updated.")
if os.path.exists(file_path): if os.path.exists(file_path):
os.remove(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, f"Import complete: {count} likelihoods created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped, {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("..") return redirect("..")
except Exception as e: except Exception as e:
print(f"DEBUG: Likelihood 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:
@ -1770,20 +1763,15 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if form.is_valid(): if form.is_valid():
csv_file = request.FILES['file'] csv_file = request.FILES['file']
tenant = form.cleaned_data['tenant'] tenant = form.cleaned_data['tenant']
if not csv_file.name.endswith('.csv'): if not csv_file.name.endswith('.csv'):
self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) self.message_user(request, "Please upload a CSV file.", level=messages.ERROR)
return redirect("..") return redirect("..")
with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:
for chunk in csv_file.chunks(): for chunk in csv_file.chunks(): tmp.write(chunk)
tmp.write(chunk)
file_path = tmp.name file_path = tmp.name
with open(file_path, 'r', encoding='utf-8-sig') 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)
context = self.admin_site.each_context(request) context = self.admin_site.each_context(request)
context.update({ context.update({
'title': "Map Likelihood Fields", 'title': "Map Likelihood Fields",
@ -1797,7 +1785,6 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
return render(request, "admin/import_mapping.html", context) return render(request, "admin/import_mapping.html", context)
else: else:
form = VoterLikelihoodImportForm() form = VoterLikelihoodImportForm()
context = self.admin_site.each_context(request) context = self.admin_site.each_context(request)
context['form'] = form context['form'] = form
context['title'] = "Import Likelihoods" context['title'] = "Import Likelihoods"
@ -1832,6 +1819,7 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
] ]
return my_urls + urls return my_urls + urls
def import_voting_records(self, request): def import_voting_records(self, request):
if request.method == "POST": if request.method == "POST":
if "_preview" in request.POST: if "_preview" in request.POST:
@ -1842,7 +1830,6 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
try: try:
with open(file_path, 'r', encoding='utf-8-sig') as f: 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 total_count = sum(1 for line in f) - 1
f.seek(0) f.seek(0)
reader = csv.DictReader(f) reader = csv.DictReader(f)
@ -1875,15 +1862,13 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
e_date_raw = row.get(ed_col) e_date_raw = row.get(ed_col)
e_desc = str(row.get(desc_col, '')).strip() e_desc = str(row.get(desc_col, '')).strip()
# Try to parse date for accurate comparison in preview
e_date = None e_date = None
if e_date_raw: if e_date_raw:
for fmt in ["%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d"]: for fmt in ["%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d"]:
try: try:
e_date = datetime.strptime(str(e_date_raw).strip(), fmt).date() e_date = datetime.strptime(str(e_date_raw).strip(), fmt).date()
break break
except: except: continue
continue
action = "update" if (v_id, e_date, e_desc) in existing_records else "create" action = "update" if (v_id, e_date, e_desc) in existing_records else "create"
preview_data.append({ preview_data.append({
@ -1921,13 +1906,14 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
created_count = 0 created_count = 0
updated_count = 0 updated_count = 0
skipped_no_change = 0 skipped_no_change = 0
skipped_no_id = 0
errors = 0 errors = 0
failed_rows = [] batch_size = 2000
batch_size = 500
with open(file_path, "r", encoding="utf-8-sig") as f: 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") v_id_col = mapping.get("voter_id")
ed_col = mapping.get("election_date") ed_col = mapping.get("election_date")
desc_col = mapping.get("election_description") 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: if not v_id_col or not ed_col or not desc_col:
raise ValueError("Missing mapping for Voter ID, Election Date, or Description") 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 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(): with transaction.atomic():
voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)] voter_ids = [row[v_idx].strip() for row in chunk if len(row) > v_idx and row[v_idx].strip()]
# 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")} 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 = { existing_records = {
(vr.voter.voter_id, vr.election_date, vr.election_description): vr (vr.voter.voter_id, vr.election_date, vr.election_description): vr
for vr in VotingRecord.objects.filter( for vr in VotingRecord.objects.filter(
voter__tenant=tenant, voter__tenant=tenant,
voter__voter_id__in=voter_ids voter__voter_id__in=voter_ids
).select_related("voter") ).only("id", "election_date", "election_description", "voter__voter_id").select_related("voter")
} }
to_create = [] to_create = []
@ -1962,92 +1948,59 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
for row in chunk: for row in chunk:
total_processed += 1 total_processed += 1
try: try:
raw_v_id = row.get(v_id_col) if len(row) <= max(v_idx, ed_idx, desc_idx): continue
raw_ed = row.get(ed_col) v_id = row[v_idx].strip()
raw_desc = row.get(desc_col) raw_ed = row[ed_idx].strip()
party = str(row.get(party_col, '')).strip() if party_col else "" 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: if not v_id or not raw_ed or not desc: continue
skipped_no_id += 1
continue
v_id = str(raw_v_id).strip()
desc = str(raw_desc).strip()
# Parse date if (v_id, raw_ed, desc) in processed_in_batch: continue
e_date = None processed_in_batch.add((v_id, raw_ed, desc))
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))
voter = voters.get(v_id) voter = voters.get(v_id)
if not voter: if not voter:
row["Import Error"] = f"Voter {v_id} not found" errors += 1
failed_rows.append(row) 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 errors += 1
continue continue
vr = existing_records.get((v_id, e_date, desc)) vr = existing_records.get((v_id, e_date, desc))
created = False
if not vr: if not vr:
vr = VotingRecord(voter=voter, election_date=e_date, election_description=desc, primary_party=party) to_create.append(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)
created_count += 1 created_count += 1
else: elif vr.primary_party != party:
vr.primary_party = party
to_update.append(vr) to_update.append(vr)
updated_count += 1 updated_count += 1
else:
skipped_no_change += 1
count += 1 count += 1
except Exception as e: except Exception as e:
print(f"DEBUG: Error importing row {total_processed}: {e}")
row["Import Error"] = str(e)
failed_rows.append(row)
errors += 1 errors += 1
if to_create: if to_create: VotingRecord.objects.bulk_create(to_create, batch_size=batch_size)
VotingRecord.objects.bulk_create(to_create) if to_update: VotingRecord.objects.bulk_update(to_update, ["primary_party"], batch_size=batch_size)
if to_update:
VotingRecord.objects.bulk_update(to_update, ["primary_party"], batch_size=250)
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): if os.path.exists(file_path):
os.remove(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, f"Import complete: {count} voting records created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped, {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)
return redirect("..") return redirect("..")
except Exception as e: except Exception as e:
print(f"DEBUG: Voting record 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:
@ -2055,20 +2008,15 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
if form.is_valid(): if form.is_valid():
csv_file = request.FILES['file'] csv_file = request.FILES['file']
tenant = form.cleaned_data['tenant'] tenant = form.cleaned_data['tenant']
if not csv_file.name.endswith('.csv'): if not csv_file.name.endswith('.csv'):
self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) self.message_user(request, "Please upload a CSV file.", level=messages.ERROR)
return redirect("..") return redirect("..")
with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:
for chunk in csv_file.chunks(): for chunk in csv_file.chunks(): tmp.write(chunk)
tmp.write(chunk)
file_path = tmp.name file_path = tmp.name
with open(file_path, 'r', encoding='utf-8-sig') 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)
context = self.admin_site.each_context(request) context = self.admin_site.each_context(request)
context.update({ context.update({
'title': "Map Voting Record Fields", 'title': "Map Voting Record Fields",
@ -2082,7 +2030,6 @@ class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
return render(request, "admin/import_mapping.html", context) return render(request, "admin/import_mapping.html", context)
else: else:
form = VotingRecordImportForm() form = VotingRecordImportForm()
context = self.admin_site.each_context(request) context = self.admin_site.each_context(request)
context['form'] = form context['form'] = form
context['title'] = "Import Voting Records" context['title'] = "Import Voting Records"

View File

@ -317,7 +317,7 @@ class DoorVisitLogForm(forms.Form):
] ]
outcome = forms.ChoiceField( outcome = forms.ChoiceField(
choices=OUTCOME_CHOICES, choices=OUTCOME_CHOICES,
widget=forms.RadioSelect(attrs={"class": "form-check-input"}), widget=forms.RadioSelect(attrs={"class": "btn-check"}),
label="Outcome" label="Outcome"
) )
notes = forms.CharField( 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" %} {% extends "base.html" %}
{% load static %}
{% block content %} {% block content %}
<div class="container py-5"> <div class="container-fluid py-5 px-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
<h1 class="h2">Door Visits</h1>
<div> <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> </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"> <div class="card-body p-4">
<h5 class="card-title mb-3 text-primary">Filters</h5> <form method="GET" action="." class="row g-3 align-items-end">
<form action="." method="GET" class="row g-3">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label small text-muted fw-bold">District</label> <label class="form-label small fw-bold text-uppercase text-muted">District</label>
<input type="text" name="district" class="form-control" placeholder="Filter by district..." value="{{ district_filter }}"> <input type="text" name="district" class="form-control rounded-3" placeholder="Filter by district..." value="{{ district_filter }}">
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label small text-muted fw-bold">Neighborhood</label> <label class="form-label small fw-bold text-uppercase text-muted">Neighborhood</label>
<input type="text" name="neighborhood" class="form-control" placeholder="Partial neighborhood..." value="{{ neighborhood_filter }}"> <input type="text" name="neighborhood" class="form-control rounded-3" placeholder="Filter by neighborhood..." value="{{ neighborhood_filter }}">
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label small text-muted fw-bold">Address</label> <label class="form-label small fw-bold text-uppercase text-muted">Address Search</label>
<input type="text" name="address" class="form-control" placeholder="Partial address..." value="{{ address_filter }}"> <input type="text" name="address" class="form-control rounded-3" placeholder="Filter by street address..." value="{{ address_filter }}">
</div> </div>
<div class="col-md-2 d-flex align-items-end"> <div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary w-100 py-2">Apply Filters</button> <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> </div>
</form> </form>
</div> </div>
</div> </div>
<div class="card border-0 shadow-sm overflow-hidden"> <!-- Households List -->
<div class="card-header bg-white py-3 border-bottom"> <div class="card border-0 shadow-sm rounded-4 overflow-hidden">
<h5 class="mb-0">Unvisited Households</h5> <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>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0 align-middle"> <table class="table table-hover align-middle mb-0">
<thead class="bg-light"> <thead class="bg-light text-muted">
<tr> <tr>
<th class="ps-4" style="min-width: 250px;">Target Voters</th> <th class="ps-4 py-3 text-uppercase small ls-1">Action</th>
<th>Neighborhood</th> <th class="py-3 text-uppercase small ls-1">Household Address</th>
<th>Address</th> <th class="py-3 text-uppercase small ls-1">Targeted Voters</th>
<th>City, State</th> <th class="py-3 text-uppercase small ls-1">Neighborhood</th>
<th class="text-end pe-4">Action</th> <th class="pe-4 py-3 text-uppercase small ls-1">District</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for household in households %} {% for household in households %}
<tr> <tr>
<td class="ps-4"> <td class="ps-4">
{% for voter in household.target_voters %} <button type="button" class="btn btn-sm btn-primary px-3 shadow-sm"
<a href="{% url 'voter_detail' voter.id %}" class="fw-semibold text-primary text-decoration-none hover-underline"> data-bs-toggle="modal" data-bs-target="#logVisitModal"
{{ 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"
data-address="{{ household.address_street }}" data-address="{{ household.address_street }}"
data-city="{{ household.city }}" data-city="{{ household.city }}"
data-state="{{ household.state }}" data-state="{{ household.state }}"
data-zip="{{ household.zip_code }}"> data-zip="{{ household.zip_code }}">
<i class="bi bi-journal-check me-1"></i> Log Visit Log Visit
</button> </button>
</td> </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> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5" class="text-center py-5"> <td colspan="5" class="text-center py-5">
<div class="text-muted"> <div class="text-muted mb-2">
<i class="bi bi-house-door fs-1 text-secondary opacity-25 mb-3 d-block"></i> <i class="bi bi-house-dash mb-2" style="font-size: 3rem; opacity: 0.3;"></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> </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> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -94,41 +122,33 @@
</div> </div>
{% if households.paginator.num_pages > 1 %} {% 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"> <nav aria-label="Page navigation">
<ul class="pagination justify-content-center mb-0"> <ul class="pagination justify-content-center mb-0">
{% if households.has_previous %} {% if households.has_previous %}
<li class="page-item"> <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"> <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">
<span aria-hidden="true">&laquo;&laquo;</span> <i class="bi bi-chevron-double-left small"></i>
</a> </a>
</li> </li>
<li class="page-item"> <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"> <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">
<span aria-hidden="true">&laquo;</span> <i class="bi bi-chevron-left small"></i>
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% for num in households.paginator.page_range %} <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.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 %}
{% if households.has_next %} {% if households.has_next %}
<li class="page-item"> <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"> <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">
<span aria-hidden="true">&raquo;</span> <i class="bi bi-chevron-right small"></i>
</a> </a>
</li> </li>
<li class="page-item"> <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"> <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">
<span aria-hidden="true">&raquo;&raquo;</span> <i class="bi bi-chevron-double-right small"></i>
</a> </a>
</li> </li>
{% endif %} {% endif %}
@ -139,38 +159,54 @@
</div> </div>
</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 --> <!-- Log Visit Modal -->
<div class="modal fade" id="logVisitModal" tabindex="-1" aria-labelledby="logVisitModalLabel" aria-hidden="true"> <div class="modal fade" id="logVisitModal" tabindex="-1" aria-labelledby="logVisitModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content border-0 shadow-lg"> <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"> <form action="{% url 'log_door_visit' %}" method="POST">
{% csrf_token %} {% 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="modal-body p-4">
<div class="mb-4 bg-light p-3 rounded border"> <div class="bg-primary-subtle p-3 rounded-3 mb-4 border border-primary-subtle">
<p class="mb-0 small text-muted text-uppercase fw-bold ls-1">Address</p> <div class="small text-uppercase fw-bold text-primary mb-1">Household Address</div>
<p id="modalAddressDisplay" class="mb-0 fw-bold text-dark fs-5"></p> <div id="modalAddressDisplay" class="h5 mb-0 fw-bold text-dark"></div>
</div> </div>
<!-- Hidden fields for address -->
<input type="hidden" name="address_street" id="modal_address_street"> <input type="hidden" name="address_street" id="modal_address_street">
<input type="hidden" name="city" id="modal_city"> <input type="hidden" name="city" id="modal_city">
<input type="hidden" name="state" id="modal_state"> <input type="hidden" name="state" id="modal_state">
<input type="hidden" name="zip_code" id="modal_zip_code"> <input type="hidden" name="zip_code" id="modal_zip_code">
<div class="mb-4"> <div class="mb-4">
<label class="form-label fw-bold text-primary small text-uppercase">Outcome</label> <label class="form-label fw-bold text-primary small text-uppercase">Visit Outcome</label>
<div class="bg-light p-3 rounded border"> <div class="row g-2">
{% for radio in visit_form.outcome %} {% for radio in visit_form.outcome %}
<div class="form-check mb-2 last-child-mb-0"> <div class="col-md-4">
{{ radio.tag }} {{ radio.tag }}
<label class="form-check-label fw-medium" for="{{ radio.id_for_label }}"> <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 }} {{ radio.choice_label }}
</label> </label>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@ -204,8 +240,78 @@
</div> </div>
</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> <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() { 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'); var logVisitModal = document.getElementById('logVisitModal');
if (logVisitModal) { if (logVisitModal) {
logVisitModal.addEventListener('show.bs.modal', function (event) { logVisitModal.addEventListener('show.bs.modal', function (event) {
@ -226,6 +332,15 @@
</script> </script>
<style> <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 { .hover-underline:hover {
text-decoration: underline !important; text-decoration: underline !important;
} }

View File

@ -99,6 +99,7 @@
</div> </div>
<h6 class="text-uppercase fw-bold small text-muted mb-1">Door Visits</h6> <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> <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> </div>
</div> </div>

View File

@ -37,6 +37,7 @@
<div class="mt-2"> <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">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">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> <span class="badge bg-light text-dark border">Precinct: {{ voter.precinct|default:"-" }}</span>
</div> </div>
</div> </div>
@ -478,6 +479,10 @@
<label class="form-label fw-medium">{{ voter_form.voter_id.label }}</label> <label class="form-label fw-medium">{{ voter_form.voter_id.label }}</label>
{{ voter_form.voter_id }} {{ voter_form.voter_id }}
</div> </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"> <div class="col-md-4 mb-3">
<label class="form-label fw-medium">{{ voter_form.district.label }}</label> <label class="form-label fw-medium">{{ voter_form.district.label }}</label>
{{ voter_form.district }} {{ voter_form.district }}

View File

@ -56,4 +56,5 @@ urlpatterns = [
# Door Visits # Door Visits
path('door-visits/', views.door_visits, name='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/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 urllib.request
import csv import csv
import io import io
import json
from django.http import JsonResponse, HttpResponse from django.http import JsonResponse, HttpResponse
from django.urls import reverse from django.urls import reverse
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.db.models import Q, Sum from django.db.models import Q, Sum
from django.contrib import messages from django.contrib import messages
from django.core.paginator import Paginator 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 .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 from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm
import logging import logging
@ -49,7 +51,7 @@ def index(request):
'total_target_voters': voters.filter(is_targeted=True).count(), 'total_target_voters': voters.filter(is_targeted=True).count(),
'total_supporting': voters.filter(candidate_support='supporting').count(), 'total_supporting': voters.filter(candidate_support='supporting').count(),
'total_target_households': voters.filter(is_targeted=True).exclude(address='').values('address').distinct().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_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_window_stickers': voters.filter(Q(window_sticker='wants') | Q(window_sticker='has')).count(),
'total_donations': float(total_donations), 'total_donations': float(total_donations),
@ -103,7 +105,7 @@ def voter_list(request):
if request.GET.get("has_address") == "true": if request.GET.get("has_address") == "true":
voters = voters.exclude(address__isnull=True).exclude(address="") voters = voters.exclude(address__isnull=True).exclude(address="")
if request.GET.get("visited") == "true": 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": if request.GET.get("yard_sign") == "true":
voters = voters.filter(Q(yard_sign="wants") | Q(yard_sign="has")) voters = voters.filter(Q(yard_sign="wants") | Q(yard_sign="has"))
if request.GET.get("window_sticker") == "true": if request.GET.get("window_sticker") == "true":
@ -1221,6 +1223,8 @@ def door_visits(request):
'zip_code': voter.zip_code, 'zip_code': voter.zip_code,
'neighborhood': voter.neighborhood, 'neighborhood': voter.neighborhood,
'district': voter.district, '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_name_sort': street_name.lower(),
'street_number_sort': street_number_sort, 'street_number_sort': street_number_sort,
'target_voters': [] 'target_voters': []
@ -1234,6 +1238,17 @@ def door_visits(request):
x['street_number_sort'] 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) paginator = Paginator(households_list, 50)
page_number = request.GET.get('page') page_number = request.GET.get('page')
households_page = paginator.get_page(page_number) households_page = paginator.get_page(page_number)
@ -1244,6 +1259,8 @@ def door_visits(request):
'district_filter': district_filter, 'district_filter': district_filter,
'neighborhood_filter': neighborhood_filter, 'neighborhood_filter': neighborhood_filter,
'address_filter': address_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(), 'visit_form': DoorVisitLogForm(),
} }
return render(request, 'core/door_visits.html', context) 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.") messages.error(request, "There was an error in the visit log form.")
return redirect('door_visits') 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)