From 4056b1778071f2380139bc1ff36cdfe17a868895 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 28 Jan 2026 13:06:12 +0000 Subject: [PATCH] Autosave: 20260128-130611 --- core/__pycache__/admin.cpython-311.pyc | Bin 84739 -> 90510 bytes core/__pycache__/forms.cpython-311.pyc | Bin 19392 -> 19476 bytes core/__pycache__/models.cpython-311.pyc | Bin 26385 -> 26588 bytes core/__pycache__/views.cpython-311.pyc | Bin 35974 -> 36543 bytes core/admin.py | 321 +++++++++++------- core/admin.py.tmp | 22 ++ core/forms.py | 3 +- core/migrations/0022_voter_notes.py | 18 + ...s_street_alter_voter_birthdate_and_more.py | 83 +++++ .../0022_voter_notes.cpython-311.pyc | Bin 0 -> 785 bytes ...r_voter_birthdate_and_more.cpython-311.pyc | Bin 0 -> 3151 bytes core/models.py | 29 +- .../templates/core/voter_advanced_search.html | 62 +++- core/templates/core/voter_detail.html | 15 +- core/templates/core/voter_list.html | 37 ++ core/views.py | 17 +- 16 files changed, 449 insertions(+), 158 deletions(-) create mode 100644 core/admin.py.tmp create mode 100644 core/migrations/0022_voter_notes.py create mode 100644 core/migrations/0023_alter_voter_address_street_alter_voter_birthdate_and_more.py create mode 100644 core/migrations/__pycache__/0022_voter_notes.cpython-311.pyc create mode 100644 core/migrations/__pycache__/0023_alter_voter_address_street_alter_voter_birthdate_and_more.cpython-311.pyc diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index ac9121497bafa660c18e09b444100f67902deb0f..b1885221482c5719671bac0e86f63295fc28fc64 100644 GIT binary patch delta 21824 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 H3zFbp;#Gi3 delta 3007 zcmbtWZ%k8H6z7)ql~Rk$p%ucP&Z1DW7Eu@#5G#VfKqLZgS&XHFH`~y@SMPi1l(@RF zME567J#KD%g7=BB_Uy`5Q- z$tGMgE6`drtVh^@(2UT^AZqb?hl4&v85V;1|Hf3b4%>i76U8dahONl z{gj~`p0ZSf(_$fwT9ai~r~4xp?k>GRM&VSUy?hiC1{pYXw4eQp=5CP=GC73$QSEkN zd2ZP{lv@xsqlfwB09F0okSwWw>VblylA$eV#Ik8UdIO!psN{wA;_6J6$5DyU53cfwSmEQG>xE5*2(Qc}!Ac{PA5Ox4jNiCW%IjnT5nSb!g3C?l{wl>`%d_J(B!_)mES2@<#16 ziM)qv|Ak95;XI*75so47GkgoBETq!6nL&AsIi@_cyiJyT*iL-vc)bBBpnAQ*90;$v zx&W8&VkU$Uc)>nF8sQiF-G+>Lk%vAhZZ_i23^Lwlx-ocAxhB4KxKUf7ZC(8*63YJY3o@2E}33wzYqk?Ug`KWVdrrn{P1^=x#8v+kdd3(vQ=b z4cgdX#*ln^WDH_`<`uY|c`AAaX~UQOI<%5MLtUY;Ox48m!>7o3xYIDs@5EV4`bmN0 z-n_tVcmXRyn1NgFH)F?eA->5;t)S6EhGI7_PtKb(OY%z?C#MdL-N(DmcdUDTywzntu+|coi`?p0z{Q-RtqA0}5gA*N!z~7w4!PMDgPP<=V oN2is{z~0X9>dHtTxtEh2PZ&-wo#(du=^icGP}k==eIrZt5Bx5N(EtDd diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 7fdfe9b1c1eab6d01fec947813bb13ac26074b63..89c49f21ace26f4fe5a8a70e41544d33a2e25d83 100644 GIT binary patch delta 2515 zcmZuye@t7~74~(EA-2Ib7-I*UXD}>43JEdk<^-^b!Tb!NKnS>@#I9dkX9SaHCrc+S zwVM2as#a>ZF#X8qruStx=;%tgEzcRg>rJWkOE3C|#sY?bJ3^Rz*t_?VM{! zX;e%1)4AXI&bjB_ckcE4MfTApR`jdFLY)eK7w4aj`C5Nl^qv!b-@TD_z(>v_mT1A^ z#)S%;JV8az4;^i|r8W>FJ$uH%kW+Mh9tdB{~DD=g%p5=ZVAs^b|; z*5;@vT41Qav8t%&iY;q%RJa~cJQS^tR-R&^hFR4UELwG{Ak-LjpHhXk&Z>^H(6(Wf zx~?YuuS$XS)oqX}F;O-zG8LZwVYVO|nT${Ws4ojpkkCA=9+8M0yXc1U0M6C2b=0 zpP=09X8#7C_fd8k&UhRmCp zPHPpD>ulhBwQTZbOg_crgHyg5{jxN2cY?7=gy}zH`@(+6C;c+?olx&J+Ofkh2gK)c}VV`S$-$dHOD|fHkPFD)=<`toAD+${^9@(b_2NHY|44=!(fCu*21^ z3@c8b>wbqh_~mM$0J@73&(($&-lsV@*?T2$ooMPvyij#y|C8Vb0dEj|f$%Q4Qn?=C zty?r2pE^376j$4~3C{GT9sfZcrpUD`+S$BBQi{z;_t1ue0V|~Xi`l~f{V$mAYoxpA zl=DYkof7bq^T2Ngt8{Lf6L{h3;7PSR7k~Vb zPt_I%@dGya$xvHa6N&mMd=COXe*9~C;POx}3xacasD@|c3wTJ&Zx$Cx6y+e!i}?&? z#vNo6wBtyefNdT1aASBvy(72sqa!QK0?qqoY*zHIE|t}W56AHXNM*>;*%q5R;?v<| zEI!>hiBEwvIk^{Z>>I1ibr~&5O6ee3^=M_ahsESm@u+l%7+Fz!}(l@El9Rm4iD=>nK|VdHAR9AnOiPhab|7kcVIjLg6_!0-uDxZ+Q#- zS;J9jYAP0KoC!+_QCfr@ks^nURj_J5kkmI?p> delta 2311 zcmZux3rrhz81KJpyV92R+HueV?Ol1uzzwLd6&Q@Nb;vegV|7gG*eJc5Ls`K)rq0(I zw-|hm`Kd8JGG9UFP+6*pE-`8}zT)&6&&Znjz{Pib&uENG{D0RK7xj|=@4ol<-S_{# zJM}&}^d4FCWNxlSN1x+&Uo#YHKDOv}kHv&MeSS5;DbH0^(>arRo$e?-nJ1E~*NF?S zuw{9{GHsnC)PlhE-a_*{bJ*)4Iq;IV1Wt1f_{HlaxnSft>mtFN4GDg(cxjf&#*5_t zE57|ahZ{M!QRCr3j$@gBH1pnWh(#9we+jze$?W@@t-a{2SQ_~MGTE|{$c2JW z@E#zMrQ^DtMBomXBg+K-fG$!!uDg~*YQnn8l4N-aM=Fo#B_H)l8wZD@@hc^c(h^VM zh(2wI?-?GJCXgZ01RN|4k_k{sz2E8~d)f@R)htgP5Vo=qmIMFV*kzP#u! z(<^B51bkh$lDrIM^{wRbq)>mxV0e|vVaG*Hi>22nCF3yhyi|zRZ@`hpQt}m?XuOQ1 zpt8x^{4R=5AiRh0KEhiFrw|l`eH79QM+UDDV`H-PJc`~%I0^fk_7}`i@;XcJ$e1Wq z%h9XFk0~#|Kp@_kpR8M1r6)Gn-f|(Sh1*)jP2Z#658zr=vsz2?3G(wLq^+aU(1^4{ z9*fdwSWns%8`%>d+nct9#Msd8=y3axI4oq!O2MtG?s7=A7{ZTm5@8N%S_{cHu)ftp zIM~&CQ=MXOO4*xKdsCug*5RATR~^2ou;TEi9Ddc|PpqG1_z9EB@Kb9PrZ&aYs!T0# z!SWo138a{S$^>9zur#2sZ7H@*W!nV6{@x95qYLeu?AAsfNBjSLbJBw@pjc# zJVj>m6x)iFZG~!E0ncKjYz6RX@CjHJv_VO*0G?{|?NglfDQCUvtWRv1buGEyq`H>O ztbWC=^bIPmSjrVsT`|SL&R4ivX;`O(`xGXUVj?ONNvxY?%O`qOwtU8^w1k!Z*iW?i zWWAog@;B&z*6@smiSExF<@oXCGxdt2A?0XL9SzC&>Rv()!pY_~FoiY~7WzUPO;-AY zk$!_`LN0oDZ-y!?W)vXU;g8U5dNYBP*_C`~Z5uHi#GW65)9otp!3!7PZ2AoaECf61 zO!?^cJM8T!w=hT|xL~RyMHrad>Q2JCM@jJn+BYL;i=o9^McGO{U5HH^K3ww|>gG^l zkSdY>9N{#=Hxy*tS)S(;nE^+`>lXEn4AZF*&rM`4?CMOqzoQx&A~S)MP8;PhX=qeB z3%jFk=<0HkFbQLg}g#QT1NcED5 zf=rJtn5o5sLWB#F1KmEN_h@~C2RA-Ud)2t%l0s=%BUA4>$CbSbWGM zG{98Pe!VZ_zjpI?#0mcJ)`A+;ccNz(g~|nzc5{9>99~b>!nfhgWm+&TwjXVAlQTvV zQiUiBz`!LgdmWMpL3-qy;B2T0j$P8Pugm0IvSpU$B)y~gc~pNcQ`!{WjoT@)blamY zINa+X-Ege;(u&LkVG0)G4j{FJQfbp}actL!ApMDw7A(u z>juK`sCFXUgFZa`*v>*lZyPH$Q(?9e!`R+YFyT;Fj&x)^?1Iu#LbWTONF{{fNFj6ZgIy zjf6Y^%7DkXg&K^&F9S~#86Jr?u@&g$#VFdnhLHLbevB@)gi(WVC6o^yBw=`dFpzhQ z>eIG8((v%mVD)HJl11t0-LcK&9SFx-DsibZ6Q^}a8#rwgv|G_WUaK+;FUCA1n*1nM fps(MiC(D1*{XdSk-_iNAraN=K#SO`loyW=FM@nMJ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index d9e576b320c132cc707196bdeccc7341baa65dab..ac9719adc1aad401a925e10b9050dcd344aadf68 100644 GIT binary patch delta 6605 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 MRCLyq85sufe*pXuKmY&$ delta 5953 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 LKXHG99lie-(@bv^ diff --git a/core/admin.py b/core/admin.py index 54572ac..4cf45bb 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,6 @@ +from decimal import Decimal +from datetime import datetime, date +from django.db import transaction from django.http import HttpResponse from django.utils.safestring import mark_safe import csv @@ -10,6 +13,7 @@ from django.urls import path, reverse from django.shortcuts import render, redirect from django.template.response import TemplateResponse from .models import ( + format_phone_number, Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter, VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings, Interest, Volunteer, VolunteerEvent, ParticipationStatus @@ -227,51 +231,58 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): def import_voters(self, request): if request.method == "POST": if "_preview" in request.POST: - file_path = request.POST.get('file_path') - tenant_id = request.POST.get('tenant') + file_path = request.POST.get("file_path") + tenant_id = request.POST.get("tenant") tenant = Tenant.objects.get(id=tenant_id) mapping = {} for field_name, _ in VOTER_MAPPABLE_FIELDS: - mapping[field_name] = request.POST.get(f'map_{field_name}') + mapping[field_name] = request.POST.get(f"map_{field_name}") try: - with open(file_path, 'r', encoding='UTF-8') as f: + with open(file_path, "r", encoding="UTF-8") as f: + # Optimization: Fast count and partial preview + total_count = sum(1 for line in f) - 1 + f.seek(0) reader = csv.DictReader(f) - total_count = 0 - create_count = 0 - update_count = 0 - preview_data = [] - for row in reader: - total_count += 1 - voter_id = row.get(mapping.get('voter_id')) - exists = Voter.objects.filter(tenant=tenant, voter_id=voter_id).exists() - if exists: - update_count += 1 - action = 'update' + preview_rows = [] + voter_ids_for_preview = [] + for i, row in enumerate(reader): + if i < 10: + preview_rows.append(row) + v_id = row.get(mapping.get("voter_id")) + if v_id: + voter_ids_for_preview.append(v_id) else: - create_count += 1 - action = 'create' - - if len(preview_data) < 10: - preview_data.append({ - 'action': action, - 'identifier': voter_id, - 'details': f"{row.get(mapping.get('first_name', '')) or ''} {row.get(mapping.get('last_name', '')) or ''}".strip() - }) + break + + existing_preview_ids = set(Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids_for_preview).values_list("voter_id", flat=True)) + + preview_data = [] + for row in preview_rows: + v_id = row.get(mapping.get("voter_id")) + action = "update" if v_id in existing_preview_ids else "create" + preview_data.append({ + "action": action, + "identifier": v_id, + "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, - 'preview_data': preview_data, - 'mapping': mapping, - 'file_path': file_path, - 'tenant_id': tenant_id, - 'action_url': request.path, - 'opts': self.model._meta, + "title": "Import Preview", + "total_count": total_count, + "create_count": create_count, + "update_count": update_count, + "preview_data": preview_data, + "mapping": mapping, + "file_path": file_path, + "tenant_id": tenant_id, + "action_url": request.path, + "opts": self.model._meta, }) return render(request, "admin/import_preview.html", context) except Exception as e: @@ -279,133 +290,191 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): return redirect("..") elif "_import" in request.POST: - file_path = request.POST.get('file_path') - tenant_id = request.POST.get('tenant') + file_path = request.POST.get("file_path") + tenant_id = request.POST.get("tenant") tenant = Tenant.objects.get(id=tenant_id) - mapping = {} - for field_name, _ in VOTER_MAPPABLE_FIELDS: - mapping[field_name] = request.POST.get(f'map_{field_name}') + mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTER_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")} try: - with open(file_path, 'r', encoding='UTF-8') as f: - reader = csv.DictReader(f) - count = 0 - errors = 0 - failed_rows = [] - for row in reader: - try: - voter_data = {} - voter_id = '' - for field_name, csv_col in mapping.items(): - if csv_col: - val = row.get(csv_col) - if val is not None and str(val).strip() != '': - if field_name == 'voter_id': - voter_id = val - continue - - if field_name == 'is_targeted': - val = str(val).lower() in ['true', '1', 'yes'] - voter_data[field_name] = val - - if 'candidate_support' in voter_data: - if voter_data['candidate_support'] not in dict(Voter.SUPPORT_CHOICES): - voter_data['candidate_support'] = 'unknown' - if 'yard_sign' in voter_data: - if voter_data['yard_sign'] not in dict(Voter.YARD_SIGN_CHOICES): - voter_data['yard_sign'] = 'none' - if 'window_sticker' in voter_data: - if voter_data['window_sticker'] not in dict(Voter.WINDOW_STICKER_CHOICES): - voter_data['window_sticker'] = 'none' - if 'phone_type' in voter_data: - pt_val = str(voter_data['phone_type']).lower() - pt_choices = dict(Voter.PHONE_TYPE_CHOICES) - if pt_val not in pt_choices: - # Try to match by display name - found = False - for k, v in pt_choices.items(): - if v.lower() == pt_val: - voter_data['phone_type'] = k - found = True - break - if not found: - voter_data['phone_type'] = 'cell' - else: - voter_data['phone_type'] = pt_val - - voter, created = Voter.objects.get_or_create( - tenant=tenant, - voter_id=voter_id, - ) - for key, value in voter_data.items(): - setattr(voter, key, value) - - # Flag that coordinates were provided in the import to avoid geocoding - if "latitude" in voter_data and "longitude" in voter_data: - voter._coords_provided_in_import = True - - voter.save() - count += 1 - except Exception as e: - logger.error(f"Error importing: {e}") - row["Import Error"] = str(e) - failed_rows.append(row) - errors += 1 + count = 0 + errors = 0 + failed_rows = [] + batch_size = 500 # Optimized batch size + # Pre-calculate choice dicts and sets + support_choices = dict(Voter.SUPPORT_CHOICES) + yard_sign_choices = dict(Voter.YARD_SIGN_CHOICES) + window_sticker_choices = dict(Voter.WINDOW_STICKER_CHOICES) + phone_type_choices = dict(Voter.PHONE_TYPE_CHOICES) + phone_type_reverse = {v.lower(): k for k, v in phone_type_choices.items()} + + # Fields to fetch for change detection + valid_fields = {f.name for f in Voter._meta.get_fields()} + mapped_fields = {f for f in mapping.keys() if f in valid_fields} + fetch_fields = list(mapped_fields | {"voter_id", "address", "phone", "longitude"}) + update_fields = list(mapped_fields | {"address", "phone"}) + if "voter_id" in update_fields: update_fields.remove("voter_id") + + def chunk_reader(reader, size): + chunk = [] + for row in reader: + chunk.append(row) + if len(chunk) == size: + yield chunk + chunk = [] + if chunk: + yield chunk + + with open(file_path, "r", encoding="UTF-8") 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") + + for chunk_index, chunk in enumerate(chunk_reader(reader, batch_size)): + with transaction.atomic(): + voter_ids = [row.get(v_id_col) for row in chunk if row.get(v_id_col)] + existing_voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids).only(*fetch_fields)} + + to_create = [] + to_update = [] + processed_in_batch = set() + + for row in chunk: + try: + voter_id = row.get(v_id_col) + if not voter_id or voter_id in processed_in_batch: + continue + processed_in_batch.add(voter_id) + + voter = existing_voters.get(voter_id) + created = False + if not voter: + voter = Voter(tenant=tenant, voter_id=voter_id) + created = True + + changed = created + + for field_name, csv_col in mapping.items(): + if field_name == "voter_id": continue + val = row.get(csv_col) + if val is None or str(val).strip() == "": continue + + # Type-specific conversions + if field_name == "is_targeted": + val = str(val).lower() in ["true", "1", "yes"] + elif field_name in ["birthdate", "registration_date"]: + try: + if isinstance(val, str): + val = datetime.strptime(val.strip(), "%Y-%m-%d").date() + except: + pass + elif field_name == "candidate_support": + if val not in support_choices: val = "unknown" + elif field_name == "yard_sign": + if val not in yard_sign_choices: val = "none" + elif field_name == "window_sticker": + if val not in window_sticker_choices: val = "none" + elif field_name == "phone_type": + val_lower = str(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 getattr(voter, field_name) != val: + setattr(voter, field_name, val) + changed = True + + # Special fields + old_phone = voter.phone + voter.phone = format_phone_number(voter.phone) + if voter.phone != old_phone: + changed = True + + if voter.longitude: + try: + new_lon = Decimal(str(voter.longitude)[:12]) + if voter.longitude != new_lon: + voter.longitude = new_lon + changed = True + 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 not changed: + continue + + if created: + to_create.append(voter) + else: + to_update.append(voter) + + count += 1 + except Exception as e: + logger.error(f"Error importing row: {e}") + row["Import Error"] = str(e) + failed_rows.append(row) + errors += 1 + + if to_create: + Voter.objects.bulk_create(to_create) + if to_update: + Voter.objects.bulk_update(to_update, update_fields, batch_size=250) + + logger.info(f"Voter import progress: Processed batch {chunk_index + 1}. Total successes: {count}") + if os.path.exists(file_path): os.remove(file_path) self.message_user(request, f"Successfully imported {count} voters.") request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows request.session.modified = True - logger.info(f"Stored {len(failed_rows)} failed rows in session for {self.model._meta.model_name}") if errors > 0: error_url = reverse("admin:voter-download-errors") self.message_user(request, mark_safe(f"Failed to import {errors} rows. Download failed records"), level=messages.WARNING) return redirect("..") except Exception as e: + logger.exception("Voter import failed") self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) return redirect("..") else: form = VoterImportForm(request.POST, request.FILES) if form.is_valid(): - csv_file = request.FILES['file'] - tenant = form.cleaned_data['tenant'] + csv_file = request.FILES["file"] + 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) 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(): tmp.write(chunk) file_path = tmp.name - with open(file_path, 'r', encoding='UTF-8') as f: + with open(file_path, "r", encoding="UTF-8") as f: reader = csv.reader(f) headers = next(reader) context = self.admin_site.each_context(request) context.update({ - 'title': "Map Voter Fields", - 'headers': headers, - 'model_fields': VOTER_MAPPABLE_FIELDS, - 'tenant_id': tenant.id, - 'file_path': file_path, - 'action_url': request.path, - 'opts': self.model._meta, + "title": "Map Voter Fields", + "headers": headers, + "model_fields": VOTER_MAPPABLE_FIELDS, + "tenant_id": tenant.id, + "file_path": file_path, + "action_url": request.path, + "opts": self.model._meta, }) return render(request, "admin/import_mapping.html", context) - else: - form = VoterImportForm() - - context = self.admin_site.each_context(request) - context['form'] = form - context['title'] = "Import Voters" - context['opts'] = self.model._meta - return render(request, "admin/import_csv.html", context) - -@admin.register(Event) class EventAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('id', 'name', 'event_type', 'date', 'start_time', 'end_time', 'tenant') list_filter = ('tenant', 'date', 'event_type') diff --git a/core/admin.py.tmp b/core/admin.py.tmp new file mode 100644 index 0000000..1dd03f8 --- /dev/null +++ b/core/admin.py.tmp @@ -0,0 +1,22 @@ +from django.http import HttpResponse +from django.utils.safestring import mark_safe +import csv +import io +import logging +import tempfile +import os +from decimal import Decimal +from django.contrib import admin, messages +from django.urls import path, reverse +from django.shortcuts import render, redirect +from django.template.response import TemplateResponse +from .models import ( + Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter, + VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings, + Interest, Volunteer, VolunteerEvent, ParticipationStatus, format_phone_number +) +from .forms import ( + VoterImportForm, EventImportForm, EventParticipationImportForm, + DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm, + VolunteerImportForm +) diff --git a/core/forms.py b/core/forms.py index a281af4..95ff70e 100644 --- a/core/forms.py +++ b/core/forms.py @@ -8,13 +8,14 @@ class VoterForm(forms.ModelForm): 'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state', 'prior_state', 'zip_code', 'county', 'latitude', 'longitude', 'phone', 'phone_type', 'email', 'voter_id', 'district', 'precinct', - 'registration_date', 'is_targeted', 'candidate_support', 'yard_sign', 'window_sticker' + 'registration_date', 'is_targeted', 'candidate_support', 'yard_sign', 'window_sticker', 'notes' ] widgets = { 'birthdate': forms.DateInput(attrs={'type': 'date'}), 'registration_date': forms.DateInput(attrs={'type': 'date'}), 'latitude': forms.TextInput(attrs={'class': 'form-control bg-light'}), 'longitude': forms.TextInput(attrs={'class': 'form-control bg-light'}), + 'notes': forms.Textarea(attrs={'rows': 3}), } def __init__(self, *args, **kwargs): diff --git a/core/migrations/0022_voter_notes.py b/core/migrations/0022_voter_notes.py new file mode 100644 index 0000000..3f3f725 --- /dev/null +++ b/core/migrations/0022_voter_notes.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-26 17:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0021_voter_phone_type'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='notes', + field=models.TextField(blank=True), + ), + ] diff --git a/core/migrations/0023_alter_voter_address_street_alter_voter_birthdate_and_more.py b/core/migrations/0023_alter_voter_address_street_alter_voter_birthdate_and_more.py new file mode 100644 index 0000000..161345e --- /dev/null +++ b/core/migrations/0023_alter_voter_address_street_alter_voter_birthdate_and_more.py @@ -0,0 +1,83 @@ +# Generated by Django 5.2.7 on 2026-01-28 04:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0022_voter_notes'), + ] + + operations = [ + migrations.AlterField( + model_name='voter', + name='address_street', + field=models.CharField(blank=True, db_index=True, max_length=255), + ), + migrations.AlterField( + model_name='voter', + name='birthdate', + field=models.DateField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name='voter', + name='candidate_support', + field=models.CharField(choices=[('unknown', 'Unknown'), ('supporting', 'Supporting'), ('not_supporting', 'Not Supporting')], db_index=True, default='unknown', max_length=20), + ), + migrations.AlterField( + model_name='voter', + name='city', + field=models.CharField(blank=True, db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='voter', + name='district', + field=models.CharField(blank=True, db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='voter', + name='first_name', + field=models.CharField(db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='voter', + name='is_targeted', + field=models.BooleanField(db_index=True, default=False), + ), + migrations.AlterField( + model_name='voter', + name='last_name', + field=models.CharField(db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='voter', + name='precinct', + field=models.CharField(blank=True, db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='voter', + name='state', + field=models.CharField(blank=True, db_index=True, max_length=2), + ), + migrations.AlterField( + model_name='voter', + name='voter_id', + field=models.CharField(blank=True, db_index=True, max_length=50), + ), + migrations.AlterField( + model_name='voter', + name='window_sticker', + field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants Sticker'), ('has', 'Has Sticker')], db_index=True, default='none', max_length=20, verbose_name='Window Sticker Status'), + ), + migrations.AlterField( + model_name='voter', + name='yard_sign', + field=models.CharField(choices=[('none', 'None'), ('wants', 'Wants a yard sign'), ('has', 'Has a yard sign')], db_index=True, default='none', max_length=20), + ), + migrations.AlterField( + model_name='voter', + name='zip_code', + field=models.CharField(blank=True, db_index=True, max_length=20), + ), + ] diff --git a/core/migrations/__pycache__/0022_voter_notes.cpython-311.pyc b/core/migrations/__pycache__/0022_voter_notes.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..90c4524c0e0b438393baf8a3640f4c615d1f67f7 GIT binary patch literal 785 zcmZuuyKdA#6uslu+EyZg6-We#B0>mL*dcXcCJI!hZGo%*mT6ZJc0B0kVL3>XLKtz% zcvSS-o$}k62h#w_F>nY7gFs=VPXbi|t_@pJcr}wN#<1N3Ogq<4(8L+OvvF?xD+f<8 zSqBA~!urrEL0MWa&AGU@wzmF)?xiKqX||IloR)_f7kaZI^aK;dwo8QGjlyKtRnd)f zK$9@$LTga}G~f~2bS+`hW}Q-DQ;Ky}#E~yh`nd`tSt}#PGoCP>^ai}hWz&TDim^BH zj9inUwapL8CKh&@@pe(fis4>(MuRY(AVUKc3uOnK-LQPsh&c zkEIL8`{{UN$E!PdjZdGKee$yj2dpkkv+ciHt_6h9vINn46dJ0GnR*Ky;ZYSJi3JGDUo*GcTwA3&i7K{b@2d1r|>P0B|q zjs+CRkfCEbW$)l7i2i_#9Xm>Z2IJOD0y@aBCl77kk)j;Mt{ep+#dlxt{qDVY@4eF> zsg#1?`TMsm^Pd5PUW-BaMBAOco6vcN2qKb&)}_3(j`LVT7|kMrpCBSrX;lt|wxRd3 zGs?>(l0%tjQ!HmNSJuq}(>>F%T^?I^2(?@zloaim_F(S-Af6$L@)ALLOr(O8mocJ| z)lezenuwX$YCHrW5qP83Shot*c=z}SO2S7;bfHA5$!@6=QIu|73WyB+E`dISBvnms zN5CoY1<#3Cr@FCxyN3FCnQBCsu#B*TV8fD=ES(0Jc9OVh}L%u zU+Tv4V>oq^eT8&hAyZH=F7xDKSSg@vL=1;fK6L&1jT_oS$D>TMfx0~E4|rN9gi+Vk zT#r%e{n-2$LM+6(9d>UObsYQ5l z*<@ak=pN0;Y!I+m3JCC&EsGBux=l>6sJZ1*$zfhb1{LFFd(Cz>Z63cDbVRjlGnjUP zE4hFf`mmA){X=n>sic=2Z{m=Hore9Iqr!>^j~hkDG^iUW5hrv-FIyf<`zjfxx78$~ zn;<5bZyFv~R!ru4zFisYO9jjWrmJ~6D^QP;yWz@tA)B=Hwg69*7&S~AV4|)EMm9y7 z^F-ivlQeILVR{q%>1=|(O=FF+jQj%5$aZY-;}TFXFuJMRo?A)X7tKVj&4;;M)ZNO! zoZgvZp95g!^nD*NWQ11t%5Emg#$YutK`0v7G5CUnaes0U-7ybYf8m-yhfj$={X4n~SoqV4vI_orBUM|~SIZGc=qwF~>yXmksx1<|1D{>)wc#mcwE56ipv3WZ;y0f8c zsT0^51Th%L=(W-oi^0A=5gqplnDe;vwqhtb)spdO?D0ZNLCNu7l}6&i&e`#u;fp)! ztH@s?HFdGBE;iJ~Ry>kab`g+$LEe%&|EAQRU)9vPx;ocT=Xy(B3Z+^I zU7f>k(Hy=nrgXe2noYbVnvn|L_36G(d*h!C@%yr6?7gHebfj*-%GK2Ax;ouZr+e!+ zzN1bFUr#;vUfrvyGj(;Qq0aP{IrQ|q@AG~Zo$FX6B4N0u&ezrXhC1I{zwW)%nz~R| z7aHn9Z>dhy)vjy=KRD}Oq-k;F^Y{nR{EQ!n#=&=GXhvpb5eKufh=W4%qz1+0mC!@jGjckryF#vXq!UiM9xmP>F8B+0677o2Nk z*)KPKi3t+-Yr*F8ivQ^ciJL*qy2Wn6P=e2W0Hh^Lk_5>tWoqcNqrX;cQIcXUbUb~k QG=?ti`iuMj!{Tf3ABFGwIsgCw literal 0 HcmV?d00001 diff --git a/core/models.py b/core/models.py index e847349..e88c2d4 100644 --- a/core/models.py +++ b/core/models.py @@ -133,30 +133,31 @@ class Voter(models.Model): ] tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='voters') - voter_id = models.CharField(max_length=50, blank=True) - first_name = models.CharField(max_length=100) - last_name = models.CharField(max_length=100) + voter_id = models.CharField(max_length=50, blank=True, db_index=True) + first_name = models.CharField(max_length=100, db_index=True) + last_name = models.CharField(max_length=100, db_index=True) nickname = models.CharField(max_length=100, blank=True) - birthdate = models.DateField(null=True, blank=True) + birthdate = models.DateField(null=True, blank=True, db_index=True) address = models.TextField(blank=True) - address_street = models.CharField(max_length=255, blank=True) - city = models.CharField(max_length=100, blank=True) - state = models.CharField(max_length=2, blank=True) + address_street = models.CharField(max_length=255, blank=True, db_index=True) + city = models.CharField(max_length=100, blank=True, db_index=True) + state = models.CharField(max_length=2, blank=True, db_index=True) prior_state = models.CharField(max_length=2, blank=True) - zip_code = models.CharField(max_length=20, blank=True) + zip_code = models.CharField(max_length=20, blank=True, db_index=True) county = models.CharField(max_length=100, blank=True) latitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) longitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) phone = models.CharField(max_length=20, blank=True) phone_type = models.CharField(max_length=10, choices=PHONE_TYPE_CHOICES, default='cell') email = models.EmailField(blank=True) - district = models.CharField(max_length=100, blank=True) - precinct = models.CharField(max_length=100, blank=True) + district = models.CharField(max_length=100, blank=True, db_index=True) + precinct = models.CharField(max_length=100, blank=True, db_index=True) registration_date = models.DateField(null=True, blank=True) - is_targeted = models.BooleanField(default=False) - candidate_support = models.CharField(max_length=20, choices=SUPPORT_CHOICES, default='unknown') - yard_sign = models.CharField(max_length=20, choices=YARD_SIGN_CHOICES, default='none') - window_sticker = models.CharField(max_length=20, choices=WINDOW_STICKER_CHOICES, default='none', verbose_name='Window Sticker Status') + is_targeted = models.BooleanField(default=False, db_index=True) + candidate_support = models.CharField(max_length=20, choices=SUPPORT_CHOICES, default='unknown', db_index=True) + yard_sign = models.CharField(max_length=20, choices=YARD_SIGN_CHOICES, default='none', db_index=True) + window_sticker = models.CharField(max_length=20, choices=WINDOW_STICKER_CHOICES, default='none', verbose_name='Window Sticker Status', db_index=True) + notes = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/core/templates/core/voter_advanced_search.html b/core/templates/core/voter_advanced_search.html index 731ed40..4bf18c5 100644 --- a/core/templates/core/voter_advanced_search.html +++ b/core/templates/core/voter_advanced_search.html @@ -78,23 +78,25 @@ {% csrf_token %} {% for key, value in request.GET.items %} - {% if key != 'csrfmiddlewaretoken' %} + {% if key != 'csrfmiddlewaretoken' and key != 'page' %} {% endif %} {% endfor %}
-
Search Results ({{ voters.count }})
-
- -
-
- +
Search Results ({{ voters.paginator.count }})
+
+
+ +
+
+ +
@@ -157,6 +159,42 @@
+ + {% if voters.paginator.num_pages > 1 %} + + {% endif %}
@@ -194,4 +232,4 @@ document.addEventListener('DOMContentLoaded', function() { }); }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/core/templates/core/voter_detail.html b/core/templates/core/voter_detail.html index c4d3b35..2dc1bb3 100644 --- a/core/templates/core/voter_detail.html +++ b/core/templates/core/voter_detail.html @@ -178,6 +178,15 @@ + +
+
+
Notes
+
+
+

