From 63faa21a4f1cba8d83a5d4a1c63caa3f7eae0add Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 1 Feb 2026 21:17:19 +0000 Subject: [PATCH] Autosave: 20260201-211719 --- config/csrf_settings.tmp | 13 - .../context_processors.cpython-311.pyc | Bin 921 -> 2145 bytes core/__pycache__/models.cpython-311.pyc | Bin 29913 -> 29939 bytes core/__pycache__/permissions.cpython-311.pyc | Bin 0 -> 5207 bytes core/__pycache__/views.cpython-311.pyc | Bin 82781 -> 85168 bytes core/admin.py.bak | 1816 ----------------- core/admin.py.tmp | 22 - core/context_processors.py | 20 +- .../0039_alter_tenantuserrole_role.py | 18 + ..._alter_tenantuserrole_role.cpython-311.pyc | Bin 0 -> 996 bytes core/models.py | 4 +- core/permissions.py | 116 ++ core/templates/base.html | 88 +- core/templates/core/door_visit_history.html | 110 +- core/templates/core/door_visits.html | 82 +- core/templates/core/index.html | 4 +- core/templates/core/volunteer_detail.html | 12 +- core/templates/core/volunteer_list.html | 12 +- .../templates/core/voter_advanced_search.html | 128 +- core/templates/core/voter_detail.html | 59 +- core/templates/core/voter_list.html | 124 +- core/views.py | 22 + core_view_fix.tmp | 14 - 23 files changed, 614 insertions(+), 2050 deletions(-) delete mode 100644 config/csrf_settings.tmp create mode 100644 core/__pycache__/permissions.cpython-311.pyc delete mode 100644 core/admin.py.bak delete mode 100644 core/admin.py.tmp create mode 100644 core/migrations/0039_alter_tenantuserrole_role.py create mode 100644 core/migrations/__pycache__/0039_alter_tenantuserrole_role.cpython-311.pyc create mode 100644 core/permissions.py delete mode 100644 core_view_fix.tmp diff --git a/config/csrf_settings.tmp b/config/csrf_settings.tmp deleted file mode 100644 index 504f8f2..0000000 --- a/config/csrf_settings.tmp +++ /dev/null @@ -1,13 +0,0 @@ -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/core/__pycache__/context_processors.cpython-311.pyc b/core/__pycache__/context_processors.cpython-311.pyc index 3282f43ca53533e50e0eda22cdef881c41b6260e..d987661f772d7405031db898a33b3938cd0f2246 100644 GIT binary patch literal 2145 zcmcIkO>7fK6rT0}PfQH4TLg--EfnJ@&X0rup)?Reh=L(WlT?8f>dNs>!n*t8&W;1A zjG6-njvSEaAqOe}1*L}`sKkLo4jgjyqLr{liiA}4z|9isg;U?Gv&IoZ%B8crZ{NK6 z=Dm4u-h00%5-|khub025Ye9tm<`ZxDc9{3`z&t<-QUnc2&A%WCF!yOb-7onCr1+J9 z7SMxI(B*?#NDoV4Jt9T)s1(&>QcRCaalJ$8fc4NFBqbDK4rRmdxQVH(z#CQK`IQQcPH48B@7pVImV#!>p^d zWyRDg2E~|IO+7n%c7&|64G4OyBDe012rz#5-mgO>9w6GbR%-Ezp!lA^`i2*+&*A)^ z;2_@Kpka?gsKjl$;|b7>9Uh@4U~{8omquF)h`M*!oGSsa0~$73T9n{=;I>djrI5Fy z)Urn2f}H#5j)!eJJsE=g+HLu^uj(VOXzC-P6!-Xt9Uj;IuloK7@7D1!(Z25SyZycG zulmV7+U>0()b2yozdPzxL5U0ll=kcyA9e9x?HM0)@kjQIkGuG9_Kfdv@tHm26C7XV zb+qs2g_sCqR8cIkOw1B4(wtSsMRiFnir83DiD~HApyEn_sD(uhTOu_@dIO6T>t(G# zu~;;rPHs_gSu+<4nzq{SYGhqAv(uNx$L8e=<8x!PlQZ*^(^Cww{hoO8%IL(n{Qc}@ z)-^FbJ#l$lzA`#9Cy&ld%K7nY*#PU>IghMht4P!`FJQLc6RLV)8Os%-v6O<#nz_nP zDN|L)7A@#yk^~o7l)Ak@RuyOrK2;$+lL=2^!ZVNXv?aU-ShUpxvMJ^>Ef#{t0RA(- zYETy7dRZ7m1xg9w6~Y312TQ7!T&O@i45}6(8VU#Vy#7 zcY5-Tc&IbfKtR?e8c7uCyi;|;M;j;>%nS9@;rr8XQkmDO%+t7?I^m>F+>O@bsRoKf z^TKxe@ckd&qR&fwL1;o8AIJDJ<=JM-ey%i*mr?7kVNZ-$GH+sW*9ZurH*%j;Vw?cA)Bo8{uH zo$TEny0E$M>UwSNnmr^rLlT$g>}3CEwP#NJx$yk@=1Dtq$;n*07ydPFCy&03A6T1u z4^e~?BVWRg90J1PN~vHhoBa^$rDhg|bW_2a)y$K4853P~v&U*n4xSAkQ?;47{Fa^} za-1(61M(p<44Nt$9w9lH@@{WE2h#8hf>1}%HTS8b_?r9FkyzXP*3qHb?ziDX!nm&i R%Kj%BI4E>A(5J-p>Oa4H11A6g delta 244 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 delta 3329 zcmZ`+dr(y8757_VMc!|Ac`imIn-OGaBSI)*07VJ1;Hsdq%VqZhi~E{y7ZPkhJ2t7A zPKnc#wrSE@ZIY(yq-nDL)1*(QCY>~i_8srks{eF`nZ7jrE9r!OzjG0y5NG)9x#xYp zbG~!0AI^!Zb0XvAjEvM(>fbAuW&+z@%ecO_K3VgJ0%0S08-AP?BipBiqFP`y>vfCCoj&A1AmehIiFl|Yz5}o8pn<{Qo4vmzFm@dOwNSE#EQC0&w=vLZ!7Rz!x zvt$!X0G;$*mo4#r`l~`T(M;x0`l97-xt(rhWV^s*f4!jE z>DO7+O00aP?g0Z(N3Z6tqq^*Av4?u{E>bjST}~5(6#$0Uklg zokRK*qgDOM1AtP18Gk&tS{&lkhQJ)6^SLjGAr<1ritam>yACM3F4*4dI=P_Abe%c^ zF2KVi@8v|VIfouV!$AWDlVC}#8#14vn5_D zDM91kE47Jc`g>_b;X_KxND5B`gYt1$H3Q5pb(du?kNzbkk3i?{=ua%4kgD653M>6(n;dsokCm!$})}`V; zOYc092gUV-HWdUwl1 z;&LLTQA@VI2+r^5%HDi(H#qa;AJlUUH8~uz7_vXpFIrBDo8;PB-uzcEt^xiAxDNOL z@DIQZz^npeO=Ngn_r+uK5?EIOb98R&adA6Q+xoG^`B}|2QYIsDU3vq4`3a2XsjqEQ zKS%gCo)_aAX;B%7NN+5zDSaekSbdR+aD2*0Z`XZ+kQUq?(1U(6r<<^~K`Avl776R# z_*7Jv|DvmHZZSx;?c>%tXxye#?Zt&3DKaJ$w3_v!AUT_Ozy00SVwOkzLg#yh7%2ID zfCEsWz%5U!W3v2sEDE3{O`~j8K7m(Qj7?c7!>C)~;t&f20riXjZum7wDnlCXc ztZOL8RdpZkyRpXA*W>PJbIV!?c)dLVUrz#3Xu#!I&plpjuQ^_6(7pkkn*ehMRDoHQ zc+>TP5LJ9=uk@Y5Qhda1E8tV$+E2rRYyTomxj%1h#`76UxS!v23*>DD>N+_=e+c+5 zfP0^$D2Kyw%xwQ{n%?`i{eOz5)IWjJyaFRR7MFpj%%Z`4(??f04I`r`th*x$%sGTp z$wlMUZ*I0lZ!!}_WdSgy@;HE(-BX}YvXQ05V(Jq^_uuJw3e<}LRVYS204TF+x01P4wJAshFXk58LU=z<+10xxe{F13N(%`XRxO0>B5MSBvTRg3ba3;E zmm7X-07GzyejC=19v_SNB~SBSPOTRjz4Y6muZ!MxpOQZbuR9TWVV!u=>~wi#UoIc? zU*LS9(|&3geZ6j_iue=H4@XGcNDT%K>%qWSL>;ZU83rL6q=vDJVleU17>O!mZSFp_ zc~t$aI`+$?Z7P=8Z;1EN>*EhvA2-Q=j&Bexlz-S;_(#PsEb$}vyb7N>9Jca{w^h}T zZ^!}bIhaP5zP+O~8^q`1PlGP?ZFx%-x*_B)*ox%Jp~|z{4l0eF4t?4D47ZL$KAxx# z9}p>^oz?PDdM5H<{;!Zo`h*My1H-lI&Zz$~yh+z0eZru+XmKw$b_m}1eE1UZUE~if l-XVNN_*KJqhbM!tC*KgBMeaJE-#9%VEfdERKZ!Oa{U4h(J&6DS diff --git a/core/__pycache__/permissions.cpython-311.pyc b/core/__pycache__/permissions.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e8eab61c3a9547f6a7202b1deee7bdf978732a50 GIT binary patch literal 5207 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 delta 13806 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�IlKl`B6}M`McYY98RM|pw)_>%l7Rp6OEbr%-@1R$LbvjhP|<`j)_dVTXPU@H`;9g ztfE#@A~iQs?GbZh568Gt7EP*0Q%7^f0k(WK9OvuO) zk0|M23u;?r-sz{I*9^e3fC9jF5CJ2|R{D5L*oG=RfLLi-{2?n_lKVyX(L#nRit-wu#vvC>rxXrg*yKO zT5?KKq;86+*f)H*SeljF)AU7tGC`CKsiwxX6ca#oCLR{NUc4mQi$L3bW#qXDVnNnf zn4XQy4Us_;MMa5|398~(RJ|1=t0#(aVs7N>iDI~z9eH@7nAA_9doP-<%d#P#6Um+= zJcF*IyFQ2cwz{5P^7Mx1mTz@Q*bcmEECA~nuE--3MRos~5X^UsByNnnI7t)?aTp#N zhAvd_=&iIS`cf)LpOM#Mm}!xW8ZjZUEgHNF)bXmS??*?`U~n#5Y+KkH)cT<;6bj_u5uW&PSuyFcMA-D;c+4I(zPz zEG8G*Ml3rfR2`Pw6#3U=Q6V-*222q}Su1IT#aP=($+2(DIUsl2byGyL2t}@+E=Cr! z>jGEB%GPg?qxcC@UL1LQy2#I*iW;{+CytO^qjQL7ibKQY76_DL;MhG zWzY-yf?y+sMy+(M#9puVTi7<94?gY!JPddR@H*fC;BCMmzz2Z80geK`0DKAf2H+C7 z;<$+>*#|HPuo!?xWq!`&=RSEKvcCa540sgqEMPwXfl3`PdlxALcsMM=!36m!vR?pj z?c-Yud+PGvl&z8QZTKkQoh~6`y(L)b5urGiKD8xiv^AU4WDC_&FIVVj%Dx9r5ihvK z!N^CmM0LqzG4-e`4()U{Pdn;jh+HsR%pa32){3Jp2bN}vAeA_ z%N8@KjRQ;bxWs`~nOx$)qTXUI^>Sc&BzwNdD=q)Y?> 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/admin.py.tmp b/core/admin.py.tmp deleted file mode 100644 index 1dd03f8..0000000 --- a/core/admin.py.tmp +++ /dev/null @@ -1,22 +0,0 @@ -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/context_processors.py b/core/context_processors.py index dd5df10..0c5f61a 100644 --- a/core/context_processors.py +++ b/core/context_processors.py @@ -1,15 +1,31 @@ import os import time from django.conf import settings +from .models import Tenant +from .permissions import can_view_donations, can_edit_voter, get_user_role, can_view_volunteers, can_edit_volunteer, can_view_voters def project_context(request): """ Adds project-specific environment variables to the template context globally. """ - return { + context = { "project_description": os.getenv("PROJECT_DESCRIPTION", ""), "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), # Used for cache-busting static assets "deployment_timestamp": int(time.time()), "GOOGLE_MAPS_API_KEY": getattr(settings, 'GOOGLE_MAPS_API_KEY', ''), - } \ No newline at end of file + } + + if request.user.is_authenticated: + tenant_id = request.session.get('tenant_id') + if tenant_id: + tenant = Tenant.objects.filter(id=tenant_id).first() + if tenant: + context['can_view_donations'] = can_view_donations(request.user, tenant) + context['can_edit_voter'] = can_edit_voter(request.user, tenant) + context['can_view_voters'] = can_view_voters(request.user, tenant) + context['can_view_volunteers'] = can_view_volunteers(request.user, tenant) + context['can_edit_volunteer'] = can_edit_volunteer(request.user, tenant) + context['user_role'] = get_user_role(request.user, tenant) + + return context diff --git a/core/migrations/0039_alter_tenantuserrole_role.py b/core/migrations/0039_alter_tenantuserrole_role.py new file mode 100644 index 0000000..3310a92 --- /dev/null +++ b/core/migrations/0039_alter_tenantuserrole_role.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-02-01 15:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0038_alter_campaignsettings_timezone'), + ] + + operations = [ + migrations.AlterField( + model_name='tenantuserrole', + name='role', + field=models.CharField(choices=[('system_admin', 'System Administrator'), ('campaign_admin', 'Campaign Administrator'), ('campaign_staff', 'Campaign Staff')], max_length=20), + ), + ] diff --git a/core/migrations/__pycache__/0039_alter_tenantuserrole_role.cpython-311.pyc b/core/migrations/__pycache__/0039_alter_tenantuserrole_role.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6c7233a493e366b77093dcf99f08c991ac75f6c6 GIT binary patch literal 996 zcmaJ<&ui2`6rM@4*-dDRu8Re=Dy3K#q$zl@P=r!Y5G_c-!(PT|<~5r&Kh{i^wtKM$ z4<38#-9ts%KgL6mlB=iQQnsg_e3MNowc;f6<$W`6-hA^-zD-Wr1l!N8PyQW^kY9>a ztyTu3WeoO-Ac6)Yq#YV+9gPx=yd*;VOoR@!r&o&(Fgmc^4H%rF)ZNVi(4zKxQM-ea&&fOM!J9ZgV=c65z^(W^w^)nljPW3agtY7wqARV!llA@Nt4!Q&s6Q@AhiB z)qgT&$~QNMQF5Jv5x~Ds9ePa^dS-uxjx85-D_sAC~sbP~Pu% zGE(+mymh*92+m+*fOa{s4ekIfaJJ&)gY0t`9Ob(6=f7~!|E1FZt0pt$@j`zmF|lp| zDv%Ok_wvb2VlCym)-rcr=QgqKf3c4nGbhvYCpV{0nzMV4j+?VNAy0bRU$~|1sG(a! z?X1A_0`t0I*=GddTtTjzO>_5^dXjsUinX?pS)#`yFJ2HEg-L}mW1V+ZT9G@0(M4l< u?DX^G0aA)xzC*}$N-1im?H}av^*1*ssF{=NuxB6Nm^)Jw=l_^3_WccD@(@`7 literal 0 HcmV?d00001 diff --git a/core/models.py b/core/models.py index 569acdd..ea51444 100644 --- a/core/models.py +++ b/core/models.py @@ -31,8 +31,8 @@ class Tenant(models.Model): class TenantUserRole(models.Model): ROLE_CHOICES = [ - ('admin', 'Admin'), - ('campaign_manager', 'Campaign Manager'), + ('system_admin', 'System Administrator'), + ('campaign_admin', 'Campaign Administrator'), ('campaign_staff', 'Campaign Staff'), ] user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tenant_roles') diff --git a/core/permissions.py b/core/permissions.py new file mode 100644 index 0000000..4de6d9b --- /dev/null +++ b/core/permissions.py @@ -0,0 +1,116 @@ +from functools import wraps +from django.core.exceptions import PermissionDenied +from django.shortcuts import redirect +from django.contrib import messages +from .models import TenantUserRole + +# Allowed roles for staff/admin actions +STAFF_ROLES = [ + 'admin', 'campaign_manager', 'campaign_staff', + 'system_admin', 'campaign_admin' +] + +def get_user_role(user, tenant): + if user.is_superuser: + return 'admin' + role_obj = TenantUserRole.objects.filter(user=user, tenant=tenant).first() + if role_obj: + return role_obj.role + return None + +def has_role(user, tenant, roles): + if user.is_superuser: + return True + if not tenant: + return False + user_role = get_user_role(user, tenant) + return user_role in roles + +def is_block_walker(user): + return user.groups.filter(name='Block Walker').exists() + +def can_view_voters(user, tenant): + if user.has_perm("core.view_voter"): + return True + if user.is_superuser: + return True + # If they can edit, they can view + if can_edit_voter(user, tenant): + return True + # All authenticated users with a tenant role can usually view voters in our app + # but we should restrict it if they have NO role and NO permission. + role = get_user_role(user, tenant) + if role: # Any role (even if not in STAFF_ROLES) allows viewing voters? + # Block Walkers don't have a TenantUserRole usually, they have a Group. + return True + return False + +def can_view_donations(user, tenant): + if user.has_perm("core.view_donation"): + return True + if user.is_superuser: + return True + role = get_user_role(user, tenant) + if role in STAFF_ROLES: + return True + return False + +def can_edit_voter(user, tenant): + if user.has_perm("core.change_voter"): + return True + if user.is_superuser: + return True + role = get_user_role(user, tenant) + if role in STAFF_ROLES: + return True + return False + +def can_view_volunteers(user, tenant): + if user.has_perm("core.view_volunteer"): + return True + if user.is_superuser: + return True + role = get_user_role(user, tenant) + if role in STAFF_ROLES: + return True + return False + +def can_edit_volunteer(user, tenant): + if user.has_perm("core.change_volunteer"): + return True + if user.is_superuser: + return True + role = get_user_role(user, tenant) + if role in STAFF_ROLES: + return True + return False + +def role_required(roles, permission=None): + def decorator(view_func): + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + from .models import Tenant + tenant_id = request.session.get('tenant_id') + if not tenant_id: + if request.user.is_superuser: + return view_func(request, *args, **kwargs) + messages.warning(request, "Please select a campaign first.") + return redirect('index') + + tenant = Tenant.objects.filter(id=tenant_id).first() + if not tenant: + messages.warning(request, "Campaign not found.") + return redirect('index') + + # Check roles first + if has_role(request.user, tenant, roles): + return view_func(request, *args, **kwargs) + + # Check for specific permission if provided + if permission and request.user.has_perm(permission): + return view_func(request, *args, **kwargs) + + messages.error(request, "You do not have permission to perform this action.") + return redirect('index') + return _wrapped_view + return decorator diff --git a/core/templates/base.html b/core/templates/base.html index 7a75e50..47fb9c8 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -1,41 +1,32 @@ +{% load static %} {% block title %}Grassroots Campaign Manager{% endblock %} - - - - - {% load static %} - {% if project_description %} {% endif %} - - {% block head %}{% endblock %} + {% if project_image_url %} + + {% endif %} + {% block extra_css %}{% endblock %} - -