From a669564e6df16a312d4c8a0edd65f5703e72a86f Mon Sep 17 00:00:00 2001 From: ismail Date: Thu, 16 Oct 2025 13:15:46 +0300 Subject: [PATCH] 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 zcma)8dr(y86~Euz2g`+h0Sk!0ULHc0eXy*E5`ln<3dr*UMlfy`_9Cn7u70~~m6?RJ zb{JE`_&9YW4=d?7W1VJ9?V6OfwoU4EnvR{;b*7bWCuz+eO>Cltim`DzW6yWdWSYse zxij~-=R42uoO8eP?WI|A_!2SP(dz{cALpYo{4w8rGKC$TTz> zmcg@SZ}8W6I40)DyUQnetyHl+r6(H_%{e)Rl3fyADoIyMyo9Dp4yxbjw7KAht$>`0 zKd@;Cq}$7~lBS?$L(3=bi1qQC75!Qk)OduM@hxoOrGEF+aCzVf?_BZ9FnOR z9uXhzJrs>lH>z~l3{X^OIo@rA2Ynhyaazc6XlN@gd<})nM`VQOxJho#ly^w;Pd+6> zIHa974Zu^*A~FCIP7!fN!MB~_`T>$D%vW&w{OP{a-j}>{%gX1hS8{dUB!r_3LaMQysI2Je^lsC70j<>I!`D# zR9Rf1IN!x{dh3klx>$8htYZFia_6k|3n}Va452PE&hO?22s~|CE@&`2BffV!;QN(_ z$Pl>AxiD|iL9hFae>8SMNoxt6_@lk6&t7Pcp}Gi#9d2qQj&*%`unJx)JCGdXeND=KJuMo<2c?6lHJ&8l*fPk1;d9URMdPN_pf%CGGo?mE459-gb>16>q{=?^+>^ z*)jMKF7)Q{X`kC=_2p=8p_Ps&5;&I}SKxa}Mz5=VK7P?f|} z^>z)swK5xjdJH(rEbvTAnjod%DC4Q_OJoyLsv=931S;%Iv!y5X>!|G8-5UtX8^bgh z_ypsRzbQ0{kw9?Im@G1;h@ZY+90^k~Ix36*r7y`*m(UUw0}(M4j*339J?xjq6r&+| z*oTIS4c5lTbRrYcFhyVf0F{TMV+X`QNNfq=h}##Cza|Zj%Kq4xTpkfyrOu9x0eQ^t z5SUxk@`(|7kB{Ow{CopqkOf02egSyqTtZSE#XOYy(@a)U2iA-}kAO3|m(kfl#Bp77 z$S<;#ACAE^9D{i_6^fFnN+nq6C9);7Cl;lXOr(=TelH6E#AF$@F#il##Uh=kFv%_O zYK?AQ;4*SfT8~@jj9Et}zBTc^k=bVxD>@R!&N)LKj=K!)bFK3_&SYW2FVV?G9h3Ip z=B4k!65p|DCCN?2LELLoC3SmaGCdHHqf6;rhI5+_=3mC(!}xyou;as?JL?Euc!e<1 zat*Gwl*8*=i_}u8O9yqW<=R|yX%isOT0@q>Ol#@Rq-ltK3SO)xjVg&I4NIIyiPK-B z(=V1cn-zjxc;|G~$!He?Rx!GaQEmoR3_J{0!h_c8wBImsE?X-%PJtaswxP%w6tEko zh}}49<;F>eXWDj^&50|{8oz6t-FLNXN211?5TBkaEMfP^Z2vo9z<&{Y;C_%Sc!3LD!Pw_mk^SwKq!ige#WST#tw=1@NlBz zo1_-qdLRXrD7%7W64u z+UK_1%P_Ioi*UD8s}b&*R2t7bhp^P)f~E2DqyFyvHMrTY+DH>?Bu&F}oUWYX74Sj7 z*zanWqrSl7thL(z(~bYxOIpp6Q~o5-RRql4IYJ9^nB&L{y!_;Wui6jOCYyZ|^TmMW z_h`1O(>1~(hpTY?)kj$p6918vWpj3?zcvxEa3BA_t#@np;vNcF?U~U7A`en@sO5UXTp47?1yk@EkNb53*mS z&pW?jrK9Nxn=tM|c^Lky_)4WPF0ZLJP5{g}(~L-ZFL`8oh;<*GZ`zjU=P|Y4W%LQST;p z432qA^4~zkq;W9RHRKCa2e$gEt2gsE;gYwUU4+lQ8^P~uhsof^lHa4pll^Ri)KWa=pr7m5-~O^*d4jj>=C|>Z&SG@PX)h4it&^CAnRw`{%1*esapovW{G^3q} zj7NDIb~Y>Q63jJ6r8KJf3J!(?&UTq#+?fx(E!$WseY9n-h?W0i$2JMb<|LH2ZI+JF zY5W=-rF-Cf+bNcS-uB1V8(Voa^2k7JJlYZGb9uk)He~X?7tY@kO-5p;F(#j)7WC1H(Qp!TWwb=T z36(F1{O44oI!#`baI(mlYtRio?AcIMK&!2UT2+t8WK+dZW_Q^|G@}fOZK$V#D4NzS z2`DoQ$eSwQe6iY=sPLR*;i3|b7hH5vV5 zsVF~`h$i^}?OujYdY@vK;jFeu{7!%;`WCfHwqJs``YWV2(Z#KYFZ*km2FiB1w)6MM zg*xOq+~d?5*OEk?z94ZM$;E{KwaYiqvIJh-Rg?7r*-H$57%X>KQB4UFPoR=|ETf^< zs6#Ic#SZ9I9MGe5YL<*y@P_gsD}ti~9%g_)3@FkSa@>Kh-3k?he=4!#(yieKDZGNn zXkrsdR1A^A4(1_6{4;~-(9f6)z8rF5DC6!T3wiNoGTBM+FgV_@!61{CaE3v6cYD3w zA}{6%=KQ~!Jl9N~1xx-?Va|H#r#n~eMw$e~N(r9zs#&88EDDIVQaxa-r}S)lzGH{d_H1;9JUvTuFROk7KH8}lwd)k1uin#(H}*|!|FHxD4pEx3jtqu zx*%gRNg7ffhsT3XT`Ssu0ly1AU~5K+6DS_vMo_P3^POz{;u3>%TI=m=n5&3>i5qYV zb7%@-{(((Z-;h@+;s0JlUX2BWxC2Uut#T)tlV9S;&@y~Ty-hedo_2~tSfaf@zKb@uQ21K^*_~17H6iQZ)a#icx9 z{l>Y*3+&y|YwLnn1@p4j&jQc3Ki7K-4NzQun8DP8&Fc(Io=crM{M*AT0*Y$l{{rhf 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 zcmbt+30z#|wfMcW+<}<^1{j!?nPF!jKms8Nge)W>Aqz{mnHYsQAutIfFpxWtC{+hv zU5Hvu{GKIg^(DSqP1Ht|w#F_R+p4ivGZUgSz0x-JZ=dz`)kc$Uc6;Z1cQ#0DpTGb7 zemQ*0`IfVO=UZ-$zNxtLIYsK_loT}sKl|uMy$>EZoN8qIpDliyRdlwsKTY<1tT%UF zSHmI#!wB1qOdi8q%?zK@S=9a5a6s2uh1P7sX25e2JM6cJNMsU3@9PoqQR{8zz+HSLz}@_OfW7O!uW zX31LhZQIvjay5V!r$$(VnMMR_5zx9iOf?}`k6;4=2ZD_VHX*>~0`0Q}Q*BYOj^t`0 zgSC-1tBH6tP3$IeN>j)Ff&5QSKGACbp50*pboz5~QEoUGqE5*t>ryhvij<`ClsE>T ztb`1&Md*_Cr(RW)Lhes7lKWDUvvdh{sS*5i@?=Uik)O*Xmr@>Ki^%<{huKczNZX@x z0rrQl1L(T~_UhD=ucS2>C?GGm5$r(Fg-=CY))Qs6AYJyzx+NAhUEl>5|M$9@Fhq&1lmP72VfNxb#mz6<;n}&49}N zXkIzMSIp|hCGuAak{he#uavWxu2uq!NK1Q_w8)Z-W%)jw#I>ZtV+Ddu>_ zBIC&qGHzz~k^0PfGESr=I_lto@I(v!d5 zskiYmUS1=UFugLDtR&K^D-R^ZlH4i%hK{sI8HkKm)Uvbk%38Ku1)T$%@CV(q1&otx z8P$~ghP#Ae_i*2UN925-E}^@Z@Y$6zXA=2Kwp}LdC2oVIRv~zy8P!WsD`C0!jd+Bc z#bmd;yT8llbGv;gNQc8QCQt>v19GB+Tr?Y-D5lRdd;p4kD9}HRF*BLQ!;6EN#nYM2 zP-ayyvubkLbY=s{jAM+}iCYfe5;7DA4aFftSm zCX;NGAx@J;qnHm|Si>%2y)yEsX$2YEs__FCSC2J^&H2-c$jFG) z4gX?4vcsB8w&!f(9)KcYf;^Lx2Zr#ALDST4a#qXy*FmYs-f&VbB)9^^ydJ=)u5x4u zgy$(A@OXy%yt^v}7uMZ?0PRfjC)+XAgWyI0qQcidvil?}+z%P2PS}MNvChThKHuQ3 zL1Cb4Sm}LeIEiVf;|YRuH-{Xlnw0_%CLwIg7zr<2LMnrYCVHkFa$utE@P9!EvXlO(nu zQ#T>_Oeb~}YZ0IneBfbX0!(*|Z2$oV$f>+cX?;6NrE@Xw9=7OFY({-{oX02uSn_hQ zimJ1l94g3eqzIIG$TwjCfGGD33rbE!*q8w0A z)b6hC;lBMIQQ_?x@CZ+nRb{tkq0F3CksI&~_YU?5S&$W;Bp1qx*j?l=WjThYu>gI9 zsD%0V4GEIeN#(o>wu#(0&$x)v8Yh$MAKbm$BT&^5IYXf*wHk@)OudP z)OQfXcq4olv(#cJH(tUlu%uNIy>JHeR2?WIl#&u4DZ(iPbS{(`v#mr-iq2Yi4e*px zzJ(?w`zrZT?J^GS3?vn(9cE9F#=2Af^;mEx08!aJIN}`^#<9SNz>I(riCj#eW%iWC z3|uE*y&N9#xqJG2L;YPhBN>Tv*Up{$d8ilDh6l@syZYT-Lqq+2poPQ21#I>cpGq4-3_uY63rCOv zhkH z*Ka^ZKyU}SV|5wZJTk|q6!Knw}^i6_6^y}zeCBvsfRawL#CpP_$x!& znjk8-LJa~5$N2gn(QGb+Z?hh40)r?UPI@N4x3IH zmNweGoP~nFV(F&{=GrQ;Z#F1Zfb)~7rJIM@l=lG%d<@mEPV&9>g2hxR{~O!>2EZ(7 ziTaq?po5Ayb0HbJ`+vm1m(S9OukrcD&+3c(oHubAX!dKC{EaV-v}@RMJ_kLOm^ADe z0Fxf}h?;21-PZ%L*L;_ABlUEtNaZ9>l4@?mN_1HI0G6gfYL1m?F??|E9O>)t0hy%( zLYO?}x=~s@pPWkC8c?v4>TX5SyG5x#H>OSmd5I*o_7C6=BLa}7U=Lf*cADe zz$@CZ3Cqj~^Z@U;wbC1s3aAxv1&!__|^3A4Wre21b=uE#wHul5!m7=7*Iu z7i;6}i!%+o6**9JkGDtaIu}Z=Im0;VRIXG6$=Gi-wup@{a@0ZhI3vD9HxH|+0?}cq z=Fnl4RC9c@oyg1GM&)8?Q8M-O?ow9K3`pQ{OyR06 zwDT0{B3DxBO0ecVCtd&p$ zBBnYun|C6&zpH=5;}fWIlpI|{`RG2Y`7^5(b5b=I$76i3}a(DG*SzO>Ci?~E|yQOmBG2mOEw!h_dj|SoSUd?SugqBeFm~au%>1* zO(P65B4e1LMwf!HSy`-x92z#U3evDdpTTQ*ZFJIJ4#qH(mu*%cN3Y(tlTU%JRUKAZ z1f3VVyofU}7MhHK1TrdYae75L8*TKpQW}rUfan0*!$dq`H#Z z^Cld6HcM%Asima8s#@-n_h-Z;plL_fVP9;6GdNd;em3n_@p4`&9#j&>4)v{((gKZrcRzbOzZVV$ix{o}$m8FXeEbHhp zMNSGSg)yKL{64NfVj7Lj-0tW)(E@9|Iy!%de-VVdIZS{d^|Z=%ZJ~Z@c}rBiR>J_DeFJP2SAT z7)qYDo`wVawc|}!%#67tWU36BDnq74LDQl$NdeQMfNA~shKu>7Q~PhbO{VzMl@a#A z+aL2E1)6`LZ?Lr%Ge2CE0q{r34a(+X*(<4wA@gc77QJe%Z;{JhGb$kcT25nqi=BD1 zSl_}Z-&~4CZ?0e={T3sK^jmU$i&goS0n6XAu$Z>ea=W@!BY(?jY~|$VSS8e+HJprAgr{mZr7TYES!mFT-fDSwwyVH#_HAzVk=$V2~ zSdDmV5a1}Y19*m!Gop>W_eGmZH~_W(kdsq)+yy~y&7GIog&+OE{*JG5vjW>hPRC8D@Or2ldK*;3*>1JKEqm(JaAYswn;rvS{9MeJp~+63Rhd~ zy`?%TCF-X#am>AB>Agj=OqNs{vO1}7_F^%eJ|&5I0xF#31WynjDxoWEcpCy7&WC;^ zy7|D}$7}cID950-oP2oiD^}cKb;|%me`$~a`(`fRl0tUy4)TrXtQqULu3QM-R8gF5 zI`Z-|{X-YoB!ySrp&{p&t$yIFmeeFoo>@ zYARuWY*)A#UP<1w>yvp7;y@M_W|H=OJIUFWC6l=(HfOR~r0#tOx%J_6G7-)sx*bWF zSG$yak`j8ZVacJenH>66ah3u^95w9kY^8!E(sc zJ7kCk)n2Znh#bByXX4#-R-dlqb%?;HL!72^aqXpKvp&;RF7=)WU8)*jDgGQxCF4zs zb_r2zFRb6muB3K|D(3z=7DP)rY~;+Z?3!e0{(MG_j679hM#^QeDoR6Qm7mY7O$hy) z;W!1u*p8ZLuklNFubS6;H64p%HL>|-GV9o;tVpE&rb`=-s9fqOO?417_JEQ!8yIHW z)5sBTYKM&>?13DIE0xcpOibmo@oy|BPvZ?PS$lmn3QcKWj!C__TH)2X(!A;MP{^fo zr2}(U;iH)x)IIolGh7+Gi89}edx#DlcH4qRXpPtsULu4R7TUr}Clt-)L~=G- zXRqbFnH_7fCwq!Z=E`(&b4lPGwVZ=#UG(6O9Yz-ez2;)eC}FTq;M&)d+{T>6z&7c} z=Sh0(lR20I#--u&@m+&gvyN>xMueh^;( zB`+RU$@S4HYE9m(XqG;|@WQr5YDi7Vn;y+l_468{S!zLEqm(sDhU7JY?wef-(E2-I zOpD8cJz*@brs|UovyzJbQKYZpOQQN!65$PQ%$i{f>@r`=TBN2)l8Ll$#=u#V5$z&c zNoOfl`eT)RW<{c%z)C>v1hiTDfg0#tS*~nQLxaocGP%sWvo_glrIu{|zuR!Mzjj=4 z8S>Vz(m$s~cl=9>wnho|s;_0K31u-0N9*lc!H|Evo?8N*xAa;#po(P)vB~15%IrmvStX*Q3ZT5 zz%lcc70F)Zwl=bPP1brvF(7s%l0F~QBPT&Y%%~##f(mt<6|KGT#gSSm>y?wuEt!Q1 zzRJb4ga4ZQ!xgft5mcRNzX2B&c01~GN^C>twI{4Y$#%ZFmIbrF5s_MoubSdDq=T*q z$=k&^7q$pAfW(cAsDno&yZYdAz}MaD=^5$wkb568kvES!$;pQb*gbaF6O3l{?BE3D^5K;<)s7eg~$y0l=a7 zZcNio`!O}QUQCWnic%zMN8yEoJMv&dOp3BXOr>XfyO|&nlx*rAbB?P#WgE9Vj_q6wW5&a(W-j|E~5lJ7y zlFw|3@{NmoRNb_#JaTAo62v^T7sc$HFNNmxi2PefGkRHR4-MfUBV0jnHv;LB4^#Pm zgczK}FL0!Dcr}!XDZ7M0IH!m{iHT83H`daXE^-&1asWRT;v``WfkS(E83C6ga0!H= zmrw!~qP*YZ#dlN#JK<_epbrA@wA?S?5+u=vCrSby@d%G2poa#hAth?N`#oK7Ax7_q zXz#md?|F!J90A7YqN>-^1zQ*&-EfF1xH-U6ZrJ~O`-c0v`kf9be5a8+RRoYKJn{K= zY?~6*A?Xf^ii46yuLj+cS&C{&1MoZrcKrL{iAmSaevjMNKRE1zgOZ3{_rTLKcyA!G zeN;K{$=5DQnQK9OJutR)I6U9Mu==o$X9OFro}cXn1GGSQ;*v;rvR}<0m!d$WabHz zA2$6nX$8l(JX3$NemZT*xMIdwa>DS8^`v#$xM(~DUh!#d#|=+dAGKc4lwY*wA8S3@ z8nRXft#ID3e%iWWy!q#ujv1YCBK5)4BL@N{n=a^@XAJg$qvrIQprIk4Yk*@7qcdcf zA2iGl85Rc(i^FpS3IlU(cgPAsjOszpv>$GWeIH&Z)@?cKgjKvB%wHR}I!}}btxLj&g5%qQhKfsBe#W@u5~DGu!a;|X zG3SR&r9o3^$h076T5vk&j6Ya=UBI*;VA?jmA*|JhwDzFZ9@6FqwfV<4T+mhmdn`HQ z8^bnu*?7TLb4VT5SwgzJpe}DlpEHwR7Rq0EK7V1@T6FxDpmpKop38Dsf%yZo?P$SQ zi)M12p6jfICALPmD86V3T`yx1pu`iyyY1+1OycLGE zJH}gQ9L^J^AxF(&?IAXtssDH1krmU1vbQtK!j{sYrToyE8RvqKv-Z5R_H@DN{V&Wr zt2n#$b?vm%70?$!7sWM`y#M*@pS^x^<7wZS+Siu9ygaa~Jy5_OYQC6TeQ3iK9b;QC znR7;cwkfn}cW~40>73p}Yr}?|BfapT^F-~mVc|@1Rj9ZwSX>t>ZU`1Pg!3xH#kJwW z@=)Q*VByMeae1I(MX-3~8Ede(C0tk*n71rgxcu~Juy8}TYDu8BE$FY>d?|$~S;byr zR3+vk+AA51sVroy3L2|I#@e8;}o5?CTzAvaRA*~;G z`&lM;(Un~+ladwETF+~(VVxmhshV67s9JrdK45CQBA2D4UQ#nU=ZSUCw4Q98PFot* zTP9i$w+0H91@z0`&$JxbbYeJ|xiFwzh}NQeVA;WC53IOn#gSV>_L`u*CS+fH-oE%E zswL{yiTa>v322j)_s0timXM(&Xec>h4jLAYH-%BP=LIeEPTUx@)Q@k9H27@%+`>?9 zRWP?IP`xgg+Z52+!e(2@ToyEgB?QfN;~Qai*~am;@7l^hPcw5unWdpje`zqYbh0i~ zy&+h=AynNGtZoVGt$&ou7G_*#06?28%S7|x=8L5ZL!}MD(uS~O-p6uTne78x(b3-H zqtmvka7k6DWJR!K#Tj+5WMimgOR!{1z~!DU+3|6*(q#UF9Q63(WX3)ZEG(yjT7O~K zlpD@*hAn=Y%C8Aqi$SqI&Q#~5jyGR2Q>%K{R`B@^>i=p_g_1q_Z=JR^j<>#_mT_ps zbXxvn-6u-FHaMMD6Y*#u>Tq`b_xLmB@45r^&1bh?=(sW9_D**U25uM%==WVw%F{Eh zBr!%CV(M$dIk{)^gZh@sDuprQ5~E1ZxRlB`^59UH+Uh$ITQw8~4bDS~u+HHR>57B8 z;&5Ks>7r*l&n^tKZGF8ysNePnxeV-H3Cej%$Jm`ATUF3jHEjb|QV_CM1g#a**7+cb zu&w0URYgm}_R^ouuba+Y`kea2g5$YQ6g*b&tU8#xG;DT6*__SP`PZ=zv>t2?SZjjX zMc}FRW-x%TuIi%27Iw@J=gkW{O2P%vgsmW4S|84@_&7_Go^e@~rcb@Zq@|?(=}I1> zD?CvUOsk~p$F#9@JSD6#99ev9#nBbhn$j?=8yk;ql-7;Ov}xV`tdanY&y8GMjBe4eM4==tRpu(p~GF(Wtkk0+@{_=1<%?3@e(&Rd2%5uPpL>EA3nJho^=*J zwbVfa`5yza@CJf60l*v5Xs{`qBco5Pb0$djHdqe(!Vjtd7ndcWgoc!HXm`HNX3QqzZQd(WCu3tfOHz z-r7oGHU_KGn^@R}M;--H`CUa)th)q~$ewTJac5wh{TzAln+M@&+xo3XSQDL2C-f&O zhTyXck!u484=Udx=f7QJKyssp@-*`Q51i=T#P)pl7JBBr3uiKb0D`&1w<>gsIJ@XO z6w$;Cb?$76SU z@XQQljtwN1(@hQCGsIuv&?ukMnD{&tnID;45rn(Yw`ho~5^QDM@vh$h+%I!v+joZ| zv;O6GzsEMRMh| zA;ZfDXitA|M#Z@jPN$rz$f*Zq19g_q;UvCCPP|;X{`-);+PbOVLIss(0||j(8w4jg z5Z4+gf^Ue_7O>?tLG=PKT{&F{i(7np*V3Pi2KD zDdFn~s4*k6BQNA1#==JsTu)rDmNcQ7z)myz#QevrKKYJ>D7n@TqB6XHiD#}Sd9m&DXLaZir5bc8ZY(V^2YMZHMkd-D*oh)#0O zB40#H}%qMo8gi9RQl`RW$WVHqI3V}q{|aYC)^v$ zB&wY_0G-t1n2M|G9?Vj8rPNRgDM^uQwYdU-Z)>han?wurHTQmvAwPH{mlJ`iH#zd* z8{dsQ-;&;gzW9?L%d?O?xcZDU3wPP%@xWld3u}~;&!d<0lyo}$4y@vYm&lS}Ieb~L zE7or6>-PZVQ_gXqZ@AZy zaLzab#cMw`@Z{1JgQfs7(KjUN?^bZvW9vtV+hO$Yz~oK@-2g<@fi5@-^6oyF^w|fO z6r0M6Hc5`2p7cP>LWM`?Mdce8H{yS4hJ*?}`cQH!Z@ zheJO?I)Stxj?*IKA{JfL>C*QSW~t^-4~^Q@Sqj%Ej@s1iPc-Js1vc!2^Ilkl}Aemy7r6sss3C_~V(MfDDkwcv{xLDD;& zw+p_n!Y^pxi&G6fv+KnH_K}L8nb`-(`k&2Hw_s~q*HE9Ey!W1kQ^*)5Pc?P?XZOih zpf(7f;9!(utODiu2NuvN9E19nlL;# zKViRrM(`L`6a zke~*$r6=)&L6%c~u$Ypx4*4O&EVW)av43ck)^v?NdmWqjJICf(b(p5Ak}Q1+nV>Hd^(wY@73zqX(c1+t8%Ksm1W)uvO`wYK2+nwp zHtfK%od`0qcm2fkDt1^$SEEm`Gz&|~#L^_T*nb=~g}Z8K#GuMq04e9ZSq=iK>am+9 z1aXByOv!QRv4V1fM#K-p5YlB-T>Q~B3+`m79QR<8JOotHsCpg8Y@&)lb`EQIAj(bz zlm*mPex}02bVV@WRP(V}8AhVc3@P-wmbOrBmsDor-elIEJqQiNWV9sd9>;=xnI_I8 zv!yyKrgIRWYWXtYk!Myq-E|5MEJaY3^t?mOmMYoi=QpOYA9K~z|HN0mITR)+56+h$ zFKN8v+=2O>2x2RQ@JFnk>-Q%nv)RJ=RQz_Fg`|Ga@M^i|VtpQh{0XIjb^3AD67U)p z?wFOmgClU`RtVMLDv=Ly=jOD|RT1hxK8GeGDgqS^N@k06Wr!ioXRjetu6c+;bw>*+ zXG%gaJtu@2oM4S?uaQdV8pmf7w;S04t_p~JDe3vAjBJry+8|H-#=>DN{l6#JH6$&^6)F%g z-91>*v$JAg5Wbr6J-^h!zRmjKI&+isCHWd0=nMc++l&}n;cgsGP=p37Z$p5ODCmC# zLH}C@`f0y#0?U4c;57t61n(jEHG*#-_&b7+5U6lQ_`#*1L6FYkMd<=eE=GXgPzg;4 znh~@i;1T!`96&INU<|=21bC1m;9-?;1_7QR33wtT-~o#;jesD)O{su;I01Jd0`3r` zm}(iuVASnOW`n{Ikeze>pByo%9@qx2E0zmwPz&uD-#fC2gZb=Nl$W%OI(?#|fUP#= zvE@fLTw&nPl?`kbYdBJJg@Hd;=0E?10#?N??qXT>WjP~Dy(GirCr$Enmiwf{0AQgV zz$$i^9R7@I+%k4E`^g&k*mW=aNybF5khOAhww3)vJ#o2^y<2m6gIvZgr2p~8{{jRB 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 %} - - - + + + + +
{% 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 %}