{{ voter.notes|default:"No notes available." }}

+
+
@@ -486,6 +495,10 @@ {{ voter_form.window_sticker }} +
+ + {{ voter_form.notes }} +
+ + {% if voters.paginator.num_pages > 1 %} + + {% endif %} {% endblock %} diff --git a/core/views.py b/core/views.py index 8003067..b60b666 100644 --- a/core/views.py +++ b/core/views.py @@ -5,6 +5,7 @@ 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 .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm import logging @@ -123,8 +124,12 @@ def voter_list(request): voters = voters.filter(search_filter).order_by("last_name", "first_name") + paginator = Paginator(voters, 50) + page_number = request.GET.get('page') + voters_page = paginator.get_page(page_number) + context = { - "voters": voters, + "voters": voters_page, "query": query, "selected_tenant": tenant } @@ -442,9 +447,13 @@ def voter_advanced_search(request): if data.get('window_sticker'): voters = voters.filter(window_sticker=data['window_sticker']) + paginator = Paginator(voters, 50) + page_number = request.GET.get('page') + voters_page = paginator.get_page(page_number) + context = { 'form': form, - 'voters': voters, + 'voters': voters_page, 'selected_tenant': tenant, } return render(request, 'core/voter_advanced_search.html', context) @@ -519,7 +528,7 @@ def export_voters_csv(request): writer.writerow([ 'Voter ID', 'First Name', 'Last Name', 'Nickname', 'Birthdate', 'Address', 'City', 'State', 'Zip Code', 'Phone', 'Phone Type', 'Email', - 'District', 'Precinct', 'Is Targeted', 'Support', 'Yard Sign', 'Window Sticker' + 'District', 'Precinct', 'Is Targeted', 'Support', 'Yard Sign', 'Window Sticker', 'Notes' ]) for voter in voters: @@ -527,7 +536,7 @@ def export_voters_csv(request): voter.voter_id, voter.first_name, voter.last_name, voter.nickname, voter.birthdate, voter.address, voter.city, voter.state, voter.zip_code, voter.phone, voter.get_phone_type_display(), voter.email, voter.district, voter.precinct, 'Yes' if voter.is_targeted else 'No', - voter.get_candidate_support_display(), voter.get_yard_sign_display(), voter.get_window_sticker_display() + voter.get_candidate_support_display(), voter.get_yard_sign_display(), voter.get_window_sticker_display(), voter.notes ]) return response \ No newline at end of file