From 36826575271d283770a64450e87891e3360a8743 Mon Sep 17 00:00:00 2001 From: ismail Date: Wed, 15 Oct 2025 13:13:59 +0300 Subject: [PATCH 1/3] more update in css and templates --- .../__pycache__/settings.cpython-313.pyc | Bin 7693 -> 7927 bytes .../__pycache__/models.cpython-313.pyc | Bin 45802 -> 45802 bytes recruitment/__pycache__/urls.cpython-313.pyc | Bin 9373 -> 9512 bytes recruitment/__pycache__/utils.cpython-313.pyc | Bin 20463 -> 20463 bytes recruitment/__pycache__/views.cpython-313.pyc | Bin 71654 -> 71941 bytes .../views_frontend.cpython-313.pyc | Bin 19074 -> 16895 bytes recruitment/urls.py | 11 ++- recruitment/views_frontend.py | 79 ++---------------- templates/includes/search_form.html | 8 +- templates/meetings/list_meetings.html | 2 +- templates/recruitment/candidate_detail.html | 51 ++++++----- .../partials/stage_update_modal.html | 59 ++----------- 12 files changed, 46 insertions(+), 164 deletions(-) diff --git a/NorahUniversity/__pycache__/settings.cpython-313.pyc b/NorahUniversity/__pycache__/settings.cpython-313.pyc index b4559a90f4c47624f9ad7c5a4613c0e6af2856a3..9395c449071310344438a33cd0f34f15a44e5438 100644 GIT binary patch delta 1440 zcmb7COH&+G6z)4OWQON3JOYGw!n=FIBY^}G2n>iKg!uqtAdrS>pd-wXGYtfc4pC#& zDl5TWx$qCTbT@ zp;!}PffCgf7F34Pc@gc9vS*)Y_t{uR^Vultb{*$60UyqBmsI(bl&erZnxG0)QYE)J z&(x~+fTEz%SmsAX0S>Kt3|*oLssJXD1XRL!e~1%0@vx~LY7XyPI2&`kBL)FPEw zy#cLsfrq)ss}XI~gm!99F+qJvXnCbsw3At=^_BL8PxrOq5~bSF!Pn3^CD?XN33NLX ztGG-Wu22{~bP2uGfveQXtBcRxjcarneRKu=JXH^_Q!lTpxIx!2Kz$gbe%z$%7{V|j z7~xwPWz5Fw7{eP7cvE#_9B*L)ld3y~TU?q_U5N668lW4P#tddTdK zcd_8$BJN4ld^7-^2C+mpv5XiGV#trmpGU!pGuSE*wg!{A3hq0ZcR91paZ16)l$alw zA3$7nogfl?#!Wl`Aqk89Eo@^453#Fyu!r~X2=6=o2S{NbkEO49_^jwL`QyHbbVu!Ue!Ga+Kjl9V(KHGs z#mIupFG;g3jT6S$Yp$WYCy7i}V#|sdaVz69;}VZ)i?V)C8r}ZAa8xYV;b3dSsaEDS zD-lbKo5`1SsY-@43wA1Kih27*u!}cU72RRGTht@w>~D*nhdz^(&)IO>P0LKgleA*b zhWf;+ZHHP)p3917+cb9QKr+9SX3hQ~)Fe#%pHNTZoFsV74Sm(fcrATt-Ebz!_%|uB zX^>?a$!0e_vFC~_D@!Fe%XnxR@mMnBS?9xMyh%%6S!s^gcJbG@gX5EjmC;5TkBe~4t3$F44) zj8q3*`*}wrLJ8#TXC3*JlKMj>$XH!>s8mR7JI37V{p=&Z;tl`k={@qgD_n<4>7V|b SJlDSes4{JrZ@WFeuY`(v@4UWy?)~od zpA(;_1HU&lc@_RH(XSWt@q2+bv3f6lzY3qJQnRVRpAi&96K#i^LI_Y8%?M&ULZ(Ow zgbg(%D1t~<%nr#hFMqSimvfnAw;?*JKC7DoZnW?&tr4BD%4QcJQRQoW$dMFi5>D zW+z)864Z|&8sPr$^ZqU=rTXu4|A&^{FSJnnf89aypLK&UC;^jiA(at~dolvmy^i7` z?4x0f&~A)U68p)(0W#TAeD*yUqrDiXeK^RfMsSEm+4dt%2QWcnn51zWrh_rSy2<8*1p!h5F%Eaa&@bor-I-5TEc$lkJ#UMP781C56_RH=J#F~qTRmb z+Z4Uu`X6ZSR;E##{iu1`Jtf2Ac7HG_rtI0^WpSqVU2sO6$jbVngq#E?UE(OwLuX$U z>Ymb-O5Q3~>h3~O`e(ES**_=Yyge5>DYEugq2c~TS&*~puT)C;(sZHxoGz!*1qoTZ z5h{pzdp%k@fT^$Fi delta 21 bcmaF$lkjJX=L?m&;$pgewCrzOq{mHLi=>t%k21eC^dEfQGO`)qWOu zPSp+sufS9v5|!R4Y!?i%3&gH!4$v9wR<&k7mBsvgACq}$vEv(KMoSc0qPuB8r?8J@ zL{=cSyJE!0AU>w2z5c;P`e%!PmT;S@)%+4!>9s#X zD2S9JV&pKJqi7+~cjPLdYq*;QdT8JS7U)&Asz~?vg7GggOl(IWN~kb?!FVqaz00Pi zINAcgQ7}Al@Wc&IA9(s|p8lU)fHHWBW1=(_>5bCPj3Ez$JZ#A0AdlDNN&Va$ps(>+ zQWwxe#uIJg_&Y80FrfeWyZ8Z`pNTiI!G$f{LiCO*2B|ytKYwD&= z-TyK@`wgIZJVRQ^2L;00!Q+Hm!8XEYL6Pte#(x=w5Y_aDghAFrg;rblZuA=NPH=bX z-Q$2J@QS(Ab#tlEPOl*ifi$F#UI26v-yj7xLw>^V8Gm8?I}{@kUzpxWG8_q*=V)a| zl&JO^-80ZVqvuuu-NI8=Rl6Ui6~1NM57SN68DFzVs}v-WlM=0VQKHJ5j9ZdS?0v>R rCAwmBgz|KR#tV^Hn|U=h+E##m&mG|Fs5${9@mU+q-FK80$iMO*u-{Sh delta 998 zcmX|=%S#(U7{GVqCe_4fFp9=(c9U$3S}Z zb}NIgSE+^HkvBTvcgH7e1m2saQIzE@WM&( zSG7qyara7U1c%NY3v24i?DleIPMx3KT2{8zC^2sl^l0ULhZf`0H>z}-b9igv~O-DLKUl`)rPg@YRCl7UMu zlmjk`Y>L+EakGjx;;G}QRfR3Rj6YDI5C%fHFax+gs=f2LyQo%2iq+?0usR5; zgZWXwMUacm2(&5*6)|RrX}PLo8|G$`>#R?^6doF!m138G1Tj%H2c stUbw#&G!=N@|EWPq_hMYNFBa4zdS2Mn1^k=*1?DI_{r~^~ diff --git a/recruitment/__pycache__/utils.cpython-313.pyc b/recruitment/__pycache__/utils.cpython-313.pyc index fbf2b0d82f84aabde430b7ecca08c22bb8f46092..d6f5ba5cf057d2c5ff51a00baaab23f8f29b3978 100644 GIT binary patch delta 21 bcmaDqpYi>CMy}7iyj%=GAYZkS>#08gQ$_~J delta 21 bcmaDqpYi>CMy}7iyj%=Gpc}A}>#08gQpg6Z diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index 433256d66faafab84c5b6634d60ef08847543e98..415073d87081adaecd30c2cf4b31de145f91d24d 100644 GIT binary patch delta 13417 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 Zh1w62^8VVH?AEl5&z#mIZ3c(m{{mB~b6NlZ delta 13311 zcmbVSd3;pWz0b`u*%y)!vXC$dgfKu7kVQa{C4?mji7<#rV#mqMO)_LM6V9Dsd+Jyb zsjW-#D6zQq^Qt}+M4T#WUE3e)rtjdw=?SN7D;F%g@hAp}+UG|8d~m?fVPLwJrM_x+b;di9CCXGsT(Np5K-) z^4khTL0h3HLjc-4rZH}1JHdoATnJ z+q>G%5$Ck67aQ7~!f8+G>D=&dBknA%w6f>ic^Wj3k|H*hr_`l5t145R6MLp?xL3|z zSTZ3kHny3^s%fkyJ{F0OozG*FXw2R-XI{K_3-{JiZ(Yy)L~l3uPNv=|J@XR1J=|MQ zy;FPUCVDS$HdLmFZ#btBx||Ne3!T#m-Od?=y%Fb3`sZ;r5_+9Ygu1htu+KS*u-`eG zaKL#6p|59|^IYeg%9O3zJS9(x*xHlXlTG5zZPwxnaL%htiI2zsi$R_>KWWyPu~`x5 z%9OF6S!3-Okm7|KeOjc#(50;l<82!b_a(gb`;4;ib-Xgx_>_ z5?)3db|r1N$9WF*UakZo-J~XTvp#tikI$YzYQl!3345JRB6EdvBjLV&&9UiysJ%0K zXL=;PW)U^b%wX?U-4im*G`HVxW^D`WVwa(ZM2&38Zq!!DZP_ihWi(MNlaFLOY#plk ze)gV?E2!D&$Q3QCX4hkU_+XaK=9x&J_k zJX)BM+l|LwK#%-sVZ*dOH2VRA1g1^sM%W)RHQmUfXM5~Vtc#Wuy{auxvgVyq)&*Ff zF%A>9>l59y*rRu@-K64>QbGH<}Z26=w*G^N)K(S5~vsZ+LsVA|@| zjb>WE9x`oxK7WX0&8Kchx){QuweooB)Ld5F9?Z7OOUlZx8z0s>uSVO^kLHv02_;RTkL@O6LQ%0NBBL(DDE-ATZMnf4KjUCa$IjN0HDm zkf3E|`;1^;PzFDA;3Q`tR^A>t0!?H^RFc!qj zyznp?R`)9N4pWzrLBKApR4k&~CYEWQge5y2f6tU9puQcznhm4118`dBz7eB0$`7iW zm7;HC#V)A%widY^Lu2#>ZBjC^V+au?z5$p3r)g6Rm>D5o$e*kMOL&_<*xyg- z0Xi{loD&UEOAm34uwxvN>C@)cjZuWsLrh55)?lyrNp#D!23zD1Eho2S_ey1_5_ePA zDgmtsW*ymOclo&i-*#OXzR)iFa_^8Y;3HYwp`Z}=VR%BFJ?4sbBkb|$h5=3NsulNR zw6qoZTg@O7*lGVo_D&H^Nx81MJfgU!d5$viL;9yY14mX&$7nqOco1+L!147ES`Pyf zDi)96(Q=>1@tDw{cmj{?QmnQNIMOT?T;d4Em>1UF=r}U7bgwVOihCw{qPaRlds_Z} z?h+fKkep!JypVQ8-ZD={?C44+}^YL=@W_W_(KuGMv+zEgR0Lu=0 zB#Ag$60;aSW`x|Ku;KFh3_5>yDe2-eO;2&;spw$$Wtm$#tibM&sy6#Q zwn!zg$2Q0tbi2a?E{|L26m)-(KVNcQk}~A7vsdOq7rSeS5u;hUrtth~hkq{KY9)#n7F2g`R{X`RHykuV zu29hB_XP$i$O7U^nsR0I>sFb%yc#6(;PmcrXuuT~e&*c~Q{mq*oE0A$t;dgPOnfX~ zTXQD+K}B@R+LftFZy(7(`!$ihn80GO3vvXpVt7+7p4^&msAuC-2AOIK%SYs<#gmFl#{Da9(H!s>$-=s`s>zaJ}b<4Xd zv>e&p)l7Q&PS=MKk91} z);#bln{`%EvutLlo4#~Btr%wC;ceMzc-sti8snr~R_v$4$HfjDj~X#Qy2RC_QEWAqR7Ce)xF|IX z37bl;d*q4U*|ujP-$nAP-YJ#Pfg{b#jxROL41Px!0lC;S^#Utju{zj)_F=UOHMCG% zh=~Md7INk=If59EK0f`xx_Upar9MZN%1_JCyHq~mnOYveR0QUj7i0OQr$TF$6TBbC zQfZBTwN{6CRAu7s!c~+D>~X&Bb>bK(waSMsCMs!6yOU|z5;sR&^15wuBF8(WBT zF>@Nr_RJoqjKqN{yZS4&)pBe9Jxal{dElP$3n0-VK*Hn18S;~XNf)wbEu|4M@hOyO z+P1s>bitrP&4GEbk^pN)l>{Y-%h8rLM28Icwh$xF`R>*74B>mr=6tqN!%%``K8hKufJ=*^B_^r)qB1GC4@nmGH_ zD~6S@IaiNU@n)$SBAQuB6=aVscr0{#LSeV`?aa-EK14UbC3o+vo|6N1w-FfQ2)KGk z231YvvRag93K9MJ&R=Crc^V5h5uwlX8`Usw4CpH2A`C2{f!z+d{<2c}n@h?gobFaq zU;GVyjH)=}B=U@PnIx8j(^{e!PoKAdI-6TD#kMlU3e0N(V3)BuG%dxfk*{1*lPb2$ z??t|&bwukg{Y;yCF6Ki75h8GCVl&!E$Yvg&cP>{@P#vNUZY7uOxNMds4j)5~f}|*9 z53zi}5{&)eJ#aq}VC7v)#8~dMY#9bWoM1=ES9DS;&D}Fu+a;U#yhx&bvFE5Z5u#BH zB($ZdZ~-z;p4dCZ#>dW9`RU&2N*ay~K9IK3QnOIWPnVj*!2nhBC)*8k%_6FZ`t*=z zAo1SAMg$4Lva0hT z@$Q^8n;SQ%`}aedX&WANk;sl&DxkQ&;PhGsVflzJN9!}d6@XDMS&SQV?sgQ2Mlm;3w)BJkC%=83@ zdg)C-@Ldp>77ZxDlx#S`#9077kokbY4U?JY@#}7Sf8auzsrNDUTFku-fM78*2Xr?j z9)r__nMp?jDg!z!0=|&X?RVH!9B~M-(z%MMlvtZ@cm{ND*srT%oz3y4EI7DAyIQV4 z*jU8wh_`5Q4NVt&~QDy1aF8@)j2dVp>dRlGeM z{q3EbQpX>KNURHJK^L;jn~?ob>@{_aR4h;}EsEDfmdPOcn3|}>3VeO1dr0x{ znEdxc8@MQ|h_1PNu~x*4a4`HF+&m_)zh~9}M=rb|o~ajNjDx2bEuL>(a&l$C%riqL zahM=ChQo=n>u9z1gls!}jn*-;ew5`n>I4jhw0mlX*-9IuEAcC*WNoX z@)+qaeoC@}Zh=BMfE`$q%hJ-3LpP9|_|;7;S3Z28a+q@zG2`Hwvg=j|W+@cgs|?2V zjEx?Zu~Wr5*?P1=dpbIFv|iJGAmx42WgF9o-w7c;D?S$=q5opwSN z9Gexh#pYw>8r_0yyT2y#Flgl^(Ml9~^coIB;;L53WJzMZjwI|5FWOnA(c2S4<^;<< zC`-8#lvBm?vM7pgclh*lTl7(FIqYF(4!Omk%WViw(bp@1*@It#d_My~bTfN-`~sIa z;L6}RdGiC)SuQg@B*J#*H^MrvZ$QB zj)ByV2x6IN&O@cmkJ97Gp~=Z2J+=Yo2yq=2UnjRev|hc6q|0p=mPUX3(C4Z3yv+|F z4aXxi68q3`6FP4OY`*T%X=`9sIsxK$_jq06ihv!Lb%NtxZN)BdD?Ji->>nJF2QVIx z!;emj*wJJYu=nzbQ-VH@c{Ym`?tK{O2k;rg#Yn_q@mVnz6HDvIK?7Ov9&RGs#wy!NRlNyc-2 z{BBw?WTZC^OLy1E)@Q`zjp)u$SPAX>k4y8JM*1w2b)qFn(z9q#)jfzi zReNWnbr3|p3)m0fBcIKmi&3UN2LtS0oRZ)n$l{>^b<@FYBsfr>kjuF@GnRbiD<>*# z?}5sF^3xNg9iVUK(G42sJ+5zl$D32Xfqi3fi+{1AK2cNPu9{Z5g*yB`imre51>AF0 zKmScWWbk4ST2LIdS%3G*1lY*@?A-LmtQYVB?5 ze`P_$$}Dgp_FS{T%0m1#`7ZE`?Bl3PO75Il9>*f~430+5^$%b=-$byJ9l+y*04u39 zU>UMfAgLZ_%UHE{73MH|3C_gLAcKIUf&mA-nT4Ar{0>6=3PUU{%Z;RF7Esk7=t~ck z==2Fwh(Wpk)iN8Wx?y?b)k^Ie`RuDRbJk&QjeFSVk{7;FrKLqHU)!IyloQRTpvt19 zrLvj7LI>}4E3N80l$3zY9J)!N4_o}rC{=#&%epQ*%}L6`oM=EueUGoD(YO~{bS)Y9 zq#5OW-EY{nY3{Kh-!{T7pZu@%$Kzy{2nu%Vd+fK!S;16X{H^0(-5mG}&u zNp1-`e?i|^*NnI^bRnP@@Sik^-U6ZNB%)R_u%;&*RQxvx{}14=fVXMwM)5hCe*=60 zU{9+V>uEnsiO^W$yh(bs`u};}Kks;5%Jb9*5OF$#DSdnmL0HKt(7tjxC`!X4$78Cz z_3d)?D=RizTJ)*6|B`bSTND$h+Pa-qnE6&Ty69|S_kgiKFQ`Aiv3{tr-n+4W&7%5s z;%51$_osCv_)5-cv@Lblr1yimQ5kFh2WK>~TUhRN z6VXljxy=Q5WS8Kmb<0mbcsT;wQmHaJ*O+Aiy1B6OF3Mm&4%q%PlI+ikVKoT92Vm`S zC|Rz@iGlUa(Qmm4oFk&%CjNoE^f>4z4mvRdkL=Jh(NYqjWjVBtM^0LtUvu!thiMgB zEb$XdsEKSY@b0LbD0VZOFqSBS>e)E3i;{61x8xTeb=bZSYQwVg<7LWY3NTa<{ocn$ zK~kbha+{>=&+%cYinV0fZ?kOwVx}!0^dj=&FX|>1qRHNpt$v%5V97>^jERZC7*KrdFD)K1n?l2sGocYH%Q+^F^%YJW|Ab2*!$ccJ1DdE{PJfQD zfom~^ohX-D^h-#TEvhsgQ?Ud_F3r~l%Gp@?G-;Hv#JU3o+7xZ}@%jR7M%t1N(9${` z-x5dj7W!|msK!30){*AN=UyA8_Mf%8H#agw^V~9SHfp%TQ zhWWX%`2(TQu*jsIq#BOX8U!%&VzY4VY=}H`6~(%|y-oZ(WrKfAoS;X)pfUK|PV~?Z zg!|ObS`@YNU{g4RTY7|{nSpPAhJe40beCw8NA{Fx(u&&coWYb0X_k6*^`Z^=HraB&}=-e0K@Qc^#Yi+nDj|^)@O?j8l{yf o#!J-rQ<|n}Z>8kEl~VZYl(OF+UtFdgPtE&mAlN^l+viSuy6%?yOlLKE_Cc!bh4BBW!@BFDszV`>~Y*QRU? zIY8UAq7`gMXp!0#7-3z?ga(NR9a>eXo4P-?p)qM_)5N~txp^Xo zg!r%>m`d{pUt%U9a5apdkcS3wA$Ns zu@&OV#sV>Jzg*QwOPxwKe+paL0h<7u0r)h&1+Y~dcKBF_IOnJ;+>Qxs?|1T@h;sA65b^!q+!YFGkEPJP^Y69 z@BI?Q3;Fdrv;D;ECANFAui#&7`4ATO0AheRV4sM6ts;eV)AcX>CN9Zc$fh3s(n}TF z5&&?EOvG4nD``uUlK2R%_^Dt#6s3#~Z;z^p=b|FJ=qsZ8UeWm`xerWFWXK|rj_I@n zLvPvli#%dQmP7n!#U@3P4Nd&pfCrg6gh{~5VmLov94ju?89RhTnEZ9|IO|6c@(=*$ zKU0xYL*w}v84o|gFa4k&K}ri7+96Oq6aY z5Fb~5-hgb=Ou<-8W*axrt76oP&|5TCjRao3glWzM4^sTaBY_xY7(XrkRMo-iCu>%= zGZqp_Wj#A5eyaG~XKBy8_~qgYrOE;d_&VXLu4t9zKpuvaJcpHWz;d-tkL*3IgSabvGVv|5VHWriMu=yK*ddW_vh znm6^Bb~weQmCHoQYOCn-+blt9qdg{3+D_%(1Z8pauC|zs2J-1K?pP+So$&;Sp@*gm^pU~PZJ|wtqO>a4R%fg*M68NU{9#Rbzn<T2+t$mBQCD>r1;Ni z_7452M+rs@U%E@jD{q$7PLXxVQrl)GkQ}q+;l&9uZ`>jIX>)E$#IVW7Nxz0q+eDhV;lx@vzoBO6M z|5IE3Sl*4&`p;||Z)fM-$gf`Zv&>((F1e<%n{U{f@7N8tJYG*N=%Bd2KF9Irf0Il6 zO~6}|MH~Lko`3Yd^F!=j*mrn}<{s#`elb*6#+W!>_hZ>ueo(@}2R{Z#+n>LxuT14o zC*?0gWUfd)q*F^@{@+gh9?Sqhii$kDC*Ih&T2K1-sOpTfJkiu#C`K9_?6h#T<%#iz zvyCz#WrCi7f?on8i>SJaN$ zshlR{yUm^@c8Y~x!mq&eqj!oAu?xCKFKpQcVu_%8{T*WKEFWOowb+cC`JEN@uRtwY4P&gJ&gjWwG_Qt5}yV_Ru zK}o0LSh5dvHuHW|q;=#YhyZHESX;I3$VX^YlV7x@j463Vm+dNJU^p44D>3BXCeShx z(L^lFcgr!S#gJxFlY8}UmZ=xLk0E^xPy&$mr|TH~4)6(}l|aoTkOdH+cOX|Ns1CVz z4+nWDa3C6fk$*-qb(4SFbdj-l#M@i$r>K?azkhfiM*V<80C_EucLCXgPh$>6T~<_? zsd~i9Adn{^S%85De)Va#hP}g~WGvjo|46G8D+YCd@Q#TpQc=f!OD1b!|L}g4XF4^}nV4ic_MH2Js9h4< zJMi86-^V%Uo_p>&_w3Eb_(z}Nt=BA;1P;pQhJSW{yyTqKq&J`B6`dUNg!*MtLLK^k zzJxxfe}JDidPDz9e)(dUtY`xssp}-?)R9vBOzaXvZf{TuiF-qCpC{xY^P!t8Kv;-i z0ywE9mGmQHYRVG$khK7UAnYgAI9^K)34OX{*sP)9gvWLB)Lx^h)Xb*y@Lfl$X}zgI zot8__&dH?5O|KT!z)-W(MjCL*DumStjR?3kvIe1vK5TaIYw1hoqO1+rfy)Tv$RyZ| zpPT7t=4zGxW}v^HzGNxpUG!axBdl^s{5~#7#f;F3(1x%UAq`;*!d3vqDhK+z-2Eir z-aX(UT_l!7?Km5u3I1uXb+IZ~JKOjltdE0W>uh=64xFY+XSo;YVu(W?KpL@R*ntB( z=p|dZ%6tcXB_ne*FY(>DT;zL_Cu*g{Js8}j#DmpziB6t$Q$xxD6VrP=Qs|;bQ!-qO zuz8nZq+?$>LIr@Lm4cE;%An~4Yr*a+gl+^hyeYF0RI`~knvnV+&o86r7G}~Pr=4oV zIiw2#H8N?Qq!v5wWUT1HWIdq}A$2e~MfR38K9c@uH?xgS7(Hp7S+6pZ#sgB1c^$Ua zBd7+9EvDCIY;6RPCoLJjJq+mXjHRk}J)qrlymxea<~Mw*9|w0M^dJNg`e-1#AdFW| zrGMw12vhK#8dYxqlQM+M`VmmScpI_J^vl#1<67b-6#Gh#+cA}lwDX_NNu#+5X8P~kRZgaGq(lZ0?$*{2b|Us7Nab>3p-G{mB*82l342FWX`=E&S+LT8Zlxc7Jhe%};NEq19>VlX^J{rP?2 zTY7QMx)(!4_oG;D9olMSFq0!92T&7-5N0$}JsHOFVY+k9T2Iom2s1t4 zp*1_d!#Pf06O@kp1Yt&*X7onS;xdHa!oQ5x{sf)nSfmQ|?4)1ZNMCg<2jQ+d9QWuO zr-l2UeB%+6<_O(eSfL7VgdQu)r0*1dbRXU{CC(EFu$xBW;M1v59ncHVmNfu^>cSLD zhB_OQf?DSm z)H7GW4uj1+jw8Q7co{&}0YJ2c!W^fP=$*RLZXftTe}J5#^4!xY7%x%WYo~$L4$}_v zGn}2iI6IvNDpKe>Sw_2+<2vGD0xNBti+5>UaSM2t))il^EucnWu7P*ysx>{PHo7S( zwb-R!I>2$BSj!Bs%*d8;9aefKD~B#9)e0@Mrl?QXttMAOwMNpkX6K{sPz0_6BMU383|L%7FC-1#pz&lVfxXiBXby$~nmo>xEg9+!1(S6r-^neH%i}tEr!Y8s4P3W?D6DDS zaK8Xyfj(S1D?FG-L@(+0hk8UQG{4tFFh0riMM8q4tUJ^bP~us8Flm!Wdi=8N4@!bh zl>DO4Pr(YwTo8ywJ4L%`qpNi=d!rQCCty?P5kuWUpWv4T@It`>Sn&+5cnMc@BWSoV z#|=7~Bo>WOrY=!{cE4CAZ0MIm!Y)yOrLkG)A%9SpFNA_nXXTd(t<$48c~XM1*1;4PW7FS@kBLU#-Q zWkufu2qGy)NgQx9>6D~fO}Ec4_Xa%s2zohsrZlVh6f~T1j0o#wUT?4@rh*wYwpog5nJ^ZUQBdo+T^(cC8chC=7P_z#Q{gR^T-OY|nNdlkkp4PlO z6jZdbXOE~P;rmA2)s$k;-;;9)Xv*x7PBNgS7+3A}ioJMpiV zk(@{V7N)Jqpd+hi1umSEE$H_#KuwmqI>`3g=jDXof&QyFOuIsqO)NxzUd$ywR3H=%KN%MJQ)V6k5 zJ7KboEPrzONbOU#6Iu50tdeL}$%)2jR>kl-SW~%dyrLmm(J)@IHd?WEyrSj3ik7i# z7vPD)V+)^eidM7?ua9KACTv+_wz4bfxre(VMQbiDj%;}dcK9$~ulWkA)tWEV3OXCZ z8kgo8ezt1hbBJ%ls*T@*)jEDFRy#FbT#@0!k0d~lK67MfWayab#J)4(li@L;W+KON zbmNhY2aOZy!g%_kX!@eF%f{1dqv^F*vS%MFij=IsI1t%JBJxm#JPc%hz&9YZRhsZ7 ztQ$0&vD&QJja54zz-lAkgVi3*K4hOWh##qdIxaK+sO^YtxM{-f9Bw+Wal)KB()?ue zWpmC|1DBaQk)1b@yYQ^@Z1!ub&sV=yd!aV6!WD6~MzY%`atn{TkGLlsW#f*zsH1Mo z5ncg(Us&|%*5StMiJT>M+??~CIp^@!h@*)Pr4-yo$2}krnNi zw{4H?aF1=<8EN-K>UKq{ypcdKYU}-zq4(RX79{wMjOond#cQ_TY2si{wg>i1dowNSm4Re)HV6A#rzZ9-E@+(qlMCR^OwMjqO4+u{M~>5P8* zgR&&Ee+jzx=Ya)zVf4AOFZpey*v2S(SK>Q!lK3J{zB5TA`=RfyNbvSXOwxy)a`v-hOiT* zT?maQ=)uJc)kArozO^{O``Mv1FG<_Z!c7g#X?B{vPlrgCrETn|ml^qgfb$Ui^um%t zmF)*CW7Ji<`5h)9_KZr)PxJfWq$Mf{Lx|@boJm1G(-=v|(>N~W{VE8{y{YMl4 diff --git a/recruitment/urls.py b/recruitment/urls.py index 4aefef5..e48911c 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -41,11 +41,11 @@ urlpatterns = [ path('training//delete/', views_frontend.TrainingDeleteView.as_view(), name='training_delete'), # Meeting URLs - path('', views.ZoomMeetingListView.as_view(), name='list_meetings'), - path('create-meeting/', views.ZoomMeetingCreateView.as_view(), name='create_meeting'), - path('meeting-details//', views.ZoomMeetingDetailsView.as_view(), name='meeting_details'), - path('update-meeting//', views.ZoomMeetingUpdateView.as_view(), name='update_meeting'), - path('delete-meeting//', views.ZoomMeetingDeleteView, name='delete_meeting'), + path('meetings/', views.ZoomMeetingListView.as_view(), name='list_meetings'), + path('meetings/create-meeting/', views.ZoomMeetingCreateView.as_view(), name='create_meeting'), + path('meetings/meeting-details//', views.ZoomMeetingDetailsView.as_view(), name='meeting_details'), + path('meetings/update-meeting//', views.ZoomMeetingUpdateView.as_view(), name='update_meeting'), + path('meetings/delete-meeting//', views.ZoomMeetingDeleteView, name='delete_meeting'), # JobPosting functional views URLs (keeping for compatibility) path('api/create/', views.create_job, name='create_job_api'), @@ -105,7 +105,6 @@ urlpatterns = [ path('jobs//candidates//schedule-meeting-page/', views.schedule_meeting_for_candidate, name='schedule_meeting_for_candidate'), path('jobs//candidates//delete_meeting_for_candidate//', views.delete_meeting_for_candidate, name='delete_meeting_for_candidate'), - # users urls path('user/',views.user_detail,name='user_detail') ] diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 3e1043a..34d3f55 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -92,7 +92,7 @@ class JobCandidatesListView(LoginRequiredMixin, ListView): context_object_name = 'candidates' paginate_by = 10 - + def get_queryset(self): # Get the job by slug @@ -239,75 +239,14 @@ def candidate_detail(request, slug): def candidate_update_stage(request, slug): """Handle HTMX stage update requests""" - try: - if not request.user.is_staff: - return render(request, 'recruitment/partials/error.html', {'error': 'Permission denied'}, status=403) - - candidate = get_object_or_404(models.Candidate, slug=slug) - - if request.method != 'POST': - return render(request, 'recruitment/partials/error.html', {'error': 'Only POST method is allowed'}, status=405) - - # Handle form data - form = forms.CandidateStageForm(request.POST, candidate=candidate) - if form.is_valid(): - stage_value = form.cleaned_data['stage'] - - # Validate stage value - valid_stages = [choice[0] for choice in models.Candidate.Stage.choices] - if stage_value not in valid_stages: - return render(request, 'recruitment/partials/error.html', {'error': f'Invalid stage value. Must be one of: {", ".join(valid_stages)}'}, status=400) - - # Check transition rules - if candidate.pk and stage_value != candidate.stage: - old_stage = candidate.stage - if not candidate.can_transition_to(stage_value): - return render(request, 'recruitment/partials/error.html', {'error': f'Cannot transition from "{old_stage}" to "{stage_value}". Transition not allowed.'}, status=400) - - # Update the stage - old_stage = candidate.stage - candidate.stage = stage_value - candidate.save() - - # Return success template - context = { - 'form': form, - 'success': True, - 'message': f'Stage updated from "{old_stage}" to "{candidate.stage}"', - 'new_stage': candidate.stage, - 'new_stage_display': candidate.get_stage_display(), - 'candidate': candidate - } - messages.success(request,"Candidate Stage Updated") - return redirect("candidate_detail",slug=candidate.slug) - def response(): - stage_form = forms.CandidateStageForm(candidate=candidate) - context['stage_form'] = stage_form - stage_form_partial = render_to_string('recruitment/partials/stage_update_modal.html#id-stage', context) - success_html = render_to_string('recruitment/partials/stage_update_success.html', context) - yield SSE.patch_elements(stage_form_partial,"#id_stage") - yield SSE.patch_elements(success_html,"#availableStagesInfo") - yield SSE.patch_signals({'stage':candidate.stage}) - - return DatastarResponse(response()) - # return render(request, 'recruitment/partials/stage_update_success.html', context) - else: - # Return form with errors - context = { - 'form': form, - 'candidate': candidate, - 'stage_form': forms.CandidateStageForm(candidate=candidate) - } - return render(request, 'recruitment/partials/stage_update_form.html', context) - - except Exception as e: - # Log the error for debugging - import traceback - error_details = traceback.format_exc() - print(f"Error in candidate_update_stage: {error_details}") - - return render(request, 'partials/error.html', {'error': f'Internal server error: {str(e)}'}, status=500) - + candidate = get_object_or_404(models.Candidate, slug=slug) + form = forms.CandidateStageForm(request.POST, candidate=candidate) + if form.is_valid(): + stage_value = form.cleaned_data['stage'] + candidate.stage = stage_value + candidate.save(update_fields=['stage']) + messages.success(request,"Candidate Stage Updated") + return redirect("candidate_detail",slug=candidate.slug) class TrainingListView(LoginRequiredMixin, ListView): model = models.TrainingMaterial diff --git a/templates/includes/search_form.html b/templates/includes/search_form.html index 5cdad13..721c99f 100644 --- a/templates/includes/search_form.html +++ b/templates/includes/search_form.html @@ -1,6 +1,6 @@ {% load i18n %}
@@ -15,10 +15,4 @@ value="{{ search_query }}" aria-label="{% trans 'Search' %}">
- {% comment %} {% endcomment %}
diff --git a/templates/meetings/list_meetings.html b/templates/meetings/list_meetings.html index 0297876..a602ac3 100644 --- a/templates/meetings/list_meetings.html +++ b/templates/meetings/list_meetings.html @@ -184,7 +184,7 @@ {% trans "Apply Filters" %} {% if status_filter or search_query %} - + {% trans "Clear" %} {% endif %} diff --git a/templates/recruitment/candidate_detail.html b/templates/recruitment/candidate_detail.html index a35e152..e00e130 100644 --- a/templates/recruitment/candidate_detail.html +++ b/templates/recruitment/candidate_detail.html @@ -118,11 +118,11 @@ padding: 1.5rem 1.25rem; background-color: white; } - + /* ==================================== */ /* NEW: Vertical Timeline Styling */ /* ==================================== */ - + /* Highlight box for the current stage */ .current-stage { border: 1px solid var(--kaauh-border); @@ -131,7 +131,7 @@ .current-stage .text-primary { color: var(--kaauh-teal) !important; } - + .timeline { position: relative; padding-left: 2rem; @@ -163,17 +163,17 @@ color: white; font-size: 0.8rem; z-index: 10; - border: 4px solid white; + border: 4px solid white; } .timeline-item:last-child { margin-bottom: 0; } - + /* Custom Timeline Background Classes for Stages (Using Bootstrap color palette) */ - .timeline-bg-applied { background-color: var(--kaauh-teal) !important; } - .timeline-bg-exam { background-color: #17a2b8 !important; } - .timeline-bg-interview { background-color: #ffc107 !important; } - .timeline-bg-offer { background-color: #28a745 !important; } + .timeline-bg-applied { background-color: var(--kaauh-teal) !important; } + .timeline-bg-exam { background-color: #17a2b8 !important; } + .timeline-bg-interview { background-color: #ffc107 !important; } + .timeline-bg-offer { background-color: #28a745 !important; } .timeline-bg-rejected { background-color: #dc3545 !important; } {% endblock %} @@ -187,7 +187,7 @@ - + {# LEFT COLUMN: MAIN CANDIDATE DETAILS AND TABS #}
@@ -199,7 +199,7 @@

{{ candidate.name }}

- + @@ -345,7 +345,7 @@
{% trans "Candidate Journey" %}
- +
{% trans "Current Stage" %}

{{ candidate.stage }}

@@ -356,8 +356,8 @@
{% trans "Historical Timeline" %}
- - + + {# Base Status: Application Submitted (Always required) #}
@@ -365,11 +365,11 @@

{% trans "Application Submitted" %}

{{ candidate.created_at|date:"M d, Y" }} - | + | {{ candidate.created_at|date:"h:i A" }}
- +
{% if candidate.exam_date %}
@@ -378,11 +378,11 @@

{% trans "Exam" %}

{{ candidate.exam_date|date:"M d, Y" }} - | + | {{ candidate.exam_date|date:"h:i A" }}
- +
{% endif %} {% if candidate.interview_date %} @@ -392,11 +392,11 @@

{% trans "Interview" %}

{{ candidate.interview_date|date:"M d, Y" }} - | + | {{ candidate.interview_date|date:"h:i A" }}
- +
{% endif %} @@ -407,21 +407,18 @@

{% trans "Offer" %}

{{ candidate.offer_date|date:"M d, Y" }} - | + | {{ candidate.offer_date|date:"h:i A" }}
- + {% endif %} - - - - + - + diff --git a/templates/recruitment/partials/stage_update_modal.html b/templates/recruitment/partials/stage_update_modal.html index f6a5f28..bf809f4 100644 --- a/templates/recruitment/partials/stage_update_modal.html +++ b/templates/recruitment/partials/stage_update_modal.html @@ -19,66 +19,21 @@
Current Stage: - -
- - - -
-
Application Pipeline
-
-
-
-
- -
-
Applied
-
- - -
- -
-
- -
-
Exam
-
-
- -
-
- -
-
Interview
-
-
-
-
- -
-
Offer
-
-
+ {{candidate.stage}}
-
- {% comment %}
{% endcomment %} - {% url 'candidate_update_stage' candidate.slug as stage_update_url %} - +
+ {% csrf_token %} -
{% partialdef id-stage %} - {% for value, label in stage_form.stage.field.choices %} {% endfor %} @@ -95,17 +50,15 @@
-
-
From a669564e6df16a312d4c8a0edd65f5703e72a86f Mon Sep 17 00:00:00 2001 From: ismail Date: Thu, 16 Oct 2025 13:15:46 +0300 Subject: [PATCH 2/3] push bulk create meeting to to background and create zoom webhook --- .../__pycache__/settings.cpython-313.pyc | Bin 7927 -> 7983 bytes .../__pycache__/urls.cpython-313.pyc | Bin 2177 -> 2268 bytes NorahUniversity/settings.py | 3 +- NorahUniversity/urls.py | 1 + recruitment/__pycache__/forms.cpython-313.pyc | Bin 23612 -> 24075 bytes .../__pycache__/models.cpython-313.pyc | Bin 45802 -> 46819 bytes recruitment/__pycache__/urls.cpython-313.pyc | Bin 9512 -> 9671 bytes recruitment/__pycache__/utils.cpython-313.pyc | Bin 20463 -> 20398 bytes recruitment/__pycache__/views.cpython-313.pyc | Bin 71941 -> 75417 bytes .../views_frontend.cpython-313.pyc | Bin 16895 -> 17910 bytes recruitment/forms.py | 188 ++-- recruitment/hooks.py | 1 + .../management/commands/generate_data.py | 48 - recruitment/management/commands/seed.py | 156 +++ ..._formtemplate_max_applications_and_more.py | 33 + ...5_remove_formtemplate_close_at_and_more.py | 27 + ..._formtemplate_max_applications_and_more.py | 22 + ...emove_interviewschedule_breaks_and_more.py | 27 + ...terviewschedule_break_end_time_and_more.py | 23 + ...0019_alter_interviewschedule_candidates.py | 18 + ...0020_alter_interviewschedule_created_at.py | 18 + recruitment/models.py | 94 +- recruitment/tasks.py | 124 +++ .../__pycache__/form_filters.cpython-313.pyc | Bin 3046 -> 3428 bytes recruitment/templatetags/form_filters.py | 9 + recruitment/urls.py | 3 +- recruitment/utils.py | 3 +- recruitment/views.py | 894 +++++++++++++----- recruitment/views_frontend.py | 12 +- templates/icons/video.html | 3 + templates/includes/paginator.html | 27 + templates/interviews/preview_schedule.html | 52 +- templates/interviews/schedule_interviews.html | 76 +- templates/jobs/edit_job.html | 37 +- templates/jobs/job_list.html | 69 +- .../jobs/partials/applicant_tracking.html | 15 +- templates/meetings/list_meetings.html | 75 +- templates/recruitment/candidate_list.html | 61 +- 38 files changed, 1512 insertions(+), 607 deletions(-) create mode 100644 recruitment/hooks.py delete mode 100644 recruitment/management/commands/generate_data.py create mode 100644 recruitment/management/commands/seed.py create mode 100644 recruitment/migrations/0014_formtemplate_close_at_formtemplate_max_applications_and_more.py create mode 100644 recruitment/migrations/0015_remove_formtemplate_close_at_and_more.py create mode 100644 recruitment/migrations/0016_remove_formtemplate_max_applications_and_more.py create mode 100644 recruitment/migrations/0017_remove_interviewschedule_breaks_and_more.py create mode 100644 recruitment/migrations/0018_rename_break_end_interviewschedule_break_end_time_and_more.py create mode 100644 recruitment/migrations/0019_alter_interviewschedule_candidates.py create mode 100644 recruitment/migrations/0020_alter_interviewschedule_created_at.py create mode 100644 templates/icons/video.html create mode 100644 templates/includes/paginator.html diff --git a/NorahUniversity/__pycache__/settings.cpython-313.pyc b/NorahUniversity/__pycache__/settings.cpython-313.pyc index 9395c449071310344438a33cd0f34f15a44e5438..73b3ff6bc6398c42ee692c11f2a017c6573339af 100644 GIT binary patch delta 738 zcmX|8OHUI~80~FKMX*YsMbwy>NP=jb5(NQyw)BNO%5YSm7KTEHGCVrvYXR{E_&}oz z#rq4~xzd=f+?%*@;ga+R7&I=3nz;4eQJkCaob#RU%)K+YzdPP`x_`ReWj66_9@?8# zx829s#=E+JeZ7qu(MC;h(IvD~GdidRojyjb=%UM_+R#ny@KXl@)QKMILJ%Q@5rGR) z!3WWcE9e75zv93E1`)%MP^y>=D|SHCRi&J|aShioBDztGSscd=jI0YIfpN>2fM)R| zl9GKc@?(kun5G`2p!;l?QCWjnLZwxSvnqpuIgzzt-f|X%vuO1hl_mTMKTO5G&&Y}o zTLK}6JeGxCL1+~>v4&fU6Sr{(cX7|s>)601?n~`+HZVL;O7Kt|CD$XxA)GD6K|wsG zP}*Mqr109&UtUwJz7QD<_B0KztgW{Cqh`dIkB;Wl0RMIPJuBsJj$~b7aRC(NwH4=C zm=9MhF%&*j#8_C9{ox)6o|%ReVTu$SL+Ln6~5Q}U}d{v dHzq3QF6aehh!}+sr!Xcki3HBVilpGZIEVAN z0EQ{WgcL5~5~hWk5tX!J#HVA;$d~Hg3v%y z=ry#kj@WJ7QOwxDU2Ni>uJ5Cb2Y4v8-#pClNU`9txJvFPib*(IiisxhlqSnY-?Ppu zQ>vFc`ra}#f8STPCd408l1ClKS%Oy`t8BLO(J{>ibJ8zLSd^frC82y?n%50JAGuh@qFRIe}JpfMs~Z zz2n}NEzbpXdusKjR;xB>iGOkjS($r1NBehV#oW@=b=sAkFC+=-J#K^>ODKUW6D#ow?btp|GCo# zfK7m?w8Kyd((iyNiqM;`qN%#siupaGViiwfL8g9rYEnjiezyKC!K(cH+;}KAzAQ7f ze6l91I-}y|Kvq#krXrEa$!z^xxM7j`K|&JXee{7enp OA5/', views.load_form_template, name='load_form_template'), path('api/templates//delete/', views.delete_form_template, name='delete_form_template'), + path('api/webhook/',views.zoom_webhook_view,name='zoom_webhook_view') ] urlpatterns += i18n_patterns( diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index c5aac7d51f74d21761843a93553a9835b6a5e428..606d3d5232fc90cadb5d792b2c14d414f54e98a6 100644 GIT binary patch delta 2868 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 BW;y@> diff --git a/recruitment/__pycache__/models.cpython-313.pyc b/recruitment/__pycache__/models.cpython-313.pyc index ddb6d5e302816d24f7d6ff4cb241c88e0b03d14f..ebc6d925b93d0b681a1834599705b472a184dfc6 100644 GIT binary patch delta 13541 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 Tx+fu5S}7e(`a(s&d@}zJ#51zo diff --git a/recruitment/__pycache__/urls.cpython-313.pyc b/recruitment/__pycache__/urls.cpython-313.pyc index eda9760dc3367f9a0374bf37d8cd08d8d12bb18c..46150fb9d67e4e735ef68ccae826ba33b90e5e15 100644 GIT binary patch delta 1769 zcmZ{kOH3O_7{|TqF>KMpwCJ9i3A$E8Z$7Ta1ju#RPm^^6g03mIl z=_93wNeqzbN1B-Z;Dew3wEOrF1P-6?`s$S z!#c1tjt&Tdbthx15{#)sv6R$lO$ceB!IFxnQF0i6Qs%)QmsQ~(%l6|#LJj`A%wuMi z@^gX>e_381Ng1|@*NeRQPfh%*mHL04onOo6t<7w?yN2iPoBZo4^%?Jzk1cHZKi`Sk zGh!mJw0yA;dZ%z@WueO)dP0|$ug&N3R|ADhi`luAE7?Hq+Kp^}HJ4p0gr2!yQSCL= z-cZf->^!LW-7duOM~+9fe2uX(_8eI%a~VZm-oZy|1pIwn)RXTvCY~LfU(R3E)^fLI z^K&emkC?t5bLhqUoMENkEKiw3%pBt8aM~OO%wZ5~&hwp1MkyG-!X3$Cp;kc*!FD6_p6D+4-S86AD{@e z=kPICosh$$u39H^y=A!cJP|3Qt=a6zcAQjsp~_nv z2h>d+jL57(ptwRpaR|lr(F`DsDmnZo>#$d@i!+~>`Hq#ENLvEh68hv7Kv(He#(a#O zeB4C3W}s_^S}cpgu=v7av5kn-&nzu?Kz5Y}SRKTdWw|`W>SK6YmRnj`-NxD+Ey~0{ z2L3VPSHZ9T;-Azr1wc1wJ2QoifMef#fUeUHHgVjX@UaPguP0d>Z*f2I4S;VzAD;m< zOFNml3rCe|shibN0jCwG)F&|2k2e)XI>ki{f1@bYxUtcHDc&;12k}n^AL4u**STHN z87>ky>QOX_0m{-GPb}f<^@pX)T;%a~ef3O%iyJ)nc2OmPUI_H=UIV1k6%JR~1=enNkjfUQ zY|+~X0S(bL4sY^g{lgC8iGU}f$EE?Dr|X=4fcqM1Vz;^2;8u2yMxbp(Pc8sjq??>Z zMYc&Dr4@2Qg%hftUIny9`$TqUF|0Lsr8pOZ_=^VD<`5UdJlC^pBhdwjE*K|pSxB-I zm=fs-H;r>sYpH{@tI%#VzXj-hdWO>kr{PkT98baVls?T4-C0g2{+miF$5Xh~Q$6=C z7t`G9D>jklC^ScRZ=3sgp2IYU-H%lgnTE);KFcoR0;d^H+e&c~o`Ud{u5Fr6QJ+XP zZkWN}d0f&g7pwSZPmOnti<>;!zZ)kp4Pu%;e-qF;UFUQI+Z*NJCKpjF+vSw5P7`$< z)OBOK`ovkbT^B{#XMOcROg4I@Q(VOGr;XlVoQpx8Pw2@>l3a!4s*&FiGAyr&bcnl# j@zX}He}s#K8QD_Iw^=hdnoAXcdgxik?%4B-#+&jV@W2nb delta 1474 zcmZvcOH3PA6ox&XYkLT?69o)5c>K1(j%{8xg*t!%n@2E@*i0bCu|>kev`v~AFewQ{ zm8z5_(cNUz%B+G|xne^bRidk|D$P|YjapSg6}wid232aMEUJ$0$)-|A^5}l^{pZf{ znbCYdad<=jr(Sfr>o!9=lC~v?8TS- zaThW$EK0@b^>xkp)pR_~^|CWemT{`mi7ZN8#h+6&j(^mxu-fAA>T}=c$4j+2^>Llyyq1aMzs(2ui>42>7Nw%d zAM2YN7!*|2*l8KG{(NQS3tX zX-xq}`K@PhKi^<3FqhRoA#TsIzbiF{Nn`jVO3S$LH@$ejeL(aQyV#y2UUw|w(^G}J&ty(bYVI!mIn;=)6M9?6Z=nH0Czw4adb zy`9#frWi5B-k65O@!JGtJJxC`cH{4CMi)$>hwc}u7^HTA zvf&8ej%%9X9SJKne#PwpWC3iZYL%I3BvUIfwTf*6 z1jXb9kXb0Tmp!jS2cgO?K_l`VP!_M6USi%>MMs`{0^aqC(IxXNU&PHwx{6pO4 zG%h{@D8i=K4h1PVK!O9O4+s+Eb&wmh9n*e9stb_1fY>!g(73z_vILcmazt|V5m%oW z&k(dAZ-L!r@UKo|%M+ldkZrGcq|OP_IU%N22`b1tV0USjdj>Rw|8aV`VKsdOBRJsV zxlw>IeAC6>9S2CN=^#5Q5vfZcUCJZfCg_Pg33du>xUwvDr%88O%+U8{8f@zSZ5r$? zEV_(Ka{%)YZ9Viz{uuGcPIs#FxeYP{GI}CNeHqf15f^C>3t+QgJ1Yq(JWs;&V*c5M zN~IZ8%Xc7FhfWhxJWt|zapf^VpU7KapW-98wPpukSB;6X z%QK)D{>$y<2HAJ-uiN7d4Fim@bY3%JYEDX(NU9{ORJ%-0U2Q3^mPa8qh97&pzHxx5 dYRR7MrBO2biP>M#6VxwHQ}=0YaaH+l{R^c5#D)L> diff --git a/recruitment/__pycache__/utils.cpython-313.pyc b/recruitment/__pycache__/utils.cpython-313.pyc index d6f5ba5cf057d2c5ff51a00baaab23f8f29b3978..32180233f5cf75219879c54074d0c96ea9533b3a 100644 GIT binary patch delta 465 zcmaDqpK;xMM&8f7yj%=GaGd``M&Cx>W?jaL$rE+G>q8keK*B(f!pOjo&KS)O;W03T zGMEBIKp>APniIkTiZa1Ok;E8bVpc*742jAN4AI;$^})z`;p+Gpf|bVUb%bIhn;J zMczOqMV>(7mc->+-DQ@z4 Ge+2-N!%l?& delta 459 zcmZ2CpYi>CM&8f7yj%=GAYb(^W8X&JW?jZ5lPBtW+eNd3L_r{w(UcLwQUJ0wAWVi7 zpjbL%G$)kH5Xt~kmB$p#J~`1<#4ef}F3ALwL=uA=YbC_Mkf_YS5X}oyAIucZJ=sA| zoRNESgq|uR|KvJ7bw+{7(WWw!59ui~icG#}B0O18U(_#{J(M*_0;UZ?^D!8+}c+d4dR_Zy0V#OrBsXvAM<2m60)Y@+l()jd zMDKBn-ryJb!p0!3zoO!@m_>s>NJjq(i`1RXe~n(UFrMA~-24hFqaPt=DbS6go%`$GcSs6Vy|L~s3%&NyI{V8E`pT7bCC;nV> diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index 415073d87081adaecd30c2cf4b31de145f91d24d..fe6f2a13de7d86f99259150e06b597d0a00278b5 100644 GIT binary patch delta 21543 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 M8FnV6C@l8>0Ao(7iU0rr delta 19165 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 B)06-J diff --git a/recruitment/__pycache__/views_frontend.cpython-313.pyc b/recruitment/__pycache__/views_frontend.cpython-313.pyc index 61611f12cd988273c9715a3d15644ff25507940b..961ccff5edbede83597d7814238336f270848237 100644 GIT binary patch delta 1889 zcmZ9MdrVVT7{Gh_0!m-fmQqUFyY^BjZzwI$QGxk~Y&wi0G0quQp(+*> zs1y8#8W$tzHZU#>*DNzc6N!eIXg2Ohb_sD0vt*g`kD2J0i+{ND9msGy$^G@~_d1X7 zJLmCRWbq);-`D9hGCE#+^N-!(@(cP!6FHYsnLSDphs#y<5Y2fcztagTqavh zhGpB(pN-8bIAYrl_bXTH6s__Awd3ekz-e29PWgmJ33qLk3RN9}wYl~5(~%5l$~8GP zb)+;+CJT_|NYbV#ToPpwgY0NUj#<%~Sx34_oASSUm9kY!vz@Zp;EnQkp~!BATPB0g zLxTk#u@kWi(S_KH*oWvryaF+MUY3t6WbEnL(cT`9_3jdOQv)RJp->z>5{O4Z;O0_z+kqWXrSD&9sHaHHaL97CyLM}Z&ejQIcGydmU^W+ z)R0y{_kO6*1+SM|$SSx{Xac7_O}@zl)2mF1^Z)@}RxUW~>HX9>gF4IFXgY*wBoXawk0ciKkyj&KvB-{Cw4DaUg)mL+ z%M^;~kFXk1hA2l=Al%rj!de7YXh9K)#aM+F(mT82iLUOB1WY^X)t^vf`~oPPp?;1Z z(~fE-u0rH0B(7TIswdknJ~PKPB+~}9V!C7A?Hwx_Es@++qPt3R2Ss;qa{m?ioO=y* z(2MD=TYQnk2Sh#~@nMkDB!0cfufP7{9RGsEcZht)93M@l&C;T#KQ(X2 zPU?jaT}2w(lW+hw=9o2oxNyWf(36wH2L_&>&-KnKt@Bxyfpvc>b5s?7RjXCj`!Wg^ zE%d>p#|BrNC&)xH1$y#FE^h-`G2njd~f%`-3lkkNNFlp6XJtGM>ge{!w-9_+$tMQ z5(l~SSs1CxBWLh|&78tK2n7M9|7kJ>jsA~`3oZw9;jv#!&cfk3D|o8U`#(g_O9*y7 zhNUONiki6cX|Ft!FmhZ@k(1>F@qwa7~ZRCAT#iFO^j5tEwt3u)2R>E zmVhplL3Pu$#pF6`8+Bbf2{E66x}|NlP|qQQ1OBcvYi?jlL

*a&iOPCG}G1ZFv^E z_o|Mb-tL6JIyDK4^@Xl)FyLE+3&Gy!S(HS?cMuMjs3a;jrMkltgq%+~8cZb2E+o4t zthHH_V!sL4C;~PBs|`CWwnO1M>e%We*w^SFzrn%A34@ndXLE|in;*!i^k5S-H+}UF DHE{Xj delta 1277 zcmY+De@t6d6vunoK7>L`H(L6jv{hO<9?(&{w(IZxj&n@nvM7HLEl8_C3SBPc4`Z?s za9I{KLXJyFHio*nIhT>lw@l3v;v7pBe+>T6WU?jdAG(<6#sW?wS>`>L#Lf4|C+FPz zJ?Gwg&UyDo;o%6FmW)P&hJO>E-06PJciwcr0u)uUxb?tPMcxQFW43F*0vxNfVu#y^ z9f3C3g-_dMoDY~y?M8~F?*;8%J%YUgBqGjMH{(x%(^y?&*;NvPRgFiZ7+RT|JIq7q zCiD_=gnq(44h0shz4=$t=|WE-mrbX8y7K*n&Yu1OEDpvjjAR3Z0>Q^|244&+Xmfb9 za~e!K{Mc4(#5>j`-gQ)J7cF=$pd@7~UWu;UMN^*%KOi?nbqDUT?dx)-u%cr@lh+|J9(AY)cvr787E0 z#j0>y?F5`sZ`Jlf(I{VCaN)f=3wC%);hdWD8~_-@-|O6P0nJ_p3`e|Sp<`osKI$|a zqC|vKc-z|ohj_{%RM%PI65gt{8wdAgIt$tL-G#m!JB;_%wU|Y5c2ZOs0WlR18;+1k zxP))jZ-yh{V!zgBU{++wWOLa^Sq79Aevk3;uwjD22($Q53Bd%WQdQX6@M8@Xe?ixo z%ZcJM9d{KiTTk+1_-vy~n&Rg7YG31JfD_p4t24h(dwK#58)KO9wXZIoApa!cL!9$@ zrL)|;ss7`0YT*N{@^5g^LaSnWJet(vc^_kse@~G}F^*sOBhm%FwWvPuTcPZ?6_On# z98;YEkJdU#_WuWm9l-8j2tYj^Ou-Xum=q&e8cvn0;WNt)av$Fgdtnk4IZ=9%wh1d5 zTnWeFqA29^8%M#0FD0vuT`c=wJEl+<`LgC6iujz+NEqW#bSy~rIQ|@|$>%c9v=D3ajD{qaq3 z1!v+pXyTDI_)H?k4;fB08LpCu(1f2Q8sVyV$b&>00$k|j>EBGYa_M-v0~eDPoKBSR z9aE|quBoZiq;{>C0I4fK*k52`lw9F6kU`{title} Role" + "".join(f"

{fake.paragraph(nb_sentences=3, variable_nb_sentences=True)}

" for _ in range(3)) + qualifications_html = "
    " + "".join(f"
  • {fake.sentence(nb_words=6)}
  • " for _ in range(random.randint(3, 5))) + "
" + benefits_html = f"

Standard benefits include: {fake.sentence(nb_words=8)}

" + instructions_html = f"

To apply, visit: {fake.url()} and follow the steps below.

" + + job_data = { + "title": title, + "department": department, + "job_type": job_type, + "workplace_type": random.choice(WORKPLACE_TYPES), + "location_city": fake.city(), + "location_state": fake.state_abbr(), + "location_country": "Saudia Arabia", + "description": description_html, + "qualifications": qualifications_html, + "salary_range": salary_range, + "benefits": benefits_html, + "application_url": fake.url(), + "application_start_date": start_date, + "application_deadline": deadline_date, + "application_instructions": instructions_html, + "created_by": "Faker Script", + "status": random.choice(STATUS_CHOICES), + "hash_tags": f"#{department.lower().replace(' ', '')},#jobopening,#{fake.word()}", + "position_number": f"{department[:3].upper()}{random.randint(100, 999)}", + "reporting_to": random.choice(REPORTING_TO), + "joining_date": joining_date, + "open_positions": random.randint(1, 5), + "source": default_source, + "published_at": timezone.now() if random.random() < 0.7 else None, + } + + job = JobPosting.objects.create( + **job_data + ) + FormTemplate.objects.create(job=job, name=f"{job.title} Form", description=f"Form for {job.title}",is_active=True) + created_jobs.append(job) + self.stdout.write(self.style.SUCCESS(f'Created JobPosting {i+1}/{jobs_count}: {job.title}')) + + + # 4. Generate Candidates + if created_jobs: + for i in range(candidates_count): + # Link candidate to a random job + target_job = random.choice(created_jobs) + + first_name = fake.first_name() + last_name = fake.last_name() + + candidate_data = { + "first_name": first_name, + "last_name": last_name, + # Create a plausible email based on name + "email": f"{first_name.lower()}.{last_name.lower()}@{fake.domain_name()}", + "phone": fake.phone_number(), + "address": fake.address(), + # Placeholder resume path + 'match_score': random.randint(0, 100), + "resume": f"resumes/{last_name.lower()}_{target_job.internal_job_id}_{fake.file_name(extension='pdf')}", + "job": target_job, + } + + Candidate.objects.create(**candidate_data) + self.stdout.write(self.style.NOTICE( + f'Created Candidate {i+1}/{candidates_count}: {first_name} for {target_job.title[:30]}...' + )) + + else: + self.stdout.write(self.style.WARNING("No jobs created, skipping candidate generation.")) + + + self.stdout.write(self.style.SUCCESS('\n--- Database Seeding Complete! ---')) + + # Summary output + self.stdout.write(f"Total JobPostings created: {JobPosting.objects.count()}") + self.stdout.write(f"Total Candidates created: {Candidate.objects.count()}") \ No newline at end of file diff --git a/recruitment/migrations/0014_formtemplate_close_at_formtemplate_max_applications_and_more.py b/recruitment/migrations/0014_formtemplate_close_at_formtemplate_max_applications_and_more.py new file mode 100644 index 0000000..ac56849 --- /dev/null +++ b/recruitment/migrations/0014_formtemplate_close_at_formtemplate_max_applications_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.6 on 2025-10-15 10:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0013_alter_formtemplate_created_by'), + ] + + operations = [ + migrations.AddField( + model_name='formtemplate', + name='close_at', + field=models.DateTimeField(blank=True, help_text='Date and time at which applications close', null=True), + ), + migrations.AddField( + model_name='formtemplate', + name='max_applications', + field=models.PositiveIntegerField(default=1000, help_text='Maximum number of applications allowed'), + ), + migrations.AlterField( + model_name='formtemplate', + name='created_at', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='formtemplate', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/recruitment/migrations/0015_remove_formtemplate_close_at_and_more.py b/recruitment/migrations/0015_remove_formtemplate_close_at_and_more.py new file mode 100644 index 0000000..b519f91 --- /dev/null +++ b/recruitment/migrations/0015_remove_formtemplate_close_at_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.6 on 2025-10-15 10:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0014_formtemplate_close_at_formtemplate_max_applications_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='formtemplate', + name='close_at', + ), + migrations.AlterField( + model_name='formtemplate', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'), + ), + migrations.AlterField( + model_name='formtemplate', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Updated at'), + ), + ] diff --git a/recruitment/migrations/0016_remove_formtemplate_max_applications_and_more.py b/recruitment/migrations/0016_remove_formtemplate_max_applications_and_more.py new file mode 100644 index 0000000..e660370 --- /dev/null +++ b/recruitment/migrations/0016_remove_formtemplate_max_applications_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.6 on 2025-10-15 10:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0015_remove_formtemplate_close_at_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='formtemplate', + name='max_applications', + ), + migrations.AddField( + model_name='jobposting', + name='max_applications', + field=models.PositiveIntegerField(default=1000, help_text='Maximum number of applications allowed'), + ), + ] diff --git a/recruitment/migrations/0017_remove_interviewschedule_breaks_and_more.py b/recruitment/migrations/0017_remove_interviewschedule_breaks_and_more.py new file mode 100644 index 0000000..4961d4d --- /dev/null +++ b/recruitment/migrations/0017_remove_interviewschedule_breaks_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.6 on 2025-10-15 15:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0016_remove_formtemplate_max_applications_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='interviewschedule', + name='breaks', + ), + migrations.AddField( + model_name='interviewschedule', + name='break_end', + field=models.TimeField(blank=True, null=True, verbose_name='Break End Time'), + ), + migrations.AddField( + model_name='interviewschedule', + name='break_start', + field=models.TimeField(blank=True, null=True, verbose_name='Break Start Time'), + ), + ] diff --git a/recruitment/migrations/0018_rename_break_end_interviewschedule_break_end_time_and_more.py b/recruitment/migrations/0018_rename_break_end_interviewschedule_break_end_time_and_more.py new file mode 100644 index 0000000..16b2ed2 --- /dev/null +++ b/recruitment/migrations/0018_rename_break_end_interviewschedule_break_end_time_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.6 on 2025-10-15 15:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0017_remove_interviewschedule_breaks_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='interviewschedule', + old_name='break_end', + new_name='break_end_time', + ), + migrations.RenameField( + model_name='interviewschedule', + old_name='break_start', + new_name='break_start_time', + ), + ] diff --git a/recruitment/migrations/0019_alter_interviewschedule_candidates.py b/recruitment/migrations/0019_alter_interviewschedule_candidates.py new file mode 100644 index 0000000..d10d068 --- /dev/null +++ b/recruitment/migrations/0019_alter_interviewschedule_candidates.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2025-10-15 16:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0018_rename_break_end_interviewschedule_break_end_time_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='interviewschedule', + name='candidates', + field=models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate'), + ), + ] diff --git a/recruitment/migrations/0020_alter_interviewschedule_created_at.py b/recruitment/migrations/0020_alter_interviewschedule_created_at.py new file mode 100644 index 0000000..3824c35 --- /dev/null +++ b/recruitment/migrations/0020_alter_interviewschedule_created_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2025-10-15 16:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0019_alter_interviewschedule_candidates'), + ] + + operations = [ + migrations.AlterField( + model_name='interviewschedule', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'), + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index be2c445..0b6bb54 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -1,21 +1,15 @@ from django.db import models +from django.urls import reverse from django.utils import timezone -from .validators import validate_hash_tags, validate_image_size +from django.db.models import JSONField from django.contrib.auth.models import User from django.core.validators import URLValidator +from django_countries.fields import CountryField +from django.core.exceptions import ValidationError +from django_ckeditor_5.fields import CKEditor5Field from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import RandomCharField -from django.core.exceptions import ValidationError -from django_countries.fields import CountryField -from django.urls import reverse -# from ckeditor.fields import RichTextField -from django_ckeditor_5.fields import CKEditor5Field - - - -class Profile(models.Model): - profile_image = models.ImageField(null=True, blank=True, upload_to="profile_pic/") - user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") +from .validators import validate_hash_tags, validate_image_size class Base(models.Model): @@ -28,24 +22,9 @@ class Base(models.Model): class Meta: abstract = True - -# # Create your models here. -# class Job(Base): -# title = models.CharField(max_length=255, verbose_name=_('Title')) -# description_en = models.TextField(verbose_name=_('Description English')) -# description_ar = models.TextField(verbose_name=_('Description Arabic')) -# is_published = models.BooleanField(default=False, verbose_name=_('Published')) -# posted_to_linkedin = models.BooleanField(default=False, verbose_name=_('Posted to LinkedIn')) -# created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at')) -# updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated at')) - -# class Meta: -# verbose_name = _('Job') -# verbose_name_plural = _('Jobs') - -# def __str__(self): -# return self.title - +class Profile(models.Model): + profile_image = models.ImageField(null=True, blank=True, upload_to="profile_pic/") + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") class JobPosting(Base): # Basic Job Information @@ -164,7 +143,9 @@ class JobPosting(Base): blank=True, help_text="The system or channel from which this job posting originated or was first published.", ) - + max_applications = models.PositiveIntegerField( + default=1000, help_text="Maximum number of applications allowed" + ) hiring_agency = models.ManyToManyField( "HiringAgency", blank=True, @@ -246,6 +227,18 @@ class JobPosting(Base): "form_wizard", kwargs={"slug": self.form_template.slug} ) self.save() + @property + def current_applications_count(self): + """Returns the current number of candidates associated with this job.""" + return self.candidates.count() + + @property + def is_application_limit_reached(self): + """Checks if the current application count meets or exceeds the max limit.""" + if self.max_applications == 0: + return True + + return self.current_applications_count >= self.max_applications class JobPostingImage(models.Model): @@ -375,43 +368,12 @@ class Candidate(Base): return self.resume.size return 0 - # def clean(self): - # """Validate stage transitions""" - # # Only validate if this is an existing record (not being created) - # if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage: - # old_stage = self.__class__.objects.get(pk=self.pk).stage - # allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, []) - - # if self.stage not in allowed_next_stages: - # raise ValidationError( - # { - # "stage": f'Cannot transition from "{old_stage}" to "{self.stage}". ' - # f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}" - # } - # ) - - # # Validate that the stage is a valid choice - # if self.stage not in [choice[0] for choice in self.Stage.choices]: - # raise ValidationError( - # { - # "stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}" - # } - # ) def save(self, *args, **kwargs): """Override save to ensure validation is called""" self.clean() # Call validation before saving super().save(*args, **kwargs) - # def can_transition_to(self, new_stage): - # """Check if a stage transition is allowed""" - # if not self.pk: # New record - can be in Applied stage - # return new_stage == "Applied" - - # old_stage = self.__class__.objects.get(pk=self.pk).stage - # allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, []) - # return new_stage in allowed_next_stages - def get_available_stages(self): """Get list of stages this candidate can transition to""" if not self.pk: # New record @@ -489,6 +451,7 @@ class ZoomMeeting(Base): SCHEDULED = "scheduled", _("Scheduled") STARTED = "started", _("Started") ENDED = "ended", _("Ended") + CANCELLED = "cancelled",_("Cancelled") # Basic meeting details topic = models.CharField(max_length=255, verbose_name=_("Topic")) meeting_id = models.CharField( @@ -989,7 +952,7 @@ class InterviewSchedule(Base): job = models.ForeignKey( JobPosting, on_delete=models.CASCADE, related_name="interview_schedules" ) - candidates = models.ManyToManyField(Candidate, related_name="interview_schedules") + candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True,null=True) start_date = models.DateField(verbose_name=_("Start Date")) end_date = models.DateField(verbose_name=_("End Date")) working_days = models.JSONField( @@ -998,7 +961,8 @@ class InterviewSchedule(Base): start_time = models.TimeField(verbose_name=_("Start Time")) end_time = models.TimeField(verbose_name=_("End Time")) - breaks = models.JSONField(default=list, blank=True, verbose_name=_('Break Times')) + break_start_time = models.TimeField(verbose_name=_("Break Start Time"),null=True,blank=True) + break_end_time = models.TimeField(verbose_name=_("Break End Time"),null=True,blank=True) interview_duration = models.PositiveIntegerField( verbose_name=_("Interview Duration (minutes)") @@ -1007,7 +971,6 @@ class InterviewSchedule(Base): verbose_name=_("Buffer Time (minutes)"), default=0 ) created_by = models.ForeignKey(User, on_delete=models.CASCADE) - created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return f"Interview Schedule for {self.job.title}" @@ -1030,6 +993,7 @@ class ScheduledInterview(Base): schedule = models.ForeignKey( InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True ) + interview_date = models.DateField(verbose_name=_("Interview Date")) interview_time = models.TimeField(verbose_name=_("Interview Time")) status = models.CharField( diff --git a/recruitment/tasks.py b/recruitment/tasks.py index abfa760..103fc4e 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -3,7 +3,10 @@ import json import logging import requests from PyPDF2 import PdfReader +from datetime import datetime +from .utils import create_zoom_meeting from recruitment.models import Candidate +from .models import ScheduledInterview, ZoomMeeting, Candidate, JobPosting, InterviewSchedule logger = logging.getLogger(__name__) @@ -153,3 +156,124 @@ def handle_reume_parsing_and_scoring(pk): except Exception as e: logger.error(f"Failed to score resume for candidate {instance.id}: {e}") + + +def create_interview_and_meeting( + candidate_id, + job_id, + schedule_id, + slot_date, + slot_time, + duration +): + """ + Synchronous task for a single interview slot, dispatched by django-q. + """ + try: + candidate = Candidate.objects.get(pk=candidate_id) + job = JobPosting.objects.get(pk=job_id) + schedule = InterviewSchedule.objects.get(pk=schedule_id) + + interview_datetime = datetime.combine(slot_date, slot_time) + meeting_topic = f"Interview for {job.title} - {candidate.name}" + + # 1. External API Call (Slow) + result = create_zoom_meeting(meeting_topic, interview_datetime, duration) + + if result["status"] == "success": + # 2. Database Writes (Slow) + zoom_meeting = ZoomMeeting.objects.create( + topic=meeting_topic, + start_time=interview_datetime, + duration=duration, + meeting_id=result["meeting_details"]["meeting_id"], + join_url=result["meeting_details"]["join_url"], + zoom_gateway_response=result["zoom_gateway_response"], + ) + ScheduledInterview.objects.create( + candidate=candidate, + job=job, + zoom_meeting=zoom_meeting, + schedule=schedule, + 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 {candidate.name}") + return True # Task succeeded + else: + # Handle Zoom API failure (e.g., log it or notify administrator) + logger.error(f"Zoom API failed for {candidate.name}: {result['message']}") + return False # Task failed + + except Exception as e: + # Catch any unexpected errors during database lookups or processing + logger.error(f"Critical error scheduling interview: {e}") + return False # Task failed + + +def handle_zoom_webhook_event(payload): + """ + Background task to process a Zoom webhook event and update the local ZoomMeeting status. + It handles: created, updated, started, ended, and deleted events. + """ + event_type = payload.get('event') + object_data = payload['payload']['object'] + + # Zoom often uses a long 'id' for the scheduled meeting and sometimes a 'uuid'. + # We rely on the unique 'id' that maps to your ZoomMeeting.meeting_id field. + meeting_id_zoom = str(object_data.get('id')) + print(meeting_id_zoom) + if not meeting_id_zoom: + logger.warning(f"Webhook received without a valid Meeting ID: {event_type}") + return False + + try: + # Use filter().first() to avoid exceptions if the meeting doesn't exist yet, + # and to simplify the logic flow. + meeting_instance = ZoomMeeting.objects.filter(meeting_id=meeting_id_zoom).first() + print(meeting_instance) + # --- 1. Creation and Update Events --- + if event_type == 'meeting.updated': + if meeting_instance: + # Update key fields from the webhook payload + meeting_instance.topic = object_data.get('topic', meeting_instance.topic) + + # Check for and update status and time details + # if event_type == 'meeting.created': + # meeting_instance.status = 'scheduled' + # elif event_type == 'meeting.updated': + # Only update time fields if they are in the payload + print(object_data) + meeting_instance.start_time = object_data.get('start_time', meeting_instance.start_time) + meeting_instance.duration = object_data.get('duration', meeting_instance.duration) + meeting_instance.timezone = object_data.get('timezone', meeting_instance.timezone) + + # Also update join_url, password, etc., if needed based on the payload structure + meeting_instance.status = 'scheduled' + + meeting_instance.save(update_fields=['topic', 'start_time', 'duration', 'timezone', 'status']) + + # --- 2. Status Change Events (Start/End) --- + elif event_type == 'meeting.started': + if meeting_instance: + meeting_instance.status = 'started' + meeting_instance.save(update_fields=['status']) + + elif event_type == 'meeting.ended': + if meeting_instance: + meeting_instance.status = 'ended' + meeting_instance.save(update_fields=['status']) + + # --- 3. Deletion Event (User Action) --- + elif event_type == 'meeting.deleted': + if meeting_instance: + # Mark as cancelled/deleted instead of physically deleting for audit trail + meeting_instance.status = 'cancelled' + meeting_instance.save(update_fields=['status']) + + return True + + except Exception as e: + logger.error(f"Failed to process Zoom webhook for {event_type} (ID: {meeting_id_zoom}): {e}", exc_info=True) + return False \ No newline at end of file diff --git a/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc b/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc index 9436ae7db28a18169b8629dc071206f69fd81aa2..0d101863b37395bdb6d1425446a8b93c31a54c85 100644 GIT binary patch delta 423 zcmaDR{zQuJGcPX}0}w>df1h!cYa*Wnt?nm_iwX7{TgLh)^bd22&=8kP3q)Q&k98P-;n0 zW@=e#u|i2kszOd?afw2HnnH47UP@+4Vo9okr%SOyaY=qrYKlT;9#~CrYH@L9ex5>F zevv|=LUBQAa%Nh6X0k$7ev(3QPHDOx*Ddkldw6?1GR{gZBe&p?>R5>j~8hoGx>#Utm!$;s&bn)8w7}hAWWKbh0J4ESEJ<1Qbri n(5pVrA6+z{kME)h^#C|B;D}QR6c+1Ct<_1=bD#`^|K$ delta 75 zcmaDN^-P@aGcPX}0}$9Wzs{J-Igw9-@!Ul9^DOD?njD+u7~gX;`)Tq__T>qjyqHIl b%LJ&95r~WHCLiOe;rhtL%BWq$4HN(XDKir# diff --git a/recruitment/templatetags/form_filters.py b/recruitment/templatetags/form_filters.py index 33d5293..0572851 100644 --- a/recruitment/templatetags/form_filters.py +++ b/recruitment/templatetags/form_filters.py @@ -72,3 +72,12 @@ def to_list(data): Usage: {% to_list "item1,item2,item3" as list %} """ return data.split(",") if data else [] + +@register.filter +def get_schedule_candidate_ids(session, slug): + """ + Retrieves the list of candidate IDs stored in the session for a specific job slug. + """ + session_key = f"schedule_candidate_ids_{slug}" + # Returns the list of IDs (or an empty list if not found) + return session.get(session_key, []) \ No newline at end of file diff --git a/recruitment/urls.py b/recruitment/urls.py index e48911c..c20dd58 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path('jobs/linkedin/callback/', views.linkedin_callback, name='linkedin_callback'), path('jobs//schedule-interviews/', views.schedule_interviews_view, name='schedule_interviews'), + path('jobs//confirm-schedule-interviews/', views.confirm_schedule_interviews_view, name='confirm_schedule_interviews_view'), # Candidate URLs path('candidates/', views_frontend.CandidateListView.as_view(), name='candidate_list'), path('candidates/create/', views_frontend.CandidateCreateView.as_view(), name='candidate_create'), @@ -106,5 +107,5 @@ urlpatterns = [ path('jobs//candidates//delete_meeting_for_candidate//', views.delete_meeting_for_candidate, name='delete_meeting_for_candidate'), # users urls - path('user/',views.user_detail,name='user_detail') + path('user/',views.user_detail,name='user_detail'), ] diff --git a/recruitment/utils.py b/recruitment/utils.py index b7b19a6..4b24c1a 100644 --- a/recruitment/utils.py +++ b/recruitment/utils.py @@ -416,13 +416,12 @@ def schedule_interviews(schedule): Returns the number of interviews successfully scheduled. """ candidates = list(schedule.candidates.all()) - print(candidates) if not candidates: return 0 # Calculate available time slots available_slots = get_available_time_slots(schedule) - print(available_slots) + if len(available_slots) < len(candidates): raise ValueError(f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}") diff --git a/recruitment/views.py b/recruitment/views.py index 58b4be2..bc6899b 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -59,6 +59,8 @@ from datastar_py.django import ( ServerSentEventGenerator as SSE, read_signals, ) +from django.db import transaction +from django_q.tasks import async_task logger = logging.getLogger(__name__) @@ -91,7 +93,6 @@ class ZoomMeetingCreateView(CreateView): duration = instance.duration result = create_zoom_meeting(topic, start_time, duration) - print(result) if result["status"] == "success": instance.meeting_id = result["meeting_details"]["meeting_id"] instance.join_url = result["meeting_details"]["join_url"] @@ -119,18 +120,44 @@ class ZoomMeetingListView(ListView): def get_queryset(self): queryset = super().get_queryset().order_by("-start_time") - # Handle search - search_query = self.request.GET.get("search", "") + # Prefetch related interview data efficiently + from django.db.models import Prefetch + queryset = queryset.prefetch_related( + Prefetch( + 'interview', # related_name from ZoomMeeting to ScheduledInterview + queryset=ScheduledInterview.objects.select_related('candidate', 'job'), + to_attr='interview_details' # Changed to not start with underscore + ) + ) + + # Handle search by topic or meeting_id + search_query = self.request.GET.get("q", "") # Renamed from 'search' to 'q' for consistency if search_query: queryset = queryset.filter( Q(topic__icontains=search_query) | Q(meeting_id__icontains=search_query) ) + # Handle filter by status + status_filter = self.request.GET.get("status", "") + if status_filter: + queryset = queryset.filter(status=status_filter) + + # Handle search by candidate name + candidate_name = self.request.GET.get("candidate_name", "") + if candidate_name: + # Filter based on the name of the candidate associated with the meeting's interview + queryset = queryset.filter( + Q(interview__candidate__first_name__icontains=candidate_name) | + Q(interview__candidate__last_name__icontains=candidate_name) + ) + return queryset def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["search_query"] = self.request.GET.get("search", "") + 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", "") return context @@ -922,27 +949,18 @@ def form_wizard_view(request, template_id): @require_POST def submit_form(request, template_id): """Handle form submission""" - print(f"Request method: {request}") - print(f"CSRF token in POST: {'csrfmiddlewaretoken' in request.POST}") - print(f"CSRF token value: {request.POST.get('csrfmiddlewaretoken', 'NOT FOUND')}") - print(f"POST data: {request.POST}") - print(f"FILES data: {request.FILES}") + template = get_object_or_404(FormTemplate, id=template_id) if request.method == "POST": try: - template = get_object_or_404(FormTemplate, id=template_id) + with transaction.atomic(): + job_posting = JobPosting.objects.select_for_update().get(form_template=template) - # # Create form submission - # print({key: value for key, value in request.POST.items()}) - # first_name = next((value for key, value in request.POST.items() if key == 'First Name'), None) - # last_name = next((value for key, value in request.POST.items() if key == 'Last Name'), None) - # email = next((value for key, value in request.POST.items() if key == 'Email Address'), None) - # phone = next((value for key, value in request.POST.items() if key == 'Phone Number'), None) - # address = next((value for key, value in request.POST.items() if key == 'Address'), None) - # resume = next((value for key, value in request.POST.items() if key == 'Resume Upload'), None) - # print(first_name, last_name, email, phone, address, resume) - # create candidate - - submission = FormSubmission.objects.create(template=template) + current_count = job_posting.candidates.count() + if current_count >= job_posting.max_applications: + return JsonResponse( + {"success": False, "message": "Application limit reached for this job."} + ) + submission = FormSubmission.objects.create(template=template) # Process field responses for field_id, value in request.POST.items(): if field_id.startswith("field_"): @@ -1099,237 +1117,577 @@ def form_submission_details(request, template_id, slug): }, ) -def schedule_interviews_view(request, slug): - job = get_object_or_404(JobPosting, slug=slug) - if request.method == "POST": - form = InterviewScheduleForm(slug, request.POST) - break_formset = BreakTimeFormSet(request.POST) +def _handle_get_request(request, slug, job): + """ + Handles GET requests, setting up forms and restoring candidate selections + from the session for persistence. + """ + SESSION_KEY = f"schedule_candidate_ids_{slug}" + form = InterviewScheduleForm(slug=slug) + # break_formset = BreakTimeFormSet(prefix='breaktime') - # Check if this is a confirmation request - if "confirm_schedule" in request.POST: - # Get the schedule data from session - schedule_data = request.session.get("interview_schedule_data") - if not schedule_data: - messages.error(request, "Session expired. Please try again.") - return redirect("schedule_interviews", slug=slug) + selected_ids = [] - # Create the interview schedule - schedule = InterviewSchedule.objects.create( - job=job, - created_by=request.user, - start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), - end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), - working_days=schedule_data["working_days"], - start_time=time.fromisoformat(schedule_data["start_time"]), - end_time=time.fromisoformat(schedule_data["end_time"]), - interview_duration=schedule_data["interview_duration"], - buffer_time=schedule_data["buffer_time"], - breaks=schedule_data["breaks"], - ) + # 1. Capture IDs from HTMX request and store in session (when first clicked) + if "HX-Request" in request.headers: + candidate_ids = request.GET.getlist("candidate_ids") + if candidate_ids: + request.session[SESSION_KEY] = candidate_ids + selected_ids = candidate_ids - # Add candidates to the schedule - candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"]) - schedule.candidates.set(candidates) + # 2. Restore IDs from session (on refresh or navigation) + if not selected_ids: + selected_ids = request.session.get(SESSION_KEY, []) - # Create temporary break time objects for slot calculation - temp_breaks = [] - for break_data in schedule_data["breaks"]: - temp_breaks.append( - BreakTime( - start_time=datetime.strptime( - break_data["start_time"], "%H:%M:%S" - ).time(), - end_time=datetime.strptime( - break_data["end_time"], "%H:%M:%S" - ).time(), - ) - ) - - # Get available slots - available_slots = get_available_time_slots(schedule) - - # Create scheduled interviews - scheduled_count = 0 - for i, candidate in enumerate(candidates): - if i < len(available_slots): - slot = available_slots[i] - interview_datetime = datetime.combine(slot['date'], slot['time']) - - # Create Zoom meeting - meeting_topic = f"Interview for {job.title} - {candidate.name}" - - start_time = interview_datetime - - # zoom_meeting = create_zoom_meeting( - # topic=meeting_topic, - # start_time=start_time, - # duration=schedule.interview_duration - # ) - - result = create_zoom_meeting(meeting_topic, start_time, schedule.interview_duration) - if result["status"] == "success": - zoom_meeting = ZoomMeeting.objects.create( - topic=meeting_topic, - start_time=interview_datetime, - duration=schedule.interview_duration, - meeting_id=result["meeting_details"]["meeting_id"], - join_url=result["meeting_details"]["join_url"], - zoom_gateway_response=result["zoom_gateway_response"], - ) - # Create scheduled interview record - ScheduledInterview.objects.create( - candidate=candidate, - job=job, - zoom_meeting=zoom_meeting, - schedule=schedule, - interview_date=slot['date'], - interview_time=slot['time'] - ) - else: - messages.error(request, result["message"]) - schedule.delete() - return redirect("candidate_interview_view", slug=slug) - - # Send email to candidate - # try: - # send_interview_email(scheduled_interview) - # except Exception as e: - # messages.warning( - # request, - # f"Interview scheduled for {candidate.name}, but failed to send email: {str(e)}" - # ) - - scheduled_count += 1 - - messages.success( - request, f"Successfully scheduled {scheduled_count} interviews." - ) - - # Clear the session data - if "interview_schedule_data" in request.session: - del request.session["interview_schedule_data"] - - return redirect("job_detail", slug=slug) - - # This is the initial form submission - if form.is_valid() and break_formset.is_valid(): - # Get the form data - candidates = form.cleaned_data["candidates"] - start_date = form.cleaned_data["start_date"] - end_date = form.cleaned_data["end_date"] - working_days = form.cleaned_data["working_days"] - start_time = form.cleaned_data["start_time"] - end_time = form.cleaned_data["end_time"] - interview_duration = form.cleaned_data["interview_duration"] - buffer_time = form.cleaned_data["buffer_time"] - - # Process break times - breaks = [] - for break_form in break_formset: - if break_form.cleaned_data and not break_form.cleaned_data.get( - "DELETE" - ): - breaks.append( - { - "start_time": break_form.cleaned_data[ - "start_time" - ].strftime("%H:%M:%S"), - "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"), - } - ) - - # Create a temporary schedule object (not saved to DB) - temp_schedule = InterviewSchedule( - job=job, - start_date=start_date, - end_date=end_date, - working_days=working_days, - start_time=start_time, - end_time=end_time, - interview_duration=interview_duration, - buffer_time=buffer_time, - breaks=breaks, - ) - - # Create temporary break time objects - temp_breaks = [] - for break_data in breaks: - temp_breaks.append( - BreakTime( - start_time=datetime.strptime( - break_data["start_time"], "%H:%M:%S" - ).time(), - end_time=datetime.strptime( - break_data["end_time"], "%H:%M:%S" - ).time(), - ) - ) - - # Get available slots - available_slots = get_available_time_slots(temp_schedule) - - if len(available_slots) < len(candidates): - messages.error( - request, - f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}", - ) - return render( - request, - "interviews/schedule_interviews.html", - {"form": form, "break_formset": break_formset, "job": job}, - ) - - # Create a preview schedule - preview_schedule = [] - for i, candidate in enumerate(candidates): - slot = available_slots[i] - preview_schedule.append( - {"candidate": candidate, "date": slot["date"], "time": slot["time"]} - ) - - # Save the form data to session for later use - schedule_data = { - "start_date": start_date.isoformat(), - "end_date": end_date.isoformat(), - "working_days": working_days, - "start_time": start_time.isoformat(), - "end_time": end_time.isoformat(), - "interview_duration": interview_duration, - "buffer_time": buffer_time, - "candidate_ids": [c.id for c in candidates], - "breaks": breaks, - } - request.session["interview_schedule_data"] = schedule_data - - # Render the preview page - return render( - request, - "interviews/preview_schedule.html", - { - "job": job, - "schedule": preview_schedule, - "start_date": start_date, - "end_date": end_date, - "working_days": working_days, - "start_time": start_time, - "end_time": end_time, - "breaks": breaks, - "interview_duration": interview_duration, - "buffer_time": buffer_time, - }, - ) - else: - form = InterviewScheduleForm(slug=slug) - break_formset = BreakTimeFormSet() - if "HX-Request" in request.headers: - candidate_ids = request.GET.getlist("candidate_ids") - form.initial["candidates"] = Candidate.objects.filter(pk__in = candidate_ids) + # 3. Use the list of IDs to initialize the form + if selected_ids: + candidates_to_load = Candidate.objects.filter(pk__in=selected_ids) + form.initial["candidates"] = candidates_to_load return render( request, "interviews/schedule_interviews.html", - {"form": form, "break_formset": break_formset, "job": job}, + {"form": form, "job": job}, ) + + +def _handle_preview_submission(request, slug, job): + """ + Handles the initial POST request (Preview Schedule). + Validates forms, calculates slots, saves data to session, and renders preview. + """ + SESSION_DATA_KEY = "interview_schedule_data" + form = InterviewScheduleForm(slug, request.POST) + # break_formset = BreakTimeFormSet(request.POST,prefix='breaktime') + + if form.is_valid(): + # Get the form data + candidates = form.cleaned_data["candidates"] + start_date = form.cleaned_data["start_date"] + end_date = form.cleaned_data["end_date"] + working_days = form.cleaned_data["working_days"] + start_time = form.cleaned_data["start_time"] + end_time = form.cleaned_data["end_time"] + interview_duration = form.cleaned_data["interview_duration"] + buffer_time = form.cleaned_data["buffer_time"] + break_start_time = form.cleaned_data["break_start_time"] + break_end_time = form.cleaned_data["break_end_time"] + + # Process break times + # breaks = [] + # for break_form in break_formset: + # print(break_form.cleaned_data) + # if break_form.cleaned_data and not break_form.cleaned_data.get("DELETE"): + # breaks.append( + # { + # "start_time": break_form.cleaned_data["start_time"].strftime("%H:%M:%S"), + # "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"), + # } + # ) + + # Create a temporary schedule object (not saved to DB) + temp_schedule = InterviewSchedule( + job=job, + start_date=start_date, + end_date=end_date, + working_days=working_days, + start_time=start_time, + end_time=end_time, + interview_duration=interview_duration, + buffer_time=buffer_time, + break_start_time=break_start_time, + break_end_time=break_end_time + ) + + # Get available slots (temp_breaks logic moved into get_available_time_slots if needed) + available_slots = get_available_time_slots(temp_schedule) + + if len(available_slots) < len(candidates): + messages.error( + request, + f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}", + ) + return render( + request, + "interviews/schedule_interviews.html", + {"form": form, "job": job}, + ) + + # Create a preview schedule + preview_schedule = [] + for i, candidate in enumerate(candidates): + slot = available_slots[i] + preview_schedule.append( + {"candidate": candidate, "date": slot["date"], "time": slot["time"]} + ) + + # Save the form data to session for later use + schedule_data = { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "working_days": working_days, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "interview_duration": interview_duration, + "buffer_time": buffer_time, + "break_start_time": break_start_time.isoformat(), + "break_end_time": break_end_time.isoformat(), + "candidate_ids": [c.id for c in candidates], + } + request.session[SESSION_DATA_KEY] = schedule_data + + # Render the preview page + return render( + request, + "interviews/preview_schedule.html", + { + "job": job, + "schedule": preview_schedule, + "start_date": start_date, + "end_date": end_date, + "working_days": working_days, + "start_time": start_time, + "end_time": end_time, + "break_start_time": break_start_time, + "break_end_time": break_end_time, + "interview_duration": interview_duration, + "buffer_time": buffer_time, + }, + ) + else: + # Re-render the form if validation fails + return render( + request, + "interviews/schedule_interviews.html", + {"form": form, "job": job}, + ) + + +def _handle_confirm_schedule(request, slug, job): + """ + Handles the final POST request (Confirm Schedule). + Creates the main schedule record and queues individual interviews asynchronously. + """ + + + SESSION_DATA_KEY = "interview_schedule_data" + SESSION_ID_KEY = f"schedule_candidate_ids_{slug}" + + # 1. Get schedule data from session + schedule_data = request.session.get(SESSION_DATA_KEY) + + if not schedule_data: + messages.error(request, "Session expired. Please try again.") + return redirect("schedule_interviews", slug=slug) + + # 2. Create the Interview Schedule (Parent Record) + # NOTE: You MUST convert the time strings back to Python time objects here. + try: + schedule = InterviewSchedule.objects.create( + job=job, + created_by=request.user, + start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), + end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), + working_days=schedule_data["working_days"], + start_time=time.fromisoformat(schedule_data["start_time"]), + end_time=time.fromisoformat(schedule_data["end_time"]), + interview_duration=schedule_data["interview_duration"], + buffer_time=schedule_data["buffer_time"], + + # Use the simple break times saved in the session + # If the value is None (because required=False in form), handle it gracefully + break_start_time=schedule_data.get("break_start_time"), + break_end_time=schedule_data.get("break_end_time"), + ) + except Exception as e: + # Handle database creation error + messages.error(request, f"Error creating schedule: {e}") + if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] + return redirect("schedule_interviews", slug=slug) + + + # 3. Setup candidates and get slots + candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"]) + schedule.candidates.set(candidates) + available_slots = get_available_time_slots(schedule) # This should still be synchronous and fast + + # 4. Queue scheduled interviews asynchronously (FAST RESPONSE) + queued_count = 0 + for i, candidate in enumerate(candidates): + if i < len(available_slots): + slot = available_slots[i] + + # Dispatch the individual creation task to the background queue + async_task( + "recruitment.tasks.create_interview_and_meeting", + candidate.pk, + job.pk, + schedule.pk, + slot['date'], + slot['time'], + schedule.interview_duration, + ) + queued_count += 1 + + # 5. Success and Cleanup (IMMEDIATE RESPONSE) + messages.success( + request, + f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!" + ) + + # Clear both 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=slug) +# def _handle_confirm_schedule(request, slug, job): +# """ +# Handles the final POST request (Confirm Schedule). +# Creates all database records (Schedule, Meetings, Interviews) and clears sessions. +# """ +# SESSION_DATA_KEY = "interview_schedule_data" +# SESSION_ID_KEY = f"schedule_candidate_ids_{slug}" + +# # 1. Get schedule data from session +# schedule_data = request.session.get(SESSION_DATA_KEY) + +# if not schedule_data: +# messages.error(request, "Session expired. Please try again.") +# return redirect("schedule_interviews", slug=slug) +# # 2. Create the Interview Schedule (Your existing logic) +# schedule = InterviewSchedule.objects.create( +# job=job, +# created_by=request.user, +# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), +# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), +# working_days=schedule_data["working_days"], +# start_time=time.fromisoformat(schedule_data["start_time"]), +# end_time=time.fromisoformat(schedule_data["end_time"]), +# interview_duration=schedule_data["interview_duration"], +# buffer_time=schedule_data["buffer_time"], +# break_start_time=schedule_data["break_start_time"], +# break_end_time=schedule_data["break_end_time"], +# ) + +# # 3. Setup candidates and get slots +# candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"]) +# schedule.candidates.set(candidates) +# available_slots = get_available_time_slots(schedule) + +# # 4. Create scheduled interviews +# scheduled_count = 0 +# for i, candidate in enumerate(candidates): +# if i < len(available_slots): +# slot = available_slots[i] +# interview_datetime = datetime.combine(slot['date'], slot['time']) + +# meeting_topic = f"Interview for {job.title} - {candidate.name}" +# result = create_zoom_meeting(meeting_topic, interview_datetime, schedule.interview_duration) + +# if result["status"] == "success": +# zoom_meeting = ZoomMeeting.objects.create( +# topic=meeting_topic, +# start_time=interview_datetime, +# duration=schedule.interview_duration, +# meeting_id=result["meeting_details"]["meeting_id"], +# join_url=result["meeting_details"]["join_url"], +# zoom_gateway_response=result["zoom_gateway_response"], +# ) +# ScheduledInterview.objects.create( +# candidate=candidate, +# job=job, +# zoom_meeting=zoom_meeting, +# schedule=schedule, +# interview_date=slot['date'], +# interview_time=slot['time'] +# ) +# scheduled_count += 1 +# else: +# messages.error(request, result["message"]) +# schedule.delete() +# # Clear candidate IDs session key only on error return +# if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] +# return redirect("candidate_interview_view", slug=slug) + +# # 5. Success and Cleanup +# messages.success( +# request, f"Successfully scheduled {scheduled_count} interviews." +# ) + +# # Clear both 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=slug) + + +# --- Main View Function --- + +def schedule_interviews_view(request, slug): + job = get_object_or_404(JobPosting, slug=slug) + if request.method == "POST": + # return _handle_confirm_schedule(request, slug, job) + return _handle_preview_submission(request, slug, job) + else: + return _handle_get_request(request, slug, job) +def confirm_schedule_interviews_view(request, slug): + job = get_object_or_404(JobPosting, slug=slug) + if request.method == "POST": + return _handle_confirm_schedule(request, slug, job) +# def schedule_interviews_view(request, slug): +# job = get_object_or_404(JobPosting, slug=slug) +# SESSION_KEY = f"schedule_candidate_ids_{slug}" + +# if request.method == "POST": +# form = InterviewScheduleForm(slug, request.POST) +# break_formset = BreakTimeFormSet(request.POST) + +# # Check if this is a confirmation request +# if "confirm_schedule" in request.POST: +# # Get the schedule data from session +# schedule_data = request.session.get("interview_schedule_data") +# if not schedule_data: +# messages.error(request, "Session expired. Please try again.") +# return redirect("schedule_interviews", slug=slug) + +# # Create the interview schedule +# schedule = InterviewSchedule.objects.create( +# job=job, +# created_by=request.user, +# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), +# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), +# working_days=schedule_data["working_days"], +# start_time=time.fromisoformat(schedule_data["start_time"]), +# end_time=time.fromisoformat(schedule_data["end_time"]), +# interview_duration=schedule_data["interview_duration"], +# buffer_time=schedule_data["buffer_time"], +# breaks=schedule_data["breaks"], +# ) + +# # Add candidates to the schedule +# candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"]) +# schedule.candidates.set(candidates) + +# # Create temporary break time objects for slot calculation +# temp_breaks = [] +# for break_data in schedule_data["breaks"]: +# temp_breaks.append( +# BreakTime( +# start_time=datetime.strptime( +# break_data["start_time"], "%H:%M:%S" +# ).time(), +# end_time=datetime.strptime( +# break_data["end_time"], "%H:%M:%S" +# ).time(), +# ) +# ) + +# # Get available slots +# available_slots = get_available_time_slots(schedule) + +# # Create scheduled interviews +# scheduled_count = 0 +# for i, candidate in enumerate(candidates): +# if i < len(available_slots): +# slot = available_slots[i] +# interview_datetime = datetime.combine(slot['date'], slot['time']) + +# # Create Zoom meeting +# meeting_topic = f"Interview for {job.title} - {candidate.name}" + +# start_time = interview_datetime + +# # zoom_meeting = create_zoom_meeting( +# # topic=meeting_topic, +# # start_time=start_time, +# # duration=schedule.interview_duration +# # ) + +# result = create_zoom_meeting(meeting_topic, start_time, schedule.interview_duration) +# if result["status"] == "success": +# zoom_meeting = ZoomMeeting.objects.create( +# topic=meeting_topic, +# start_time=interview_datetime, +# duration=schedule.interview_duration, +# meeting_id=result["meeting_details"]["meeting_id"], +# join_url=result["meeting_details"]["join_url"], +# zoom_gateway_response=result["zoom_gateway_response"], +# ) +# # Create scheduled interview record +# ScheduledInterview.objects.create( +# candidate=candidate, +# job=job, +# zoom_meeting=zoom_meeting, +# schedule=schedule, +# interview_date=slot['date'], +# interview_time=slot['time'] +# ) + +# else: +# messages.error(request, result["message"]) +# schedule.delete() +# return redirect("candidate_interview_view", slug=slug) + +# # Send email to candidate +# # try: +# # send_interview_email(scheduled_interview) +# # except Exception as e: +# # messages.warning( +# # request, +# # f"Interview scheduled for {candidate.name}, but failed to send email: {str(e)}" +# # ) + +# scheduled_count += 1 + +# messages.success( +# request, f"Successfully scheduled {scheduled_count} interviews." +# ) + +# # Clear the session data +# if "interview_schedule_data" in request.session: +# del request.session["interview_schedule_data"] + +# return redirect("job_detail", slug=slug) + +# # This is the initial form submission +# if form.is_valid() and break_formset.is_valid(): +# # Get the form data +# candidates = form.cleaned_data["candidates"] +# start_date = form.cleaned_data["start_date"] +# end_date = form.cleaned_data["end_date"] +# working_days = form.cleaned_data["working_days"] +# start_time = form.cleaned_data["start_time"] +# end_time = form.cleaned_data["end_time"] +# interview_duration = form.cleaned_data["interview_duration"] +# buffer_time = form.cleaned_data["buffer_time"] + +# # Process break times +# breaks = [] +# for break_form in break_formset: +# if break_form.cleaned_data and not break_form.cleaned_data.get( +# "DELETE" +# ): +# breaks.append( +# { +# "start_time": break_form.cleaned_data[ +# "start_time" +# ].strftime("%H:%M:%S"), +# "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"), +# } +# ) + +# # Create a temporary schedule object (not saved to DB) +# temp_schedule = InterviewSchedule( +# job=job, +# start_date=start_date, +# end_date=end_date, +# working_days=working_days, +# start_time=start_time, +# end_time=end_time, +# interview_duration=interview_duration, +# buffer_time=buffer_time, +# breaks=breaks, +# ) + +# # Create temporary break time objects +# temp_breaks = [] +# for break_data in breaks: +# temp_breaks.append( +# BreakTime( +# start_time=datetime.strptime( +# break_data["start_time"], "%H:%M:%S" +# ).time(), +# end_time=datetime.strptime( +# break_data["end_time"], "%H:%M:%S" +# ).time(), +# ) +# ) + +# # Get available slots +# available_slots = get_available_time_slots(temp_schedule) + +# if len(available_slots) < len(candidates): +# messages.error( +# request, +# f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}", +# ) +# return render( +# request, +# "interviews/schedule_interviews.html", +# {"form": form, "break_formset": break_formset, "job": job}, +# ) + +# # Create a preview schedule +# preview_schedule = [] +# for i, candidate in enumerate(candidates): +# slot = available_slots[i] +# preview_schedule.append( +# {"candidate": candidate, "date": slot["date"], "time": slot["time"]} +# ) + +# # Save the form data to session for later use +# schedule_data = { +# "start_date": start_date.isoformat(), +# "end_date": end_date.isoformat(), +# "working_days": working_days, +# "start_time": start_time.isoformat(), +# "end_time": end_time.isoformat(), +# "interview_duration": interview_duration, +# "buffer_time": buffer_time, +# "candidate_ids": [c.id for c in candidates], +# "breaks": breaks, +# } +# request.session["interview_schedule_data"] = schedule_data + +# # Render the preview page +# return render( +# request, +# "interviews/preview_schedule.html", +# { +# "job": job, +# "schedule": preview_schedule, +# "start_date": start_date, +# "end_date": end_date, +# "working_days": working_days, +# "start_time": start_time, +# "end_time": end_time, +# "breaks": breaks, +# "interview_duration": interview_duration, +# "buffer_time": buffer_time, +# }, +# ) +# else: +# form = InterviewScheduleForm(slug=slug) +# break_formset = BreakTimeFormSet() + +# selected_ids = [] + +# # 1. Capture IDs from HTMX request and store in session (when first clicked from timeline) +# if "HX-Request" in request.headers: +# candidate_ids = request.GET.getlist("candidate_ids") +# if candidate_ids: +# request.session[SESSION_KEY] = candidate_ids +# selected_ids = candidate_ids + +# # 2. Restore IDs from session (on refresh or navigation) +# if not selected_ids: +# selected_ids = request.session.get(SESSION_KEY, []) + +# # 3. Use the list of IDs to initialize the form +# if selected_ids: +# # Load Candidate objects corresponding to the IDs +# candidates_to_load = Candidate.objects.filter(pk__in=selected_ids) +# # This line sets the selected values for {{ form.candidates }} +# form.initial["candidates"] = candidates_to_load + +# return render( +# request, +# "interviews/schedule_interviews.html", +# {"form": form, "break_formset": break_formset, "job": job}, +# ) + # def schedule_interviews_view(request, slug): # job = get_object_or_404(JobPosting, slug=slug) @@ -1487,7 +1845,7 @@ def candidate_screening_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) applied_count=job.candidates.filter(stage='Applied').count() exam_count=job.candidates.filter(stage='Exam').count() - interview_count=job.candidates.filter(stage='interview').count() + interview_count=job.candidates.filter(stage='Interview').count() offer_count=job.candidates.filter(stage='Offer').count() # Get all candidates for this job, ordered by match score (descending) candidates = job.candidates.filter(stage="Applied").order_by("-match_score") @@ -1635,8 +1993,8 @@ def candidate_screening_view(request, slug): 'applied_count':applied_count, 'exam_count':exam_count, 'interview_count':interview_count, - 'offer_count':offer_count - + 'offer_count':offer_count, + "current_stage" : "Applied" } return render(request, "recruitment/candidate_screening_view.html", context) @@ -1647,9 +2005,21 @@ def candidate_exam_view(request, slug): Manage candidate tiers and stage transitions """ job = get_object_or_404(JobPosting, slug=slug) + applied_count=job.candidates.filter(stage='Applied').count() + exam_count=job.candidates.filter(stage='Exam').count() + interview_count=job.candidates.filter(stage='Interview').count() + offer_count=job.candidates.filter(stage='Offer').count() candidates = job.candidates.filter(stage="Exam").order_by("-match_score") - - return render(request, "recruitment/candidate_exam_view.html", {"job": job, "candidates": candidates}) + context = { + "job": job, + "candidates": candidates, + 'applied_count':applied_count, + 'exam_count':exam_count, + 'interview_count':interview_count, + 'offer_count':offer_count, + 'current_stage' : "Exam" + } + return render(request, "recruitment/candidate_exam_view.html", context) def update_candidate_exam_status(request, slug): candidate = get_object_or_404(Candidate, slug=slug) @@ -1704,7 +2074,11 @@ def candidate_update_status(request, slug): def candidate_interview_view(request,slug): job = get_object_or_404(JobPosting,slug=slug) - context = {"job":job,"candidates":job.candidates.filter(stage="Interview").order_by("-match_score")} + applied_count=job.candidates.filter(stage='Applied').count() + exam_count=job.candidates.filter(stage='Exam').count() + interview_count=job.candidates.filter(stage='Interview').count() + offer_count=job.candidates.filter(stage='Offer').count() + context = {"job":job,"candidates":job.candidates.filter(stage="Interview").order_by("-match_score"),'applied_count':applied_count,'exam_count':exam_count,'interview_count':interview_count,'offer_count':offer_count,"current_stage":"Interview"} return render(request,"recruitment/candidate_interview_view.html",context) def reschedule_meeting_for_candidate(request,slug,candidate_id,meeting_id): @@ -2334,9 +2708,27 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): 'job': job, 'candidate': candidate }) - + def user_detail(requests,pk): user=get_object_or_404(User,pk=pk) return render(requests,'user/profile.html') + + +@csrf_exempt +def zoom_webhook_view(request): + print(request.headers) + print(settings.ZOOM_WEBHOOK_API_KEY) + # if api_key != settings.ZOOM_WEBHOOK_API_KEY: + # return HttpResponse(status=405) + if request.method == 'POST': + try: + payload = json.loads(request.body) + async_task("recruitment.tasks.handle_zoom_webhook_event", payload) + return HttpResponse(status=200) + + except Exception: + # Bad data or internal server error + return HttpResponse(status=400) + return HttpResponse(status=405) # Method Not Allowed \ No newline at end of file diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 34d3f55..51bf44f 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -134,13 +134,15 @@ class CandidateListView(LoginRequiredMixin, ListView): model = models.Candidate template_name = 'recruitment/candidate_list.html' context_object_name = 'candidates' - paginate_by = 10 + paginate_by = 100 def get_queryset(self): queryset = super().get_queryset() # Handle search search_query = self.request.GET.get('search', '') + job = self.request.GET.get('job', '') + stage = self.request.GET.get('stage', '') if search_query: queryset = queryset.filter( Q(first_name__icontains=search_query) | @@ -150,7 +152,10 @@ class CandidateListView(LoginRequiredMixin, ListView): Q(stage__icontains=search_query) | Q(job__title__icontains=search_query) ) - + if job: + queryset = queryset.filter(job__slug=job) + if stage: + queryset = queryset.filter(stage=stage) # Filter for non-staff users if not self.request.user.is_staff: return models.Candidate.objects.none() # Restrict for non-staff @@ -160,6 +165,9 @@ class CandidateListView(LoginRequiredMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['search_query'] = self.request.GET.get('search', '') + context['job_filter'] = self.request.GET.get('job', '') + context['stage_filter'] = self.request.GET.get('stage', '') + context['available_jobs'] = models.JobPosting.objects.all().order_by('created_at').distinct() return context diff --git a/templates/icons/video.html b/templates/icons/video.html new file mode 100644 index 0000000..b7640f6 --- /dev/null +++ b/templates/icons/video.html @@ -0,0 +1,3 @@ + + + diff --git a/templates/includes/paginator.html b/templates/includes/paginator.html new file mode 100644 index 0000000..3d60ba4 --- /dev/null +++ b/templates/includes/paginator.html @@ -0,0 +1,27 @@ +{% if is_paginated %} + + {% endif %} diff --git a/templates/interviews/preview_schedule.html b/templates/interviews/preview_schedule.html index 0cdae99..28fa4e0 100644 --- a/templates/interviews/preview_schedule.html +++ b/templates/interviews/preview_schedule.html @@ -1,18 +1,22 @@ - {% extends "base.html" %} {% load static %} {% block content %}
-

Interview Schedule Preview for {{ job.title }}

+
+

+ Interview Schedule Preview for {{ job.title }} +

+
-
+
-
Schedule Details
+
Schedule Details

Period: {{ start_date|date:"F j, Y" }} to {{ end_date|date:"F j, Y" }}

-

Working Days: +

+ Working Days: {% for day_id in working_days %} {% if day_id == 0 %}Monday{% endif %} {% if day_id == 1 %}Tuesday{% endif %} @@ -25,26 +29,32 @@ {% endfor %}

Working Hours: {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}

-
-
- {% if breaks %} -

Break Times:

-
    - {% for break in breaks %} -
  • {{ break.start_time|time:"g:i A" }} to {{ break.end_time|time:"g:i A" }}
  • - {% endfor %} -
- {% endif %}

Interview Duration: {{ interview_duration }} minutes

Buffer Time: {{ buffer_time }} minutes

+
+

Daily Break Times:

+ {% if breaks %} + +
+ {% for break in breaks %} + + + {{ break.start_time|time:"g:i A" }} — {{ break.end_time|time:"g:i A" }} + + {% endfor %} +
+ {% else %} +

No daily breaks scheduled.

+ {% endif %} +
-
+
-
Scheduled Interviews
+
Scheduled Interviews
@@ -75,7 +85,7 @@
-
+ {% csrf_token %} -
+
+
+ + {{ form.break_start_time }}
- {% endfor %} +
+ + {{ form.break_end_time }} +
+
- +
@@ -249,12 +229,13 @@ document.addEventListener('DOMContentLoaded', function() { const addBreakBtn = document.getElementById('add-break'); const breakTimesContainer = document.getElementById('break-times-container'); - // The ID is now guaranteed to be 'id_breaks-TOTAL_FORMS' thanks to the template fix - const totalFormsInput = document.getElementById('id_breaks-TOTAL_FORMS'); - // Safety check added, though the template fix should resolve the core issue + // *** FIX: Hardcode formset prefix for reliability (requires matching Python change) *** + const FORMSET_PREFIX = 'breaktime'; + const totalFormsInput = document.querySelector(`input[name="${FORMSET_PREFIX}-TOTAL_FORMS"]`); + if (!totalFormsInput) { - console.error("TOTAL_FORMS input not found. Cannot add break dynamically."); + console.error(`TOTAL_FORMS input with name ${FORMSET_PREFIX}-TOTAL_FORMS not found. Cannot add break dynamically. Please ensure formset prefix is set to '${FORMSET_PREFIX}' in the Python view and management_form is rendered.`); return; } @@ -262,20 +243,22 @@ document.addEventListener('DOMContentLoaded', function() { const formCount = parseInt(totalFormsInput.value); // Template for a new form, ensuring the correct classes are applied + // Use the hardcoded prefix in all new form fields const newFormHtml = `
- - + +
- - + +
- - - + + + + @@ -298,12 +281,17 @@ document.addEventListener('DOMContentLoaded', function() { if (form) { const deleteCheckbox = form.querySelector('input[name$="-DELETE"]'); if (deleteCheckbox) { + // Check the DELETE box and hide the form deleteCheckbox.checked = true; + form.style.display = 'none'; + } else { + // If it's a new form, remove it entirely + form.remove(); } - form.style.display = 'none'; } } }); }); + -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/jobs/edit_job.html b/templates/jobs/edit_job.html index a5abbed..cfade4d 100644 --- a/templates/jobs/edit_job.html +++ b/templates/jobs/edit_job.html @@ -73,7 +73,7 @@ border: 1px solid #ced4da; width: 100%; padding: 0.375rem 0.75rem; - box-sizing: border-box; + box-sizing: border-box; } /* ================================================= */ @@ -88,11 +88,11 @@ margin-right: -0.75rem !important; /* General cleanup to maintain look */ - box-sizing: border-box; + box-sizing: border-box; margin-bottom: 0 !important; border-radius: 0.5rem; } - + /* Set minimum heights for specific fields using sibling selector */ #id_description + .note-editor { min-height: 300px; } #id_qualifications + .note-editor { min-height: 200px; } @@ -103,7 +103,7 @@ {% endblock %} {% block content %} -
+

{# UPDATED TITLE FOR EDIT CONTEXT #} {% trans "Edit Job Posting" %} @@ -151,6 +151,20 @@ {% if form.workplace_type.errors %}
{{ form.workplace_type.errors }}
{% endif %}

+
+
+ + {{ form.application_deadline }} + {% if form.application_deadline.errors %}
{{ form.application_deadline.errors }}
{% endif %} +
+
+
+
+ + {{ form.max_applications }} + {% if form.max_applications.errors %}
{{ form.max_applications.errors }}
{% endif %} +
+
@@ -257,14 +271,6 @@ {% if form.location_country.errors %}
{{ form.location_country.errors }}
{% endif %}
- -
-
- - {{ form.application_deadline }} - {% if form.application_deadline.errors %}
{{ form.application_deadline.errors }}
{% endif %} -
-
@@ -272,13 +278,6 @@ {% if form.start_date.errors %}
{{ form.start_date.errors }}
{% endif %}
-
-
- - {{ form.status }} - {% if form.status.errors %}
{{ form.status.errors }}
{% endif %} -
-
diff --git a/templates/jobs/job_list.html b/templates/jobs/job_list.html index 4b366bc..9cb014c 100644 --- a/templates/jobs/job_list.html +++ b/templates/jobs/job_list.html @@ -11,8 +11,8 @@ --kaauh-teal-dark: #004a53; --kaauh-border: #eaeff3; --kaauh-primary-text: #343a40; - --kaauh-success: #28a745; - --kaauh-danger: #dc3545; + --kaauh-success: #28a745; + --kaauh-danger: #dc3545; } /* Primary Color Overrides */ @@ -81,42 +81,42 @@ /* --- TABLE ALIGNMENT AND SIZING FIXES --- */ .table { - table-layout: fixed; + table-layout: fixed; width: 100%; - border-collapse: collapse; + border-collapse: collapse; } .table thead th { color: var(--kaauh-primary-text); - font-weight: 600; - font-size: 0.85rem; + font-weight: 600; + font-size: 0.85rem; vertical-align: middle; border-bottom: 2px solid var(--kaauh-border); - padding: 0.5rem 0.25rem; + padding: 0.5rem 0.25rem; } .table-hover tbody tr:hover { background-color: #f3f7f9; } /* Optimized Main Table Column Widths (Total must be 100%) */ - .table th:nth-child(1) { width: 22%; } - .table th:nth-child(2) { width: 12%; } - .table th:nth-child(3) { width: 8%; } - .table th:nth-child(4) { width: 8%; } - .table th:nth-child(5) { width: 50%; } + .table th:nth-child(1) { width: 22%; } + .table th:nth-child(2) { width: 12%; } + .table th:nth-child(3) { width: 8%; } + .table th:nth-child(4) { width: 8%; } + .table th:nth-child(5) { width: 50%; } /* Candidate Management Header Row (The one with P/F) */ .nested-metrics-row th { font-weight: 500; color: #6c757d; - font-size: 0.75rem; + font-size: 0.75rem; padding: 0.3rem 0; - border-bottom: 2px solid var(--kaauh-teal); + border-bottom: 2px solid var(--kaauh-teal); text-align: center; border-left: 1px solid var(--kaauh-border); } - + .nested-metrics-row th { - width: calc(50% / 7); + width: calc(50% / 7); } .nested-metrics-row th[colspan="2"] { width: calc(50% / 7 * 2); @@ -148,10 +148,10 @@ text-align: center; vertical-align: middle; font-weight: 600; - font-size: 0.9rem; - padding: 0; + font-size: 0.9rem; + padding: 0; } - .table tbody td.candidate-data-cell:not(:first-child) { + .table tbody td.candidate-data-cell:not(:first-child) { border-left: 1px solid var(--kaauh-border); } .table tbody tr td:nth-child(5) { @@ -161,15 +161,15 @@ .candidate-data-cell a { display: block; text-decoration: none; - padding: 0.4rem 0.25rem; + padding: 0.4rem 0.25rem; } - + /* Fix action button sizing */ .btn-group-sm > .btn { padding: 0.2rem 0.4rem; font-size: 0.75rem; } - + /* Additional CSS for Card View layout */ .card-view .card { height: 100%; @@ -227,7 +227,7 @@
- + @@ -236,8 +236,8 @@ {% trans "Clear" %} {% endif %} - - + +
@@ -249,19 +249,21 @@ {# --- START OF JOB LIST CONTAINER --- #}
{# View Switcher (Contains the Card/Table buttons and JS/CSS logic) #} - {% include "includes/_list_view_switcher.html" with list_id="job-list" %} + {% include "includes/_list_view_switcher.html" with list_id="job-list" %} {# 1. TABLE VIEW (Default Active) #}
- + {# --- Corrected Multi-Row Header Structure --- #} + + @@ -269,7 +271,7 @@ {% trans "Applicants Metrics" %} - + @@ -290,7 +292,7 @@ - + {% for job in jobs %} @@ -301,6 +303,8 @@ {{ job.status }} + +
{% trans "Job Title / ID" %} {% trans "Source" %}{% trans "Number Of Applicants" %}{% trans "Application Deadline" %} {% trans "Actions" %} {% trans "Manage Forms" %}
{% trans "Applied" %} {% trans "Screened" %}{% trans "Offer" %}
{{ job.get_source }}{{ job.max_applications }}{{ job.application_deadline|date:"d-m-Y" }} - + {# 2. CARD VIEW (Previously Missing) - Added Bootstrap row/col structure for layout #} -
+
{% for job in jobs %}
@@ -389,8 +393,7 @@ {# --- END CARD VIEW --- #}
{# --- END OF JOB LIST CONTAINER --- #} - - {% comment %} Fallback/Empty State {% endcomment %} + {% include "includes/paginator.html" %} {% if not jobs and not job_list_data and not page_obj %}
diff --git a/templates/jobs/partials/applicant_tracking.html b/templates/jobs/partials/applicant_tracking.html index 382652c..9200925 100644 --- a/templates/jobs/partials/applicant_tracking.html +++ b/templates/jobs/partials/applicant_tracking.html @@ -1,4 +1,4 @@ -{% load static i18n %} +{% load static i18n %} +
- {% comment %} STAGE 1: Applied {% endcomment %} -
- +
+
{% trans "Screened" %}
{{ applied_count|default:"0" }}
@@ -143,7 +142,7 @@ {% comment %} STAGE 2: Exam {% endcomment %}
@@ -157,7 +156,7 @@ {% comment %} STAGE 3: Interview {% endcomment %}
diff --git a/templates/meetings/list_meetings.html b/templates/meetings/list_meetings.html index a602ac3..baca877 100644 --- a/templates/meetings/list_meetings.html +++ b/templates/meetings/list_meetings.html @@ -139,6 +139,26 @@ .text-muted.fa-3x { color: var(--kaauh-teal-dark) !important; } + @keyframes svg-pulse { + 0% { + transform: scale(0.9); + opacity: 0.8; + } + 50% { + transform: scale(1.1); + opacity: 1; + } + 100% { + transform: scale(0.9); + opacity: 0.8; + } + } + + /* Apply the animation to the custom class */ + .svg-pulse { + animation: svg-pulse 2s infinite ease-in-out; + transform-origin: center; /* Ensure scaling is centered */ + } {% endblock %} @@ -167,6 +187,7 @@
{% if search_query %}{% endif %} + {% if status_filter %}{% endif %}
@@ -177,13 +198,16 @@
- +
+ + +

+ {% trans "Candidate" %}: {% with interview=meeting.interview_details.first %}{% if interview %}{{ interview.candidate.name }}{% else %}{% trans "N/A" %}{% endif %}{% endwith %}
+ {% trans "Job" %}: {% with interview=meeting.interview_details.first %}{% if interview %}{{ interview.job.title }}{% else %}{% trans "N/A" %}{% endif %}{% endwith %}
{% trans "ID" %}: {{ meeting.meeting_id|default:meeting.id }}
- {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }}
- {% trans "Duration" %}: {{ meeting.duration }} minutes + {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }} ({{ meeting.timezone }})
+ {% trans "Duration" %}: {{ meeting.duration }} minutes{% if meeting.password %}
{% trans "Password" %}: Yes{% endif %}

@@ -254,25 +280,40 @@ - - - - - - + + + + + + + + {% for meeting in meetings %} - - - + + + + + - @@ -275,20 +274,8 @@ - - - - + + @@ -332,13 +319,11 @@ {# CANDIDATE MANAGEMENT DATA - URLS NEUTRALIZED #} - - - - - - - + + + + + {% endfor %} diff --git a/templates/jobs/partials/applicant_tracking.html b/templates/jobs/partials/applicant_tracking.html index 9200925..bf75e03 100644 --- a/templates/jobs/partials/applicant_tracking.html +++ b/templates/jobs/partials/applicant_tracking.html @@ -43,39 +43,14 @@ /* ---------------- STAGE SPECIFIC COLORS ---------------- */ - /* APPLIED STAGE (Teal) */ - .stage-item[data-stage="Applied"].completed .stage-icon, - .stage-item[data-stage="Applied"].active .stage-icon { - background-color: var(--stage-applied); - color: white; - } - .stage-item[data-stage="Applied"].active { color: var(--stage-applied); } - /* EXAM STAGE (Cyan/Info) */ - .stage-item[data-stage="Exam"].completed .stage-icon, - .stage-item[data-stage="Exam"].active .stage-icon { - background-color: var(--stage-exam); - color: white; - } - .stage-item[data-stage="Exam"].active { color: var(--stage-exam); } - /* INTERVIEW STAGE (Yellow/Warning) */ - .stage-item[data-stage="Interview"].completed .stage-icon, - .stage-item[data-stage="Interview"].active .stage-icon { + .stage-item.completed .stage-icon, + .stage-item.active .stage-icon { background-color: var(--stage-interview); color: var(--kaauh-primary-text); /* Dark text for light background */ } - .stage-item[data-stage="Interview"].active { color: var(--stage-interview); } - - /* OFFER STAGE (Green/Success) */ - .stage-item[data-stage="Offer"].completed .stage-icon, - .stage-item[data-stage="Offer"].active .stage-icon { - background-color: var(--stage-offer); - color: white; - } - .stage-item[data-stage="Offer"].active { color: var(--stage-offer); } - - /* ---------------- GENERIC ACTIVE/COMPLETED STYLING ---------------- */ + .stage-item.active { color: var(--stage-interview); } /* Active State (Applies glow/scale to current stage) */ .stage-item.active .stage-icon { @@ -134,11 +109,11 @@
{% trans "Screened" %}
-
{{ applied_count|default:"0" }}
+
{{ job.screening_candidates.count|default:"0" }}
{% comment %} CONNECTOR 1 -> 2 {% endcomment %} -
+
{% comment %} STAGE 2: Exam {% endcomment %}
{% trans "Exam" %}
-
{{ exam_count|default:"0" }}
+
{{ job.exam_candidates.count|default:"0" }}
{% comment %} CONNECTOR 2 -> 3 {% endcomment %} -
+
{% comment %} STAGE 3: Interview {% endcomment %}
{% trans "Interview" %}
-
{{ interview_count|default:"0" }}
+
{{ job.interview_candidates.count|default:"0" }}
{% comment %} CONNECTOR 3 -> 4 {% endcomment %} -
+
{% comment %} STAGE 4: Offer {% endcomment %} -
{% trans "Offer" %}
-
{{ offer_count|default:"0" }}
+
{{ job.offer_candidates.count|default:"0" }}
- \ No newline at end of file + diff --git a/templates/meetings/delete_meeting_form.html b/templates/meetings/delete_meeting_form.html index b49403d..c12676f 100644 --- a/templates/meetings/delete_meeting_form.html +++ b/templates/meetings/delete_meeting_form.html @@ -1,7 +1,10 @@ {% load i18n %} - + {% csrf_token %}

{% trans "Are you sure you want to delete this meeting? This action is irreversible." %}

- - +
+ + + +
\ No newline at end of file diff --git a/templates/meetings/list_meetings.html b/templates/meetings/list_meetings.html index baca877..a56e803 100644 --- a/templates/meetings/list_meetings.html +++ b/templates/meetings/list_meetings.html @@ -92,6 +92,7 @@ } /* Status Badge Mapping */ .bg-waiting { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;} + .bg-scheduled { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;} .bg-started { background-color: var(--kaauh-teal) !important; color: white !important;} .bg-ended { background-color: #dc3545 !important; color: white !important;} @@ -186,8 +187,8 @@
- {% if search_query %}{% endif %} - {% if status_filter %}{% endif %} + {% if search_query %}{% endif %} + {% if status_filter %}{% endif %}
@@ -204,11 +205,11 @@
- {% if status_filter or search_query or candidate_name_filter %} - + {% trans "Clear" %} {% endif %} @@ -238,10 +239,24 @@

- {% trans "Candidate" %}: {% with interview=meeting.interview_details.first %}{% if interview %}{{ interview.candidate.name }}{% else %}{% trans "N/A" %}{% endif %}{% endwith %}
- {% trans "Job" %}: {% with interview=meeting.interview_details.first %}{% if interview %}{{ interview.job.title }}{% else %}{% trans "N/A" %}{% endif %}{% endwith %}
+ {% trans "Candidate" %}: {% if meeting.interview %}{{ meeting.interview.candidate.name }}{% else %} + + {% endif %}
+ {% trans "Job" %}: {% if meeting.interview %}{{ meeting.interview.job.title }}{% else %} + + {% endif %}
{% trans "ID" %}: {{ meeting.meeting_id|default:meeting.id }}
- {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }} ({{ meeting.timezone }})
+ {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }}
{% trans "Duration" %}: {{ meeting.duration }} minutes{% if meeting.password %}
{% trans "Password" %}: Yes{% endif %}

@@ -262,7 +277,9 @@ @@ -295,25 +312,43 @@
- - + +
{% trans "Topic" %}{% trans "ID" %}{% trans "Start Time" %}{% trans "Duration" %}{% trans "Status" %}{% trans "Actions" %}{% trans "Topic" %}{% trans "Candidate" %}{% trans "Job" %}{% trans "ID" %}{% trans "Start Time" %}{% trans "Duration" %}{% trans "Status" %}{% trans "Actions" %}
{{ meeting.topic }}{{ meeting.meeting_id|default:meeting.id }}{{ meeting.start_time|date:"M d, Y H:i" }}{{ meeting.duration }} min - - {{ meeting.status|title }} - + {% with interview=meeting.interview_details.first %} + {% if interview %}{{ interview.candidate.name }}{% else %}{% trans "N/A" %}{% endif %} + {% endwith %} + + {% with interview=meeting.interview_details.first %} + {% if interview %}{{ interview.job.title }}{% else %}{% trans "N/A" %}{% endif %} + {% endwith %} + {{ meeting.meeting_id|default:meeting.id }}{{ meeting.start_time|date:"M d, Y H:i" }} ({{ meeting.timezone }}){{ meeting.duration }} min{% if meeting.password %} ({% trans "Password" %}){% endif %} + {% if meeting.status == "started" %} + + {{ meeting.status|title }} + {% include "icons/video.html" %} + + {% endif %}
@@ -344,4 +385,4 @@
{% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/recruitment/candidate_list.html b/templates/recruitment/candidate_list.html index 8c350b8..8588135 100644 --- a/templates/recruitment/candidate_list.html +++ b/templates/recruitment/candidate_list.html @@ -67,7 +67,7 @@ color: white; border-color: var(--kaauh-teal-dark); } - + /* Card Specifics (Adapted from Job Card to Candidate Card) */ .candidate-card .card-title { color: var(--kaauh-teal-dark); @@ -76,7 +76,7 @@ } .candidate-card .card-text i { color: var(--kaauh-teal); - width: 1.25rem; + width: 1.25rem; } /* Table & Card Badge Styling (Unified) */ @@ -90,7 +90,7 @@ /* Status Badge Mapping (Using standard Bootstrap names where possible) */ .bg-primary { background-color: var(--kaauh-teal) !important; color: white !important;} /* Main job/stage badge */ - .bg-success { background-color: #28a745 !important; color: white !important;} + .bg-success { background-color: #28a745 !important; color: white !important;} .bg-warning { background-color: #ffc107 !important; color: #343a40 !important;} /* Table Styling (Consistent with Reference) */ @@ -112,7 +112,7 @@ .table-view .table tbody tr:hover { background-color: var(--kaauh-gray-light); } - + /* Pagination Link Styling (Consistent) */ .pagination .page-item .page-link { color: var(--kaauh-teal-dark); @@ -126,7 +126,7 @@ .pagination .page-item:hover .page-link:not(.active) { background-color: #e9ecef; } - + /* Filter & Search Layout Adjustments */ .filter-buttons { display: flex; @@ -161,18 +161,27 @@
{% url 'candidate_list' as candidate_list_url %} - + {% if search_query %}{% endif %} -
+
- + + {% for job in available_jobs %} - {% endfor %} + {% endfor %} + +
@@ -260,7 +269,7 @@
{{ candidate.name }}
{{ candidate.stage }}
- +

{{ candidate.email }}
{{ candidate.phone|default:"N/A" }}
@@ -293,33 +302,7 @@

{# Pagination (Standardized to Reference) #} - {% if is_paginated %} - - {% endif %} + {% include "includes/paginator.html" %} {% else %}
From 22870af025acb32a3b6f6448422344d18f0d93c7 Mon Sep 17 00:00:00 2001 From: ismail Date: Sun, 19 Oct 2025 17:23:06 +0300 Subject: [PATCH 3/3] ai parsing update --- ATS_PRODUCT_DOCUMENT.md | 454 +++++++ ATS_PROJECT_HLD.md | 241 ++++ ATS_PROJECT_LLD.md | 1083 ++++++++++++++++ .../__pycache__/settings.cpython-313.pyc | Bin 7983 -> 8045 bytes .../__pycache__/urls.cpython-313.pyc | Bin 2268 -> 2268 bytes NorahUniversity/settings.py | 19 +- TESTING_GUIDE.md | 312 +++++ conftest.py | 394 ++++++ pytest.ini | 20 + recruitment/__pycache__/admin.cpython-313.pyc | Bin 11558 -> 12160 bytes recruitment/__pycache__/forms.cpython-313.pyc | Bin 24075 -> 23595 bytes .../__pycache__/models.cpython-313.pyc | Bin 46819 -> 56831 bytes recruitment/__pycache__/urls.cpython-313.pyc | Bin 9671 -> 10574 bytes recruitment/__pycache__/utils.cpython-313.pyc | Bin 20398 -> 20398 bytes recruitment/__pycache__/views.cpython-313.pyc | Bin 75417 -> 82328 bytes .../views_frontend.cpython-313.pyc | Bin 17910 -> 21055 bytes recruitment/admin.py | 30 +- recruitment/forms.py | 112 +- recruitment/migrations/0021_meetingcomment.py | 35 + .../0022_candidate_resume_parsed_category.py | 18 + .../0023_alter_jobposting_max_applications.py | 18 + .../0024_alter_zoommeeting_status.py | 18 + .../0025_candidate_recommendation.py | 18 + ...ndidate_resume_parsed_category_and_more.py | 22 + .../0027_alter_candidate_email_and_more.py | 159 +++ .../0028_alter_candidate_interview_status.py | 18 + ..._recruitment_job_id_766dbe_idx_and_more.py | 62 + .../0030_alter_candidate_options.py | 17 + .../0031_alter_candidate_options.py | 17 + recruitment/models.py | 256 +++- recruitment/tasks.py | 409 ++++-- .../__pycache__/form_filters.cpython-313.pyc | Bin 3428 -> 3428 bytes recruitment/tests.py | 627 ++++++++- recruitment/tests_advanced.py | 1078 ++++++++++++++++ recruitment/urls.py | 11 +- recruitment/views.py | 1140 ++++------------- recruitment/views_frontend.py | 86 +- run.py | 6 +- template_partials/__init__.py | 1 + template_partials/apps.py | 7 + templates/base.html | 2 +- .../forms/form_template_all_submissions.html | 2 +- .../forms/form_template_submissions_list.html | 2 +- templates/forms/form_templates_list.html | 10 +- templates/icons/delete.html | 2 +- templates/includes/candidate_modal_body.html | 26 +- .../includes/candidate_update_exam_form.html | 10 + .../candidate_update_interview_form.html | 10 + .../includes/candidate_update_offer_form.html | 10 + templates/includes/comment_form.html | 24 + templates/includes/comment_list.html | 56 + templates/includes/delete_comment_form.html | 16 + templates/includes/delete_modal.html | 26 +- templates/includes/edit_comment_form.html | 24 + templates/includes/paginator.html | 12 +- templates/includes/search_form.html | 8 +- templates/jobs/job_detail.html | 121 +- templates/jobs/job_list.html | 35 +- .../jobs/partials/applicant_tracking.html | 51 +- templates/meetings/delete_meeting_form.html | 9 +- templates/meetings/list_meetings.html | 86 +- templates/meetings/meeting_details.html | 136 +- templates/meetings/set_candidate_form.html | 7 + templates/recruitment/candidate_detail.html | 160 ++- .../recruitment/candidate_exam_view.html | 80 +- .../recruitment/candidate_interview_view.html | 107 +- templates/recruitment/candidate_list.html | 24 +- .../recruitment/candidate_offer_view.html | 273 ++++ .../recruitment/candidate_screening_view.html | 76 +- .../partials/stage_update_modal.html | 9 +- 70 files changed, 6728 insertions(+), 1374 deletions(-) create mode 100644 ATS_PRODUCT_DOCUMENT.md create mode 100644 ATS_PROJECT_HLD.md create mode 100644 ATS_PROJECT_LLD.md create mode 100644 TESTING_GUIDE.md create mode 100644 conftest.py create mode 100644 pytest.ini create mode 100644 recruitment/migrations/0021_meetingcomment.py create mode 100644 recruitment/migrations/0022_candidate_resume_parsed_category.py create mode 100644 recruitment/migrations/0023_alter_jobposting_max_applications.py create mode 100644 recruitment/migrations/0024_alter_zoommeeting_status.py create mode 100644 recruitment/migrations/0025_candidate_recommendation.py create mode 100644 recruitment/migrations/0026_remove_candidate_resume_parsed_category_and_more.py create mode 100644 recruitment/migrations/0027_alter_candidate_email_and_more.py create mode 100644 recruitment/migrations/0028_alter_candidate_interview_status.py create mode 100644 recruitment/migrations/0029_remove_candidate_recruitment_job_id_766dbe_idx_and_more.py create mode 100644 recruitment/migrations/0030_alter_candidate_options.py create mode 100644 recruitment/migrations/0031_alter_candidate_options.py create mode 100644 recruitment/tests_advanced.py create mode 100644 template_partials/__init__.py create mode 100644 template_partials/apps.py create mode 100644 templates/includes/candidate_update_exam_form.html create mode 100644 templates/includes/candidate_update_interview_form.html create mode 100644 templates/includes/candidate_update_offer_form.html create mode 100644 templates/includes/comment_form.html create mode 100644 templates/includes/comment_list.html create mode 100644 templates/includes/delete_comment_form.html create mode 100644 templates/includes/edit_comment_form.html create mode 100644 templates/meetings/set_candidate_form.html create mode 100644 templates/recruitment/candidate_offer_view.html diff --git a/ATS_PRODUCT_DOCUMENT.md b/ATS_PRODUCT_DOCUMENT.md new file mode 100644 index 0000000..e4f5282 --- /dev/null +++ b/ATS_PRODUCT_DOCUMENT.md @@ -0,0 +1,454 @@ +# KAAUH Applicant Tracking System (ATS) - Product Document + +## 1. Product Overview + +### 1.1 Product Description +The King Abdulaziz University Hospital (KAAUH) Applicant Tracking System (ATS) is a comprehensive recruitment management platform designed to streamline and optimize the entire hiring process. The system provides end-to-end functionality for job posting, candidate management, interview coordination, and integration with external recruitment platforms. + +### 1.2 Target Users +- **System Administrators**: Manage system configurations, user accounts, and integrations +- **Hiring Managers**: Create job postings, review candidates, and make hiring decisions +- **Recruiters**: Manage candidate pipelines, conduct screenings, and coordinate interviews +- **Interviewers**: Schedule and conduct interviews, provide feedback +- **Candidates**: Apply for positions, track application status, and participate in interviews +- **External Agencies**: Submit candidates and track progress + +### 1.3 Key Features +- **Job Management**: Create, edit, and publish job postings with customizable templates +- **Candidate Pipeline**: Track candidates through all stages of recruitment +- **Interview Scheduling**: Automated scheduling with calendar integration +- **Video Interviews**: Zoom integration for seamless virtual interviews +- **Form Builder**: Dynamic application forms with custom fields +- **LinkedIn Integration**: Automated job posting and profile synchronization +- **Reporting & Analytics**: Comprehensive dashboards and reporting tools +- **Multi-language Support**: Arabic and English interfaces + +## 2. User Stories + +### 2.1 Hiring Manager Stories +``` +As a Hiring Manager, I want to: +- Create job postings with detailed requirements and qualifications +- Review and shortlist candidates based on predefined criteria +- Track the status of all recruitment activities +- Generate reports on hiring metrics and trends +- Collaborate with recruiters and interviewers +- Post jobs directly to LinkedIn + +Acceptance Criteria: +- Can create job postings with rich text descriptions +- Can filter candidates by stage, skills, and match score +- Can view real-time recruitment metrics +- Can approve or reject candidates +- Can post jobs to LinkedIn with one click +``` + +### 2.2 Recruiter Stories +``` +As a Recruiter, I want to: +- Source and screen candidates from multiple channels +- Move candidates through the recruitment pipeline +- Schedule interviews and manage availability +- Send automated notifications and updates +- Track candidate engagement and response rates +- Maintain a database of potential candidates + +Acceptance Criteria: +- Can bulk import candidates from CSV files +- Can update candidate stages in bulk +- Can schedule interviews with calendar sync +- Can send automated email/SMS notifications +- Can track candidate communication history +``` + +### 2.3 Interviewer Stories +``` +As an Interviewer, I want to: +- View my interview schedule and availability +- Join video interviews seamlessly +- Provide structured feedback for candidates +- Access candidate information and resumes +- Confirm or reschedule interviews +- View interview history and patterns + +Acceptance Criteria: +- Receive email/SMS reminders for upcoming interviews +- Can join Zoom meetings with one click +- Can submit structured feedback forms +- Can access all candidate materials +- Can update interview status and availability +``` + +### 2.4 Candidate Stories +``` +As a Candidate, I want to: +- Search and apply for relevant positions +- Track my application status in real-time +- Receive timely updates about my application +- Participate in virtual interviews +- Submit required documents securely +- Communicate with recruiters easily + +Acceptance Criteria: +- Can create a profile and upload resumes +- Can search jobs by department and keywords +- Can track application status history +- Can schedule interviews within available slots +- Can receive notifications via email/SMS +- Can access all application materials +``` + +### 2.5 System Administrator Stories +``` +As a System Administrator, I want to: +- Manage user accounts and permissions +- Configure system settings and integrations +- Monitor system performance and usage +- Generate audit logs and reports +- Manage integrations with external systems +- Ensure data security and compliance + +Acceptance Criteria: +- Can create and manage user roles +- Can configure API keys and integrations +- Can monitor system health and performance +- Can generate audit trails for all actions +- Can backup and restore data +- Can ensure GDPR compliance +``` + +## 3. Functional Requirements + +### 3.1 Job Management Module +#### 3.1.1 Job Creation & Editing +- **FR1.1.1**: Users must be able to create new job postings with all required fields +- **FR1.1.2**: System must auto-generate unique internal job IDs +- **FR1.1.3**: Users must be able to edit job postings at any stage +- **FR1.1.4**: System must support job cloning for similar positions +- **FR1.1.5**: System must support multi-language content + +#### 3.1.2 Job Publishing & Distribution +- **FR1.2.1**: System must support job status management (Draft, Active, Closed) +- **FR1.2.2**: System must integrate with LinkedIn for job posting +- **FR1.2.3**: System must generate career pages for active jobs +- **FR1.2.4**: System must support application limits per job posting +- **FR1.2.5**: System must track application sources and effectiveness + +### 3.2 Candidate Management Module +#### 3.2.1 Candidate Database +- **FR2.1.1**: System must store comprehensive candidate profiles +- **FR2.1.2**: System must parse and analyze uploaded resumes +- **FR2.1.3**: System must support candidate import from various sources +- **FR2.1.4**: System must provide candidate search and filtering +- **FR2.1.5**: System must calculate match scores for candidates + +#### 3.2.2 Candidate Pipeline +- **FR2.2.1**: System must support customizable candidate stages +- **FR2.2.2**: System must enforce stage transition rules +- **FR2.2.3**: System must track all candidate interactions +- **FR2.2.4**: System must support bulk candidate operations +- **FR2.2.5**: System must provide candidate relationship management + +### 3.3 Interview Management Module +#### 3.3.1 Interview Scheduling +- **FR3.1.1**: System must support automated interview scheduling +- **FR3.1.2**: System must integrate with calendar systems +- **FR3.1.3**: System must handle timezone conversions +- **FR3.1.4**: System must support buffer times between interviews +- **FR3.1.5**: System must prevent scheduling conflicts + +#### 3.3.2 Video Interviews +- **FR3.2.1**: System must integrate with Zoom for video interviews +- **FR3.2.2**: System must create Zoom meetings automatically +- **FR3.2.3**: System must handle meeting updates and cancellations +- **FR3.2.4**: System must support meeting recordings +- **FR3.2.5**: System must manage meeting access controls + +### 3.4 Form Builder Module +#### 3.4.1 Form Creation +- **FR4.1.1**: System must support multi-stage form creation +- **FR4.1.2**: System must provide various field types +- **FR4.1.3**: System must support form validation rules +- **FR4.1.4**: System must allow conditional logic +- **FR4.1.5**: System must support form templates + +#### 3.4.2 Form Processing +- **FR4.2.1**: System must handle form submissions securely +- **FR4.2.2**: System must support file uploads +- **FR4.2.3**: System must extract data from submissions +- **FR4.2.4**: System must create candidates from submissions +- **FR4.2.5**: System must provide submission analytics + +### 3.5 Reporting & Analytics Module +#### 3.5.1 Dashboards +- **FR5.1.1**: System must provide role-based dashboards +- **FR5.1.2**: System must display key performance indicators +- **FR5.1.3**: System must support real-time data updates +- **FR5.1.4**: System must allow customization of dashboard views +- **FR5.1.5**: System must support data visualization + +#### 3.5.2 Reports +- **FR5.2.1**: System must generate standard reports +- **FR5.2.2**: System must support custom report generation +- **FR5.2.3**: System must export data in multiple formats +- **FR5.2.4**: System must schedule automated reports +- **FR5.2.5**: System must support report distribution + +## 4. Non-Functional Requirements + +### 4.1 Performance Requirements +- **NF1.1**: System must support concurrent users (100+) +- **NF1.2**: Page load time must be under 3 seconds +- **NF1.3**: API response time must be under 1 second +- **NF1.4**: System must handle 10,000+ job postings +- **NF1.5**: System must handle 100,000+ candidate records + +### 4.2 Security Requirements +- **NF2.1**: All data must be encrypted in transit and at rest +- **NF2.2**: System must support role-based access control +- **NF2.3**: System must maintain audit logs for all actions +- **NF2.4**: System must comply with GDPR regulations +- **NF2.5**: System must protect against common web vulnerabilities + +### 4.3 Usability Requirements +- **NF3.1**: Interface must be intuitive and easy to use +- **NF3.2**: System must support both Arabic and English +- **NF3.3**: System must be responsive and mobile-friendly +- **NF3.4**: System must provide clear error messages +- **NF3.5**: System must support keyboard navigation + +### 4.4 Reliability Requirements +- **NF4.1**: System must have 99.9% uptime +- **NF4.2**: System must handle failures gracefully +- **NF4.3**: System must support data backup and recovery +- **NF4.4**: System must provide monitoring and alerts +- **NF4.5**: System must support load balancing + +### 4.5 Scalability Requirements +- **NF5.1**: System must scale horizontally +- **NF5.2**: System must handle peak loads +- **NF5.3**: System must support database sharding +- **NF5.4**: System must cache frequently accessed data +- **NF5.5**: System must support microservices architecture + +## 5. Integration Requirements + +### 5.1 External Integrations +- **INT1.1**: Zoom API for video conferencing +- **INT1.2**: LinkedIn API for job posting and profiles +- **INT1.3**: Email/SMS services for notifications +- **INT1.4**: Calendar systems for scheduling +- **INT1.5**: ERP systems for employee data + +### 5.2 Internal Integrations +- **INT2.1**: Single Sign-On (SSO) for authentication +- **INT2.2**: File storage system for documents +- **INT2.3**: Search engine for candidate matching +- **INT2.4**: Analytics platform for reporting +- **INT2.5**: Task queue for background processing + +## 6. Business Rules + +### 6.1 Job Posting Rules +- **BR1.1**: Job postings must be approved before publishing +- **BR1.2**: Application limits must be enforced per job +- **BR1.3**: Job postings must have required fields completed +- **BR1.4**: LinkedIn posts must follow platform guidelines +- **BR1.5**: Job postings must comply with equal opportunity laws + +### 6.2 Candidate Management Rules +- **BR2.1**: Candidates can only progress to next stage with approval +- **BR2.2**: Duplicate candidates must be prevented +- **BR2.3**: Candidate data must be kept confidential +- **BR2.4**: Communication must be tracked for all candidates +- **BR2.5**: Background checks must be completed before offers + +### 6.3 Interview Scheduling Rules +- **BR3.1**: Interviews must be scheduled during business hours +- **BR3.2**: Buffer time must be respected between interviews +- **BR3.3**: Interviewers must be available for scheduled times +- **BR3.4**: Cancellations must be handled according to policy +- **BR3.5**: Feedback must be collected after each interview + +### 6.4 Form Processing Rules +- **BR4.1**: Required fields must be validated before submission +- **BR4.2**: File uploads must be scanned for security +- **BR4.3**: Form submissions must be processed in order +- **BR4.4**: Duplicate submissions must be prevented +- **BR4.5**: Form data must be extracted accurately + +## 7. User Interface Requirements + +### 7.1 Design Principles +- **UI1.1**: Clean, modern interface with consistent branding +- **UI1.2**: Intuitive navigation with clear hierarchy +- **UI1.3**: Responsive design for all devices +- **UI1.4**: Accessibility compliance (WCAG 2.1) +- **UI1.5**: Fast loading with optimized performance + +### 7.2 Key Screens +- **UI2.1**: Dashboard with key metrics and quick actions +- **UI2.2**: Job posting creation and management interface +- **UI2.3**: Candidate pipeline with drag-and-drop stages +- **UI2.4**: Interview scheduling calendar view +- **UI2.5**: Form builder with drag-and-drop fields +- **UI2.6**: Reports and analytics with interactive charts +- **UI2.7**: Candidate profile with comprehensive information +- **UI2.8**: Meeting interface with Zoom integration + +### 7.3 Interaction Patterns +- **UI3.1**: Consistent button styles and behaviors +- **UI3.2**: Clear feedback for all user actions +- **UI3.3**: Progressive disclosure for complex forms +- **UI3.4**: Contextual help and tooltips +- **UI3.5**: Keyboard shortcuts for power users + +## 8. Data Management + +### 8.1 Data Storage +- **DM1.1**: All data must be stored securely +- **DM1.2**: Sensitive data must be encrypted +- **DM1.3**: Data must be backed up regularly +- **DM1.4**: Data retention policies must be enforced +- **DM1.5**: Data must be accessible for reporting + +### 8.2 Data Migration +- **DM2.1**: Support import from legacy systems +- **DM2.2**: Provide data validation during migration +- **DM2.3**: Support incremental data updates +- **DM2.4**: Maintain data integrity during migration +- **DM2.5**: Provide rollback capabilities + +### 8.3 Data Quality +- **DM3.1**: Implement data validation rules +- **DM3.2**: Provide data cleansing tools +- **DM3.3**: Monitor data quality metrics +- **DM3.4**: Handle duplicate data detection +- **DM3.5**: Support data standardization + +## 9. Implementation Plan + +### 9.1 Development Phases +#### Phase 1: Core Functionality (Months 1-3) +- User authentication and authorization +- Basic job posting and management +- Candidate database and pipeline +- Basic reporting dashboards +- Form builder with essential fields + +#### Phase 2: Enhanced Features (Months 4-6) +- Interview scheduling and Zoom integration +- LinkedIn integration for job posting +- Advanced reporting and analytics +- Candidate matching and scoring +- Mobile-responsive design + +#### Phase 3: Advanced Features (Months 7-9) +- AI-powered candidate matching +- Advanced form builder with conditions +- Integration with external systems +- Performance optimization +- Security hardening + +#### Phase 4: Production Readiness (Months 10-12) +- Load testing and performance optimization +- Security audit and compliance +- Documentation and training materials +- Beta testing with real users +- Production deployment + +### 9.2 Team Structure +- **Project Manager**: Overall project coordination +- **Product Owner**: Requirements and backlog management +- **UI/UX Designer**: Interface design and user experience +- **Backend Developers**: Server-side development +- **Frontend Developers**: Client-side development +- **QA Engineers**: Testing and quality assurance +- **DevOps Engineers**: Deployment and infrastructure +- **Business Analyst**: Requirements gathering and analysis + +### 9.3 Technology Stack +- **Frontend**: HTML5, CSS3, JavaScript, Bootstrap 5, HTMX +- **Backend**: Django 5.2.1, Python 3.11 +- **Database**: PostgreSQL (production), SQLite (development) +- **APIs**: Django REST Framework +- **Authentication**: Django Allauth, OAuth 2.0 +- **Real-time**: HTMX, WebSocket +- **Task Queue**: Celery with Redis +- **Storage**: Local filesystem, AWS S3 +- **Monitoring**: Prometheus, Grafana +- **CI/CD**: Docker, Kubernetes + +## 10. Success Metrics + +### 10.1 Business Metrics +- **BM1.1**: Reduce time-to-hire by 30% +- **BM1.2**: Improve candidate quality by 25% +- **BM1.3**: Increase recruiter efficiency by 40% +- **BM1.4**: Reduce recruitment costs by 20% +- **BM1.5**: Improve candidate satisfaction by 35% + +### 10.2 Technical Metrics +- **TM1.1**: System uptime of 99.9% +- **TM1.2**: Page load time under 3 seconds +- **TM1.3**: API response time under 1 second +- **TM1.4**: 0 critical security vulnerabilities +- **TM1.5**: 95% test coverage + +### 10.3 User Adoption Metrics +- **UM1.1**: 90% of target users actively using the system +- **UM1.2**: 80% reduction in manual processes +- **UM1.3**: 75% improvement in user satisfaction +- **UM1.4**: 50% reduction in recruitment time +- **UM1.5**: 95% data accuracy in the system + +## 11. Risk Assessment + +### 11.1 Technical Risks +- **TR1.1**: Integration complexity with external systems +- **TR1.2**: Performance issues with large datasets +- **TR1.3**: Security vulnerabilities in third-party APIs +- **TR1.4**: Data migration challenges +- **TR1.5**: Scalability concerns + +### 11.2 Business Risks +- **BR1.1**: User resistance to new system +- **BR1.2**: Changes in recruitment processes +- **BR1.3**: Budget constraints +- **BR1.4**: Timeline delays +- **BR1.5**: Regulatory changes + +### 11.3 Mitigation Strategies +- **MS1.1**: Phased implementation with user feedback +- **MS1.2**: Regular performance testing and optimization +- **MS1.3**: Security audits and penetration testing +- **MS1.4**: Comprehensive training and support +- **MS1.5**: Flexible architecture for future changes + +## 12. Training & Support + +### 12.1 User Training +- **TU1.1**: Role-based training programs +- **TU1.2**: Online documentation and help guides +- **TU1.3**: Video tutorials for key features +- **TU1.4**: In-person training sessions +- **TU1.5**: Refresher courses and updates + +### 12.2 Technical Support +- **TS1.1**: Helpdesk with dedicated support staff +- **TS1.2**: Online ticketing system +- **TS1.3**: Remote support capabilities +- **TS1.4**: Knowledge base and FAQs +- **TS1.5**: 24/7 support for critical issues + +### 12.3 System Maintenance +- **SM1.1**: Regular system updates and patches +- **SM1.2**: Performance monitoring and optimization +- **SM1.3**: Data backup and recovery procedures +- **SM1.4**: System health checks +- **SM1.5**: Continuous improvement based on feedback + +--- + +*Document Version: 1.0* +*Last Updated: October 17, 2025* diff --git a/ATS_PROJECT_HLD.md b/ATS_PROJECT_HLD.md new file mode 100644 index 0000000..8d3ae2c --- /dev/null +++ b/ATS_PROJECT_HLD.md @@ -0,0 +1,241 @@ +# KAAUH Applicant Tracking System (ATS) - High Level Design Document + +## 1. Executive Summary + +This document outlines the High-Level Design (HLD) for the King Abdulaziz University Hospital (KAAUH) Applicant Tracking System (ATS). The system is designed to streamline the recruitment process by providing comprehensive tools for job posting, candidate management, interview scheduling, and integration with external platforms. + +## 2. System Overview + +### 2.1 Vision +To create a modern, efficient, and user-friendly recruitment management system that automates and optimizes the hiring process at KAAUH. + +### 2.2 Mission +The ATS aims to: +- Centralize recruitment activities +- Improve candidate experience +- Enhance recruiter efficiency +- Provide data-driven insights +- Integrate with external platforms (Zoom, LinkedIn, ERP) + +### 2.3 Goals +- Reduce time-to-hire +- Improve candidate quality +- Enhance reporting and analytics +- Provide seamless user experience +- Ensure system scalability and maintainability + +## 3. Architecture Overview + +### 3.1 Technology Stack +- **Backend**: Django 5.2.1 (Python) +- **Frontend**: HTML5, CSS3, JavaScript, Bootstrap 5 +- **Database**: SQLite (development), PostgreSQL (production) +- **APIs**: REST API with Django REST Framework +- **Real-time**: HTMX for dynamic UI updates +- **Authentication**: Django Allauth with OAuth (LinkedIn) +- **File Storage**: Local filesystem +- **Task Queue**: Celery with Redis +- **Communication**: Email, Webhooks (Zoom) + +### 3.2 System Architecture +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Web Browser │ │ Mobile App │ │ Admin Panel │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + └───────────────────────┼───────────────────────┘ + │ + ┌─────────────────┐ + │ Load Balancer │ + └─────────────────┘ + │ + ┌─────────────────┐ + │ Web Server │ + │ (Gunicorn) │ + └─────────────────┘ + │ + ┌─────────────────┐ + │ Application │ + │ (Django) │ + └─────────────────┘ + │ │ + ┌───────────────┴─────────┐ ┌─────┴────────────────┐ + │ Database Layer │ │ External Services│ + │ (SQLite/PostgreSQL) │ │ (Zoom, LinkedIn) │ + └─────────────────────────┘ └─────────────────────┘ +``` + +## 4. Core Components + +### 4.1 User Management +- **Role-based Access Control**: + - System Administrators + - Hiring Managers + - Recruiters + - Interviewers + - Candidates +- **Authentication**: + - User registration and login + - LinkedIn OAuth integration + - Session management + +### 4.2 Job Management +- **Job Posting**: + - Create, edit, delete job postings + - Job templates and cloning + - Multi-language support + - Approval workflows +- **Job Distribution**: + - LinkedIn integration + - Career page management + - Application tracking + +### 4.3 Candidate Management +- **Candidate Database**: + - Profile management + - Resume parsing and storage + - Skills assessment + - Candidate scoring +- **Candidate Tracking**: + - Application status tracking + - Stage transitions + - Communication logging + - Candidate relationship management + +### 4.4 Interview Management +- **Scheduling**: + - Automated interview scheduling + - Calendar integration + - Time slot management + - Buffer time configuration +- **Video Interviews**: + - Zoom API integration + - Meeting creation and management + - Recording and playback + - Interview feedback collection + +### 4.5 Form Builder +- **Dynamic Forms**: + - Multi-stage form creation + - Custom field types + - Validation rules + - File upload support +- **Application Processing**: + - Form submission handling + - Data extraction and storage + - Notification systems + +### 4.6 Reporting and Analytics +- **Dashboards**: + - Executive dashboard + - Recruitment metrics + - Candidate analytics + - Department-specific reports +- **Data Export**: + - CSV, Excel, PDF exports + - Custom report generation + - Scheduled reports + +## 5. Integration Architecture + +### 5.1 External API Integrations +- **Zoom Video Conferencing**: + - Meeting creation and management + - Webhook event handling + - Recording and transcription +- **LinkedIn Recruitment**: + - Job posting automation + - Profile synchronization + - Analytics tracking +- **ERP Systems**: + - Employee data synchronization + - Position management + - Financial integration + +### 5.2 Internal Integrations +- **Email System**: + - Automated notifications + - Interview reminders + - Status updates +- **Calendar System**: + - Interview scheduling + - Availability management + - Conflict detection + +## 6. Security Architecture + +### 6.1 Authentication & Authorization +- Multi-factor authentication support +- Role-based access control +- JWT token authentication +- OAuth 2.0 integration + +### 6.2 Data Protection +- Data encryption at rest and in transit +- Secure file storage +- Personal data protection +- Audit logging + +### 6.3 System Security +- Input validation and sanitization +- SQL injection prevention +- XSS protection +- CSRF protection +- Rate limiting + +## 7. Scalability & Performance + +### 7.1 Performance Optimization +- Database indexing +- Query optimization +- Caching strategies (Redis) +- Asynchronous task processing (Celery) + +### 7.2 Scalability Considerations +- Horizontal scaling support +- Load balancing +- Database replication +- Microservices-ready architecture + +## 8. Deployment & Operations + +### 8.1 Deployment Strategy +- Container-based deployment (Docker) +- Environment management +- CI/CD pipeline +- Automated testing + +### 8.2 Monitoring & Maintenance +- Application monitoring +- Performance metrics +- Error tracking +- Automated backups + +## 9. Future Roadmap + +### 9.1 Phase 1 (Current) +- Core ATS functionality +- Basic reporting +- Zoom and LinkedIn integration +- Mobile-responsive design + +### 9.2 Phase 2 (Next 6 months) +- Advanced analytics +- AI-powered candidate matching +- Enhanced reporting +- Mobile app development + +### 9.3 Phase 3 (Next 12 months) +- Voice interview support +- Video interview AI analysis +- Advanced integrations +- Multi-tenant support + +## 10. Conclusion + +The KAAUH ATS system is designed to be a comprehensive, modern, and scalable solution for managing the recruitment lifecycle. By leveraging Django's robust framework and integrating with external platforms, the system will significantly improve recruitment efficiency and provide valuable insights for decision-making. + +--- + +*Document Version: 1.0* +*Last Updated: October 17, 2025* diff --git a/ATS_PROJECT_LLD.md b/ATS_PROJECT_LLD.md new file mode 100644 index 0000000..f0337f9 --- /dev/null +++ b/ATS_PROJECT_LLD.md @@ -0,0 +1,1083 @@ +# KAAUH Applicant Tracking System (ATS) - Low Level Design Document + +## 1. Introduction + +This document provides the Low-Level Design (LLD) for the KAAUH Applicant Tracking System (ATS). It details the technical specifications, database schema, API endpoints, and implementation details for the system. + +## 2. Database Design + +### 2.1 Entity-Relationship Diagram (ERD) + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ JobPosting │ │ Candidate │ │ ZoomMeeting │ +├─────────────────┤ ├─────────────────┤ ├─────────────────┤ +│ id (PK) │ │ id (PK) │ │ id (PK) │ +│ slug │ │ slug │ │ slug │ +│ title │ │ first_name │ │ topic │ +│ department │ │ last_name │ │ meeting_id │ +│ job_type │ │ email │ │ start_time │ +│ workplace_type │ │ phone │ │ duration │ +│ location_city │ │ address │ │ timezone │ +│ location_state │ │ resume │ │ join_url │ +│ location_country│ │ is_resume_parsed│ │ password │ +│ description │ │ stage │ │ status │ +│ qualifications │ │ exam_status │ │ created_at │ +│ salary_range │ │ interview_status│ │ updated_at │ +│ benefits │ │ offer_status │ │ │ +│ application_url │ │ match_score │ │ │ +│ status │ │ strengths │ │ │ +│ created_by │ │ weaknesses │ │ │ +│ created_at │ │ job (FK) │ │ │ +│ updated_at │ │ │ │ │ +│ │ │ │ │ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + └───────────────────────┼───────────────────────┘ + │ + ┌─────────────────┐ + │ ScheduledInterview │ + ├─────────────────┤ + │ id (PK) │ + │ candidate (FK) │ + │ job (FK) │ + │ zoom_meeting (FK)│ + │ interview_date │ + │ interview_time │ + │ status │ + │ created_at │ + │ updated_at │ + └─────────────────┘ + │ + ┌─────────────────┐ + │ FormTemplate │ + ├─────────────────┤ + │ id (PK) │ + │ slug │ + │ name │ + │ description │ + │ job (FK) │ + │ is_active │ + │ created_by (FK) │ + │ created_at │ + │ updated_at │ + └─────────────────┘ + │ + ┌─────────────────┐ + │ FormStage │ + ├─────────────────┤ + │ id (PK) │ + │ name │ + │ order │ + │ is_predefined │ + │ template (FK) │ + │ created_at │ + │ updated_at │ + └─────────────────┘ + │ + ┌─────────────────┐ + │ FormField │ + ├─────────────────┤ + │ id (PK) │ + │ label │ + │ field_type │ + │ placeholder │ + │ required │ + │ order │ + │ options │ + │ file_types │ + │ max_file_size │ + │ stage (FK) │ + │ created_at │ + │ updated_at │ + └─────────────────┘ + │ + ┌─────────────────┐ + │ FormSubmission │ + ├─────────────────┤ + │ id (PK) │ + │ slug │ + │ template (FK) │ + │ submitted_by (FK)│ + │ submitted_at │ + │ applicant_name │ + │ applicant_email │ + └─────────────────┘ + │ + ┌─────────────────┐ + │ FieldResponse │ + ├─────────────────┤ + │ id (PK) │ + │ submission (FK) │ + │ field (FK) │ + │ value │ + │ uploaded_file │ + │ created_at │ + │ updated_at │ + └─────────────────┘ + │ + ┌─────────────────┐ + │ MeetingComment │ + ├─────────────────┤ + │ id (PK) │ + │ meeting (FK) │ + │ author (FK) │ + │ content │ + │ created_at │ + │ updated_at │ + └─────────────────┘ +``` + +### 2.2 Detailed Schema Definitions + +#### 2.2.1 Base Model +```python +class Base(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + slug = RandomCharField(length=8, unique=True, editable=False) +``` + +#### 2.2.2 JobPosting Model +```python +class JobPosting(Base): + JOB_TYPES = [ + ("FULL_TIME", "Full-time"), + ("PART_TIME", "Part-time"), + ("CONTRACT", "Contract"), + ("INTERNSHIP", "Internship"), + ("FACULTY", "Faculty"), + ("TEMPORARY", "Temporary"), + ] + + WORKPLACE_TYPES = [ + ("ON_SITE", "On-site"), + ("REMOTE", "Remote"), + ("HYBRID", "Hybrid"), + ] + + STATUS_CHOICES = [ + ("DRAFT", "Draft"), + ("ACTIVE", "Active"), + ("CLOSED", "Closed"), + ("CANCELLED", "Cancelled"), + ("ARCHIVED", "Archived"), + ] + + title = models.CharField(max_length=200) + department = models.CharField(max_length=100, blank=True) + job_type = models.CharField(max_length=20, choices=JOB_TYPES, default="FULL_TIME") + workplace_type = models.CharField(max_length=20, choices=WORKPLACE_TYPES, default="ON_SITE") + location_city = models.CharField(max_length=100, blank=True) + location_state = models.CharField(max_length=100, blank=True) + location_country = models.CharField(max_length=100, default="Saudia Arabia") + description = CKEditor5Field(config_name='extends') + qualifications = CKEditor5Field(blank=True, config_name='extends') + salary_range = models.CharField(max_length=200, blank=True) + benefits = CKEditor5Field(blank=True, config_name='extends') + application_url = models.URLField(blank=True) + application_start_date = models.DateField(null=True, blank=True) + application_deadline = models.DateField(null=True, blank=True) + application_instructions = CKEditor5Field(blank=True, config_name='extends') + internal_job_id = models.CharField(max_length=50, primary_key=True, editable=False) + created_by = models.CharField(max_length=100, blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="DRAFT") + hash_tags = models.CharField(max_length=200, blank=True, validators=[validate_hash_tags]) + linkedin_post_id = models.CharField(max_length=200, blank=True) + linkedin_post_url = models.URLField(blank=True) + posted_to_linkedin = models.BooleanField(default=False) + linkedin_post_status = models.CharField(max_length=50, blank=True) + linkedin_posted_at = models.DateTimeField(null=True, blank=True) + published_at = models.DateTimeField(null=True, blank=True) + position_number = models.CharField(max_length=50, blank=True) + reporting_to = models.CharField(max_length=100, blank=True) + joining_date = models.DateField(null=True, blank=True) + open_positions = models.PositiveIntegerField(default=1) + source = models.ForeignKey("Source", on_delete=models.SET_NULL, null=True, blank=True) + max_applications = models.PositiveIntegerField(default=1000) + hiring_agency = models.ManyToManyField("HiringAgency", blank=True) + cancel_reason = models.TextField(blank=True) + cancelled_by = models.CharField(max_length=100, blank=True) + cancelled_at = models.DateTimeField(null=True, blank=True) +``` + +#### 2.2.3 Candidate Model +```python +class Candidate(Base): + class Stage(models.TextChoices): + APPLIED = "Applied", _("Applied") + EXAM = "Exam", _("Exam") + INTERVIEW = "Interview", _("Interview") + OFFER = "Offer", _("Offer") + + class ExamStatus(models.TextChoices): + PASSED = "Passed", _("Passed") + FAILED = "Failed", _("Failed") + + class Status(models.TextChoices): + ACCEPTED = "Accepted", _("Accepted") + REJECTED = "Rejected", _("Rejected") + + class ApplicantType(models.TextChoices): + APPLICANT = "Applicant", _("Applicant") + CANDIDATE = "Candidate", _("Candidate") + + STAGE_SEQUENCE = { + "Applied": ["Exam", "Interview", "Offer"], + "Exam": ["Interview", "Offer"], + "Interview": ["Offer"], + "Offer": [], + } + + job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="candidates") + first_name = models.CharField(max_length=255) + last_name = models.CharField(max_length=255) + email = models.EmailField() + phone = models.CharField(max_length=20) + address = models.TextField(max_length=200) + resume = models.FileField(upload_to="resumes/") + is_resume_parsed = models.BooleanField(default=False) + is_potential_candidate = models.BooleanField(default=False) + parsed_summary = models.TextField(blank=True) + applied = models.BooleanField(default=False) + stage = models.CharField(max_length=100, default="Applied", choices=Stage.choices) + applicant_status = models.CharField(max_length=100, default="Applicant", choices=ApplicantType.choices) + exam_date = models.DateTimeField(null=True, blank=True) + exam_status = models.CharField(max_length=100, null=True, blank=True, choices=ExamStatus.choices) + interview_date = models.DateTimeField(null=True, blank=True) + interview_status = models.CharField(max_length=100, null=True, blank=True, choices=Status.choices) + offer_date = models.DateField(null=True, blank=True) + offer_status = models.CharField(max_length=100, null=True, blank=True, choices=Status.choices) + join_date = models.DateField(null=True, blank=True) + match_score = models.IntegerField(null=True, blank=True) + strengths = models.TextField(blank=True) + weaknesses = models.TextField(blank=True) + criteria_checklist = models.JSONField(default=dict, blank=True) + resume_parsed_category = models.TextField(blank=True) + submitted_by_agency = models.ForeignKey("HiringAgency", on_delete=models.SET_NULL, null=True, blank=True) +``` + +#### 2.2.4 ZoomMeeting Model +```python +class ZoomMeeting(Base): + class MeetingStatus(models.TextChoices): + SCHEDULED = "waiting", _("Waiting") + STARTED = "started", _("Started") + ENDED = "ended", _("Ended") + CANCELLED = "cancelled", _("Cancelled") + + topic = models.CharField(max_length=255) + meeting_id = models.CharField(max_length=20, unique=True) + start_time = models.DateTimeField() + duration = models.PositiveIntegerField() + timezone = models.CharField(max_length=50) + join_url = models.URLField() + participant_video = models.BooleanField(default=True) + password = models.CharField(max_length=20, blank=True, null=True) + join_before_host = models.BooleanField(default=False) + mute_upon_entry = models.BooleanField(default=False) + waiting_room = models.BooleanField(default=False) + zoom_gateway_response = models.JSONField(blank=True, null=True) + status = models.CharField(max_length=20, null=True, blank=True) +``` + +#### 2.2.5 FormTemplate Model +```python +class FormTemplate(Base): + job = models.OneToOneField(JobPosting, on_delete=models.CASCADE, related_name="form_template") + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name="form_templates", null=True, blank=True) + is_active = models.BooleanField(default=False) +``` + +#### 2.2.6 FormStage Model +```python +class FormStage(Base): + template = models.ForeignKey(FormTemplate, on_delete=models.CASCADE, related_name="stages") + name = models.CharField(max_length=200) + order = models.PositiveIntegerField(default=0) + is_predefined = models.BooleanField(default=False) +``` + +#### 2.2.7 FormField Model +```python +class FormField(Base): + FIELD_TYPES = [ + ("text", "Text Input"), + ("email", "Email"), + ("phone", "Phone"), + ("textarea", "Text Area"), + ("file", "File Upload"), + ("date", "Date Picker"), + ("select", "Dropdown"), + ("radio", "Radio Buttons"), + ("checkbox", "Checkboxes"), + ] + + stage = models.ForeignKey(FormStage, on_delete=models.CASCADE, related_name="fields") + label = models.CharField(max_length=200) + field_type = models.CharField(max_length=20, choices=FIELD_TYPES) + placeholder = models.CharField(max_length=200, blank=True) + required = models.BooleanField(default=False) + order = models.PositiveIntegerField(default=0) + is_predefined = models.BooleanField(default=False) + options = models.JSONField(default=list, blank=True) + file_types = models.CharField(max_length=200, blank=True) + max_file_size = models.PositiveIntegerField(default=5) + multiple_files = models.BooleanField(default=False) + max_files = models.PositiveIntegerField(default=1) +``` + +#### 2.2.8 FormSubmission Model +```python +class FormSubmission(Base): + template = models.ForeignKey(FormTemplate, on_delete=models.CASCADE, related_name="submissions") + submitted_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="form_submissions") + submitted_at = models.DateTimeField(auto_now_add=True) + applicant_name = models.CharField(max_length=200, blank=True) + applicant_email = models.EmailField(blank=True) +``` + +#### 2.2.9 FieldResponse Model +```python +class FieldResponse(Base): + submission = models.ForeignKey(FormSubmission, on_delete=models.CASCADE, related_name="responses") + field = models.ForeignKey(FormField, on_delete=models.CASCADE, related_name="responses") + value = models.JSONField(null=True, blank=True) + uploaded_file = models.FileField(upload_to="form_uploads/", null=True, blank=True) +``` + +#### 2.2.10 ScheduledInterview Model +```python +class ScheduledInterview(Base): + candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE, related_name="scheduled_interviews") + job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="scheduled_interviews") + zoom_meeting = models.OneToOneField(ZoomMeeting, on_delete=models.CASCADE, related_name="interview") + schedule = models.ForeignKey(InterviewSchedule, on_delete=models.CASCADE, related_name="interviews", null=True, blank=True) + interview_date = models.DateField() + interview_time = models.TimeField() + status = models.CharField(max_length=20, choices=[ + ("scheduled", "Scheduled"), + ("confirmed", "Confirmed"), + ("cancelled", "Cancelled"), + ("completed", "Completed"), + ], default="scheduled") +``` + +#### 2.2.11 InterviewSchedule Model +```python +class InterviewSchedule(Base): + job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="interview_schedules") + candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True, null=True) + start_date = models.DateField() + end_date = models.DateField() + working_days = models.JSONField() + start_time = models.TimeField() + end_time = models.TimeField() + break_start_time = models.TimeField(null=True, blank=True) + break_end_time = models.TimeField(null=True, blank=True) + interview_duration = models.PositiveIntegerField() + buffer_time = models.PositiveIntegerField(default=0) + created_by = models.ForeignKey(User, on_delete=models.CASCADE) +``` + +#### 2.2.12 MeetingComment Model +```python +class MeetingComment(Base): + meeting = models.ForeignKey(ZoomMeeting, on_delete=models.CASCADE, related_name="comments") + author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="meeting_comments") + content = CKEditor5Field(config_name='extends') +``` + +## 3. API Design + +### 3.1 REST API Endpoints + +#### 3.1.1 Job Posting Endpoints +``` +GET /api/jobs/ # List all job postings +POST /api/jobs/ # Create new job posting +GET /api/jobs/{id}/ # Get specific job posting +PUT /api/jobs/{id}/ # Update job posting +DELETE /api/jobs/{id}/ # Delete job posting +``` + +#### 3.1.2 Candidate Endpoints +``` +GET /api/candidates/ # List all candidates +POST /api/candidates/ # Create new candidate +GET /api/candidates/{id}/ # Get specific candidate +PUT /api/candidates/{id}/ # Update candidate +DELETE /api/candidates/{id}/ # Delete candidate +GET /api/candidates/job/{job_id}/ # Get candidates for specific job +``` + +#### 3.1.3 Meeting Endpoints +``` +GET /api/meetings/ # List all meetings +POST /api/meetings/ # Create new meeting +GET /api/meetings/{id}/ # Get specific meeting +PUT /api/meetings/{id}/ # Update meeting +DELETE /api/meetings/{id}/ # Delete meeting +POST /api/meetings/{id}/join/ # Join meeting +``` + +#### 3.1.4 Form Template Endpoints +``` +GET /api/templates/ # List form templates +POST /api/templates/ # Create form template +GET /api/templates/{id}/ # Get specific template +PUT /api/templates/{id}/ # Update template +DELETE /api/templates/{id}/ # Delete template +POST /api/templates/{id}/submit/ # Submit form +``` + +### 3.2 WebSocket Events (HTMX) + +#### 3.2.1 Real-time Updates +```javascript +// Meeting status updates +event: 'meeting:status_update' +data: { meeting_id: '123', status: 'started' } + +// Candidate status updates +event: 'candidate:stage_update' +data: { candidate_id: '456', stage: 'Interview' } + +// Interview schedule updates +event: 'interview:schedule_update' +data: { interview_id: '789', date: '2025-10-20' } +``` + +## 4. Authentication & Authorization + +### 4.1 Authentication Flow +```python +# Authentication Middleware +class CustomAuthenticationMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # Check for session authentication + if 'user_id' in request.session: + request.user = get_user_from_session(request.session['user_id']) + # Check for JWT token + elif 'Authorization' in request.headers: + request.user = authenticate_jwt(request.headers['Authorization']) + else: + request.user = AnonymousUser() + + return self.get_response(request) +``` + +### 4.2 Permission Classes +```python +# Custom Permission Classes +class IsHiringManager(permissions.BasePermission): + def has_permission(self, request, view): + return request.user.groups.filter(name='Hiring Managers').exists() + +class IsRecruiter(permissions.BasePermission): + def has_permission(self, request, view): + return request.user.groups.filter(name='Recruiters').exists() + +class IsInterviewer(permissions.BasePermission): + def has_permission(self, request, view): + return request.user.groups.filter(name='Interviewers').exists() + +class IsCandidate(permissions.BasePermission): + def has_permission(self, request, view): + return request.user.groups.filter(name='Candidates').exists() +``` + +## 5. Business Logic Implementation + +### 5.1 Candidate Stage Transitions +```python +class CandidateService: + @staticmethod + def advance_candidate_stage(candidate, new_stage): + """Advance candidate to new stage with validation""" + if new_stage not in candidate.get_available_stages(): + raise ValidationError(f"Cannot advance from {candidate.stage} to {new_stage}") + + old_stage = candidate.stage + candidate.stage = new_stage + + # Auto-set exam date when moving to Exam stage + if new_stage == "Exam" and not candidate.exam_date: + candidate.exam_date = timezone.now() + timedelta(days=7) + + # Auto-set interview date when moving to Interview stage + if new_stage == "Interview" and not candidate.interview_date: + candidate.interview_date = timezone.now() + timedelta(days=14) + + # Auto-set offer date when moving to Offer stage + if new_stage == "Offer" and not candidate.offer_date: + candidate.offer_date = timezone.now() + timedelta(days=3) + + candidate.save() + + # Log stage transition + StageTransition.objects.create( + candidate=candidate, + old_stage=old_stage, + new_stage=new_stage, + changed_by=candidate.changed_by + ) + + return candidate +``` + +### 5.2 Interview Scheduling Logic +```python +class InterviewScheduler: + @staticmethod + def get_available_slots(schedule, date): + """Get available interview slots for a specific date""" + # Get working hours + start_time = datetime.combine(date, schedule.start_time) + end_time = datetime.combine(date, schedule.end_time) + + # Apply break times + if schedule.break_start_time and schedule.break_end_time: + break_start = datetime.combine(date, schedule.break_start_time) + break_end = datetime.combine(date, schedule.break_end_time) + end_time = break_start # Remove break time from available slots + + # Calculate available slots + slots = [] + current_time = start_time + + while current_time < end_time: + slot_end = current_time + timedelta(minutes=schedule.interview_duration + schedule.buffer_time) + if slot_end <= end_time: + slots.append({ + 'start': current_time.time(), + 'end': slot_end.time(), + 'available': True + }) + current_time = slot_end + + # Filter out booked slots + booked_slots = ScheduledInterview.objects.filter( + interview_date=date, + job=schedule.job + ).values_list('interview_time', 'interview_time') + + for slot in slots: + if slot['start'] in booked_slots: + slot['available'] = False + + return slots + + @staticmethod + def schedule_interview(candidate, job, schedule_data): + """Schedule an interview with candidate""" + # Create Zoom meeting + zoom_meeting = create_zoom_meeting( + topic=f"Interview: {job.title} with {candidate.name}", + start_time=schedule_data['start_time'], + duration=schedule_data['duration'] + ) + + # Create scheduled interview + interview = ScheduledInterview.objects.create( + candidate=candidate, + job=job, + zoom_meeting=zoom_meeting, + interview_date=schedule_data['start_date'], + interview_time=schedule_data['start_time'].time(), + status='scheduled' + ) + + return interview +``` + +### 5.3 Form Submission Processing +```python +class FormSubmissionService: + @staticmethod + def process_submission(template, submission_data, files=None): + """Process form submission and create candidate""" + with transaction.atomic(): + # Create submission record + submission = FormSubmission.objects.create( + template=template, + applicant_name=submission_data.get('applicant_name', ''), + applicant_email=submission_data.get('applicant_email', '') + ) + + # Process field responses + for field_id, value in submission_data.items(): + if field_id.startswith('field_'): + field_id = field_id.replace('field_', '') + try: + field = FormField.objects.get(id=field_id, stage__template=template) + response = FieldResponse.objects.create( + submission=submission, + field=field, + value=value if value else None + ) + + # Handle file uploads + if field.field_type == 'file' and files: + file_key = f'field_{field_id}' + if file_key in files: + response.uploaded_file = files[file_key] + response.save() + except FormField.DoesNotExist: + continue + + # Create candidate if required fields are present + try: + first_name = submission.responses.get(field__label='First Name') + last_name = submission.responses.get(field__label='Last Name') + email = submission.responses.get(field__label='Email Address') + phone = submission.responses.get(field__label='Phone Number') + address = submission.responses.get(field__label='Address') + resume = submission.responses.get(field__label='Resume Upload') + + candidate = Candidate.objects.create( + first_name=first_name.display_value, + last_name=last_name.display_value, + email=email.display_value, + phone=phone.display_value, + address=address.display_value, + resume=resume.get_file if resume.is_file else None, + job=template.job + ) + + return submission, candidate + except Exception as e: + logger.error(f"Candidate creation failed: {e}") + return submission, None +``` + +## 6. Error Handling Strategy + +### 6.1 Custom Exception Classes +```python +class ATSException(Exception): + """Base exception for ATS system""" + pass + +class CandidateStageTransitionError(ATSException): + """Raised when candidate stage transition fails""" + pass + +class InterviewSchedulingError(ATSException): + """Raised when interview scheduling fails""" + pass + +class FormSubmissionError(ATSException): + """Raised when form submission processing fails""" + pass + +class ZoomAPIError(ATSException): + """Raised when Zoom API calls fail""" + pass + +class LinkedInAPIError(ATSException): + """Raised when LinkedIn API calls fail""" + pass +``` + +### 6.2 Error Response Format +```python +class ErrorResponse: + def __init__(self, error_code, message, details=None): + self.error_code = error_code + self.message = message + self.details = details + self.timestamp = timezone.now() + + def to_dict(self): + return { + 'error': { + 'code': self.error_code, + 'message': self.message, + 'details': self.details, + 'timestamp': self.timestamp.isoformat() + } + } +``` + +## 7. Caching Strategy + +### 7.1 Cache Configuration +```python +# settings.py +CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': 'redis://127.0.0.1:6379/1', + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + } + } +} + +# Cache timeouts +CACHE_TIMEOUTS = { + 'job_listings': 60 * 15, # 15 minutes + 'candidate_profiles': 60 * 30, # 30 minutes + 'meeting_details': 60 * 5, # 5 minutes + 'form_templates': 60 * 60, # 1 hour +} +``` + +### 7.2 Cache Implementation +```python +class CacheService: + @staticmethod + def get_or_set(key, func, timeout=None): + """Get from cache or set if not exists""" + cached_value = cache.get(key) + if cached_value is None: + cached_value = func() + cache.set(key, cached_value, timeout or CACHE_TIMEOUTS.get(key, 300)) + return cached_value + + @staticmethod + def invalidate_pattern(pattern): + """Invalidate all keys matching pattern""" + keys = cache.keys(pattern) + if keys: + cache.delete_many(keys) +``` + +## 8. Logging Strategy + +### 8.1 Logging Configuration +```python +# settings.py +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'file': { + 'level': 'INFO', + 'class': 'logging.FileHandler', + 'filename': 'logs/ats.log', + 'formatter': 'verbose', + }, + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'formatter': 'simple', + }, + }, + 'loggers': { + 'ats': { + 'handlers': ['file', 'console'], + 'level': 'DEBUG', + 'propagate': False, + }, + }, +} +``` + +### 8.2 Audit Logging +```python +class AuditLogger: + @staticmethod + def log_action(user, action, model, instance_id, details=None): + """Log user actions for audit trail""" + AuditLog.objects.create( + user=user, + action=action, + model=model, + instance_id=instance_id, + details=details + ) + + @staticmethod + def log_candidate_stage_transition(candidate, old_stage, new_stage, user): + """Log candidate stage transitions""" + CandidateStageTransition.objects.create( + candidate=candidate, + old_stage=old_stage, + new_stage=new_stage, + changed_by=user + ) +``` + +## 9. Security Implementation + +### 9.1 Data Encryption +```python +from cryptography.fernet import Fernet + +class EncryptionService: + def __init__(self): + self.key = Fernet.generate_key() + self.cipher = Fernet(self.key) + + def encrypt(self, data): + """Encrypt sensitive data""" + return self.cipher.encrypt(data.encode()).decode() + + def decrypt(self, encrypted_data): + """Decrypt sensitive data""" + return self.cipher.decrypt(encrypted_data.encode()).decode() +``` + +### 9.2 File Upload Security +```python +class SecureFileUpload: + @staticmethod + def validate_file(file): + """Validate uploaded file for security""" + # Check file size + if file.size > 10 * 1024 * 1024: # 10MB + raise ValidationError("File size exceeds 10MB limit") + + # Check file type + allowed_types = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ] + if file.content_type not in allowed_types: + raise ValidationError("Invalid file type") + + # Scan for malware (placeholder) + if not SecureFileUpload.scan_malware(file): + raise ValidationError("File contains malicious content") + + return True + + @staticmethod + def scan_malware(file): + """Placeholder for malware scanning""" + # Implement actual malware scanning logic + return True +``` + +## 10. Testing Strategy + +### 10.1 Unit Test Structure +```python +class CandidateTestCase(TestCase): + def setUp(self): + self.job = JobPosting.objects.create( + title="Software Engineer", + department="IT", + status="ACTIVE" + ) + self.candidate = Candidate.objects.create( + first_name="John", + last_name="Doe", + email="john@example.com", + phone="1234567890", + job=self.job + ) + + def test_candidate_stage_transition(self): + """Test candidate stage transitions""" + # Test valid transition + self.candidate.stage = "Exam" + self.candidate.save() + self.assertEqual(self.candidate.stage, "Exam") + + # Test invalid transition + with self.assertRaises(ValidationError): + self.candidate.stage = "Offer" + self.candidate.save() +``` + +### 10.2 Integration Tests +```python +class InterviewSchedulingTestCase(TestCase): + def setUp(self): + self.job = JobPosting.objects.create( + title="Product Manager", + department="Product", + status="ACTIVE" + ) + self.candidate = Candidate.objects.create( + first_name="Jane", + last_name="Smith", + email="jane@example.com", + phone="9876543210", + job=self.job + ) + self.schedule = InterviewSchedule.objects.create( + job=self.job, + start_date=timezone.now().date(), + end_date=timezone.now().date() + timedelta(days=7), + working_days=[0, 1, 2, 3, 4], + start_time=time(9, 0), + end_time=time(17, 0), + interview_duration=60, + buffer_time=15, + created_by=self.user + ) + + def test_interview_scheduling(self): + """Test interview scheduling process""" + # Test slot availability + available_slots = InterviewScheduler.get_available_slots( + self.schedule, + timezone.now().date() + ) + self.assertGreater(len(available_slots), 0) + + # Test interview scheduling + schedule_data = { + 'start_date': timezone.now().date(), + 'start_time': timezone.now().time(), + 'duration': 60 + } + interview = InterviewScheduler.schedule_interview( + self.candidate, + self.job, + schedule_data + ) + self.assertIsNotNone(interview) +``` + +## 11. Deployment Configuration + +### 11.1 Production Settings +```python +# settings/production.py +DEBUG = False +ALLOWED_HOSTS = ['your-domain.com'] +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'ats_db', + 'USER': 'ats_user', + 'PASSWORD': 'secure_password', + 'HOST': 'localhost', + 'PORT': '5432', + } +} +STATIC_ROOT = '/var/www/ats/static/' +MEDIA_ROOT = '/var/www/ats/media/' +SECURE_SSL_REDIRECT = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +``` + +### 11.2 Docker Configuration +```dockerfile +# Dockerfile +FROM python:3.11-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . +EXPOSE 8000 + +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "kaauh_ats.wsgi:application"] +``` + +```yaml +# docker-compose.yml +version: '3.8' +services: + web: + build: . + ports: + - "8000:8000" + depends_on: + - db + - redis + environment: + - DEBUG=0 + - DATABASE_URL=postgresql://ats_user:secure_password@db:5432/ats_db + - REDIS_URL=redis://redis:6379/0 + + db: + image: postgres:13 + environment: + - POSTGRES_DB=ats_db + - POSTGRES_USER=ats_user + - POSTGRES_PASSWORD=secure_password + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:6-alpine + volumes: + - redis_data:/data + +volumes: + postgres_data: + redis_data: +``` + +## 12. Monitoring & Analytics + +### 12.1 Performance Monitoring +```python +# monitoring.py +class PerformanceMonitor: + @staticmethod + def track_performance(func): + """Decorator to track function performance""" + def wrapper(*args, **kwargs): + start_time = time.time() + result = func(*args, **kwargs) + end_time = time.time() + + # Log performance metrics + PerformanceMetric.objects.create( + function_name=func.__name__, + execution_time=end_time - start_time, + timestamp=timezone.now() + ) + + return result + return wrapper +``` + +### 12.2 Analytics Dashboard +```python +# analytics.py +class AnalyticsService: + @staticmethod + def get_recruitment_metrics(): + """Get recruitment performance metrics""" + return { + 'total_jobs': JobPosting.objects.count(), + 'active_jobs': JobPosting.objects.filter(status='ACTIVE').count(), + 'total_candidates': Candidate.objects.count(), + 'conversion_rate': AnalyticsService.calculate_conversion_rate(), + 'time_to_hire': AnalyticsService.calculate_average_time_to_hire(), + 'source_effectiveness': AnalyticsService.get_source_effectiveness() + } + + @staticmethod + def calculate_conversion_rate(): + """Calculate candidate conversion rate""" + total_candidates = Candidate.objects.count() + hired_candidates = Candidate.objects.filter( + stage='Offer', + offer_status='Accepted' + ).count() + + return (hired_candidates / total_candidates * 100) if total_candidates > 0 else 0 +``` + +--- + +*Document Version: 1.0* +*Last Updated: October 17, 2025* diff --git a/NorahUniversity/__pycache__/settings.cpython-313.pyc b/NorahUniversity/__pycache__/settings.cpython-313.pyc index 73b3ff6bc6398c42ee692c11f2a017c6573339af..ceb77b7016671275dec900f07a50675e5dc511eb 100644 GIT binary patch delta 1249 zcmYLH+fy5L6yDt|34~D6kU$|^0_7H3Hk4a|a!U&W3WV@ctBAHCSwgsE!$|7ey?)d#2ro?M>?%JsUOjly_RWZs+;Gx)l#&N7l!YX!2njeB70HWo^1)7(a8nh| z!-EQVLt>W*pCWaMbODu5#m;B$6L~s^#KZ)LC~u8W2$I zTwaGpG;v-dE?U@ZVM~`7lBpi8)W8zpj&`&u5;dY7K_=IP4!XEff=ivHg24To1Gr2r z%(9iG4V}~ug@T+AI!)+cj(SLP>Y}9NubgqE%jl*~^f24rsL0qC713|4rQj-sFhE@x zq$?PrZd{`t3{x*l9}nrr2wlY}4PcDt4`Q5#Sgs*V!?;c(xIv?spfOD1HB4a|CA`55 zTP1isk9xd;S9-7w=j6HttwD zhE-FC>=Z_vt|LJ=pkj^JNt&O4U!{O`YrPb&w}Jd7CkxoJLfaflTfP<*SHde{WEAO4 zV~2?mvdCc!3w@|Jywd$^BJOgkj=7kr=;;Zq)E-k&Kpj(o1z zXaWyuGAT*HM}_ZfKiLZR9W!#iHL(#(Cp9Ips;tK1n`%0dRWe#Omn1d2vvoU@J&0?W zWVcbC)+n~Vn@;ViluhLhsG9#bL+Zr`{ex8%>1Mw&xhw2Z(?%MC|(Eyw<3!Z zhCCg?k|8fd7MG}n%kEB}4vlLCkLzcfy@rFo+w?!mX9wrlqzFS$GUP2SoivKF>l)>P zE<;YowpGKiuhAxRFdU@D58_)Yos{Tl_l~$+_|+W|8`sPx8zyX;V7Xgn&j6`bKwt3q z+*UYg`s*g7^j*)CxTgQ;xzzk$tx=rQ654DkoBO{DZJ+dTRiwm(zE{!9Mc-B|Gupia zVq72de(8N;raWaZ99kxq($ZN<>o2^+qNWFY9hG01C-PoO-KVe3%x5NK^gF(GaYujT z8)%E02}P;xSW-2LaOIoo zx#I~@D0Lk=j_pEa^>e|)x}oX0;J38?*#E44=*Zn@KHioixBtjp`B$04Z9le^lu3sr g$4!Ni+8t5y`t;M1& delta 1196 zcmX|9No*Ts6rO*)HE|pq$IiYrN$WUGX3}PDnzd~!_{P$qWmEb$4}*v^Zej({Oj=`g;7pbsGtZ!sMJ-e))l;z6ck06V(?Q9s;Cwb zR3nO*F8Ty&G__Aq9crJ5-u=2k^+iElz>j)PZb(W|qvq8GHKK`{IJr4hhL+x-qM+4H zZblnUvZ@X3E_S%s*(Y=_wcr%BvbAwNKTc~ZokSd6JX$*v)WMayWhJiOi8C#9if25{ z7Do?tp_dXY(2fM%JfYC-f5q^A-|?K(gR|6&b3AE(QZNoA1qR)zGz^iBVd}#OoyB=N zhf(Us1sY%*^~gsZs5UXrg~)l`7(x)!7*pHJzGyj#o~g`(rn7bL%s-In)h2^ss_$edWVpGD4e{$I;EQ8csWV%|EE%c^uo!m>S8 zofkLl@2Uql>G$dzjHA&Jk+QAmqv&T+@;L*y-74nIf<-yIHZ~?!?d!4bn#c0SdoOS7 z&=aY&CFJcdVqId*{xLQZPf39aRB4plWB=6nRju}0xbDlbU&HZd nK2O+x=<$WrgR;Y>(*34wVgKDcd7`=Ajf|E0TZV-9Pj2uZ!CW_E diff --git a/NorahUniversity/__pycache__/urls.cpython-313.pyc b/NorahUniversity/__pycache__/urls.cpython-313.pyc index 180c13025542d3fa57f1adc045c870ed2295a34e..27f06b27b7edfe5394584126f3ebde17daa845d4 100644 GIT binary patch delta 19 Zcmca3ct?=yGcPX}0}yze*~oQ?0{}Y`1*HH0 delta 19 Zcmca3ct?=yGcPX}0}vE*Z{)hf0RT8p1rGoK diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index e2d8d8f..38db2a2 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -59,7 +59,6 @@ INSTALLED_APPS = [ 'django_q', 'widget_tweaks', 'easyaudit' - ] @@ -67,13 +66,13 @@ INSTALLED_APPS = [ SITE_ID = 1 -LOGIN_REDIRECT_URL = '/dashboard/' +LOGIN_REDIRECT_URL = '/' ACCOUNT_LOGOUT_REDIRECT_URL = '/' -ACCOUNT_SIGNUP_REDIRECT_URL = '/dashboard/' +ACCOUNT_SIGNUP_REDIRECT_URL = '/' LOGIN_URL = '/accounts/login/' @@ -135,11 +134,14 @@ WSGI_APPLICATION = 'NorahUniversity.wsgi.application' DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': 'norahuniversity', + 'USER': 'norahuniversity', + 'PASSWORD': 'norahuniversity', + 'HOST': '127.0.0.1', + 'PORT': '5432', } } - # Password validation # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators @@ -249,6 +251,7 @@ Q_CLUSTER = { 'workers': 8, 'recycle': 500, 'timeout': 60, + 'max_attempts': 1, 'compress': True, 'save_limit': 250, 'queue_limit': 500, @@ -257,7 +260,7 @@ Q_CLUSTER = { 'redis': { 'host': '127.0.0.1', 'port': 6379, - 'db': 0, }, + 'db': 3, }, 'ALT_CLUSTERS': { 'long': { 'timeout': 3000, @@ -369,4 +372,4 @@ CKEDITOR_5_CONFIGS = { } # Define a constant in settings.py to specify file upload permissions -CKEDITOR_5_FILE_UPLOAD_PERMISSION = "staff" # Possible values: "staff", "authenticated", "any" \ No newline at end of file +CKEDITOR_5_FILE_UPLOAD_PERMISSION = "staff" # Possible values: "staff", "authenticated", "any" diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md new file mode 100644 index 0000000..c4b77f3 --- /dev/null +++ b/TESTING_GUIDE.md @@ -0,0 +1,312 @@ +# Recruitment Application Testing Guide + +This guide provides comprehensive information about testing the Recruitment Application (ATS) system. + +## Test Structure + +The test suite is organized into several modules: + +### 1. Basic Tests (`recruitment/tests.py`) +- **BaseTestCase**: Common setup for all tests +- **ModelTests**: Basic model functionality tests +- **ViewTests**: Standard view tests +- **FormTests**: Basic form validation tests +- **IntegrationTests**: Simple integration scenarios + +### 2. Advanced Tests (`recruitment/tests_advanced.py`) +- **AdvancedModelTests**: Complex model scenarios and edge cases +- **AdvancedViewTests**: Complex view logic with multiple filters and workflows +- **AdvancedFormTests**: Complex form validation and dynamic fields +- **AdvancedIntegrationTests**: End-to-end workflows and concurrent operations +- **SecurityTests**: Security-focused testing + +### 3. Configuration Files +- **`pytest.ini`**: Pytest configuration with coverage settings +- **`conftest.py`**: Pytest fixtures and common test setup + +## Running Tests + +### Basic Test Execution +```bash +# Run all tests +python manage.py test recruitment + +# Run specific test class +python manage.py test recruitment.tests.AdvancedModelTests + +# Run with verbose output +python manage.py test recruitment --verbosity=2 + +# Run tests with coverage +python manage.py test recruitment --coverage +``` + +### Using Pytest +```bash +# Install pytest and required packages +pip install pytest pytest-django pytest-cov + +# Run all tests +pytest + +# Run specific test file +pytest recruitment/tests.py + +# Run with coverage +pytest --cov=recruitment --cov-report=html + +# Run with markers +pytest -m unit # Run only unit tests +pytest -m integration # Run only integration tests +pytest -m "not slow" # Skip slow tests +``` + +### Test Markers +- `@pytest.mark.unit`: For unit tests +- `@pytest.mark.integration`: For integration tests +- `@pytest.mark.security`: For security tests +- `@pytest.mark.api`: For API tests +- `@pytest.mark.slow`: For performance-intensive tests + +## Test Coverage + +The test suite aims for 80% code coverage. Coverage reports are generated in: +- HTML: `htmlcov/index.html` +- Terminal: Shows missing lines + +### Improving Coverage +1. Add tests for untested branches +2. Test error conditions and edge cases +3. Use mocking for external dependencies + +## Key Testing Areas + +### 1. Model Testing +- **JobPosting**: ID generation, validation, methods +- **Candidate**: Stage transitions, relationships +- **ZoomMeeting**: Time validation, status handling +- **FormTemplate**: Template integrity, field ordering +- **InterviewSchedule**: Scheduling logic, slot generation + +### 2. View Testing +- **Job Management**: CRUD operations, search, filtering +- **Candidate Management**: Stage updates, bulk operations +- **Meeting Management**: Scheduling, API integration +- **Form Handling**: Submission processing, validation + +### 3. Form Testing +- **JobPostingForm**: Complex validation, field dependencies +- **CandidateForm**: File upload, validation +- **InterviewScheduleForm**: Dynamic fields, validation +- **MeetingCommentForm**: Comment creation/editing + +### 4. Integration Testing +- **Complete Hiring Workflow**: Job → Application → Interview → Hire +- **Data Integrity**: Cross-component data consistency +- **API Integration**: Zoom API, LinkedIn integration +- **Concurrent Operations**: Multi-threading scenarios + +### 5. Security Testing +- **Access Control**: Permission validation +- **CSRF Protection**: Form security +- **Input Validation**: SQL injection, XSS prevention +- **Authentication**: User authorization + +## Test Fixtures + +Common fixtures available in `conftest.py`: + +- **User Fixtures**: `user`, `staff_user`, `profile` +- **Model Fixtures**: `job`, `candidate`, `zoom_meeting`, `form_template` +- **Form Data Fixtures**: `job_form_data`, `candidate_form_data` +- **Mock Fixtures**: `mock_zoom_api`, `mock_time_slots` +- **Client Fixtures**: `client`, `authenticated_client`, `authenticated_staff_client` + +## Writing New Tests + +### Test Naming Convention +- Use descriptive names: `test_user_can_create_job_posting` +- Follow the pattern: `test_[subject]_[action]_[expected_result]` + +### Best Practices +1. **Use Fixtures**: Leverage existing fixtures instead of creating test data +2. **Mock External Dependencies**: Use `@patch` for API calls +3. **Test Edge Cases**: Include invalid data, boundary conditions +4. **Maintain Independence**: Each test should be runnable independently +5. **Use Assertions**: Be specific about expected outcomes + +### Example Test Structure +```python +from django.test import TestCase +from recruitment.models import JobPosting +from recruitment.tests import BaseTestCase + +class JobPostingTests(BaseTestCase): + + def test_job_creation_minimal_data(self): + """Test job creation with minimal required fields""" + job = JobPosting.objects.create( + title='Minimal Job', + department='IT', + job_type='FULL_TIME', + workplace_type='REMOTE', + created_by=self.user + ) + self.assertEqual(job.title, 'Minimal Job') + self.assertIsNotNone(job.slug) + + def test_job_posting_validation_invalid_data(self): + """Test that invalid data raises validation errors""" + with self.assertRaises(ValueError): + JobPosting.objects.create( + title='', # Empty title + department='IT', + job_type='FULL_TIME', + workplace_type='REMOTE', + created_by=self.user + ) +``` + +## Testing External Integrations + +### Zoom API Integration +```python +@patch('recruitment.views.create_zoom_meeting') +def test_meeting_creation(self, mock_zoom): + """Test Zoom meeting creation with mocked API""" + mock_zoom.return_value = { + 'status': 'success', + 'meeting_details': { + 'meeting_id': '123456789', + 'join_url': 'https://zoom.us/j/123456789' + } + } + + # Test meeting creation logic + result = create_zoom_meeting('Test Meeting', start_time, duration) + self.assertEqual(result['status'], 'success') + mock_zoom.assert_called_once() +``` + +### LinkedIn Integration +```python +@patch('recruitment.views.LinkedinService') +def test_linkedin_posting(self, mock_linkedin): + """Test LinkedIn job posting with mocked service""" + mock_service = mock_linkedin.return_value + mock_service.create_job_post.return_value = { + 'success': True, + 'post_id': 'linkedin123', + 'post_url': 'https://linkedin.com/jobs/view/linkedin123' + } + + # Test LinkedIn posting logic + result = mock_service.create_job_post(job) + self.assertTrue(result['success']) +``` + +## Performance Testing + +### Running Performance Tests +```bash +# Run slow tests only +pytest -m slow + +# Profile test execution +pytest --profile +``` + +### Performance Considerations +1. Use `TransactionTestCase` for tests that require database commits +2. Mock external API calls to avoid network delays +3. Use `select_related` and `prefetch_related` in queries +4. Test with realistic data volumes + +## Continuous Integration + +### GitHub Actions Integration +```yaml +name: Tests +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.9, 3.10, 3.11] + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-django pytest-cov + - name: Run tests + run: | + pytest --cov=recruitment --cov-report=xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 +``` + +## Troubleshooting Common Issues + +### Database Issues +```python +# Use TransactionTestCase for tests that modify database structure +from django.test import TransactionTestCase + +class MyTests(TransactionTestCase): + def test_database_modification(self): + # This test will properly clean up the database + pass +``` + +### Mocking Issues +```python +# Correct way to mock imports +from unittest.mock import patch + +@patch('recruitment.views.zoom_api.ZoomClient') +def test_zoom_integration(self, mock_zoom_client): + mock_instance = mock_zoom_client.return_value + mock_instance.create_meeting.return_value = {'success': True} + + # Test code +``` + +### HTMX Testing +```python +# Test HTMX responses +def test_htmx_partial_update(self): + response = self.client.get('/some-url/', HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + self.assertIn('partial-content', response.content) +``` + +## Contributing to Tests + +### Adding New Tests +1. Place tests in appropriate test modules +2. Use existing fixtures when possible +3. Add descriptive docstrings +4. Mark tests with appropriate markers +5. Ensure new tests maintain coverage requirements + +### Test Review Checklist +- [ ] Tests are properly isolated +- [ ] Fixtures are used effectively +- [ ] External dependencies are mocked +- [ ] Edge cases are covered +- [ ] Naming conventions are followed +- [ ] Documentation is clear + +## Resources + +- [Django Testing Documentation](https://docs.djangoproject.com/en/stable/topics/testing/) +- [Pytest Documentation](https://docs.pytest.org/) +- [Test-Driven Development](https://testdriven.io/blog/tdd-with-django-and-react/) +- [Code Coverage Best Practices](https://pytest-cov.readthedocs.io/) diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..864753a --- /dev/null +++ b/conftest.py @@ -0,0 +1,394 @@ +""" +Pytest configuration and fixtures for the recruitment application tests. +""" + +import os +import sys +import django +from pathlib import Path + +# Setup Django +BASE_DIR = Path(__file__).resolve().parent + +# Add the project root to sys.path +sys.path.append(str(BASE_DIR)) + +# Set the Django settings module +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings') + +# Configure Django +django.setup() + +import pytest +from django.test import TestCase +from django.contrib.auth.models import User +from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils import timezone +from datetime import datetime, time, timedelta, date + +from recruitment.models import ( + JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField, + FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview, + TrainingMaterial, Source, HiringAgency, Profile, MeetingComment, JobPostingImage, + BreakTime +) +from recruitment.forms import ( + JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm, + CandidateStageForm, InterviewScheduleForm, BreakTimeFormSet +) + + +# Removed: django_db_setup fixture conflicts with Django TestCase +# Django TestCase handles its own database setup + + +@pytest.fixture +def user(): + """Create a regular user for testing""" + return User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123', + is_staff=False + ) + + +@pytest.fixture +def staff_user(): + """Create a staff user for testing""" + return User.objects.create_user( + username='staffuser', + email='staff@example.com', + password='testpass123', + is_staff=True + ) + + +@pytest.fixture +def profile(user): + """Create a user profile""" + return Profile.objects.create(user=user) + + +@pytest.fixture +def job(staff_user): + """Create a job posting for testing""" + return JobPosting.objects.create( + title='Software Engineer', + department='IT', + job_type='FULL_TIME', + workplace_type='REMOTE', + location_country='Saudi Arabia', + description='Job description', + qualifications='Job qualifications', + created_by=staff_user, + status='ACTIVE', + max_applications=100, + open_positions=1 + ) + + +@pytest.fixture +def candidate(job): + """Create a candidate for testing""" + return Candidate.objects.create( + first_name='John', + last_name='Doe', + email='john@example.com', + phone='1234567890', + job=job, + stage='Applied' + ) + + +@pytest.fixture +def zoom_meeting(): + """Create a Zoom meeting for testing""" + return ZoomMeeting.objects.create( + topic='Interview with John Doe', + start_time=timezone.now() + timedelta(hours=1), + duration=60, + timezone='UTC', + join_url='https://zoom.us/j/123456789', + meeting_id='123456789', + status='waiting' + ) + + +@pytest.fixture +def form_template(staff_user, job): + """Create a form template for testing""" + return FormTemplate.objects.create( + job=job, + name='Test Application Form', + description='Test form template', + created_by=staff_user, + is_active=True + ) + + +@pytest.fixture +def form_stage(form_template): + """Create a form stage for testing""" + return FormStage.objects.create( + template=form_template, + name='Personal Information', + order=0 + ) + + +@pytest.fixture +def form_field(form_stage): + """Create a form field for testing""" + return FormField.objects.create( + stage=form_stage, + label='First Name', + field_type='text', + order=0, + required=True + ) + + +@pytest.fixture +def form_submission(form_template): + """Create a form submission for testing""" + return FormSubmission.objects.create( + template=form_template, + applicant_name='John Doe', + applicant_email='john@example.com' + ) + + +@pytest.fixture +def field_response(form_submission, form_field): + """Create a field response for testing""" + return FieldResponse.objects.create( + submission=form_submission, + field=form_field, + value='John' + ) + + +@pytest.fixture +def interview_schedule(staff_user, job): + """Create an interview schedule for testing""" + # Create candidates first + candidates = [] + for i in range(3): + candidate = Candidate.objects.create( + first_name=f'Candidate{i}', + last_name=f'Test{i}', + email=f'candidate{i}@example.com', + phone=f'12345678{i}', + job=job, + stage='Interview' + ) + candidates.append(candidate) + + return InterviewSchedule.objects.create( + job=job, + created_by=staff_user, + start_date=date.today() + timedelta(days=1), + end_date=date.today() + timedelta(days=7), + working_days=[0, 1, 2, 3, 4], # Mon-Fri + start_time=time(9, 0), + end_time=time(17, 0), + interview_duration=60, + buffer_time=15, + break_start_time=time(12, 0), + break_end_time=time(13, 0) + ) + + +@pytest.fixture +def scheduled_interview(candidate, job, zoom_meeting): + """Create a scheduled interview for testing""" + return ScheduledInterview.objects.create( + candidate=candidate, + job=job, + zoom_meeting=zoom_meeting, + interview_date=timezone.now().date(), + interview_time=time(10, 0), + status='scheduled' + ) + + +@pytest.fixture +def meeting_comment(user, zoom_meeting): + """Create a meeting comment for testing""" + return MeetingComment.objects.create( + meeting=zoom_meeting, + author=user, + content='This is a test comment' + ) + + +@pytest.fixture +def file_content(): + """Create test file content""" + return b'%PDF-1.4\n% ... test content ...' + + +@pytest.fixture +def uploaded_file(file_content): + """Create an uploaded file for testing""" + return SimpleUploadedFile( + 'test_file.pdf', + file_content, + content_type='application/pdf' + ) + + +@pytest.fixture +def job_form_data(): + """Basic job posting form data for testing""" + return { + 'title': 'Test Job Title', + 'department': 'IT', + 'job_type': 'FULL_TIME', + 'workplace_type': 'REMOTE', + 'location_city': 'Riyadh', + 'location_state': 'Riyadh', + 'location_country': 'Saudi Arabia', + 'description': 'Job description', + 'qualifications': 'Job qualifications', + 'salary_range': '5000-7000', + 'application_deadline': '2025-12-31', + 'max_applications': '100', + 'open_positions': '1', + 'hash_tags': '#hiring, #jobopening' + } + + +@pytest.fixture +def candidate_form_data(job): + """Basic candidate form data for testing""" + return { + 'job': job.id, + 'first_name': 'John', + 'last_name': 'Doe', + 'phone': '1234567890', + 'email': 'john@example.com' + } + + +@pytest.fixture +def zoom_meeting_form_data(): + """Basic Zoom meeting form data for testing""" + start_time = timezone.now() + timedelta(hours=1) + return { + 'topic': 'Test Meeting', + 'start_time': start_time.strftime('%Y-%m-%dT%H:%M'), + 'duration': 60 + } + + +@pytest.fixture +def interview_schedule_form_data(job): + """Basic interview schedule form data for testing""" + # Create candidates first + candidates = [] + for i in range(2): + candidate = Candidate.objects.create( + first_name=f'Interview{i}', + last_name=f'Candidate{i}', + email=f'interview{i}@example.com', + phone=f'12345678{i}', + job=job, + stage='Interview' + ) + candidates.append(candidate) + + return { + 'candidates': [c.pk for c in candidates], + 'start_date': (date.today() + timedelta(days=1)).isoformat(), + 'end_date': (date.today() + timedelta(days=7)).isoformat(), + 'working_days': [0, 1, 2, 3, 4], + 'start_time': '09:00', + 'end_time': '17:00', + 'interview_duration': '60', + 'buffer_time': '15' + } + + +@pytest.fixture +def client(): + """Django test client""" + from django.test import Client + return Client() + + +@pytest.fixture +def authenticated_client(client, user): + """Authenticated Django test client""" + client.force_login(user) + return client + + +@pytest.fixture +def authenticated_staff_client(client, staff_user): + """Authenticated staff Django test client""" + client.force_login(staff_user) + return client + + +@pytest.fixture +def mock_zoom_api(): + """Mock Zoom API responses""" + with pytest.MonkeyPatch().context() as m: + m.setattr('recruitment.utils.create_zoom_meeting', lambda *args, **kwargs: { + 'status': 'success', + 'meeting_details': { + 'meeting_id': '123456789', + 'join_url': 'https://zoom.us/j/123456789', + 'password': 'meeting123' + }, + 'zoom_gateway_response': {'status': 'waiting'} + }) + yield + + +@pytest.fixture +def mock_time_slots(): + """Mock available time slots for interview scheduling""" + return [ + {'date': date.today() + timedelta(days=1), 'time': '10:00'}, + {'date': date.today() + timedelta(days=1), 'time': '11:00'}, + {'date': date.today() + timedelta(days=1), 'time': '14:00'}, + {'date': date.today() + timedelta(days=2), 'time': '09:00'}, + {'date': date.today() + timedelta(days=2), 'time': '15:00'} + ] + + +# Test markers +def pytest_configure(config): + """Configure custom markers""" + config.addinivalue_line( + "markers", "slow: marks tests as slow (deselect with '-m \"not slow\"')" + ) + config.addinivalue_line( + "markers", "integration: marks tests as integration tests" + ) + config.addinivalue_line( + "markers", "unit: marks tests as unit tests" + ) + config.addinivalue_line( + "markers", "security: marks tests as security tests" + ) + config.addinivalue_line( + "markers", "api: marks tests as API tests" + ) + + +# Pytest hooks for better test output +# Note: HTML reporting hooks are commented out to avoid plugin validation issues +# def pytest_html_report_title(report): +# """Set the HTML report title""" +# report.title = "Recruitment Application Test Report" + + +# def pytest_runtest_logreport(report): +# """Customize test output""" +# if report.when == 'call' and report.failed: +# # Add custom information for failed tests +# pass diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..df55272 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,20 @@ +[tool:pytest] +DJANGO_SETTINGS_MODULE = NorahUniversity.settings +python_files = tests.py test_*.py *_tests.py +python_classes = Test* +python_functions = test_* +addopts = + --verbose + --tb=short + --strict-markers + --durations=10 + --cov=recruitment + --cov-report=term-missing + --cov-report=html:htmlcov + --cov-fail-under=80 +testpaths = recruitment +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + integration: marks tests as integration tests + unit: marks tests as unit tests + security: marks tests as security tests diff --git a/recruitment/__pycache__/admin.cpython-313.pyc b/recruitment/__pycache__/admin.cpython-313.pyc index c67547f63693a27f6f29f7f3ddd02c317c30233c..87d1eb6ef934949ad2caa421144aeb960fc6f302 100644 GIT binary patch delta 3374 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 G&i?>N;+sVP diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index 606d3d5232fc90cadb5d792b2c14d414f54e98a6..231e31656ff18b148a3dbb083afa2d1f3dad26d9 100644 GIT binary patch delta 6443 zcma)A3v83u7549U?8JFMfIvw8m?Yqkgb>0bJc1Jl5FX)S5-3e^jr|jxI(E2rpkyts zKr890ZP2T=bY+ZgeTB!?)~dQqQ=yYKwVF18s%i9BSFx^|)Jb%u38i9Pwe#KI4o+fV z7Wp{$+;i?d_ndRjz4w3mx6-*^Nsi&19Gi}Q{pY^i_aE;G$29#;!-+Z1l&_!3X09(` zC9=+=^Vl|&u9ulC>!#_PI*)z2&Xe6GZENB4r%d9xdTXvz@N-;p(yIMdQN*4#NlvWWQ8$M9W(KZE$iWB65q zKa=<+WB79gzm)j$7=E?jml5Ad{9UQ_m)3S!oVu=BkKQxOQ{HG`^MqUl$yJWguM_;) z#Gf;UKVR@&#IG8|uNVBe#IH{0n-+A}xN7+v!?Ti&Uo=F^RmUbpi3P*mt0KLMd6Kz~RHw1r~|I}lV3ChCnLeW8;=&1M100TqBso-p^9R}*>LzBj^pYoba>@yA>y zHeQ-6t*h|<>tP=P|)v-1tVeE6Z3T|4BljQfLcI3LAxs_ zqlNhpSPXyzSR-I5zyMeQXac}~F%WZuY8K8B%iGI~^V;HqE85y@mQ1s$7OyuD@q4|R zW5$n(f&D31PDC`9;7Q$w#&x%hR+DW+N1)9QG5n=|iHk*VN-}~zFsT(uC--VODaRsx zK|h;IJ*<|Wm@(yHI7c3lwsbZ2@crYy)@zW0Sj13;Yi188Wh+5O@Hv3m}pdu99W>2`x|C`F~5^ zu5hDwH^2+<5u_(`512jt!_s6j_wd(?x)NUbs8l8bH8~k5S_KRGLUJq^3n{5c8Jw=I zh?L8~j$xdq0N(@LD>MZVxJPIncNX0zG~eZKIiE>|X49-TX$e9zVCb${pcyiVDPYZj z)gKAf^ag4cY4L$^16Gogd-v2dunqjr<&E~VaPadb(|A?IrJcSH*Ac z=h-7rKEjt(COvxuWg>C7@~lBRmT*-yNoL`*D&9M{Zn`K2NNQBa{ry3v1hlenlD|E- z<^i!Yp2vW71TMW+JT8FoW55dps<|Z+jYY!j6dL+X&Fm*c4CHi#gE1w5q7zf1NmbQ! zz!&OQ*crZ}dg_#)LBSY6hz}6SehwJoJ=L=s4uL#NFfeJ>z96IXqk2|PWKX0|2@}m; zMo&&hds}BseO>*+n!39A>=piQb*1@LD89z8SC>fVcy^65u4RNH<7S-%;X4^&Kyw)Y zrA^IRO_sHV`}$+-F);7FZJ#BPcj+&>SC6uEHE;T$=}WyOaiQiReJW-0>W)hBH!;?0 z1gd#k@Q^RCPk3=4S8K>ySmZ!3>{o<42eLiB{y#>1$RYz(06wMuO`cm4SHzR~nE6I96`FVQQA~mSWp04D z1PG%(qGn7YhdGF#TyxAEG+oUr9z6WCSrZjfBJ+pLV>7=s{qm7%0u@sMP_3icVuD+G zy{TkGN1zR_ijs1w=_M(Fo>ACX&&=ot>vHt-8F= z&$?^kC_bu@MGi)_a~18|dE%Z&cSQdqH9>4XD)Rq;BI3?I1!!A3^Z7d&0(V>}%IQ+@ zMmM)=-L5=J8Lsfo=KT8<{7?9k%`c^PZ(YmxGoQuqLE2e7&f3q=D^BIy6Y7* z_r$_7{nhlbV6TrIl6zxxk{pJ@gng@}B457x(q=aVb`uO`jSX%xw&Sw*?wNWG16|`A zT4!r(yf&Jtms*eCGgHS^&P<6Ua&VYt_!x<#UDiZZyjoCpIo~-kRoTZdXEfh)w1mF_ z<_#VHXw5Xu$Qz?Zwyb?QfSj3do3&?j%Q%}wLfrihi*dygzSi*#>l)hJqYROL-qX+dZc@{1{|3}IIzDrK($C+F8hUj7`FKCNvA2ix>>z02_*zifcFgSad6~P0j~*g*VX80C7)w8m<5L^|HTH^3O97_l(vi^(2*7cTnBL*8bVE%n7r{BsSPV& zMsx|&qtv7msFVIvO(EYNB{X1d4F}}yL8@o>TGu{)eN(Bc4|-N59Pq}VkZCz)%w^^a z+q<;vH7A~HZPp}UnJ;PqQV$9a(?c)%sA}FD4ADhUySn5-)+BBQ zsxcbs?;ii+Fp;iIzbCZhZRh{o-bXiv9UU*!IiYJ{?_kajOkoIyafpa(20B$kz;`H$ zRHOmuvf=C;oC=4y3MTl2zuP%Ss^pKh+>M2NizADlYFg_p82eFV4Sm`OF4oh*SqoC8BT)9^1xI&X*+==P1cHK2y<7d- z$CRj_W*z-mlGJb~?^{tE*W=5N$IxXZG%h4d@bj2z^&Ydw(x|VKSe8dGdabEm;@Lb| z%-(Ht+105NZLHm-#C*X6j^!h3_=Ufc>)OY*}|a=T?uhyiJ^X@EO9IUP-Lw2Lw#&iC<~FAf<|ESA&SVRIBM z6q6E^#u>+F44SWH7oLb;m1}>!|8ny~AFSMcWs3J|am5XjVVdI$gK3g&(DH>tmow$W z{*y)gu`dkz0$X}H^(UQ5!|HT+QR9#sUPRm`=|_CH_|w_gOXBCTJ~(xh&Pw^)W1~nI z7V7zlRj#pp6A#%4NRBF^Mk9 zTP06o?ars9cn9gb%=f8k_pFI*Hf#~)j?GDt+MLZW9Vx+DsRL6*&rk~qQZ{H(^LqyQ z=3UROcSAymX`_6MHpsM5R$*j-2csmt9l*Vji zYKz~nOdEM>++&!hXJ(I)S-MRwvsy4lTicR&bFwm0qHmg1FYsbZonM)+6-6YQwxPyv z9`v>V+9X|EhtZ>`*6@uJn`i1t`{n%d^u3VuaVGDqg?9j0=%{z}rV6|$Dm3q)yWQ2G zngcz)aCb!9)>RvQEC>epx(EGIBR~J(6Bexw6meHqX&h;#W^IZDln}hgieWC?$a(-r z034tKt^sZUz5wu^luB{nJI)^AEeK=HAj7B>hdBPFgf#bxF44t4=Uk7))api2D zsdC^dfE&g=IqMIaD!7C7Ucp=Gt?JMbH3 z`BM6p`7L873UtjpmT^+pTqx)LKW@I zezc+$nyT@5C^#a=h#Pq{0+NG}h0sR-J-geu1)IBP>MVu)ew=@rCwuUZnXJd2CWK~$ z7KAQ@-3XfjREtkrOF#~XeLfg^AFxp#po=iY4it6JXL72v%{l1R96P^_Zf@-;aB#%r zH4*a=x0xpyRh(p|S-HRG8|kgwwQ)Q@(tuEp-~kwrEUT_59|blb;Fd`nLOX&E;cEz+ z5OBpz5n<=d*4Kqx5CET|DE2tFm}j4)-^pt$*RBJ&0PZOHF+n32zDw-_{ZWmR=jg9% zoL18icYihqE;Ph@xFMmJZ{rLT=a)6&ksyP{{3&I8HI_zh_X;2Cl?GTtnJt$gm-?(!sp6E_iGa?h*FX zaa4}gvPm0&37kIBrQRnyk7v2aKZN)}Z&78xTW zFfG}IfR?BVW5+Z(D_FYE|Bycv_8$(*zG%!pB1ct&7yqgTAmuQcNSsqmfuoU7K#r0@ z)PfcxnaMD=zmBjUK(z$M36YhUrbo?7SFHJ(g7VDj&Cn9H&neCL+3MEzBsas>u`NR4 zqYXY^ND0M!zJ3@J{RH6iQ`|>(=LN@k$HiTjt@HNQN&Qnf*UXkDOz)eEr>bVn(#3V} zo2BbUE<5jZ=jqO=;05KpvQX9t{}xNc3j^l|7D_yGC7#JWi_XdmC(oZ;aCXl*yXT#~ zv)Ls#%v@E=l8!IhpjI|d#jCIDID45U)I8^GzT7wO+_>QEnsau|7j6BZvKjg<*6SR| ze^l3Wde;-Xrc86$;=g3OVE+1!6!9xYG_<|Jzb^ead$zuBwr<;GZ>k&Q7wcM95Fr1* ze=Ovyi#9}!u;+I&JiSHw+k6Yp5WRNEC{U%gR~K}0^oP5ga3QOx6vfKwbyDOhxR7NHUSosK4SP<`0xRYr;F%`u z!~QU{bdg!B7$bfq8j6J?3Nx>XHTQ&Ne^eH=93uKPv4#mRSd|iqt&kUwkjSW5QSA!E zHM^Cf6=E!cj3OV0hX!$LI2<`H2UiS=ZWOCsevOu*6GFW>Q7{luWYKjbr1-;NoKk%K;PS3mK>4H4?Ex5c$E%sll$Br!3m`QX^TnEnQUvW7jSv@S#vQNwo9n2qz z-0epa0n=jC3Gn&j+z0v{pXtqp%o`lQr&gxnF6Js*L`&ETj&$Lcsb)zALK-x+c z0lBLd0hsUZTm*DL?M>Na*F70xhOJ2pIqn~fbtC-M@xSO@rJ&&Te#0hgC5_XRU-L2YvTx1-`iKB@urwfe^5m}olCdXs(Em~L4_|iBQ#g2v{=77C&{H$l zOW)P;&&^yZ@8pf_JYDqj3XcO_fY~_~{eM0lB63ixeP5>Cm2S;@UO|}#07-Kh@-7mW z5ncsQjop!G406Y7_#o`rN@k!jVHtvA1L8i!#F!jS3{?#e`NQKf`4LLwSG9WTJsywf z7OUD>i@c61^sESb3YsSJ_kiRr`e|jQ@iaEC(y}VM?Z?oB2?qLy-A$gR4Q`KTJ$Zxf zu3BgO2@1VQAFe9n-=@!2Rm3%8$ywEX3sHEvhQ}I!pwqJQUCltp)%^4vk{FQgT+UyD z-nZc!Jp$Pm%X!D7;g5o8roH-pL62@h|EzY+%quk!zVJO%^d^9691NZC2al3h>3Yd_ z0MBnC+v^_>hQzJJe>mi4CkaJj2uJ)ALdtSfb4Y>gtpsH9Aa+2B$!Ue+=Nu zIr>V&(I!m=6AjA>nTMA4`h)u5#)pwWV~fI>K>8$GXn*7Ld?)=~<0t$T`fJY?`?VF4 zS8kJRSWjWa@2ua*m!#$)Si!BBLPv&aadQcKom6e~fo2=s+x&w=cv5$GZ99nyx)88V zCJP83BK#KNcL*f#VB(tkNqwYI4TE@xkH!>A+q1Zfq3uaQv}UfSC8yg z6j(mOHhgSHNV%pH%d4Cu2RPxJ9^)RmT7@eEev*0j~$ z!rp#>iQGh%-Iq9)3G3mQBEr`_Mna>0azY%9L8kZ)4p_}Sfv4BLc6Dzj3Sjz_GH$=z z#NDP@@0~661}eHiKi*WM?eRv+QXklS=B}2S786%9q%Kr0fDwI&hLj6WiFmhIP(GVq z@kLW5f4~tbd$(v60|%oC$J0kTiy9?KV53e7mx}Oj@Lfu}j=_+5lLOFP$H3FKbBfveFL(WA ze1$!)9um^*`Hlx|fhqqJzR8dP(ZH}+PEV(05StFxyPWwm#m-=6jbuw!MQ_>6_a-vx$N$(CC@$9e>LkS%q$= zzv$l{{{{A?Eqo0s#w&myL*3YJK+pzZ%Y(2Up$VaxaUebbW&L{8*M!iF(1Kt?SdN?Q zp8gTJV!j6OE0i{M^vcdx6S0yW+x1pKBMz^Ji6v8_AfcOfJ6c6jR5|<9?lJKHKkfdmrxC~M5|s&vsk)GYS%TFO*s1D*{u5E$0w{oL;=MhS zMQEc^!56kpM+RJc2R$=TF@P&o3-^qNW1+FI+{1o^+ZK|;L4rC-7bwuGfi8T!b5JeC z?$UF6AJN>XblHvmdS8XmF3ebV_<8CYu8D7idPg#T$@Sg!@<|I$#{7@q?>EcQQ;SG! z(f!bYQ$;Vr7SNZyX<{e+8`u4t{9u!$Sz7<+oHxtUVp7@M zx%b@t-gC}9%f0Vkf35xK8@i+q6BDf(_*?p)w+~!9cRDFs_}1x)im5ImGj^GnNz_Oh zNx#|LWnmVI8#Y_JY|Pe`z!E5J+??2z#F8j(+HCJiX2}#cZ%*k-WvLXmY)I7uU72SuAy_KF(xR~PQi7!eEMuUgL5K6xXjmCRGXa_vEjd+AuxxDhP0km2LttF@vpfv+E@ztp#a4mrARE3ulbU8p*#MW#D z!Ri6lpek8U(3JpPrGhq$tadcMkF#_LvT-ahb?Y_Z~ ztaJG%AlDr7jd>>ney>9-TRTSso=~UHJ31s=+75VFX9C8dl)lXq49&c+^$J1?->Tmr zyJtR*gX!1U-&K zR?goVNaMdT)HqWCBI|>r<0DFmWzk|vttw1rMS~Q+*|^I@&1Gr$iw|e>`yEyMH^zP; zkGGk&S(N4zBF$ek)htiI=1pxbsm;~iA?wG3Ud9&dq8_`zAdjb*S1Dc8Yxwv7CR_OB z%mMR{1%pY-Zq#v?wbGU&nWS7PuTjVH_%Cb)wtOia;`udzulyxzL83{r3=~L(QckO73~ zoC8MgsZCFpYM`n~s)c`bjV4wbt&*}_T0uGWFw8oD4q-Jz1He{tLt>H1!0G{dkcPBA zQPkJKsddqm)WSW9l~b*Nv91QKQi3%~>vJ`1jnoEV({@e0 zAhqXe4w_lB)B%vSQYVD#qzw?Z475rcb2SZ0MGbR7dXp->9@1T^bQ`2MtJ3X|?#|V8 z#v14Vz!p`3PDuBt(i^0$Y~yy#T7hjU(>Qu%Ygb@@Zy*@*`A6O_;zH7US=(VOY!w9W zr(>)G0*8)m=N~0y=5K|>WTNC5AM$xb7xV1*c^q0sHFonld!-HQv6UFy%}4A-Mx`xQ@4HRhyO#ByP=}9FTAU(r2K{7T93djN2XaZ^jK#i7JBr~&)SR7W_9^Xpm z7H`P&{%ictX_Z@mI~s!_PiQ>IYGEL<2@48@P<(OE4K?Md& zA&|`hHsoc1AuN|~P0yLaqfj<%!tIhxv}@krqI`$A;xQ=w8vG050s_-aFFw`sxcNp_ z+2aW}3ai5Aymu2)Pj)}qeN}9oO>o^%64RN}=Gg?tO@l7Y`jjcoV+Vlot3z>f> z2#@ju*`-yFLH7F}!5>S5n5?T^IRqRWq0CGGf8eOBpYVDZYvW(bZq0uZp?VJ+3ChMp zN0r~h%scEm#?JDzoTfZt>gTYOdP9PqBi_H|zMSvF_P%wzR zL&SkD$zLP``QdyV3OhqR4|zgfHfLr(FIXbH%MFD%^`v`p_>RJG4(=&n z`RDM*!tTZDN*jEA(UJP3faN7#NwMM=tRA+#bJYbRy^rx?z<(Zugic zGKd@vyxjY(H|{J~eBfx~b8)w0+tSHJW5e3ki!}(y~habaReSA6>sG zNq{vHU_Ep_vdM+}>m340Hvs*QcrR$#4{+=+V4%hzNXzNLLFV=PfymVpC{j&eu>yaF zs=<5+Xd2hE?IpAJlIwQItle?Z61F?S_BCPinmZ0t-*L<{#;)O*euTmNf!V!~uO3sb zYE1L;G=N^J;iyMrq|vMjn^)a&G?q<%*ykfY@6kI9Dcu(>q#l=gtrjbw`?$fuTG?oH z&0%x%9Y<%_Iy?*_-p4OiasUgcN0p~qt@)Xzj~kKWqKO8yI&7|4P0Sb0xxd5RA;$Pk zj3G~PbS*e9_!}i<2BH!E?rsza^3QL{F;2Z|8WAHT$-h@ClA7+8WUKW~Q#Ld2g8ALZf z#+Z!3&oTHV2FoCj6W#8?QBN>9=mqk3Ul7=NfYQO!%IGv`!=f0NiZG{2Kl4)gGQr$` zMgN`l*}QH12TOLEQ!=JY&ik*7@T!W>TP~(wZvT#cHf;;PUh$xKBPHXO-JE1R(Rnjf zlU;Z{Yx&izpI6+ftJ3~T~FyR1>sdms*akKuc8y6okrw>8exRQ3w#pyGq1C1pmB z6e+7w%THJ3nX(C0?7Myb*0V#!tZN+IlSJPtILow_%>%X{XXFA zpx^IC(tmJP^Vgi&1as9WW$vj_%DfRtzh6^rs~@P68l;t=T2=E0Y72w`I$EJ+X*JEE zF*b@djYg)}0-8YCX^ts7wbI&wI%(a&@_N100&0$)tr)16S_c~HwUP_d(c_z~B={bpv%MUO+Br zUaH)dSS`@R2(I+cS}DUdP&WWgMyi5xrmDI=Ruz;q6}&Cdp{j1AJ}7Uha_zDDpv1-D zQ>-JF0m@uFgLTH>pwy}08)ERSbKn~R-m6moHbHuuD%}O?K2>@%X{%EbG-77mQa`Au zTcF5xRgoS@?@*<;N;{!cFF<#xpxYpQmnz){>D{WdB<*4Sq=#+?ab*CkGEuR~mn`ta zhHO62pTpNRl=AoLT1tH>$VILqPW%LepJDI|3>1E0Rg5A+4L`WNycn4>WyGy4vOz)- zh^0b1DFVN^e7#XIwD5$60p7nNqsW&9S+cPg__%jSHg`a8ZR+l5UySp}RupQWM^w6UpxrF~qZJiOkA>9Z-uhIq>fBSG9kJ@n>A~!3YwPIk zhd#`G9bFx55L>htW26TWddTDN)JJ+KhaScYi*Py?k^w;?4Y6cM&z(*iO~>Nu?d{&w z=IZH(J#h83Z)$h-cP!dnVyvon5M*_AE zWRwd!m8zY~{VTIpZpUpz-Fec|4MsY#$1~>bmkk|bur(mddZO8E7jBE8_W;=XSU>;D z%HlP2o?z9^$;3WyaD2=w_IkilI0W@}N9#?d^#(#-f5_(<6=T@PQ&y$blIo7tl`-#t z7$^hOAq|R}AZr5^TFSSs%HB(9%AqoGa{-o_v>--^?VuLGOy}j~blgztX94njfM8p{c?A7U@}V}O>42zG)E0_~?$h@Hwi1U+GILBxSaH9yJ9K9k3fKc4WD!lLPf7Xop6!*tfPWj0}1)Zmamoiv+J6E`?y zKqn|I$~v$($%Z4I(Q)sfj?Z39H@5=#Q<_~OzvrHG({`Z(lH2)|D+AOoSPD?UM9qlK zp`UEBZtwz1`Ub@@Z|Fc^NDKwUpf@CfOezLpOGdpSv|UY2i-$cv2023_S_+(2IeET1 z3mR+U_+eBm6%97n1ArCYR|MVZXj%23t2bf4WSO%8?*dCChNqkjQG4&wNK*A3zV4~ zHOcAM?Pas}vg`Jxv-YLu3oZ-SR4VMpPi{-SQ|t$t3xT0vRP>LJ?e{Vz?m)93XMdPPrA^k8{J6UZT4aLQ>^&m9}aX>B#^p+g)FFwy5n z_0&5g`UC#zkazHapC%PNQ4P zn+hm~D4OilqO+C3q@XUUtd3k5-_6DHKciXz^E}iIJKC?n0&EPMHxju<$&{aIPWt$r z-B|~CygmRV=JAV5K@a!V40}ew!-jJuTOm-WZ&(Cl7VWX$I}(8N6KrVWn9uJU8y^E= zzGvLWykkJlfZ7Z)ydkHY4B2kTazo0!A3ErSbpU=fAB*MmNWoYeSVO%Ki{-zpT8M>s z-E?{THG5mw+%{(+fC}!+ue$115MG3U2YJ|#ZxZmDfwdG?V5!$R;;a%&Tw=Aj(Km8n zbVA%V?iuxgWpStk;OhY%2RNK^X54rdL^;&0H1tPZPmN1K2=_d@7rP7bKQ5Oz7jNN< zcbvKFO2ak#rm%U_0vf2%sCct>vcIkt=Q;{b)F`5d4uCxnga&{N1z^!ZhNR<(d5I!I z;(YoRfdLRKFd7)~d4b-OLV?5X73i@!3Lb=D4DER+BHyjd=$FQ*rEr}@PO$Y#d6bCJ)T^T0+POQ})gUerk&*eu!2W~Y z!B7-e=QAxj`iNV5J%b0t;9>6|;O(F|$b4Y=g!9QMr$*u)_uv8V;333{c{A)&qzZ;y zMb3}UaAC!7-f?YY^g94qh^PW9s?iQJ38gqp(UrUf&d zM~Y2#%hjYf>hX_^qs5Gf52gcB;xLG!{=o?m2oWwq0D}d|33Q(!V6)NU?ns$=`xe=@ zgSc(ExNR-usc_D(_U$|FTGQpe|J~8Lnx?WJM4e#~lrZ$)B6~S{U}ElBD*8|g0xSyv zbA{b?g18z2VjKw_2+9knOChc?oP%1F4b?62x=b3kcj&) zGIlSVX>*85gd-BRA$S1XNr-?)4|qaRa`GrgpbsQJ|Ah|G>l9g>bdV}>FyJ5b1-+n~fL{Y;9c8b4 zlX%wARKbC!>`MwVKG-3*9~`1^7{dLVIrEq^jSx;FuPr{01(mPkbUs!fZN3b9V8keF1IG@G0gZs>1XL2^3WHJA0?w(qMeZ=8qd24W zs>5n-&3{o%XQ4o*KCHk6%ma-B2nZ(x#mfXe-?w!VL(0&J;XqzJfV;v7E})wHP>5wxEWpSPdAqfy=d_PwN-ogki*EH zh5=G5?0IZ&BPP)RASX%vt_>Y-sbkyrj-Iv-&}y-3*%BBXa+9(=dt z@#1m#`nu;zs5SCwCPI_4Vc5rjd8ikbA8IKV{)eA#Sq{}cDuR1mE#uWwP#BcvPh%Ym zsGoKUox@lxr zZh)Wn_!Gy&<^tH2sk{4yDL(DWPkDZRW&Y1Dht{Iy|K%!zz7SFB)<3*<6551Kc?nCu zi0yw02c9q)2#kS*?8Wmix^U(o9NR6wkBv2?g)JDc$;?R1!%!x zirfppCaG~+$*)3E*5d^_+2}n6=YKGY5NG(Mjt01I@gE%x+B7XM?!1sl=D#u=-wwre zvsH)HZT;S3p|%47-ykr%EF5s8LxD+D=HZu4u4IT5x>-7>FIx_@aR)Zu+)uI`l^Iwx#O!{nVSf73~Ta#DJ)4HpsjOd?);AWD+u=0#_Qv*Ne({AI>%}jDl zY7I?iqz^Ul6LC3042)6ewbU9qgz4>=*CE6*JA_!SOPF4PxvqGwE1Ih_Y!apir#{qR zBvKipHjMF z70ZR5(JPCSY7?g0&!?RqJfD582aC1E`)G^z(X6!59BZLD);`R;Y&vwl?PA*bjpvTf zmaN29<}N~{uPMWtGqgl&V$H0HHPdva4J$6T?&~8fyFNa&_0gf}3@y{JHt-W^6Jj^5 zFgNrrH$-3%L%%V1cYj)Oz$Obbf9~EQv{eFYWKKXc+0c(S(pU||`{6ok0Hu%+8vtwu z1|7G>-wl9C>kc^N17bJa0D#+>pXe!GO1A!3sYz=a^Mb@aB<`Pp+HtdXE)T5k=Z>ve z+Lc?QPB=#1R zt$`_jsGQMw8caSh88+uAJz!rwj*N)y8yTp=0cl*+WHP{H6Ub_`I={x06(yd48?QsK zui{GThk-3BJQcfP$h+7rjKwcLEH<_8VmSQ!PHo88Z?;3PxR#!nDGuTwU8dm&47WVN9*QfQf^i(7wcxs%gvsX zn;FeDDRpzu$DbDMCpT6$kKdGvGotYPC>(AL*Q4LOv;chMc@kXXXZcbQD;N^=@9Oif)NqVW;fyi6mo6q-_)}~ zHi5|0hYmtRM^8I=2M=O}MK)GWjC3q1hu{s^;ubBqKePFlwlABallxzB?;~^=IP4o# z=#ZRhlh#OeaZ@`803Pgx98*OY%Z1>NR%jX2o_-J&SRJ$^o7=}xF$nnK9t@UX`yr6c zSO72MPnx>|KEJrVuX{3uckf6o#x<19=ypXT5%fYG5IvJSDwj{~*s6Hslr*-9LIjAE z@d3p>SJdb3C{^}GWHh+-N^@I4mpHgI)ebq`ATThu`w;97n}u%F2Aq5dbpWNSnobNhxE-B+@Ll)*w^FM>7tZ(5C^$Qmia7tyl$Agl}B!~7lp_0B%+ z+X8p(%3c00_Hr+FKqvVkd(3`}MKD;0#QP|Jd{?7`ZWHytC0wccd;2Y&Hr05d3%GD< z_VtwVt10E@LvIyd>Hhng*_6JSU+oeEClPs{OHh#~A ztSKWEF-1v+iX;pqLOxbC$KV#p3gntZ??7yD(Ig>i$76OW5eO?;N@6J^c1N@Zd4_Pq@}}GHDg;z8j8}9PU2p+MG6bIEvr%5gd~}k|Rb&I+;8V5M(IcD-?gh zq9B=P-idSqODwki#MwWBabaIU5sH!5Gn}*RT2>|bJ;UPiYlSPo^GSEFV^3C4gAxw% zq{j+*&-yezwcgCLzPx(M78xZRpyH%n1eMvLmyI5pznlQ>H8;L9LB&)yMb?QS-Y-1! zU?_6tyj6*i*ONF_B#slB8!7o``d`{}cF*;KhS`FKscQwR!uhK&>o51eIq=57^|f1P z*KQ3r_lE7;!scyiYD`^l`4Brax!ps7LAP6R7?XNI1kwtb3X$reZ$`(X7Grp>GPJ8G zXXMn!(%B!d3I99wAX4*2dciG&4lDmYL1!qv1)^5z zZ>@r%Pf*t34|F>7^ew6Uif6SRX^Q)I=kZ>PnV;UBE9m*FyPM#mF#ea&Xym`y?a@A? z<=ghOWdAJ;zyE^z(uD`tp`X97r`SR)*bQm^?LDPc0f@*+U`-u!hrDBlM?ty@UNESx zYs8tahbIp#5$>UT1H-CU3<*Hu2Jnr?-Z0bxV6it0DV@+wf20?MgwEk6P4Z_4N~daZ zi1*`gEO5u$1D8a>{zT^_o5C!NDl1ckF`|qlsE$np@kmiUKUjj#51gv&0Lle`=yia+ z>BQLtXZ$(Rv51ypqn+&`N`S&} zyz5>rT;yr{Dw62rS0s$=h|Gzm3WaD|u$MLrS}};L6|%-QSJgdFu<82+4#lX`7Kg{lqlKYfU(m7lLQBhJ$Aq( zDy6ctku*m-m>K{jLCP8N`Y!S3_BWQ};`TEOj=dkIJeh(QlcH`j+Er%N^4t5D0wEL( z-Ya~IpB*ey)Qt;~dtTKiaIHvGc>rUJEeY!`W%Of*Fkhzd8~P!0s0VH(AKtMvkvUQiZMbT6o43YiufWY0wKfgk0E$K10D<&=3wuOu8WB6VQ>ll z1(EKN4f7qtIl6FfKMxG=Q`Sl(%=oWoCo{Nyq}d9*2V@eE)H6}PfM<+m$pUzg5QW&S z0|vQc{+b1SPU~cqE69A9z5R<-b3$rF{kmTMGf$ z;4Wc&ImK~%j)Cmw^-NmO8U|11y!c@5hmbpILlc&`{V*7ah5+_e{x1=s~W;H9_86ugNSMLo&{otL;bnZn#yCb@UiGw3}4)>OEXg5zO(0o*xR zu=4=iNU_TZ<4H2&0~(>szt^rhXKv?5}NWG#cK z#NxpSKQ6;DF2;{h2c}lau>|pZxcs-wX*^`08N9gpAmW@X3B<9TrB5gt;CMTAE8&MDSO6puSs`X6>q_GiOA>zV` zElOdvSQvv@_!r#9Yl9Wq@AS{?3|0w>RiIW&R)O?qCpQ^Mkhh?$&yC}GHWW3C@XrL| z#+!egn?SOM872Aw1HU+uWw8JRxG}so{>|<@l1+dmL+aFcx#^QIe&1>x|K|86mqG<{ z@-U#5o2;*NdnyR49XINbn%Io<@IU}mgg-0?gMQw8B(D-teKM1-j7G^MW*G)jq2knt zTZ4+Z(5VtyoN=M0Mu2yg(YuXl#1hdSOe0dHlpY=b{*n4AIU#N+2D>lHDiMM}MzttG zY=uSc0kR4rE}?)@8cr+22a-!sLa9|rD1~9Mb~d5zrcS%?Q6$ubKfdee>AiR~OeU4z zRb4(-T|Ok1Z(LKp1+YDa=&Todl+B>#4wFhcpMD78Gv`e*g7rIey!Tj>T71Ys z=kfoTHA=le`zJ_0rt#1T0T1YuH;G`jM+Jku4hRu@s0ctW-u+b_67h302UkzA(;*fy z3|8Z+ewJ??F4EtPQpM0jc|N|SNi?M!Pjuf*)}-fNPpi0^R&g=?O8acuCO$n;UItHZ z`omkivzfyuw*FopwOao+Sul)5M2=4iJb5}>aL&X2!O22J@;J3SPX}bc&rO!n?>|l+ z)9!}H_4jsI(Il@RHa~T5N%3LasYJr?aXJTrHGq_4&{Sk<5~TN`rv2=FMtl};(kqv3 z;az&JLy5w@TQG?+3*Ea-B_7omac3#0>DTe{`^s`Zjmtv9Gq}j$;je<1j);M2DlUw` zs%hAR7(9eQe2wEvOiW}qpphS!xE6{5;$~GK!q%t|cCJXR+_#-8QbSGja{%Ukx;dG@ zecz#Cwg;MJ#Qtc+3$T;--k+;IqT{3YR|7jo_vXy~ZFAk4+T*496F9OIXc_#WHv4s3 z0sIdiY`9@hC#UE@K}4k9huyg`Q1`^Ye!N^erQ^Rij?Y^aAL`Buyd?`86tD6quBo~n z^aqdD&Q6KiM|AufQ|PwEv+Yk)hXkV^3xAQ1KCojc^)0Y}!1mzX5cDn!M`D6JHj;}l zCx80EJZ^X}P0MvW_d!Q}yl0Z7@R~1Mi(@CIe-SHUn{$T5CmyWPKBeQYJjm2^yHM;W zxs8aY6&s?}r=x&&gw7V~@T=GZtyo;nC$|@kZV-n zR@LVcB3C8}A<=o=C z_}*uF922k-F!~Aqpa}X8c!5E9i1$k(veHddvtM&3%^rGyy9%@vQAO&%aN$yM(JLc% zz@FwmAqR--iyTEW=Z~DKFrsOV)$-R*EioZtfm+VrIkjRb9_kB3!TpyXoT;&`#HNnokly2#vz6La0=M6j&3n!s z>W*j&CvkOX(T!+%3iy>rD+`za6q7SIftPMX@q=^+Mxy9aspp308{kY(Y{sd4`iPN_ zKEKZB)I-~o{NnQ!ktK{6fmfWsPUl&I7g;l9Prm@eck20)7nZ851v6tWtPzqwi!*xx z1KI`7zxHCm+%xmt7i+aE^gQ*iM%28kM8Un@IPW622em}!1?@v?Y?QP}R!q_sV7Iav z?q1R2!>60*9SrCx{6GFmoH~!=L8O%v;n_fd!9|u}5bP}MG0b=z>sry)O!qB9%mU5I zcDQ;87fSFto#If5qcojCXn9)bz(9bt4jzMEXHI=)B7)?nX6)1V3EM-^gRcem z0u4L;lQtJ!^q`k#QPe8J$suaJ|7i#&lh9oa_#pPem#6?IUVehuRR&!=~P5U(5)0vO06!;&Z`vOer9SUA4Fi{X8Ua1HmZMbE1~|E7ZYA&{HT zY5Vp3lV5Ne{|#GtkALS2`znZ%we0@^gs@Vl^_hR; zuM0CJJw3;c`_E9Pw)40CIy*g*6?e-+7AV)?IIS}9|9)*LKT)OU&z!dkHa>Q~n1B7e z)nEf(dj`Mfd_h`L%<+bryB%y>nF!~(7p$bqBP0t{_ght&*j`H9Uyp6%sB%=kdicB- zxW~vQFm@rYMK5JQEBO+9G>;XK-8BpBuGulWYoV0GibirBd2$N!P5S(X6uHCZ+pFha zzR+2&XiY#er0vJ(W-{V`#)p-xj_1FUZ+j0nfj0dLU-wEj_z)qj$Ozo?N^dHqcLNZ} zj4TNrx)YfASTj%gVqP9uyXnHkLcG}U#X{{dz>6=6+DiID8$6Dj)>Rro5rfat@Gt8W z{VWpSjUEq(*XWp0GBVSM$zfJbhB-4-l(c(+Q00iD7~l9Qni{fB{FaSZxx`Thqwa~? zkm)l_{~JB+p8(si`QQqu{-`GMxuxm3%31Do!fZly)cX;63~AEOH8ez2Mv)wE{96Ob z?iel1zsN_+NHQ$mBx7&mCj15iI?TR@RPfIjpjsg(fb0Jda`Yk3M38+8;R-47v{#o1 z|IOFDTD~goXSg#*_%_zYU@uO&gP(pCbc#K0{@SaR#pmna@LWmwMqoCj=S0^H@Y&gm zuG)*vr@!I4eDE7vX6;@4cdveXHGGpJW!s6a5A=dz&F_r{gB35SSrrWc8yKof~fzW!RC&5Xnj|5?Cv_Vd?r zk>pdr1Z9m;DEci@fjV=t5S|c&Cjup=L*vWPvb|F;!RIciW^%LyeDWZLATb&RNE$(6 zcW3~TL6F!tFzU56z!Bg}7ej#Yo%tke*vERwldzTd$Bcd|IpJV(t`6z`a|I_)-VKWKyh(a=(Nq z8xK`@9uQ#<@Hxj=2eJi@9k~5F2JiPRa4aiP_71jph-!g5|L~|6E{9=$B+C4d#g0MG zs8Ht7wc>xfc+y1*_8pi3HkcdhJ2uj&sP_du|H_x^qHN+gvI(=KtI^YaR!lb-W(R|W)qf2xI(zNF1(^GEVjcv&?uwGJMV>8!LtbsH$R3~ z6f97FNo*vh(26ThBcye1!Q_u|w?2mf5%T9T7Uvr{M}aC63HJkbXJ{ivl7 z{QGB)ymb8R@ynaTeLJ`?Q(6-CGbopAJh2h(Q)cF0&nUl|K{sr|?fuu*Z=cQB!Cf;Y z2E1bPE3}6FcZ2CdFkUj26NV1Li=cpW zJmebtJC#qPlBBE{RCDL&Ifuvnk&i1O>yo1dP-0`g{Z72+fz9yNOZsLq zKV(Llz^|w|;e9f`7z(f;LmP>lrDVDlkquw{k!>-^lK6>B`QWAbt4oDKGXLhKLL=JZ z;44h;UMikS#ppf^(lN+@KsM059p!7N^$2alz=j)6$U;{X2|MT>23b!;+{aoB$ZSP+ zB{G4KR&ok6P&6cYOTj1F`Bo%|I6BDYW|%PCmtV&|fGW_VCfEnzO`RYJKhxy@OjGoZ zMx51%KhqSyqpACurjoz@m1gfjozQqH{k8^wZ|^c`g`L8w;pawAkKWc`{I+$MURZrf ze_MmUw~v^Uh1^r2+i2Ks@Y+g-vkNTmN(13xsw{||TW B!LI-S delta 14832 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 Ft7Lj)6-R3sR~@Ibz3l3<_+D z{xtlJ?MW7sZH6sN=5Kc6hY^z{E?LHJde2W6$&7!1S@M|u13q7D+E<>>^ZI<=&)qfm z?Bf1d>;Ek+ZU_0yd+@@3_?y;F=LeqOk~L8P@HSE>7n@im*=B;JZMM4kYk zxOB~ICFQr03>7ILbigxGtyv;Fro8*R)FFUwvWENekbH3D#*$ep*NqKwQhC*wubX+= zcbei1%!V?d0$mPMXcRidS&DNMBk+gN^r=O%6v%J+p6GC3mmml)9YOZ>y>x^yFoZVe z3m|R@r|aI2xe#joZ<5cuRiJZu?WnpK5p!$YYNIYvDka`Bvhs z!^%$a*V&(D_qynK8x!v_5qvOj%iT!sZp184d^rd=U=t{7#yGd|S7Vs_3Zt zXdHnY_8YJ*Lc2~x0;gD5l!ZlibUT(qv7A*XB5(;S%&E-vG~#yO1nQfxF5g0+h7$(d zj6_Ms&k=?2pGb`GHRF+JteH(_AddLGpCRx$<~Q>`-8jTH2FQGrUc>i{e?;m0zfqOY zrBJ3BCmGiiYJX(wzZqXEYKWb2Nnn?Eve9n$UO>GUtWT~ZP{o%?e@`q(_)+XQ;X*7y zxE+%TA2U8<{Ezjx||{vkCeQnl@OT5 z>)d|DZMLyu4;0Zr(VAIBppI{o1MkKq!iRB*KNbzPsH&(tS?CDNsggL#i}P?!mDDj_ zj62z#^)|A0e-ZVQzzqbx#2<4z0sm4vT$4_ctG>&fg0Ix}>J{!wd^-6kZl?!PdhqFO z1d4c?%NZ_ndqq26Liv(aVbNE)o#nQ#q1%}$l$o;1n{OPBrXeRT^M*M%m5}rbFIIVX zd}qZ@XegmsV`T*9aGl#VZVOL!TPq{2Y~5HxU>&b>d&dc%BqVW@SA+0XLKcTy?70+R zQ&JU&d7;66Qs}%Gft92zj`HF>{5q+o#&|K|qW@lqzD#N4Pd8c+IE^QnyXP?%$cy?G D4^L+- diff --git a/recruitment/__pycache__/utils.cpython-313.pyc b/recruitment/__pycache__/utils.cpython-313.pyc index 32180233f5cf75219879c54074d0c96ea9533b3a..fefa99503fe4bf387a331eea4b83c8fdfac68374 100644 GIT binary patch delta 21 bcmZ2CpK;xMMy}7iyj%=G;BjUn*AjmKOKk># delta 21 bcmZ2CpK;xMMy}7iyj%=GaGZZ5*AjmKO11_< diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index fe6f2a13de7d86f99259150e06b597d0a00278b5..6a70d428650310636e2bf0f862cdfcdecc6142b9 100644 GIT binary patch delta 25524 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

6W?UGMiNWp^+OKz1>Eu_BTdk6H1plg>1 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 WAvCdCN-Pi?C9*EdFv#e2^#275E?V>e delta 19865 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 diff --git a/recruitment/__pycache__/views_frontend.cpython-313.pyc b/recruitment/__pycache__/views_frontend.cpython-313.pyc index 961ccff5edbede83597d7814238336f270848237..fd6412809454ca6cfde9c3d7e455975a06d88206 100644 GIT binary patch delta 7351 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 diff --git a/recruitment/admin.py b/recruitment/admin.py index bfcf6cc..6b7cb8b 100644 --- a/recruitment/admin.py +++ b/recruitment/admin.py @@ -5,7 +5,7 @@ from django.utils import timezone from .models import ( JobPosting, Candidate, TrainingMaterial, ZoomMeeting, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, - SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage + SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment ) class FormFieldInline(admin.TabularInline): @@ -152,13 +152,13 @@ class CandidateAdmin(admin.ModelAdmin): 'fields': ('first_name', 'last_name', 'email', 'phone', 'resume') }), ('Application Details', { - 'fields': ('job', 'applied', 'stage') + 'fields': ('job', 'applied', 'stage','is_resume_parsed') }), ('Interview Process', { 'fields': ('exam_date', 'exam_status', 'interview_date', 'interview_status', 'offer_date', 'offer_status', 'join_date') }), ('Scoring', { - 'fields': ('match_score', 'strengths', 'weaknesses', 'criteria_checklist') + 'fields': ('ai_analysis_data',) }), ('Additional Information', { 'fields': ('submitted_by_agency', 'created_at', 'updated_at') @@ -206,7 +206,7 @@ class ZoomMeetingAdmin(admin.ModelAdmin): readonly_fields = ['created_at', 'updated_at'] fieldsets = ( ('Meeting Details', { - 'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone') + 'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone','status') }), ('Meeting Settings', { 'fields': ('participant_video', 'join_before_host', 'mute_upon_entry', 'waiting_room') @@ -221,6 +221,26 @@ class ZoomMeetingAdmin(admin.ModelAdmin): save_on_top = True +@admin.register(MeetingComment) +class MeetingCommentAdmin(admin.ModelAdmin): + list_display = ['meeting', 'author', 'created_at', 'updated_at'] + list_filter = ['created_at', 'author', 'meeting'] + search_fields = ['content', 'meeting__topic', 'author__username'] + readonly_fields = ['created_at', 'updated_at', 'slug'] + fieldsets = ( + ('Meeting Information', { + 'fields': ('meeting', 'author') + }), + ('Comment Content', { + 'fields': ('content',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at', 'slug') + }), + ) + save_on_top = True + + @admin.register(FormTemplate) class FormTemplateAdmin(admin.ModelAdmin): list_display = ['name', 'created_by', 'created_at', 'is_active'] @@ -265,4 +285,4 @@ admin.site.register(Profile) # admin.site.register(HiringAgency) -admin.site.register(JobPostingImage) \ No newline at end of file +admin.site.register(JobPostingImage) diff --git a/recruitment/forms.py b/recruitment/forms.py index a43ace2..561e237 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -7,7 +7,7 @@ from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Submit, Row, Column, Field, Div from .models import ( ZoomMeeting, Candidate,TrainingMaterial,JobPosting, - FormTemplate,InterviewSchedule,BreakTime,JobPostingImage + FormTemplate,InterviewSchedule,BreakTime,JobPostingImage,MeetingComment,ScheduledInterview ) # from django_summernote.widgets import SummernoteWidget from django_ckeditor_5.widgets import CKEditor5Widget @@ -70,50 +70,50 @@ class CandidateStageForm(forms.ModelForm): 'stage': forms.Select(attrs={'class': 'form-select'}), } - def __init__(self, *args, **kwargs): - # Get the current candidate instance for validation - self.candidate = kwargs.pop('candidate', None) - super().__init__(*args, **kwargs) + # def __init__(self, *args, **kwargs): + # # Get the current candidate instance for validation + # self.candidate = kwargs.pop('candidate', None) + # super().__init__(*args, **kwargs) - # Dynamically filter stage choices based on current stage - if self.candidate and self.candidate.pk: - current_stage = self.candidate.stage - available_stages = self.candidate.get_available_stages() + # # Dynamically filter stage choices based on current stage + # if self.candidate and self.candidate.pk: + # current_stage = self.candidate.stage + # available_stages = self.candidate.get_available_stages() - # Filter choices to only include available stages - choices = [(stage, self.candidate.Stage(stage).label) - for stage in available_stages] - self.fields['stage'].choices = choices + # # Filter choices to only include available stages + # choices = [(stage, self.candidate.Stage(stage).label) + # for stage in available_stages] + # self.fields['stage'].choices = choices - # Set initial value to current stage - self.fields['stage'].initial = current_stage - else: - # For new candidates, only show 'Applied' stage - self.fields['stage'].choices = [('Applied', _('Applied'))] - self.fields['stage'].initial = 'Applied' + # # Set initial value to current stage + # self.fields['stage'].initial = current_stage + # else: + # # For new candidates, only show 'Applied' stage + # self.fields['stage'].choices = [('Applied', _('Applied'))] + # self.fields['stage'].initial = 'Applied' - def clean_stage(self): - """Validate stage transition""" - new_stage = self.cleaned_data.get('stage') - if not new_stage: - raise forms.ValidationError(_('Please select a stage.')) + # def clean_stage(self): + # """Validate stage transition""" + # new_stage = self.cleaned_data.get('stage') + # if not new_stage: + # raise forms.ValidationError(_('Please select a stage.')) - # Use model validation for stage transitions - if self.candidate and self.candidate.pk: - current_stage = self.candidate.stage - if new_stage != current_stage: - if not self.candidate.can_transition_to(new_stage): - allowed_stages = self.candidate.get_available_stages() - raise forms.ValidationError( - _('Cannot transition from "%(current)s" to "%(new)s". ' - 'Allowed transitions: %(allowed)s') % { - 'current': current_stage, - 'new': new_stage, - 'allowed': ', '.join(allowed_stages) or 'None (final stage)' - } - ) + # # Use model validation for stage transitions + # if self.candidate and self.candidate.pk: + # current_stage = self.candidate.stage + # if new_stage != current_stage: + # if not self.candidate.can_transition_to(new_stage): + # allowed_stages = self.candidate.get_available_stages() + # raise forms.ValidationError( + # _('Cannot transition from "%(current)s" to "%(new)s". ' + # 'Allowed transitions: %(allowed)s') % { + # 'current': current_stage, + # 'new': new_stage, + # 'allowed': ', '.join(allowed_stages) or 'None (final stage)' + # } + # ) - return new_stage + # return new_stage class ZoomMeetingForm(forms.ModelForm): class Meta: @@ -564,6 +564,33 @@ class InterviewScheduleForm(forms.ModelForm): # Convert string values to integers return [int(day) for day in working_days] +class MeetingCommentForm(forms.ModelForm): + """Form for creating and editing meeting comments""" + class Meta: + model = MeetingComment + fields = ['content'] + widgets = { + 'content': CKEditor5Widget( + attrs={'class': 'form-control', 'placeholder': _('Enter your comment or note')}, + config_name='extends' + ), + } + labels = { + 'content': _('Comment'), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'form-horizontal' + self.helper.label_class = 'col-md-3' + self.helper.field_class = 'col-md-9' + self.helper.layout = Layout( + Field('content', css_class='form-control'), + Submit('submit', _('Add Comment'), css_class='btn btn-primary mt-3') + ) + # --- ScheduleInterviewForCandiateForm remains unchanged --- class ScheduleInterviewForCandiateForm(forms.ModelForm): @@ -580,3 +607,10 @@ class ScheduleInterviewForCandiateForm(forms.ModelForm): 'break_start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), 'break_end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), } + + +class InterviewForm(forms.ModelForm): + + class Meta: + model = ScheduledInterview + fields = ['job','candidate'] \ No newline at end of file diff --git a/recruitment/migrations/0021_meetingcomment.py b/recruitment/migrations/0021_meetingcomment.py new file mode 100644 index 0000000..734d421 --- /dev/null +++ b/recruitment/migrations/0021_meetingcomment.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.6 on 2025-10-16 13:52 + +import django.db.models.deletion +import django_ckeditor_5.fields +import django_extensions.db.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0020_alter_interviewschedule_created_at'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='MeetingComment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meeting_comments', to=settings.AUTH_USER_MODEL, verbose_name='Author')), + ('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recruitment.zoommeeting', verbose_name='Meeting')), + ], + options={ + 'verbose_name': 'Meeting Comment', + 'verbose_name_plural': 'Meeting Comments', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/recruitment/migrations/0022_candidate_resume_parsed_category.py b/recruitment/migrations/0022_candidate_resume_parsed_category.py new file mode 100644 index 0000000..f767301 --- /dev/null +++ b/recruitment/migrations/0022_candidate_resume_parsed_category.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2025-10-16 19:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0021_meetingcomment'), + ] + + operations = [ + migrations.AddField( + model_name='candidate', + name='resume_parsed_category', + field=models.TextField(blank=True, verbose_name='Resume Parsed Category'), + ), + ] diff --git a/recruitment/migrations/0023_alter_jobposting_max_applications.py b/recruitment/migrations/0023_alter_jobposting_max_applications.py new file mode 100644 index 0000000..94104e1 --- /dev/null +++ b/recruitment/migrations/0023_alter_jobposting_max_applications.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2025-10-16 19:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0022_candidate_resume_parsed_category'), + ] + + operations = [ + migrations.AlterField( + model_name='jobposting', + name='max_applications', + field=models.PositiveIntegerField(blank=True, default=1000, help_text='Maximum number of applications allowed', null=True), + ), + ] diff --git a/recruitment/migrations/0024_alter_zoommeeting_status.py b/recruitment/migrations/0024_alter_zoommeeting_status.py new file mode 100644 index 0000000..9a69166 --- /dev/null +++ b/recruitment/migrations/0024_alter_zoommeeting_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-10-17 20:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0023_alter_jobposting_max_applications'), + ] + + operations = [ + migrations.AlterField( + model_name='zoommeeting', + name='status', + field=models.CharField(blank=True, default='waiting', max_length=20, null=True, verbose_name='Status'), + ), + ] diff --git a/recruitment/migrations/0025_candidate_recommendation.py b/recruitment/migrations/0025_candidate_recommendation.py new file mode 100644 index 0000000..061dbf7 --- /dev/null +++ b/recruitment/migrations/0025_candidate_recommendation.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-10-17 21:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0024_alter_zoommeeting_status'), + ] + + operations = [ + migrations.AddField( + model_name='candidate', + name='recommendation', + field=models.TextField(blank=True, verbose_name='Recommendation'), + ), + ] diff --git a/recruitment/migrations/0026_remove_candidate_resume_parsed_category_and_more.py b/recruitment/migrations/0026_remove_candidate_resume_parsed_category_and_more.py new file mode 100644 index 0000000..1a06a70 --- /dev/null +++ b/recruitment/migrations/0026_remove_candidate_resume_parsed_category_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.4 on 2025-10-17 21:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0025_candidate_recommendation'), + ] + + operations = [ + migrations.RemoveField( + model_name='candidate', + name='resume_parsed_category', + ), + migrations.AddField( + model_name='candidate', + name='major_category_name', + field=models.TextField(blank=True, verbose_name='Major Category Name'), + ), + ] diff --git a/recruitment/migrations/0027_alter_candidate_email_and_more.py b/recruitment/migrations/0027_alter_candidate_email_and_more.py new file mode 100644 index 0000000..af3b674 --- /dev/null +++ b/recruitment/migrations/0027_alter_candidate_email_and_more.py @@ -0,0 +1,159 @@ +# Generated by Django 5.2.6 on 2025-10-18 17:51 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0026_remove_candidate_resume_parsed_category_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='candidate', + name='email', + field=models.EmailField(db_index=True, max_length=254, verbose_name='Email'), + ), + migrations.AlterField( + model_name='candidate', + name='major_category_name', + field=models.TextField(blank=True, db_index=True, verbose_name='Major Category Name'), + ), + migrations.AlterField( + model_name='candidate', + name='match_score', + field=models.IntegerField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name='candidate', + name='stage', + field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], db_index=True, default='Applied', max_length=100, verbose_name='Stage'), + ), + migrations.AlterField( + model_name='formsubmission', + name='applicant_email', + field=models.EmailField(blank=True, db_index=True, help_text='Email of the applicant', max_length=254), + ), + migrations.AlterField( + model_name='formsubmission', + name='submitted_at', + field=models.DateTimeField(auto_now_add=True, db_index=True), + ), + migrations.AlterField( + model_name='interviewschedule', + name='end_date', + field=models.DateField(db_index=True, verbose_name='End Date'), + ), + migrations.AlterField( + model_name='interviewschedule', + name='start_date', + field=models.DateField(db_index=True, verbose_name='Start Date'), + ), + migrations.AlterField( + model_name='jobposting', + name='application_deadline', + field=models.DateField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name='jobposting', + name='published_at', + field=models.DateTimeField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name='jobposting', + name='status', + field=models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20), + ), + migrations.AlterField( + model_name='scheduledinterview', + name='interview_date', + field=models.DateField(db_index=True, verbose_name='Interview Date'), + ), + migrations.AlterField( + model_name='scheduledinterview', + name='status', + field=models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20), + ), + migrations.AlterField( + model_name='zoommeeting', + name='meeting_id', + field=models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID'), + ), + migrations.AlterField( + model_name='zoommeeting', + name='start_time', + field=models.DateTimeField(db_index=True, verbose_name='Start Time'), + ), + migrations.AlterField( + model_name='zoommeeting', + name='status', + field=models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status'), + ), + migrations.AddIndex( + model_name='candidate', + index=models.Index(fields=['job', 'stage'], name='recruitment_job_id_766dbe_idx'), + ), + migrations.AddIndex( + model_name='candidate', + index=models.Index(fields=['job', 'stage', 'match_score'], name='recruitment_job_id_bd6512_idx'), + ), + migrations.AddIndex( + model_name='candidate', + index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'), + ), + migrations.AddIndex( + model_name='fieldresponse', + index=models.Index(fields=['submission'], name='recruitment_submiss_474130_idx'), + ), + migrations.AddIndex( + model_name='fieldresponse', + index=models.Index(fields=['field'], name='recruitment_field_i_097e5b_idx'), + ), + migrations.AddIndex( + model_name='formsubmission', + index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'), + ), + migrations.AddIndex( + model_name='formtemplate', + index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'), + ), + migrations.AddIndex( + model_name='formtemplate', + index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'), + ), + migrations.AddIndex( + model_name='interviewschedule', + index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'), + ), + migrations.AddIndex( + model_name='interviewschedule', + index=models.Index(fields=['end_date'], name='recruitment_end_dat_aeb00e_idx'), + ), + migrations.AddIndex( + model_name='interviewschedule', + index=models.Index(fields=['created_by'], name='recruitment_created_d0bdcc_idx'), + ), + migrations.AddIndex( + model_name='jobposting', + index=models.Index(fields=['status', 'created_at'], name='recruitment_status_42c036_idx'), + ), + migrations.AddIndex( + model_name='jobposting', + index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'), + ), + migrations.AddIndex( + model_name='scheduledinterview', + index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'), + ), + migrations.AddIndex( + model_name='scheduledinterview', + index=models.Index(fields=['interview_date', 'interview_time'], name='recruitment_intervi_7f5877_idx'), + ), + migrations.AddIndex( + model_name='scheduledinterview', + index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'), + ), + ] diff --git a/recruitment/migrations/0028_alter_candidate_interview_status.py b/recruitment/migrations/0028_alter_candidate_interview_status.py new file mode 100644 index 0000000..f9b6c05 --- /dev/null +++ b/recruitment/migrations/0028_alter_candidate_interview_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-10-18 21:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0027_alter_candidate_email_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='candidate', + name='interview_status', + field=models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status'), + ), + ] diff --git a/recruitment/migrations/0029_remove_candidate_recruitment_job_id_766dbe_idx_and_more.py b/recruitment/migrations/0029_remove_candidate_recruitment_job_id_766dbe_idx_and_more.py new file mode 100644 index 0000000..1ab4af2 --- /dev/null +++ b/recruitment/migrations/0029_remove_candidate_recruitment_job_id_766dbe_idx_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 5.2.4 on 2025-10-19 10:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0028_alter_candidate_interview_status'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='candidate', + name='recruitment_job_id_766dbe_idx', + ), + migrations.RemoveIndex( + model_name='candidate', + name='recruitment_job_id_bd6512_idx', + ), + migrations.RemoveIndex( + model_name='jobposting', + name='recruitment_status_42c036_idx', + ), + migrations.RemoveField( + model_name='candidate', + name='criteria_checklist', + ), + migrations.RemoveField( + model_name='candidate', + name='major_category_name', + ), + migrations.RemoveField( + model_name='candidate', + name='match_score', + ), + migrations.RemoveField( + model_name='candidate', + name='recommendation', + ), + migrations.RemoveField( + model_name='candidate', + name='strengths', + ), + migrations.RemoveField( + model_name='candidate', + name='weaknesses', + ), + migrations.AddField( + model_name='candidate', + name='ai_analysis_data', + field=models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data'), + ), + migrations.AddIndex( + model_name='candidate', + index=models.Index(fields=['stage'], name='recruitment_stage_f1c6eb_idx'), + ), + migrations.AddIndex( + model_name='jobposting', + index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'), + ), + ] diff --git a/recruitment/migrations/0030_alter_candidate_options.py b/recruitment/migrations/0030_alter_candidate_options.py new file mode 100644 index 0000000..eff8232 --- /dev/null +++ b/recruitment/migrations/0030_alter_candidate_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.6 on 2025-10-19 13:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0029_remove_candidate_recruitment_job_id_766dbe_idx_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='candidate', + options={'ordering': ['-ai_analysis_data__match_score', '-created_at'], 'verbose_name': 'Candidate', 'verbose_name_plural': 'Candidates'}, + ), + ] diff --git a/recruitment/migrations/0031_alter_candidate_options.py b/recruitment/migrations/0031_alter_candidate_options.py new file mode 100644 index 0000000..ffdc406 --- /dev/null +++ b/recruitment/migrations/0031_alter_candidate_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.6 on 2025-10-19 13:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0030_alter_candidate_options'), + ] + + operations = [ + migrations.AlterModelOptions( + name='candidate', + options={'verbose_name': 'Candidate', 'verbose_name_plural': 'Candidates'}, + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index 0b6bb54..eaba811 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -1,7 +1,9 @@ from django.db import models from django.urls import reverse +from typing import List,Dict,Any from django.utils import timezone -from django.db.models import JSONField +from django.db.models import FloatField,CharField +from django.db.models.functions import Cast from django.contrib.auth.models import User from django.core.validators import URLValidator from django_countries.fields import CountryField @@ -78,7 +80,7 @@ class JobPosting(Base): blank=True, ) application_start_date=models.DateField(null=True, blank=True) - application_deadline = models.DateField(null=True, blank=True) + application_deadline = models.DateField(db_index=True, null=True, blank=True) # Added index application_instructions =CKEditor5Field( blank=True, null=True,config_name='extends' ) @@ -98,7 +100,7 @@ class JobPosting(Base): ("ARCHIVED", "Archived"), ] status = models.CharField( - max_length=20, choices=STATUS_CHOICES, default="DRAFT" + db_index=True, max_length=20, choices=STATUS_CHOICES, default="DRAFT" # Added index ) # hashtags for social media @@ -122,7 +124,7 @@ class JobPosting(Base): ) linkedin_posted_at = models.DateTimeField(null=True, blank=True) - published_at = models.DateTimeField(null=True, blank=True) + published_at = models.DateTimeField(db_index=True, null=True, blank=True) # Added index # University Specific Fields position_number = models.CharField( max_length=50, blank=True, help_text="University position number" @@ -142,9 +144,10 @@ class JobPosting(Base): null=True, blank=True, help_text="The system or channel from which this job posting originated or was first published.", + db_index=True # Explicitly index ForeignKey ) max_applications = models.PositiveIntegerField( - default=1000, help_text="Maximum number of applications allowed" + default=1000, help_text="Maximum number of applications allowed",null=True,blank=True ) hiring_agency = models.ManyToManyField( "HiringAgency", @@ -172,6 +175,10 @@ class JobPosting(Base): ordering = ["-created_at"] verbose_name = "Job Posting" verbose_name_plural = "Job Postings" + indexes = [ + models.Index(fields=['status', 'created_at','title']), + models.Index(fields=['slug']), + ] def __str__(self): return f"{self.title} - {self.internal_job_id}-{self.get_status_display()}" @@ -239,7 +246,23 @@ class JobPosting(Base): return True return self.current_applications_count >= self.max_applications + @property + def all_candidates(self): + return self.candidates.annotate(sortable_score=Cast('ai_analysis_data__match_score',output_field=CharField())).order_by('-sortable_score') + @property + def screening_candidates(self): + return self.all_candidates.filter(stage="Applied") + @property + def exam_candidates(self): + return self.all_candidates.filter(stage="Exam") + @property + def interview_candidates(self): + return self.all_candidates.filter(stage="Interview") + + @property + def offer_candidates(self): + return self.all_candidates.filter(stage="Offer") class JobPostingImage(models.Model): job=models.OneToOneField('JobPosting',on_delete=models.CASCADE,related_name='post_images') @@ -281,7 +304,7 @@ class Candidate(Base): ) first_name = models.CharField(max_length=255, verbose_name=_("First Name")) last_name = models.CharField(max_length=255, verbose_name=_("Last Name")) - email = models.EmailField(verbose_name=_("Email")) + email = models.EmailField(db_index=True, verbose_name=_("Email")) # Added index phone = models.CharField(max_length=20, verbose_name=_("Phone")) address = models.TextField(max_length=200, verbose_name=_("Address")) resume = models.FileField(upload_to="resumes/", verbose_name=_("Resume")) @@ -294,7 +317,7 @@ class Candidate(Base): parsed_summary = models.TextField(blank=True, verbose_name=_("Parsed Summary")) applied = models.BooleanField(default=False, verbose_name=_("Applied")) stage = models.CharField( - max_length=100, + db_index=True, max_length=100, # Added index default="Applied", choices=Stage.choices, verbose_name=_("Stage"), @@ -319,7 +342,7 @@ class Candidate(Base): null=True, blank=True, verbose_name=_("Interview Date") ) interview_status = models.CharField( - choices=Status.choices, + choices=ExamStatus.choices, max_length=100, null=True, blank=True, @@ -334,13 +357,18 @@ class Candidate(Base): verbose_name=_("Offer Status"), ) join_date = models.DateField(null=True, blank=True, verbose_name=_("Join Date")) - + ai_analysis_data = models.JSONField( + verbose_name="AI Analysis Data", + default=dict, + help_text="Full JSON output from the resume scoring model." + ) # Scoring fields (populated by signal) - match_score = models.IntegerField(null=True, blank=True) - strengths = models.TextField(blank=True) - weaknesses = models.TextField(blank=True) - criteria_checklist = models.JSONField(default=dict, blank=True) - + # match_score = models.IntegerField(db_index=True, null=True, blank=True) # Added index + # strengths = models.TextField(blank=True) + # weaknesses = models.TextField(blank=True) + # criteria_checklist = models.JSONField(default=dict, blank=True) + # major_category_name = models.TextField(db_index=True, blank=True, verbose_name=_("Major Category Name")) # Added index + # recommendation = models.TextField(blank=True, verbose_name=_("Recommendation")) submitted_by_agency = models.ForeignKey( "HiringAgency", on_delete=models.SET_NULL, @@ -353,6 +381,103 @@ class Candidate(Base): class Meta: verbose_name = _("Candidate") verbose_name_plural = _("Candidates") + indexes = [ + models.Index(fields=['stage']), + models.Index(fields=['created_at']), + ] + def set_field(self, key: str, value: Any): + """ + Generic method to set any single key-value pair and save. + """ + self.ai_analysis_data[key] = value + self.save(update_fields=['ai_analysis_data']) + + # ==================================================================== + # ✨ PROPERTIES (GETTERS) + # ==================================================================== + + @property + def match_score(self) -> int: + """1. A score from 0 to 100 representing how well the candidate fits the role.""" + return self.ai_analysis_data.get('match_score', 0) + + @property + def years_of_experience(self) -> float: + """4. The total number of years of professional experience as a numerical value.""" + return self.ai_analysis_data.get('years_of_experience', 0.0) + + @property + def soft_skills_score(self) -> int: + """15. A score (0-100) for inferred non-technical skills.""" + return self.ai_analysis_data.get('soft_skills_score', 0) + + @property + def industry_match_score(self) -> int: + """16. A score (0-100) for the relevance of the candidate's industry experience.""" + # Renamed to clarify: experience_industry_match + return self.ai_analysis_data.get('experience_industry_match', 0) + + # --- Properties for Funnel & Screening Efficiency --- + + @property + def min_requirements_met(self) -> bool: + """14. Boolean (true/false) indicating if all non-negotiable minimum requirements are met.""" + return self.ai_analysis_data.get('min_req_met_bool', False) + + @property + def screening_stage_rating(self) -> str: + """13. A standardized rating (e.g., "A - Highly Qualified", "B - Qualified").""" + return self.ai_analysis_data.get('screening_stage_rating', 'N/A') + + @property + def top_3_keywords(self) -> List[str]: + """10. A list of the three most dominant and relevant technical skills or technologies.""" + return self.ai_analysis_data.get('top_3_keywords', []) + + @property + def most_recent_job_title(self) -> str: + """8. The candidate's most recent or current professional job title.""" + return self.ai_analysis_data.get('most_recent_job_title', 'N/A') + + # --- Properties for Structured Detail --- + + @property + def criteria_checklist(self) -> Dict[str, str]: + """5 & 6. An object rating the candidate's match for each specific criterion.""" + return self.ai_analysis_data.get('criteria_checklist', {}) + + @property + def professional_category(self) -> str: + """7. The most fitting professional field or category for the individual.""" + return self.ai_analysis_data.get('category', 'N/A') + + @property + def language_fluency(self) -> List[Dict[str, str]]: + """12. A list of languages and their fluency levels mentioned.""" + return self.ai_analysis_data.get('language_fluency', []) + + # --- Properties for Summaries and Narrative --- + + @property + def strengths(self) -> str: + """2. A brief summary of why the candidate is a strong fit.""" + return self.ai_analysis_data.get('strengths', '') + + @property + def weaknesses(self) -> str: + """3. A brief summary of where the candidate falls short or what criteria are missing.""" + return self.ai_analysis_data.get('weaknesses', '') + + @property + def job_fit_narrative(self) -> str: + """11. A single, concise sentence summarizing the core fit.""" + return self.ai_analysis_data.get('job_fit_narrative', '') + + @property + def recommendation(self) -> str: + """9. Provide a detailed final recommendation for the candidate.""" + # Using a more descriptive name to avoid conflict with potential built-in methods + return self.ai_analysis_data.get('recommendation', '') @property def name(self): @@ -448,16 +573,16 @@ class TrainingMaterial(Base): class ZoomMeeting(Base): class MeetingStatus(models.TextChoices): - SCHEDULED = "scheduled", _("Scheduled") + WAITING = "waiting", _("Waiting") STARTED = "started", _("Started") ENDED = "ended", _("Ended") CANCELLED = "cancelled",_("Cancelled") # Basic meeting details topic = models.CharField(max_length=255, verbose_name=_("Topic")) meeting_id = models.CharField( - max_length=20, unique=True, verbose_name=_("Meeting ID") + db_index=True, max_length=20, unique=True, verbose_name=_("Meeting ID") # Added index ) # Unique identifier for the meeting - start_time = models.DateTimeField(verbose_name=_("Start Time")) + start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time")) # Added index duration = models.PositiveIntegerField( verbose_name=_("Duration") ) # Duration in minutes @@ -483,10 +608,11 @@ class ZoomMeeting(Base): blank=True, null=True, verbose_name=_("Zoom Gateway Response") ) status = models.CharField( - max_length=20, + db_index=True, max_length=20, # Added index null=True, blank=True, verbose_name=_("Status"), + default=MeetingStatus.WAITING, ) # Timestamps @@ -494,20 +620,51 @@ class ZoomMeeting(Base): return self.topic +class MeetingComment(Base): + """ + Model for storing meeting comments/notes + """ + meeting = models.ForeignKey( + ZoomMeeting, + on_delete=models.CASCADE, + related_name="comments", + verbose_name=_("Meeting") + ) + author = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="meeting_comments", + verbose_name=_("Author") + ) + content = CKEditor5Field( + verbose_name=_("Content"), + config_name='extends' + ) + # Inherited from Base: created_at, updated_at, slug + + class Meta: + verbose_name = _("Meeting Comment") + verbose_name_plural = _("Meeting Comments") + ordering = ['-created_at'] + + def __str__(self): + return f"Comment by {self.author.get_username()} on {self.meeting.topic}" + + class FormTemplate(Base): """ Represents a complete form template with multiple stages """ job = models.OneToOneField( - JobPosting, on_delete=models.CASCADE, related_name="form_template" + JobPosting, on_delete=models.CASCADE, related_name="form_template", db_index=True ) name = models.CharField(max_length=200, help_text="Name of the form template") description = models.TextField( blank=True, help_text="Description of the form template" ) created_by = models.ForeignKey( - User, on_delete=models.CASCADE, related_name="form_templates",null=True,blank=True + User, on_delete=models.CASCADE, related_name="form_templates",null=True,blank=True, db_index=True ) is_active = models.BooleanField( default=False, help_text="Whether this template is active" @@ -517,6 +674,10 @@ class FormTemplate(Base): ordering = ["-created_at"] verbose_name = "Form Template" verbose_name_plural = "Form Templates" + indexes = [ + models.Index(fields=['created_at']), + models.Index(fields=['is_active']), + ] def __str__(self): return self.name @@ -534,7 +695,7 @@ class FormStage(Base): """ template = models.ForeignKey( - FormTemplate, on_delete=models.CASCADE, related_name="stages" + FormTemplate, on_delete=models.CASCADE, related_name="stages", db_index=True ) name = models.CharField(max_length=200, help_text="Name of the stage") order = models.PositiveIntegerField( @@ -575,7 +736,7 @@ class FormField(Base): ] stage = models.ForeignKey( - FormStage, on_delete=models.CASCADE, related_name="fields" + FormStage, on_delete=models.CASCADE, related_name="fields", db_index=True ) label = models.CharField(max_length=200, help_text="Label for the field") field_type = models.CharField( @@ -666,7 +827,7 @@ class FormSubmission(Base): """ template = models.ForeignKey( - FormTemplate, on_delete=models.CASCADE, related_name="submissions" + FormTemplate, on_delete=models.CASCADE, related_name="submissions", db_index=True ) submitted_by = models.ForeignKey( User, @@ -674,17 +835,21 @@ class FormSubmission(Base): null=True, blank=True, related_name="form_submissions", + db_index=True ) - submitted_at = models.DateTimeField(auto_now_add=True) + submitted_at = models.DateTimeField(db_index=True, auto_now_add=True) # Added index applicant_name = models.CharField( max_length=200, blank=True, help_text="Name of the applicant" ) - applicant_email = models.EmailField(blank=True, help_text="Email of the applicant") + applicant_email = models.EmailField(db_index=True, blank=True, help_text="Email of the applicant") # Added index class Meta: ordering = ["-submitted_at"] verbose_name = "Form Submission" verbose_name_plural = "Form Submissions" + indexes = [ + models.Index(fields=['submitted_at']), + ] def __str__(self): return f"Submission for {self.template.name} - {self.submitted_at.strftime('%Y-%m-%d %H:%M')}" @@ -696,10 +861,10 @@ class FieldResponse(Base): """ submission = models.ForeignKey( - FormSubmission, on_delete=models.CASCADE, related_name="responses" + FormSubmission, on_delete=models.CASCADE, related_name="responses", db_index=True ) field = models.ForeignKey( - FormField, on_delete=models.CASCADE, related_name="responses" + FormField, on_delete=models.CASCADE, related_name="responses", db_index=True ) # Store the response value as JSON to handle different data types @@ -713,6 +878,10 @@ class FieldResponse(Base): class Meta: verbose_name = "Field Response" verbose_name_plural = "Field Responses" + indexes = [ + models.Index(fields=['submission']), + models.Index(fields=['field']), + ] def __str__(self): return f"Response to {self.field.label} in {self.submission}" @@ -950,11 +1119,11 @@ class InterviewSchedule(Base): """Stores the scheduling criteria for interviews""" job = models.ForeignKey( - JobPosting, on_delete=models.CASCADE, related_name="interview_schedules" + JobPosting, on_delete=models.CASCADE, related_name="interview_schedules", db_index=True ) candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True,null=True) - start_date = models.DateField(verbose_name=_("Start Date")) - end_date = models.DateField(verbose_name=_("End Date")) + start_date = models.DateField(db_index=True, verbose_name=_("Start Date")) # Added index + end_date = models.DateField(db_index=True, verbose_name=_("End Date")) # Added index working_days = models.JSONField( verbose_name=_("Working Days") ) # Store days of week as [0,1,2,3,4] for Mon-Fri @@ -970,11 +1139,18 @@ class InterviewSchedule(Base): buffer_time = models.PositiveIntegerField( verbose_name=_("Buffer Time (minutes)"), default=0 ) - created_by = models.ForeignKey(User, on_delete=models.CASCADE) + created_by = models.ForeignKey(User, on_delete=models.CASCADE, db_index=True) # Added index def __str__(self): return f"Interview Schedule for {self.job.title}" + class Meta: + indexes = [ + models.Index(fields=['start_date']), + models.Index(fields=['end_date']), + models.Index(fields=['created_by']), + ] + class ScheduledInterview(Base): """Stores individual scheduled interviews""" @@ -983,21 +1159,22 @@ class ScheduledInterview(Base): Candidate, on_delete=models.CASCADE, related_name="scheduled_interviews", + db_index=True ) job = models.ForeignKey( - "JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews" + "JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True ) zoom_meeting = models.OneToOneField( - ZoomMeeting, on_delete=models.CASCADE, related_name="interview" + ZoomMeeting, on_delete=models.CASCADE, related_name="interview", db_index=True ) schedule = models.ForeignKey( - InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True + InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True, db_index=True ) - interview_date = models.DateField(verbose_name=_("Interview Date")) + interview_date = models.DateField(db_index=True, verbose_name=_("Interview Date")) # Added index interview_time = models.TimeField(verbose_name=_("Interview Time")) status = models.CharField( - max_length=20, + db_index=True, max_length=20, # Added index choices=[ ("scheduled", _("Scheduled")), ("confirmed", _("Confirmed")), @@ -1011,3 +1188,10 @@ class ScheduledInterview(Base): def __str__(self): return f"Interview with {self.candidate.name} for {self.job.title}" + + class Meta: + indexes = [ + models.Index(fields=['job', 'status']), + models.Index(fields=['interview_date', 'interview_time']), + models.Index(fields=['candidate', 'job']), + ] diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 103fc4e..c70927e 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -4,14 +4,18 @@ import logging import requests from PyPDF2 import PdfReader from datetime import datetime +from django.db import transaction from .utils import create_zoom_meeting from recruitment.models import Candidate from .models import ScheduledInterview, ZoomMeeting, Candidate, JobPosting, InterviewSchedule logger = logging.getLogger(__name__) -OPENROUTER_API_KEY ='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' -OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free' +OPENROUTER_API_KEY ='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' +# OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free' +# OPENROUTER_MODEL = 'openai/gpt-oss-20b:free' +OPENROUTER_MODEL = 'openai/gpt-oss-20b' +# OPENROUTER_MODEL = 'mistralai/mistral-small-3.2-24b-instruct:free' if not OPENROUTER_API_KEY: logger.warning("OPENROUTER_API_KEY not set. Resume scoring will be skipped.") @@ -49,114 +53,324 @@ def ai_handler(prompt): res = response.json() content = res["choices"][0]['message']['content'] try: - + # print(content) content = content.replace("```json","").replace("```","") - res = json.loads(content) - + print("success response") + return {"status": "success", "data": res} except Exception as e: print(e) - - # res = raw_output["choices"][0]["message"]["content"] + return {"status": "error", "data": str(e)} else: print("error response") - return res + return {"status": "error", "data": response.json()} + + +# def handle_reume_parsing_and_scoring(pk): +# from django.db import transaction + +# logger.info(f"Scoring resume for candidate {pk}") +# instance = Candidate.objects.get(pk=pk) +# try: +# file_path = instance.resume.path +# with transaction.atomic(): +# if not os.path.exists(file_path): +# logger.warning(f"Resume file not found: {file_path}") +# return + +# resume_text = extract_text_from_pdf(file_path) +# job_detail= f"{instance.job.description} {instance.job.qualifications}" +# resume_parser_prompt = f""" +# You are an expert resume parser and summarizer. Given a resume in plain text format, extract and organize the following key-value information into a clean, valid JSON object: + +# full_name: Full name of the candidate +# current_title: Most recent or current job title +# location: City and state (or country if outside the U.S.) +# contact: Phone number and email (as a single string or separate fields) +# linkedin: LinkedIn profile URL (if present) +# github: GitHub or portfolio URL (if present) +# summary: Brief professional profile or summary (1–2 sentences) +# education: List of degrees, each with: +# institution +# degree +# year +# gpa (if provided) +# relevant_courses (as a list, if mentioned) +# skills: Grouped by category if possible (e.g., programming, big data, visualization), otherwise as a flat list of technologies/tools +# experience: List of roles, each with: +# company +# job_title +# location +# start_date and end_date (or "Present" if applicable) +# key_achievements (as a list of concise bullet points) +# projects: List of notable projects (if clearly labeled), each with: +# name +# year +# technologies_used +# brief_description +# Instructions: + +# Be concise but preserve key details. +# Normalize formatting (e.g., “Jun. 2014” → “2014-06”). +# Omit redundant or promotional language. +# If a section is missing, omit the key or set it to null/empty list as appropriate. +# Output only valid JSON—no markdown, no extra text. +# Now, process the following resume text: +# {resume_text} +# """ +# resume_parser_result = ai_handler(resume_parser_prompt) +# resume_scoring_prompt = f""" +# You are an expert technical recruiter. Your task is to score the following candidate for the role based on the provided job criteria. + +# **Job Criteria:** +# {job_detail} + +# **Candidate's Extracted Resume Json:** +# \"\"\" +# {resume_parser_result} +# \"\"\" + +# **Your Task:** +# Provide a response in strict JSON format with the following keys: +# 1. 'match_score': A score from 0 to 100 representing how well the candidate fits the role. +# 2. 'strengths': A brief summary of why the candidate is a strong fit, referencing specific criteria. +# 3. 'weaknesses': A brief summary of where the candidate falls short or what criteria are missing. +# 4. 'years_of_experience': The total number of years of professional experience mentioned in the resume as a numerical value (e.g., 6.5). +# 5. 'criteria_checklist': An object where you rate the candidate's match for each specific criterion (e.g., {{'Python': 'Met', 'AWS': 'Not Mentioned'}}). +# 6. 'criteria_checklist': An object where you rate the candidate's match for each specific criterion (e.g., {{'Python': 'Met', 'AWS': 'Not Mentioned'}}). +# 7. 'category': Based on the content provided, determine the most fitting professional field or category for the individual. (e.g., {{"category" : "Data Science"}}) only output the category name and no other text example ('Software Development', 'correct') , ('Software Development and devops','wrong'). +# 8. 'most_recent_job_title': The candidate's most recent or current professional job title. +# 9. 'recommendation': Provide a recommendation for the candidate (e.g., {{"recommendation": " +# Conclusion and Minor Considerations +# Overall Assessment: Highly Recommended Candidate. + +# [Candidate] is an exceptionally strong candidate for this role. His proven track record with the core technology stack (Django, Python, Docker, CI/CD) and relevant experience in large-scale, high-impact enterprise projects (Telecom BPM/MDM) make him an excellent technical fit. His fluency in Arabic and English directly addresses a major non-negotiable requirement. + +# The only minor area not explicitly mentioned is the mentoring aspect, but his senior level of experience and technical breadth strongly suggest he possesses the capability to mentor junior engineers. + +# The hiring manager should move forward with this candidate with high confidence. +# ."}}). +# 10. 'top_3_keywords': A list of the three most dominant and relevant technical skills or technologies from the resume that match the job criteria. +# 11. 'job_fit_narrative': A single, concise sentence summarizing the core fit. +# 12. 'language_fluency': A list of languages and their fluency levels mentioned. +# 13. 'screening_stage_rating': A standardized rating (e.g., "A - Highly Qualified", "B - Qualified"). +# 14. 'min_req_met_bool': Boolean (true/false) indicating if all non-negotiable minimum requirements are met. +# 15. 'soft_skills_score': A score (0-100) for inferred non-technical skills like leadership and communication. +# 16. 'experience_industry_match': A score (0-100) for the relevance of the candidate's industry experience. + +# Only output valid JSON. Do not include any other text. +# """ + +# resume_scoring_result = ai_handler(resume_scoring_prompt) + +# print(resume_scoring_result) + +# instance.parsed_summary = str(resume_parser_result) + + +# # Core Scores +# instance.set_field('match_score', resume_scoring_result.get('match_score', 0)) # Set default for int +# instance.set_field('years_of_experience', resume_scoring_result.get('years_of_experience', 0.0)) # Set default for float +# instance.set_field('soft_skills_score', resume_scoring_result.get('soft_skills_score', 0)) +# instance.set_field('experience_industry_match', resume_scoring_result.get('experience_industry_match', 0)) + +# # Screening & Funnel +# instance.set_field('min_req_met_bool', resume_scoring_result.get('min_req_met_bool', False)) # Set default for bool +# instance.set_field('screening_stage_rating', resume_scoring_result.get('screening_stage_rating', 'N/A')) +# instance.set_field('most_recent_job_title', resume_scoring_result.get('most_recent_job_title', 'N/A')) +# instance.set_field('top_3_keywords', resume_scoring_result.get('top_3_keywords', [])) # Set default for list + +# # Summaries & Narrative +# instance.set_field('strengths', resume_scoring_result.get('strengths', '')) +# instance.set_field('weaknesses', resume_scoring_result.get('weaknesses', '')) +# instance.set_field('job_fit_narrative', resume_scoring_result.get('job_fit_narrative', '')) +# instance.set_field('recommendation', resume_scoring_result.get('recommendation', '')) + +# # Structured Data +# instance.set_field('criteria_checklist', resume_scoring_result.get('criteria_checklist', {})) # Set default for dict +# instance.set_field('language_fluency', resume_scoring_result.get('language_fluency', [])) # Set default for list + +# instance.set_field('category', resume_scoring_result.get('category', 'Uncategorized')) # Use 'category' key + +# instance.is_resume_parsed = True + +# instance.save(update_fields=['ai_analysis_data', 'is_resume_parsed','parsed_summary']) + +# logger.info(f"Successfully scored resume for candidate {instance.id}") + +# except Exception as e: +# instance.is_resume_parsed = False +# instance.save(update_fields=['is_resume_parsed']) +# logger.error(f"Failed to score resume for candidate:{instance.pk} {e}") def handle_reume_parsing_and_scoring(pk): - logger.info(f"Scoring resume for candidate {pk}") + """ + Optimized Django-Q task to parse a resume, score the candidate against a job, + and atomically save the results. + """ + + # --- 1. Robust Object Retrieval (Prevents looping on DoesNotExist) --- try: instance = Candidate.objects.get(pk=pk) + except Candidate.DoesNotExist: + # Exit gracefully if the candidate was deleted after the task was queued + logger.warning(f"Candidate matching query does not exist for pk={pk}. Exiting task.") + print(f"Candidate matching query does not exist for pk={pk}. Exiting task.") + return + + logger.info(f"Scoring resume for candidate {pk}") + print(f"Scoring resume for candidate {pk}") + + # --- 2. I/O and Initial Data Check --- + try: file_path = instance.resume.path if not os.path.exists(file_path): logger.warning(f"Resume file not found: {file_path}") + print(f"Resume file not found: {file_path}") + # Consider marking the task as unsuccessful but don't re-queue return resume_text = extract_text_from_pdf(file_path) - job_detail= f"{instance.job.description} {instance.job.qualifications}" - resume_parser_prompt = f""" - You are an expert resume parser and summarizer. Given a resume in plain text format, extract and organize the following key-value information into a clean, valid JSON object: - - full_name: Full name of the candidate - current_title: Most recent or current job title - location: City and state (or country if outside the U.S.) - contact: Phone number and email (as a single string or separate fields) - linkedin: LinkedIn profile URL (if present) - github: GitHub or portfolio URL (if present) - summary: Brief professional profile or summary (1–2 sentences) - education: List of degrees, each with: - institution - degree - year - gpa (if provided) - relevant_courses (as a list, if mentioned) - skills: Grouped by category if possible (e.g., programming, big data, visualization), otherwise as a flat list of technologies/tools - experience: List of roles, each with: - company - job_title - location - start_date and end_date (or "Present" if applicable) - key_achievements (as a list of concise bullet points) - projects: List of notable projects (if clearly labeled), each with: - name - year - technologies_used - brief_description - Instructions: - - Be concise but preserve key details. - Normalize formatting (e.g., “Jun. 2014” → “2014-06”). - Omit redundant or promotional language. - If a section is missing, omit the key or set it to null/empty list as appropriate. - Output only valid JSON—no markdown, no extra text. - Now, process the following resume text: - {resume_text} - """ - resume_parser_result = ai_handler(resume_parser_prompt) - resume_scoring_prompt = f""" - You are an expert technical recruiter. Your task is to score the following candidate for the role of a Senior Data Analyst based on the provided job criteria. - - **Job Criteria:** - {job_detail} - - **Candidate's Extracted Resume Json:** - \"\"\" - {resume_parser_result} - \"\"\" - - **Your Task:** - Provide a response in strict JSON format with the following keys: - 1. 'match_score': A score from 0 to 100 representing how well the candidate fits the role. - 2. 'strengths': A brief summary of why the candidate is a strong fit, referencing specific criteria. - 3. 'weaknesses': A brief summary of where the candidate falls short or what criteria are missing. - 4. 'criteria_checklist': An object where you rate the candidate's match for each specific criterion (e.g., {{'Python': 'Met', 'AWS': 'Not Mentioned'}}). - - - Only output valid JSON. Do not include any other text. - """ - - resume_scoring_result = ai_handler(resume_scoring_prompt) - - instance.parsed_summary = str(resume_parser_result) - - # Update candidate with scoring results - instance.match_score = resume_scoring_result.get('match_score') - instance.strengths = resume_scoring_result.get('strengths', '') - instance.weaknesses = resume_scoring_result.get('weaknesses', '') - instance.criteria_checklist = resume_scoring_result.get('criteria_checklist', {}) - - instance.is_resume_parsed = True - - # Save only scoring-related fields to avoid recursion - instance.save(update_fields=[ - 'match_score', 'strengths', 'weaknesses', - 'criteria_checklist','parsed_summary', 'is_resume_parsed' - ]) - - logger.info(f"Successfully scored resume for candidate {instance.id}") + job_detail = f"{instance.job.description} {instance.job.qualifications}" except Exception as e: - logger.error(f"Failed to score resume for candidate {instance.id}: {e}") + logger.error(f"Error during initial data retrieval/parsing for candidate {instance.pk}: {e}") + print(f"Error during initial data retrieval/parsing for candidate {instance.pk}: {e}") + return + # --- 3. Single, Combined LLM Prompt (Major Cost & Latency Optimization) --- + prompt = f""" + You are an expert AI system functioning as both a Resume Parser and a Technical Recruiter. + + Your task is to: + 1. **PARSE**: Extract all key-value information from the provided RESUME TEXT into a clean JSON structure under the key 'parsed_data'. + 2. **SCORE**: Analyze the parsed data against the JOB CRITERIA and generate a comprehensive score and analysis under the key 'scoring_data'. + + **JOB CRITERIA:** + {job_detail} + + **RESUME TEXT:** + {resume_text} + + **STRICT JSON OUTPUT INSTRUCTIONS:** + Output a single, valid JSON object with ONLY the following two top-level keys: + + 1. "parsed_data": {{ + "full_name": "Full name of the candidate", + "current_title": "Most recent or current job title", + "location": "City and state", + "contact": "Phone number and email", + "linkedin": "LinkedIn profile URL", + "github": "GitHub or portfolio URL", + "summary": "Brief professional profile or summary (1–2 sentences)", + "education": [{{ + "institution": "Institution name", + "degree": "Degree name", + "year": "Year of graduation", + "gpa": "GPA (if provided)", + "relevant_courses": ["list", "of", "courses"] + }}], + "skills": {{ + "category_1": ["skill_a", "skill_b"], + "uncategorized": ["tool_x"] + }}, + "experience": [{{ + "company": "Company name", + "job_title": "Job Title", + "location": "Location", + "start_date": "YYYY-MM", + "end_date": "YYYY-MM or Present", + "key_achievements": ["concise", "bullet", "points"] + }}], + "projects": [{{ + "name": "Project name", + "year": "Year", + "technologies_used": ["list", "of", "tech"], + "brief_description": "description" + }}] + }} + + 2. "scoring_data": {{ + "match_score": "Score 0-100", + "strengths": "Brief summary of strengths", + "weaknesses": "Brief summary of weaknesses", + "years_of_experience": "Total years of experience (float, e.g., 6.5)", + "criteria_checklist": {{ "Python": "Met", "AWS": "Not Mentioned"}}, + "category": "Most fitting professional field (e.g., Data Science)", + "most_recent_job_title": "Candidate's most recent job title", + "recommendation": "Detailed hiring recommendation narrative", + "top_3_keywords": ["keyword1", "keyword2", "keyword3"], + "job_fit_narrative": "Single, concise summary sentence", + "language_fluency": ["language: fluency_level"], + "screening_stage_rating": "Standardized rating (e.g., A - Highly Qualified)", + "min_req_met_bool": "Boolean (true/false)", + "soft_skills_score": "Score 0-100 for inferred non-technical skills", + "experience_industry_match": "Score 0-100 for industry relevance" + }} + + If a top-level key or its required fields are missing, set the field to null, an empty list, or an empty object as appropriate. + + Output only valid JSON—no markdown, no extra text. + """ + + try: + result = ai_handler(prompt) + if result['status'] == 'error': + logger.error(f"AI handler returned error for candidate {instance.pk}") + print(f"AI handler returned error for candidate {instance.pk}") + return + # Ensure the result is parsed as a Python dict (if ai_handler returns a JSON string) + data = result['data'] + if isinstance(data, str): + data = json.loads(data) + print(data) + + parsed_summary = data.get('parsed_data', {}) + scoring_result = data.get('scoring_data', {}) + + except Exception as e: + logger.error(f"AI handler failed for candidate {instance.pk}: {e}") + print(f"AI handler failed for candidate {instance.pk}: {e}") + return + + # --- 4. Atomic Database Update (Ensures data integrity) --- + with transaction.atomic(): + + # Map JSON keys to model fields with appropriate defaults + update_map = { + 'match_score': ('match_score', 0), + 'years_of_experience': ('years_of_experience', 0.0), + 'soft_skills_score': ('soft_skills_score', 0), + 'experience_industry_match': ('experience_industry_match', 0), + + 'min_req_met_bool': ('min_req_met_bool', False), + 'screening_stage_rating': ('screening_stage_rating', 'N/A'), + 'most_recent_job_title': ('most_recent_job_title', 'N/A'), + 'top_3_keywords': ('top_3_keywords', []), + + 'strengths': ('strengths', ''), + 'weaknesses': ('weaknesses', ''), + 'job_fit_narrative': ('job_fit_narrative', ''), + 'recommendation': ('recommendation', ''), + + 'criteria_checklist': ('criteria_checklist', {}), + 'language_fluency': ('language_fluency', []), + 'category': ('category', 'N/A'), + } + + # Apply scoring results to the instance + for model_field, (json_key, default_value) in update_map.items(): + instance.ai_analysis_data[model_field] = scoring_result.get(json_key, default_value) + # instance.set_field(model_field, scoring_result.get(json_key, default_value)) + + # Apply parsing results + instance.parsed_summary = json.dumps(parsed_summary) + instance.is_resume_parsed = True + + instance.save(update_fields=['ai_analysis_data','parsed_summary', 'is_resume_parsed']) + + logger.info(f"Successfully scored and saved analysis for candidate {instance.id}") + print(f"Successfully scored and saved analysis for candidate {instance.id}") def create_interview_and_meeting( candidate_id, @@ -189,6 +403,8 @@ def create_interview_and_meeting( meeting_id=result["meeting_details"]["meeting_id"], join_url=result["meeting_details"]["join_url"], zoom_gateway_response=result["zoom_gateway_response"], + host_email=result["meeting_details"]["host_email"], + password=result["meeting_details"]["password"] ) ScheduledInterview.objects.create( candidate=candidate, @@ -249,8 +465,7 @@ def handle_zoom_webhook_event(payload): meeting_instance.duration = object_data.get('duration', meeting_instance.duration) meeting_instance.timezone = object_data.get('timezone', meeting_instance.timezone) - # Also update join_url, password, etc., if needed based on the payload structure - meeting_instance.status = 'scheduled' + meeting_instance.status = object_data.get('status', meeting_instance.status) meeting_instance.save(update_fields=['topic', 'start_time', 'duration', 'timezone', 'status']) @@ -268,9 +483,11 @@ def handle_zoom_webhook_event(payload): # --- 3. Deletion Event (User Action) --- elif event_type == 'meeting.deleted': if meeting_instance: - # Mark as cancelled/deleted instead of physically deleting for audit trail - meeting_instance.status = 'cancelled' - meeting_instance.save(update_fields=['status']) + try: + meeting_instance.status = 'cancelled' + meeting_instance.save(update_fields=['status']) + except Exception as e: + logger.error(f"Failed to mark Zoom meeting as cancelled: {e}") return True diff --git a/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc b/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc index 0d101863b37395bdb6d1425446a8b93c31a54c85..b9a5e2b3066347bbed2ff69dcd661ba4fce99ac7 100644 GIT binary patch delta 20 acmaDN^+byMGcPX}0}yze`LK~Yk{19)Yz9RD delta 20 acmaDN^+byMGcPX}0}w>df4`ACk{19(-UccF diff --git a/recruitment/tests.py b/recruitment/tests.py index 7ce503c..ecf0a7b 100644 --- a/recruitment/tests.py +++ b/recruitment/tests.py @@ -1,3 +1,626 @@ -from django.test import TestCase +from django.test import TestCase, Client +from django.contrib.auth.models import User +from django.urls import reverse +from django.utils import timezone +from django.core.files.uploadedfile import SimpleUploadedFile +from datetime import datetime, time, timedelta +import json +from unittest.mock import patch, MagicMock -# Create your tests here. +from .models import ( + JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField, + FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview, + TrainingMaterial, Source, HiringAgency, Profile, MeetingComment +) +from .forms import ( + JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm, + CandidateStageForm, InterviewScheduleForm +) +from .views import ( + ZoomMeetingListView, ZoomMeetingCreateView, job_detail, candidate_screening_view, + candidate_exam_view, candidate_interview_view, submit_form, api_schedule_candidate_meeting +) +from .views_frontend import CandidateListView, JobListView +from .utils import create_zoom_meeting, get_candidates_from_request + + +class BaseTestCase(TestCase): + """Base test case setup with common test data""" + + def setUp(self): + self.client = Client() + self.user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123', + is_staff=True + ) + self.profile = Profile.objects.create(user=self.user) + + # Create test data + self.job = JobPosting.objects.create( + title='Software Engineer', + department='IT', + job_type='FULL_TIME', + workplace_type='REMOTE', + location_country='Saudi Arabia', + description='Job description', + qualifications='Job qualifications', + created_by=self.user + ) + + self.candidate = Candidate.objects.create( + first_name='John', + last_name='Doe', + email='john@example.com', + phone='1234567890', + resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'), + job=self.job, + stage='Applied' + ) + + self.zoom_meeting = ZoomMeeting.objects.create( + topic='Interview with John Doe', + start_time=timezone.now() + timedelta(hours=1), + duration=60, + timezone='UTC', + join_url='https://zoom.us/j/123456789', + meeting_id='123456789' + ) + + +class ModelTests(BaseTestCase): + """Test cases for models""" + + def test_job_posting_creation(self): + """Test JobPosting model creation""" + self.assertEqual(self.job.title, 'Software Engineer') + self.assertEqual(self.job.department, 'IT') + self.assertIsNotNone(self.job.slug) + self.assertEqual(self.job.status, 'DRAFT') + + def test_job_posting_unique_id_generation(self): + """Test unique internal job ID generation""" + self.assertTrue(self.job.internal_job_id.startswith('KAAUH')) + self.assertIn(str(timezone.now().year), self.job.internal_job_id) + + def test_job_posting_methods(self): + """Test JobPosting model methods""" + # Test is_expired method + self.assertFalse(self.job.is_expired()) + + # Test location display + self.assertIn('Saudi Arabia', self.job.get_location_display()) + + def test_candidate_creation(self): + """Test Candidate model creation""" + self.assertEqual(self.candidate.first_name, 'John') + self.assertEqual(self.candidate.stage, 'Applied') + self.assertEqual(self.candidate.job, self.job) + + def test_candidate_stage_transitions(self): + """Test candidate stage transition logic""" + # Test current available stages + available_stages = self.candidate.get_available_stages() + self.assertIn('Exam', available_stages) + self.assertIn('Interview', available_stages) + + def test_zoom_meeting_creation(self): + """Test ZoomMeeting model creation""" + self.assertEqual(self.zoom_meeting.topic, 'Interview with John Doe') + self.assertEqual(self.zoom_meeting.duration, 60) + self.assertIsNotNone(self.zoom_meeting.meeting_id) + + def test_template_creation(self): + """Test FormTemplate model creation""" + template = FormTemplate.objects.create( + name='Test Template', + job=self.job, + created_by=self.user + ) + self.assertEqual(template.name, 'Test Template') + self.assertEqual(template.job, self.job) + + def test_scheduled_interview_creation(self): + """Test ScheduledInterview model creation""" + scheduled = ScheduledInterview.objects.create( + candidate=self.candidate, + job=self.job, + zoom_meeting=self.zoom_meeting, + interview_date=timezone.now().date(), + interview_time=time(10, 0), + status='scheduled' + ) + self.assertEqual(scheduled.candidate, self.candidate) + self.assertEqual(scheduled.status, 'scheduled') + + +class ViewTests(BaseTestCase): + """Test cases for views""" + + def test_job_list_view(self): + """Test JobListView""" + response = self.client.get(reverse('job_list')) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Software Engineer') + + def test_job_list_search(self): + """Test JobListView search functionality""" + response = self.client.get(reverse('job_list'), {'search': 'Software'}) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Software Engineer') + + def test_job_detail_view(self): + """Test job_detail view""" + response = self.client.get(reverse('job_detail', kwargs={'slug': self.job.slug})) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Software Engineer') + self.assertContains(response, 'John Doe') + + def test_zoom_meeting_list_view(self): + """Test ZoomMeetingListView""" + response = self.client.get(reverse('list_meetings')) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Interview with John Doe') + + def test_zoom_meeting_list_search(self): + """Test ZoomMeetingListView search functionality""" + response = self.client.get(reverse('list_meetings'), {'q': 'Interview'}) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Interview with John Doe') + + def test_zoom_meeting_list_filter_status(self): + """Test ZoomMeetingListView status filter""" + response = self.client.get(reverse('list_meetings'), {'status': 'waiting'}) + self.assertEqual(response.status_code, 200) + + def test_zoom_meeting_create_view(self): + """Test ZoomMeetingCreateView""" + self.client.login(username='testuser', password='testpass123') + response = self.client.get(reverse('create_meeting')) + self.assertEqual(response.status_code, 200) + + def test_candidate_screening_view(self): + """Test candidate_screening_view""" + response = self.client.get(reverse('candidate_screening_view', kwargs={'slug': self.job.slug})) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'John Doe') + + def test_candidate_screening_view_filters(self): + """Test candidate_screening_view with filters""" + response = self.client.get( + reverse('candidate_screening_view', kwargs={'slug': self.job.slug}), + {'min_ai_score': '50', 'tier1_count': '5'} + ) + self.assertEqual(response.status_code, 200) + + def test_candidate_exam_view(self): + """Test candidate_exam_view""" + response = self.client.get(reverse('candidate_exam_view', kwargs={'slug': self.job.slug})) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'John Doe') + + def test_candidate_interview_view(self): + """Test candidate_interview_view""" + response = self.client.get(reverse('candidate_interview_view', kwargs={'slug': self.job.slug})) + self.assertEqual(response.status_code, 200) + + @patch('recruitment.views.create_zoom_meeting') + def test_schedule_candidate_meeting(self, mock_create_zoom): + """Test api_schedule_candidate_meeting view""" + mock_create_zoom.return_value = { + 'status': 'success', + 'meeting_details': { + 'meeting_id': '987654321', + 'join_url': 'https://zoom.us/j/987654321', + 'password': '123456' + }, + 'zoom_gateway_response': {'status': 'waiting'} + } + + self.client.login(username='testuser', password='testpass123') + data = { + 'start_time': (timezone.now() + timedelta(hours=2)).isoformat(), + 'duration': 60 + } + response = self.client.post( + reverse('api_schedule_candidate_meeting', + kwargs={'job_slug': self.job.slug, 'candidate_pk': self.candidate.pk}), + data + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'success') + + def test_submit_form(self): + """Test submit_form view""" + # Create a form template first + template = FormTemplate.objects.create( + job=self.job, + name='Test Template', + created_by=self.user, + is_active=True + ) + + data = { + 'field_1': 'John', # Assuming field ID 1 corresponds to First Name + 'field_2': 'Doe', # Assuming field ID 2 corresponds to Last Name + 'field_3': 'john@example.com', # Email + } + + response = self.client.post( + reverse('submit_form', kwargs={'template_id': template.id}), + data + ) + # After successful submission, should redirect to success page + self.assertEqual(response.status_code, 302) + + +class FormTests(BaseTestCase): + """Test cases for forms""" + + def test_job_posting_form(self): + """Test JobPostingForm""" + form_data = { + 'title': 'New Job Title', + 'department': 'New Department', + 'job_type': 'FULL_TIME', + 'workplace_type': 'REMOTE', + 'location_city': 'Riyadh', + 'location_state': 'Riyadh', + 'location_country': 'Saudi Arabia', + 'description': 'Job description', + 'qualifications': 'Job qualifications', + 'salary_range': '5000-7000', + 'application_deadline': '2025-12-31', + 'max_applications': '100', + 'open_positions': '2', + 'hash_tags': '#hiring, #jobopening' + } + form = JobPostingForm(data=form_data) + self.assertTrue(form.is_valid()) + + def test_candidate_form(self): + """Test CandidateForm""" + form_data = { + 'job': self.job.id, + 'first_name': 'Jane', + 'last_name': 'Smith', + 'phone': '9876543210', + 'email': 'jane@example.com', + 'resume': SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf') + } + form = CandidateForm(data=form_data, files=form_data) + self.assertTrue(form.is_valid()) + + def test_zoom_meeting_form(self): + """Test ZoomMeetingForm""" + form_data = { + 'topic': 'Test Meeting', + 'start_time': (timezone.now() + timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M'), + 'duration': 60 + } + form = ZoomMeetingForm(data=form_data) + self.assertTrue(form.is_valid()) + + def test_meeting_comment_form(self): + """Test MeetingCommentForm""" + form_data = { + 'content': 'This is a test comment' + } + form = MeetingCommentForm(data=form_data) + self.assertTrue(form.is_valid()) + + def test_candidate_stage_form(self): + """Test CandidateStageForm with valid transition""" + form_data = { + 'stage': 'Exam' + } + form = CandidateStageForm(data=form_data, candidate=self.candidate) + self.assertTrue(form.is_valid()) + + def test_interview_schedule_form(self): + """Test InterviewScheduleForm""" + form_data = { + 'candidates': [self.candidate.id], + 'start_date': (timezone.now() + timedelta(days=1)).date(), + 'end_date': (timezone.now() + timedelta(days=7)).date(), + 'working_days': [0, 1, 2, 3, 4], # Monday to Friday + 'start_time': '09:00', + 'end_time': '17:00', + 'interview_duration': 60, + 'buffer_time': 15 + } + form = InterviewScheduleForm(slug=self.job.slug, data=form_data) + self.assertTrue(form.is_valid()) + + +class IntegrationTests(BaseTestCase): + """Integration tests for multiple components""" + + def test_candidate_journey(self): + """Test the complete candidate journey from application to interview""" + # 1. Create candidate + candidate = Candidate.objects.create( + first_name='Jane', + last_name='Smith', + email='jane@example.com', + phone='9876543210', + resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'), + job=self.job, + stage='Applied' + ) + + # 2. Move to Exam stage + candidate.stage = 'Exam' + candidate.save() + + # 3. Move to Interview stage + candidate.stage = 'Interview' + candidate.save() + + # 4. Create interview schedule + scheduled_interview = ScheduledInterview.objects.create( + candidate=candidate, + job=self.job, + zoom_meeting=self.zoom_meeting, + interview_date=timezone.now().date(), + interview_time=time(10, 0), + status='scheduled' + ) + + # 5. Verify all stages and relationships + self.assertEqual(Candidate.objects.count(), 2) + self.assertEqual(ScheduledInterview.objects.count(), 1) + self.assertEqual(candidate.stage, 'Interview') + self.assertEqual(scheduled_interview.candidate, candidate) + + def test_meeting_candidate_association(self): + """Test the association between meetings and candidates""" + # Create a scheduled interview + scheduled_interview = ScheduledInterview.objects.create( + candidate=self.candidate, + job=self.job, + zoom_meeting=self.zoom_meeting, + interview_date=timezone.now().date(), + interview_time=time(10, 0), + status='scheduled' + ) + + # Verify the relationship + self.assertEqual(self.zoom_meeting.interview, scheduled_interview) + self.assertEqual(self.candidate.get_meetings().count(), 1) + + def test_form_submission_candidate_creation(self): + """Test creating a candidate through form submission""" + # Create a form template + template = FormTemplate.objects.create( + job=self.job, + name='Application Form', + created_by=self.user, + is_active=True + ) + + # Create form stages and fields + stage = FormStage.objects.create( + template=template, + name='Personal Information', + order=0 + ) + + FormField.objects.create( + stage=stage, + label='First Name', + field_type='text', + order=0 + ) + FormField.objects.create( + stage=stage, + label='Last Name', + field_type='text', + order=1 + ) + FormField.objects.create( + stage=stage, + label='Email', + field_type='email', + order=2 + ) + + # Submit form data + form_data = { + 'field_1': 'New', + 'field_2': 'Candidate', + 'field_3': 'new@example.com' + } + + response = self.client.post( + reverse('submit_form', kwargs={'template_id': template.id}), + form_data + ) + + # Verify candidate was created + self.assertEqual(Candidate.objects.filter(email='new@example.com').count(), 1) + + +class PerformanceTests(BaseTestCase): + """Basic performance tests""" + + def test_large_dataset_pagination(self): + """Test pagination with large datasets""" + # Create many candidates + for i in range(100): + Candidate.objects.create( + first_name=f'Candidate{i}', + last_name=f'Test{i}', + email=f'candidate{i}@example.com', + phone=f'123456789{i}', + job=self.job, + stage='Applied' + ) + + # Test pagination + response = self.client.get(reverse('candidate_list')) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Candidate') + + +class AuthenticationTests(BaseTestCase): + """Authentication and permission tests""" + + def test_unauthorized_access(self): + """Test that unauthorized users cannot access protected views""" + # Create a non-staff user + regular_user = User.objects.create_user( + username='regularuser', + email='regular@example.com', + password='testpass123' + ) + + # Try to access a view that requires staff privileges + self.client.login(username='regularuser', password='testpass123') + response = self.client.get(reverse('job_list')) + # Should still be accessible for now (can be adjusted based on actual requirements) + self.assertEqual(response.status_code, 200) + + def test_login_required(self): + """Test that login is required for certain operations""" + # Test form submission without login + template = FormTemplate.objects.create( + job=self.job, + name='Test Template', + created_by=self.user, + is_active=True + ) + + response = self.client.post( + reverse('submit_form', kwargs={'template_id': template.id}), + {} + ) + # Should redirect to login page + self.assertEqual(response.status_code, 302) + + +class EdgeCaseTests(BaseTestCase): + """Tests for edge cases and error handling""" + + def test_invalid_job_id(self): + """Test handling of invalid job slug""" + response = self.client.get(reverse('job_detail', kwargs={'slug': 'invalid-slug'})) + self.assertEqual(response.status_code, 404) + + def test_meeting_past_time(self): + """Test handling of meeting with past time""" + # This would be tested in the view that validates meeting time + pass + + def test_duplicate_submission(self): + """Test handling of duplicate form submissions""" + # Create form template + template = FormTemplate.objects.create( + job=self.job, + name='Test Template', + created_by=self.user, + is_active=True + ) + + # Submit form twice + response1 = self.client.post( + reverse('submit_form', kwargs={'template_id': template.id}), + {'field_1': 'John', 'field_2': 'Doe'} + ) + + # This should be handled by the view logic + # Currently, it would create a duplicate candidate + # We can add validation to prevent this if needed + + def test_invalid_stage_transition(self): + """Test invalid candidate stage transitions""" + # Try to transition from Interview back to Applied (should be invalid) + self.candidate.stage = 'Interview' + self.candidate.save() + + # The model should prevent this through validation + # This would be tested in the model's clean method or view logic + pass + + +class UtilityFunctionTests(BaseTestCase): + """Tests for utility functions""" + + @patch('recruitment.views.create_zoom_meeting') + def test_create_zoom_meeting_utility(self, mock_create): + """Test the create_zoom_meeting utility function""" + mock_create.return_value = { + 'status': 'success', + 'meeting_details': { + 'meeting_id': '123456789', + 'join_url': 'https://zoom.us/j/123456789' + } + } + + result = create_zoom_meeting( + topic='Test Meeting', + start_time=timezone.now() + timedelta(hours=1), + duration=60 + ) + + self.assertEqual(result['status'], 'success') + self.assertIn('meeting_id', result['meeting_details']) + + def test_get_candidates_from_request(self): + """Test the get_candidates_from_request utility function""" + # This would be tested with a request that has candidate_ids + pass + + +# Factory classes for test data (can be expanded with factory_boy) +class TestFactories: + """Factory methods for creating test data""" + + @staticmethod + def create_job_posting(**kwargs): + defaults = { + 'title': 'Test Job', + 'department': 'Test Department', + 'job_type': 'FULL_TIME', + 'workplace_type': 'ON_SITE', + 'location_country': 'Saudi Arabia', + 'description': 'Test description', + 'created_by': User.objects.create_user('factoryuser', 'factory@example.com', 'password') + } + defaults.update(kwargs) + return JobPosting.objects.create(**defaults) + + @staticmethod + def create_candidate(**kwargs): + job = TestFactories.create_job_posting() + defaults = { + 'first_name': 'Test', + 'last_name': 'Candidate', + 'email': 'test@example.com', + 'phone': '1234567890', + 'job': job, + 'stage': 'Applied' + } + defaults.update(kwargs) + return Candidate.objects.create(**defaults) + + @staticmethod + def create_zoom_meeting(**kwargs): + defaults = { + 'topic': 'Test Meeting', + 'start_time': timezone.now() + timedelta(hours=1), + 'duration': 60, + 'timezone': 'UTC', + 'join_url': 'https://zoom.us/j/test123', + 'meeting_id': 'test123' + } + defaults.update(kwargs) + return ZoomMeeting.objects.create(**defaults) + + +# Test runner configuration (can be added to settings) +""" +TEST_RUNNER = 'django.test.runner.DiscoverRunner' +TEST_DISCOVER_TOPS = ['recruitment'] +""" diff --git a/recruitment/tests_advanced.py b/recruitment/tests_advanced.py new file mode 100644 index 0000000..70f1dac --- /dev/null +++ b/recruitment/tests_advanced.py @@ -0,0 +1,1078 @@ +""" +Advanced test cases for the recruitment application. +These tests cover complex scenarios, API integrations, and edge cases. +""" + +from django.test import TestCase, Client, TransactionTestCase +from django.contrib.auth.models import User, Group +from django.urls import reverse +from django.utils import timezone +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.db.models import ProtectedError, Q +from django.test.utils import override_settings +from django.core.management import call_command +from django.conf import settings +from unittest.mock import patch, MagicMock, Mock +from datetime import datetime, time, timedelta, date +import json +import os +import tempfile +from io import BytesIO +from PIL import Image + +from .models import ( + JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField, + FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview, + TrainingMaterial, Source, HiringAgency, Profile, MeetingComment, JobPostingImage, + BreakTime +) +from .forms import ( + JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm, + CandidateStageForm, InterviewScheduleForm, BreakTimeFormSet +) +from .views import ( + ZoomMeetingListView, ZoomMeetingCreateView, job_detail, candidate_screening_view, + candidate_exam_view, candidate_interview_view, submit_form, api_schedule_candidate_meeting, + schedule_interviews_view, confirm_schedule_interviews_view, _handle_preview_submission, + _handle_confirm_schedule, _handle_get_request +) +from .views_frontend import CandidateListView, JobListView, JobCreateView +from .utils import ( + create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting, + get_zoom_meeting_details, get_candidates_from_request, + get_available_time_slots +) +from .zoom_api import ZoomAPIError + + +class AdvancedModelTests(TestCase): + """Advanced model tests with complex scenarios""" + + def setUp(self): + self.user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123', + is_staff=True + ) + self.profile = Profile.objects.create(user=self.user) + + self.job = JobPosting.objects.create( + title='Software Engineer', + department='IT', + job_type='FULL_TIME', + workplace_type='REMOTE', + location_country='Saudi Arabia', + description='Job description', + qualifications='Job qualifications', + created_by=self.user, + max_applications=10, + open_positions=2 + ) + + def test_job_posting_complex_validation(self): + """Test complex validation scenarios for JobPosting""" + # Test with valid data + valid_data = { + 'title': 'Valid Job Title', + 'department': 'IT', + 'job_type': 'FULL_TIME', + 'workplace_type': 'REMOTE', + 'location_city': 'Riyadh', + 'location_state': 'Riyadh', + 'location_country': 'Saudi Arabia', + 'description': 'Job description', + 'qualifications': 'Job qualifications', + 'salary_range': '5000-7000', + 'application_deadline': '2025-12-31', + 'max_applications': '100', + 'open_positions': '2', + 'hash_tags': '#hiring, #jobopening' + } + form = JobPostingForm(data=valid_data) + self.assertTrue(form.is_valid()) + + def test_job_posting_invalid_data_scenarios(self): + """Test various invalid data scenarios for JobPosting""" + # Test empty title + invalid_data = { + 'title': '', + 'department': 'IT', + 'job_type': 'FULL_TIME', + 'workplace_type': 'REMOTE' + } + form = JobPostingForm(data=invalid_data) + self.assertFalse(form.is_valid()) + self.assertIn('title', form.errors) + + # Test invalid max_applications + invalid_data['title'] = 'Test Job' + invalid_data['max_applications'] = '0' + form = JobPostingForm(data=invalid_data) + self.assertFalse(form.is_valid()) + + # Test invalid hash_tags + invalid_data['max_applications'] = '100' + invalid_data['hash_tags'] = 'invalid hash tags without #' + form = JobPostingForm(data=invalid_data) + self.assertFalse(form.is_valid()) + + def test_candidate_stage_transition_validation(self): + """Test advanced candidate stage transition validation""" + candidate = Candidate.objects.create( + first_name='John', + last_name='Doe', + email='john@example.com', + phone='1234567890', + job=self.job, + stage='Applied' + ) + + # Test valid transitions + valid_transitions = ['Exam', 'Interview', 'Offer'] + for stage in valid_transitions: + candidate.stage = stage + candidate.save() + form = CandidateStageForm(data={'stage': stage}, candidate=candidate) + self.assertTrue(form.is_valid()) + + # Test invalid transition (e.g., from Offer back to Applied) + candidate.stage = 'Offer' + candidate.save() + form = CandidateStageForm(data={'stage': 'Applied'}, candidate=candidate) + # This should fail based on your STAGE_SEQUENCE logic + # Note: You'll need to implement can_transition_to method in Candidate model + + def test_zoom_meeting_conflict_detection(self): + """Test conflict detection for overlapping meetings""" + # Create a meeting + meeting1 = ZoomMeeting.objects.create( + topic='Meeting 1', + start_time=timezone.now() + timedelta(hours=1), + duration=60, + timezone='UTC', + join_url='https://zoom.us/j/123456789', + meeting_id='123456789' + ) + + # Try to create overlapping meeting (this logic would be in your view/service) + # This is a placeholder for actual conflict detection implementation + with self.assertRaises(ValidationError): + # This would trigger your conflict validation + pass + + def test_form_template_integrity(self): + """Test form template data integrity""" + template = FormTemplate.objects.create( + job=self.job, + name='Test Template', + created_by=self.user + ) + + # Create stages + stage1 = FormStage.objects.create(template=template, name='Stage 1', order=0) + stage2 = FormStage.objects.create(template=template, name='Stage 2', order=1) + + # Create fields + field1 = FormField.objects.create( + stage=stage1, label='Field 1', field_type='text', order=0 + ) + field2 = FormField.objects.create( + stage=stage1, label='Field 2', field_type='email', order=1 + ) + + # Test stage ordering + stages = template.stages.all() + self.assertEqual(stages[0], stage1) + self.assertEqual(stages[1], stage2) + + # Test field ordering within stage + fields = stage1.fields.all() + self.assertEqual(fields[0], field1) + self.assertEqual(fields[1], field2) + + def test_interview_schedule_complex_validation(self): + """Test interview schedule validation with complex constraints""" + # Create candidates + candidate1 = Candidate.objects.create( + first_name='John', last_name='Doe', email='john@example.com', + phone='1234567890', job=self.job, stage='Interview' + ) + candidate2 = Candidate.objects.create( + first_name='Jane', last_name='Smith', email='jane@example.com', + phone='9876543210', job=self.job, stage='Interview' + ) + + # Create schedule with valid data + schedule_data = { + 'candidates': [candidate1.id, candidate2.id], + 'start_date': date.today() + timedelta(days=1), + 'end_date': date.today() + timedelta(days=7), + 'working_days': [0, 1, 2, 3, 4], # Mon-Fri + 'start_time': '09:00', + 'end_time': '17:00', + 'interview_duration': 60, + 'buffer_time': 15, + 'break_start_time': '12:00', + 'break_end_time': '13:00' + } + + form = InterviewScheduleForm(slug=self.job.slug, data=schedule_data) + self.assertTrue(form.is_valid()) + + def test_field_response_data_types(self): + """Test different data types for field responses""" + # Create template and field + template = FormTemplate.objects.create( + job=self.job, name='Test Template', created_by=self.user + ) + stage = FormStage.objects.create(template=template, name='Stage 1', order=0) + field = FormField.objects.create( + stage=stage, label='Test Field', field_type='text', order=0 + ) + + # Create submission + submission = FormSubmission.objects.create(template=template) + + # Test different value types + response = FieldResponse.objects.create( + submission=submission, + field=field, + value="Test string value" + ) + self.assertEqual(response.display_value, "Test string value") + + # Test list value (for checkbox/radio) + field.field_type = 'checkbox' + field.save() + response_checkbox = FieldResponse.objects.create( + submission=submission, + field=field, + value=["option1", "option2"] + ) + self.assertEqual(response_checkbox.display_value, "option1, option2") + + # Test file upload + file_content = b"Test file content" + uploaded_file = SimpleUploadedFile( + 'test_file.pdf', file_content, content_type='application/pdf' + ) + response_file = FieldResponse.objects.create( + submission=submission, + field=field, + uploaded_file=uploaded_file + ) + self.assertTrue(response_file.is_file) + self.assertEqual(response_file.get_file_size, len(file_content)) + + +class AdvancedViewTests(TestCase): + """Advanced view tests with complex scenarios""" + + def setUp(self): + self.client = Client() + self.user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123', + is_staff=True + ) + self.profile = Profile.objects.create(user=self.user) + + self.job = JobPosting.objects.create( + title='Software Engineer', + department='IT', + job_type='FULL_TIME', + workplace_type='REMOTE', + location_country='Saudi Arabia', + description='Job description', + qualifications='Job qualifications', + created_by=self.user, + status='ACTIVE' + ) + + self.candidate = Candidate.objects.create( + first_name='John', + last_name='Doe', + email='john@example.com', + phone='1234567890', + job=self.job, + stage='Applied' + ) + + self.zoom_meeting = ZoomMeeting.objects.create( + topic='Interview with John Doe', + start_time=timezone.now() + timedelta(hours=1), + duration=60, + timezone='UTC', + join_url='https://zoom.us/j/123456789', + meeting_id='123456789' + ) + + def test_job_detail_with_multiple_candidates(self): + """Test job detail view with multiple candidates at different stages""" + # Create more candidates at different stages + Candidate.objects.create( + first_name='Jane', last_name='Smith', email='jane@example.com', + phone='9876543210', job=self.job, stage='Exam' + ) + Candidate.objects.create( + first_name='Bob', last_name='Johnson', email='bob@example.com', + phone='5555555555', job=self.job, stage='Interview' + ) + Candidate.objects.create( + first_name='Alice', last_name='Brown', email='alice@example.com', + phone='4444444444', job=self.job, stage='Offer' + ) + + response = self.client.get(reverse('job_detail', kwargs={'slug': self.job.slug})) + self.assertEqual(response.status_code, 200) + + # Check that counts are correct + self.assertContains(response, 'Total Applicants: 4') + self.assertContains(response, 'Applied: 1') + self.assertContains(response, 'Exam: 1') + self.assertContains(response, 'Interview: 1') + self.assertContains(response, 'Offer: 1') + + def test_meeting_list_with_complex_filters(self): + """Test meeting list view with multiple filter combinations""" + # Create meetings with different statuses and candidates + meeting2 = ZoomMeeting.objects.create( + topic='Interview with Jane Smith', + start_time=timezone.now() + timedelta(hours=2), + duration=60, + timezone='UTC', + join_url='https://zoom.us/j/987654321', + meeting_id='987654321', + status='started' + ) + + # Create scheduled interviews + ScheduledInterview.objects.create( + candidate=self.candidate, + job=self.job, + zoom_meeting=self.zoom_meeting, + interview_date=timezone.now().date(), + interview_time=time(10, 0), + status='scheduled' + ) + + ScheduledInterview.objects.create( + candidate=Candidate.objects.create( + first_name='Jane', last_name='Smith', email='jane@example.com', + phone='9876543210', job=self.job, stage='Interview' + ), + job=self.job, + zoom_meeting=meeting2, + interview_date=timezone.now().date(), + interview_time=time(11, 0), + status='scheduled' + ) + + # Test combined filters + response = self.client.get(reverse('list_meetings'), { + 'q': 'Interview', + 'status': 'waiting', + 'candidate_name': 'John' + }) + self.assertEqual(response.status_code, 200) + + def test_candidate_list_advanced_search(self): + """Test candidate list view with advanced search functionality""" + # Create more candidates for testing + Candidate.objects.create( + first_name='Jane', last_name='Smith', email='jane@example.com', + phone='9876543210', job=self.job, stage='Exam' + ) + Candidate.objects.create( + first_name='Bob', last_name='Johnson', email='bob@example.com', + phone='5555555555', job=self.job, stage='Interview' + ) + + # Test search by name + response = self.client.get(reverse('candidate_list'), { + 'search': 'Jane' + }) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Jane Smith') + + # Test search by email + response = self.client.get(reverse('candidate_list'), { + 'search': 'bob@example.com' + }) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Bob Johnson') + + # Test filter by job + response = self.client.get(reverse('candidate_list'), { + 'job': self.job.slug + }) + self.assertEqual(response.status_code, 200) + + # Test filter by stage + response = self.client.get(reverse('candidate_list'), { + 'stage': 'Exam' + }) + self.assertEqual(response.status_code, 200) + + def test_interview_scheduling_workflow(self): + """Test the complete interview scheduling workflow""" + # Create candidates for scheduling + candidates = [] + for i in range(3): + candidate = Candidate.objects.create( + first_name=f'Candidate{i}', + last_name=f'Test{i}', + email=f'candidate{i}@example.com', + phone=f'123456789{i}', + job=self.job, + stage='Interview' + ) + candidates.append(candidate) + + # Test GET request (initial form) + request = self.client.get(reverse('schedule_interviews', kwargs={'slug': self.job.slug})) + self.assertEqual(request.status_code, 200) + + # Test POST request with preview + with patch('recruitment.views.get_available_time_slots') as mock_slots: + # Mock available time slots + mock_slots.return_value = [ + {'date': date.today() + timedelta(days=1), 'time': '10:00'}, + {'date': date.today() + timedelta(days=1), 'time': '11:00'}, + {'date': date.today() + timedelta(days=1), 'time': '14:00'} + ] + + # Test _handle_preview_submission + self.client.login(username='testuser', password='testpass123') + post_data = { + 'candidates': [c.pk for c in candidates], + 'start_date': (date.today() + timedelta(days=1)).isoformat(), + 'end_date': (date.today() + timedelta(days=7)).isoformat(), + 'working_days': [0, 1, 2, 3, 4], + 'start_time': '09:00', + 'end_time': '17:00', + 'interview_duration': '60', + 'buffer_time': '15' + } + + # This would normally be handled by the view, but we test the logic directly + # In a real test, you'd make a POST request to the view + request = self.client.post( + reverse('schedule_interviews', kwargs={'slug': self.job.slug}), + data=post_data + ) + self.assertEqual(request.status_code, 200) # Should show preview + + @patch('recruitment.views.create_zoom_meeting') + def test_meeting_creation_with_api_errors(self, mock_create): + """Test meeting creation when API returns errors""" + # Test API error + mock_create.return_value = { + 'status': 'error', + 'message': 'Failed to create meeting' + } + + self.client.login(username='testuser', password='testpass123') + data = { + 'topic': 'Test Meeting', + 'start_time': (timezone.now() + timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M'), + 'duration': 60 + } + + response = self.client.post(reverse('create_meeting'), data) + # Should show error message + self.assertEqual(response.status_code, 200) # Form with error + + def test_htmx_responses(self): + """Test HTMX responses for partial updates""" + # Test HTMX request for candidate screening + response = self.client.get( + reverse('candidate_screening_view', kwargs={'slug': self.job.slug}), + HTTP_HX_REQUEST='true' + ) + self.assertEqual(response.status_code, 200) + + # Test HTMX request for meeting details + response = self.client.get( + reverse('meeting_details', kwargs={'slug': self.zoom_meeting.slug}), + HTTP_HX_REQUEST='true' + ) + self.assertEqual(response.status_code, 200) + + def test_bulk_operations(self): + """Test bulk operations on candidates""" + # Create multiple candidates + candidates = [] + for i in range(5): + candidate = Candidate.objects.create( + first_name=f'Bulk{i}', + last_name=f'Test{i}', + email=f'bulk{i}@example.com', + phone=f'123456789{i}', + job=self.job, + stage='Applied' + ) + candidates.append(candidate) + + # Test bulk status update + candidate_ids = [c.pk for c in candidates] + self.client.login(username='testuser', password='testpass123') + + # This would be tested via a form submission + # For now, we test the view logic directly + request = self.client.post( + reverse('candidate_update_status', kwargs={'slug': self.job.slug}), + data={'candidate_ids': candidate_ids, 'mark_as': 'Exam'} + ) + # Should redirect back to the view + self.assertEqual(request.status_code, 302) + + # Verify candidates were updated + updated_count = Candidate.objects.filter( + pk__in=candidate_ids, + stage='Exam' + ).count() + self.assertEqual(updated_count, len(candidates)) + + +class AdvancedFormTests(TestCase): + """Advanced form tests with complex validation scenarios""" + + def setUp(self): + self.user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123', + is_staff=True + ) + + self.job = JobPosting.objects.create( + title='Software Engineer', + department='IT', + job_type='FULL_TIME', + workplace_type='REMOTE', + location_country='Saudi Arabia', + description='Job description', + qualifications='Job qualifications', + created_by=self.user + ) + + def test_complex_form_validation_scenarios(self): + """Test complex validation scenarios for forms""" + # Test JobPostingForm with all field types + complex_data = { + 'title': 'Senior Software Engineer', + 'department': 'Engineering', + 'job_type': 'FULL_TIME', + 'workplace_type': 'HYBRID', + 'location_city': 'Riyadh', + 'location_state': 'Riyadh', + 'location_country': 'Saudi Arabia', + 'description': 'Detailed job description', + 'qualifications': 'Detailed qualifications', + 'salary_range': '8000-12000 SAR', + 'benefits': 'Health insurance, annual leave', + 'application_start_date': '2025-01-01', + 'application_deadline': '2025-12-31', + 'application_instructions': 'Submit your resume online', + 'position_number': 'ENG-2025-001', + 'reporting_to': 'Engineering Manager', + 'joining_date': '2025-06-01', + 'created_by': self.user.get_full_name(), + 'open_positions': '3', + 'hash_tags': '#tech, #engineering, #senior', + 'max_applications': '200' + } + + form = JobPostingForm(data=complex_data) + self.assertTrue(form.is_valid(), form.errors) + + def test_form_dependency_validation(self): + """Test validation for dependent form fields""" + # Test InterviewScheduleForm with dependent fields + schedule_data = { + 'candidates': [], # Empty for now + 'start_date': '2025-01-15', + 'end_date': '2025-01-10', # Invalid: end_date before start_date + 'working_days': [0, 1, 2, 3, 4], + 'start_time': '09:00', + 'end_time': '17:00', + 'interview_duration': '60', + 'buffer_time': '15' + } + + form = InterviewScheduleForm(slug=self.job.slug, data=schedule_data) + self.assertFalse(form.is_valid()) + self.assertIn('end_date', form.errors) + + def test_file_upload_validation(self): + """Test file upload validation in forms""" + # Test valid file upload + valid_file = SimpleUploadedFile( + 'valid_resume.pdf', + b'%PDF-1.4\n% ...', + content_type='application/pdf' + ) + + candidate_data = { + 'job': self.job.id, + 'first_name': 'John', + 'last_name': 'Doe', + 'phone': '1234567890', + 'email': 'john@example.com', + 'resume': valid_file + } + + form = CandidateForm(data=candidate_data, files=candidate_data) + self.assertTrue(form.is_valid()) + + # Test invalid file type (would need custom validator) + # This test depends on your actual file validation logic + + def test_dynamic_form_fields(self): + """Test forms with dynamically populated fields""" + # Test InterviewScheduleForm with dynamic candidate queryset + # Create candidates in Interview stage + candidates = [] + for i in range(3): + candidate = Candidate.objects.create( + first_name=f'Interview{i}', + last_name=f'Candidate{i}', + email=f'interview{i}@example.com', + phone=f'123456789{i}', + job=self.job, + stage='Interview' + ) + candidates.append(candidate) + + # Form should only show Interview stage candidates + form = InterviewScheduleForm(slug=self.job.slug) + self.assertEqual(form.fields['candidates'].queryset.count(), 3) + + for candidate in candidates: + self.assertIn(candidate, form.fields['candidates'].queryset) + + +class AdvancedIntegrationTests(TransactionTestCase): + """Advanced integration tests covering multiple components""" + + def setUp(self): + self.client = Client() + self.user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123', + is_staff=True + ) + self.profile = Profile.objects.create(user=self.user) + + def test_complete_hiring_workflow(self): + """Test the complete hiring workflow from job posting to hire""" + # 1. Create job + job = JobPosting.objects.create( + title='Product Manager', + department='Product', + job_type='FULL_TIME', + workplace_type='ON_SITE', + location_country='Saudi Arabia', + description='Product Manager job description', + qualifications='Product management experience', + created_by=self.user, + status='ACTIVE' + ) + + # 2. Create form template for applications + template = FormTemplate.objects.create( + job=job, + name='Product Manager Application', + created_by=self.user, + is_active=True + ) + + # 3. Create form stages and fields + personal_stage = FormStage.objects.create( + template=template, + name='Personal Information', + order=0 + ) + + FormField.objects.create( + stage=personal_stage, + label='First Name', + field_type='text', + order=0, + required=True + ) + FormField.objects.create( + stage=personal_stage, + label='Last Name', + field_type='text', + order=1, + required=True + ) + FormField.objects.create( + stage=personal_stage, + label='Email', + field_type='email', + order=2, + required=True + ) + + experience_stage = FormStage.objects.create( + template=template, + name='Work Experience', + order=1 + ) + + FormField.objects.create( + stage=experience_stage, + label='Years of Experience', + field_type='number', + order=0 + ) + + # 4. Submit application + submission_data = { + 'field_1': 'Sarah', + 'field_2': 'Johnson', + 'field_3': 'sarah@example.com', + 'field_4': '5' + } + + response = self.client.post( + reverse('submit_form', kwargs={'template_id': template.id}), + submission_data + ) + self.assertEqual(response.status_code, 302) # Redirect to success page + + # 5. Verify candidate was created + candidate = Candidate.objects.get(email='sarah@example.com') + self.assertEqual(candidate.stage, 'Applied') + self.assertEqual(candidate.job, job) + + # 6. Move candidate to Exam stage + candidate.stage = 'Exam' + candidate.save() + + # 7. Move candidate to Interview stage + candidate.stage = 'Interview' + candidate.save() + + # 8. Create interview schedule + scheduled_interview = ScheduledInterview.objects.create( + candidate=candidate, + job=job, + interview_date=timezone.now().date() + timedelta(days=7), + interview_time=time(14, 0), + status='scheduled' + ) + + # 9. Create Zoom meeting + zoom_meeting = ZoomMeeting.objects.create( + topic=f'Interview: {job.title} with {candidate.name}', + start_time=timezone.now() + timedelta(days=7, hours=14), + duration=60, + timezone='UTC', + join_url='https://zoom.us/j/interview123', + meeting_id='interview123' + ) + + # 10. Assign meeting to interview + scheduled_interview.zoom_meeting = zoom_meeting + scheduled_interview.save() + + # 11. Verify all relationships + self.assertEqual(candidate.scheduled_interviews.count(), 1) + self.assertEqual(zoom_meeting.interview, scheduled_interview) + self.assertEqual(job.candidates.count(), 1) + + # 12. Complete hire process + candidate.stage = 'Offer' + candidate.save() + + # 13. Verify final state + self.assertEqual(Candidate.objects.filter(stage='Offer').count(), 1) + + def test_data_integrity_across_operations(self): + """Test data integrity across multiple operations""" + # Create complex data structure + job = JobPosting.objects.create( + title='Data Scientist', + department='Analytics', + job_type='FULL_TIME', + workplace_type='REMOTE', + location_country='Saudi Arabia', + description='Data Scientist position', + created_by=self.user, + max_applications=5 + ) + + # Create multiple candidates + candidates = [] + for i in range(3): + candidate = Candidate.objects.create( + first_name=f'Data{i}', + last_name=f'Scientist{i}', + email=f'data{i}@example.com', + phone=f'123456789{i}', + job=job, + stage='Applied' + ) + candidates.append(candidate) + + # Create form template + template = FormTemplate.objects.create( + job=job, + name='Data Scientist Application', + created_by=self.user, + is_active=True + ) + + # Create submissions for candidates + for i, candidate in enumerate(candidates): + submission = FormSubmission.objects.create( + template=template, + applicant_name=f'{candidate.first_name} {candidate.last_name}', + applicant_email=candidate.email + ) + + # Create field responses + FieldResponse.objects.create( + submission=submission, + field=FormField.objects.create( + stage=FormStage.objects.create(template=template, name='Stage 1', order=0), + label='Test Field', + field_type='text' + ), + value=f'Test response {i}' + ) + + # Verify data consistency + self.assertEqual(FormSubmission.objects.filter(template=template).count(), 3) + self.assertEqual(FieldResponse.objects.count(), 3) + + # Test application limit + for i in range(3): # Try to add more candidates than limit + Candidate.objects.create( + first_name=f'Extra{i}', + last_name=f'Candidate{i}', + email=f'extra{i}@example.com', + phone=f'11111111{i}', + job=job, + stage='Applied' + ) + + # Verify that the job shows application limit warning + job.refresh_from_db() + self.assertTrue(job.is_application_limit_reached) + + @patch('recruitment.views.create_zoom_meeting') + def test_zoom_integration_workflow(self, mock_create): + """Test complete Zoom integration workflow""" + # Setup job and candidate + job = JobPosting.objects.create( + title='Remote Developer', + department='Engineering', + job_type='REMOTE', + created_by=self.user + ) + + candidate = Candidate.objects.create( + first_name='Remote', + last_name='Developer', + email='remote@example.com', + job=job, + stage='Interview' + ) + + # Mock successful Zoom meeting creation + mock_create.return_value = { + 'status': 'success', + 'meeting_details': { + 'meeting_id': 'zoom123', + 'join_url': 'https://zoom.us/j/zoom123', + 'password': 'meeting123' + }, + 'zoom_gateway_response': { + 'status': 'waiting', + 'id': 'meeting_zoom123' + } + } + + # Schedule meeting via API + with patch('recruitment.views.ScheduledInterview.objects.create') as mock_create_interview: + mock_create_interview.return_value = ScheduledInterview( + candidate=candidate, + job=job, + zoom_meeting=None, + interview_date=timezone.now().date(), + interview_time=time(15, 0), + status='scheduled' + ) + + response = self.client.post( + reverse('api_schedule_candidate_meeting', + kwargs={'job_slug': job.slug, 'candidate_pk': candidate.pk}), + data={ + 'start_time': (timezone.now() + timedelta(hours=1)).isoformat(), + 'duration': 60 + } + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'success') + + # Verify Zoom API was called + mock_create.assert_called_once() + + # Verify interview was created + mock_create_interview.assert_called_once() + + def test_concurrent_operations(self): + """Test handling of concurrent operations""" + # Create job + job = JobPosting.objects.create( + title='Concurrency Test', + department='Test', + created_by=self.user + ) + + # Create candidates + candidates = [] + for i in range(10): + candidate = Candidate.objects.create( + first_name=f'Concurrent{i}', + last_name=f'Test{i}', + email=f'concurrent{i}@example.com', + job=job, + stage='Applied' + ) + candidates.append(candidate) + + # Test concurrent candidate updates + from concurrent.futures import ThreadPoolExecutor + + def update_candidate(candidate_id, stage): + from django.test import TestCase + from django.db import transaction + from recruitment.models import Candidate + + with transaction.atomic(): + candidate = Candidate.objects.select_for_update().get(pk=candidate_id) + candidate.stage = stage + candidate.save() + + # Update candidates concurrently + with ThreadPoolExecutor(max_workers=3) as executor: + futures = [ + executor.submit(update_candidate, c.pk, 'Exam') + for c in candidates + ] + + for future in futures: + future.result() + + # Verify all updates completed + self.assertEqual(Candidate.objects.filter(stage='Exam').count(), len(candidates)) + + +class SecurityTests(TestCase): + """Security-focused tests""" + + def setUp(self): + self.client = Client() + self.user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123', + is_staff=False + ) + self.staff_user = User.objects.create_user( + username='staffuser', + email='staff@example.com', + password='testpass123', + is_staff=True + ) + + self.job = JobPosting.objects.create( + title='Security Test Job', + department='Security', + job_type='FULL_TIME', + created_by=self.staff_user + ) + + def test_unauthorized_access_control(self): + """Test that unauthorized users cannot access protected resources""" + # Test regular user accessing staff-only functionality + self.client.login(username='testuser', password='testpass123') + + # Access job list (should be accessible) + response = self.client.get(reverse('job_list')) + self.assertEqual(response.status_code, 200) + + # Try to edit job (should be restricted based on your actual implementation) + response = self.client.get(reverse('job_update', kwargs={'slug': self.job.slug})) + # This depends on your actual access control implementation + # For now, we'll assume it redirects or shows 403 + + def test_csrf_protection(self): + """Test CSRF protection on forms""" + # Test POST request without CSRF token (should fail) + self.client.login(username='staffuser', password='testpass123') + + response = self.client.post( + reverse('create_meeting'), + data={ + 'topic': 'Test Meeting', + 'start_time': (timezone.now() + timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M'), + 'duration': 60 + }, + HTTP_X_CSRFTOKEN='invalid' # Invalid or missing CSRF token + ) + # Should be blocked by Django's CSRF protection + # The exact behavior depends on your middleware setup + + def test_sql_injection_prevention(self): + """Test that forms prevent SQL injection""" + # Test SQL injection in form fields + malicious_input = "Robert'); DROP TABLE candidates;--" + + form_data = { + 'title': f'SQL Injection Test {malicious_input}', + 'department': 'IT', + 'job_type': 'FULL_TIME', + 'workplace_type': 'REMOTE' + } + + form = JobPostingForm(data=form_data) + # Form should still be valid (malicious input stored as text, not executed) + self.assertTrue(form.is_valid()) + + # The actual protection comes from Django's ORM parameterized queries + + def test_xss_prevention(self): + """Test that forms prevent XSS attacks""" + # Test XSS attempt in form fields + xss_script = '' + + form_data = { + 'title': f'XSS Test {xss_script}', + 'department': 'IT', + 'job_type': 'FULL_TIME', + 'workplace_type': 'REMOTE' + } + + form = JobPostingForm(data=form_data) + self.assertTrue(form.is_valid()) + + # The actual protection should be in template rendering + # Test template rendering with potentially malicious content + job = JobPosting.objects.create( + title=f'XSS Test {xss_script}', + department='IT', + created_by=self.staff_user + ) diff --git a/recruitment/urls.py b/recruitment/urls.py index c20dd58..5e93c73 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -4,7 +4,7 @@ from . import views from . import views_integration urlpatterns = [ - path('dashboard/', views_frontend.dashboard_view, name='dashboard'), + path('', views_frontend.dashboard_view, name='dashboard'), # Job URLs (using JobPosting model) path('jobs/', views_frontend.JobListView.as_view(), name='job_list'), @@ -68,6 +68,8 @@ urlpatterns = [ path('jobs//candidate_screening_view/', views.candidate_screening_view, name='candidate_screening_view'), path('jobs//candidate_exam_view/', views.candidate_exam_view, name='candidate_exam_view'), path('jobs//candidate_interview_view/', views.candidate_interview_view, name='candidate_interview_view'), + path('jobs//candidate_offer_view/', views_frontend.candidate_offer_view, name='candidate_offer_view'), + path('jobs//candidates//update_status///', views_frontend.update_candidate_status, name='update_candidate_status'), path('jobs///reschedule_meeting_for_candidate//', views.reschedule_meeting_for_candidate, name='reschedule_meeting_for_candidate'), @@ -108,4 +110,11 @@ urlpatterns = [ # users urls path('user/',views.user_detail,name='user_detail'), + + # Meeting Comments URLs + path('meetings//comments/add/', views.add_meeting_comment, name='add_meeting_comment'), + path('meetings//comments//edit/', views.edit_meeting_comment, name='edit_meeting_comment'), + path('meetings//comments//delete/', views.delete_meeting_comment, name='delete_meeting_comment'), + + path('meetings//set_meeting_candidate/', views.set_meeting_candidate, name='set_meeting_candidate'), ] diff --git a/recruitment/views.py b/recruitment/views.py index bc6899b..9d58fd6 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -1,6 +1,9 @@ import json import requests from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import LoginRequiredMixin +from django import forms from rich import print from django.template.loader import render_to_string from django.views.decorators.csrf import csrf_exempt @@ -14,12 +17,13 @@ from django.conf import settings from django.utils import timezone from .forms import ( CandidateExamDateForm, + InterviewForm, ZoomMeetingForm, JobPostingForm, FormTemplateForm, InterviewScheduleForm,JobPostingStatusForm, BreakTimeFormSet, - JobPostingImageForm + JobPostingImageForm,MeetingCommentForm ) from rest_framework import viewsets from django.contrib import messages @@ -51,7 +55,7 @@ from .models import ( ZoomMeeting, Candidate, JobPosting, - ScheduledInterview + ScheduledInterview,MeetingComment ) import logging from datastar_py.django import ( @@ -61,10 +65,12 @@ from datastar_py.django import ( ) from django.db import transaction from django_q.tasks import async_task +from django.db.models import Prefetch +from django.db.models import Q, Count, Avg +from django.db.models import FloatField logger = logging.getLogger(__name__) - class JobPostingViewSet(viewsets.ModelViewSet): queryset = JobPosting.objects.all() serializer_class = JobPostingSerializer @@ -75,7 +81,7 @@ class CandidateViewSet(viewsets.ModelViewSet): serializer_class = CandidateSerializer -class ZoomMeetingCreateView(CreateView): +class ZoomMeetingCreateView(LoginRequiredMixin, CreateView): model = ZoomMeeting template_name = "meetings/create_meeting.html" form_class = ZoomMeetingForm @@ -87,9 +93,8 @@ class ZoomMeetingCreateView(CreateView): topic = instance.topic if instance.start_time < timezone.now(): messages.error(self.request, "Start time must be in the future.") - return redirect("/create-meeting/", status=400) + return redirect(reverse("create_meeting",kwargs={"slug": instance.slug})) start_time = instance.start_time - # start_time = instance.start_time.isoformat() + "Z" duration = instance.duration result = create_zoom_meeting(topic, start_time, duration) @@ -103,15 +108,16 @@ class ZoomMeetingCreateView(CreateView): instance.save() messages.success(self.request, result["message"]) - return redirect("/", status=201) + return redirect(reverse("list_meetings")) else: messages.error(self.request, result["message"]) - return redirect("/", status=400) + return redirect(reverse("create_meeting",kwargs={"slug": instance.slug})) except Exception as e: - return redirect("/", status=500) + messages.error(self.request, f"Error creating meeting: {e}") + return redirect(reverse("create_meeting",kwargs={"slug": instance.slug})) -class ZoomMeetingListView(ListView): +class ZoomMeetingListView(LoginRequiredMixin, ListView): model = ZoomMeeting template_name = "meetings/list_meetings.html" context_object_name = "meetings" @@ -121,7 +127,7 @@ class ZoomMeetingListView(ListView): queryset = super().get_queryset().order_by("-start_time") # Prefetch related interview data efficiently - from django.db.models import Prefetch + queryset = queryset.prefetch_related( Prefetch( 'interview', # related_name from ZoomMeeting to ScheduledInterview @@ -161,13 +167,13 @@ class ZoomMeetingListView(ListView): return context -class ZoomMeetingDetailsView(DetailView): +class ZoomMeetingDetailsView(LoginRequiredMixin, DetailView): model = ZoomMeeting template_name = "meetings/meeting_details.html" context_object_name = "meeting" -class ZoomMeetingUpdateView(UpdateView): +class ZoomMeetingUpdateView(LoginRequiredMixin, UpdateView): model = ZoomMeeting form_class = ZoomMeetingForm context_object_name = "meeting" @@ -198,7 +204,7 @@ class ZoomMeetingUpdateView(UpdateView): } if instance.start_time < timezone.now(): messages.error(self.request, "Start time must be in the future.") - return redirect(f"/update-meeting/{instance.pk}/", status=400) + return redirect(reverse("meeting_details", kwargs={"slug": instance.slug})) result = update_meeting(instance, updated_data) @@ -209,21 +215,22 @@ class ZoomMeetingUpdateView(UpdateView): return redirect(reverse("meeting_details", kwargs={"slug": instance.slug})) -def ZoomMeetingDeleteView(request, pk): - meeting = get_object_or_404(ZoomMeeting, pk=pk) - meeting_id = meeting.meeting_id - try: - result = delete_zoom_meeting(meeting_id) - if result["status"] == "success": - meeting.delete() - messages.success(request, result["message"]) - else: - messages.error(request, result["message"]) - return redirect("/") - except Exception as e: - messages.error(request, str(e)) - return redirect("/") - +def ZoomMeetingDeleteView(request, slug): + meeting = get_object_or_404(ZoomMeeting, slug=slug) + if "HX-Request" in request.headers: + return render(request, "meetings/delete_meeting_form.html", {"meeting": meeting,"delete_url": reverse("delete_meeting", kwargs={"slug": meeting.slug})}) + if request.method == "POST": + try: + result = delete_zoom_meeting(meeting.meeting_id) + if result["status"] == "success" or "Meeting does not exist" in result["details"]["message"]: + meeting.delete() + messages.success(request, "Meeting deleted successfully.") + else: + messages.error(request, f"{result["message"]} , {result['details']["message"]}") + return redirect(reverse("list_meetings")) + except Exception as e: + messages.error(request, str(e)) + return redirect(reverse("list_meetings")) # Job Posting # def job_list(request): @@ -247,6 +254,7 @@ def ZoomMeetingDeleteView(request, pk): # }) +@login_required def create_job(request): """Create a new job posting""" @@ -285,10 +293,11 @@ def create_job(request): return render(request, "jobs/create_job.html", {"form": form}) +@login_required def edit_job(request, slug): """Edit an existing job posting""" + job = get_object_or_404(JobPosting, slug=slug) if request.method == "POST": - job = get_object_or_404(JobPosting, slug=slug) form = JobPostingForm( request.POST, instance=job, @@ -321,14 +330,13 @@ def edit_job(request, slug): return render(request, "jobs/edit_job.html", {"form": form, "job": job}) +@login_required def job_detail(request, slug): """View details of a specific job""" job = get_object_or_404(JobPosting, slug=slug) - print(job) # Get all candidates for this job, ordered by most recent applicants = job.candidates.all().order_by("-created_at") - print(applicants) # Count candidates by stage for summary statistics total_applicant = applicants.count() @@ -341,12 +349,10 @@ def job_detail(request, slug): offer_count = applicants.filter(stage="Offer").count() - status_form = JobPostingStatusForm(instance=job) image_upload_form=JobPostingImageForm(instance=job) - # 2. Check for POST request (Status Update Submission) if request.method == 'POST': @@ -365,6 +371,19 @@ def job_detail(request, slug): messages.error(request, "Failed to update status due to validation errors.") + category_data = applicants.filter( + major_category_name__isnull=False, + ai_analysis_data__match_score__isnull=False # This was part of the original query, ensure it's intentional + ).values('major_category_name').annotate( + candidate_count=Count('id'), + ai_analysis_data__avg_match_score=Avg('match_score', output_field=FloatField()) + ).order_by('major_category_name') + + # Prepare data for Chart.js + categories = [item['major_category_name'] for item in category_data] + candidate_counts = [item['candidate_count'] for item in category_data] + avg_scores = [round(item['avg_match_score'], 2) if item['avg_match_score'] is not None else 0 for item in category_data] + context = { "job": job, @@ -375,10 +394,14 @@ def job_detail(request, slug): "interview_count": interview_count, "offer_count": offer_count, 'status_form':status_form, - 'image_upload_form':image_upload_form + 'image_upload_form':image_upload_form, + 'categories': categories, + 'candidate_counts': candidate_counts, + 'avg_scores': avg_scores } return render(request, "jobs/job_detail.html", context) +@login_required def job_image_upload(request, slug): #only for handling the post request job=get_object_or_404(JobPosting,slug=slug) @@ -417,6 +440,7 @@ def job_detail_candidate(request, slug): return render(request, "jobs/job_detail_candidate.html", {"job": job}) +@login_required def post_to_linkedin(request, slug): """Post a job to LinkedIn""" job = get_object_or_404(JobPosting, slug=slug) @@ -516,243 +540,8 @@ def application_success(request,slug): job=get_object_or_404(JobPosting,slug=slug) return render(request,'jobs/application_success.html',{'job':job}) - - - - -# Form Preview Views -# from django.http import JsonResponse -# from django.views.decorators.csrf import csrf_exempt -# from django.core.paginator import Paginator -# from django.contrib.auth.decorators import login_required -# import json - -# def form_list(request): -# """Display list of all available forms""" -# forms = Form.objects.filter(is_active=True).order_by('-created_at') - -# # Pagination -# paginator = Paginator(forms, 12) -# page_number = request.GET.get('page') -# page_obj = paginator.get_page(page_number) - -# return render(request, 'forms/form_list.html', { -# 'page_obj': page_obj -# }) - -# def form_preview(request, form_id): -# """Display form preview for end users""" -# form = get_object_or_404(Form, id=form_id, is_active=True) - -# # Get submission count for analytics -# submission_count = form.submissions.count() - -# return render(request, 'forms/form_preview.html', { -# 'form': form, -# 'submission_count': submission_count, -# 'is_embed': request.GET.get('embed', 'false') == 'true' -# }) - -# @csrf_exempt -# def form_submit(request, form_id): -# """Handle form submission via AJAX""" -# if request.method != 'POST': -# return JsonResponse({'success': False, 'error': 'Only POST method allowed'}, status=405) - -# form = get_object_or_404(Form, id=form_id, is_active=True) - -# try: -# # Parse form data -# submission_data = {} -# files = {} - -# # Process regular form fields -# for key, value in request.POST.items(): -# if key != 'csrfmiddlewaretoken': -# submission_data[key] = value - -# # Process file uploads -# for key, file in request.FILES.items(): -# if file: -# files[key] = file - -# # Create form submission -# submission = FormSubmission.objects.create( -# form=form, -# submission_data=submission_data, -# ip_address=request.META.get('REMOTE_ADDR'), -# user_agent=request.META.get('HTTP_USER_AGENT', '') -# ) - -# # Handle file uploads -# for field_id, file in files.items(): -# UploadedFile.objects.create( -# submission=submission, -# field_id=field_id, -# file=file, -# original_filename=file.name -# ) - -# # TODO: Send email notification if configured - -# return JsonResponse({ -# 'success': True, -# 'message': 'Form submitted successfully!', -# 'submission_id': submission.id -# }) - -# except Exception as e: -# logger.error(f"Error submitting form {form_id}: {e}") -# return JsonResponse({ -# 'success': False, -# 'error': 'An error occurred while submitting the form. Please try again.' -# }, status=500) - -# def form_embed(request, form_id): -# """Display embeddable version of form""" -# form = get_object_or_404(Form, id=form_id, is_active=True) - -# return render(request, 'forms/form_embed.html', { -# 'form': form, -# 'is_embed': True -# }) - -# @login_required -# def save_form_builder(request): -# """Save form from builder to database""" -# if request.method != 'POST': -# return JsonResponse({'success': False, 'error': 'Only POST method allowed'}, status=405) - -# try: -# data = json.loads(request.body) -# form_data = data.get('form', {}) - -# # Check if this is an update or create -# form_id = data.get('form_id') - -# if form_id: -# # Update existing form -# form = Form.objects.get(id=form_id, created_by=request.user) -# form.title = form_data.get('title', 'Untitled Form') -# form.description = form_data.get('description', '') -# form.structure = form_data -# form.save() -# else: -# # Create new form -# form = Form.objects.create( -# title=form_data.get('title', 'Untitled Form'), -# description=form_data.get('description', ''), -# structure=form_data, -# created_by=request.user -# ) - -# return JsonResponse({ -# 'success': True, -# 'form_id': form.id, -# 'message': 'Form saved successfully!' -# }) - -# except json.JSONDecodeError: -# return JsonResponse({ -# 'success': False, -# 'error': 'Invalid JSON data' -# }, status=400) -# except Exception as e: -# logger.error(f"Error saving form: {e}") -# return JsonResponse({ -# 'success': False, -# 'error': 'An error occurred while saving the form' -# }, status=500) - -# @login_required -# def load_form(request, form_id): -# """Load form data for editing in builder""" -# try: -# form = get_object_or_404(Form, id=form_id, created_by=request.user) - -# return JsonResponse({ -# 'success': True, -# 'form': { -# 'id': form.id, -# 'title': form.title, -# 'description': form.description, -# 'structure': form.structure -# } -# }) - -# except Exception as e: -# logger.error(f"Error loading form {form_id}: {e}") -# return JsonResponse({ -# 'success': False, -# 'error': 'An error occurred while loading the form' -# }, status=500) - -# @csrf_exempt -# def update_form_builder(request, form_id): -# """Update existing form from builder""" -# if request.method != 'POST': -# return JsonResponse({'success': False, 'error': 'Only POST method allowed'}, status=405) - -# try: -# form = get_object_or_404(Form, id=form_id) - -# # Check if user has permission to edit this form -# if form.created_by != request.user: -# return JsonResponse({ -# 'success': False, -# 'error': 'You do not have permission to edit this form' -# }, status=403) - -# data = json.loads(request.body) -# form_data = data.get('form', {}) - -# # Update form -# form.title = form_data.get('title', 'Untitled Form') -# form.description = form_data.get('description', '') -# form.structure = form_data -# form.save() - -# return JsonResponse({ -# 'success': True, -# 'form_id': form.id, -# 'message': 'Form updated successfully!' -# }) - -# except json.JSONDecodeError: -# return JsonResponse({ -# 'success': False, -# 'error': 'Invalid JSON data' -# }, status=400) -# except Exception as e: -# logger.error(f"Error updating form {form_id}: {e}") -# return JsonResponse({ -# 'success': False, -# 'error': 'An error occurred while updating the form' -# }, status=500) - -# def edit_form(request, form_id): -# """Display form edit page""" -# form = get_object_or_404(Form, id=form_id) - -# # Check if user has permission to edit this form -# if form.created_by != request.user: -# messages.error(request, 'You do not have permission to edit this form.') -# return redirect('form_list') - -# return render(request, 'forms/edit_form.html', { -# 'form': form -# }) - -# def form_submissions(request, form_id): -# """View submissions for a specific form""" -# form = get_object_or_404(Form, id=form_id, created_by=request.user) -# submissions = form.submissions.all().order_by('-submitted_at') - -# # Pagination -# - - @ensure_csrf_cookie +@login_required def form_builder(request, template_id=None): """Render the form builder interface""" context = {} @@ -878,6 +667,7 @@ def load_form_template(request, template_id): ) +@login_required def form_templates_list(request): """List all form templates for the current user""" query = request.GET.get("q", "") @@ -898,6 +688,7 @@ def form_templates_list(request): return render(request, "forms/form_templates_list.html", context) +@login_required def create_form_template(request): """Create a new form template""" if request.method == "POST": @@ -916,6 +707,7 @@ def create_form_template(request): return render(request, "forms/create_form_template.html", {"form": form}) +@login_required @require_http_methods(["GET"]) def list_form_templates(request): """List all form templates for the current user""" @@ -925,6 +717,7 @@ def list_form_templates(request): return JsonResponse({"success": True, "templates": list(templates)}) +@login_required @require_http_methods(["DELETE"]) def delete_form_template(request, template_id): """Delete a form template""" @@ -1038,6 +831,7 @@ def submit_form(request, template_id): ) +@login_required def form_template_submissions_list(request, slug): """List all submissions for a specific form template""" template = get_object_or_404(FormTemplate, slug=slug) @@ -1058,6 +852,7 @@ def form_template_submissions_list(request, slug): ) +@login_required def form_template_all_submissions(request, template_id): """Display all submissions for a form template in table format""" template = get_object_or_404(FormTemplate, id=template_id) @@ -1084,6 +879,7 @@ def form_template_all_submissions(request, template_id): ) +@login_required def form_submission_details(request, template_id, slug): """Display detailed view of a specific form submission""" # Get the form template and verify ownership @@ -1123,6 +919,7 @@ def _handle_get_request(request, slug, job): from the session for persistence. """ SESSION_KEY = f"schedule_candidate_ids_{slug}" + form = InterviewScheduleForm(slug=slug) # break_formset = BreakTimeFormSet(prefix='breaktime') @@ -1131,6 +928,7 @@ def _handle_get_request(request, slug, job): # 1. Capture IDs from HTMX request and store in session (when first clicked) if "HX-Request" in request.headers: candidate_ids = request.GET.getlist("candidate_ids") + if candidate_ids: request.session[SESSION_KEY] = candidate_ids selected_ids = candidate_ids @@ -1340,90 +1138,6 @@ def _handle_confirm_schedule(request, slug, job): if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] return redirect("job_detail", slug=slug) -# def _handle_confirm_schedule(request, slug, job): -# """ -# Handles the final POST request (Confirm Schedule). -# Creates all database records (Schedule, Meetings, Interviews) and clears sessions. -# """ -# SESSION_DATA_KEY = "interview_schedule_data" -# SESSION_ID_KEY = f"schedule_candidate_ids_{slug}" - -# # 1. Get schedule data from session -# schedule_data = request.session.get(SESSION_DATA_KEY) - -# if not schedule_data: -# messages.error(request, "Session expired. Please try again.") -# return redirect("schedule_interviews", slug=slug) -# # 2. Create the Interview Schedule (Your existing logic) -# schedule = InterviewSchedule.objects.create( -# job=job, -# created_by=request.user, -# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), -# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), -# working_days=schedule_data["working_days"], -# start_time=time.fromisoformat(schedule_data["start_time"]), -# end_time=time.fromisoformat(schedule_data["end_time"]), -# interview_duration=schedule_data["interview_duration"], -# buffer_time=schedule_data["buffer_time"], -# break_start_time=schedule_data["break_start_time"], -# break_end_time=schedule_data["break_end_time"], -# ) - -# # 3. Setup candidates and get slots -# candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"]) -# schedule.candidates.set(candidates) -# available_slots = get_available_time_slots(schedule) - -# # 4. Create scheduled interviews -# scheduled_count = 0 -# for i, candidate in enumerate(candidates): -# if i < len(available_slots): -# slot = available_slots[i] -# interview_datetime = datetime.combine(slot['date'], slot['time']) - -# meeting_topic = f"Interview for {job.title} - {candidate.name}" -# result = create_zoom_meeting(meeting_topic, interview_datetime, schedule.interview_duration) - -# if result["status"] == "success": -# zoom_meeting = ZoomMeeting.objects.create( -# topic=meeting_topic, -# start_time=interview_datetime, -# duration=schedule.interview_duration, -# meeting_id=result["meeting_details"]["meeting_id"], -# join_url=result["meeting_details"]["join_url"], -# zoom_gateway_response=result["zoom_gateway_response"], -# ) -# ScheduledInterview.objects.create( -# candidate=candidate, -# job=job, -# zoom_meeting=zoom_meeting, -# schedule=schedule, -# interview_date=slot['date'], -# interview_time=slot['time'] -# ) -# scheduled_count += 1 -# else: -# messages.error(request, result["message"]) -# schedule.delete() -# # Clear candidate IDs session key only on error return -# if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] -# return redirect("candidate_interview_view", slug=slug) - -# # 5. Success and Cleanup -# messages.success( -# request, f"Successfully scheduled {scheduled_count} interviews." -# ) - -# # Clear both 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=slug) - - -# --- Main View Function --- def schedule_interviews_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) @@ -1436,524 +1150,15 @@ def confirm_schedule_interviews_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) if request.method == "POST": return _handle_confirm_schedule(request, slug, job) -# def schedule_interviews_view(request, slug): -# job = get_object_or_404(JobPosting, slug=slug) -# SESSION_KEY = f"schedule_candidate_ids_{slug}" -# if request.method == "POST": -# form = InterviewScheduleForm(slug, request.POST) -# break_formset = BreakTimeFormSet(request.POST) - -# # Check if this is a confirmation request -# if "confirm_schedule" in request.POST: -# # Get the schedule data from session -# schedule_data = request.session.get("interview_schedule_data") -# if not schedule_data: -# messages.error(request, "Session expired. Please try again.") -# return redirect("schedule_interviews", slug=slug) - -# # Create the interview schedule -# schedule = InterviewSchedule.objects.create( -# job=job, -# created_by=request.user, -# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), -# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), -# working_days=schedule_data["working_days"], -# start_time=time.fromisoformat(schedule_data["start_time"]), -# end_time=time.fromisoformat(schedule_data["end_time"]), -# interview_duration=schedule_data["interview_duration"], -# buffer_time=schedule_data["buffer_time"], -# breaks=schedule_data["breaks"], -# ) - -# # Add candidates to the schedule -# candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"]) -# schedule.candidates.set(candidates) - -# # Create temporary break time objects for slot calculation -# temp_breaks = [] -# for break_data in schedule_data["breaks"]: -# temp_breaks.append( -# BreakTime( -# start_time=datetime.strptime( -# break_data["start_time"], "%H:%M:%S" -# ).time(), -# end_time=datetime.strptime( -# break_data["end_time"], "%H:%M:%S" -# ).time(), -# ) -# ) - -# # Get available slots -# available_slots = get_available_time_slots(schedule) - -# # Create scheduled interviews -# scheduled_count = 0 -# for i, candidate in enumerate(candidates): -# if i < len(available_slots): -# slot = available_slots[i] -# interview_datetime = datetime.combine(slot['date'], slot['time']) - -# # Create Zoom meeting -# meeting_topic = f"Interview for {job.title} - {candidate.name}" - -# start_time = interview_datetime - -# # zoom_meeting = create_zoom_meeting( -# # topic=meeting_topic, -# # start_time=start_time, -# # duration=schedule.interview_duration -# # ) - -# result = create_zoom_meeting(meeting_topic, start_time, schedule.interview_duration) -# if result["status"] == "success": -# zoom_meeting = ZoomMeeting.objects.create( -# topic=meeting_topic, -# start_time=interview_datetime, -# duration=schedule.interview_duration, -# meeting_id=result["meeting_details"]["meeting_id"], -# join_url=result["meeting_details"]["join_url"], -# zoom_gateway_response=result["zoom_gateway_response"], -# ) -# # Create scheduled interview record -# ScheduledInterview.objects.create( -# candidate=candidate, -# job=job, -# zoom_meeting=zoom_meeting, -# schedule=schedule, -# interview_date=slot['date'], -# interview_time=slot['time'] -# ) - -# else: -# messages.error(request, result["message"]) -# schedule.delete() -# return redirect("candidate_interview_view", slug=slug) - -# # Send email to candidate -# # try: -# # send_interview_email(scheduled_interview) -# # except Exception as e: -# # messages.warning( -# # request, -# # f"Interview scheduled for {candidate.name}, but failed to send email: {str(e)}" -# # ) - -# scheduled_count += 1 - -# messages.success( -# request, f"Successfully scheduled {scheduled_count} interviews." -# ) - -# # Clear the session data -# if "interview_schedule_data" in request.session: -# del request.session["interview_schedule_data"] - -# return redirect("job_detail", slug=slug) - -# # This is the initial form submission -# if form.is_valid() and break_formset.is_valid(): -# # Get the form data -# candidates = form.cleaned_data["candidates"] -# start_date = form.cleaned_data["start_date"] -# end_date = form.cleaned_data["end_date"] -# working_days = form.cleaned_data["working_days"] -# start_time = form.cleaned_data["start_time"] -# end_time = form.cleaned_data["end_time"] -# interview_duration = form.cleaned_data["interview_duration"] -# buffer_time = form.cleaned_data["buffer_time"] - -# # Process break times -# breaks = [] -# for break_form in break_formset: -# if break_form.cleaned_data and not break_form.cleaned_data.get( -# "DELETE" -# ): -# breaks.append( -# { -# "start_time": break_form.cleaned_data[ -# "start_time" -# ].strftime("%H:%M:%S"), -# "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"), -# } -# ) - -# # Create a temporary schedule object (not saved to DB) -# temp_schedule = InterviewSchedule( -# job=job, -# start_date=start_date, -# end_date=end_date, -# working_days=working_days, -# start_time=start_time, -# end_time=end_time, -# interview_duration=interview_duration, -# buffer_time=buffer_time, -# breaks=breaks, -# ) - -# # Create temporary break time objects -# temp_breaks = [] -# for break_data in breaks: -# temp_breaks.append( -# BreakTime( -# start_time=datetime.strptime( -# break_data["start_time"], "%H:%M:%S" -# ).time(), -# end_time=datetime.strptime( -# break_data["end_time"], "%H:%M:%S" -# ).time(), -# ) -# ) - -# # Get available slots -# available_slots = get_available_time_slots(temp_schedule) - -# if len(available_slots) < len(candidates): -# messages.error( -# request, -# f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}", -# ) -# return render( -# request, -# "interviews/schedule_interviews.html", -# {"form": form, "break_formset": break_formset, "job": job}, -# ) - -# # Create a preview schedule -# preview_schedule = [] -# for i, candidate in enumerate(candidates): -# slot = available_slots[i] -# preview_schedule.append( -# {"candidate": candidate, "date": slot["date"], "time": slot["time"]} -# ) - -# # Save the form data to session for later use -# schedule_data = { -# "start_date": start_date.isoformat(), -# "end_date": end_date.isoformat(), -# "working_days": working_days, -# "start_time": start_time.isoformat(), -# "end_time": end_time.isoformat(), -# "interview_duration": interview_duration, -# "buffer_time": buffer_time, -# "candidate_ids": [c.id for c in candidates], -# "breaks": breaks, -# } -# request.session["interview_schedule_data"] = schedule_data - -# # Render the preview page -# return render( -# request, -# "interviews/preview_schedule.html", -# { -# "job": job, -# "schedule": preview_schedule, -# "start_date": start_date, -# "end_date": end_date, -# "working_days": working_days, -# "start_time": start_time, -# "end_time": end_time, -# "breaks": breaks, -# "interview_duration": interview_duration, -# "buffer_time": buffer_time, -# }, -# ) -# else: -# form = InterviewScheduleForm(slug=slug) -# break_formset = BreakTimeFormSet() - -# selected_ids = [] - -# # 1. Capture IDs from HTMX request and store in session (when first clicked from timeline) -# if "HX-Request" in request.headers: -# candidate_ids = request.GET.getlist("candidate_ids") -# if candidate_ids: -# request.session[SESSION_KEY] = candidate_ids -# selected_ids = candidate_ids - -# # 2. Restore IDs from session (on refresh or navigation) -# if not selected_ids: -# selected_ids = request.session.get(SESSION_KEY, []) - -# # 3. Use the list of IDs to initialize the form -# if selected_ids: -# # Load Candidate objects corresponding to the IDs -# candidates_to_load = Candidate.objects.filter(pk__in=selected_ids) -# # This line sets the selected values for {{ form.candidates }} -# form.initial["candidates"] = candidates_to_load - -# return render( -# request, -# "interviews/schedule_interviews.html", -# {"form": form, "break_formset": break_formset, "job": job}, -# ) - -# def schedule_interviews_view(request, slug): -# job = get_object_or_404(JobPosting, slug=slug) - -# if request.method == "POST": -# form = InterviewScheduleForm(slug, request.POST) -# break_formset = BreakTimeFormSet(request.POST) - -# # Check if this is a confirmation request -# if "confirm_schedule" in request.POST: -# # Get the schedule data from session -# schedule_data = request.session.get("interview_schedule_data") -# if not schedule_data: -# messages.error(request, "Session expired. Please try again.") -# return redirect("schedule_interviews", slug=slug) - -# # Create the interview schedule -# schedule = InterviewSchedule.objects.create( -# job=job, -# created_by=request.user, -# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), -# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), -# working_days=schedule_data["working_days"], -# start_time=time.fromisoformat(schedule_data["start_time"]), -# end_time=time.fromisoformat(schedule_data["end_time"]), -# interview_duration=schedule_data["interview_duration"], -# buffer_time=schedule_data["buffer_time"], -# breaks=schedule_data["breaks"], # Direct assignment for JSON field -# ) - -# # Add candidates to the schedule -# candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"]) -# schedule.candidates.set(candidates) - -# # Schedule the interviews -# try: -# scheduled_count = schedule_interviews(schedule) -# messages.success( -# request, f"Successfully scheduled {scheduled_count} interviews." -# ) -# # Clear the session data -# if "interview_schedule_data" in request.session: -# del request.session["interview_schedule_data"] -# return redirect("job_detail", slug=slug) -# except Exception as e: -# messages.error(request, f"Error scheduling interviews: {str(e)}") -# return redirect("schedule_interviews", slug=slug) - -# # This is the initial form submission -# if form.is_valid() and break_formset.is_valid(): -# # Get the form data -# candidates = form.cleaned_data["candidates"] -# start_date = form.cleaned_data["start_date"] -# end_date = form.cleaned_data["end_date"] -# working_days = form.cleaned_data["working_days"] -# start_time = form.cleaned_data["start_time"] -# end_time = form.cleaned_data["end_time"] -# interview_duration = form.cleaned_data["interview_duration"] -# buffer_time = form.cleaned_data["buffer_time"] - -# # Process break times -# breaks = [] -# for break_form in break_formset: -# if break_form.cleaned_data and not break_form.cleaned_data.get( -# "DELETE" -# ): -# breaks.append( -# { -# "start_time": break_form.cleaned_data[ -# "start_time" -# ].strftime("%H:%M:%S"), -# "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"), -# } -# ) - -# # Create a temporary schedule object (not saved to DB) -# temp_schedule = InterviewSchedule( -# job=job, -# start_date=start_date, -# end_date=end_date, -# working_days=working_days, -# start_time=start_time, -# end_time=end_time, -# interview_duration=interview_duration, -# buffer_time=buffer_time, -# breaks=breaks, # Direct assignment for JSON field -# ) - -# # Get available slots -# available_slots = get_available_time_slots(temp_schedule) - -# if len(available_slots) < len(candidates): -# messages.error( -# request, -# f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}", -# ) -# return render( -# request, -# "interviews/schedule_interviews.html", -# {"form": form, "break_formset": break_formset, "job": job}, -# ) - -# # Create a preview schedule -# preview_schedule = [] -# for i, candidate in enumerate(candidates): -# slot = available_slots[i] -# preview_schedule.append( -# {"candidate": candidate, "date": slot["date"], "time": slot["time"]} -# ) - -# # Save the form data to session for later use -# schedule_data = { -# "start_date": start_date.isoformat(), -# "end_date": end_date.isoformat(), -# "working_days": working_days, -# "start_time": start_time.isoformat(), -# "end_time": end_time.isoformat(), -# "interview_duration": interview_duration, -# "buffer_time": buffer_time, -# "candidate_ids": [c.id for c in candidates], -# "breaks": breaks, -# } -# request.session["interview_schedule_data"] = schedule_data - -# # Render the preview page -# return render( -# request, -# "interviews/preview_schedule.html", -# { -# "job": job, -# "schedule": preview_schedule, -# "start_date": start_date, -# "end_date": end_date, -# "working_days": working_days, -# "start_time": start_time, -# "end_time": end_time, -# "breaks": breaks, -# "interview_duration": interview_duration, -# "buffer_time": buffer_time, -# }, -# ) -# else: -# form = InterviewScheduleForm(slug=slug) -# break_formset = BreakTimeFormSet() - -# return render( -# request, -# "interviews/schedule_interviews.html", -# {"form": form, "break_formset": break_formset, "job": job}, -# ) +@login_required def candidate_screening_view(request, slug): """ Manage candidate tiers and stage transitions """ job = get_object_or_404(JobPosting, slug=slug) - applied_count=job.candidates.filter(stage='Applied').count() - exam_count=job.candidates.filter(stage='Exam').count() - interview_count=job.candidates.filter(stage='Interview').count() - offer_count=job.candidates.filter(stage='Offer').count() - # Get all candidates for this job, ordered by match score (descending) - candidates = job.candidates.filter(stage="Applied").order_by("-match_score") - - - - # Get tier categorization parameters - # tier1_count = int(request.GET.get("tier1_count", 100)) - - # # Categorize candidates into tiers - # tier1_candidates = candidates[:tier1_count] if tier1_count > 0 else [] - # remaining_candidates = candidates[tier1_count:] if tier1_count > 0 else [] - - # if len(remaining_candidates) > 0: - # # Tier 2: Next 50% of remaining candidates - # tier2_count = max(1, len(remaining_candidates) // 2) - # tier2_candidates = remaining_candidates[:tier2_count] - # tier3_candidates = remaining_candidates[tier2_count:] - # else: - # tier2_candidates = [] - # tier3_candidates = [] - - # # Handle form submissions - # if request.method == "POST": - # # Update tier categorization - # if "update_tiers" in request.POST: - # tier1_count = int(request.POST.get("tier1_count", 100)) - # messages.success(request, f"Tier categorization updated. Tier 1: {tier1_count} candidates") - # return redirect("candidate_screening_view", slug=slug) - - # # Update individual candidate stages - # elif "update_stage" in request.POST: - # candidate_id = request.POST.get("candidate_id") - # new_stage = request.POST.get("new_stage") - # candidate = get_object_or_404(Candidate, id=candidate_id, job=job) - - # if candidate.can_transition_to(new_stage): - # old_stage = candidate.stage - # candidate.stage = new_stage - # candidate.save() - # messages.success(request, f"Updated {candidate.name} from {old_stage} to {new_stage}") - # else: - # messages.error(request, f"Cannot transition {candidate.name} from {candidate.stage} to {new_stage}") - - # # Update exam status - # elif "update_exam_status" in request.POST: - # candidate_id = request.POST.get("candidate_id") - # exam_status = request.POST.get("exam_status") - # exam_date = request.POST.get("exam_date") - # candidate = get_object_or_404(Candidate, id=candidate_id, job=job) - - # if candidate.stage == "Exam": - # candidate.exam_status = exam_status - # if exam_date: - # candidate.exam_date = exam_date - # candidate.save() - # messages.success(request, f"Updated exam status for {candidate.name}") - # else: - # messages.error(request, f"Can only update exam status for candidates in Exam stage") - - # # Bulk stage update - # elif "bulk_update_stage" in request.POST: - # selected_candidates = request.POST.getlist("selected_candidates") - # new_stage = request.POST.get("bulk_new_stage") - # updated_count = 0 - - # for candidate_id in selected_candidates: - # candidate = get_object_or_404(Candidate, id=candidate_id, job=job) - # if candidate.can_transition_to(new_stage): - # candidate.stage = new_stage - # candidate.save() - # updated_count += 1 - - # messages.success(request, f"Updated {updated_count} candidates to {new_stage} stage") - - # # Mark individual candidate as Candidate - # elif "mark_as_candidate" in request.POST: - # candidate_id = request.POST.get("candidate_id") - # candidate = get_object_or_404(Candidate, id=candidate_id, job=job) - - # if candidate.applicant_status == "Applicant": - # candidate.applicant_status = "Candidate" - # candidate.save() - # messages.success(request, f"Marked {candidate.name} as Candidate") - # else: - # messages.info(request, f"{candidate.name} is already marked as Candidate") - - # # Mark all Tier 1 candidates as Candidates - # elif "mark_as_candidates" in request.POST: - # updated_count = 0 - # for candidate in tier1_candidates: - # if candidate.applicant_status == "Applicant": - # candidate.applicant_status = "Candidate" - # candidate.save() - # updated_count += 1 - - # if updated_count > 0: - # messages.success(request, f"Marked {updated_count} Tier 1 candidates as Candidates") - # else: - # messages.info(request, "All Tier 1 candidates are already marked as Candidates") - - # Group candidates by current stage for display - # stage_groups = { - # "Applied": candidates.filter(stage="Applied"), - # "Exam": candidates.filter(stage="Exam"), - # "Interview": candidates.filter(stage="Interview"), - # "Offer": candidates.filter(stage="Offer"), - # } + candidates = job.screening_candidates min_ai_score_str = request.GET.get('min_ai_score') tier1_count_str = request.GET.get('tier1_count') @@ -1985,42 +1190,28 @@ def candidate_screening_view(request, slug): context = { "job": job, "candidates": candidates, - # "stage_groups": stage_groups, - # "tier1_count": tier1_count, - # "total_candidates": candidates.count(), 'min_ai_score':min_ai_score, 'tier1_count':tier1_count, - 'applied_count':applied_count, - 'exam_count':exam_count, - 'interview_count':interview_count, - 'offer_count':offer_count, "current_stage" : "Applied" } return render(request, "recruitment/candidate_screening_view.html", context) +@login_required def candidate_exam_view(request, slug): """ Manage candidate tiers and stage transitions """ job = get_object_or_404(JobPosting, slug=slug) - applied_count=job.candidates.filter(stage='Applied').count() - exam_count=job.candidates.filter(stage='Exam').count() - interview_count=job.candidates.filter(stage='Interview').count() - offer_count=job.candidates.filter(stage='Offer').count() - candidates = job.candidates.filter(stage="Exam").order_by("-match_score") context = { "job": job, - "candidates": candidates, - 'applied_count':applied_count, - 'exam_count':exam_count, - 'interview_count':interview_count, - 'offer_count':offer_count, + "candidates": job.exam_candidates, 'current_stage' : "Exam" } return render(request, "recruitment/candidate_exam_view.html", context) +@login_required def update_candidate_exam_status(request, slug): candidate = get_object_or_404(Candidate, slug=slug) if request.method == "POST": @@ -2031,6 +1222,7 @@ def update_candidate_exam_status(request, slug): else: form = CandidateExamDateForm(request.POST, instance=candidate) return render(request, "includes/candidate_exam_status_form.html", {"candidate": candidate,"form": form}) +@login_required def bulk_update_candidate_exam_status(request,slug): job = get_object_or_404(JobPosting, slug=slug) status = request.headers.get('status') @@ -2053,6 +1245,7 @@ def candidate_criteria_view_htmx(request, pk): return render(request, "includes/candidate_modal_body.html", {"candidate": candidate}) +@login_required def candidate_set_exam_date(request, slug): candidate = get_object_or_404(Candidate, slug=slug) candidate.exam_date = timezone.now() @@ -2060,27 +1253,27 @@ def candidate_set_exam_date(request, slug): messages.success(request, f"Set exam date for {candidate.name} to {candidate.exam_date}") return redirect("candidate_screening_view", slug=candidate.job.slug) +@login_required def candidate_update_status(request, slug): job = get_object_or_404(JobPosting, slug=slug) mark_as = request.POST.get('mark_as') - candidate_ids = request.POST.getlist("candidate_ids") - if c := Candidate.objects.filter(pk__in = candidate_ids): - c.update(stage=mark_as,exam_date=timezone.now(),applicant_status="Candidate" if mark_as in ["Exam","Interview","Offer"] else "Applicant") + if mark_as != '----------': + candidate_ids = request.POST.getlist("candidate_ids") + if c := Candidate.objects.filter(pk__in = candidate_ids): + c.update(stage=mark_as,exam_date=timezone.now(),applicant_status="Candidate" if mark_as in ["Exam","Interview","Offer"] else "Applicant") - messages.success(request, f"Candidates Updated") + messages.success(request, f"Candidates Updated") response = HttpResponse(redirect("candidate_screening_view", slug=job.slug)) response.headers["HX-Refresh"] = "true" return response +@login_required def candidate_interview_view(request,slug): job = get_object_or_404(JobPosting,slug=slug) - applied_count=job.candidates.filter(stage='Applied').count() - exam_count=job.candidates.filter(stage='Exam').count() - interview_count=job.candidates.filter(stage='Interview').count() - offer_count=job.candidates.filter(stage='Offer').count() - context = {"job":job,"candidates":job.candidates.filter(stage="Interview").order_by("-match_score"),'applied_count':applied_count,'exam_count':exam_count,'interview_count':interview_count,'offer_count':offer_count,"current_stage":"Interview"} + context = {"job":job,"candidates":job.interview_candidates,'current_stage':'Interview'} return render(request,"recruitment/candidate_interview_view.html",context) +@login_required def reschedule_meeting_for_candidate(request,slug,candidate_id,meeting_id): job = get_object_or_404(JobPosting,slug=slug) candidate = get_object_or_404(Candidate,pk=candidate_id) @@ -2112,22 +1305,24 @@ def reschedule_meeting_for_candidate(request,slug,candidate_id,meeting_id): return render(request,"meetings/reschedule_meeting.html",context) +@login_required def delete_meeting_for_candidate(request,slug,candidate_pk,meeting_id): job = get_object_or_404(JobPosting,slug=slug) candidate = get_object_or_404(Candidate,pk=candidate_pk) meeting = get_object_or_404(ZoomMeeting,pk=meeting_id) if request.method == "POST": result = delete_zoom_meeting(meeting.meeting_id) - if result["status"] == "success": + if result["status"] == "success" or "Meeting does not exist" in result["details"]["message"]: meeting.delete() - messages.success(request, result["message"]) + messages.success(request, "Meeting deleted successfully") else: messages.error(request, result["message"]) return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug})) - context = {"job":job,"candidate":candidate,"meeting":meeting} + context = {"job":job,"candidate":candidate,"meeting":meeting,'delete_url':reverse("delete_meeting_for_candidate",kwargs={"slug":job.slug,"candidate_pk":candidate_pk,"meeting_id":meeting_id})} return render(request,"meetings/delete_meeting_form.html",context) +@login_required def interview_calendar_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) @@ -2181,6 +1376,7 @@ def interview_calendar_view(request, slug): return render(request, 'recruitment/interview_calendar.html', context) +@login_required def interview_detail_view(request, slug, interview_id): job = get_object_or_404(JobPosting, slug=slug) interview = get_object_or_404( @@ -2711,6 +1907,7 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): +@login_required def user_detail(requests,pk): user=get_object_or_404(User,pk=pk) return render(requests,'user/profile.html') @@ -2727,8 +1924,155 @@ def zoom_webhook_view(request): payload = json.loads(request.body) async_task("recruitment.tasks.handle_zoom_webhook_event", payload) return HttpResponse(status=200) - except Exception: - # Bad data or internal server error return HttpResponse(status=400) - return HttpResponse(status=405) # Method Not Allowed \ No newline at end of file + return HttpResponse(status=405) + + +# Meeting Comments Views +@login_required +def add_meeting_comment(request, slug): + """Add a comment to a meeting""" + meeting = get_object_or_404(ZoomMeeting, slug=slug) + + if request.method == 'POST': + form = MeetingCommentForm(request.POST) + if form.is_valid(): + comment = form.save(commit=False) + comment.meeting = meeting + comment.author = request.user + comment.save() + messages.success(request, 'Comment added successfully!') + + # HTMX response - return just the comment section + if 'HX-Request' in request.headers: + return render(request, 'includes/comment_list.html', { + 'comments': meeting.comments.all().order_by('-created_at'), + 'meeting': meeting + }) + + return redirect('meeting_details', slug=slug) + else: + form = MeetingCommentForm() + + context = { + 'form': form, + 'meeting': meeting, + } + + # HTMX response - return the comment form + if 'HX-Request' in request.headers: + return render(request, 'includes/comment_form.html', context) + + return redirect('meeting_details', slug=slug) + + +@login_required +def edit_meeting_comment(request, slug, comment_id): + """Edit a meeting comment""" + meeting = get_object_or_404(ZoomMeeting, slug=slug) + comment = get_object_or_404(MeetingComment, id=comment_id, meeting=meeting) + + # Check if user is the author + if comment.author != request.user: + messages.error(request, 'You can only edit your own comments.') + return redirect('meeting_details', slug=slug) + + if request.method == 'POST': + form = MeetingCommentForm(request.POST, instance=comment) + if form.is_valid(): + form.save() + messages.success(request, 'Comment updated successfully!') + + # HTMX response - return just the comment section + if 'HX-Request' in request.headers: + return render(request, 'includes/comment_list.html', { + 'comments': meeting.comments.all().order_by('-created_at'), + 'meeting': meeting + }) + + return redirect('meeting_details', slug=slug) + else: + form = MeetingCommentForm(instance=comment) + + context = { + 'form': form, + 'meeting': meeting, + 'comment': comment, + } + + # HTMX response - return the comment form + if 'HX-Request' in request.headers: + return render(request, 'includes/edit_comment_form.html', context) + + return redirect('meeting_details', slug=slug) + + +@login_required +def delete_meeting_comment(request, slug, comment_id): + """Delete a meeting comment""" + meeting = get_object_or_404(ZoomMeeting, slug=slug) + comment = get_object_or_404(MeetingComment, id=comment_id, meeting=meeting) + + # Check if user is the author + if comment.author != request.user and not request.user.is_staff: + messages.error(request, 'You can only delete your own comments.') + return redirect('meeting_details', slug=slug) + + if request.method == 'POST': + comment.delete() + messages.success(request, 'Comment deleted successfully!') + + # HTMX response - return just the comment section + if 'HX-Request' in request.headers: + return render(request, 'includes/comment_list.html', { + 'comments': meeting.comments.all().order_by('-created_at'), + 'meeting': meeting + }) + + return redirect('meeting_details', slug=slug) + + # HTMX response - return the delete confirmation modal + if 'HX-Request' in request.headers: + return render(request, 'includes/delete_comment_form.html', { + 'meeting': meeting, + 'comment': comment, + 'delete_url': reverse('delete_meeting_comment', kwargs={'slug': slug, 'comment_id': comment_id}) + }) + + return redirect('meeting_details', slug=slug) + + + +@login_required +def set_meeting_candidate(request,slug): + meeting = get_object_or_404(ZoomMeeting, slug=slug) + if request.method == 'POST' and 'HX-Request' not in request.headers: + form = InterviewForm(request.POST) + if form.is_valid(): + candidate = form.save(commit=False) + candidate.zoom_meeting = meeting + candidate.interview_date = meeting.start_time.date() + candidate.interview_time = meeting.start_time.time() + candidate.save() + messages.success(request, 'Candidate added successfully!') + return redirect('list_meetings') + job = request.GET.get("job") + form = InterviewForm() + + if job: + form.fields['candidate'].queryset = Candidate.objects.filter(job=job) + + else: + form.fields['candidate'].queryset = Candidate.objects.none() + form.fields['job'].widget.attrs.update({ + 'hx-get': reverse('set_meeting_candidate', kwargs={'slug': slug}), + 'hx-target': '#div_id_candidate', + 'hx-select': '#div_id_candidate', + 'hx-swap': 'outerHTML' + }) + context = { + "form": form, + "meeting": meeting + } + return render(request, 'meetings/set_candidate_form.html', context) diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 51bf44f..5f71202 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -16,7 +16,8 @@ from django.contrib.messages.views import SuccessMessageMixin from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView # JobForm removed - using JobPostingForm instead from django.urls import reverse_lazy -from django.db.models import Q +from django.db.models import Q, Count, Avg +from django.db.models import FloatField from datastar_py.django import ( DatastarResponse, @@ -224,6 +225,7 @@ def training_list(request): return render(request, 'recruitment/training_list.html', {'materials': materials}) +@login_required def candidate_detail(request, slug): from rich.json import JSON candidate = get_object_or_404(models.Candidate, slug=slug) @@ -235,7 +237,7 @@ def candidate_detail(request, slug): # Create stage update form for staff users stage_form = None if request.user.is_staff: - stage_form = forms.CandidateStageForm(candidate=candidate) + stage_form = forms.CandidateStageForm() # parsed = JSON(json.dumps(parsed), indent=2, highlight=True, skip_keys=False, ensure_ascii=False, check_circular=True, allow_nan=True, default=None, sort_keys=False) # parsed = json_to_markdown_table([parsed]) @@ -245,10 +247,11 @@ def candidate_detail(request, slug): 'stage_form': stage_form, }) +@login_required def candidate_update_stage(request, slug): """Handle HTMX stage update requests""" candidate = get_object_or_404(models.Candidate, slug=slug) - form = forms.CandidateStageForm(request.POST, candidate=candidate) + form = forms.CandidateStageForm(request.POST, instance=candidate) if form.is_valid(): stage_value = form.cleaned_data['stage'] candidate.stage = stage_value @@ -318,6 +321,7 @@ class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): success_message = 'Training material deleted successfully.' +@login_required def dashboard_view(request): total_jobs = models.JobPosting.objects.count() total_candidates = models.Candidate.objects.count() @@ -335,3 +339,79 @@ def dashboard_view(request): 'average_applications': average_applications, } return render(request, 'recruitment/dashboard.html', context) + + +@login_required +def candidate_offer_view(request, slug): + """View for candidates in the Offer stage""" + job = get_object_or_404(models.JobPosting, slug=slug) + + # Filter candidates for this specific job and stage + candidates = job.offer_candidates + + # Handle search + search_query = request.GET.get('search', '') + if search_query: + candidates = candidates.filter( + Q(first_name__icontains=search_query) | + Q(last_name__icontains=search_query) | + Q(email__icontains=search_query) | + Q(phone__icontains=search_query) + ) + + candidates = candidates.order_by('-created_at') + + context = { + 'job': job, + 'candidates': candidates, + 'search_query': search_query, + 'current_stage': 'Offer', + } + return render(request, 'recruitment/candidate_offer_view.html', context) + + +@login_required +def update_candidate_status(request, job_slug, candidate_slug, stage_type, status): + """Handle exam/interview/offer status updates""" + from django.utils import timezone + + job = get_object_or_404(models.JobPosting, slug=job_slug) + candidate = get_object_or_404(models.Candidate, slug=candidate_slug, job=job) + print(stage_type,status) + + if request.method == "POST": + if stage_type == 'exam': + candidate.exam_status = status + candidate.exam_date = timezone.now() + candidate.save(update_fields=['exam_status', 'exam_date']) + elif stage_type == 'interview': + candidate.interview_status = status + candidate.interview_date = timezone.now() + candidate.save(update_fields=['interview_status', 'interview_date']) + elif stage_type == 'offer': + candidate.offer_status = status + candidate.offer_date = timezone.now() + candidate.save(update_fields=['offer_status', 'offer_date']) + messages.success(request, f"Candidate {status} successfully!") + else: + messages.error(request, "No changes made.") + + if stage_type == 'exam': + return redirect('candidate_exam_view', job.slug) + elif stage_type == 'interview': + return redirect('candidate_interview_view', job.slug) + elif stage_type == 'offer': + return redirect('candidate_offer_view', job.slug) + + return redirect('candidate_detail', candidate.slug) + else: + if stage_type == 'exam': + return render(request,"includes/candidate_update_exam_form.html",{'candidate':candidate,'job':job}) + elif stage_type == 'interview': + return render(request,"includes/candidate_update_interview_form.html",{'candidate':candidate,'job':job}) + elif stage_type == 'offer': + return render(request,"includes/candidate_update_offer_form.html",{'candidate':candidate,'job':job}) + + +# Removed incorrect JobDetailView class. +# The job_detail view is handled by function-based view in recruitment.views diff --git a/run.py b/run.py index 7d1388c..cd38ff7 100644 --- a/run.py +++ b/run.py @@ -37,4 +37,8 @@ if __name__ == "__main__": duration = 60 host_email = "your_zoom_email" response = create_zoom_meeting(topic, start_time, duration, host_email) - print(response.json()) \ No newline at end of file + print(response.json()) + + + + diff --git a/template_partials/__init__.py b/template_partials/__init__.py new file mode 100644 index 0000000..579c83f --- /dev/null +++ b/template_partials/__init__.py @@ -0,0 +1 @@ +# Template partials app diff --git a/template_partials/apps.py b/template_partials/apps.py new file mode 100644 index 0000000..e5dddab --- /dev/null +++ b/template_partials/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class TemplatePartialsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'template_partials' + verbose_name = 'Template Partials' diff --git a/templates/base.html b/templates/base.html index ffe4948..819171c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,5 +1,5 @@ {% load i18n static %} -{% load partials %} + diff --git a/templates/forms/form_template_all_submissions.html b/templates/forms/form_template_all_submissions.html index 9b0e498..b62f404 100644 --- a/templates/forms/form_template_all_submissions.html +++ b/templates/forms/form_template_all_submissions.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} {% load static i18n form_filters %} -{% load partials %} + {% block title %}All Submissions for {{ template.name }} - ATS{% endblock %} diff --git a/templates/forms/form_template_submissions_list.html b/templates/forms/form_template_submissions_list.html index f552085..5ef90ba 100644 --- a/templates/forms/form_template_submissions_list.html +++ b/templates/forms/form_template_submissions_list.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} {% load static i18n crispy_forms_tags %} -{% load partials %} + {% block title %}Submissions for {{ template.name }} - ATS{% endblock %} diff --git a/templates/forms/form_templates_list.html b/templates/forms/form_templates_list.html index 6c14e28..e20a366 100644 --- a/templates/forms/form_templates_list.html +++ b/templates/forms/form_templates_list.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} {% load static i18n crispy_forms_tags %} -{% load partials %} + {% block title %}Form Templates - {{ block.super }}{% endblock %} @@ -76,13 +76,13 @@ transform: none; box-shadow: 0 4px 12px rgba(0,0,0,0.06); } - + /* Card Header (For Search/Filter Card) */ .card-header { font-weight: 600; padding: 1.25rem; border-bottom: 1px solid var(--kaauh-border); - background-color: var(--kaauh-gray-light); + background-color: var(--kaauh-gray-light); } /* Stats Theming */ @@ -205,7 +205,7 @@ {{ template.job|default:"N/A" }} - + {# Stats #}
@@ -217,7 +217,7 @@
{% trans "Fields" %}
- + {# Description #}

{% if template.description %} diff --git a/templates/icons/delete.html b/templates/icons/delete.html index 56aef3d..a35dfcb 100644 --- a/templates/icons/delete.html +++ b/templates/icons/delete.html @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/templates/includes/candidate_modal_body.html b/templates/includes/candidate_modal_body.html index 139555b..45a5f03 100644 --- a/templates/includes/candidate_modal_body.html +++ b/templates/includes/candidate_modal_body.html @@ -1,25 +1,31 @@ {% load i18n %} -

+
{% trans "AI Score" %}: {{ candidate.match_score }} {{ candidate.professional_category }}
+
- - + +
- - + +
- + + +
+
+
    {% for key, value in candidate.criteria_checklist.items %}
  • - {{ key }} + {{ key }} {% if value == 'Met' %} - Yes + Yes {% else %} - Not Mentioned + Not Mentioned {% endif %}
  • {% endfor %}
-
\ No newline at end of file +

+ diff --git a/templates/includes/candidate_update_exam_form.html b/templates/includes/candidate_update_exam_form.html new file mode 100644 index 0000000..ea40514 --- /dev/null +++ b/templates/includes/candidate_update_exam_form.html @@ -0,0 +1,10 @@ +{% load i18n %} + \ No newline at end of file diff --git a/templates/includes/candidate_update_interview_form.html b/templates/includes/candidate_update_interview_form.html new file mode 100644 index 0000000..1214319 --- /dev/null +++ b/templates/includes/candidate_update_interview_form.html @@ -0,0 +1,10 @@ +{% load i18n %} + \ No newline at end of file diff --git a/templates/includes/candidate_update_offer_form.html b/templates/includes/candidate_update_offer_form.html new file mode 100644 index 0000000..821395b --- /dev/null +++ b/templates/includes/candidate_update_offer_form.html @@ -0,0 +1,10 @@ +{% load i18n %} + \ No newline at end of file diff --git a/templates/includes/comment_form.html b/templates/includes/comment_form.html new file mode 100644 index 0000000..4ef849a --- /dev/null +++ b/templates/includes/comment_form.html @@ -0,0 +1,24 @@ +
+
+
Add Comment
+
+
+ + {% csrf_token %} +
+ {{ form.content }} + {% if form.content.errors %} +
+ {% for error in form.content.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ + {% if 'HX-Request' in request.headers %} + + {% endif %} + +
+
diff --git a/templates/includes/comment_list.html b/templates/includes/comment_list.html new file mode 100644 index 0000000..40e37ef --- /dev/null +++ b/templates/includes/comment_list.html @@ -0,0 +1,56 @@ +
+
+
+
Comments ({{ comments.count }})
+ {% if 'HX-Request' in request.headers %} + + {% endif %} +
+
+ {% if comments %} +
+ {% for comment in comments %} +
+
+
+
+ {{ comment.author.get_full_name|default:comment.author.username }} + {% if comment.author != user %} + Comment + {% endif %} +
+ {{ comment.created_at|date:"M d, Y P" }} +
+
+

{{ comment.content|safe }}

+
+ +
+
+ {% endfor %} +
+ {% else %} +

No comments yet. Be the first to comment!

+ {% endif %} +
+
+
diff --git a/templates/includes/delete_comment_form.html b/templates/includes/delete_comment_form.html new file mode 100644 index 0000000..ba4953e --- /dev/null +++ b/templates/includes/delete_comment_form.html @@ -0,0 +1,16 @@ +
+
+
Delete Comment
+
+
+

Are you sure you want to delete this comment by {{ comment.author.get_full_name|default:comment.author.username }}?

+

{{ comment.created_at|date:"F d, Y P" }}

+
+ {% csrf_token %} + + {% if 'HX-Request' in request.headers %} + + {% endif %} +
+
+
diff --git a/templates/includes/delete_modal.html b/templates/includes/delete_modal.html index da5a213..06f8176 100644 --- a/templates/includes/delete_modal.html +++ b/templates/includes/delete_modal.html @@ -1,32 +1,18 @@ {% load i18n %} -
{% trans "Actions" %} {% trans "Manage Forms" %} + {% trans "Applicants Metrics" %}
{% trans "Applied" %} {% trans "Screened" %}{% trans "Exam" %} -
- P - F -
-
{% trans "Interview" %} -
- P - F -
-
{% trans "Exam" %}{% trans "Interview" %} {% trans "Offer" %}
{% if job.metrics.applied %}{{ job.metrics.applied }}{% else %}-{% endif %}{% if job.metrics.screening %}{{ job.metrics.screening }}{% else %}-{% endif %}{% if job.metrics.exam_p %}{{ job.metrics.exam_p }}{% else %}-{% endif %}{% if job.metrics.exam_f %}{{ job.metrics.exam_f }}{% else %}-{% endif %}{% if job.metrics.interview_p %}{{ job.metrics.interview_p }}{% else %}-{% endif %}{% if job.metrics.interview_f %}{{ job.metrics.interview_f }}{% else %}-{% endif %}{% if job.metrics.offer %}{{ job.metrics.offer }}{% else %}-{% endif %}{% if job.applying_count %}{{ job.applying_count }}{% else %}-{% endif %}{% if job.screening_count %}{{ job.screening_count }}{% else %}-{% endif %}{% if job.exam_count %}{{ job.exam_count }}{% else %}-{% endif %}{% if job.interview_count %}{{ job.interview_count }}{% else %}-{% endif %}{% if job.offer_count %}{{ job.offer_count }}{% else %}-{% endif %}
{{ meeting.topic }} - {% with interview=meeting.interview_details.first %} - {% if interview %}{{ interview.candidate.name }}{% else %}{% trans "N/A" %}{% endif %} - {% endwith %} + {% if meeting.interview %} + {{ meeting.interview.candidate.name }} + {% else %} + + {% endif %} - {% with interview=meeting.interview_details.first %} - {% if interview %}{{ interview.job.title }}{% else %}{% trans "N/A" %}{% endif %} - {% endwith %} + {% if meeting.interview %} + {{ meeting.interview.job.title }} + {% else %} + + {% endif %} {{ meeting.meeting_id|default:meeting.id }}{{ meeting.start_time|date:"M d, Y H:i" }} ({{ meeting.timezone }}){{ meeting.duration }} min{% if meeting.password %} ({% trans "Password" %}){% endif %}{{ meeting.start_time|date:"M d, Y H:i" }}{{ meeting.duration }} min - {% if meeting.status == "started" %} - - {{ meeting.status|title }} - {% include "icons/video.html" %} - - {% endif %} + {% if meeting %} + + {% if meeting.status == 'started' %} + + {% endif %} + {{ meeting.status|title }} + + {% else %} + -- + {% endif %}
@@ -329,8 +364,11 @@ diff --git a/templates/meetings/meeting_details.html b/templates/meetings/meeting_details.html index 7574cc4..050cdf5 100644 --- a/templates/meetings/meeting_details.html +++ b/templates/meetings/meeting_details.html @@ -83,7 +83,7 @@ body { } .card-header .btn-secondary-back { /* Subtle Back Button */ - align-self: flex-start; + align-self: flex-start; background-color: transparent; border: none; color: var(--kaauh-secondary-text); @@ -215,7 +215,7 @@ body { /* 🎯 Copy Message Pill Style */ #copy-message { position: absolute; - top: -5px; + top: -5px; right: 0; background-color: var(--kaauh-success); color: white; @@ -300,7 +300,7 @@ body { {% block content %}
- +
@@ -311,16 +311,25 @@ body { {{ meeting.topic }} - - {{ meeting.status|title }} - +
+ + {{ meeting.status|title }} + +
+ {% if meeting.interview %} +
+ + Candidate Name : {{ meeting.interview.candidate.name }} + +
+ {% endif %}
{% trans "Back to Meetings" %}
- +

{% trans "Meeting Information" %}

@@ -332,7 +341,6 @@ body {
{% trans "Host Email" %}:
{{ meeting.host_email|default:"N/A" }}
- {% if meeting.join_url %}

{% trans "Join Information" %}

@@ -340,14 +348,14 @@ body { {% trans "Join Meeting Now" %} - +
{% trans "Copied!" %}
- +
{% trans "Join URL" %}: {{ meeting.join_url }}
- + @@ -378,19 +386,21 @@ body { {% trans "Update Meeting" %} - + {% if meeting.zoom_gateway_response %} {% endif %} - - - {% csrf_token %} - - +
@@ -402,7 +412,87 @@ body {
{% endif %} + + +
+
+
+ + Comments ({{ meeting.comments.count }}) +
+ {% if user.is_authenticated %} + + {% endif %} +
+
+
+ {% if meeting.comments.all %} +
+ {% for comment in meeting.comments.all|dictsortreversed:"created_at" %} +
+
+
+
+ {{ comment.author.get_full_name|default:comment.author.username }} + {% if comment.author != user %} + Comment + {% endif %} +
+ {{ comment.created_at|date:"M d, Y P" }} +
+
+

{{ comment.content|safe }}

+
+ +
+
+ {% endfor %} +
+ {% else %} +

No comments yet. Be the first to comment!

+ {% endif %} +
+
+
+ + + + {% endblock %} {% block customJS %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/meetings/set_candidate_form.html b/templates/meetings/set_candidate_form.html new file mode 100644 index 0000000..8eadd01 --- /dev/null +++ b/templates/meetings/set_candidate_form.html @@ -0,0 +1,7 @@ +{% load i18n crispy_forms_tags %} +
+ {% csrf_token %} + {{ form|crispy }} + + +
\ No newline at end of file diff --git a/templates/recruitment/candidate_detail.html b/templates/recruitment/candidate_detail.html index e00e130..57d40e8 100644 --- a/templates/recruitment/candidate_detail.html +++ b/templates/recruitment/candidate_detail.html @@ -200,11 +200,9 @@

{{ candidate.name }}

- + {% trans "Stage:" %} - + {{ candidate.stage }}
@@ -240,6 +238,11 @@ {% trans "Resume Summary" %} + {% endif %} @@ -313,6 +316,153 @@ {% endif %} + {# TAB 4 CONTENT: AI ANALYSIS #} + {% if candidate.is_resume_parsed %} +
+
{% trans "AI Analysis Report" %}
+
+ {% with analysis=candidate.ai_analysis_data %} + + {# Match Score Card #} +
+
+
{% trans "Match Score" %}
+ + {{ analysis.match_score }}% + +
+
+
+
+
+ + {# Category & Job Fit #} +
+
{% trans "Category" %}
+

{{ analysis.category }}

+ +
{% trans "Job Fit Narrative" %}
+

{{ analysis.job_fit_narrative }}

+
+ + {# Strengths and Weaknesses #} +
+
{% trans "Strengths" %}
+

{{ analysis.strengths }}

+ +
{% trans "Weaknesses" %}
+

{{ analysis.weaknesses }}

+
+ + {# Recommendation #} +
+
{% trans "Recommendation" %}
+

{{ analysis.recommendation }}

+
+ + {# Top Keywords #} +
+
{% trans "Top Keywords" %}
+
+ {% for keyword in analysis.top_3_keywords %} + {{ keyword }} + {% endfor %} +
+
+ + {# Professional Details #} +
+
{% trans "Professional Details" %}
+

{% trans "Years of Experience:" %} {{ analysis.years_of_experience }}

+

{% trans "Most Recent Job Title:" %} {{ analysis.most_recent_job_title }}

+

{% trans "Experience Industry Match:" %} + + {{ analysis.experience_industry_match }}% + +

+

{% trans "Soft Skills Score:" %} {{ analysis.soft_skills_score }}%

+
+ + {# Screening Status #} +
+
{% trans "Screening Status" %}
+
+ {% trans "Minimum Requirements Met:" %} + {% if analysis.min_req_met_bool %} + {% trans "Yes" %} + {% else %} + {% trans "No" %} + {% endif %} +
+
+ {% trans "Screening Stage Rating:" %} + {{ analysis.screening_stage_rating }} +
+
+ + {# Criteria Checklist #} +
+
{% trans "Criteria Assessment" %}
+
+ + + + + + + + + {% for criterion, status in analysis.criteria_checklist.items %} + + + + + {% endfor %} + +
{% trans "Criteria" %}{% trans "Status" %}
{{ criterion }} + {% if status == "Met" %} + {% trans "Met" %} + {% elif status == "Not Met" %} + {% trans "Not Met" %} + {% else %} + {{ status }} + {% endif %} +
+
+
+ + {# Language Fluency #} + {% if analysis.language_fluency %} +
+
{% trans "Language Fluency" %}
+
+ {% for language in analysis.language_fluency %} + {{ language }} + {% endfor %} +
+
+ {% endif %} + + {% endwith %} +
+
+ {% else %} +
+
+
+ {% trans "Loading..." %} +
+
{% trans "Resume is being parsed" %}
+

{% trans "Our AI is analyzing the candidate's resume to generate insights. This may take a few moments." %}

+
+
+
+
+
+ {% endif %} + @@ -426,4 +576,4 @@ {% if user.is_staff %} {% include "recruitment/partials/stage_update_modal.html" with candidate=candidate form=stage_form %} {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/recruitment/candidate_exam_view.html b/templates/recruitment/candidate_exam_view.html index a41bd50..6ceb9d7 100644 --- a/templates/recruitment/candidate_exam_view.html +++ b/templates/recruitment/candidate_exam_view.html @@ -37,15 +37,18 @@ {% csrf_token %} @@ -56,7 +59,7 @@ - - - - - - - + + + + + + @@ -86,14 +89,6 @@ - - + + @@ -227,4 +227,4 @@ } }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/recruitment/candidate_interview_view.html b/templates/recruitment/candidate_interview_view.html index 03740c9..6f3abe2 100644 --- a/templates/recruitment/candidate_interview_view.html +++ b/templates/recruitment/candidate_interview_view.html @@ -30,15 +30,18 @@ {% csrf_token %} @@ -50,12 +53,13 @@ {% endif %} +
{% csrf_token %}
+ {% if candidates %}
{% endif %}
{% trans "Name" %}{% trans "Contact" %}{% trans "AI Score" %}{% trans "Exam Status" %}{% trans "Exam Date" %}{% trans "Actions" %}{% trans "Name" %}{% trans "Contact" %}{% trans "AI Score" %}{% trans "Exam Date" %}{% trans "Exam Results" %}{% trans "Actions" %}
{{ candidate.name }} - {# Tier badges now use defined stage-badge and tier-X-badge classes #} - {% if forloop.counter <= tier1_count %} - Tier 1 - {% elif forloop.counter <= tier1_count|default:0|add:tier1_count %} - Tier 2 - {% else %} - Tier 3+ - {% endif %}
@@ -105,34 +100,39 @@ {{ candidate.match_score|default:"0" }}% - {% if candidate.exam_status == "Passed" %} - {{ candidate.exam_status }} - {% elif candidate.exam_status == "Failed" %} - {{ candidate.exam_status }} - {% else %} - Pending - {% endif %} - - {{candidate.exam_date|date:"M d, Y h:i A"|default:"N/A"}} + {{candidate.exam_date|date:"d-m-Y h:i A"|default:"--"}} + + + {% if not candidate.exam_status %} + + {% else %} + {% if candidate.exam_status == "Passed" %} + {{ candidate.exam_status }} + {% elif candidate.exam_status == "Failed" %} + {{ candidate.exam_status }} + {% else %} + -- + {% endif %} + {% endif %} + -
- - - - - - - - + + + + + + + + + @@ -91,28 +97,77 @@ {{ candidate.phone }} - - + + + + {% endfor %} @@ -163,7 +218,7 @@ {% endif %} - + @@ -182,11 +237,7 @@ {% trans "Loading content..." %} - + @@ -293,4 +344,4 @@ }); }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/recruitment/candidate_list.html b/templates/recruitment/candidate_list.html index 8588135..7500c2c 100644 --- a/templates/recruitment/candidate_list.html +++ b/templates/recruitment/candidate_list.html @@ -186,11 +186,11 @@
- {% if job_filter or search_query %} - + {% trans "Clear" %} {% endif %} @@ -212,13 +212,14 @@
+ {% if candidates %}
{% endif %}
{% trans "Name" %}{% trans "Contact" %}{% trans "Topic" %}{% trans "Duration" %}{% trans "Interview Date" %}{% trans "Interview Link" %}{% trans "Actions" %} {% trans "Name" %} {% trans "Contact" %} {% trans "Topic" %} {% trans "Duration" %} {% trans "Meeting Date" %} {% trans "Link" %} {% trans "Meeting Status" %} {% trans "Interview Result" %} {% trans "Actions" %}
{{ candidate.get_latest_meeting.topic }}
{{ candidate.get_latest_meeting.duration }} min
+ {% if candidate.get_latest_meeting.topic %} + {{ candidate.get_latest_meeting.topic }} + {% else %} + -- + {% endif %} +
+ {% if candidate.get_latest_meeting.duration %} + {{ candidate.get_latest_meeting.duration }} {% trans _("Minutes") %} + {% else %} + -- + {% endif %} +
{% with latest_meeting=candidate.get_latest_meeting %} {% if latest_meeting %} - {{ latest_meeting.start_time|date:"M d, Y h:i A" }} + {{ latest_meeting.start_time|date:"d-m-Y h:i A" }} {% else %} - N/A + -- {% endif %} {% endwith %} {% with latest_meeting=candidate.get_latest_meeting %} {% if latest_meeting and latest_meeting.join_url %} - - + + click to join + {% else %} -- {% endif %} {% endwith %} + {{ latest_meeting.status }} + {% with latest_meeting=candidate.get_latest_meeting %} + {% if latest_meeting %} + + {% if latest_meeting.status == 'started' %} + + {% endif %} + {{ latest_meeting.status|title }} + + {% else %} + -- + {% endif %} + {% endwith %} + + {% if not candidate.interview_status %} + + {% else %} + {% if candidate.interview_status == "Passed" %} + {{ candidate.interview_status }} + {% elif candidate.interview_status == "Failed" %} + {{ candidate.interview_status }} + {% else %} + -- + {% endif %} + {% endif %} + - - {% else %} - - {% endif %} + {% endif %} +
- - - + + + + - - + + @@ -228,12 +229,19 @@ + - +
{% trans "Name" %}{% trans "Email" %}{% trans "Phone" %}{% trans "Name" %}{% trans "Email" %}{% trans "Phone" %} {% trans "Job" %}{% trans "Major" %} {% trans "Stage" %}{% trans "Created" %}{% trans "Actions" %}{% trans "created At" %}{% trans "Actions" %}
{{ candidate.email }} {{ candidate.phone }} {{ candidate.job.title }} + {% if candidate.professional_category != 'Uncategorized' %} + + {{ candidate.professional_category }} + + {% endif %} + {{ candidate.stage }} {{ candidate.created_at|date:"M d, Y" }}{{ candidate.created_at|date:"d-m-Y" }}
diff --git a/templates/recruitment/candidate_offer_view.html b/templates/recruitment/candidate_offer_view.html new file mode 100644 index 0000000..98ecd81 --- /dev/null +++ b/templates/recruitment/candidate_offer_view.html @@ -0,0 +1,273 @@ +{% extends 'base.html' %} +{% load static i18n %} + +{% block title %}- {{ job.title }} - ATS{% endblock %} + +{% block content %} +
+ +
+ {% include 'jobs/partials/applicant_tracking.html' %} +
+ +
+ {% if candidates %} +
+ +
+ {% csrf_token %} + + +
+ +
+ +
+ {% endif %} +
+
+ {% csrf_token %} + + + + + + + + + + + + + + + + + {% for candidate in candidates %} + + + + + + + + + + + + + {% endfor %} + +
+ {% if candidates %} +
+ +
+ {% endif %} +
{% trans "Name" %} {% trans "Contact" %} {% trans "Topic" %} {% trans "Duration" %} {% trans "Meeting Date" %} {% trans "Meeting Link" %} {% trans "Meeting Status" %} {% trans "Offer" %} {% trans "Actions" %}
+
+ +
+
+
+ {{ candidate.name }} +
+
+
+ {{ candidate.email }}
+ {{ candidate.phone }} +
+
+ {% if candidate.get_latest_meeting.topic %} + {{ candidate.get_latest_meeting.topic }} + {% else %} + -- + {% endif %} +
+ {% if candidate.get_latest_meeting.duration %} + {{ candidate.get_latest_meeting.duration }} {% trans _("Minutes") %} + {% else %} + -- + {% endif %} +
+ {% with latest_meeting=candidate.get_latest_meeting %} + {% if latest_meeting %} + {{ latest_meeting.start_time|date:"d-m-Y h:i A" }} + {% else %} + -- + {% endif %} + {% endwith %} + + {% with latest_meeting=candidate.get_latest_meeting %} + {% if latest_meeting and latest_meeting.join_url %} + + click to join + + + {% else %} + -- + {% endif %} + {% endwith %} + + {{ latest_meeting.status }} + {% with latest_meeting=candidate.get_latest_meeting %} + {% if latest_meeting %} + + {% if latest_meeting.status == 'started' %} + + {% endif %} + {{ latest_meeting.status|title }} + + {% else %} + -- + {% endif %} + {% endwith %} + + {% if not candidate.offer_status %} + + {% else %} + {% if candidate.offer_status == "Accepted" %} + {{ candidate.offer_status }} + {% elif candidate.offer_status == "Rejected" %} + {{ candidate.offer_status }} + {% else %} + -- + {% endif %} + {% endif %} + + +
+ {% if not candidates %} + + {% endif %} +
+
+
+ +
+ + + +{% endblock %} + +{% block customJS %} + +{% endblock %} diff --git a/templates/recruitment/candidate_screening_view.html b/templates/recruitment/candidate_screening_view.html index 05938fa..7597fa9 100644 --- a/templates/recruitment/candidate_screening_view.html +++ b/templates/recruitment/candidate_screening_view.html @@ -72,6 +72,9 @@ {% csrf_token %} - - - - - - - + + + + + + + @@ -128,26 +146,38 @@ - - + + +
+ {% if candidates %}
{% endif %}
{% trans "Candidate Name" %}{% trans "Contact Info" %}{% trans "AI Score" %}{% trans "Application Status" %}{% trans "Current Stage" %}{% trans "Actions" %} + {% trans "Candidate Name" %} + + {% trans "Contact Info" %} + + {% trans "AI Score" %} + + {% trans "Is Qualified?" %} + + {% trans "Professional Category" %} + + {% trans "Top 3 Skills" %} + + {% trans "Actions" %} +
- - {{ candidate.match_score|default:"0" }}% - + {% if candidate.match_score %} + + {{ candidate.match_score|default:"0" }}% + + {% endif %} - - {{ candidate.get_applicant_status_display }} - - - - {{ candidate.get_stage_display }} - - {% if candidate.stage == "Exam" and candidate.exam_status %} -
- - {{ candidate.get_exam_status_display }} +
+ {% if candidate.screening_stage_rating %} + + {{ candidate.screening_stage_rating|default:"--" }} {% endif %} + {% if candidate.professional_category %} + + {{ candidate.professional_category }} + + {% endif %} + + {% if candidate.top_3_keywords %} +
+ {% for skill in candidate.top_3_keywords %} + + {{ skill }} + + {% endfor %} +
+ {% endif %} +
- {% partialdef stage-modal %} @@ -64,8 +60,7 @@ - {% endpartialdef %} - {% partial stage-modal %} +