From 239f7ba1fa6d201672c3dcb5d1681c7be2254f32 Mon Sep 17 00:00:00 2001 From: ismail Date: Wed, 19 Nov 2025 12:44:51 +0300 Subject: [PATCH] update and bug fixes --- recruitment/__pycache__/forms.cpython-313.pyc | Bin 85420 -> 87767 bytes .../__pycache__/signals.cpython-313.pyc | Bin 9054 -> 9071 bytes recruitment/__pycache__/views.cpython-313.pyc | Bin 193535 -> 198603 bytes recruitment/email_service.py | 213 ++++----- recruitment/forms.py | 49 ++ recruitment/signals.py | 3 + recruitment/tasks.py | 18 +- recruitment/urls.py | 19 +- recruitment/views.py | 347 +++++++++----- templates/base.html | 16 +- templates/includes/email_compose_form.html | 20 +- templates/jobs/job_detail.html | 94 +++- .../recruitment/staff_assignment_view.html | 432 ++++++++++++++++++ 13 files changed, 940 insertions(+), 271 deletions(-) create mode 100644 templates/recruitment/staff_assignment_view.html diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index 55879c613d23ba7be2ca584f8f2b75352c17eb78..d69c70ae1cb77c452e4920a71e86374770217c8c 100644 GIT binary patch delta 1697 zcmZuxYituo5Z<}-`Efow50g0YBfccf!%#m%3MpwIEd(_XXmkOJQYms0Cm8ub&qgi& zLrYZ@)B=VTI%ungA4LN33hECFwF>aJLY0G&VxmYqid1M-)hQrQ5mI+;jDpnr@$Kw4 zGdpuTvvclSZuUH9d}J{2hZs^cFuHV}VKk*$yv& z;aDZI6l{umC7ji8EJAVwDc5?SL4)W9vC7?*FvcJj2Ts-CY2%yegB>u<0H5C2316zf zsN7fsN6B)v2O8B{>Z4T-b%PJI>8suF8>_FPTr~xA`ocQUz+$H8W};e?@~{Uc8B-}z z-&&KD2#k$&$K#>#Xe1a-^u**ys6mHN(n&>eEGCPAf;JQ#7vt1eOvJ>1xHC2^?ux|| zgvM8HRKrf3x@X4Guf@F|>Bxnt+JRm#LJ}vb-K?bP058bA#1ypJl9q6KNhb^Ayi-3{ z^FO(``+|u;sEH&bKVt|yBu6GqQZO7GNxV!a5{wK75jzJh@q zAo@)T_`!@l{Y#XX1vu>3-8x_=H$wx&HxPsUC|7Qo;Bu9Ylw(4DN2opCKH2$AXRfyS zmP5?czm;{Y$l1gNjml$OKq^~Vo>iOp2~EBn87wD!8M{AgZOgNaa;Fol*zleXRQT`9 zt;%N(@Tnw_7-e-miT_oz9;dIH@#H>Ec_9MNNNVM-2zJoo{w@=%X$O4ix}B0h_<_Ot5;@pdl1ORvlF-2nHkFvc zmXahH22HPIW2szH8L?-ja_ZI2;9t(#@PnTWdn-1{|Ip$*rf{9O$-h#d1?x#Jak<1T z(P9t1EQre`$Ed*@q(_ofg{E>94QLCxNY6#&ergb>u$w?qSX@#0FG}f0VqurhFGkiT zTy4%Oc@H&CQn*0Ql~YtYLcv1Q8mH23s@O^;FM&CIFtRJLXDA#P4u+K}J5=!+8ha=- zDr@n=c@w_(vt4Z~G!gb*Fss{Wm(1gjKdBkH@Ah8|p9#;pyJy_pxoUf!MV@ydj~EaC zJ70OAHPw02n&n-24(SYs^#}B+z8P)9P3^Kgk37pKREw#3pFh*~e%9Cf8tuNXd|n4) zN3O2D$lugj@7o*ag^HBxbk~gV_PkIwUtXR1^oNR*UDFLEX0feeSZ+o_fh(yKm1Ax>*R9vYHqvlXu2$;h64 zbVK`u>X9hQezMF}jD&-M=uojGf}=x;*j!Vw3Nuqx6^rN%v>@y7f!4aE$5+i-y)#zt z)cUM-)qJISwzB0$Wy?9pEZvrE{V-d(JtJ(-m02=c3%+>8%QUk1+6wyyD@jSFKu2EO zL4rTqk8Y{jo~i_nUqA$&*s3}Hsh8udc|<^7Lu?8jPIxF1nJkoYj-D-o#X^j6TAG|9$Sz)`*_U)F8`Ynv5?MfL;Ys4j?LN`=$WKrA*vlcY$pZnw}oaD9fb@RLcld WQEa%qFPKrEnd>Wq45LgDND}}+d`s;B diff --git a/recruitment/__pycache__/signals.cpython-313.pyc b/recruitment/__pycache__/signals.cpython-313.pyc index 6fc1dcde6c56b6b774a958a7f017cda2b61374ea..2412a5254f91f2baeca02c01270ccf209740362d 100644 GIT binary patch delta 142 zcmV;90CE4`M(;)q^9>CO00000UveC2Hjxb(0m_jl%?eWhQv^!}N(XWWvGOPf0U?tc z2wwpXvwH}y0s&OBD+^Bp0bjFw4*UcGn6r2jM*;!3v%nQ10|CCX;}{160nW4Q8}9=F w-m}{uPyzujlPn@Y2;BfA@CE=15tDBsBM&73C-VaU0x0kTB>^Y#WRsyHy~C<2H2?qr delta 126 zcmV-^0D=GSM&3pZ^9>CO00000+(;a050MQS0ltwZ&9VU|2LT_GCkS5w46}y_uL1#4 zvo{M*0|8vKhYtJ%0hP0a6Gs98wX@6>A_D=tv+o!O0|Cpk_Z#m60o$|ZA5a1TDw8-O gKm*_j5R-r+BM&42C-VaU0x0kTBmpP!Vw18Wy{Et@?f?J) diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index 5cf136610c2f64f15d677e8dc1f9686639b8d491..87066dd836cebac565179895d18835e0d47bf0ab 100644 GIT binary patch delta 39660 zcmchA2Y8g#((s;>?51pb?;Vl=X%y)tKqwmsWeHuAC0QVlf@c#-L>5#86cv2cqf!(_ z2q*$Z4R)>-#gc#;T(AJ@wcRTqS4FS-&&<2K?{19uzu))#&!0z!^PW00XU?2CbIShl zcjNIpf4i418-<++&2BOY6Gg<7mYODsNv6qSvZ+jz znWl&-6dw!m7>8+7#Z-!mE1hPVE~ZmBzSL}*ASO^a0kD}NW>7d0S{*CKno2~8X|xz^ z8ZX8}e1JKrG|f~l%1txHOw%l(3T8ydea)Q#$*?E(^|3Cv`(y}3VM{@V7gJL6Irj25wX_MGwx<%YVmGp$Jyj9#v;cV#2&0;f^&nZ<(e9G;nhsDE`#!7dK-O%;9 zrH`2Qh&`sgqQ$gN?4z_^rTa~fibpBjyYw;B0dauBeSnFNi^nOP2Vr2Mi>VZsuXZhk z-OQiAdpw(d4N4#U$-N_3uzmia0ZjcChvyqJbOY26op>Tnm!UHkB{U?58h0H}Q~W@PPaOma>)Z)#lrR_) zhRm-AT$d@+-i=iL@C`8zg}7l}asD;WQtsi9G6HJ3-d)Rc6h9K;M?t*Z9sfMV7eoAL zFLYiok4eyp7w3n~k28-=aI?V8ikFmS`gr?M^EmN0^LX(x^wfmXS4^*pSJQM*Vxm?; zyk<6u*UhC++Z$BdBrVNd+c8R++)c`x1v+!tG$!7Hf2Y8|$IVlrrnk-00KPMdicRmP z*t=#kWO&a!17N$k9AJleCcqQ(Z>2KtnP(;FmM~?MU%dY0{Ji-E%0fe?&-?K2W<%o8 z4=B(4ZtDGzQm*bMr4v)2{5eB!c1j8Hi}(A;8I$kVJKJGi0Ii=kR{=Z&?QJ$!cT>m5 z%38m8`zO@K!fw((ogZdiq{WMm%!{GE&&)Ld&zhG2JZD}C@VvPe;OEd0bso9DfbV*b z?=RuI!Q=by@VzWSXLt9?SLSP>ny(>Rcx3wqzHJ`g-@Nu zUg7cmJ$$e9_#S)#B35}sT!imtkMB#sz3V)_e=x5GG%o}1+x?S$et>^3_$S-{Wwt}? zkLI-ie**DbSNgN*ins!U{|1Qp#e5^cU(GiG{5PboFa6E*yZD_{!_B3kra#0V5Z;iW z8_xs^^hT{ch+z=3$t#9K%q^ZVcHsln-a4L@`i^HNJrvT@$k^hgVE2#O(BniG!F2 zJYwuj#DknX=n=(4f_WG9XrlQc`Zvk^F#Veh<7&58&XoBv=106@QXyuKS4*Cr!iB{)Tg{^SI%|FFh_GB{ z7z*XY5T!1>NA^GnLJdK}y4)&k){0t7^Qv55(GPR|s@7-16Z8>RVc4CN%S661 zDkEO`J!T-=rd$-*;AY*apoq04r=kkhPCk{8and(d&HlXNpR$0xq%2M8 zW2O53dve4PY-JPxhtbwlRb>VKHHlhrKSp;!=qSb^cuDzpN|s8}QKe_BTM_`^erSAWXu6V`9<8Et4A7}n z_M}H#-GRCP6H2Qv1EM6}LU0_xH3*29?htqvQxSA1(HTwwdKU!9!ai($C}W5(+oOEk zbFcj|$l=g8HY}^Das=8MEuyiav8K){@-W_~zF~!!LpiKMGzhUD!oq@}Hv$|y4*%*V zVQH*ss27Vc?h^!`BKQo!Sp=sMoI`L1fFqz}WtDXq7RgQW7p)loB!Z_9e1YIg1Yawu zIZ1}?7<@u0>QYg>BC=W!a#p)^$0j+0`n?x*vNhv79#cit&?#l_cf~{Opcx)Ds9k zRG#VOWLpRH6KWSTv$am^eJeK*qE9O4@`f8OU_~uTkNhlu#3naX99E{~=NiAk_(KRT zE9>*q*;~rq{381d%o3uK?0~Stud1QG(YmrxP(8n5{_m&|f))h7Bls7B9})bFfVf2( zBLqHGP3446g>?nu9ocgm8tNunt&KJHi^f_TEj7xlf>^e)wV_}*n@!kuFaKRwcON#1 z6{-w-21CyR@KjgdV$Iu)EUm@@w8)MblmtYE3oU895=#HSKW!08OYjp)ci})7u*z^FX zADy#~GImf8_L#C{&_(+;tjH;0-Ay%GpJ|eQAC|cn0m*y-c7i7O-S(p%^B}+;v*knC zpK;bmQ5=JF)n3)N1`KK9hDWg4UCK?v2Ijcgn9E6w9f8%>T5F?~CV~T)Pi3!iepnB7 zUI`eUWB(jtbHhXhCKBe4VyF^X3x_lKwW_CWl{2@<9V%d%y) zHC3eRDynfj)jCB;i5-1?Zyc1l5h4-FiZR$C7Ipa7*eaI8Y*DR>luDiQ(&*gLBsoX~ zsNb5#TC1v8i3~$+!=go?agjQQfmE-JxRYy8X(I*DqVyWmzbC2-n*jg`j+3syDq#iL zs#wynK;!{JKeXO7X1|Y-I*oedk}`Pw9J^W#lm&^WzPJv;T__MekRTclZj40^##t~k z;wy6Tle(f3KD|1i1=Fb=Lexvb!+|~4>Y7GEvk2;lYR#B%Um$x#d4BrPpi5A)jF>1+ zD?aA&>=&ifd}m|{0V{`b zS%a;yqNdKW$ZAtLK)rrIIXknr(Sub=?5yOW7UTf+q`#%Uz5x`PRSdznp;%R*dCZjQ zB^6VPXG{=%F(SaSXpyik!d(58hFLv~Xi2Nqa-s6Tti+!E@Vfv3O)r%Y%1!eSdE)pi z<+EAQ1Lh!Ea}mr#FdxCy2o@r^1_AbpBM{`I0w({)CYz{Jdd)5|S}<(^f-0qXc28EV z?3&#-&k7NaU>E_`MGayVDK3X^O{29=RAAOh1RQ`Z4Qf@`oYZht9>r)(K~UIQIOm|A zO_JgFv2^9L7Y4Nsu9(Ap`1C@V{~(X^Ee|q>D0f>XvP|VuOJ3sg;XbLlg)BF;OrY%U zV>Bl^*a~q{$zCvJRtC%tma4_D0BIbWwy>twT5qYd4$lCqt)gnV4JVX}W_^Bh&9aPN zBFErNTi=*Bw#EkCS3~AUyocDYf(BF*Yl-slf{ocE*~nstugz**Dtcf#3Vv>|C%2Ui zRkPW5%4bz+Y`em$6AbTgV88+;y*e^FzB44fBP6{uB&Q=Jr^VbJlG7eiq)e>-d#Ew5 z#e8tizBw(Whnm{`hbw)p)A~jGyb@g;RMOk`)pXx+(LS&B4jdQZ_u7E)alwAC8~h;V z^L*s;k$ETId-RJ0)lNlDO@d613D}XJA*@Uy{!5SIohXyPly zyk-%tIK-{^P2E5MgZ68&tr2N#hS^mOsxYpF*tHgzlQp!$>hVHUPuw zm%`LiFTR1Sw*i^9{t#HJyX@aO*xtz!=Oea3Xmnbd8W)qLVu7e!;E>XLU1|dHIK)*; z1LR|(&gI+bMVS|orP--1+C!l!F2{e}@7!M_1`j3ul)h-~}? zO*OUEAhy3lhJPq)H1Io8I&Vgde$m2pgn)cKh*6VieVQ9*XbdWJ}X*`ZYET z6T)fb-8&L;sT(xR2q~I~b)b#aHFFZ8bWjQ35<6TVDuFdNmlhJgL4>d&=!t*`Bl$~) zLSP)>uyOd3p?<4SixIRxPNy!5E_I^b2dOwbdS;FI0YWWfB|Qxb;->phmbgRn-eNLo-_g(1>^s9weU*z|Y|k@iF+ zRJTSYgkV{?UwLa=avWKXC`>kgh<0%rQ2S$BGFz-9Z$HPjJ+WgC^K;ti%7LAu$gBky z7EJ`y0!@kT*}&o31TGwh(?+hYSg^`tK|vj4L1YLIr^X~SNSr-2rX$K}W|L66SRwQS zpbOS0*FLaM+?_%-I*9jtir!9n{);l*zjbK?|+foz8YB5AJ>qw9vvw-cScJ zj$u#}S}RE|nn>|Z-GlmT(Vi4`Ou2c_V8He09vct9QG~qN(>iQlBulWsr$-ee$Anmm zQnNq1SkuK{VdY364s|g3m(>WEkt-qM&!d3`LKYI*qIB%fB(IYrRter8YZOx<`U8+z z+gGyk3l^;uJ=zJAW3AxvO2%X3BC$fyL)aVS5y63T-D8P~KVy+Fh+V5YV+`Mt-JUr7 zq;W*M?+CH2_3&da`v;TBhN1}$?>c4eQ`6ZD<>XVf#up(<+=0lqD5j@#BB+x-!hAle ze4JNqdb-d+qH`UvnrA5l)VViKzqayR%cmR;k6(lC4)+~uV_M)W3geR$DzkHfz9bpw5_OVTy3X1o^fwx$JjHH`wt&A(gyp81y^=A7SNyn3X@Fd|6PU7FfzM~0Ak zdmlrUP`68(_9*wgR0INi{G}}YN~pR{x%|>5ml-9Ge##6@h~9E#^~+Uu)OX+s)LNv) zTzAb9!)W-C-s}zm(3Vi07>m`@XkLLKQmLm9%a0Je3&5>Kji^dhg^{crdO6u3u7e^s zDw|(*G~^^|H85j#;BoXao7me}Ekp-M==`2f>tDA2rS zG?$po6Q`6_l$OjP|AHe(6=86w0RqAf_&Oq7NGUJB83#Rc`ptO#21q!jTzT^t(E0LP z_v_grrxC6nR2ykWs zPa5%8o91efam$fo^$2wMsVT-vjElq4Ju?wTU^0y@GEt{Na&DX_!6YlY85o@)CIv#X!xJ4D^H#YHf%y1 z)+lq&=FF`_%t)v-g8$SSWFeloIRX1$PzB8R)P4Q2x<3_ip?KwNDzhu`=O!4`j#3t# zv&E0bE;UT42d{v&IRLD}nrdr)em<-+e>k_uNQB>wwYDhM^O?n8Ab$+bJqkqlVNBZ% zz!6;A0NxQ;@KiLeT4p5&GUU9A%3@Bc#iYD>{y{qlz1AldOr{ZoxJF`5*x$5p=p8A+A+ zi6VsC6Lkw}>a8lV>oI;c0vv~6oO+K*aSyT}#5FJ{j-|1=Lz(tP(gY$Ljb<>rT#CXb z?!^K@U^q6xsI6*fg8kY1kyn?ojcphr-n;oLzEGa}VhjxAD_^AOTOqxwHR;PM`hFxa zG?VI8jY=jZk%n?6aLkL3fgo+ct4#PVMgKI!Us0BRHyn(V^o{ZI9)Ff8+ize=$|v6@ z_E`je3&`LyShPB^&ySB@L`X%W?YP9 zsU)KZv4-amJdfZ71TP|}Rc2foWuRH%I_3ULsqAs(*-J_3)VZV*E0ObF4k+Z+LFK1Q zz4~A^j?l&$t0+>r2|hg`Xin7HqGmXgTWVNz77f!lcaMNW72FB+uw7Jv<5e7o3jJGe z{$UnRdKR%GJ`Vvt?(qc)P3sk}sMfNj20!n+_wf(&?Ec8bkwK1>?N?02c^YhWZ21(;9~lN313}p#4KV6@>M%GBca682bo4;p5l4xEC}wW6RTl+mBFRn^5s>(`rKWQkE^p0c!tf;>mUq=+V^Jvz0ca5qE#CuSg$GDLT z16YdwB1C>IHwCa_ReIV!4`8=C$I6Sc$;g&QoP{jFeNBPBA}<+PiBnt3;X$m2iTI+K z$fP&P@lc5rJ9p2B1zI&fLcQv?g#j|5HbT=_J{H8X^glz|75PCB+sC%G*@IcMPY+T( z4lJX3qM?ksK`6qI6X6Itto!8Ofgk373PB_i|1E|{y9Yst*y#l7w)QVr4}xfa5QOP+(+Qp|KZ{|(ZPOxH9|nwG z6UqKy>f(YJO|+2~BmCmAl>`Lm<*sO!Vf0|2d^eh9*}F0x3?D@N&#aBZT4Dfr?SN|h zCDtxRf{1YPS7@W^D@?E;AeIpuE8%mp(>0 z206FuX`1?P8AOWwuec<1=$8l5gFI=Morx@4uZN`hZ9YjX1mz(jnVt7h=gk}BuW4*^ z(n(+#>1>S0pjiz?JQkR=qDJFdJc{K_C>@5vqlh0OdBf^~hvz0<+}z zS(UU#ln~MnS10N-Fl*)%do?i@U=nP8pSUUyCA-&lwKRT2X&g72X zELQn6TVH(^n>z=<;kUv9o3-_ezz3iyHRPT;BtOLv<))sZA_1-ib@e`jDkFpf^{m#> zr)69pHZzP=%6Y7Xs`^B(=>y6ZBXScxCSVfz9F!6g0a8gcAHRkq_QdieO)dEO1_Es! zUWBQnueHS;FqEktfEo(UaVg<*Do-8AStT77cC01oMCrg3W1A))s+B>Q%u}#YI+@u!?+= z2u+g0P~@m@NyE^ILy`s>;AiDON?3aY_A6K=9y&ZgxKFO#|;?LiY4@rcHa zCSas3h^ypq(uCoEq^n4B?A~*oSc)=5U0sVIl>!WDvP%n7(r2V$b1;?W(>WL-0&z1R z)ww_qgDNiJs)6b53^4*(+Y*IkknyT8jKmO^zl~?p!0wqcfsIl<)59_DdjK=hf^7GkXf<1{8d$fQ_BxX|~ zl&%{lcckCSi3=UpDF_ zI7Ar3Rqsrk#S$7D$^6C{0c;q}gS;0f zAP*Y`%eW_t=G1X;C{=#4h$R)C$COFf{!j$|h=jpIkZFXMVNx(8i7B$zVwP*iNlyer zQ8;3$U)<0nY+^b@fxk}^P%N37^o;7SS8!7LoVKM5)Zi+k_#!?Jo0g(1%+j}*vzYlc1ZykGvg=q^; zT;4Y)Dq9yP%CG9#^|X|5_?E-j0d_O8=%GfM?`W2D#N#;y*gR>#{n3gD*Qq%2Yt>*O`C|ivgK2Gtm$6osPMEuW5@I3N zy$Arzglao-yVmqKmxurd zxJra;vbJI)2sX>fHug9yQ4ZhC;@hGdk?s4XX$7+{qDE&TN;JnUMuvWcU>3%eBcR>S z*D!7#e$w%|5Af4{Tn+{qsuA3US!An#{TDh^CmON+<5;Fi7OrIRIfNz{4lXoZ3#OS)e z*|28antNkT`lZTmRSKb}7x=PN}$k5+~)3JaN+s#c(bhb*A2si7=N zIr@`<&x@1qHS;8;a;RQ@Ka3lDP6>23=6=MWUSH13ean5y^=4zfZ@u5V9?JdqC)k^_ z1MxyZT4O^R+$@Bvl4-;}`c8wjTpFHN1Y;;r-C7}ji<^)uG!#DojGALr;Ze(5tFbJW z%ofGS@X-je0JwZxv;}BXd)$s)u1<{2J%zPOG}ScXS?K~bS*;Qy$d91zm3ST$?$Wum z`TG{&q>5+;h6vB@R3Dlec5RL)b5E6wu6y}n0}@P?qoBZo(RHh|QB{ln5S!-wQw2M^ zTLQ!;sOU=+ZZu2au;=tZoiS;K3n^AM)c^2=yAyJsI@(!M|;Gi zwWX&bV>%-I_Oc5tMZAtoEFdM}6K1_*=k{>y8z)_niBF(7ZE|iT6%wu^n8wZ)Hd1p!T3a zp9e+VQFcq&y#w2`#(fYp{`1g`lc8A~eJ>bvVUefevQEWjoC=GQj-5OKwEE>W>_cWy z=Ofv^mi02wcv*ALIySwIh7_uGFI7(NZ|V}#=eHnZyXzbacAa}lg5^5t4^6L=Ceg;K zd}$qvGu#HX8G_`;>)6xkNNqcC1MACO7=CdRc-_{?g!ODxxi+~F9)uPh7b5-emoh_s zG!3`S(4gl=!NK(`(QpSuCI!j%_3Sg8`EF)5W~@MM8j0Y5EZE3~mp+R0(VWr;LXN1p zQ>IL=m{l@*!jvhc6~$90(#~`b%tZJ_VbN#=F$iwOgl_=g8dA1xWGTrs2ZWQmWQBFX z;)aH$s_X4;z-DOMrH!noPY@Z6NE>Vi$gIsQo$Zq2HnXA#a^mCq4^%1j+XcC4Gs{pn zE>_CJn_05G3s0yBb}yXOf&sR)x)hcVIt>TjuW(|)Juglrf?%4 z2l}5qc#zK#Y00Xtb{W#Jb*yIH3AIlOf-b<7ER3eWwrg)=!RU{L13*VOonHfr$j5j= zj@=4oHZ5|AluL5eR+gE*3(=5>=2cLMxEDgeRFw&sv_rnIm1V15TD1Bc!4vTqIPM$4 zjRdRMjzx(Nc#(=my~jZoBv|diZJ4hM4{4_SD`vTD8RU~AFVIOd?$8kHcVK;l=AUgE zx#dolpx+HSRyyl+y1GrbzI#Pdk|8ad&;84JUPx;w=G}2b%=F>PRohY^X7AUm*3!wNm}uB|~bly_k^H>1Qu_({^D+14cGw0ZF# znL-XEG*F9>u_Qu-gS&Cp1v-~$w+Guou&wR5WW9kCxJBOca zEczVlZb5JZP(tomM>KBQy8Mvt{W2AUkL16$vr=}yEZ*VT1S7JEChFc~tgK7t`y*yw zVuFH0`Y(MF7R*B4K~fj1@KZBq2I1#%01n^!hI*^RAivtdLZj_NNgHw= ze&;`n5EgAg3=zE9Rd9nQ01cCdu;gr5FMK=E7o^F!=& zy7tia+*ZDd$%aQ*5<4I_Kf*F#JN@7zERQ`YKYoM_wNFH3y7Y#oytZJo7R*B8<(`AU ze4#mr#@ud9C$1r7IPasELDKbw8>izz#3g+ z5!zVd8BBi`fa@R$5k#Zof7unD7ifu6O%dvKx*pUaU3$cM^xzFZV==}LXW{=v9?uD zuvgd!>Z}*A3uuC)lOBVxTN*G9m#Z!^aKOPK)kctEJn#&Yn^+~ApJcr}i0V!ZHRVJC z=?i$OW1T1qREG_bs!8R0*!U_$qq_kg=pq$IR+?}cH?FbeItF7C?NH0Za@14ogm#gk ztlH4r=US#>~h#d4H zE3j+YlaxG(jtPI@iGGT|jS4|5jM&bJMJi4F0Q1szNuL3kjIU~;{P1zY6p zFSG10s)Ur<=NP+6zVYejwqZeo#9O>Qe9H1R|o?8IBM<1xy`Qx$zYi)9Xt_i0CuA=_4vhn~RSz89_j(JoXB^=+uNK zUu9RAhl;o)J6~r#*~jwt*I~*zEfe2h@v06aWHcT42~?FEt~$1e^#7utEyx6|PrK{( zO331J+EGquHrCTP^w1pgtv8_W{sD;O%YVGV-dAVL)5qAS3?|Fx-eM`nGcKydBl7dN zU>D(t3_Z>USV|F-zm!KJRl}|e+JvI#1WKYnocD!lk>?;+pS;832}lM!>K;)AIb0VbTub*{r(phuP4__RDJz;;?zUaT1TP^D5I!?q>enz;`P3O+5vg>X|`L%Eb$DR;G^1b zK9D3X%lc1QS~_u+90zByYIIeCm(m|SUo^R&l?Oj%qrt+s^eM~)n5>$gsIb+}DZ`L= z=Lock9e%LaM9MsdjC~4M8TM;5qbo zKhY+FF`^P8TtcH(jiN+(LouB)g<*&YK*~{G@K1!H7ep8^5kW*)+x#!smp)b_#snc~ z!3>0bEcJt`S}~%`5p03W-K%V{jHf+xG?zfF(#{ql0w!5ag)0l(*mJo7Y=m5Mp7Q&@ zV&n^7v#iwaxIkJ;WyL+;z+in`rhmgCry&*|4iX}>5*l-HfRq=pf$)ez8ln+U&OdWt zyIlVb%h1O`mVs@LK|}fgB)$yp|Lv9^e9uNsJcx8NN7%B(uwH}c(cg>-TDNOPBCQGmC#R8!JaOpFXm!GE zj>Qc%@a6(NM5o#)nhEAS>PcvPhlTuLvtu$CGd7_jg!p%{CR3puk&b{^1->F3Ns1Gw z)BIk<1;L`W?HAcq`oRfU042XnBw~<+2E}0psC)7fp2xO8%yDT=N`&m2a%udDt@bO2 zoMmFGy!|Hzce>=>pV$!gx;*z2tM8djm2SlbdqdEMRiHw_hnYG^H$ry`ckg2N%jnE>o2>G zrYx=N+#?poX^nstH;FMZg^nFk7v(^Wjz}0gRZRjctj5|^6>9S$7gKy}O?B$f#;YO2 zp!MBIGxno6h0N@S81+Y>@e{eFE)uT75We3G{YV!>)a`Lnvj}#yz4#03Fc^1ZeT3N~ z@_xp*aUWkfkaJT!br`Bp^(3^zP&~e*Y|I?-qP(B;BatO47fB@&!oat0cloJ0seiHRlZ_@(GcF4>{rVD6+Q_LSAkl;__&$kZ30r z)9sAl$aJo|ob$pxzC1=h4hnwQmgviWcTNla4Ezn>!8jwT9ZMt)#fe}j#tlP2cGL(A zQNL-u0Y@JC8Tn58cZlg2$dH@taqp5CNb1v@*#g{I0Oq2Ac=ws(WL zA6`j-eZ#izL-+%1BsEQS6GLbyqOx&aKj}gx`j*uz)BcEQhI}WC_h2*R4`Dnj4|#)o z0Pv5t{PDVYqOuU*+s=pcN*{X{T&Tl_V>8r4G-$|c;Pw@W4^aAo z>fqHBbQ9p4FyagBHd2Oqb?}lSA`9gJxJKm zfcXjuBtanEqm3F`x&y+VF4FgL)X;GH3NfpMR4~Rh9!)|5sS`=*kOZ-VvOb0n%{F5i z`6a0A*I2XKlf)?44zz+f$>k|1A zec=R**9JCct+UtV1Zs&Q4ix~_gi&PwO$ z_K8@%3BU{(VAR(hb)o3b!3=W&G>p;YmxK1-G$t)=r(kn=tO9RtI6%xaA5<$X| z=)wun`;8MRGkAu6KGgk6+lv`IT%XWQkBHgW_5t~IHc!nV*Vjm-!PyHiQ8X-vjd|~0 zI3o*lc=jM#kXn$~?sP0+rg4wBK<>79dO)(N>VlhwH(&QkTzr8LN=kY6LCvp?qF3!mXY_hDiB@sI$0J z1=$2y5+6cC4kO-scDXmnZmg9f3;5(J;=_%|2hH*!Q%F!{Jwyv=PyCT2_YJY-FlwtFb{(4wh@K=Nl+@}j|l3^%efc%wf*^!RLnR- zSOMZI7MAJ?cmIvbW#vFVQWo!qOku;p zfdo{*5t*&8!4a9l)&M=6e0F_4_7g?LKCjcI=*)Vvp)}BBtoKRNmHT91rjEy06mVH7 zYKc-#4T(`8f3E^ot~2|Y{mtAQFxao&x7^nph)%T}_#gk!H)i%P=d~e9`;ZhY;tL<4 z%BbH{Ld-^UPysgw4`yk)d1+*iV(z9?UFm^oaA-^^otr^ISG z^_h_aaB_t|s31=bL32?9V1(me7FPS<2=xGv(tEN|h7aO-elg{&5Y7=PfBQB@dBYKP zl{t3E3cxfNP9pVF3j$Ac=D2bLp5nznsK_%V9#fQ0dc^ucqxE_xy1^0idaK!?xL&@y zkH;jX0(0v1#rm>`)PEPUg*xZEhzauGARc=aP7=;Lh@@}<3wV;o5o*KhMGKm0msVKo z;9zLkjD3FaBwN!0yqF4i=MHa#8=m{+>l=B#9ks9{7#0|m6BKTJqa#=iTdJ$!I;xFi zzBy~MDrRYz!%O9*Ew8bpQHk{CGJtR$)<7vQ}<{ZqM1zJDN z)+IBd4~L`Ng=G66o*YJl7f(E?rgg3SZ4e)4Pf+dpDL{tme?gvj822<57vUJAv50JP z(FdNQ<*+OkO_;g@fb&&mI4!XnqpnAwO;uw7Y4vQ0TL}vbS(P-l-J{nv8<8nX!xkHW zD___Z@IaugskTvFp>=C9Xam~0+j%3DZo~n75omYqcGB6s_u=eb=U|@9ZjnQZd9pny z@{UQHCT%XmV@0u@p_v__nGdcw8Jd4+U}wLv@ZYi6&WUq7CeH1gSPB1K;4$G@$Sd*nhs9u8T|mLC1i~Gf_#M zQCTOVvL2k(UO4;xIrH1EzUJhdiuT!+?fI6@s0AHS3x2)egE@b(rGfyxJYaO__<_3D z^t}MSF)(mKzRxk1IU(2QSgszz=z(?r1wsgi`VWT;I^Es6c~1Qye+%J*{vLc{~>mNq%3?oq9Fc90Hm(6!fa0++Ys-lU@xe z_pObQ`y#;KkB#7Gtcxb7choQ*9UN|s0391iIyOXk$&nr!r76uRqvwUlu+i{n2Aq)8 zN0%F>50D)*fx!5ic?jxSD#7Aq(FZ)rU`{CK(}yU2+=Y|oTT z&y;lCJTzeF&rfH`2QBXB!HM9wd4pZa<$k2eM}Q^|uJ9wq%8uHNPSi?tSPI2bxqKP%N zT?jM@(%U_|&-y2&T{Ds=+R+*T@7}eD8|v<6-|1GE1yI`x)XK2>Kt1UQSq2Z9!g*8| z`FJ8$tifD%%$22D(KjLzWJi*nMH-##EaDcaYR%5Vb%|Zj#L#6YD+Eu)}DYGP+`V$s{>t6xT1*rUvz(y2=j1!h4bs zH(=Xnw5yKQKc6LW1Uoqd6A8$S*a!vo;^c6G%a&36TK2w7DCVi^j4;^#-_Hb=|Ccj? zYa+1!PiBIdAH>b_nhB=2n=3laRUPK4_UgL!hBYV6c2NChm=ACU7)>+47_S)sCV;W; zhn0LDnb@Ax=SWJYysenOKkw(u18PO8@%Rxm1@0r{n|h6wLdWH~NxaYh zVwp#75n9uG)8`1L_YT2jQ$hQbeux0t{^U^;%aD5bUt8j#K^Up}1)YbIwnBQ3$hHS3 z^ITZM(ybbKzKlO_CxwW@0zWcAou|64Vfn6g4LSKpX_7Awy$Ya&_ag!dwvDEZbMM3v zW?5E){Kmzv`b^hdkfj8fjxX86*$nn8%CM7cNo6U=w%gJbn$F(|cbzsTe^LbB} zBahGLi6x|Pw_zUlGpb@ID0xQ!-IKDxFY|!Ymbi^w2R~5L04GymR#CSuaq~`8$lh0j zrA!k7xhBY~LM|HGStG`fzV5Q!N;muIWX~#O743DBSL-JHq&bP+Zo}ps@z}+#*6B&O zifVX$zSgozb&0Dl!fI5|!33fnJwfp9JpRa#1uNcz0OuFaol8eF>;W~wc8cc}XYdh7 zq>wNprx$H)?t-{3Cjh{3h7B1vdd}z8_dv$7$hL}W_^JQ{wg_qiA8CCuA(h(v1abB@VJq3FwPt84g2=vBgVf(=Xgr#|o;$&>~@G@%RU zhzU;4?QY-&c4TF9oJW5aG{75Djd`#wXoa7w*Z@d0$8`Ur2M(4YHeq_mI9Ow+!7oju zErcias$FNrZ~8N9#XcmIWL;(HWX$swg6?=q?8FI1+ym{7MeT8z-_Q<6p6 z*zV%WrR{^=nAz?BR;L{IRPHhQwYlL3z`o1ekgR&mE9A2)c&y=Rz_lPsKDU~Wm5D3) zj~*<6YxFy=DpDYx4=DwkwxF1pfn5lEg9)dm<3L1~}^!=Y<<7Pq52eq3{KF}a3LYa%;D|+ikmkXJUs8#Xg{?fE1u2I=MyITQP&Evq$+_s6omVJ}YI%|zS|dSJ5<`S*yNZ2v+YZZ4NbO{aA*{X z2L|X5yaeD{sSlnP?TRr+yHHQlk#iupICwmp{1Pm0g6kpd;U36tE`U2ja9`Bwbftou z02a%QHS)O)JSCA<|Ei9&Hxf`*V_FONi8*&VjGNRd%v^h@Ny>a+%6(Hhllpcf^*xoHzjyVn)u(#(>+Ch8qt}qmUL!ktjXaf8fd6G> zpUUfhD!=ck3DY_!%(sF;$@%Z7NC#AP%4e3l8b~0($ zuNU+X^NTGWnErb2;^C9B_}f{5llA(y^No{r`ge4C2-6Q=w0(OUkL3{`fZ(b3I((eR z^<>l*K4>IOC^tYY>csyRhDf+*ms2}P@jeP6Ex9Toi_3ocQQo?RC*^YNK|hF3Q~zjh z4}Mt{9^$Bi-_XVTSjV9gv!iAE7T)2$k99kqE;lJWL%oU>EpJeG3>+-KPvN=t35W?T zbh^6dm$Ta?vJ* zEWV51=kzjMzKgGA>b*cb_fjTaMP?k3>+gZ}V!J$i4_~l^hAZ*$eW**lpg`;*W)fLmwjOL_n3QyN_;Oy$6|GH?Ih7QYQaPrhDE~qIRf7B1PZz=9QE2&YM>s0UAxn_~+$1 z2@fQsZG-z{+RdwRELydHkpiNN7G%fe=pDSc=UXV##2HTmS0nU1mO3u??SPF!t$&D6 zk{?=KkYu z+nC4rdA!u2jNA~{HtKP3iK;&lNQ7Mi9G&L{K9(;YU>>`h8#c=|13d(R1}?O$(h2;5wJZ93!w5yGs_DGWFjZu>|6>BQ}gR^9kb;= z9y=hWJSaqGj_v2;suI>q#jZ@|IM<2*&WmV8M^jOs`0@yOw3xvHDZ!jbhgpI=i^^e7 z^SH6Fw_Z64)-1L3;^gqdBqrH3m4f;1MOf zX4RbXpH~~rA+X@k)*Jcab8O`()UBG(i>9Hz$n3gQAn`|`Rn-9vgx~P!{k?DpTjq$MWg;-b0voHitdPI7@o2it1w9dcgcs%DLIQr_ zNj=&Fmkhwu1FkUmom*UBswHVVQaSTQlKR^&QMf#V-}iAo)}bC75QURT#Zi6_a@P%ZdT&vVs;AgWL;>Gh_QlE>7x~#&k3yS{j14`Qa zk2_l59#t+kJG_VeX&+_`F)#A(?u^8Io+G_tsT$+ey3 zqN7|KHFl1gc4E{t;QcB#7I!2^u@d~JgpbF6Ch&>)&v|8{>pgyp4Y`Equrta*+L zHlgjzbG*#QK#u~p$ikPv+jEyRy~O9o(#cUe`xyv>3wCynpnN_gUwDasFzYZhr~djS z{b(Pat|jZ5cGE}WB(fhxsU3^Zy5$4im}HLI4^F0eUv9oU5c_4T93b$ zUJLsd;I+q}!g(69dG0@~Rn2ickK#yj{zm;ISl{4tS^2na;=*iO+l;^Q25<_tz4|gg z>zhhV5p{SJR1k^bP>Vo1#Zvj|8+>Hh*U(zCPgy~+^G#>?gaf2FTpa$cpXgCvkf%~# zK!Q5ieTg9~trY}oaoRB+Ic_V&=El;`)r%{bqYIBD#5qFU&)2|}D>R9+aM<66*{PEr zk^7IqzNra0y9W_Q_m}ztIbHVAFhLUG%?aYM12drsDdLe*q(j7Eh#tX@Lp~6H10Vs- zOZ;;(7%_FgaXffoynd03!E*AOJe(bGt9X+qa*v;u4-pDheHVhe5!{2N{VH$|tD-`E zbQH-{e^ef+LKOi&jSAWVe?}2|Q{~P5nClq?&jKLF{V27^^_U02oiUJ52wt2k$9Z4B zZJ1ZDZ|nOuf5S)pX*_)S3b!Psp`8y8B8BQST;!B?J}>r3EJ8*TsX!QBc*JC{{98M( z^?KuePzNuVNmkxQ$rTw^kp#xk( z2|$h`z&vI`$=LEqC1ce`JZY1V4vc8J+`TAr9cLyBi_UI;f;GFTafE=Y7=De)^(c;d z*6}IKL(k&Udcp{w*wq0D;15iz$`d@*&;!unv2E*5@WamEOm;pZR{SB4kLD;Y^kykF z{t?#skZkx6*0=aO0Kym3V?fwk3yAKR)@8+~Es5L|s-~{zQk~xA^d?h%E{BNm*Xa^; zLO5S zC=P7!xcpU;+HdskD(#t4Z_JUVGkidvn_(c4S|YxU3X7yJk~lyxTW6nfmND=~)QUwXi!30er$&5gG6qT6> zNpv*G9~S`W&-*Q!sHmwx5elA*cis+;$j* z2Y+f=h94Wz0+&{~NR7Wu$=o{+i#!&L~X5m*r{ z1mMsUb@Jo$JTF)*!x)2#n2i0L*Kl#Oy#8~zAhZDsY{X*p}MV_whDUPME1DfeQfMvZ0wH7w0HiEZ+{vVkd&<5gbME8URPoWMmIc^JXwR z@{eJ{y9hcEe2+knwWJ`(Lr{cZ83LOu{Q}OqjFwGb@J)6bivLOk*CE(|U?+lB1WzNt zUu2_SF%$SpTjB%){IM%>8Ug+`l)#@hQq>{;)(`zI4gKtj(4!v2pVkn`2#OF4L@*4& zWFMT^7sz#A@}#gTjHy9jMX*vH_>w0ktio_J0(>ZzUS3sSJl!Ne{*tHKZ^78x5Zr;_ zZUj3K>_&jEs;O^;;R9Us)|L8z6uz+}-a&xx^9XznN8oEQ>bj|&Zn^d= zJ|rrLfo1Lj7cum>j-o;syizLAL*s7sg!(((&kvU}2Xr*vEDSe;OqD_k3b#HtXw* zh0ui=ZF9fp>v``YczFV&!!B|RUZ`di9>=tJ3SXGcW#T0`=bd(me-bg8vp{EM7`#vh ZH&}k)Pxx6FE4qA*k9_k7-j`lv`afdq@y7rF delta 35873 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 XkG?;azFr&n!!k|X7#lr^4_E$wb)mx< diff --git a/recruitment/email_service.py b/recruitment/email_service.py index 8ddedda..92d630a 100644 --- a/recruitment/email_service.py +++ b/recruitment/email_service.py @@ -178,10 +178,10 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi pass else: recipients.append(candidate.email) - + if recipient_list: recipients.extend(recipient_list) - + if not recipients: return {'success': False, 'error': 'No recipient email addresses provided'} @@ -242,51 +242,51 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= Send bulk email to multiple recipients with HTML support and attachments, supporting synchronous or asynchronous dispatch. """ - + # --- 1. Categorization and Custom Message Preparation (CORRECTED) --- if not from_interview: - + agency_emails = [] pure_candidate_emails = [] candidate_through_agency_emails = [] - + if not recipient_list: return {'success': False, 'error': 'No recipients provided'} # This must contain (final_recipient_email, customized_message) for ALL sends - customized_sends = [] - + customized_sends = [] + # 1a. Classify Recipients and Prepare Custom Messages - for email in recipient_list: + for email in recipient_list: email = email.strip().lower() - + try: candidate = get_object_or_404(Application, person__email=email) except Exception: logger.warning(f"Candidate not found for email: {email}") continue - + candidate_name = candidate.person.full_name - - + + # --- Candidate belongs to an agency (Final Recipient: Agency) --- if candidate.hiring_agency and candidate.hiring_agency.email: agency_email = candidate.hiring_agency.email agency_message = f"Hi, {candidate_name}" + "\n" + message - + # Add Agency email as the recipient with the custom message - customized_sends.append((agency_email, agency_message)) + customized_sends.append((agency_email, agency_message)) agency_emails.append(agency_email) candidate_through_agency_emails.append(candidate.email) # For sync block only - + # --- Pure Candidate (Final Recipient: Candidate) --- else: candidate_message = f"Hi, {candidate_name}" + "\n" + message - + # Add Candidate email as the recipient with the custom message - customized_sends.append((email, candidate_message)) + customized_sends.append((email, candidate_message)) pure_candidate_emails.append(email) # For sync block only - + # Calculate total recipients based on the size of the final send list total_recipients = len(customized_sends) @@ -295,21 +295,22 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= else: # For interview flow total_recipients = len(recipient_list) - - + + # --- 2. Handle ASYNC Dispatch (FIXED: Single loop used) --- if async_task_: try: - + processed_attachments = attachments if attachments else [] task_ids = [] + job_id=job.id sender_user_id=request.user.id if request and hasattr(request, 'user') and request.user.is_authenticated else None if not from_interview: # Loop through ALL final customized sends for recipient_email, custom_message in customized_sends: task_id = async_task( - 'recruitment.tasks.send_bulk_email_task', + 'recruitment.tasks.send_bulk_email_task', subject, custom_message, # Pass the custom message [recipient_email], # Pass the specific recipient as a list of one @@ -317,10 +318,10 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= sender_user_id, job_id, hook='recruitment.tasks.email_success_hook', - + ) task_ids.append(task_id) - + logger.info(f"{len(task_ids)} tasks ({total_recipients} emails) queued.") return { @@ -329,19 +330,19 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= 'task_ids': task_ids, 'message': f'Emails queued for background sending to {len(task_ids)} recipient(s).' } - + else: # from_interview is True (generic send to all participants) task_id = async_task( 'recruitment.tasks.send_bulk_email_task', subject, message, recipient_list, # Send the original message to the entire list - processed_attachments, - hook='recruitment.tasks.email_success_hook' + processed_attachments, + hook='recruitment.tasks.email_success_hook' ) task_ids.append(task_id) logger.info(f"Interview emails queued. ID: {task_id}") - + return { 'success': True, 'async': True, @@ -352,103 +353,91 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= except ImportError: logger.error("Async execution requested, but django_q or required modules not found. Defaulting to sync.") - async_task_ = False + async_task_ = False except Exception as e: logger.error(f"Failed to queue async tasks: {str(e)}", exc_info=True) return {'success': False, 'error': f"Failed to queue async tasks: {str(e)}"} - + else: # --- 3. Handle SYNCHRONOUS Send (No changes needed here, as it was fixed previously) --- + try: + # NOTE: The synchronous block below should also use the 'customized_sends' + # list for consistency instead of rebuilding messages from 'pure_candidate_emails' + # and 'agency_emails', but keeping your current logic structure to minimize changes. + + from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa') + is_html = '<' in message and '>' in message + successful_sends = 0 try: - # NOTE: The synchronous block below should also use the 'customized_sends' - # list for consistency instead of rebuilding messages from 'pure_candidate_emails' + # NOTE: The synchronous block below should also use the 'customized_sends' + # list for consistency instead of rebuilding messages from 'pure_candidate_emails' # and 'agency_emails', but keeping your current logic structure to minimize changes. - + from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa') is_html = '<' in message and '>' in message successful_sends = 0 - # Helper Function for Sync Send (as provided) - def send_individual_email(recipient, body_message): - # ... (Existing helper function logic) ... - nonlocal successful_sends - - if is_html: - plain_message = strip_tags(body_message) - email_obj = EmailMultiAlternatives(subject=subject, body=plain_message, from_email=from_email, to=[recipient]) - email_obj.attach_alternative(body_message, "text/html") - else: - email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient]) - - if attachments: - for attachment in attachments: - if hasattr(attachment, 'read'): - filename = getattr(attachment, 'name', 'attachment') - content = attachment.read() - content_type = getattr(attachment, 'content_type', 'application/octet-stream') - email_obj.attach(filename, content, content_type) - elif isinstance(attachment, tuple) and len(attachment) == 3: - filename, content, content_type = attachment - email_obj.attach(filename, content, content_type) - - try: - result=email_obj.send(fail_silently=False) - if result==1: - try: - user=get_object_or_404(User,email=recipient) - new_message = Message.objects.create( - sender=request.user, - recipient=user, - job=job, - subject=subject, - content=message, # Store the full HTML or plain content - message_type='DIRECT', - is_read=False, # It's just sent, not read yet - ) - logger.info(f"Stored sent message ID {new_message.id} in DB.") - except Exception as e: - logger.error(f"Email sent to {recipient}, but failed to store in DB: {str(e)}") + # Helper Function for Sync Send (as provided) + def send_individual_email(recipient, body_message): + # ... (Existing helper function logic) ... + nonlocal successful_sends - - else: - logger.error("fialed to send email") - - successful_sends += 1 - except Exception as e: - logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True) + if is_html: + plain_message = strip_tags(body_message) + email_obj = EmailMultiAlternatives(subject=subject, body=plain_message, from_email=from_email, to=[recipient]) + email_obj.attach_alternative(body_message, "text/html") + else: + email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient]) - if not from_interview: - # Send Emails - Pure Candidates - for email in pure_candidate_emails: - candidate_name = Application.objects.filter(person__email=email).first().person.full_name - candidate_message = f"Hi, {candidate_name}" + "\n" + message - send_individual_email(email, candidate_message) - - # Send Emails - Agencies - i = 0 - for email in agency_emails: - candidate_email = candidate_through_agency_emails[i] - candidate_name = Application.objects.filter(person__email=candidate_email).first().person.full_name - agency_message = f"Hi, {candidate_name}" + "\n" + message - send_individual_email(email, agency_message) - i += 1 - - logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.") - return { - 'success': True, - 'recipients_count': successful_sends, - 'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.' - } - else: - for email in recipient_list: - send_individual_email(email, message) - - logger.info(f"Interview email processing complete. Sent successfully to {successful_sends} out of {total_recipients} recipients.") - return { - 'success': True, - 'recipients_count': successful_sends, - 'message': f'Interview emails sent successfully to {successful_sends} recipient(s).' - } + if attachments: + for attachment in attachments: + if hasattr(attachment, 'read'): + filename = getattr(attachment, 'name', 'attachment') + content = attachment.read() + content_type = getattr(attachment, 'content_type', 'application/octet-stream') + email_obj.attach(filename, content, content_type) + elif isinstance(attachment, tuple) and len(attachment) == 3: + filename, content, content_type = attachment + email_obj.attach(filename, content, content_type) + + try: + email_obj.send(fail_silently=False) + successful_sends += 1 + except Exception as e: + logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True) + + if not from_interview: + # Send Emails - Pure Candidates + for email in pure_candidate_emails: + candidate_name = Application.objects.filter(email=email).first().first_name + candidate_message = f"Hi, {candidate_name}" + "\n" + message + send_individual_email(email, candidate_message) + + # Send Emails - Agencies + i = 0 + for email in agency_emails: + candidate_email = candidate_through_agency_emails[i] + candidate_name = Application.objects.filter(email=candidate_email).first().first_name + agency_message = f"Hi, {candidate_name}" + "\n" + message + send_individual_email(email, agency_message) + i += 1 + + logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.") + return { + 'success': True, + 'recipients_count': successful_sends, + 'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.' + } + else: + for email in recipient_list: + send_individual_email(email, message) + + logger.info(f"Interview email processing complete. Sent successfully to {successful_sends} out of {total_recipients} recipients.") + return { + 'success': True, + 'recipients_count': successful_sends, + 'message': f'Interview emails sent successfully to {successful_sends} recipient(s).' + } except Exception as e: error_msg = f"Failed to process bulk email send request: {str(e)}" diff --git a/recruitment/forms.py b/recruitment/forms.py index ea32ac2..a2a9291 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -2453,3 +2453,52 @@ class PasswordResetForm(forms.Form): raise forms.ValidationError(_('New passwords do not match.')) return cleaned_data + + +class StaffAssignmentForm(forms.ModelForm): + """Form for assigning staff to a job posting""" + + class Meta: + model = JobPosting + fields = ['assigned_to'] + widgets = { + 'assigned_to': forms.Select(attrs={ + 'class': 'form-select', + 'placeholder': _('Select staff member'), + 'required': True + }), + } + labels = { + 'assigned_to': _('Assign Staff Member'), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Filter users to only show staff members + self.fields['assigned_to'].queryset = User.objects.filter( + user_type='staff' + ).order_by('first_name', 'last_name') + + # Add empty choice for unassigning + self.fields['assigned_to'].required = False + self.fields['assigned_to'].empty_label = _('-- Unassign Staff --') + + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'g-3' + self.helper.form_id = 'staff-assignment-form' + + self.helper.layout = Layout( + Field('assigned_to', css_class='form-control'), + Div( + Submit('submit', _('Assign Staff'), css_class='btn btn-primary'), + css_class='col-12 mt-3' + ), + ) + + def clean_assigned_to(self): + """Validate the assigned staff member""" + assigned_to = self.cleaned_data.get('assigned_to') + if assigned_to and assigned_to.user_type != 'staff': + raise forms.ValidationError(_('Only staff members can be assigned to jobs.')) + return assigned_to diff --git a/recruitment/signals.py b/recruitment/signals.py index ab502eb..e567816 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -22,6 +22,8 @@ from .models import ( ) from .forms import generate_api_key, generate_api_secret from django.contrib.auth import get_user_model +from django_q.models import Schedule + logger = logging.getLogger(__name__) @@ -41,6 +43,7 @@ def format_job(sender, instance, created, **kwargs): instance.pk, # hook='myapp.tasks.email_sent_callback' # Optional callback ) + else: existing_schedule = Schedule.objects.filter( func="recruitment.tasks.form_close", diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 8ecf803..7a185d6 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -487,7 +487,7 @@ def create_interview_and_meeting( interview_date=slot_date, interview_time=slot_time ) - + # Log success or use Django-Q result system for monitoring logger.info(f"Successfully scheduled interview for {Application.name}") return True # Task succeeded @@ -606,6 +606,8 @@ def form_close(job_id): job.is_active = False job.template_form.is_active = False job.save() + #TODO:send email to admins + def sync_hired_candidates_task(job_slug): @@ -777,7 +779,7 @@ def _task_send_individual_email(subject, body_message, recipient, attachments,se try: result=email_obj.send(fail_silently=False) - + if result==1: try: user=get_object_or_404(User,email=recipient) @@ -794,11 +796,11 @@ def _task_send_individual_email(subject, body_message, recipient, attachments,se except Exception as e: logger.error(f"Email sent to {recipient}, but failed to store in DB: {str(e)}") - - else: + + else: logger.error("fialed to send email") - - + + except Exception as e: logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True) @@ -814,7 +816,7 @@ def send_bulk_email_task(subject, message, recipient_list,attachments=None,sende if not recipient_list: return {'success': False, 'error': 'No recipients provided to task.'} - + sender=get_object_or_404(User,pk=sender_user_id) job=get_object_or_404(JobPosting,pk=job_id) # Since the async caller sends one task per recipient, total_recipients should be 1. @@ -843,3 +845,5 @@ def email_success_hook(task): logger.info(f"Task ID {task.id} succeeded. Result: {task.result}") else: logger.error(f"Task ID {task.id} failed. Error: {task.result}") + + diff --git a/recruitment/urls.py b/recruitment/urls.py index c72eff2..5b60895 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -35,7 +35,8 @@ urlpatterns = [ ), path("jobs/linkedin/login/", views.linkedin_login, name="linkedin_login"), path("jobs/linkedin/callback/", views.linkedin_callback, name="linkedin_callback"), - + path("jobs//staff-assignment/", views.staff_assignment_view, name="staff_assignment_view"), + # Candidate URLs path( "candidates/", views_frontend.ApplicationListView.as_view(), name="candidate_list" @@ -109,7 +110,7 @@ urlpatterns = [ ), # Meeting URLs # path("meetings/", views.ZoomMeetingListView.as_view(), name="list_meetings"), - + # JobPosting functional views URLs (keeping for compatibility) path("api/create/", views.create_job, name="create_job_api"), path("api//edit/", views.edit_job, name="edit_job_api"), @@ -271,7 +272,7 @@ urlpatterns = [ views.interview_detail_view, name="interview_detail", ), - + # users urls path("user/", views.user_detail, name="user_detail"), path( @@ -576,7 +577,7 @@ urlpatterns = [ views.confirm_schedule_interviews_view, name="confirm_schedule_interviews_view", ), - + path( "meetings/create-meeting/", views.ZoomMeetingCreateView.as_view(), @@ -632,16 +633,16 @@ urlpatterns = [ path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"), - + # 1. Onsite Reschedule URL path( '/candidate//onsite/reschedule//', views.reschedule_onsite_meeting, name='reschedule_onsite_meeting' ), - + # 2. Onsite Delete URL - + path( 'job//candidates//delete-onsite-meeting//', views.delete_onsite_meeting_for_candidate, @@ -653,8 +654,8 @@ urlpatterns = [ views.schedule_onsite_meeting_for_candidate, name='schedule_onsite_meeting_for_candidate' # This is the name used in the button ), - - + + # Detail View (assuming slug is on ScheduledInterview) path("interviews/meetings//", views.meeting_details, name="meeting_details"), diff --git a/recruitment/views.py b/recruitment/views.py index 6a5a7b9..1a01d9d 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -30,7 +30,8 @@ from .forms import ( ProfileImageUploadForm, ParticipantsSelectForm, ApplicationForm, - PasswordResetForm + PasswordResetForm, + StaffAssignmentForm, ) from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods @@ -120,7 +121,7 @@ from .models import ( JobPosting, ScheduledInterview, JobPostingImage, - + HiringAgency, AgencyJobAssignment, AgencyAccessLink, @@ -250,7 +251,7 @@ class ZoomMeetingCreateView(StaffRequiredMixin, CreateView): messages.error(self.request, f"Error creating meeting: {e}") return redirect(reverse("create_meeting", kwargs={"slug": instance.slug})) - + class ZoomMeetingDetailsView(StaffRequiredMixin, DetailView): model = ZoomMeetingDetails @@ -496,12 +497,12 @@ def job_detail(request, slug): # --- 2. Quality Metrics (JSON Aggregation) --- - - + + candidates_with_score = applicants.filter(is_resume_parsed=True).annotate( annotated_match_score=Coalesce(Cast(SCORE_PATH, output_field=IntegerField()), 0) ) - + total_candidates = applicants.count() avg_match_score_result = candidates_with_score.aggregate( avg_score=Avg("annotated_match_score") @@ -600,7 +601,7 @@ ALLOWED_EXTENSIONS = (".pdf", ".docx") def job_cvs_download(request, slug): job = get_object_or_404(JobPosting, slug=slug) - entries = Candidate.objects.filter(job=job) + entries = Application.objects.filter(job=job) # 2. Create an in-memory byte stream (BytesIO) zip_buffer = io.BytesIO() @@ -642,7 +643,7 @@ def job_cvs_download(request, slug): # Set the header for the browser to download the file response["Content-Disposition"] = ( - 'attachment; filename=f"all_cvs_for_{job.title}.zip"' + f'attachment; filename="all_cvs_for_{job.title}.zip"' ) return response @@ -742,7 +743,7 @@ def kaauh_career(request): if selected_department and selected_department in department_type_keys: active_jobs = active_jobs.filter(department=selected_department) selected_workplace_type = request.GET.get("workplace_type", "") - + selected_job_type = request.GET.get("employment_type", "") job_type_keys = active_jobs.order_by("job_type").distinct("job_type").values_list("job_type", flat=True) @@ -1468,7 +1469,7 @@ def _handle_preview_submission(request, slug, job): preview_schedule.append( {"application": application, "date": slot["date"], "time": slot["time"]} ) - + # Save the form data to session for later use schedule_data = { "start_date": start_date.isoformat(), @@ -1482,7 +1483,7 @@ def _handle_preview_submission(request, slug, job): "break_end_time": break_end_time.isoformat() if break_end_time else None, "candidate_ids": [c.id for c in applications], "schedule_interview_type":schedule_interview_type - + } request.session[SESSION_DATA_KEY] = schedule_data @@ -1538,7 +1539,7 @@ def _handle_confirm_schedule(request, slug, job): break_start = schedule_data.get("break_start_time") break_end = schedule_data.get("break_end_time") - schedule = InterviewSchedule.objects.create( + schedule = InterviewSchedule.objects.create( job=job, created_by=request.user, start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), @@ -1557,7 +1558,7 @@ def _handle_confirm_schedule(request, slug, job): # Clear data on failure to prevent stale data causing repeated errors messages.error(request, f"Error creating schedule: {e}") if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] - if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY] + if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY] return redirect("schedule_interviews", slug=slug) # 3. Setup candidates and get slots @@ -1591,12 +1592,12 @@ def _handle_confirm_schedule(request, slug, job): elif schedule_data.get("schedule_interview_type") == 'Onsite': print("inside...") - + if request.method == 'POST': - form = OnsiteLocationForm(request.POST) + form = OnsiteLocationForm(request.POST) if form.is_valid(): - + if not available_slots: messages.error(request, "No available slots found for the selected schedule range.") return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job}) @@ -1606,27 +1607,27 @@ def _handle_confirm_schedule(request, slug, job): room_number = form.cleaned_data['room_number'] topic=form.cleaned_data['topic'] - + try: # 1. Iterate over candidates and create a NEW Location object for EACH for i, candidate in enumerate(candidates): if i < len(available_slots): slot = available_slots[i] - - + + location_start_dt = datetime.combine(slot['date'], schedule.start_time) # --- CORE FIX: Create a NEW Location object inside the loop --- onsite_location = OnsiteLocationDetails.objects.create( - start_time=location_start_dt, - duration=schedule.interview_duration, + start_time=location_start_dt, + duration=schedule.interview_duration, physical_address=physical_address, room_number=room_number, location_type="Onsite", topic=topic - + ) - + # 2. Create the ScheduledInterview, linking the unique location ScheduledInterview.objects.create( application=candidate, @@ -1634,7 +1635,7 @@ def _handle_confirm_schedule(request, slug, job): schedule=schedule, interview_date=slot['date'], interview_time=slot['time'], - interview_location=onsite_location, + interview_location=onsite_location, ) messages.success( @@ -1645,7 +1646,7 @@ def _handle_confirm_schedule(request, slug, job): # Clear session data keys upon successful completion if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY] if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] - + return redirect('job_detail', slug=job.slug) except Exception as e: @@ -1657,11 +1658,11 @@ def _handle_confirm_schedule(request, slug, job): # Form is invalid, re-render with errors # Ensure 'job' is passed to prevent NoReverseMatch return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job}) - + else: # For a GET request form = OnsiteLocationForm() - + return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job}) @@ -1915,7 +1916,7 @@ def candidate_interview_view(request, slug): "job": job, "candidates": job.interview_candidates, "current_stage": "Interview", - + } return render(request, "recruitment/candidate_interview_view.html", context) @@ -2025,32 +2026,32 @@ def delete_meeting_for_candidate(request, slug, candidate_pk, meeting_id): def delete_zoom_meeting_for_candidate(request, slug, candidate_pk, meeting_id): """ Deletes a specific Zoom (Remote) meeting instance. - The ZoomMeetingDetails object inherits from InterviewLocation, - which is linked to ScheduledInterview. Deleting the subclass + The ZoomMeetingDetails object inherits from InterviewLocation, + which is linked to ScheduledInterview. Deleting the subclass should trigger CASCADE/SET_NULL correctly on the FK chain. """ job = get_object_or_404(JobPosting, slug=slug) candidate = get_object_or_404(Application, pk=candidate_pk) - + # Target the specific Zoom meeting details instance meeting = get_object_or_404(ZoomMeetingDetails, pk=meeting_id) - + if request.method == "POST": # 1. Attempt to delete the meeting from the external Zoom API - result = delete_zoom_meeting(meeting.meeting_id) - + result = delete_zoom_meeting(meeting.meeting_id) + # 2. Check for success OR if the meeting was already deleted externally if ( result["status"] == "success" or "Meeting does not exist" in result["details"]["message"] ): - # 3. Delete the local Django object. This will delete the base + # 3. Delete the local Django object. This will delete the base # InterviewLocation object and update the ScheduledInterview FK. meeting.delete() messages.success(request, f"Remote meeting for {candidate.name} deleted successfully.") else: messages.error(request, result["message"]) - + return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug})) context = { @@ -2927,6 +2928,34 @@ def admin_settings(request): context = {"staffs": staffs, "form": form} return render(request, "user/admin_settings.html", context) +@staff_user_required +def staff_assignment_view(request, slug): + """ + View to assign staff to a job posting + """ + job = get_object_or_404(JobPosting, slug=slug) + staff_users = User.objects.filter(user_type="staff", is_superuser=False) + applications = job.applications.all() + + if request.method == "POST": + form = StaffAssignmentForm(request.POST) + if form.is_valid(): + assignment = form.save(commit=False) + messages.success(request, f"Staff assigned to job '{job.title}' successfully!") + return redirect("job_detail", slug=job.slug) + else: + messages.error(request, "Please correct the errors below.") + else: + form = StaffAssignmentForm() + + context = { + "job": job, + "applications": applications, + "staff_users": staff_users, + "form": form, + } + return render(request, "recruitment/staff_assignment_view.html", context) + from django.contrib.auth.forms import SetPasswordForm @@ -3004,6 +3033,8 @@ def zoom_webhook_view(request): @staff_user_required def add_meeting_comment(request, slug): """Add a comment to a meeting""" + # from .forms import MeetingCommentForm + meeting = get_object_or_404(ZoomMeetingDetails, slug=slug) if request.method == "POST": @@ -3219,7 +3250,7 @@ def agency_detail(request, slug): candidates = Application.objects.filter(hiring_agency=agency).order_by( "-created_at" ) - + # Statistics total_candidates = candidates.count() active_candidates = candidates.filter( @@ -4577,7 +4608,7 @@ def message_detail(request, message_id): @login_required def message_create(request): - """Create a new message""" + """Create a new message""" from .email_service import EmailService if request.method == "POST": form = MessageForm(request.user, request.POST) @@ -4586,24 +4617,51 @@ def message_create(request): message = form.save(commit=False) message.sender = request.user message.save() - messages.success(request, "Message sent successfully!") + # Send email if message_type is 'email' and recipient has email + if message.message_type == 'email' and message.recipient and message.recipient.email: + try: + from .email_service import send_bulk_email + + email_result = send_bulk_email( + subject=message.subject, + message=message.content, + recipient_list=[message.recipient.email], + request=request, + attachments=None, + async_task_=True, + from_interview=False + ) + + if email_result["success"]: + message.is_email_sent = True + message.email_address = message.recipient.email + message.save(update_fields=['is_email_sent', 'email_address']) + messages.success(request, "Message sent successfully via email!") + else: + messages.warning(request, f"Message saved but email failed: {email_result.get('message', 'Unknown error')}") + + except Exception as e: + messages.warning(request, f"Message saved but email sending failed: {str(e)}") + else: + messages.success(request, "Message sent successfully!") + ["recipient", "job", "subject", "content", "message_type"] recipient_email = form.cleaned_data['recipient'].email # Assuming recipient is a User or Model with an 'email' field subject = form.cleaned_data['subject'] custom_message = form.cleaned_data['content'] job_id = form.cleaned_data['job'].id if 'job' in form.cleaned_data and form.cleaned_data['job'] else None sender_user_id = request.user.id - + task_id = async_task( - 'recruitment.tasks.send_bulk_email_task', + 'recruitment.tasks.send_bulk_email_task', subject, custom_message, # Pass the custom message [recipient_email], # Pass the specific recipient as a list of one - + sender_user_id=sender_user_id, job_id=job_id, hook='recruitment.tasks.email_success_hook') - + logger.info(f"{task_id} queued.") return redirect("message_list") else: @@ -4644,7 +4702,34 @@ def message_reply(request, message_id): message.recipient = parent_message.sender message.save() - messages.success(request, "Reply sent successfully!") + # Send email if message_type is 'email' and recipient has email + if message.message_type == 'email' and message.recipient and message.recipient.email: + try: + from .email_service import send_bulk_email + + email_result = send_bulk_email( + subject=message.subject, + message=message.content, + recipient_list=[message.recipient.email], + request=request, + attachments=None, + async_task_=True, + from_interview=False + ) + + if email_result["success"]: + message.is_email_sent = True + message.email_address = message.recipient.email + message.save(update_fields=['is_email_sent', 'email_address']) + messages.success(request, "Reply sent successfully via email!") + else: + messages.warning(request, f"Reply saved but email failed: {email_result.get('message', 'Unknown error')}") + + except Exception as e: + messages.warning(request, f"Reply saved but email sending failed: {str(e)}") + else: + messages.success(request, "Reply sent successfully!") + return redirect("message_detail", message_id=parent_message.id) else: messages.error(request, "Please correct the errors below.") @@ -5102,7 +5187,7 @@ def compose_candidate_email(request, job_slug): from .email_service import send_bulk_email job = get_object_or_404(JobPosting, slug=job_slug) - + # # candidate = get_object_or_404(Application, slug=candidate_slug, job=job) # if request.method == "POST": # form = CandidateEmailForm(job, candidate, request.POST) @@ -5111,7 +5196,7 @@ def compose_candidate_email(request, job_slug): if request.method == 'POST': - + candidate_ids = request.POST.getlist('candidate_ids') candidates=Application.objects.filter(id__in=candidate_ids) form = CandidateEmailForm(job, candidates, request.POST) @@ -5119,7 +5204,7 @@ def compose_candidate_email(request, job_slug): print("form is valid ...") # Get email addresses email_addresses = form.get_email_addresses() - + if not email_addresses: messages.error(request, 'No email selected') @@ -5147,17 +5232,35 @@ def compose_candidate_email(request, job_slug): async_task_=True, # Changed to False to avoid pickle issues from_interview=False, job=job - ) - + if email_result["success"]: + for candidate in candidates: + if hasattr(candidate, 'person') and candidate.person: + try: + Message.objects.create( + sender=request.user, + recipient=candidate.person.user, + subject=subject, + content=message, + job=job, + message_type='email', + is_email_sent=True, + email_address=candidate.person.email if candidate.person.email else candidate.email + ) + + except Exception as e: + # Log error but don't fail the entire process + print(f"Error creating message") + messages.success( request, - f"Email sent successfully to {len(email_addresses)} recipient(s).", + f"Email will be sent shortly to recipient(s)", ) - - - return redirect("candidate_interview_view", slug=job.slug) + response = HttpResponse(status=200) + response.headers["HX-Refresh"] = "true" + return response + # return redirect("candidate_interview_view", slug=job.slug) else: messages.error( request, @@ -5181,12 +5284,10 @@ def compose_candidate_email(request, job_slug): {"form": form, "job": job, "candidate": candidates}, ) - + else: # Form validation errors - print('form is not valid') - print(form.errors) messages.error(request, "Please correct the errors below.") # For HTMX requests, return error response @@ -5472,7 +5573,7 @@ def create_interview_participants(request, slug): Uses interview_pk because ScheduledInterview has no slug. """ schedule_interview = get_object_or_404(ScheduledInterview, slug=slug) - + # Get the slug from the related InterviewLocation (the "meeting") meeting_slug = schedule_interview.interview_location.slug # ✅ Correct @@ -5561,9 +5662,29 @@ def send_interview_email(request, slug): ) if email_result["success"]: + # Create Message records for each participant after successful email send + messages_created = 0 + for participant in participants: + if hasattr(participant, 'user') and participant.user: + try: + Message.objects.create( + sender=request.user, + recipient=participant.user, + subject=subject, + content=msg_participants, + job=job, + message_type='email', + is_email_sent=True, + email_address=participant.email if hasattr(participant, 'email') else '' + ) + messages_created += 1 + except Exception as e: + # Log error but don't fail the entire process + print(f"Error creating message for {participant.email if hasattr(participant, 'email') else participant}: {e}") + messages.success( request, - f"Email sent successfully to {total_recipients} recipient(s).", + f"Email will be sent shortly to {total_recipients} recipient(s).", ) return redirect("list_meetings") @@ -5590,33 +5711,33 @@ class MeetingListView(ListView): """ A unified view to list both Remote and Onsite Scheduled Interviews. """ - model = ScheduledInterview - template_name = "meetings/list_meetings.html" + model = ScheduledInterview + template_name = "meetings/list_meetings.html" context_object_name = "meetings" paginate_by = 100 def get_queryset(self): # Start with a base queryset, ensuring an InterviewLocation link exists. queryset = super().get_queryset().filter(interview_location__isnull=False).select_related( - 'interview_location', - 'job', - 'application__person', - 'application', + 'interview_location', + 'job', + 'application__person', + 'application', ).prefetch_related( - 'interview_location__zoommeetingdetails', - 'interview_location__onsitelocationdetails', + 'interview_location__zoommeetingdetails', + 'interview_location__onsitelocationdetails', ) # Note: Printing the queryset here can consume memory for large sets. - + # Get filters from GET request search_query = self.request.GET.get("q") status_filter = self.request.GET.get("status") candidate_name_filter = self.request.GET.get("candidate_name") - type_filter = self.request.GET.get("type") + type_filter = self.request.GET.get("type") print(type_filter) # 2. Type Filter: Filter based on the base InterviewLocation's type - if type_filter: + if type_filter: # Use .title() to handle case variations from URL (e.g., 'remote' -> 'Remote') normalized_type = type_filter.title() print(normalized_type) @@ -5629,53 +5750,53 @@ class MeetingListView(ListView): if search_query: queryset = queryset.filter(interview_location__topic__icontains=search_query) - # 4. Status Filter + # 4. Status Filter if status_filter: queryset = queryset.filter(status=status_filter) - - # 5. Candidate Name Filter + + # 5. Candidate Name Filter if candidate_name_filter: queryset = queryset.filter( Q(application__person__first_name__icontains=candidate_name_filter) | Q(application__person__last_name__icontains=candidate_name_filter) ) - + return queryset.order_by("-interview_date", "-interview_time") - + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - + # Pass filters back to the template for retention context["search_query"] = self.request.GET.get("q", "") context["status_filter"] = self.request.GET.get("status", "") context["candidate_name_filter"] = self.request.GET.get("candidate_name", "") context["type_filter"] = self.request.GET.get("type", "") - - + + # CORRECTED: Pass the status choices from the model class for the filter dropdown context["status_choices"] = self.model.InterviewStatus.choices - + meetings_data = [] - + for interview in context.get(self.context_object_name, []): location = interview.interview_location - details = None + details = None if not location: - continue - + continue + # Determine and fetch the CONCRETE details object (prefetched) if location.location_type == location.LocationType.REMOTE: - details = getattr(location, 'zoommeetingdetails', None) + details = getattr(location, 'zoommeetingdetails', None) elif location.location_type == location.LocationType.ONSITE: details = getattr(location, 'onsitelocationdetails', None) - + # Combine date and time for template display/sorting start_datetime = None if interview.interview_date and interview.interview_time: start_datetime = datetime.combine(interview.interview_date, interview.interview_time) - + # SUCCESS: Build the data dictionary meetings_data.append({ 'interview': interview, @@ -5683,43 +5804,43 @@ class MeetingListView(ListView): 'details': details, 'type': location.location_type, 'topic': location.topic, - # 'slug': interview.slug, + 'slug': interview.slug, 'start_time': start_datetime, # Combined datetime object # Duration should ideally be on ScheduledInterview or fetched from details - 'duration': getattr(details, 'duration', 'N/A'), + 'duration': getattr(details, 'duration', 'N/A'), # Use details.join_url and fallback to None, if Remote 'join_url': getattr(details, 'join_url', None) if location.location_type == location.LocationType.REMOTE else None, 'meeting_id': getattr(details, 'meeting_id', None), # Use the primary status from the ScheduledInterview record 'status': interview.status, }) - + context["meetings_data"] = meetings_data - + return context - + def reschedule_onsite_meeting(request, slug, candidate_id, meeting_id): """Handles the rescheduling of an Onsite Interview (updates OnsiteLocationDetails).""" job = get_object_or_404(JobPosting, slug=slug) candidate = get_object_or_404(Application, pk=candidate_id) - + # Fetch the OnsiteLocationDetails instance, ensuring it belongs to this candidate. # We use the reverse relationship: onsitelocationdetails -> interviewlocation -> scheduledinterview -> application # The 'interviewlocation_ptr' is the foreign key field name if OnsiteLocationDetails is a proxy/multi-table inheritance model. onsite_meeting = get_object_or_404( - OnsiteLocationDetails, + OnsiteLocationDetails, pk=meeting_id, # Correct filter: Use the reverse link through the ScheduledInterview model. # This assumes your ScheduledInterview model links back to a generic InterviewLocation base. - interviewlocation_ptr__scheduled_interview__application=candidate + interviewlocation_ptr__scheduled_interview__application=candidate ) if request.method == 'POST': form = OnsiteReshuduleForm(request.POST, instance=onsite_meeting) - + if form.is_valid(): instance = form.save(commit=False) - + if instance.start_time < timezone.now(): messages.error(request, "Start time must be in the future for rescheduling.") return render(request, "meetings/reschedule_onsite.html", {"form": form, "job": job, "candidate": candidate, "meeting": onsite_meeting}) @@ -5734,10 +5855,10 @@ def reschedule_onsite_meeting(request, slug, candidate_id, meeting_id): scheduled_interview.save() except ScheduledInterview.DoesNotExist: messages.warning(request, "Parent schedule record not found. Status not updated.") - + instance.save() messages.success(request, "Onsite meeting successfully rescheduled! ✅") - + return redirect(reverse("candidate_interview_view", kwargs={'slug': job.slug})) else: @@ -5762,16 +5883,16 @@ def delete_onsite_meeting_for_candidate(request, slug, candidate_pk, meeting_id) """ job = get_object_or_404(JobPosting, slug=slug) candidate = get_object_or_404(Application, pk=candidate_pk) - + # Target the specific Onsite meeting details instance meeting = get_object_or_404(OnsiteLocationDetails, pk=meeting_id) - + if request.method == "POST": - # Delete the local Django object. + # Delete the local Django object. # This deletes the base InterviewLocation and updates the ScheduledInterview FK. meeting.delete() messages.success(request, f"Onsite meeting for {candidate.name} deleted successfully.") - + return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug})) context = { @@ -5798,17 +5919,17 @@ def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk): """ job = get_object_or_404(JobPosting, slug=slug) candidate = get_object_or_404(Application, pk=candidate_pk) - - action_url = reverse('schedule_onsite_meeting_for_candidate', + + action_url = reverse('schedule_onsite_meeting_for_candidate', kwargs={'slug': job.slug, 'candidate_pk': candidate.pk}) if request.method == 'POST': # Use the new form - form = OnsiteScheduleForm(request.POST) + form = OnsiteScheduleForm(request.POST) if form.is_valid(): - + cleaned_data = form.cleaned_data - + # 1. Create OnsiteLocationDetails onsite_loc = OnsiteLocationDetails( topic=cleaned_data['topic'], @@ -5816,8 +5937,8 @@ def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk): room_number=cleaned_data['room_number'], start_time=cleaned_data['start_time'], duration=cleaned_data['duration'], - status=OnsiteLocationDetails.Status.WAITING, - location_type=InterviewLocation.LocationType.ONSITE, + status=OnsiteLocationDetails.Status.WAITING, + location_type=InterviewLocation.LocationType.ONSITE, ) onsite_loc.save() @@ -5835,7 +5956,7 @@ def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk): interview_time=interview_time, status=ScheduledInterview.InterviewStatus.SCHEDULED, ) - + messages.success(request, "Onsite interview scheduled successfully. ✅") return redirect(reverse("candidate_interview_view", kwargs={'slug': job.slug})) @@ -5846,15 +5967,15 @@ def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk): 'job': job, # Pass the object itself for ModelChoiceField } # Use the new form - form = OnsiteScheduleForm(initial=initial_data) - + form = OnsiteScheduleForm(initial=initial_data) + context = { "form": form, "job": job, "candidate": candidate, "action_url": action_url, } - + return render(request, "meetings/schedule_onsite_meeting_form.html", context) @@ -5892,7 +6013,7 @@ def meeting_details(request, slug): # Forms for modals participant_form = InterviewParticpantsForm(instance=interview) - + # email_form = InterviewEmailForm( # candidate=candidate, diff --git a/templates/base.html b/templates/base.html index e5d3209..c4737e1 100644 --- a/templates/base.html +++ b/templates/base.html @@ -317,7 +317,7 @@ -
+
{% if messages %} {% for message in messages %}