From 443505ace58a8cf3a73ddff6d8dc794ace4d4b3c Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 25 Jan 2026 00:39:13 +0000 Subject: [PATCH] .9 --- core/__pycache__/admin.cpython-311.pyc | Bin 6089 -> 19044 bytes core/__pycache__/forms.cpython-311.pyc | Bin 11128 -> 12269 bytes core/__pycache__/models.cpython-311.pyc | Bin 21512 -> 21651 bytes core/__pycache__/urls.cpython-311.pyc | Bin 2514 -> 2419 bytes core/__pycache__/views.cpython-311.pyc | Bin 29844 -> 26607 bytes core/admin.py | 256 +++++++++++++++++- core/forms.py | 16 +- .../0011_voter_birthdate_voter_nickname.py | 23 ++ ...r_birthdate_voter_nickname.cpython-311.pyc | Bin 0 -> 1011 bytes core/models.py | 4 +- core/templates/admin/event_change_list.html | 7 + core/templates/admin/import_csv.html | 42 +++ core/templates/admin/import_mapping.html | 48 ++++ core/templates/admin/voter_change_list.html | 7 + core/templates/core/index.html | 8 +- core/templates/core/voter_detail.html | 36 ++- core/templates/core/voter_import.html | 38 --- core/templates/core/voter_list.html | 3 - core/urls.py | 1 - core/views.py | 66 +---- 20 files changed, 426 insertions(+), 129 deletions(-) create mode 100644 core/migrations/0011_voter_birthdate_voter_nickname.py create mode 100644 core/migrations/__pycache__/0011_voter_birthdate_voter_nickname.cpython-311.pyc create mode 100644 core/templates/admin/event_change_list.html create mode 100644 core/templates/admin/import_csv.html create mode 100644 core/templates/admin/import_mapping.html create mode 100644 core/templates/admin/voter_change_list.html delete mode 100644 core/templates/core/voter_import.html diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 1d14269dac3926533479a7511929df1653c3314a..ccb9d815387e818e99188eccd1aa4e0029773033 100644 GIT binary patch literal 19044 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 literal 6089 zcmd5R-h1%jyzaRN|H-Ou6qWl*>T37shaP};!C_gEdVyR`tQFT>SEX|6PH7BA+oTwgkVtUMp>v1Qc zC!D07bW(cCN$Y7Rqi39~o^^71&dKX}XG9-yM)gru(Ud!i75!1MVoZHD)_UNph@$*V zU;om_rBs}x5+pUznh!`#Y6`7RwN_c=l}ar1^)JbhB#snGO}A!yIbPB9&#fgzp9wHV z`!J@7F*7jZY=AM=hcQcxxq%t41Q_Fe81uw9GBD#@fHBdBag-Rx24Etrnv->ur$@j(gosJB#yyy za5=!3>BCqc#;XG}t^^pfeHfRBad}|I)d1sYAI248TpgJ4nsx1gQe685d$FjBm}xn7 zIE7pIzDghLx(^5;3_h#^t}SP43wxyC&&GvPCR1UC$IFlIFhs zoR#gpDm`#?({yU4y<2&}JTSYZc5$IWt2CV<6Mj;dqN|poYb>HiEmeC zTPfQW#;Fi1JH@z28b-x*m|=*tVK`N*UdA|U7=N#uWq*aEe2W+@va*P=A3SazDrYpO z{Ca(Tuj;V%`jdLatFN;kSgGz+`T9Sq{JC2*OKhDS!PceoZ`AhrB;6w+K?8S|pwa%E zWak=NC)r$M3m-n;5*@7dtqn{Si79Oa$EUwj5I4uB#eT{0^MS;#1mbC{JHEMQb< z0iA1MwHHY3vFXN_`1m(LzDI41CUBi$r4uL$QpD2qD2uW9uBIof2n8%EGJe25N6J$C zqKJZsy5;&VDao1XG0QN$LzS-(+fFeVut{Xgw(A*|?bgcXzR0=ExTep zh=eUO!3|owOp6>aLSJ{4pcACm6CX70pG+<`wogV@kJfG-5(?{5@jh*9nT6$V0*~fYMP}U)E3Sox@PX ze?zy4jC9dcyG+66zXe+$W`Y0GSdUO)xd2Ci(Uu zF}NC^LJa^?oOjf~RDU%tJpjhLPo>`SA1EC>iwB|{@+*KqM*R_U0Axw1e9wl;H$;cf zw|(kft}DvZ#kziuRRG)<(seSVD@2FTb*yXOUasya6uQv0n^*-P)k5mdgw%!T5b90^ z8jQkJ}|rjL|7?*%qClhFh=Is@xM9Dy*K#cj@4_ zM6_gk`y%FgBvDNG2hDv_k8cx*)V|3r!=-+V-^1h%;41<;g#V23UBH(FB4Jt>S1}wmx9rBisalZv(mZdSU~{eb~5%kAHMPbO<|U z!mWDCsF=(sYJXvy7_W7=^U_r6)N;PzhTnnk$58q^f*t->3S`}0;41#1xN$XZ~JFb4x>CkmVH@FWqCpaFLDpf zC-t()&&wHJGequzXO^Bb>wHeOyh+Y>sS;2}ncl|RjC6(Ub!D$7yCvC)$RUZj?(jQ+=HOH3U$i=r35mHLW)@|7ko2G6c$AfTA zL<`9AjSdY&Te*HTx^k?pHr3TrO;a~Xss4DtngzMJHdT;qs$jd;xvfLDbgWYTErX6S WEer?bAsHyjG+G7EN?2Z&um1t=o7$5A diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index 6f72c17710490bc276a98d7ed844dd611b509918..def34ca37b8efa214c64d61e896e497fa297abd2 100644 GIT binary patch delta 3737 zcmc&$Yitx%6yDkHPG@(!-L~|#r7fi`3yUqU3P>!pwWWYyd6jLg$};Q@bjoyRxic*< zbxT^b31Ets7-LM7AEE?I&>CZ+KQJ-IKPD}10vSz=iBYRDDS;mZWAvOmyW4gtH3k!B zvtRCf=iGbee)rsS?%lxe!R&Y4Zl?vF4?=yiTzD?KnCm~cxFNsS-R0&iR?8+!*fwMd z+a<0in`U!G9uMPq;4)KO4&wyi97*onU%bN!geyUmIkMo*oiuyqu`b!bc~V?H<8pw@ z1+IWiSID?L;N~T{(xPzwZi~NQ0zcc&8TJDz>D4AUd~?yW+>l`K?NUUH=rq@=I5W`= zp4(UHpA}EgcdR{}jpk%{YYGhK0hwreFe3I!hC>mPO(7zO4w>YBndseNQKyX=xASbI z2*x4R2xSQ6w9)pKa~?G9@YhD@gw5wI@xNa1=7OvIC;Zs*hB0sBxVJIxZM?Z~1bM#j6*g8shK?Hmj^_nbT_UY8$g-qoKJc24qqoW{ zNB#cc z9k3*SlUBDKmVQ6nLhg>m?MRx->4bpoM0({;R;Jyh{#c0 zRwITz0*li~%1RzV7aLKJXcJaw`mL-J*#+%3zcYP>JkVq%!YYIeggO92XqS%aB9TP0 z8o4#ah-}B$a+xwafUBYhv-a`kR*rt1b%Cqt!0rxN3yL9K?w8C@tHth*Bh&-G#&tYpX!c3t_5IF5VHGw={(4s~}jJm0_ zJ$?SyDU-cDoh1A7d6&eVN|bI{Wu{)5Cm?aUo5Jj*B0d^icmi& z?B!mixxO-+WbfhW@~WCxn7d-V2o>fsRvGqm308}g(c?(Ihcm9&E<%dE41e?Kat>P# zaAEt9(B&Go9Ob&Q;4;G-9(H85^hQ;pV`?#kX0bOa$_FEttOWCvVOPZcl5)?pn_U7b zns*1XDh|ks1cf*TcDz7Xf3?uFm9_LsUx=Q_bi+mGd*52lPCw5qj9uc}%))_^A1`M2 ziI55@TvaOAi+Q;)Y&a$>Q&4Wq3I!d^eA$Gp8U$93Y-Tx{szj^^*}y)n1i{^yrGj1u^KAlYp~@#@opFt9jtgaRp*k)sy&Ie-(%h}eA*ofClrZrC&0@*O5EX^#U^rS~p{98V%~)>8 zR)lQ;)9d2%NFqexfBOW$-GNxDG~IMp4eyD&tKJJ-*?Xh5;d*Vucx_X>w&|;qF?aj8 zyFKo1A85VlDj8mVreSR1sxepHxT`MisvBsTv}M@0a+8*ShvsSHo}VQk)&DXeS*n`a zGV%hd@FKzh!byOCia9cfo~ICA0?=j)jkIY}k-?^W8N0IVGK{VCjd=x01eQzAq@0mz z^B2<(mu%Y4_TF%?kidh*E~xC5!^$u#H7u>Nqi@)l58H7z`kK_Bctg1-)c304K1JF< z&ciDnYAr~2`paWM>m3q*EX1g=pBX4$ZbCd`4nbx0e_TOAcH#z_S delta 2981 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 diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 363abb5c9ea9e66d77366cd581fa328c3ef7bd44..57229ea75dbbb8c5902402cb57c23bbebccb5787 100644 GIT binary patch delta 121 zcmca4{8>nSIWI340}%8ilxBWqV_&i5ploHB*_h#9-1XT5H&r zF*7i%24V&=3ZuVr3WM-6@ zyqK+wsYq(F7<&M>0?-gfATAbWnq0_UB`7(=^oq3Z2EPj|&KFsnudq08e!||s$geNK N&(y#Tf<hAfXDF1So9Qy%*SIcQ2lMArUZW zTBmuao#fOerb(NmnxbW@bd$a(O53Cp(>7w6Fx=F%`KOco(aG3$+K!p&^!v`;rz~-% zJ9B?~9^X0V`pF#Le9s?95l#3H?d z^WyL2Vpe4>VHejGu)Zo6b9(ZPY7NY`)+`oJm9PueRzc7$5k=S3^33sSdT%MaVB5vs zv$$(Bnq5=w&l@!5W;3>ls0nW9&kn*;tB8`Rvq&i{~L!Av80s zpwium#Y%)~0L290^?GR+D|EVupRIQGlAhQR=PEntW|QuRT@<&K)&gj@k&qGs(sTn` zUFP-NjvcolY(&_Eumxc=3zYqtbg}K8u5CTg@QD#yIXIx?GtwwFL;$W6ZyVIH$Qyko zzI&Qw|FA?4(4B0-Q(gj4#cA%v zI?|-02(9c;d0q7bP*m_m0ow5&?SiV7Ga{=nV>qO!^hj*J{0Wm8y^oz*E1RqVDA8_K zw&9*-nuQyJbKwoYa_Y`uFV0EF*w#Ats;9BI3*jKbQG{a%#}U2(z{q}2hXo2gD@9*5 zyS_RIDdqK3XwCh#LP>X4_cd3y79}+h3e#^v?-)Evn6Qt^yVzs?lGwes83{yk_r`Ok zoUcQlX5Rhfj!tIZvWYywwr=T~7&YGE4H|UmLcqMA7r?U$(=o9rRQKN1LsM9r)OMkIO?hrfWpH>DR#=%8kmD3z(ASwa)46oG$U zr$~>B)aT&4!5z9`BQ#q$p!#Xx5h%z}UNFP@5zW#clENbW9?o$ZKtYkkBa43-(UruI zO3#9U?Kw34J#JV9`%80OY~c$pnbzY@ahCK}DD9%(1{^KAI|{-G1l5on^+Fbr#9F>_ z^aMLyza}Bi=`F>#dZ!l;V9{0(^6SX-ugDNV{pzG>65IXv2p?>q3_}pmVOA}b7l>2 zKK3u^cq)E8MrJhTWH6;9w40yiC8ol?hHYS0jkf0yCIOTiCG5mdWbk_f%r2qnMdVk@ z?%I9=6g({*CKErxX^`M(NqR6uNW+kj65&9j6y3a~=_VO);+j>TCrzwLvO2{U1AQGQ z-;0pqg#lc|t4N4%#u!{lc@|;fK@KgY!tEy^ef5uCKT#OIIPPPCgf541x}q+ zoq1fHiBa?d7=8+m@}?fZv9@CNbnBtT@=P&ZN}HT%c5QN|N#AwOKS39>kc=~CG_}QkPYXw}WsnPUzV6mJ)RJ#EV z#dh7_jrV|89_S@j{jFr?Zm)hm@EGPUP5kCkOsMc(bTE}2xzkxiSg4DzG(B#AfWg)1 z@iV#(Kkn|2CzHXRuYHad&3*R?Jb<_oPyE-5(zBs7B1Kgtd9Oi{?%=VkK>KW|un;>^ zBAvzRC4^Ma#%^|gFY{Nz-Ph+|VVQ%Gb+J}|4FrH6)5jis)#%Pljg+r(87$eFzG@Z; z)>LFtqF10VjYF__A{&Qy(94wPJaS1Hrg0cUJFY{1DYIe~5A}o&okIbthJv9Xe(YpQ z@sDUcX;=w}UN6?78@0&l{4UEp^zV#~iJMS+c!E2Uv_~tLv#;bj64_X3BT| zB(}W3o7v__t(7;PW)DRw(=L>iJk(sN*(N;1W+Igh$(ellOQucD3LiC+q~7S#<7`EA zCFy1L(N&c^r{?J&76XzTl;Qj8ui#)GZqfPFMfp;+pA>MKU(`%NWkfTEWY#$J--+)c zn{EU@!Vv^6=CfGB+bewo0dGV6;=qqpI)hcr-8^+s%mx%+9f~5N7$ok7zW2B;Pkh(y i^$}Sd4oh3+OHny(4u1 delta 6922 zcmb7J3s79wdA@hwusjyn2dqG>9$*#;BrIbql8}pd)RKuLK+#)St@d7ErG-UjF9^1a zH*Qo9a@7g=D77ZR@iwEeS2C%wSDm0VO_)wyC-I~Kbz9+E(`eed9ZeoaP114IOw#{9 zcc1bqZdcs@o_o%J{&T+bf9LD}2bKDznDL=`XEODG*nKjer z^`}s?x@^|^yEJK7-$@S`b~f8)@+UMQKBNw5gz_2ngi+d*wix1sijeU&HH-+C@&jD< z*mn&U-k^620uciN`eELmSS*rXa{FAPen}**M1x2#N#} zYb@W|3f9OT6njy$fRIZ(?!jTVw|~eL5GBbw)E|&}7uk*NPt&@BL-|LbSOixf7<-}M zJYVbF@@CbIs=4tcXKUQqddIo%wsYT-vn%fGy5sD*?d(}{9*R2;#kz_#D*g!VHd*)% z+Hb0E>_(H-C~DCfVskhgT8m-XV9$2S5G2eHwB;yhhmQ)OWb(u2>qAY=^1UYcLoRQow729W2l zK#8BSPWkE4$;Ftva#CH1z&t5x!b1sMxVXOv>gfH3r>Ic!M|Fbs9Kzw}rdqhO zMfB8`D(Z6-(3#R=O)m$UtybJ+)?Gq=_7<7YN*gkImL|&~?L&Gx=`q@EupvIW4fv3{+lhS=HjF;|SoNmVMNtLn;m9pF_ z<_r~cZWT+0iY2#-HABUkTg8^4V#}>ko}p5nOJzW)$WW=sRuSwo`Lk=Xy>t>-I*~tN z4C#f^U?G(CQ~c%q8#w8Sq*FLX07BalE2_4QBfW z+gI3H#0E)qr>%~PymcW1sIGrSO;0$gEE}>X)yoYPNhNfhx+1KWb|welI2d9hqz08x zGo#909!LYV!Mc1533~^J{X}xGco1;(5&xjW?P%*g?CA6Q#73r~1l!MpVS+K7I6%mn zg9myK$r{uOY8pL((O|_vUs!YpM90Xm&+is8&Bz5xyz#QZg4)j=>TKAq1kfQc_JCjS zRLOaQ=<^OjJSKA9`4LD(o&h%@qtJej1-m{d5sbI@Fe=@{O>hrm#XTJgJ&IBcLiaGr zIP-}cZsat5(^VgqwZjAcAyL+fgKn=+HV6=PiPs~^hG8Olyh9!d;xqNo zzy+@~01JhXWepMCf~*rnk6#dFmDevDdST&)`u84?HACWtQWu;ld*$vVK@51o#qKKjvFNFK^$uc8$LjNn3=fk)hu z8&4NjJnAi3F(JSQ;dgP|ZXT3V|Lq zz3;B1T{SJ?yZWMw&t0|N)>kj-t5=HGUcE3AiXNFgwN$(_Uc7TsvurH>Qp<}im!7`- z^nGq~Uj4Fd?Ujkh;ka${nyR>T;PQdV19#1q$&O`t7?8;bV-AkdVP~_-`HTBm{-KlB2UDLK)U9+;O zF&YkB-#)v2&V6H7bXTM-(tXVuvCjA0t#63BXKR<8o1@*c)|Kr$=i6=>zHL}&`c}bw zLDVz1ZniJl_r3KijZJeKt`E!(%$MBoMSYRBD1WUp(z&2o+4A_@w(DcFWAp27grcEH zZ?xpv;mF~I(v`=X=R9xK-K?AMetYxW=BPRxZJpIcbqjkQ-R1vmD`jfilXHQ$w%^=7 zf9CC$xt6Fpx@T4&)i0b`wHVjiS2+}VuC|@O&lQJjt-s`oYpv7!r#h}awd&w3_N(2~ z`=&akJ3d~qIl!GI8Dhn3f4S?WuElkG=G7qZeqOmRncL##wncN>ve|ysJ^k!dXY${4 z=g06{)q)|s8#t3?>imo2ljFdWxGC(2*cR)17MejQe@mv`xT$y1)Vo}4xyrv7oD4EW zrYFqzE!GRl-;zm;o5V$v_+iLkyZs+Wo#@nXlM=0ju2 z)MJZlw#^HRM@}s^e;)ppjIOxRwWz6a1@M6Vj;?H9#K+kOG(vzFGpIYfRH~h6lIn3g7MqHxz+smr|5-ZzL`~iw@qxdTn z-$C(Ry0CF$Ckq5K_?$l1GUMz-i}5XE8z-}i5y*#`CL~7qu z=gp%<41n}leKUWHeyRSLoqP=|vD0;);E+yh@+1zk4M(sQyO2KzA#1!t5Pk7zN50OU z1MX3gyo=A@L&3nZkj5hM+o+?3oHiIB*RUO@PS$~N8kK051Ej3?xg{5IUx30h{vj+8 z77Dd}UePBIG|H?6FdHfh!DQCiUKG3%xeW^c09W9Luom61md#&LZ%HizsezVG@_mrO z(!E2Fc-|B##U4$fluw)+W`4=hh3Ge{X1H1<1?fK;+-RU^RmJ`xGfJ?0g|b%3FMxq})dS36z5+DNM>4=o660 zzkpf_08Thf)na^%pvVO$7gbNB+^l$!la0yAD?|lbwP^5PQRL)lA>(PX7x@oRY(U80 z0myskPj`PIH%Ze#AH`4xG&e&T(A3lw$Unvz=D?T$M*bbzcV*C%A<9Y5;siZYnC?N> z&+znmYZUm{v*)v_@?3m`*m9G8v%RjA#g_M=Px{jg#J~Os)Q~kOa`DWB4S4!xUvJ1y zv6KrEZb_&9ktu+@O3!sv+8Gy%kc&R=Ie0r7@WUzoBk0;r|E%L7TKh8@~%YToi)>E{{@`|obfb^Aba=sCAQP|_h0-S zG!(lJZ^}+_4@xsbp*SiINr9{b%bp0%vl2xvewJWEnxFrTkN=4x?X&Su%id>us^Mek zi#=7^UqerSY__Kk)}LkQpZ9)URh}IS2^-^M(4>FdT}%J&aNWaM`vJ72FyLI$p^!~C zf^`oex{q~f9NC=AKs1hejFiW*q(jk;={?Cx)3BRJUXOQ}rJ!tL{ts$1Vj_lYV$xej z)@pwX)z8O1IMSTNk15=k!XK=p=ex~eA`bdTKbh4C!<{=aGTfieO~Q~U#}o1!oNbz* zZ1PCZ!mPwicb{2JJpwyX`2{JGCT-74{AOkW-_40eBb9u_JT?y>Yrx z$J}*|{`2YTjKgPj1hp8ia~TZNW_NW<%969_k?~d5!RG@}lpX>`o~P69wfu29=U!jI zf^VVnJ*Z#w<0lZ34~sSv`-%G&Z(_i9kQ+Fo*Fn&j=Qm+|M^HYJoy5{b6zn4d+r_u> zaTx`k0m)qyc(!2KmSB2f!JWl7f+2(81|!Haf-YmjD{GFUvjgS>!>&QUFya$;6Zl^p hjsrC=@NR6I80F1Fd<%b?U*$67bFmNlig^uo|6fWc>81bx diff --git a/core/admin.py b/core/admin.py index 35068b0..a3fd971 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,8 +1,49 @@ -from django.contrib import admin +import csv +import io +import logging +import tempfile +import os +from django.contrib import admin, messages +from django.urls import path +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 ) +from .forms import VoterImportForm, EventImportForm + +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'), + ('zip_code', 'Zip Code'), + ('county', 'County'), + ('phone', 'Phone'), + ('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'), +] + +EVENT_MAPPABLE_FIELDS = [ + ('date', 'Date'), + ('event_type', 'Event Type (Name)'), + ('description', 'Description'), +] class TenantUserRoleInline(admin.TabularInline): model = TenantUserRole @@ -66,15 +107,222 @@ class VoterLikelihoodInline(admin.TabularInline): @admin.register(Voter) class VoterAdmin(admin.ModelAdmin): - list_display = ('first_name', 'last_name', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state') + list_display = ('first_name', 'last_name', 'nickname', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state') list_filter = ('tenant', 'candidate_support', 'is_targeted', 'yard_sign', 'district', 'city', 'state') - search_fields = ('first_name', 'last_name', 'voter_id', 'address', 'city', 'state', 'zip_code', 'county') + search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'zip_code', 'county') inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline] + change_list_template = "admin/voter_change_list.html" + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + 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 "_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 VOTER_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 + for row in reader: + try: + voter_data = {} + for field_name, csv_col in mapping.items(): + if csv_col: + val = row.get(csv_col) + if val is not None: + if field_name == 'is_targeted': + val = str(val).lower() in ['true', '1', 'yes'] + voter_data[field_name] = val + + voter_id = voter_data.pop('voter_id', '') + + if 'candidate_support' in voter_data: + if voter_data['candidate_support'] not in dict(Voter.SUPPORT_CHOICES): + voter_data['candidate_support'] = 'unknown' + if 'yard_sign' in voter_data: + if voter_data['yard_sign'] not in dict(Voter.YARD_SIGN_CHOICES): + voter_data['yard_sign'] = 'none' + if 'window_sticker' in voter_data: + if voter_data['window_sticker'] not in dict(Voter.WINDOW_STICKER_CHOICES): + voter_data['window_sticker'] = 'none' + + for d_field in ['registration_date', 'birthdate', 'latitude', 'longitude']: + if d_field in voter_data and not voter_data[d_field]: + del voter_data[d_field] + + Voter.objects.update_or_create( + tenant=tenant, + voter_id=voter_id, + defaults=voter_data + ) + count += 1 + except Exception as e: + logger.error(f"Error importing voter row: {e}") + errors += 1 + + if os.path.exists(file_path): + os.remove(file_path) + self.message_user(request, f"Successfully imported {count} voters.") + if errors > 0: + self.message_user(request, f"Failed to import {errors} rows.", 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 = 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') 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(admin.ModelAdmin): list_display = ('event_type', 'date', 'tenant') list_filter = ('tenant', 'date', 'event_type') + change_list_template = "admin/event_change_list.html" + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + 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 "_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 + 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 '' + + if not date or not event_type_name: + errors += 1 + continue + + event_type, _ = EventType.objects.get_or_create( + tenant=tenant, + name=event_type_name + ) + + Event.objects.create( + tenant=tenant, + date=date, + event_type=event_type, + description=description + ) + count += 1 + except Exception as e: + logger.error(f"Error importing event row: {e}") + errors += 1 + + if os.path.exists(file_path): + os.remove(file_path) + self.message_user(request, f"Successfully imported {count} events.") + if errors > 0: + self.message_user(request, f"Failed to import {errors} rows.", 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(EventParticipation) class EventParticipationAdmin(admin.ModelAdmin): @@ -84,4 +332,4 @@ class EventParticipationAdmin(admin.ModelAdmin): @admin.register(CampaignSettings) class CampaignSettingsAdmin(admin.ModelAdmin): list_display = ('tenant', 'donation_goal') - list_filter = ('tenant',) \ No newline at end of file + list_filter = ('tenant',) diff --git a/core/forms.py b/core/forms.py index a025ab4..836d8bd 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,16 +1,17 @@ from django import forms -from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType +from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation, EventType, Tenant class VoterForm(forms.ModelForm): class Meta: model = Voter fields = [ - 'first_name', 'last_name', 'address_street', 'city', 'state', + 'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state', 'zip_code', 'county', 'latitude', 'longitude', 'phone', 'email', 'voter_id', 'district', 'precinct', 'registration_date', 'is_targeted', 'candidate_support', 'yard_sign', 'window_sticker' ] widgets = { + 'birthdate': forms.DateInput(attrs={'type': 'date'}), 'registration_date': forms.DateInput(attrs={'type': 'date'}), 'latitude': forms.TextInput(attrs={'class': 'form-control bg-light'}), 'longitude': forms.TextInput(attrs={'class': 'form-control bg-light'}), @@ -109,8 +110,19 @@ class EventForm(forms.ModelForm): self.fields['event_type'].widget.attrs.update({'class': 'form-select'}) class VoterImportForm(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'}) + +class EventImportForm(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/migrations/0011_voter_birthdate_voter_nickname.py b/core/migrations/0011_voter_birthdate_voter_nickname.py new file mode 100644 index 0000000..ce248cd --- /dev/null +++ b/core/migrations/0011_voter_birthdate_voter_nickname.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-01-25 00:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0010_alter_voter_window_sticker_campaignsettings'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='birthdate', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='voter', + name='nickname', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/core/migrations/__pycache__/0011_voter_birthdate_voter_nickname.cpython-311.pyc b/core/migrations/__pycache__/0011_voter_birthdate_voter_nickname.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7fe2c1201f1a604c0d12288bba57e7ce7a18e53b GIT binary patch literal 1011 zcmaJITfUYc%a>~?9V%d{1|aqwU~ zapURBQ7P`Ljnh7`gDd5!M0<;z)~Vcf6k(-t#DgNv zST6K5BQ(jw##4UHu{i$Z)eg0d!s`KGA{iqpc3Q_*g>7u$Z26qNfPGKpI; zBiWXp7jd`&G7Qo|5JxPhMG>Y%nBABcJrWf39RzE2lYngqjg?8_sL(jsET07_ zg*Az-_c2X~Fc?IY3#SBXa1=fXA21KL{e-fp=&d=HF#Oun_l4#A5UETk@H@W0Sq6!8 zmWt2;WrVUYra6~4D2!)>ypX4GO-geGa;vp4pZ5YjqEXslY0+Nrsq!V`Q0Z&d;a^(&y^#4>DXQx+Dp}hW?E+m;6lO; zpV@1lUyqLL&avGY+MSy|QHHZo{rMy!-e}Ze09hw?6j|;JpQ2 literal 0 HcmV?d00001 diff --git a/core/models.py b/core/models.py index 9034959..023259f 100644 --- a/core/models.py +++ b/core/models.py @@ -105,6 +105,8 @@ class Voter(models.Model): voter_id = models.CharField(max_length=50, blank=True) first_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100) + nickname = models.CharField(max_length=100, blank=True) + birthdate = models.DateField(null=True, blank=True) address = models.TextField(blank=True) address_street = models.CharField(max_length=255, blank=True) city = models.CharField(max_length=100, blank=True) @@ -302,4 +304,4 @@ class CampaignSettings(models.Model): verbose_name_plural = 'Campaign Settings' def __str__(self): - return f'Settings for {self.tenant.name}' + return f'Settings for {self.tenant.name}' \ No newline at end of file diff --git a/core/templates/admin/event_change_list.html b/core/templates/admin/event_change_list.html new file mode 100644 index 0000000..df48ce9 --- /dev/null +++ b/core/templates/admin/event_change_list.html @@ -0,0 +1,7 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +
  • + Import Events +
  • + {{ block.super }} +{% endblock %} diff --git a/core/templates/admin/import_csv.html b/core/templates/admin/import_csv.html new file mode 100644 index 0000000..ccd6b51 --- /dev/null +++ b/core/templates/admin/import_csv.html @@ -0,0 +1,42 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    + {% csrf_token %} +
    +
    +

    Upload a CSV file to import {{ opts.verbose_name_plural }}.

    + {% if title == "Import Voters" %} +

    Expected columns (header mandatory): voter_id, first_name, last_name, address_street, city, state, zip_code, county, phone, email, district, precinct, registration_date, is_targeted, candidate_support, yard_sign, window_sticker

    + {% else %} +

    Expected columns (header mandatory): date, event_type, description

    + {% endif %} +
    + {% for field in form %} +
    + {{ field.errors }} + + {{ field }} + {% if field.help_text %} +
    {{ field.help_text|safe }}
    + {% endif %} +
    + {% endfor %} +
    +
    + +
    +
    +
    +{% endblock %} diff --git a/core/templates/admin/import_mapping.html b/core/templates/admin/import_mapping.html new file mode 100644 index 0000000..c07c7a1 --- /dev/null +++ b/core/templates/admin/import_mapping.html @@ -0,0 +1,48 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    + {% csrf_token %} + + + +
    +

    {% translate "Map CSV Columns to Model Fields" %}

    +
    + Select which CSV column matches each model field. Leave blank to skip. +
    + + {% for field_name, verbose_name in model_fields %} +
    +
    + + +
    +
    + {% endfor %} +
    + +
    + +
    +
    +
    +{% endblock %} diff --git a/core/templates/admin/voter_change_list.html b/core/templates/admin/voter_change_list.html new file mode 100644 index 0000000..eb36f95 --- /dev/null +++ b/core/templates/admin/voter_change_list.html @@ -0,0 +1,7 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +
  • + Import Voters +
  • + {{ block.super }} +{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index 91ce1a5..f689bb2 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -80,11 +80,11 @@
    - +
    -
    Voter Addresses
    -

    {{ metrics.total_voter_addresses }}

    +
    Target Households
    +

    {{ metrics.total_target_households }}

    @@ -155,4 +155,4 @@ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; } -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/voter_detail.html b/core/templates/core/voter_detail.html index f1895cf..e54d851 100644 --- a/core/templates/core/voter_detail.html +++ b/core/templates/core/voter_detail.html @@ -11,7 +11,7 @@ @@ -21,12 +21,12 @@
    - {{ voter.first_name|first }}{{ voter.last_name|first }} + {% if voter.nickname %}{{ voter.nickname|first }}{% else %}{{ voter.first_name|first }}{% endif %}{{ voter.last_name|first }}
    -

    {{ voter.first_name }} {{ voter.last_name }}

    +

    {% if voter.nickname %}{{ voter.nickname }}{% else %}{{ voter.first_name }}{% endif %} {{ voter.last_name }}

    @@ -89,6 +89,10 @@ {{ voter.phone|default:"N/A" }} +
  • + + {{ voter.birthdate|date:"M d, Y"|default:"N/A" }} +
  • {{ voter.registration_date|date:"M d, Y"|default:"Unknown" }} @@ -373,15 +377,19 @@ {% csrf_token %}