From f7bc2da3565d0e1ebb348fc997d73645adb9c7e2 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 31 Jan 2026 12:59:43 +0000 Subject: [PATCH] Autosave: 20260131-125943 --- config/__pycache__/settings.cpython-311.pyc | Bin 5675 -> 5917 bytes config/__pycache__/urls.cpython-311.pyc | Bin 1557 -> 1660 bytes config/csrf_settings.tmp | 13 + config/settings.py | 8 + config/urls.py | 1 + core/__pycache__/admin.cpython-311.pyc | Bin 101789 -> 115861 bytes core/__pycache__/forms.cpython-311.pyc | Bin 26742 -> 27446 bytes core/__pycache__/middleware.cpython-311.pyc | Bin 0 -> 1861 bytes core/admin.py | 337 ++- core/admin.py.bak | 1816 +++++++++++++++++ core/forms.py | 9 + core/middleware.py | 35 + .../admin/votingrecord_change_list.html | 38 + core/templates/base.html | 10 +- core/templates/core/volunteer_list.html | 12 +- core/templates/registration/login.html | 52 + 16 files changed, 2289 insertions(+), 42 deletions(-) create mode 100644 config/csrf_settings.tmp create mode 100644 core/__pycache__/middleware.cpython-311.pyc create mode 100644 core/admin.py.bak create mode 100644 core/middleware.py create mode 100644 core/templates/admin/votingrecord_change_list.html create mode 100644 core/templates/registration/login.html diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index de0615da81456726ed149ac097fc1eba20bda42d..45dee05ed9fe208f5350823c0d467c66804e7997 100644 GIT binary patch delta 1124 zcmah{OHUI~6ux(6N^PO-w9`UMUm#*Bl(sw+1QmrAC?Qk}B<4m#8qQFvh1Rx4(}YkH zVqBS!yD<@C!U7j8jOoUeYvaOT;-dCf)WpZajkm=IjEUaNdCd9FocX?!yYGAI*FV?k zw1}NCX(Rc_vaPprqUvRrh&Q2$Lo$D}4%Qk#2l_n@p?zkDCm|3$7!sHmWCMs>=zskR z+^C$l!iCTt8@*o}zghoBL5C1AfN2ZKh9h7dV|9s$%#2_rCT3Csfn^sHGmFhETA+nA zgO#*^ja4LQW!}DvlNcO30;6pOC$WJG+zMiCHDYv?b7DPxz_~XSvK8GOpgq181%3)T zkK=F}Jd8uvjyj1+D|n%sy?LRh?tOLNo5XCncJLDi1boEFM7o%{AxPR}4fGWn*;?9J z)?dq59QYXrsmQnRXW%UD=G}OR&hVZ5Fr1@VzBfFg+&A$ksE+>C(WU4(zy0+B_R@^d zMjLQ1O$(xD3@#nlM|$A$UzcH=?g<_|LEj0TqaosjF!3u!Pr{U9P5`D!5F(_H#eU%D zk$0waQuPE!3-&}dp37y^>3nWEyXId>#q+6jBDw63XEGJdbaZ}UY4U1lu7V03tNDB; zHxdZ^;|uz>VSBip-KkuVxgx3}m5ux3$_7id0cpjmhLy{Lw#pxcDOJD09(w)6cdVw@ zG+$hv<#bl|uO$Ie^4&N~XBo5gn0)(AGAje!(AY~iv>yfhf({v8ixpLk_UI}ea__+*WY$Pa=~_yqs} delta 809 zcmZWmO-vI(6rS0cQnqE=-L{nS({7;_TKQ?kzX}x;k?6&r;K9tr%WInY6B7s*qoF2E zG~SxIm{>0yYGOTT%7KIN>`hEK5aLa59$XVdqZi*+NG(kEec#M``{sS`&GWI^i06f- zxd^UTQ%i+vpzH}LJN!*TrxOavv73#M9fl6jstO@Bw0|qlgaLXZbC#q?$Y)S`N%Gh+l(_CXv&po=3qzR zn9MMf9+%fyoSlG^vdBhR29>^MX6#f;GdMhvXy)6qgo*ObX_?|Wn}lrn*Q9*FbvezA zpcsbf1CMh?uJR#zR(|5~)Qm{NIWe~Hx>-2iIuelrHcygs^{nF=)wa|Nm*=nDn7cZA zX^Y4~XE_r_5sPVrBC&`gq%>jE+iA5Cmu{)(-sCQe{U4U!obPE&uC@Q6ZE{upA-|}) z{NPsWUtQ~*zLamh@04V^{zMNb@r4e&f5EW9i}1TWd*?1J-Of!d02|k z;!Nq{<6BS1Kk~>1k62D_C`HZc*sUx-`$ya%<-09(8$p_ZD~9Sp(R)yIS;2ZS@KABD K6F+{9s(%3z{)y;1u0xr z+zbqBxFicD5ulxGC7L^*ka8B%~)lXr6*<5{N70W1=XOdlC0XR-Ekeqd$hXKLX7 Kz%^NhEdc-=O&R$B diff --git a/config/csrf_settings.tmp b/config/csrf_settings.tmp new file mode 100644 index 0000000..504f8f2 --- /dev/null +++ b/config/csrf_settings.tmp @@ -0,0 +1,13 @@ +CSRF_TRUSTED_ORIGINS = [ + "https://grassrootscrm.flatlogic.app", +] +CSRF_TRUSTED_ORIGINS += [ + origin for origin in [ + os.getenv("HOST_FQDN", ""), + os.getenv("CSRF_TRUSTED_ORIGIN", "") + ] if origin +] +CSRF_TRUSTED_ORIGINS = [ + f"https://{host}" if not host.startswith(("http://", "https://")) else host + for host in CSRF_TRUSTED_ORIGINS +] diff --git a/config/settings.py b/config/settings.py index e1e7409..f85f89d 100644 --- a/config/settings.py +++ b/config/settings.py @@ -23,10 +23,14 @@ DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true" ALLOWED_HOSTS = [ "127.0.0.1", "localhost", + "grassrootscrm.flatlogic.app", os.getenv("HOST_FQDN", ""), ] CSRF_TRUSTED_ORIGINS = [ + "https://grassrootscrm.flatlogic.app", +] +CSRF_TRUSTED_ORIGINS += [ origin for origin in [ os.getenv("HOST_FQDN", ""), os.getenv("CSRF_TRUSTED_ORIGIN", "") @@ -64,6 +68,7 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'core.middleware.LoginRequiredMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', # Disable X-Frame-Options middleware to allow Flatlogic preview iframes. # 'django.middleware.clickjacking.XFrameOptionsMiddleware', @@ -181,3 +186,6 @@ if EMAIL_USE_SSL: DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' GOOGLE_MAPS_API_KEY = os.getenv("GOOGLE_MAPS_API_KEY", "AIzaSyAluZTEjH-RSiGJUHnfrSqWbcAXCGzGOq4") +LOGIN_URL = 'login' +LOGIN_REDIRECT_URL = 'index' +LOGOUT_REDIRECT_URL = 'login' diff --git a/config/urls.py b/config/urls.py index bcfc074..270c09c 100644 --- a/config/urls.py +++ b/config/urls.py @@ -22,6 +22,7 @@ from django.conf.urls.static import static urlpatterns = [ path("admin/", admin.site.urls), path("", include("core.urls")), + path("accounts/", include("django.contrib.auth.urls")), ] if settings.DEBUG: diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 4bd051848a14c2d4d83c9a0572e47bc181db811f..9a8a5125e1db71ffd6225a33557d63636744dd2a 100644 GIT binary patch delta 27699 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 T3nGehQFvhnDVHNanZW-8A&u0N delta 20003 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 delta 102 zcmdmXjq%$BM!w~|yj%=Gu=00x=3}#od=gCmm^NzsVPZ?s2xidK+#JAsHlEo}Q+0A< v>Jeo}pu!?25aA3YesS33=BJeAq}mnz2XYyKxVU$7ZdxnL&1)M+6rcUlmn~aC!LnsJ5sh1_;%J?O9$GtYX$^64>p%_t5R_%B9ZPaoyYkF# z?T8ilV5kyu@TGNWDAmbr@FkZ(&;1LeBB)p}1bRttDsDmK)Hl198rez~{P%&OB-4hY|oh|wVYmZKrVLCt>(l{NJxg5tanb-NUkP|#v(8aQplQ_hq zTSyc4kR~1S2pz%eQ!bzd)=?(-2U})1H$<>jA~93=QlOekh*iY=>T6lnhRJ8$An z7bi&&4iH8;PD43f*OqOcvlh zx#*d5I0q>p`J|zB^pINvUq%bqQV6CM(*WOiSEN|qAPg= z{FzOAy+R9Y2b`p4a%xkE*WxufDK&8y5k1CC+`6Jnp*`d0C)7+RE@jKH$Zou&U*C` zI)GlfpJYWx7*oW};A-n;Cg_Sb#l(%3sG{0d5gS%1uUc4h!^~qS_6?ITs9Zrc?hqD$*F%{Q zVJT+Kzhz@;xnU1}g~b~2bK}V)&{pM&#boM+;Szt%ejt8lk_j+FL%`BLz3rjH8qE*= z{~FCd!H}oF0P8`gjZfTn+T(LwG!b6x#FOp#Y<;;KlA_m+Q&$?dTB*5qYHmM#JTu$) zq&1Um&t&%_`;lIk=cCs;sqB8ZGc(ULpzHOz0dyhRiNToa2F2;I`s&|fXe#wQnm@0? zsx`gR20ppco?NNF_avU`A}+dkoVeV0qnUma#-n9_ZY37liG^lt;YmF4TYUD{_-y0j zR{Ux^ezhLzj3?^j9uVJLdL2Cu+!WR_5`)1|%UDs|h@zBD&DNPbswi8ws{1=63Zavi zfIUQBayHZO zO6rw^J94UKDfUaHugROn24UCx&b-1TR(*c`hZg;KmdIb9i*n c<>v6{pmcM1bj3>?#BrYe$#j4H$A;(5KZY>WDgXcg literal 0 HcmV?d00001 diff --git a/core/admin.py b/core/admin.py index 78b901e..8466f7e 100644 --- a/core/admin.py +++ b/core/admin.py @@ -21,7 +21,7 @@ from .models import ( from .forms import ( VoterImportForm, EventImportForm, EventParticipationImportForm, DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm, - VolunteerImportForm + VolunteerImportForm, VotingRecordImportForm ) logger = logging.getLogger(__name__) @@ -108,6 +108,13 @@ VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [ ('likelihood', 'Likelihood'), ] +VOTING_RECORD_MAPPABLE_FIELDS = [ + ('voter_id', 'Voter ID'), + ('election_date', 'Election Date'), + ('election_description', 'Election Description'), + ('primary_party', 'Primary Party'), +] + class BaseImportAdminMixin: def download_errors(self, request): logger.info(f"download_errors called for {self.model._meta.model_name}") @@ -131,6 +138,15 @@ class BaseImportAdminMixin: return response + def chunk_reader(self, reader, size): + chunk = [] + for row in reader: + chunk.append(row) + if len(chunk) == size: + yield chunk + chunk = [] + if chunk: + yield chunk class TenantUserRoleInline(admin.TabularInline): model = TenantUserRole extra = 1 @@ -323,25 +339,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): 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()} - valid_fields = {f.name for f in Voter._meta.get_fields()} - mapped_fields = {f for f in mapping.keys() if f in valid_fields} - # Ensure derived/special fields are in update_fields - update_fields = list(mapped_fields | {"address", "phone", "secondary_phone", "secondary_phone_type", "longitude", "latitude"}) - 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-sig") as f: reader = csv.DictReader(f) @@ -352,7 +350,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): print(f"DEBUG: Starting voter import. Tenant: {tenant.name}. Voter ID column: {v_id_col}") total_processed = 0 - for chunk_index, chunk in enumerate(chunk_reader(reader, batch_size)): + for chunk_index, chunk in enumerate(self.chunk_reader(reader, batch_size)): with transaction.atomic(): voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)] existing_voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids)} @@ -917,11 +915,13 @@ class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin): class VolunteerEventAdmin(admin.ModelAdmin): list_display = ('volunteer', 'event', 'role') list_filter = ('event__tenant', 'event', 'role') + autocomplete_fields = ["volunteer", "event"] @admin.register(EventParticipation) class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('voter', 'event', 'participation_status') list_filter = ('event__tenant', 'event', 'participation_status') + autocomplete_fields = ["voter", "event"] change_list_template = "admin/eventparticipation_change_list.html" def get_urls(self): @@ -1122,6 +1122,7 @@ class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('id', 'voter', 'date', 'amount', 'method') list_filter = ('voter__tenant', 'date', 'method') search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id') + autocomplete_fields = ["voter"] change_list_template = "admin/donation_change_list.html" def get_urls(self): @@ -1311,6 +1312,7 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('id', 'voter', 'volunteer', 'type', 'date', 'description') list_filter = ('voter__tenant', 'type', 'date', 'volunteer') search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description', 'volunteer__first_name', 'volunteer__last_name') + autocomplete_fields = ["voter", "volunteer"] change_list_template = "admin/interaction_change_list.html" def get_urls(self): @@ -1512,6 +1514,7 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('id', 'voter', 'election_type', 'likelihood') list_filter = ('voter__tenant', 'election_type', 'likelihood') search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id') + autocomplete_fields = ["voter"] change_list_template = "admin/voterlikelihood_change_list.html" def get_urls(self): @@ -1613,15 +1616,6 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): # Pre-fetch election types for this tenant election_types = {et.name: et for et in ElectionType.objects.filter(tenant=tenant)} - def chunk_reader(reader, size): - chunk = [] - for row in reader: - chunk.append(row) - if len(chunk) == size: - yield chunk - chunk = [] - if chunk: - yield chunk with open(file_path, "r", encoding="utf-8-sig") as f: reader = csv.DictReader(f) @@ -1635,7 +1629,7 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): print(f"DEBUG: Starting likelihood import. Tenant: {tenant.name}") total_processed = 0 - for chunk in chunk_reader(reader, batch_size): + for chunk in self.chunk_reader(reader, batch_size): with transaction.atomic(): voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)] et_names = [str(row.get(et_col)).strip() for row in chunk if row.get(et_col)] @@ -1751,7 +1745,7 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): 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(success_msg) + self.message_user(request, success_msg) request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows request.session.modified = True @@ -1806,4 +1800,283 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): class CampaignSettingsAdmin(admin.ModelAdmin): list_display = ('tenant', 'donation_goal', 'twilio_from_number') list_filter = ('tenant',) - fields = ('tenant', 'donation_goal', 'twilio_account_sid', 'twilio_auth_token', 'twilio_from_number') \ No newline at end of file + fields = ('tenant', 'donation_goal', 'twilio_account_sid', 'twilio_auth_token', 'twilio_from_number') + +@admin.register(VotingRecord) +class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('voter', 'election_date', 'election_description', 'primary_party') + list_filter = ('voter__tenant', 'election_date', 'primary_party') + search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'election_description') + autocomplete_fields = ["voter"] + change_list_template = "admin/votingrecord_change_list.html" + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + from core.models import Tenant + extra_context["tenants"] = Tenant.objects.all() + return super().changelist_view(request, extra_context=extra_context) + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='votingrecord-download-errors'), + path('import-voting-records/', self.admin_site.admin_view(self.import_voting_records), name='import-voting-records'), + ] + return my_urls + urls + + def import_voting_records(self, request): + if request.method == "POST": + if "_preview" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTING_RECORD_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")} + + 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) + preview_rows = [] + voter_ids_for_preview = set() + + v_id_col = mapping.get('voter_id') + ed_col = mapping.get('election_date') + desc_col = mapping.get('election_description') + + if not v_id_col or not ed_col or not desc_col: + raise ValueError("Missing mapping for Voter ID, Election Date, or Description") + + for i, row in enumerate(reader): + if i < 10: + preview_rows.append(row) + v_id = row.get(v_id_col) + if v_id: voter_ids_for_preview.add(str(v_id).strip()) + else: + break + + existing_records = set(VotingRecord.objects.filter( + voter__tenant=tenant, + voter__voter_id__in=voter_ids_for_preview + ).values_list("voter__voter_id", "election_date", "election_description")) + + preview_data = [] + for row in preview_rows: + v_id = str(row.get(v_id_col, '')).strip() + 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 + + action = "update" if (v_id, e_date, e_desc) in existing_records else "create" + preview_data.append({ + "action": action, + "identifier": f"Voter: {v_id}, Election: {e_desc}", + "details": f"Date: {e_date or e_date_raw}" + }) + + context = self.admin_site.each_context(request) + context.update({ + "title": "Import Preview", + "total_count": total_count, + "create_count": "N/A", + "update_count": "N/A", + "preview_data": preview_data, + "mapping": mapping, + "file_path": file_path, + "tenant_id": tenant_id, + "action_url": request.path, + "opts": self.model._meta, + }) + return render(request, "admin/import_preview.html", context) + except Exception as e: + self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR) + return redirect("..") + + elif "_import" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTING_RECORD_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")} + + try: + count = 0 + created_count = 0 + updated_count = 0 + skipped_no_change = 0 + skipped_no_id = 0 + errors = 0 + failed_rows = [] + batch_size = 500 + + with open(file_path, "r", encoding="utf-8-sig") as f: + reader = csv.DictReader(f) + v_id_col = mapping.get("voter_id") + ed_col = mapping.get("election_date") + desc_col = mapping.get("election_description") + party_col = mapping.get("primary_party") + + 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}") + + total_processed = 0 + for chunk in self.chunk_reader(reader, batch_size): + with transaction.atomic(): + voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)] + + # Fetch existing voters + voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids).only("id", "voter_id")} + + # Fetch existing 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") + } + + to_create = [] + to_update = [] + processed_in_batch = set() + + for row in chunk: + total_processed += 1 + try: + raw_v_id = row.get(v_id_col) + raw_ed = row.get(ed_col) + raw_desc = row.get(desc_col) + party = str(row.get(party_col, '')).strip() if party_col 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() + + # 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)) + + voter = voters.get(v_id) + if not voter: + row["Import Error"] = f"Voter {v_id} not found" + failed_rows.append(row) + 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) + created_count += 1 + else: + to_update.append(vr) + updated_count += 1 + + count += 1 + except Exception as e: + print(f"DEBUG: Error importing row {total_processed}: {e}") + row["Import Error"] = str(e) + failed_rows.append(row) + errors += 1 + + if to_create: + VotingRecord.objects.bulk_create(to_create) + if to_update: + VotingRecord.objects.bulk_update(to_update, ["primary_party"], batch_size=250) + + print(f"DEBUG: Voting record import progress: {total_processed} processed. {count} created/updated. {skipped_no_change} skipped (no change). {skipped_no_id} skipped (no ID/Data). {errors} errors.") + + 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. Download failed records"), level=messages.WARNING) + 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: + form = VotingRecordImportForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = request.FILES['file'] + tenant = form.cleaned_data['tenant'] + + if not csv_file.name.endswith('.csv'): + self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) + return redirect("..") + + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in csv_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + + with open(file_path, 'r', encoding='utf-8-sig') as f: + reader = csv.reader(f) + headers = next(reader) + + context = self.admin_site.each_context(request) + context.update({ + 'title': "Map Voting Record Fields", + 'headers': headers, + 'model_fields': VOTING_RECORD_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 = VotingRecordImportForm() + + context = self.admin_site.each_context(request) + context['form'] = form + context['title'] = "Import Voting Records" + context['opts'] = self.model._meta + return render(request, "admin/import_csv.html", context) diff --git a/core/admin.py.bak b/core/admin.py.bak new file mode 100644 index 0000000..883003b --- /dev/null +++ b/core/admin.py.bak @@ -0,0 +1,1816 @@ +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 +import io +import logging +import tempfile +import os +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 ( + format_phone_number, + Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter, + VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings, + Interest, Volunteer, VolunteerEvent, ParticipationStatus +) +from .forms import ( + VoterImportForm, EventImportForm, EventParticipationImportForm, + DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm, + VolunteerImportForm, VotingRecordImportForm +) + +logger = logging.getLogger(__name__) + +VOTER_MAPPABLE_FIELDS = [ + ('voter_id', 'Voter ID'), + ('first_name', 'First Name'), + ('last_name', 'Last Name'), + ('nickname', 'Nickname'), + ('birthdate', 'Birthdate'), + ('address_street', 'Street Address'), + ('city', 'City'), + ('state', 'State'), + ('prior_state', 'Prior State'), + ('zip_code', 'Zip Code'), + ('county', 'County'), + ('phone', 'Phone'), + ('notes', 'Notes'), + ('phone_type', 'Phone Type'), + ('email', 'Email'), + ('district', 'District'), + ('precinct', 'Precinct'), + ('registration_date', 'Registration Date'), + ('is_targeted', 'Is Targeted'), + ('candidate_support', 'Candidate Support'), + ('yard_sign', 'Yard Sign'), + ('window_sticker', 'Window Sticker'), + ('latitude', 'Latitude'), + ('longitude', 'Longitude'), + ('secondary_phone', 'Secondary Phone'), + ('secondary_phone_type', 'Secondary Phone Type'), +] + +EVENT_MAPPABLE_FIELDS = [ + ('name', 'Name'), + ('date', 'Date'), + ('start_time', 'Start Time'), + ('end_time', 'End Time'), + ('event_type', 'Event Type (Name)'), + ('description', 'Description'), + ('location_name', 'Location Name'), + ('address', 'Address'), + ('city', 'City'), + ('state', 'State'), + ('zip_code', 'Zip Code'), + ('latitude', 'Latitude'), + ('longitude', 'Longitude'), +] + +EVENT_PARTICIPATION_MAPPABLE_FIELDS = [ + ('voter_id', 'Voter ID'), + ('event_name', 'Event Name'), + ('participation_status', 'Participation Status'), +] + +DONATION_MAPPABLE_FIELDS = [ + ('voter_id', 'Voter ID'), + ('date', 'Date'), + ('amount', 'Amount'), + ('method', 'Donation Method (Name)'), +] + +INTERACTION_MAPPABLE_FIELDS = [ + ('voter_id', 'Voter ID'), + ('volunteer_email', 'Volunteer Email'), + ('date', 'Date'), + ('type', 'Interaction Type (Name)'), + ('description', 'Description'), + ('notes', 'Notes'), +] + + +VOLUNTEER_MAPPABLE_FIELDS = [ + ('first_name', 'First Name'), + ('last_name', 'Last Name'), + ('email', 'Email'), + ('phone', 'Phone'), + ('notes', 'Notes'), +] + +VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [ + ('voter_id', 'Voter ID'), + ('election_type', 'Election Type (Name)'), + ('likelihood', 'Likelihood'), +] + +VOTING_RECORD_MAPPABLE_FIELDS = [ + ('voter_id', 'Voter ID'), + ('election_date', 'Election Date'), + ('election_description', 'Election Description'), + ('primary_party', 'Primary Party'), +] + +class BaseImportAdminMixin: + def download_errors(self, request): + logger.info(f"download_errors called for {self.model._meta.model_name}") + session_key = f"{self.model._meta.model_name}_import_errors" + failed_rows = request.session.get(session_key, []) + if not failed_rows: + self.message_user(request, "No error log found in session.", level=messages.WARNING) + return redirect("..") + + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f"attachment; filename={self.model._meta.model_name}_import_errors.csv" + + if failed_rows: + all_keys = set() + for r in failed_rows: + all_keys.update(r.keys()) + + writer = csv.DictWriter(response, fieldnames=sorted(list(all_keys))) + writer.writeheader() + writer.writerows(failed_rows) + + return response + +class TenantUserRoleInline(admin.TabularInline): + model = TenantUserRole + extra = 1 + +class CampaignSettingsInline(admin.StackedInline): + model = CampaignSettings + can_delete = False + +@admin.register(Tenant) +class TenantAdmin(admin.ModelAdmin): + list_display = ('name', 'created_at') + search_fields = ('name',) + inlines = [TenantUserRoleInline, CampaignSettingsInline] + +@admin.register(TenantUserRole) +class TenantUserRoleAdmin(admin.ModelAdmin): + list_display = ('user', 'tenant', 'role') + list_filter = ('tenant', 'role') + search_fields = ('user__username', 'tenant__name') + +@admin.register(InteractionType) +class InteractionTypeAdmin(admin.ModelAdmin): + list_display = ('name', 'tenant', 'is_active') + list_filter = ('tenant', 'is_active') + search_fields = ('name',) + +@admin.register(DonationMethod) +class DonationMethodAdmin(admin.ModelAdmin): + list_display = ('name', 'tenant', 'is_active') + list_filter = ('tenant', 'is_active') + search_fields = ('name',) + +@admin.register(ElectionType) +class ElectionTypeAdmin(admin.ModelAdmin): + list_display = ('name', 'tenant', 'is_active') + list_filter = ('tenant', 'is_active') + search_fields = ('name',) + +@admin.register(EventType) +class EventTypeAdmin(admin.ModelAdmin): + list_display = ('name', 'tenant', 'is_active') + list_filter = ('tenant', 'is_active') + search_fields = ('name',) + + +@admin.register(ParticipationStatus) +class ParticipationStatusAdmin(admin.ModelAdmin): + list_display = ('name', 'tenant', 'is_active') + list_filter = ('tenant', 'is_active') + search_fields = ('name',) + change_list_template = 'admin/participationstatus_change_list.html' + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + from core.models import Tenant + extra_context['tenants'] = Tenant.objects.all() + return super().changelist_view(request, extra_context=extra_context) + +@admin.register(Interest) +class InterestAdmin(admin.ModelAdmin): + list_display = ('name', 'tenant') + list_filter = ('tenant',) + fields = ('tenant', 'name') + search_fields = ('name',) + +class VotingRecordInline(admin.TabularInline): + model = VotingRecord + extra = 1 + +class DonationInline(admin.TabularInline): + model = Donation + extra = 1 + +class InteractionInline(admin.TabularInline): + model = Interaction + extra = 1 + +class VoterLikelihoodInline(admin.TabularInline): + model = VoterLikelihood + extra = 1 + +class VolunteerEventInline(admin.TabularInline): + model = VolunteerEvent + extra = 1 + +@admin.register(Voter) +class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('first_name', 'last_name', 'nickname', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state', 'prior_state') + list_filter = ('tenant', 'candidate_support', 'is_targeted', 'phone_type', 'yard_sign', 'district', 'city', 'state', 'prior_state') + search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'prior_state', 'zip_code', 'county') + inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline] + readonly_fields = ('address',) + change_list_template = "admin/voter_change_list.html" + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + from core.models import Tenant + extra_context["tenants"] = Tenant.objects.all() + return super().changelist_view(request, extra_context=extra_context) + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voter-download-errors'), + path('import-voters/', self.admin_site.admin_view(self.import_voters), name='import-voters'), + ] + return my_urls + urls + + 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") + 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}") + + 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) + 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: + 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, + }) + return render(request, "admin/import_preview.html", context) + except Exception as e: + self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR) + return redirect("..") + + + elif "_import" in request.POST: + file_path = request.POST.get("file_path") + tenant_id = request.POST.get("tenant") + tenant = Tenant.objects.get(id=tenant_id) + + mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTER_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")} + + try: + count = 0 + created_count = 0 + updated_count = 0 + skipped_no_change = 0 + skipped_no_id = 0 + errors = 0 + failed_rows = [] + batch_size = 500 + + 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()} + + valid_fields = {f.name for f in Voter._meta.get_fields()} + mapped_fields = {f for f in mapping.keys() if f in valid_fields} + # Ensure derived/special fields are in update_fields + update_fields = list(mapped_fields | {"address", "phone", "secondary_phone", "secondary_phone_type", "longitude", "latitude"}) + 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-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") + + print(f"DEBUG: Starting voter import. Tenant: {tenant.name}. Voter ID column: {v_id_col}") + + total_processed = 0 + for chunk_index, chunk in enumerate(chunk_reader(reader, batch_size)): + with transaction.atomic(): + voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)] + existing_voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids)} + + to_create = [] + to_update = [] + processed_in_batch = set() + + for row in chunk: + 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 + 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: continue + val = str(val).strip() + if val == "": continue + + if field_name == "is_targeted": + val = str(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 + elif field_name == "candidate_support": + val_lower = val.lower() + if val_lower in support_choices: val = val_lower + elif val_lower in support_reverse: val = support_reverse[val_lower] + else: val = "unknown" + elif field_name == "yard_sign": + val_lower = val.lower() + if val_lower in yard_sign_choices: val = val_lower + elif val_lower in yard_sign_reverse: val = yard_sign_reverse[val_lower] + else: val = "none" + elif field_name == "window_sticker": + val_lower = val.lower() + if val_lower in window_sticker_choices: val = val_lower + elif val_lower in window_sticker_reverse: val = window_sticker_reverse[val_lower] + else: val = "none" + elif field_name 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" + + current_val = getattr(voter, field_name) + if current_val != val: + setattr(voter, field_name, val) + changed = True + + old_phone = voter.phone + voter.phone = format_phone_number(voter.phone) + if voter.phone != old_phone: + changed = True + + 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: + 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: + skipped_no_change += 1 + continue + + if created: + to_create.append(voter) + created_count += 1 + else: + to_update.append(voter) + updated_count += 1 + + count += 1 + except Exception as e: + print(f"DEBUG: Error importing row {total_processed}: {e}") + row["Import Error"] = str(e) + failed_rows.append(row) + errors += 1 + + if to_create: + Voter.objects.bulk_create(to_create) + if to_update: + Voter.objects.bulk_update(to_update, update_fields, batch_size=250) + + 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.") + + 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) + + request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows + request.session.modified = True + 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: + print(f"DEBUG: Voter import failed: {e}") + 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"] + + if not csv_file.name.endswith(".csv"): + self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) + return redirect("..") + + with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp: + for chunk in csv_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + + with open(file_path, "r", encoding="utf-8-sig") as f: + reader = csv.reader(f) + headers = next(reader) + + context = self.admin_site.each_context(request) + context.update({ + "title": "Map 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', 'location_name', 'city', 'state', 'tenant') + list_filter = ('tenant', 'date', 'event_type', 'city', 'state') + search_fields = ('name', 'description', 'location_name', 'address', 'city', 'state', 'zip_code') + change_list_template = "admin/event_change_list.html" + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + from core.models import Tenant + extra_context["tenants"] = Tenant.objects.all() + return super().changelist_view(request, extra_context=extra_context) + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='event-download-errors'), + path('import-events/', self.admin_site.admin_view(self.import_events), name='import-events'), + ] + return my_urls + urls + + def import_events(self, request): + if request.method == "POST": + if "_preview" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + mapping = {} + for field_name, _ in EVENT_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + try: + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.DictReader(f) + total_count = 0 + create_count = 0 + update_count = 0 + preview_data = [] + for row in reader: + total_count += 1 + date = row.get(mapping.get('date')) + event_type_name = row.get(mapping.get('event_type')) + event_name = row.get(mapping.get('name')) + exists = False + if date and event_type_name: + q = Event.objects.filter(tenant=tenant, date=date, event_type__name=event_type_name) + if event_name: + q = q.filter(name=event_name) + exists = q.exists() + + if exists: + update_count += 1 + action = 'update' + else: + create_count += 1 + action = 'create' + + if len(preview_data) < 10: + preview_data.append({ + 'action': action, + 'identifier': f"{event_name or 'No Name'} ({date} - {event_type_name})", + 'details': f"{row.get(mapping.get('city', '')) or ''}, {row.get(mapping.get('state', '')) or ''}" + }) + 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, + }) + return render(request, "admin/import_preview.html", context) + except Exception as e: + self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR) + return redirect("..") + + elif "_import" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + + mapping = {} + for field_name, _ in EVENT_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + + 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: + date = row.get(mapping.get('date')) if mapping.get('date') else None + event_type_name = row.get(mapping.get('event_type')) if mapping.get('event_type') else None + description = row.get(mapping.get('description')) if mapping.get('description') else None + location_name = row.get(mapping.get('location_name')) if mapping.get('location_name') else None + name = row.get(mapping.get('name')) if mapping.get('name') else None + start_time = row.get(mapping.get('start_time')) if mapping.get('start_time') else None + end_time = row.get(mapping.get('end_time')) if mapping.get('end_time') else None + address = row.get(mapping.get('address')) if mapping.get('address') else None + city = row.get(mapping.get('city')) if mapping.get('city') else None + state = row.get(mapping.get('state')) if mapping.get('state') else None + zip_code = row.get(mapping.get('zip_code')) if mapping.get('zip_code') else None + latitude = row.get(mapping.get('latitude')) if mapping.get('latitude') else None + longitude = row.get(mapping.get('longitude')) if mapping.get('longitude') else None + + if not date or not event_type_name: + row["Import Error"] = "Missing date or event type" + failed_rows.append(row) + errors += 1 + continue + + event_type, _ = EventType.objects.get_or_create( + tenant=tenant, + name=event_type_name + ) + + defaults = {} + if description and description.strip(): + defaults['description'] = description + if location_name and location_name.strip(): + defaults['location_name'] = location_name + if name and name.strip(): + defaults['name'] = name + if start_time and start_time.strip(): + defaults['start_time'] = start_time + if end_time and end_time.strip(): + defaults['end_time'] = end_time + if address and address.strip(): + defaults['address'] = address + if city and city.strip(): + defaults['city'] = city + if state and state.strip(): + defaults['state'] = state + if zip_code and zip_code.strip(): + defaults['zip_code'] = zip_code + if latitude and latitude.strip(): + defaults['latitude'] = latitude + if longitude and longitude.strip(): + defaults['longitude'] = longitude + + defaults['date'] = date + defaults['event_type'] = event_type + Event.objects.update_or_create( + tenant=tenant, + name=name or '', + defaults=defaults + ) + count += 1 + except Exception as e: + logger.error(f"Error importing: {e}") + row["Import Error"] = str(e) + failed_rows.append(row) + errors += 1 + + 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 + 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:event-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: + self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) + return redirect("..") + else: + form = EventImportForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = request.FILES['file'] + tenant = form.cleaned_data['tenant'] + + if not csv_file.name.endswith('.csv'): + self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) + return redirect("..") + + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in csv_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.reader(f) + headers = next(reader) + + context = self.admin_site.each_context(request) + context.update({ + 'title': "Map Event Fields", + 'headers': headers, + 'model_fields': EVENT_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 = EventImportForm() + + context = self.admin_site.each_context(request) + context['form'] = form + context['title'] = "Import Events" + context['opts'] = self.model._meta + return render(request, "admin/import_csv.html", context) + +@admin.register(Volunteer) +class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('first_name', 'last_name', 'email', 'phone', 'tenant', 'user') + list_filter = ('tenant',) + fields = ('tenant', 'user', 'first_name', 'last_name', 'email', 'phone', 'notes', 'interests') + search_fields = ('first_name', 'last_name', 'email', 'phone') + inlines = [VolunteerEventInline, InteractionInline] + filter_horizontal = ('interests',) + change_list_template = "admin/volunteer_change_list.html" + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + from core.models import Tenant + extra_context["tenants"] = Tenant.objects.all() + return super().changelist_view(request, extra_context=extra_context) + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='volunteer-download-errors'), + path('import-volunteers/', self.admin_site.admin_view(self.import_volunteers), name='import-volunteers'), + ] + return my_urls + urls + + def import_volunteers(self, request): + if request.method == "POST": + if "_preview" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + mapping = {} + for field_name, _ in VOLUNTEER_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + try: + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.DictReader(f) + total_count = 0 + create_count = 0 + update_count = 0 + preview_data = [] + for row in reader: + total_count += 1 + email = row.get(mapping.get('email')) + exists = Volunteer.objects.filter(tenant=tenant, email=email).exists() + if exists: + update_count += 1 + action = 'update' + else: + create_count += 1 + action = 'create' + if len(preview_data) < 10: + preview_data.append({ + 'action': action, + 'identifier': email, + 'details': f"{row.get(mapping.get('first_name', '')) or ''} {row.get(mapping.get('last_name', '')) or ''}".strip() + }) + 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, + }) + return render(request, "admin/import_preview.html", context) + except Exception as e: + self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR) + return redirect("..") + + elif "_import" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + mapping = {} + for field_name, _ in VOLUNTEER_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + 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: + email = row.get(mapping.get('email')) + if not email: + row["Import Error"] = "Missing email" + failed_rows.append(row) + errors += 1 + continue + volunteer_data = {} + 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 == 'email': continue + volunteer_data[field_name] = val + Volunteer.objects.update_or_create( + tenant=tenant, + email=email, + defaults=volunteer_data + ) + count += 1 + except Exception as e: + logger.error(f"Error importing volunteer: {e}") + row["Import Error"] = str(e) + failed_rows.append(row) + errors += 1 + 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 + request.session.modified = True + if errors > 0: + error_url = reverse("admin:volunteer-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: + self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) + return redirect("..") + else: + form = VolunteerImportForm, VotingRecordImportForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = request.FILES['file'] + tenant = form.cleaned_data['tenant'] + if not csv_file.name.endswith('.csv'): + self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) + return redirect("..") + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in csv_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.reader(f) + headers = next(reader) + context = self.admin_site.each_context(request) + context.update({ + 'title': "Map Volunteer Fields", + 'headers': headers, + 'model_fields': VOLUNTEER_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 = VolunteerImportForm, VotingRecordImportForm() + context = self.admin_site.each_context(request) + context['form'] = form + context['title'] = "Import Volunteers" + context['opts'] = self.model._meta + return render(request, "admin/import_csv.html", context) + +@admin.register(VolunteerEvent) +class VolunteerEventAdmin(admin.ModelAdmin): + list_display = ('volunteer', 'event', 'role') + list_filter = ('event__tenant', 'event', 'role') + +@admin.register(EventParticipation) +class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('voter', 'event', 'participation_status') + list_filter = ('event__tenant', 'event', 'participation_status') + change_list_template = "admin/eventparticipation_change_list.html" + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='eventparticipation-download-errors'), + path('import-event-participations/', self.admin_site.admin_view(self.import_event_participations), name='import-event-participations'), + ] + return my_urls + urls + + def import_event_participations(self, request): + if request.method == "POST": + if "_preview" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + mapping = {} + for field_name, _ in EVENT_PARTICIPATION_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + try: + with open(file_path, 'r', encoding='UTF-8') as f: + 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')) + event_name = row.get(mapping.get('event_name')) + + exists = False + if voter_id: + try: + voter = Voter.objects.get(tenant=tenant, voter_id=voter_id) + if event_name: + exists = EventParticipation.objects.filter(voter=voter, event__name=event_name).exists() + except Voter.DoesNotExist: + pass + + if exists: + update_count += 1 + action = 'update' + else: + create_count += 1 + action = 'create' + + if len(preview_data) < 10: + preview_data.append({ + 'action': action, + 'identifier': f"Voter: {voter_id}", + 'details': f"Participation: {row.get(mapping.get('participation_status', '')) or ''}" + }) + 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, + }) + return render(request, "admin/import_preview.html", context) + except Exception as e: + self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR) + return redirect("..") + + elif "_import" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + + mapping = {} + for field_name, _ in EVENT_PARTICIPATION_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + + 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_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None + participation_status_val = row.get(mapping.get('participation_status')) if mapping.get('participation_status') else None + + if not voter_id: + row["Import Error"] = "Missing voter ID" + failed_rows.append(row) + errors += 1 + continue + + try: + voter = Voter.objects.get(tenant=tenant, voter_id=voter_id) + except Voter.DoesNotExist: + error_msg = f"Voter with ID {voter_id} not found" + logger.error(error_msg) + row["Import Error"] = error_msg + failed_rows.append(row) + errors += 1 + continue + + event = None + event_name = row.get(mapping.get('event_name')) if mapping.get('event_name') else None + if event_name: + try: + event = Event.objects.get(tenant=tenant, name=event_name) + except Event.DoesNotExist: + pass + + if not event: + error_msg = "Event not found (check Event Name)" + logger.error(error_msg) + row["Import Error"] = error_msg + failed_rows.append(row) + errors += 1 + continue + + defaults = {} + if participation_status_val and participation_status_val.strip(): + status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name=participation_status_val.strip()) + defaults['participation_status'] = status_obj + else: + # Default to 'Invited' if not specified + status_obj, _ = ParticipationStatus.objects.get_or_create(tenant=tenant, name='Invited') + defaults['participation_status'] = status_obj + EventParticipation.objects.update_or_create( + event=event, + voter=voter, + defaults=defaults + ) + count += 1 + except Exception as e: + logger.error(f"Error importing: {e}") + row["Import Error"] = str(e) + failed_rows.append(row) + errors += 1 + + 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 + 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:eventparticipation-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: + self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) + return redirect("..") + else: + form = EventParticipationImportForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = request.FILES['file'] + tenant = form.cleaned_data['tenant'] + + if not csv_file.name.endswith('.csv'): + self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) + return redirect("..") + + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in csv_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.reader(f) + headers = next(reader) + + context = self.admin_site.each_context(request) + context.update({ + 'title': "Map Participation Fields", + 'headers': headers, + 'model_fields': EVENT_PARTICIPATION_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 = EventParticipationImportForm() + + context = self.admin_site.each_context(request) + context['form'] = form + context['title'] = "Import Participations" + context['opts'] = self.model._meta + return render(request, "admin/import_csv.html", context) + +@admin.register(Donation) +class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('id', 'voter', 'date', 'amount', 'method') + list_filter = ('voter__tenant', 'date', 'method') + search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id') + change_list_template = "admin/donation_change_list.html" + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='donation-download-errors'), + path('import-donations/', self.admin_site.admin_view(self.import_donations), name='import-donations'), + ] + return my_urls + urls + + def import_donations(self, request): + if request.method == "POST": + if "_preview" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + mapping = {} + for field_name, _ in DONATION_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + try: + with open(file_path, 'r', encoding='UTF-8') as f: + 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')) + date = row.get(mapping.get('date')) + amount = row.get(mapping.get('amount')) + exists = False + if voter_id and date and amount: + exists = Donation.objects.filter(voter__tenant=tenant, voter__voter_id=voter_id, date=date, amount=amount).exists() + + if exists: + update_count += 1 + action = 'update' + else: + create_count += 1 + action = 'create' + + if len(preview_data) < 10: + preview_data.append({ + 'action': action, + 'identifier': f"Voter: {voter_id}", + 'details': f"Date: {date}, Amount: {amount}" + }) + 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, + }) + return render(request, "admin/import_preview.html", context) + except Exception as e: + self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR) + return redirect("..") + + elif "_import" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + + mapping = {} + for field_name, _ in DONATION_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + + 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_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None + if not voter_id: + row["Import Error"] = "Missing voter ID" + failed_rows.append(row) + errors += 1 + continue + + try: + voter = Voter.objects.get(tenant=tenant, voter_id=voter_id) + except Voter.DoesNotExist: + row["Import Error"] = f"Voter {voter_id} not found" + failed_rows.append(row) + errors += 1 + continue + + date = row.get(mapping.get('date')) + amount = row.get(mapping.get('amount')) + method_name = row.get(mapping.get('method')) + + if not date or not amount: + row["Import Error"] = "Missing date or amount" + failed_rows.append(row) + errors += 1 + continue + + method = None + if method_name and method_name.strip(): + method, _ = DonationMethod.objects.get_or_create( + tenant=tenant, + name=method_name + ) + + defaults = {} + if method: + defaults['method'] = method + + Donation.objects.update_or_create( + voter=voter, + date=date, + amount=amount, + defaults=defaults + ) + count += 1 + except Exception as e: + logger.error(f"Error importing: {e}") + row["Import Error"] = str(e) + failed_rows.append(row) + errors += 1 + + 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 + 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:donation-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: + self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) + return redirect("..") + else: + form = DonationImportForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = request.FILES['file'] + tenant = form.cleaned_data['tenant'] + + if not csv_file.name.endswith('.csv'): + self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) + return redirect("..") + + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in csv_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.reader(f) + headers = next(reader) + + context = self.admin_site.each_context(request) + context.update({ + 'title': "Map Donation Fields", + 'headers': headers, + 'model_fields': DONATION_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 = DonationImportForm() + + context = self.admin_site.each_context(request) + context['form'] = form + context['title'] = "Import Donations" + context['opts'] = self.model._meta + return render(request, "admin/import_csv.html", context) + +@admin.register(Interaction) +class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('id', 'voter', 'volunteer', 'type', 'date', 'description') + list_filter = ('voter__tenant', 'type', 'date', 'volunteer') + search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description', 'volunteer__first_name', 'volunteer__last_name') + change_list_template = "admin/interaction_change_list.html" + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='interaction-download-errors'), + path('import-interactions/', self.admin_site.admin_view(self.import_interactions), name='import-interactions'), + ] + return my_urls + urls + + def import_interactions(self, request): + if request.method == "POST": + if "_preview" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + mapping = {} + for field_name, _ in INTERACTION_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + try: + with open(file_path, 'r', encoding='UTF-8') as f: + 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')) + date = row.get(mapping.get('date')) + exists = False + if voter_id and date: + exists = Interaction.objects.filter(voter__tenant=tenant, voter__voter_id=voter_id, date=date).exists() + + if exists: + update_count += 1 + action = 'update' + else: + create_count += 1 + action = 'create' + + if len(preview_data) < 10: + preview_data.append({ + 'action': action, + 'identifier': f"Voter: {voter_id}", + 'details': f"Date: {date}, Desc: {row.get(mapping.get('description', '')) or ''}" + }) + 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, + }) + return render(request, "admin/import_preview.html", context) + except Exception as e: + self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR) + return redirect("..") + + elif "_import" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + + mapping = {} + for field_name, _ in INTERACTION_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + + 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_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None + if not voter_id: + row["Import Error"] = "Missing voter ID" + failed_rows.append(row) + errors += 1 + continue + + try: + voter = Voter.objects.get(tenant=tenant, voter_id=voter_id) + except Voter.DoesNotExist: + row["Import Error"] = f"Voter {voter_id} not found" + failed_rows.append(row) + errors += 1 + continue + + date = row.get(mapping.get('date')) + type_name = row.get(mapping.get('type')) + volunteer_email = row.get(mapping.get('volunteer_email')) + description = row.get(mapping.get('description')) + notes = row.get(mapping.get('notes')) + + if not date or not description: + row["Import Error"] = "Missing date or description" + failed_rows.append(row) + errors += 1 + continue + + volunteer = None + if volunteer_email and volunteer_email.strip(): + try: + volunteer = Volunteer.objects.get(tenant=tenant, email=volunteer_email.strip()) + except Volunteer.DoesNotExist: + pass + interaction_type = None + if type_name and type_name.strip(): + interaction_type, _ = InteractionType.objects.get_or_create( + tenant=tenant, + name=type_name + ) + + defaults = {} + if volunteer: + defaults['volunteer'] = volunteer + if interaction_type: + defaults['type'] = interaction_type + if description and description.strip(): + defaults['description'] = description + if notes and notes.strip(): + defaults['notes'] = notes + + Interaction.objects.update_or_create( + voter=voter, + date=date, + defaults=defaults + ) + count += 1 + except Exception as e: + logger.error(f"Error importing: {e}") + row["Import Error"] = str(e) + failed_rows.append(row) + errors += 1 + + 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 + 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:interaction-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: + self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) + return redirect("..") + else: + form = InteractionImportForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = request.FILES['file'] + tenant = form.cleaned_data['tenant'] + + if not csv_file.name.endswith('.csv'): + self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) + return redirect("..") + + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in csv_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.reader(f) + headers = next(reader) + + context = self.admin_site.each_context(request) + context.update({ + 'title': "Map Interaction Fields", + 'headers': headers, + 'model_fields': INTERACTION_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 = InteractionImportForm() + + context = self.admin_site.each_context(request) + context['form'] = form + context['title'] = "Import Interactions" + context['opts'] = self.model._meta + return render(request, "admin/import_csv.html", context) + +@admin.register(VoterLikelihood) +class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): + list_display = ('id', 'voter', 'election_type', 'likelihood') + list_filter = ('voter__tenant', 'election_type', 'likelihood') + search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id') + change_list_template = "admin/voterlikelihood_change_list.html" + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + path('download-errors/', self.admin_site.admin_view(self.download_errors), name='voterlikelihood-download-errors'), + path('import-likelihoods/', self.admin_site.admin_view(self.import_likelihoods), name='import-likelihoods'), + ] + return my_urls + urls + + def import_likelihoods(self, request): + if request.method == "POST": + if "_preview" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")} + + try: + with open(file_path, 'r', encoding='utf-8-sig') as f: + # Fast count and partial preview + total_count = sum(1 for line in f) - 1 + f.seek(0) + reader = csv.DictReader(f) + preview_rows = [] + voter_ids_for_preview = set() + election_types_for_preview = set() + + v_id_col = mapping.get('voter_id') + et_col = mapping.get('election_type') + + if not v_id_col or not et_col: + raise ValueError("Missing mapping for Voter ID or Election Type") + + for i, row in enumerate(reader): + if i < 10: + preview_rows.append(row) + v_id = row.get(v_id_col) + et_name = row.get(et_col) + if v_id: voter_ids_for_preview.add(str(v_id).strip()) + if et_name: election_types_for_preview.add(str(et_name).strip()) + else: + break + + existing_likelihoods = set(VoterLikelihood.objects.filter( + voter__tenant=tenant, + voter__voter_id__in=voter_ids_for_preview, + election_type__name__in=election_types_for_preview + ).values_list("voter__voter_id", "election_type__name")) + + preview_data = [] + for row in preview_rows: + v_id = str(row.get(v_id_col, '')).strip() + et_name = str(row.get(et_col, '')).strip() + action = "update" if (v_id, et_name) in existing_likelihoods else "create" + preview_data.append({ + "action": action, + "identifier": f"Voter: {v_id}, Election: {et_name}", + "details": f"Likelihood: {row.get(mapping.get('likelihood', '')) or ''}" + }) + + context = self.admin_site.each_context(request) + context.update({ + "title": "Import Preview", + "total_count": total_count, + "create_count": "N/A", + "update_count": "N/A", + "preview_data": preview_data, + "mapping": mapping, + "file_path": file_path, + "tenant_id": tenant_id, + "action_url": request.path, + "opts": self.model._meta, + }) + return render(request, "admin/import_preview.html", context) + except Exception as e: + self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR) + return redirect("..") + + elif "_import" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")} + + try: + count = 0 + created_count = 0 + updated_count = 0 + skipped_no_change = 0 + skipped_no_id = 0 + errors = 0 + failed_rows = [] + batch_size = 500 + + likelihood_choices = dict(VoterLikelihood.LIKELIHOOD_CHOICES) + likelihood_reverse = {v.lower(): k for k, v in likelihood_choices.items()} + + # Pre-fetch election types for this tenant + election_types = {et.name: et for et in ElectionType.objects.filter(tenant=tenant)} + + def chunk_reader(reader, size): + chunk = [] + for row in reader: + chunk.append(row) + if len(chunk) == size: + yield chunk + chunk = [] + if chunk: + yield chunk + + with open(file_path, "r", encoding="utf-8-sig") as f: + reader = csv.DictReader(f) + v_id_col = mapping.get("voter_id") + et_col = mapping.get("election_type") + l_col = mapping.get("likelihood") + + if not v_id_col or not et_col or not l_col: + raise ValueError("Missing mapping for Voter ID, Election Type, or Likelihood") + + print(f"DEBUG: Starting likelihood import. Tenant: {tenant.name}") + + total_processed = 0 + for chunk in chunk_reader(reader, batch_size): + with transaction.atomic(): + voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)] + et_names = [str(row.get(et_col)).strip() for row in chunk if row.get(et_col)] + + # Fetch existing voters + voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids).only("id", "voter_id")} + + # Fetch existing likelihoods + existing_likelihoods = { + (vl.voter.voter_id, vl.election_type.name): vl + for vl in VoterLikelihood.objects.filter( + voter__tenant=tenant, + voter__voter_id__in=voter_ids, + election_type__name__in=et_names + ).select_related("voter", "election_type") + } + + to_create = [] + to_update = [] + processed_in_batch = set() + + for row in chunk: + total_processed += 1 + try: + raw_v_id = row.get(v_id_col) + raw_et_name = row.get(et_col) + raw_l_val = row.get(l_col) + + if raw_v_id is None or raw_et_name is None or raw_l_val is None: + skipped_no_id += 1 + continue + + v_id = str(raw_v_id).strip() + et_name = str(raw_et_name).strip() + l_val = str(raw_l_val).strip() + + if not v_id or not et_name or not l_val: + skipped_no_id += 1 + continue + + if (v_id, et_name) in processed_in_batch: + continue + processed_in_batch.add((v_id, et_name)) + + voter = voters.get(v_id) + if not voter: + print(f"DEBUG: Voter {v_id} not found for likelihood import") + row["Import Error"] = f"Voter {v_id} not found" + failed_rows.append(row) + errors += 1 + continue + + # Get or create election type + if et_name not in election_types: + election_type, _ = ElectionType.objects.get_or_create(tenant=tenant, name=et_name) + election_types[et_name] = election_type + election_type = election_types[et_name] + + # Normalize likelihood + normalized_l = None + l_val_lower = l_val.lower().replace(' ', '_') + if l_val_lower in likelihood_choices: + normalized_l = l_val_lower + elif l_val_lower in likelihood_reverse: + normalized_l = likelihood_reverse[l_val_lower] + else: + # Try to find by display name more broadly + for k, v in likelihood_choices.items(): + if v.lower() == l_val.lower(): + normalized_l = k + break + + if not normalized_l: + row["Import Error"] = f"Invalid likelihood value: {l_val}" + failed_rows.append(row) + errors += 1 + continue + + vl = existing_likelihoods.get((v_id, et_name)) + created = False + if not vl: + vl = VoterLikelihood(voter=voter, election_type=election_type, likelihood=normalized_l) + created = True + + if not created and vl.likelihood == normalized_l: + skipped_no_change += 1 + continue + + vl.likelihood = normalized_l + + if created: + to_create.append(vl) + created_count += 1 + else: + to_update.append(vl) + updated_count += 1 + + count += 1 + except Exception as e: + print(f"DEBUG: Error importing row {total_processed}: {e}") + row["Import Error"] = str(e) + failed_rows.append(row) + errors += 1 + + if to_create: + VoterLikelihood.objects.bulk_create(to_create) + if to_update: + VoterLikelihood.objects.bulk_update(to_update, ["likelihood"], batch_size=250) + + print(f"DEBUG: Likelihood import progress: {total_processed} processed. {count} created/updated. {skipped_no_change} skipped (no change). {skipped_no_id} skipped (no ID). {errors} errors.") + + if os.path.exists(file_path): + os.remove(file_path) + + success_msg = f"Import complete: {count} likelihoods created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped with no changes, {skipped_no_id} skipped missing data, {errors} errors)" + self.message_user(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. Download failed records"), level=messages.WARNING) + return redirect("..") + except Exception as e: + print(f"DEBUG: Likelihood import failed: {e}") + self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) + return redirect("..") + else: + form = VoterLikelihoodImportForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = request.FILES['file'] + tenant = form.cleaned_data['tenant'] + + if not csv_file.name.endswith('.csv'): + self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) + return redirect("..") + + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in csv_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + + with open(file_path, 'r', encoding='utf-8-sig') as f: + reader = csv.reader(f) + headers = next(reader) + + context = self.admin_site.each_context(request) + context.update({ + 'title': "Map Likelihood Fields", + 'headers': headers, + 'model_fields': VOTER_LIKELIHOOD_MAPPABLE_FIELDS, + 'tenant_id': tenant.id, + 'file_path': file_path, + 'action_url': request.path, + 'opts': self.model._meta, + }) + return render(request, "admin/import_mapping.html", context) + else: + form = VoterLikelihoodImportForm() + + context = self.admin_site.each_context(request) + context['form'] = form + context['title'] = "Import Likelihoods" + context['opts'] = self.model._meta + return render(request, "admin/import_csv.html", context) + +@admin.register(CampaignSettings) +class CampaignSettingsAdmin(admin.ModelAdmin): + list_display = ('tenant', 'donation_goal', 'twilio_from_number') + list_filter = ('tenant',) + fields = ('tenant', 'donation_goal', 'twilio_account_sid', 'twilio_auth_token', 'twilio_from_number') \ No newline at end of file diff --git a/core/forms.py b/core/forms.py index 9072933..107a47f 100644 --- a/core/forms.py +++ b/core/forms.py @@ -296,3 +296,12 @@ class VolunteerEventAddForm(forms.ModelForm): for field in self.fields.values(): field.widget.attrs.update({'class': 'form-control'}) self.fields['volunteer'].widget.attrs.update({'class': 'form-select'}) + +class VotingRecordImportForm(forms.Form): + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign") + file = forms.FileField(label="Select CSV file") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'}) + self.fields['file'].widget.attrs.update({'class': 'form-control'}) \ No newline at end of file diff --git a/core/middleware.py b/core/middleware.py new file mode 100644 index 0000000..65898ba --- /dev/null +++ b/core/middleware.py @@ -0,0 +1,35 @@ +from django.shortcuts import redirect +from django.urls import reverse +from django.conf import settings + +class LoginRequiredMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not request.user.is_authenticated: + path = request.path_info + + # Allow access to login, logout, admin, and any other exempted paths + # We use try/except in case URLs are not defined yet + try: + login_url = reverse('login') + logout_url = reverse('logout') + except: + login_url = '/accounts/login/' + logout_url = '/accounts/logout/' + + exempt_urls = [ + login_url, + logout_url, + '/admin/', + ] + + # Check if path starts with any of the exempt URLs + is_exempt = any(path.startswith(url) for url in exempt_urls) + + if not is_exempt: + return redirect(f"{login_url}?next={path}") + + response = self.get_response(request) + return response \ No newline at end of file diff --git a/core/templates/admin/votingrecord_change_list.html b/core/templates/admin/votingrecord_change_list.html new file mode 100644 index 0000000..57151b0 --- /dev/null +++ b/core/templates/admin/votingrecord_change_list.html @@ -0,0 +1,38 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls static admin_list %} + +{% block object-tools-items %} +
  • + Import Voting Records +
  • + {{ block.super }} +{% endblock %} + +{% block search %} + {{ block.super }} +
    + + +
    + + +{% endblock %} diff --git a/core/templates/base.html b/core/templates/base.html index b90e601..73437e3 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -50,7 +50,13 @@
    Admin Panel {% if user.is_authenticated %} - {{ user.username }} + {{ user.username }} +
    + {% csrf_token %} + +
    + {% else %} + Login {% endif %}
    @@ -111,4 +117,4 @@ }); - + \ No newline at end of file diff --git a/core/templates/core/volunteer_list.html b/core/templates/core/volunteer_list.html index f37eb25..0f8947c 100644 --- a/core/templates/core/volunteer_list.html +++ b/core/templates/core/volunteer_list.html @@ -51,8 +51,7 @@ Name Email Phone - Interests - Actions + Interests @@ -68,20 +67,17 @@ {{ volunteer.email }} {{ volunteer.phone|default:"-" }} - + {% for interest in volunteer.interests.all %} {{ interest.name }} {% empty %} No interests listed {% endfor %} - - View & Edit - {% empty %} - +

    No volunteers found matching your search.

    Add the first volunteer @@ -208,4 +204,4 @@ document.addEventListener('DOMContentLoaded', function() { } }); -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/core/templates/registration/login.html b/core/templates/registration/login.html new file mode 100644 index 0000000..3e62255 --- /dev/null +++ b/core/templates/registration/login.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
    +
    +
    +
    +
    +

    Login

    +

    Please log in to access the Grassroots Campaign Manager.

    + + {% if form.errors %} +
    + Your username and password didn't match. Please try again. +
    + {% endif %} + + {% if next %} + {% if user.is_authenticated %} +
    + Your account doesn't have access to this page. To proceed, + please log in with an account that has access. +
    + {% else %} +
    + Please log in to see this page. +
    + {% endif %} + {% endif %} + +
    + {% csrf_token %} +
    + + +
    +
    + + +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +{% endblock %}