Autosave: 20260201-053401
This commit is contained in:
parent
77709c3744
commit
c5d42d341f
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
449
core/admin.py
449
core/admin.py
@ -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"
|
||||
|
||||
@ -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(
|
||||
|
||||
150
core/templates/core/door_visit_history.html
Normal file
150
core/templates/core/door_visit_history.html
Normal 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 %}
|
||||
@ -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">««</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">«</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">»</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">»»</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;
|
||||
}
|
||||
|
||||
@ -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 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user