From da555c1460c60dd483f0b2d272aed457ce18286d Mon Sep 17 00:00:00 2001 From: ismail Date: Thu, 13 Nov 2025 14:05:59 +0300 Subject: [PATCH] person update --- recruitment/__pycache__/admin.cpython-313.pyc | Bin 12375 -> 10733 bytes recruitment/__pycache__/forms.cpython-313.pyc | Bin 73626 -> 76039 bytes .../__pycache__/models.cpython-313.pyc | Bin 96088 -> 103798 bytes .../__pycache__/serializers.cpython-313.pyc | Bin 1369 -> 1375 bytes .../__pycache__/signals.cpython-313.pyc | Bin 8036 -> 8049 bytes recruitment/__pycache__/views.cpython-313.pyc | Bin 155744 -> 161950 bytes .../views_frontend.cpython-313.pyc | Bin 47336 -> 47558 bytes recruitment/admin.py | 40 +- recruitment/decorators.py | 154 ++++- recruitment/forms.py | 307 +++++---- recruitment/migrations/0001_initial.py | 191 ++++-- .../0002_delete_candidate_and_more.py | 25 + recruitment/migrations/0002_document.py | 37 -- .../0003_convert_document_to_generic_fk.py | 45 ++ recruitment/migrations/0004_person_agency.py | 19 + .../__pycache__/0001_initial.cpython-313.pyc | Bin 49966 -> 54863 bytes recruitment/models.py | 397 +++++++++--- recruitment/serializers.py | 6 +- recruitment/signals.py | 11 +- recruitment/tasks.py | 24 +- recruitment/tests.py | 111 ++-- recruitment/urls.py | 30 +- recruitment/views.py | 536 +++++++++++----- recruitment/views_frontend.py | 154 +++-- templates/base.html | 11 +- templates/interviews/preview_schedule.html | 4 +- templates/interviews/schedule_interviews.html | 6 +- templates/jobs/job_list.html | 2 +- templates/meetings/list_meetings.html | 2 +- templates/meetings/meeting_details.html | 2 +- templates/people/create_person.html | 444 +++++++++++++ templates/people/person_detail.html | 607 ++++++++++++++++++ templates/people/person_list.html | 411 ++++++++++++ templates/people/update_person.html | 572 +++++++++++++++++ templates/portal_base.html | 20 +- .../recruitment/agency_assignment_detail.html | 18 +- .../agency_portal_persons_list.html | 390 +++++++++++ templates/recruitment/candidate_create.html | 81 ++- templates/recruitment/candidate_detail.html | 3 +- .../recruitment/candidate_exam_view.html | 2 +- templates/recruitment/candidate_list.html | 18 +- templates/recruitment/candidate_signup.html | 148 +++++ .../recruitment/partials/exam-results.html | 11 - .../partials/interview-results.html | 12 +- 44 files changed, 4144 insertions(+), 707 deletions(-) create mode 100644 recruitment/migrations/0002_delete_candidate_and_more.py delete mode 100644 recruitment/migrations/0002_document.py create mode 100644 recruitment/migrations/0003_convert_document_to_generic_fk.py create mode 100644 recruitment/migrations/0004_person_agency.py create mode 100644 templates/people/create_person.html create mode 100644 templates/people/person_detail.html create mode 100644 templates/people/person_list.html create mode 100644 templates/people/update_person.html create mode 100644 templates/recruitment/agency_portal_persons_list.html diff --git a/recruitment/__pycache__/admin.cpython-313.pyc b/recruitment/__pycache__/admin.cpython-313.pyc index d507901d5b2756360863f2f0e1dc726e68c73f53..7a4ff588ed4afedc1563482bfe839eadf443eed8 100644 GIT binary patch delta 1041 zcmZvaT}TvB6vy|@tYgl)V`REtWt*<9o{$hTZGVq z4?e7?HV9keB|rN zX}D{27f6i+OBf7J!x?gNQxmha#!WZ;HcIJz#aSLVs5$8@#I~XW$A5e#VXI0>COc3f z?}HPx4E`c#=O~6(5SJ-ro;|c?6owHk zh*nJHXc;dna1B#ocwY3?f_qb9zoz(f<$T|{(R2Y=E$$;%p~3QwgkiU}gG6B3Zh=AT zD-wlvTPvA@u-2S0}t_1Xd=ioNC#NkGn$CBBI)AnlnDZ4ctUP^^x42vQr;X~ONiGkM@ zC2{!aI%2wysRxK*=qTS~&#WJ!IExSvVF;DS%-OYPFo}pmd&Lx)VN-po2sJHGO*W=- z{feTifq|6ZyQp6n(lRGPM`kmkQJY6Muf^fEyGCH=6@+JQr+~V)0N>meTbza|u~+pg zBf8HY9N9kO92yY|&{i2BbKtMC!E$BLyh1hEs5M}I0*=b1hgkW{?~u+NW(GtPMQR7W zXiyC(GM8={ZpvrHMKmB*;Fmm17U^ro`HeRQ_+HgwTE^sKL=u`k2Td$|g6n0t);y|^P*x4V$4v>9mHYRIg3DlZ0ag}-ry+@`K(HaMV Qx3!;*$6p(quvmBJFNRVLoB#j- delta 2284 zcmah~TWnKx7(b_7?^}22x?Z-mr(H`a1WFJP#TgdK04Ff$#29DFaXp&{+SBnrXCTAb z`XB<)WblzdVuCX&u*3)16g1H+K7f~}A;OkJkc+$e} zhLq0)@AzHN>HXP!l$s9);4hCCR{Mfb@S7m*`&Fld8okZWu}9!-|JjFKU|fb$Y(pD*rLopZx$OJ_yt*89{eZdGl)1DQ-p;yNkb0o_vVouVxD{m&JFu^E7xkj^I z4^hzF)rOV!XkOeJLE*)WOso4CU9{@7I!~Z!?RrTO$SzUX&2^Gok08r&i)s_z;Ik<_ zUaCVA=>U`gS#^q%lGm+uZ4D(#>yn8**V`))U0bQkC5>%+PLy)f!$* z@wCxBGMy>S$PYHOrPCtz`*8Q82L1HL@%VPB)mfhGNbzz?+Z4Jdu319LDLM(Ky<)U$ zl+{`u3~)WOcSh#WR!DQa%xUh>rCE%DZ5N_E(!*a5a5qcCF{egUQiD*f&cJdXCFc^N&pj;3wzg$%Q3>FpskD}%} z71EzQT38edTe9CWZLOTL`Yu>~$6_NdPcH79Y}tItx`iyj8e)&Dn1;9Nr3h&VIGbQc ziyPvN)efALev8lG1^ixIlA$u$Of8EH<5^>%e6Y;Bv7FIGGWM{>E~RXw#5UH{ znhWcTVaDYtbWB%sli?>Pohu7#?^PH~n^>AQzL70#`lTX@L=uTbTiMA9&s0TR8x0wO z37Ze{O}kc)(T3;)s=bm2sjQzv?I}f0we;lZEs&IdKdc7bWdfErFI-l3)XpynQO}KJ z0K=iE=Uj-=x3e$?N1FFSOQaUKz&`dooD4kc_#Tx%AP^`9n*8PUk4XN6U`7~%dBG1C z{9mqfXo_$i?gS6B=d_+b;C8LJN;&#kS+atl(2J^?`2pqi9MN0HsB|FO1TJHQOFA(D zYeS37s3kjLFvOXWCzoL)GIi3ZmZZor+$GKVJqhU=TKINH4A28zE^cw7h9&&{?A;fNFhhLm>M=!Xe YGYqa~jEteNs40r;;FbCJfseoU5A|}1>Hq)$ diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index 34ebd84bfa60401ad57d81edd31fe858eb1a4fc5..bc3e49449caec10c34827f68c8c3d1b57470068c 100644 GIT binary patch delta 21167 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 delta 27598 zcma)l30&O8^*FPz%fc@A4FQ&$=FOWo zZ{EC_`R1#Kbkm<{!@mp*3k`t3J5EJ9ei?RCcp^J?Q{F$z5(6>=EHTLeme{VCg@KqM zAVA9RipvaGC|&|2Gv$ecJn>zOXT-+Jk>|0HutGv706I~H&MG2w5}=d23cVDI37Z1g zd8*7~C4^1|bebx2DPhwAYf@nas~LdJRAJ{+-YmdotFYyS%>it#3R^+gJiz7)>=?Cn z0in%+E>LA&NZ3Na7OAk6ge?Yai3(dq*iyijsj$_Aoe$V@6}E=36@XpPwZSK#MTA}m z=t@=QTEbQVwpxX)BWw*|7pbs|30n);I+ciPmJoU|pqHpJ*AuoLunj6~17RBhyHrJD zStFsF0Nt$0yp*slfNfP_n+UrMu;-|-&3((wD~?77)iXZ!Ogz6%`!0*)4&6gBxzGcO zc1yo)aJv#TIMUxQndRCjmc;9W+gJ|2KDeEk`5%JISOqugS7=Kidk){IPb;f~Cus?S z3IOfqFbVZlszgwQpc;S@Y_;|bbz7}c4L_nU%A)+Wn2LoYqOlmC=6OII|Cj!99VN`; zdqZAj#e79*2Jp}yIuGDAp+x}8P9^cTLJP(gK&ei5{W`z(>up<{F3HyIlKj<5?5qYN zNXQus;HrS>n1t)}g0mlMUA%e0eh>%-roZ)y}^)o_Leq75eY#tpEjHq zOf78=;B9|S9#aO6_uMEpvfE^|kAwcB6jcc|J z4cblZBLiFP(kDhhL3VGlWX6oS{BYRyY%wnhPh~ByI9YxL41X^M#48 z7>H3D<}!0#(ybNLxQ+0BL?ihcnhgjgq;*|eo3(TKsupvgNXx@=`TVVyvUow3^-3ui`&R{-1;^ z0d*uux*Q7;Id{#=QdUN)$Yb*P{N9wNFlrXoVP?SK7b%$uzPbVh9f9D5^ZIr9Sa27= zd!CfM86Qa=-U5Qx%egtVB2whUJnrKCsfR^_sX=C*m{vQRBQx(#YZrulG^q-AnY}M3 z@ekAXuy46Ny>iR~)D^AE;p(>^VT!I7RoW@7!#YV{ju__X3zpCiNj-MSG1wTT<|sR)IT-+xMAo8@r)4BC zEw9X|Vs(7&coM%l!(~+G;=!4@OwUUI)*W4!*{)&t^PgsCu?P68*`}-~fRv)MxrPQD z-O>Z_BIywmI5@OZ3EFM9NzxEEJBJL{ELE#B^%{2~*p;>(NLS)Kg-7BKTq6#aws@_ULGgME0nxP;YnV@VrvxQ;(s z(wz2pBy8xhJG&*vFcO|gFS4|Ot>J4+v)x5rFI|>PMZzx}vGqH8-P|~5ltPlp+*+2* zJIeCdIw+oGteniMJQ-0np|6rqJnJ%!uyz=QL`%QLJ_z!5&7{KP<}YF!`I`AAs{361 zS3nnw@VDlt%~lyDo0pbn^UU)3%*s2;^S-CO}Pl0#4}yB4ckHyJl=EB6~6-`(#A!gg#et6CSU4K8iu!8Kn5W z1uNKAesaM=(8`d75lr&~Xnf_osAquS%$& z8LJQ0x_d>`lpC^q5cGA2!@g7UUm6%tVtHdjEN3+t>)dT!KQ{%&4W&8`Q+zFc*w+H)L|z2_`EaeHmKVL!Gr8$lhvxAudE5PUQs2t$BF z)vS|#Kmmp$0;f|6opy;jC;)LYhSO_xi`XqZXz{sjW+xi96-8q%5)9Hi5-9n4WiElx zC9UjUzII84k3de+3<6Tc>{@}U63189$ML20nPAt}E=lHN^+`Sorqmyy!0Q@X*+W$D z?gn+ij2|m_e&Z6|qmb##0=~97jvsAQmoEIV(!)!a7@ovT^$3LSo>=-yG)bKF79|7@ zOfP?^X}K`0EBH4}8I?}~-ie;Xo`4UM)25P(CzFf!MSPHv^FaFH^r?)h$&9M!tDZMc zWK>OLv`uAnOlEYP%;@AD&4uOY>j263;YZl|cku}gsClEmxeOe*$C@kIeEv~$8q7c1 zmV%9MVu`m9OdyCw@EL+H04PSMt=}f?wo10aKD+caqV(%J+Jw6lh1sxfB?R3>f&4up z|AXKGer=11-Npa6aSO$+cUW#)Jyf-(dP5LB^c02kroVg!u{Rv(V>L}ueFN{lwq>@SA)MO}`;4Ya$Y4f~kNHq~kmQ%@8N==)QnhYh& z>$w1bG@l~P}DS&ZoW4W z>1K$g%#XB~A#|bZXcqESL*gdTTdJT&D`ios_@J(kj%NNsMNE1j6o)RMtVQ0e#Wgx9 z8+coR@<{Temw3}lYhc0SAr&l0rTPe!c{9$3I`ifbD)*)%q5N5dQhbCeycv;@88T+X zCV2S)pG7p+f@BIkWb}d`6`6(Jj7X+v7MV(KDv~J{sVSaDSItN*fwpWyp~O^om04;? zn->Ai`GD_1yrI@oXITvWQ$g$`jKz7yyTn6@gkA6dZGnbT8)l?>-`uH^V4W?4NW zq0w7l%?$ifFMjO|d=ufDp@NPX_!h!f1Ku(N-%9vpUDaA(1yaX4XC$BFEw*k3emUV+ zbX9i&Nq5n6XXIGvExCRMzRg{ss;s;Gd6;j^E0R@S0vl!!X!qjJpMhWP#c!N}U*pAh z&A_h(`~|A6>45i5$z#60vjD=X%FwwkU;(pi1|oWCoyC?MAe{?2wy1KfhxcyP`+1fg z$h85mb`|z~c<)ucZ-nXcSH#|<=vr+MR_k%c4E27UgBoZ$6lkEp}I(-bx~7a7xc*tglcUH=!B^o>nTEo(Y~DSb!Fik2pL z^2?S^R3cpg^syq1GmYmBq=$OKn)`g5CHOdN6tROrSLg0wyWhR-23u#=l zrfqpsV|%9(0iD~7hfN)0oO`Eq@NX``X74$0{< zwc7^lN8DjSBrzJ&W^>McBgYx4{&6_?krVw z9rWb!S&?SzXjlht8H%t`ppR;aSu^Ca54s$-ev`MaM#IGnt{@Uor7*%%%ZJfr5Y*36705J;a*9Wtg^dojy9NsNyC*;Z;Dfj1r?MI zB4rDL|7yXgegxjYr*tpA{v-e_n!f=cc20f*Rbb`LYRG(5z}pFlx25n)_Zi<#OFv}1 zbBI58ZZfZTYQs-y17kw33wG~H@T;$C)6|CY-(FQS7TONM>qwi!Y8$lm?{+$zR)}NS zVB}y3Qo0YDa6bSiE_h5BhJ;@w#A$a~DYC1?cpF^eMRp=b(U1{BT>60A!YO^~q(1ew z?i2b<;s8Tn?ms@ZcQJq5l~yG^2uLNc&ps!1COwQPalU%Q03bd$@z@cH9PV8SR3ev# z&1i-62$Y-=Z-H8+Cy<=VZp|pNg-;W~??zs#!i*BeD&~;*e@H^b(SXg>z18aMhShej zhOfFj#W3*rT3;{{m|L?Wg74nh1Hl>~hp~t`G>;?AR9`DGitMxw^;+$_U?J|X4|dzP z^TQXV@a04G%r=YW;D(`f?b^kVx_K*jGc$%j znb5bWD)@M`f2IEl1{{M{$$sGo1mOn2VmYk?c9*KY5`XEJKtuA};y@W^UF^v5M_u|A zio|CDOZf1`@$51Z!;tlRCocf#g{ z^J*eic}#OMqIN=Gt7`JLOV2mI3T6Fw{BMwIx|b^d{L*-cOk)2mJ~lE#e2=@r`PE~# zYQM})BAIz!G9?ghhajuNX6@c;@7~_;aJo1W<@I|!>Vxw7>7I^w|EzK2-V}q6tr8x! zH%@b#(C%%I=bVhFp3qlQBacvUZH_`uBSXnPhG2)dZv-4YYj6JuX!Gv7ft~b_%sCqP z+sl@f`E4P&6l!||red(qwbl7kwkRdoWc9i6|3sbPV!GkQZ?Wp90c~!WgUc_AlOaWVSkilf5Vk(*?)QEYTMaK{sl>@ zlufdC4-LS$0P_r+C`bS6xSOGb^D}w+`ifAqrd|3o3{P~kY5aFLHHGRB6eT1 zdqSTG+Jjqf!~4%2I|AH67T`|N3Hz=DIUS=jceNm034<8|L<>9Hn5?$rGZJ3F`sRuH z3=uch-dGFU@^bA>wNo)!lQCHn#;oaxNZ(|{^@>rhy_EIvudc~$egVo+EEM)^X&QV9 z5icWn4S+KU0L+DZL@e(`cvDNxY1?7{m4ab35^yEMU*362#E zIvv=XCf5+Tqc9a~hPQ(w^DT5f)g}ELYo$pD-3cY!(%HDI#cF9;yRN0ZsRew$UOure zzK*8lSQLS62Y8aUEhr#dRyoxc)L;4=8lHX41~j2JUB}w_;se?1sLo$t#S+%3?M44} zPLGE=N45+&oKE0hJ(_&j-(nx2m$oP1!-!P=^8+9dv1#_r0avGl+5~=v7IG;e(C5QL zgD?b@KpPw(nXR3A%%mFn-t5y@2YQd7SB40#xjD=E0y$?>Rg5U%0lVD=8`(}J-bY@I z|0C8Rb^u{4Ohx2RM&wUL6ir4Hos1}%(3hZ5h3#F-En{Oa9@?clQBXlrI95p8NI~$V z7)OVO20V449$jw7)g2NgX#jm)%hK2KIZ?BHf z2Slb%!M1XyduMr*DT$}uv16?L6ATo=fDHz>WCTPDOzDyFV+hdUS3;2I9(%vbHb+k) z&{qT@q2l^Czs z;UtQCgVn56f}!y?mrIiJp~*t7s6bK@0`;)q7vGgwsPZ6CFT8}%-L~t1moF=!Z7`w@ z(fp;m%6&YH1xR^75f*c1Bs{Y+CEP~{n>?dllohGy%slG;o5>1NFaj%55!QsueOOWO zo%3UO&%r12q*E~7%|S}&R&XSHh;Oj~6N=Ph+wCN?Ie1UfQqr9$q-S*vS+M}*-|bXS z<#Q6Fu|qJ#BTUYl_be0<2VCEoyZr`)pakqWb=r5inzjx(x?#`hBD6`DA=rliqZQJ3 z2>Kx_#T;m3Ny2)>87Vua(v}w*WwELJF+P35#(;V#MG$CrP|?~knm~uIq|J~;3ATyg z0i1z?nNw^bp2o6Wm>LO-KKGgmPMlznD;_Yzp0I({JHC~0k#2<|o~&QJ{W=uS-HYwnFtI2ZW{9qR41OQOT_kh^kz$-O4_ zAU}9-@mMb+`Viorr4k6%S_($t96?+^0$eB3>P~tN6KIj9?d>13xg=a%NxwkwJc5rf zopu9pSs>v^me9GE&=;3<_=@hd^g9IT$4X=9d`jp!O6coJ=$#2ih-Q~B@%2S4_X9rR zoF?I%B~G?Q;8QGuGz8fQ3J{bczyQ8j;SXThK>+Gq&Mc&wwG1F~=zJZx``1+gXEa)E z_I}+|WaeaK=9vI^ITH^1p8L<8ip!gf%R3VQFK6@`ZSns2si>^Us4T=3pV9OAKaK4# zpN^V$Ti--v?sR6NdC@y>U7^Q7*ZuWI8VY?YQ{; zJNSm_u&C+q=+j}rM%|v~Q;`9NsHxDD6QL>7`e<+749q*i&jX4E-JYgXMqaY1cnqi- zQ%@LE54nzK99#Bu!KATePs=o7=bbRldr)&c>R8p&$&m`e5h_QO{SulrkB%YESEQLh4jP!HI-|>BzXljfaL#W>=mL z)FkRAf@0xlO-6z*_)X!{G4Xu;{mW{I zjl2^HdDC%u(~+^$(M@hi8FYe|$N*s9n0>;SJyCwnM2BV4*a;1dP3G_3KT=8(8#@`7 z2ZD{$?OFA8v`!m(Ism|_$RKUuZI)93@bh5I8T>gF%bgGGO$yYuvP0cp2H@|PV$&NR zPlfZa2X`?ezv{uQ06%~5-1tCk-Jw9tSm(`GcPf;xekNrs$&F#!BoK!#{In)e8|BRv z_0P~iZSEJLI&CF%HKsD{DoffIG?o##uGT7zNqJ3K#Yc->*%o$nc5tK+oe1B z#SdrX(`f*2uF=pY$qw^ik7>(pD7_iVh`^@z$a5`EdL)76@%fJwX5)m=F5$^wiv0;@ zamEp`I}42aJih0VOwVyeTnoW6E7k3`-iorCH!cfOn(K1n-%u_Y6yx zl-ZYU&QT2KgTuQ@_|}e6e(1@2;Dlqt(Z;dskp4poFt9;q>YBUX_=NcgQIvkX3s^?h5*+{{|<#}B$ufZy`x*PEA-7H7&{mmge5n{ zy074O|Ew_bc1pwDD2zHug?##FZ52n{s!S1W%8YIWEF^t3`GVb)o=+<=I0Sf8d5V0` zA{J^b4hjg!4iL*S4V>VmCeXw>M+t#nB}Ua9CGOQzH;N|;QuhSl>IwAnu5;jE&4d7h=6<9K%-jk_R0hVB2=u;2@fNH7<2y-FG1u zG|q8VDSWLR__Q0r#Ry=NVvdp9fC&gnQT!7}_db0SOV0U1$F!N=DI(uHMdb5U&zOak z*r1WuFqX(~dFFG?jzE6ZvrE|l{@Sx?(QA<69;gcZcT(cLc+=#3Zh015oEP$3Zh@VR`CjmE>Wh0ot#fgLbI(U1Imme>s=! z=cO;l$N6&kjLYTxd`Q0uw$g6bJqqoR?&PLlW-YQHnS%h75LjElbsC6?ihhy~VG^3} z*%Rd5!0@Ai{E}bRd!+B4Ay?u^l|IGsr5@{uq%MesV~+!!_JLuHd3ZWt9GLmiH)nbx zkRl)jksT8ae+WOqAi`6D0kg^Tr(Y@8{5+6<{z|GiRG<5+H?l<`=^m^bOF;ZmQ&L7- zf8G!bkAJmZ^K2kr|7vyoPp~KEnw0xqE!R8~$lrd|y?ZTYvqZld?9-5btd*98q#?d_ z;Uk!UD?f3_)Hi$6yp4-K=^!%V)0&^+>%#y{en*3h$p}DC4!-u3=nt8%{BshF(sTGd zFE;V-e{I#g5Xh~sS7&1*JEhyO%tgS-XcTUJn}$TRh{P-1&YynW3~KV1*Dumc2J$U$ zWUZh!AMt;p;xIPIH)_v1V4r|Gf!tef%=4(s={For7+k&&C>+r!dY4-*{6?{b`b44n zz--*-)t%o5@;lxvz*q;w<=%U9`}`-dZ86ByYz6!pgsVjOD0KJRZG&$I^3mVS=-SvH z>00VCk~($mF|3cwKXok{cZqnQ1$-DT*CEycR~!c69Eh)1kr`iq#;XR{V@MDIblJkU zt`^dI3_JAbAKnVpXx3lAOUn7#JCH?TDH*;jB1z~;SsW| zhwP&Uj4_zj4a35_2XIq-=ZSS=I6%}Y5z}VdV(+JzAchcWFIl=BYa@3l3eS*uG=>tv z-6n@A54pgj0Hcv>`fc6ztwa5AZ^(rFNS6V+bu@MTRy)uHoQJT=Z`hf`DZ;;8i5KJPNK=1~Dc60h! z^-?jkE^ldTwsvk<(_&H0P^3wuq{JU7>2HzXuK~b8YhNcxU1J~$1rD &Zo%C_;^K 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 bzh~OVH{T4K)lug#7QN5)&j9?T8SwuDP#zj} diff --git a/recruitment/__pycache__/serializers.cpython-313.pyc b/recruitment/__pycache__/serializers.cpython-313.pyc index 72958ae5b949e7115dfed36c903260f1693d039b..25faf100ad84f87cd82fcf4682889fe6649a2819 100644 GIT binary patch delta 103 zcmcb~b)Sp(GcPX}0}!0x7S3ESk#{!_w_`y;PG)jqNoIcD#!GQbJfcVfldYIDd8Cj9 m)-vZaN^h2B5n^Q2o@~G>A?STw#{8m;`Bw&3M(H9kpveHe1Rodx delta 119 zcmcc5b(4$tGcPX}0}w24y_Yd-BJXZ4PUpnDl+2XGlGKe?;+VLE;oQk~%$Zyg2<~R) zd`8L1F-#JZtyz9)X?|g0W|W>`eO=n@qO{o;1|a7P7b{Ri`?{3rMJdy-3_wni7|7fa5Pr|`Z@lXOSvy%fF-}Mb1|<+dM1H904^5+_LKI7=RF=#VKaA0`>&~u8 zX(bX`F7yD3o#SU z7Sva&+R5coMIGHmB*-~cp zt=1yaBH?DX9IhpUigFn$F`bQpwXdc@pUh1i7(tTG%POy zqAm>A0bRZheV!U5Hs~0agcq&|Q@A1@f~5Q=d@XbcsqIZAayPJd1VxA-*hr2j!DkU+4&4-;u9l$N5Bap)=dTb$LNO89qw(@57@fg!pN6DSCj5y@K~l^covfrF^@l8b=lh;l`oGa9B_c3h(>94I39Q8U@~cQ8Ip zzB2@$%AMLcKP^AeX1Bc+@xHb%=SS2m+pV2zH?-UeUxD)&V5#|1+s*le)KmBPDfw$^ zZoi3YhZX>9NA!BD!V*9Ufninbi{-lEI7>EE0FajQaL-|EirsUYFP}jL%#u^A;RqbKs(x7hA;gY85OrABHpXNhY&QrNA7)C4{{C z|4MxRO(n2IVSzF@HkzJAfA5Uo|jDq8;()4Ae@X z%+?stE3M^-9=T{&8m1_~P5D`NJZ^3<^ABb|V`G2HTiL0^!Xv(CGZ5jOuR2&DFP~-Y T-q}Zd*JeQBy`&oQB|qRlhp{Br delta 1249 zcmZuwOKcNY6n)R}-yV~Yi9bVP3?T__%eSFbC=Du|eCNA}AQ#Tkb?;}ELQ zu;_+Le8N-39#llsMI{0ftXQ%@s)B?RG=c>ZLV{fssoVD6u>pfRi=*@IIdAS~?ko+y z98{JSCCc!*__A&E;jEJ3)3bfoR_0iN72@Az4r-v~Sdk5XAt#b*qS(2OlbJE-Dr&R; z^zf|g9xWS=kcWKh=cj$OmP{AA+^{94935(w z8v-#Q49AAFj0R69*O2PBjY;7K%c3G2kq>|>XM?{6K1Zo_UJL!<`=l2sgb?z6P>F9O z*hH`y!S*BM@PV!Jb)+-!6&`oUXmp78&!3EDTX+^BZwnVMZ`Z$>MEtG7!>vdsPO@L&oJ@#dMyKQpTWKV$TF z`6%-nR(M=9`Alj^oXM5mE>*6cy1YFHHYJF>Q}RKeX58q=DA%(NN%`w>aVf6v0Fdf>%MpDfpy{b; diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index 719d7d9758416af49394678535c06fcc1eb26a6f..9aacf1e84888940c0ee71e7f984afb1adbd0f869 100644 GIT binary patch delta 43525 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 diff --git a/recruitment/__pycache__/views_frontend.cpython-313.pyc b/recruitment/__pycache__/views_frontend.cpython-313.pyc index 43e05b354ac6c8d09faf0254b11a2133f492bee5..13c3823e63de05eac695811380ebe0da54876cd9 100644 GIT binary patch delta 11800 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 F_5TNND;od+ delta 11279 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 BdZYjV diff --git a/recruitment/admin.py b/recruitment/admin.py index 1a35903..245a14d 100644 --- a/recruitment/admin.py +++ b/recruitment/admin.py @@ -3,7 +3,7 @@ from django.utils.html import format_html from django.urls import reverse from django.utils import timezone from .models import ( - JobPosting, Candidate, TrainingMaterial, ZoomMeeting, + JobPosting, Application, TrainingMaterial, ZoomMeeting, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment, AgencyAccessLink, AgencyJobAssignment @@ -138,43 +138,6 @@ class JobPostingAdmin(admin.ModelAdmin): mark_as_closed.short_description = 'Mark selected jobs as closed' -@admin.register(Candidate) -class CandidateAdmin(admin.ModelAdmin): - list_display = ['full_name', 'job', 'email', 'phone', 'stage', 'applied','is_resume_parsed', 'created_at'] - list_filter = ['stage', 'applied', 'created_at', 'job__department'] - search_fields = ['first_name', 'last_name', 'email', 'phone'] - readonly_fields = ['slug', 'created_at', 'updated_at'] - fieldsets = ( - ('Personal Information', { - 'fields': ('first_name', 'last_name', 'email', 'phone', 'resume','user') - }), - ('Application Details', { - 'fields': ('job', 'applied', 'stage','is_resume_parsed') - }), - ('Interview Process', { - 'fields': ('exam_date', 'exam_status', 'interview_date', 'interview_status', 'offer_date', 'offer_status', 'join_date') - }), - ('Scoring', { - 'fields': ('ai_analysis_data',) - }), - ('Additional Information', { - 'fields': ('created_at', 'updated_at') - }), - ) - save_on_top = True - actions = ['mark_as_applied', 'mark_as_not_applied'] - - def mark_as_applied(self, request, queryset): - updated = queryset.update(applied=True) - self.message_user(request, f'{updated} candidates marked as applied.') - mark_as_applied.short_description = 'Mark selected candidates as applied' - - def mark_as_not_applied(self, request, queryset): - updated = queryset.update(applied=False) - self.message_user(request, f'{updated} candidates marked as not applied.') - mark_as_not_applied.short_description = 'Mark selected candidates as not applied' - - @admin.register(TrainingMaterial) class TrainingMaterialAdmin(admin.ModelAdmin): list_display = ['title', 'created_by', 'created_at'] @@ -275,6 +238,7 @@ class FormSubmissionAdmin(admin.ModelAdmin): # Register other models admin.site.register(FormStage) +admin.site.register(Application) admin.site.register(FormField) admin.site.register(FieldResponse) admin.site.register(InterviewSchedule) diff --git a/recruitment/decorators.py b/recruitment/decorators.py index b929bf2..06e68b1 100644 --- a/recruitment/decorators.py +++ b/recruitment/decorators.py @@ -1,17 +1,163 @@ from functools import wraps from datetime import date from django.shortcuts import redirect, get_object_or_404 -from django.http import HttpResponseNotFound +from django.http import HttpResponseNotFound, HttpResponseForbidden +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import AccessMixin +from django.core.exceptions import PermissionDenied +from django.contrib import messages def job_not_expired(view_func): @wraps(view_func) def _wrapped_view(request, job_id, *args, **kwargs): - + from .models import JobPosting job = get_object_or_404(JobPosting, pk=job_id) if job.expiration_date and job.application_deadline< date.today(): return redirect('expired_job_page') - + return view_func(request, job_id, *args, **kwargs) - return _wrapped_view \ No newline at end of file + return _wrapped_view + + +def user_type_required(allowed_types=None, login_url=None): + """ + Decorator to restrict view access based on user type. + + Args: + allowed_types (list): List of allowed user types ['staff', 'agency', 'candidate'] + login_url (str): URL to redirect to if user is not authenticated + """ + if allowed_types is None: + allowed_types = ['staff'] + + def decorator(view_func): + @wraps(view_func) + @login_required(login_url=login_url) + def _wrapped_view(request, *args, **kwargs): + user = request.user + + # Check if user has user_type attribute + if not hasattr(user, 'user_type') or not user.user_type: + messages.error(request, "User type not specified. Please contact administrator.") + return redirect('portal_login') + + # Check if user type is allowed + if user.user_type not in allowed_types: + # Log unauthorized access attempt + messages.error( + request, + f"Access denied. This page is restricted to {', '.join(allowed_types)} users." + ) + + # Redirect based on user type + if user.user_type == 'agency': + return redirect('agency_portal_dashboard') + elif user.user_type == 'candidate': + return redirect('candidate_portal_dashboard') + else: + return redirect('dashboard') + + return view_func(request, *args, **kwargs) + return _wrapped_view + return decorator + + +class UserTypeRequiredMixin(AccessMixin): + """ + Mixin for class-based views to restrict access based on user type. + """ + allowed_user_types = ['staff'] # Default to staff only + login_url = '/login/' + + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return self.handle_no_permission() + + # Check if user has user_type attribute + if not hasattr(request.user, 'user_type') or not request.user.user_type: + messages.error(request, "User type not specified. Please contact administrator.") + return redirect('portal_login') + + # Check if user type is allowed + if request.user.user_type not in self.allowed_user_types: + # Log unauthorized access attempt + messages.error( + request, + f"Access denied. This page is restricted to {', '.join(self.allowed_user_types)} users." + ) + + # Redirect based on user type + if request.user.user_type == 'agency': + return redirect('agency_portal_dashboard') + elif request.user.user_type == 'candidate': + return redirect('candidate_portal_dashboard') + else: + return redirect('dashboard') + + return super().dispatch(request, *args, **kwargs) + + def handle_no_permission(self): + if self.request.user.is_authenticated: + # User is authenticated but doesn't have permission + messages.error( + self.request, + f"Access denied. This page is restricted to {', '.join(self.allowed_user_types)} users." + ) + return redirect('dashboard') + else: + # User is not authenticated + return super().handle_no_permission() + + +class StaffRequiredMixin(UserTypeRequiredMixin): + """Mixin to restrict access to staff users only.""" + allowed_user_types = ['staff'] + + +class AgencyRequiredMixin(UserTypeRequiredMixin): + """Mixin to restrict access to agency users only.""" + allowed_user_types = ['agency'] + login_url = '/portal/login/' + + +class CandidateRequiredMixin(UserTypeRequiredMixin): + """Mixin to restrict access to candidate users only.""" + allowed_user_types = ['candidate'] + login_url = '/portal/login/' + + +class StaffOrAgencyRequiredMixin(UserTypeRequiredMixin): + """Mixin to restrict access to staff and agency users.""" + allowed_user_types = ['staff', 'agency'] + + +class StaffOrCandidateRequiredMixin(UserTypeRequiredMixin): + """Mixin to restrict access to staff and candidate users.""" + allowed_user_types = ['staff', 'candidate'] + + +def agency_user_required(view_func): + """Decorator to restrict view to agency users only.""" + return user_type_required(['agency'], login_url='/portal/login/')(view_func) + + +def candidate_user_required(view_func): + """Decorator to restrict view to candidate users only.""" + return user_type_required(['candidate'], login_url='/portal/login/')(view_func) + + +def staff_user_required(view_func): + """Decorator to restrict view to staff users only.""" + return user_type_required(['staff'])(view_func) + + +def staff_or_agency_required(view_func): + """Decorator to restrict view to staff and agency users.""" + return user_type_required(['staff', 'agency'], login_url='/portal/login/')(view_func) + + +def staff_or_candidate_required(view_func): + """Decorator to restrict view to staff and candidate users.""" + return user_type_required(['staff', 'candidate'], login_url='/portal/login/')(view_func) diff --git a/recruitment/forms.py b/recruitment/forms.py index 80bb4e1..d64e2f7 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -11,7 +11,7 @@ User = get_user_model() import re from .models import ( ZoomMeeting, - Candidate, + Application, TrainingMaterial, JobPosting, FormTemplate, @@ -27,6 +27,7 @@ from .models import ( AgencyAccessLink, Participants, Message, + Person ) # from django_summernote.widgets import SummernoteWidget @@ -262,42 +263,38 @@ class SourceAdvancedForm(forms.ModelForm): return cleaned_data -class CandidateForm(forms.ModelForm): +class PersonForm(forms.ModelForm): class Meta: - model = Candidate + model = Person + fields = ["first_name","middle_name", "last_name", "email", "phone","date_of_birth","nationality","address","gender"] + widgets = { + "first_name": forms.TextInput(attrs={'class': 'form-control'}), + "middle_name": forms.TextInput(attrs={'class': 'form-control'}), + "last_name": forms.TextInput(attrs={'class': 'form-control'}), + "email": forms.EmailInput(attrs={'class': 'form-control'}), + "phone": forms.TextInput(attrs={'class': 'form-control'}), + "gender": forms.Select(attrs={'class': 'form-control'}), + "date_of_birth": forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), + "nationality": forms.Select(attrs={'class': 'form-control select2'}), + "address": forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + } +class ApplicationForm(forms.ModelForm): + + class Meta: + model = Application fields = [ + 'person', "job", - "first_name", - "last_name", - "phone", - "email", "hiring_source", "hiring_agency", "resume", ] labels = { - "first_name": _("First Name"), - "last_name": _("Last Name"), - "phone": _("Phone"), - "email": _("Email"), "resume": _("Resume"), "hiring_source": _("Hiring Type"), "hiring_agency": _("Hiring Agency"), } widgets = { - "first_name": forms.TextInput( - attrs={"class": "form-control", "placeholder": _("Enter first name")} - ), - "last_name": forms.TextInput( - attrs={"class": "form-control", "placeholder": _("Enter last name")} - ), - "phone": forms.TextInput( - attrs={"class": "form-control", "placeholder": _("Enter phone number")} - ), - "email": forms.EmailInput( - attrs={"class": "form-control", "placeholder": _("Enter email")} - ), - "stage": forms.Select(attrs={"class": "form-select"}), "hiring_source": forms.Select(attrs={"class": "form-select"}), "hiring_agency": forms.Select(attrs={"class": "form-select"}), } @@ -317,23 +314,43 @@ class CandidateForm(forms.ModelForm): self.helper.layout = Layout( Field("job", css_class="form-control"), - Field("first_name", css_class="form-control"), - Field("last_name", css_class="form-control"), - Field("phone", css_class="form-control"), - Field("email", css_class="form-control"), - Field("stage", css_class="form-control"), Field("hiring_source", css_class="form-control"), Field("hiring_agency", css_class="form-control"), Field("resume", css_class="form-control"), Submit("submit", _("Submit"), css_class="btn btn-primary"), ) + # def save(self, commit=True): + # """Override save to handle person creation/update""" + # instance = super().save(commit=False) -class CandidateStageForm(forms.ModelForm): + # # Get or create person + # if instance.person: + # person = instance.person + # else: + # # Create new person + # from .models import Person + # person = Person() + + # # Update person fields + # person.first_name = self.cleaned_data['first_name'] + # person.last_name = self.cleaned_data['last_name'] + # person.email = self.cleaned_data['email'] + # person.phone = self.cleaned_data['phone'] + + # if commit: + # person.save() + # instance.person = person + # instance.save() + + # return instance + + +class ApplicationStageForm(forms.ModelForm): """Form specifically for updating candidate stage with validation""" class Meta: - model = Candidate + model = Application fields = ["stage"] labels = { "stage": _("New Application Stage"), @@ -648,8 +665,8 @@ BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True) class InterviewScheduleForm(forms.ModelForm): - candidates = forms.ModelMultipleChoiceField( - queryset=Candidate.objects.none(), + applications = forms.ModelMultipleChoiceField( + queryset=Application.objects.none(), widget=forms.CheckboxSelectMultiple, required=True, ) @@ -670,7 +687,7 @@ class InterviewScheduleForm(forms.ModelForm): class Meta: model = InterviewSchedule fields = [ - "candidates", + "applications", "start_date", "end_date", "working_days", @@ -706,7 +723,7 @@ class InterviewScheduleForm(forms.ModelForm): def __init__(self, slug, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["candidates"].queryset = Candidate.objects.filter( + self.fields["applications"].queryset = Application.objects.filter( job__slug=slug, stage="Interview" ) @@ -750,7 +767,7 @@ class MeetingCommentForm(forms.ModelForm): class InterviewForm(forms.ModelForm): class Meta: model = ScheduledInterview - fields = ["job", "candidate"] + fields = ["job", "application"] class ProfileImageUploadForm(forms.ModelForm): @@ -832,7 +849,7 @@ class FormTemplateIsActiveForm(forms.ModelForm): class CandidateExamDateForm(forms.ModelForm): class Meta: - model = Candidate + model = Application fields = ["exam_date"] widgets = { "exam_date": forms.DateTimeInput( @@ -1164,41 +1181,50 @@ class AgencyAccessLinkForm(forms.ModelForm): # Agency messaging forms removed - AgencyMessage model has been deleted -class AgencyCandidateSubmissionForm(forms.ModelForm): +class AgencyApplicationSubmissionForm(forms.ModelForm): """Form for agencies to submit candidates (simplified - resume + basic info)""" + # Person fields for creating/updating person + # first_name = forms.CharField( + # max_length=255, + # widget=forms.TextInput(attrs={ + # "class": "form-control", + # "placeholder": "First Name", + # "required": True, + # }), + # label=_("First Name") + # ) + # last_name = forms.CharField( + # max_length=255, + # widget=forms.TextInput(attrs={ + # "class": "form-control", + # "placeholder": "Last Name", + # "required": True, + # }), + # label=_("Last Name") + # ) + # email = forms.EmailField( + # widget=forms.EmailInput(attrs={ + # "class": "form-control", + # "placeholder": "email@example.com", + # "required": True, + # }), + # label=_("Email Address") + # ) + # phone = forms.CharField( + # max_length=20, + # widget=forms.TextInput(attrs={ + # "class": "form-control", + # "placeholder": "+966 50 123 4567", + # "required": True, + # }), + # label=_("Phone Number") + # ) + class Meta: - model = Candidate - fields = ["first_name", "last_name", "email", "phone", "resume"] + model = Application + fields = ["person","resume"] widgets = { - "first_name": forms.TextInput( - attrs={ - "class": "form-control", - "placeholder": "First Name", - "required": True, - } - ), - "last_name": forms.TextInput( - attrs={ - "class": "form-control", - "placeholder": "Last Name", - "required": True, - } - ), - "email": forms.EmailInput( - attrs={ - "class": "form-control", - "placeholder": "email@example.com", - "required": True, - } - ), - "phone": forms.TextInput( - attrs={ - "class": "form-control", - "placeholder": "+966 50 123 4567", - "required": True, - } - ), "resume": forms.FileInput( attrs={ "class": "form-control", @@ -1208,10 +1234,6 @@ class AgencyCandidateSubmissionForm(forms.ModelForm): ), } labels = { - "first_name": _("First Name"), - "last_name": _("Last Name"), - "email": _("Email Address"), - "phone": _("Phone Number"), "resume": _("Resume"), } @@ -1223,39 +1245,46 @@ class AgencyCandidateSubmissionForm(forms.ModelForm): self.helper.form_class = "g-3" self.helper.enctype = "multipart/form-data" - self.helper.layout = Layout( - Row( - Column("first_name", css_class="col-md-6"), - Column("last_name", css_class="col-md-6"), - css_class="g-3 mb-3", - ), - Row( - Column("email", css_class="col-md-6"), - Column("phone", css_class="col-md-6"), - css_class="g-3 mb-3", - ), - Field("resume", css_class="form-control"), - Div( - Submit( - "submit", _("Submit Candidate"), css_class="btn btn-main-action" - ), - css_class="col-12 mt-4", - ), - ) + # self.helper.layout = Layout( + # Row( + # Column("first_name", css_class="col-md-6"), + # Column("last_name", css_class="col-md-6"), + # css_class="g-3 mb-3", + # ), + # Row( + # Column("email", css_class="col-md-6"), + # Column("phone", css_class="col-md-6"), + # css_class="g-3 mb-3", + # ), + # Field("resume", css_class="form-control"), + # Div( + # Submit( + # "submit", _("Submit Candidate"), css_class="btn btn-main-action" + # ), + # css_class="col-12 mt-4", + # ), + # ) def clean_email(self): """Validate email format and check for duplicates in the same job""" email = self.cleaned_data.get("email") if email: - # Check if candidate with this email already exists for this job - existing_candidate = Candidate.objects.filter( - email=email.lower().strip(), job=self.assignment.job + # Check if person with this email already exists for this job + from .models import Person + existing_person = Person.objects.filter( + email=email.lower().strip() ).first() - if existing_candidate: - raise ValidationError( - f"A candidate with this email has already applied for {self.assignment.job.title}." - ) + if existing_person: + # Check if this person already has an application for this job + existing_application = Application.objects.filter( + person=existing_person, job=self.assignment.job + ).first() + + if existing_application: + raise ValidationError( + f"A candidate with this email has already applied for {self.assignment.job.title}." + ) return email.lower().strip() if email else email def clean_resume(self): @@ -1277,11 +1306,30 @@ class AgencyCandidateSubmissionForm(forms.ModelForm): """Override save to set additional fields""" instance = super().save(commit=False) + # Create or get person + from .models import Person + person, created = Person.objects.get_or_create( + email=self.cleaned_data['email'].lower().strip(), + defaults={ + 'first_name': self.cleaned_data['first_name'], + 'last_name': self.cleaned_data['last_name'], + 'phone': self.cleaned_data['phone'], + } + ) + + if not created: + # Update existing person with new info + person.first_name = self.cleaned_data['first_name'] + person.last_name = self.cleaned_data['last_name'] + person.phone = self.cleaned_data['phone'] + person.save() + # Set required fields for agency submission + instance.person = person instance.job = self.assignment.job instance.hiring_agency = self.assignment.agency - instance.stage = Candidate.Stage.APPLIED - instance.applicant_status = Candidate.ApplicantType.CANDIDATE + instance.stage = Application.Stage.APPLIED + instance.applicant_status = Application.ApplicantType.CANDIDATE instance.applied = True if commit: @@ -1586,7 +1634,7 @@ class CandidateEmailForm(forms.Form): return list(set(email_addresses)) # Remove duplicates def get_formatted_message(self): - """Get the formatted message with optional additional information""" + """Get formatted message with optional additional information""" message = self.cleaned_data.get("message", "") # Add candidate information if requested @@ -1777,13 +1825,15 @@ class MessageForm(forms.ModelForm): ) class CandidateSignupForm(forms.Form): - first_name = forms.CharField(max_length=30, required=True) - middle_name = forms.CharField(max_length=30, required=False) - last_name = forms.CharField(max_length=30, required=True) - email = forms.EmailField(max_length=254, required=True) - phone = forms.CharField(max_length=30, required=True) - password = forms.CharField(widget=forms.PasswordInput, required=True) - confirm_password = forms.CharField(widget=forms.PasswordInput, required=True) + """Form for candidate signup creating Person and Application""" + + first_name = forms.CharField(max_length=30, required=True, widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "First Name"})) + middle_name = forms.CharField(max_length=30, required=False, widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Middle Name (optional)"})) + last_name = forms.CharField(max_length=30, required=True, widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Last Name"})) + email = forms.EmailField(max_length=254, required=True, widget=forms.EmailInput(attrs={"class": "form-control", "placeholder": "Email Address"})) + phone = forms.CharField(max_length=30, required=True, widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Phone Number"})) + password = forms.CharField(widget=forms.PasswordInput(attrs={"class": "form-control", "placeholder": "Password"}), required=True) + confirm_password = forms.CharField(widget=forms.PasswordInput(attrs={"class": "form-control", "placeholder": "Confirm Password"}), required=True) def clean(self): cleaned_data = super().clean() @@ -1793,4 +1843,43 @@ class CandidateSignupForm(forms.Form): if password != confirm_password: raise forms.ValidationError("Passwords do not match.") - return cleaned_data \ No newline at end of file + return cleaned_data + + def save(self, job): + """Create Person and Application objects""" + from .models import Person, Application + from django.contrib.auth.hashers import make_password + + # Create Person first + person = Person.objects.create( + first_name=self.cleaned_data['first_name'], + middle_name=self.cleaned_data.get('middle_name', ''), + last_name=self.cleaned_data['last_name'], + email=self.cleaned_data['email'], + phone=self.cleaned_data['phone'], + ) + + # Create User account + user = User.objects.create_user( + username=self.cleaned_data['email'], # Use email as username + email=self.cleaned_data['email'], + password=make_password(self.cleaned_data['password']), + first_name=self.cleaned_data['first_name'], + last_name=self.cleaned_data['last_name'], + user_type='candidate' + ) + + # Link User to Person + person.user = user + person.save() + + # Create Application + application = Application.objects.create( + person=person, + job=job, + stage=Application.Stage.APPLIED, + applicant_status=Application.ApplicantType.CANDIDATE, + applied=True, + ) + + return application diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py index bb16241..68d8fc2 100644 --- a/recruitment/migrations/0001_initial.py +++ b/recruitment/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-11-09 15:04 +# Generated by Django 5.2.6 on 2025-11-10 14:13 import django.contrib.auth.models import django.contrib.auth.validators @@ -145,6 +145,51 @@ class Migration(migrations.Migration): ('objects', django.contrib.auth.models.UserManager()), ], ), + migrations.CreateModel( + name='Application', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')), + ('cover_letter', models.FileField(blank=True, null=True, upload_to='cover_letters/', verbose_name='Cover Letter')), + ('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')), + ('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')), + ('applied', models.BooleanField(default=False, verbose_name='Applied')), + ('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired'), ('Rejected', 'Rejected')], db_index=True, default='Applied', max_length=20, verbose_name='Stage')), + ('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=20, null=True, verbose_name='Applicant Status')), + ('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')), + ('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Exam Status')), + ('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')), + ('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Interview Status')), + ('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')), + ('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected'), ('Pending', 'Pending')], max_length=20, null=True, verbose_name='Offer Status')), + ('hired_date', models.DateField(blank=True, null=True, verbose_name='Hired Date')), + ('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')), + ('ai_analysis_data', models.JSONField(blank=True, default=dict, help_text='Full JSON output from the resume scoring model.', null=True, verbose_name='AI Analysis Data')), + ('retry', models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry')), + ('hiring_source', models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source')), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='application_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account')), + ], + options={ + 'verbose_name': 'Application', + 'verbose_name_plural': 'Applications', + }, + ), + migrations.CreateModel( + name='Candidate', + fields=[ + ], + options={ + 'verbose_name': 'Candidate (Legacy)', + 'verbose_name_plural': 'Candidates (Legacy)', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('recruitment.application',), + ), migrations.CreateModel( name='FormField', fields=[ @@ -236,43 +281,10 @@ class Migration(migrations.Migration): 'ordering': ['name'], }, ), - migrations.CreateModel( - name='Candidate', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), - ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), - ('first_name', models.CharField(max_length=255, verbose_name='First Name')), - ('last_name', models.CharField(max_length=255, verbose_name='Last Name')), - ('email', models.EmailField(db_index=True, max_length=254, verbose_name='Email')), - ('phone', models.CharField(max_length=20, verbose_name='Phone')), - ('address', models.TextField(max_length=200, verbose_name='Address')), - ('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')), - ('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')), - ('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')), - ('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')), - ('applied', models.BooleanField(default=False, verbose_name='Applied')), - ('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')), - ('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')), - ('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')), - ('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')), - ('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')), - ('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status')), - ('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')), - ('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')), - ('hired_date', models.DateField(blank=True, null=True, verbose_name='Hired Date')), - ('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')), - ('ai_analysis_data', models.JSONField(blank=True, default=dict, help_text='Full JSON output from the resume scoring model.', null=True, verbose_name='AI Analysis Data')), - ('retry', models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry')), - ('hiring_source', models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source')), - ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='candidate_profile', to=settings.AUTH_USER_MODEL, verbose_name='User')), - ('hiring_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.hiringagency', verbose_name='Hiring Agency')), - ], - options={ - 'verbose_name': 'Candidate', - 'verbose_name_plural': 'Candidates', - }, + migrations.AddField( + model_name='application', + name='hiring_agency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applications', to='recruitment.hiringagency', verbose_name='Hiring Agency'), ), migrations.CreateModel( name='JobPosting', @@ -341,7 +353,7 @@ class Migration(migrations.Migration): ('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')), ('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')), ('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')), - ('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate')), + ('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.application')), ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')), ], @@ -352,9 +364,9 @@ class Migration(migrations.Migration): field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'), ), migrations.AddField( - model_name='candidate', + model_name='application', name='job', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.jobposting', verbose_name='Job'), ), migrations.CreateModel( name='AgencyJobAssignment', @@ -411,6 +423,55 @@ class Migration(migrations.Migration): 'ordering': ['-created_at'], }, ), + migrations.CreateModel( + name='Person', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('first_name', models.CharField(max_length=255, verbose_name='First Name')), + ('last_name', models.CharField(max_length=255, verbose_name='Last Name')), + ('middle_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Middle Name')), + ('email', models.EmailField(db_index=True, help_text='Unique email address for the person', max_length=254, unique=True, verbose_name='Email')), + ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')), + ('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')), + ('gender', models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female'), ('O', 'Other'), ('P', 'Prefer not to say')], max_length=1, null=True, verbose_name='Gender')), + ('nationality', django_countries.fields.CountryField(blank=True, max_length=2, null=True, verbose_name='Nationality')), + ('address', models.TextField(blank=True, null=True, verbose_name='Address')), + ('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')), + ('linkedin_profile', models.URLField(blank=True, null=True, verbose_name='LinkedIn Profile URL')), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='person_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account')), + ], + options={ + 'verbose_name': 'Person', + 'verbose_name_plural': 'People', + }, + ), + migrations.CreateModel( + name='Document', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('file', models.FileField(upload_to='candidate_documents/%Y/%m/', validators=[recruitment.validators.validate_image_size], verbose_name='Document File')), + ('document_type', models.CharField(choices=[('resume', 'Resume'), ('cover_letter', 'Cover Letter'), ('certificate', 'Certificate'), ('id_document', 'ID Document'), ('passport', 'Passport'), ('education', 'Education Document'), ('experience', 'Experience Letter'), ('other', 'Other')], default='other', max_length=20, verbose_name='Document Type')), + ('description', models.CharField(blank=True, max_length=200, verbose_name='Description')), + ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Uploaded By')), + ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='recruitment.person', verbose_name='Person')), + ], + options={ + 'verbose_name': 'Document', + 'verbose_name_plural': 'Documents', + 'ordering': ['-created_at'], + }, + ), + migrations.AddField( + model_name='application', + name='person', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.person', verbose_name='Person'), + ), migrations.CreateModel( name='Profile', fields=[ @@ -490,7 +551,7 @@ class Migration(migrations.Migration): ('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), - ('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')), ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')), ('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')), ('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')), @@ -598,14 +659,6 @@ class Migration(migrations.Migration): model_name='formtemplate', index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'), ), - migrations.AddIndex( - model_name='candidate', - index=models.Index(fields=['stage'], name='recruitment_stage_f1c6eb_idx'), - ), - migrations.AddIndex( - model_name='candidate', - index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'), - ), migrations.AddIndex( model_name='agencyjobassignment', index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'), @@ -642,6 +695,42 @@ class Migration(migrations.Migration): model_name='message', index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'), ), + migrations.AddIndex( + model_name='person', + index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'), + ), + migrations.AddIndex( + model_name='person', + index=models.Index(fields=['first_name', 'last_name'], name='recruitment_first_n_739de5_idx'), + ), + migrations.AddIndex( + model_name='person', + index=models.Index(fields=['created_at'], name='recruitment_created_33495a_idx'), + ), + migrations.AddIndex( + model_name='document', + index=models.Index(fields=['person', 'document_type', 'created_at'], name='recruitment_person__0a6844_idx'), + ), + migrations.AddIndex( + model_name='application', + index=models.Index(fields=['person', 'job'], name='recruitment_person__34355c_idx'), + ), + migrations.AddIndex( + model_name='application', + index=models.Index(fields=['stage'], name='recruitment_stage_52c2d1_idx'), + ), + migrations.AddIndex( + model_name='application', + index=models.Index(fields=['created_at'], name='recruitment_created_80633f_idx'), + ), + migrations.AddIndex( + model_name='application', + index=models.Index(fields=['person', 'stage', 'created_at'], name='recruitment_person__8715ec_idx'), + ), + migrations.AlterUniqueTogether( + name='application', + unique_together={('person', 'job')}, + ), migrations.AddIndex( model_name='jobposting', index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'), @@ -660,7 +749,7 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name='scheduledinterview', - index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'), + index=models.Index(fields=['application', 'job'], name='recruitment_applica_927561_idx'), ), migrations.AddIndex( model_name='notification', diff --git a/recruitment/migrations/0002_delete_candidate_and_more.py b/recruitment/migrations/0002_delete_candidate_and_more.py new file mode 100644 index 0000000..1d47a45 --- /dev/null +++ b/recruitment/migrations/0002_delete_candidate_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.6 on 2025-11-11 10:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0001_initial'), + ] + + operations = [ + migrations.DeleteModel( + name='Candidate', + ), + migrations.RenameField( + model_name='interviewschedule', + old_name='candidates', + new_name='applications', + ), + migrations.RemoveField( + model_name='application', + name='user', + ), + ] diff --git a/recruitment/migrations/0002_document.py b/recruitment/migrations/0002_document.py deleted file mode 100644 index cb62c62..0000000 --- a/recruitment/migrations/0002_document.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 5.2.6 on 2025-11-09 19:56 - -import django.db.models.deletion -import django_extensions.db.fields -import recruitment.validators -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Document', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), - ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), - ('file', models.FileField(upload_to='candidate_documents/%Y/%m/', validators=[recruitment.validators.validate_image_size], verbose_name='Document File')), - ('document_type', models.CharField(choices=[('resume', 'Resume'), ('cover_letter', 'Cover Letter'), ('certificate', 'Certificate'), ('id_document', 'ID Document'), ('passport', 'Passport'), ('education', 'Education Document'), ('experience', 'Experience Letter'), ('other', 'Other')], default='other', max_length=20, verbose_name='Document Type')), - ('description', models.CharField(blank=True, max_length=200, verbose_name='Description')), - ('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='recruitment.candidate', verbose_name='Candidate')), - ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Uploaded By')), - ], - options={ - 'verbose_name': 'Document', - 'verbose_name_plural': 'Documents', - 'ordering': ['-created_at'], - 'indexes': [models.Index(fields=['candidate', 'document_type', 'created_at'], name='recruitment_candida_f6ec68_idx')], - }, - ), - ] diff --git a/recruitment/migrations/0003_convert_document_to_generic_fk.py b/recruitment/migrations/0003_convert_document_to_generic_fk.py new file mode 100644 index 0000000..2d3f90c --- /dev/null +++ b/recruitment/migrations/0003_convert_document_to_generic_fk.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.6 on 2025-11-11 12:13 + +import django.db.models.deletion +import recruitment.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('recruitment', '0002_delete_candidate_and_more'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='document', + name='recruitment_person__0a6844_idx', + ), + migrations.RemoveField( + model_name='document', + name='person', + ), + migrations.AddField( + model_name='document', + name='content_type', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type'), + preserve_default=False, + ), + migrations.AddField( + model_name='document', + name='object_id', + field=models.PositiveIntegerField(default=1, verbose_name='Object ID'), + preserve_default=False, + ), + migrations.AlterField( + model_name='document', + name='file', + field=models.FileField(upload_to='documents/%Y/%m/', validators=[recruitment.validators.validate_image_size], verbose_name='Document File'), + ), + migrations.AddIndex( + model_name='document', + index=models.Index(fields=['content_type', 'object_id', 'document_type', 'created_at'], name='recruitment_content_547650_idx'), + ), + ] diff --git a/recruitment/migrations/0004_person_agency.py b/recruitment/migrations/0004_person_agency.py new file mode 100644 index 0000000..24bd305 --- /dev/null +++ b/recruitment/migrations/0004_person_agency.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.6 on 2025-11-12 20:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0003_convert_document_to_generic_fk'), + ] + + operations = [ + migrations.AddField( + model_name='person', + name='agency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recruitment.hiringagency', verbose_name='Hiring Agency'), + ), + ] diff --git a/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc b/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc index 9800da7fef1edd505e9b38b28c08f1c02702e862..7ee8741b3bbcc80c4b3e1211df930d0cfe8796a6 100644 GIT binary patch delta 13001 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 0: vacancy_fill_rate = no_of_positions_filled / total_positions @@ -456,21 +458,121 @@ class JobPostingImage(models.Model): post_image = models.ImageField(upload_to="post/", validators=[validate_image_size]) -class Candidate(Base): +class Person(Base): + """Model to store personal information that can be reused across multiple applications""" + + GENDER_CHOICES = [ + ("M", _("Male")), + ("F", _("Female")), + ("O", _("Other")), + ("P", _("Prefer not to say")), + ] + + # Personal Information + first_name = models.CharField(max_length=255, verbose_name=_("First Name")) + last_name = models.CharField(max_length=255, verbose_name=_("Last Name")) + middle_name = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Middle Name")) + email = models.EmailField( + unique=True, + db_index=True, + verbose_name=_("Email"), + help_text=_("Unique email address for the person") + ) + phone = models.CharField(max_length=20, blank=True, null=True, verbose_name=_("Phone")) + date_of_birth = models.DateField(null=True, blank=True, verbose_name=_("Date of Birth")) + gender = models.CharField( + max_length=1, + choices=GENDER_CHOICES, + blank=True, + null=True, + verbose_name=_("Gender") + ) + nationality = CountryField(blank=True, null=True, verbose_name=_("Nationality")) + address = models.TextField(blank=True, null=True, verbose_name=_("Address")) + + # Optional linking to user account + user = models.OneToOneField( + User, + on_delete=models.SET_NULL, + related_name="person_profile", + verbose_name=_("User Account"), + null=True, + blank=True, + ) + + # Profile information + profile_image = models.ImageField( + null=True, + blank=True, + upload_to="profile_pic/", + validators=[validate_image_size], + verbose_name=_("Profile Image") + ) + linkedin_profile = models.URLField( + blank=True, + null=True, + verbose_name=_("LinkedIn Profile URL") + ) + agency = models.ForeignKey( + "HiringAgency", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_("Hiring Agency") + ) + class Meta: + verbose_name = _("Person") + verbose_name_plural = _("People") + indexes = [ + models.Index(fields=["email"]), + models.Index(fields=["first_name", "last_name"]), + models.Index(fields=["created_at"]), + ] + + def __str__(self): + return f"{self.first_name} {self.last_name}" + + @property + def full_name(self): + return f"{self.first_name} {self.last_name}" + + @property + def age(self): + """Calculate age from date of birth""" + if self.date_of_birth: + today = timezone.now().date() + return today.year - self.date_of_birth.year - ( + (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day) + ) + return None + + @property + def documents(self): + """Return all documents associated with this Person""" + from django.contrib.contenttypes.models import ContentType + content_type = ContentType.objects.get_for_model(self.__class__) + return Document.objects.filter(content_type=content_type, object_id=self.id) + + +class Application(Base): + """Model to store job-specific application data""" + class Stage(models.TextChoices): APPLIED = "Applied", _("Applied") EXAM = "Exam", _("Exam") INTERVIEW = "Interview", _("Interview") OFFER = "Offer", _("Offer") HIRED = "Hired", _("Hired") + REJECTED = "Rejected", _("Rejected") class ExamStatus(models.TextChoices): PASSED = "Passed", _("Passed") FAILED = "Failed", _("Failed") - class Status(models.TextChoices): + class OfferStatus(models.TextChoices): ACCEPTED = "Accepted", _("Accepted") REJECTED = "Rejected", _("Rejected") + PENDING = "Pending", _("Pending") class ApplicantType(models.TextChoices): APPLICANT = "Applicant", _("Applicant") @@ -478,43 +580,50 @@ class Candidate(Base): # Stage transition validation constants STAGE_SEQUENCE = { - "Applied": ["Exam", "Interview", "Offer"], - "Exam": ["Interview", "Offer"], - "Interview": ["Offer"], - "Offer": [], # Final stage - no further transitions + "Applied": ["Exam", "Interview", "Offer", "Rejected"], + "Exam": ["Interview", "Offer", "Rejected"], + "Interview": ["Offer", "Rejected"], + "Offer": ["Hired", "Rejected"], + "Rejected": [], # Final stage - no further transitions + "Hired": [], # Final stage - no further transitions } - user = models.OneToOneField( - User, + # Core relationships + person = models.ForeignKey( + Person, on_delete=models.CASCADE, - related_name="candidate_profile", - verbose_name=_("User"), - null=True, - blank=True, + related_name="applications", + verbose_name=_("Person"), ) job = models.ForeignKey( JobPosting, on_delete=models.CASCADE, - related_name="candidates", + related_name="applications", verbose_name=_("Job"), ) - first_name = models.CharField(max_length=255, verbose_name=_("First Name")) - last_name = models.CharField(max_length=255, verbose_name=_("Last Name")) - email = models.EmailField(db_index=True, verbose_name=_("Email")) # Added index - phone = models.CharField(max_length=20, verbose_name=_("Phone")) - address = models.TextField(max_length=200, verbose_name=_("Address")) + + # Application-specific data resume = models.FileField(upload_to="resumes/", verbose_name=_("Resume")) + cover_letter = models.FileField( + upload_to="cover_letters/", + blank=True, + null=True, + verbose_name=_("Cover Letter") + ) is_resume_parsed = models.BooleanField( - default=False, verbose_name=_("Resume Parsed") + default=False, + verbose_name=_("Resume Parsed") ) - is_potential_candidate = models.BooleanField( - default=False, verbose_name=_("Potential Candidate") + parsed_summary = models.TextField( + blank=True, + verbose_name=_("Parsed Summary") ) - parsed_summary = models.TextField(blank=True, verbose_name=_("Parsed Summary")) + + # Workflow fields applied = models.BooleanField(default=False, verbose_name=_("Applied")) stage = models.CharField( db_index=True, - max_length=100, # Added index + max_length=20, default="Applied", choices=Stage.choices, verbose_name=_("Stage"), @@ -522,15 +631,17 @@ class Candidate(Base): applicant_status = models.CharField( choices=ApplicantType.choices, default="Applicant", - max_length=100, + max_length=20, null=True, blank=True, verbose_name=_("Applicant Status"), ) + + # Timeline fields exam_date = models.DateTimeField(null=True, blank=True, verbose_name=_("Exam Date")) exam_status = models.CharField( choices=ExamStatus.choices, - max_length=100, + max_length=20, null=True, blank=True, verbose_name=_("Exam Status"), @@ -540,30 +651,36 @@ class Candidate(Base): ) interview_status = models.CharField( choices=ExamStatus.choices, - max_length=100, + max_length=20, null=True, blank=True, verbose_name=_("Interview Status"), ) offer_date = models.DateField(null=True, blank=True, verbose_name=_("Offer Date")) offer_status = models.CharField( - choices=Status.choices, - max_length=100, + choices=OfferStatus.choices, + max_length=20, null=True, blank=True, verbose_name=_("Offer Status"), ) hired_date = models.DateField(null=True, blank=True, verbose_name=_("Hired Date")) join_date = models.DateField(null=True, blank=True, verbose_name=_("Join Date")) + + # AI Analysis ai_analysis_data = models.JSONField( verbose_name="AI Analysis Data", default=dict, help_text="Full JSON output from the resume scoring model.", null=True, blank=True, - ) # {'resume_data': {}, 'analysis_data': {}} + ) + retry = models.SmallIntegerField( + verbose_name="Resume Parsing Retry", + default=3 + ) - retry = models.SmallIntegerField(verbose_name="Resume Parsing Retry", default=3) + # Source tracking hiring_source = models.CharField( max_length=255, null=True, @@ -581,27 +698,36 @@ class Candidate(Base): on_delete=models.SET_NULL, null=True, blank=True, - related_name="candidates", + related_name="applications", verbose_name=_("Hiring Agency"), ) + # Optional linking to user account (for candidate portal access) + # user = models.OneToOneField( + # User, + # on_delete=models.SET_NULL, + # related_name="application_profile", + # verbose_name=_("User Account"), + # null=True, + # blank=True, + # ) + class Meta: - verbose_name = _("Candidate") - verbose_name_plural = _("Candidates") + verbose_name = _("Application") + verbose_name_plural = _("Applications") indexes = [ + models.Index(fields=["person", "job"]), models.Index(fields=["stage"]), models.Index(fields=["created_at"]), + models.Index(fields=["person", "stage", "created_at"]), ] + unique_together = [["person", "job"]] # Prevent duplicate applications - def set_field(self, key: str, value: Any): - """ - Generic method to set any single key-value pair and save. - """ - self.ai_analysis_data[key] = value - # self.save(update_fields=['ai_analysis_data']) + def __str__(self): + return f"{self.person.full_name} - {self.job.title}" # ==================================================================== - # ✨ PROPERTIES (GETTERS) + # ✨ PROPERTIES (GETTERS) - Migrated from Candidate # ==================================================================== @property def resume_data(self): @@ -629,11 +755,8 @@ class Candidate(Base): @property def industry_match_score(self) -> int: """16. A score (0-100) for the relevance of the candidate's industry experience.""" - # Renamed to clarify: experience_industry_match return self.analysis_data.get("experience_industry_match", 0) - # --- Properties for Funnel & Screening Efficiency --- - @property def min_requirements_met(self) -> bool: """14. Boolean (true/false) indicating if all non-negotiable minimum requirements are met.""" @@ -654,8 +777,6 @@ class Candidate(Base): """8. The candidate's most recent or current professional job title.""" return self.analysis_data.get("most_recent_job_title", "N/A") - # --- Properties for Structured Detail --- - @property def criteria_checklist(self) -> Dict[str, str]: """5 & 6. An object rating the candidate's match for each specific criterion.""" @@ -671,8 +792,6 @@ class Candidate(Base): """12. A list of languages and their fluency levels mentioned.""" return self.analysis_data.get("language_fluency", []) - # --- Properties for Summaries and Narrative --- - @property def strengths(self) -> str: """2. A brief summary of why the candidate is a strong fit.""" @@ -691,55 +810,94 @@ class Candidate(Base): @property def recommendation(self) -> str: """9. Provide a detailed final recommendation for the candidate.""" - # Using a more descriptive name to avoid conflict with potential built-in methods return self.analysis_data.get("recommendation", "") - @property - def name(self): - return f"{self.first_name} {self.last_name}" - - @property - def full_name(self): - return self.name - - @property - def get_file_size(self): - if self.resume: - return self.resume.size - return 0 - - def save(self, *args, **kwargs): - """Override save to ensure validation is called""" - self.clean() # Call validation before saving - super().save(*args, **kwargs) + # ==================================================================== + # 🔄 HELPER METHODS + # ==================================================================== + def set_field(self, key: str, value: Any): + """Generic method to set any single key-value pair and save.""" + self.ai_analysis_data[key] = value def get_available_stages(self): - """Get list of stages this candidate can transition to""" + """Get list of stages this application can transition to""" if not self.pk: # New record return ["Applied"] old_stage = self.__class__.objects.get(pk=self.pk).stage return self.STAGE_SEQUENCE.get(old_stage, []) + def save(self, *args, **kwargs): + """Override save to ensure validation is called""" + self.clean() # Call validation before saving + super().save(*args, **kwargs) + + # ==================================================================== + # 📋 LEGACY COMPATIBILITY PROPERTIES + # ==================================================================== + # These properties maintain compatibility with existing code that expects Candidate model + @property + def first_name(self): + """Legacy compatibility - delegates to person.first_name""" + return self.person.first_name + + @property + def last_name(self): + """Legacy compatibility - delegates to person.last_name""" + return self.person.last_name + + @property + def email(self): + """Legacy compatibility - delegates to person.email""" + return self.person.email + + @property + def phone(self): + """Legacy compatibility - delegates to person.phone if available""" + return self.person.phone or "" + + @property + def address(self): + """Legacy compatibility - delegates to person.address if available""" + return self.person.address or "" + + @property + def name(self): + """Legacy compatibility - delegates to person.full_name""" + return self.person.full_name + + @property + def full_name(self): + """Legacy compatibility - delegates to person.full_name""" + return self.person.full_name + + @property + def get_file_size(self): + """Legacy compatibility - returns resume file size""" + if self.resume: + return self.resume.size + return 0 + @property def submission(self): + """Legacy compatibility - get form submission for this application""" return FormSubmission.objects.filter(template__job=self.job).first() @property def responses(self): + """Legacy compatibility - get form responses for this application""" if self.submission: return self.submission.responses.all() return [] - def __str__(self): - return self.full_name - @property def get_meetings(self): + """Legacy compatibility - get scheduled interviews for this application""" return self.scheduled_interviews.all() @property def get_latest_meeting(self): + """Legacy compatibility - get latest meeting for this application""" schedule = self.scheduled_interviews.order_by("-created_at").first() if schedule: return schedule.zoom_meeting @@ -747,49 +905,71 @@ class Candidate(Base): @property def has_future_meeting(self): - """ - Checks if the candidate has any scheduled interviews for a future date/time. - """ - # Ensure timezone.now() is used for comparison + """Legacy compatibility - check for future meetings""" now = timezone.now() - # Check if any related ScheduledInterview has a future interview_date and interview_time - # We need to combine date and time for a proper datetime comparison if they are separate fields future_meetings = ( self.scheduled_interviews.filter(interview_date__gt=now.date()) .filter(interview_time__gte=now.time()) .exists() ) - - # Also check for interviews happening later today today_future_meetings = self.scheduled_interviews.filter( interview_date=now.date(), interview_time__gte=now.time() ).exists() - return future_meetings or today_future_meetings @property def scoring_timeout(self): + """Legacy compatibility - check scoring timeout""" return timezone.now() <= (self.created_at + timezone.timedelta(minutes=5)) @property def get_interview_date(self): + """Legacy compatibility - get interview date""" if hasattr(self, "scheduled_interview") and self.scheduled_interview: return self.scheduled_interviews.first().interview_date return None @property def get_interview_time(self): + """Legacy compatibility - get interview time""" if hasattr(self, "scheduled_interview") and self.scheduled_interview: return self.scheduled_interviews.first().interview_time return None @property def time_to_hire_days(self): + """Legacy compatibility - calculate time to hire""" if self.hired_date and self.created_at: time_to_hire = self.hired_date - self.created_at.date() return time_to_hire.days return 0 + @property + def documents(self): + """Return all documents associated with this Application""" + from django.contrib.contenttypes.models import ContentType + content_type = ContentType.objects.get_for_model(self.__class__) + return Document.objects.filter(content_type=content_type, object_id=self.id) + + +# ============================================================================ +# 🔄 BACKWARD COMPATIBILITY - Keep Candidate model for transition period +# ============================================================================ +# class Candidate(Application): +# """ +# DEPRECATED: Legacy Candidate model for backward compatibility. + +# This model extends Application to maintain compatibility with existing code +# during the migration period. All new code should use Application model. + +# TODO: Remove this model after migration is complete and all code is updated. +# """ + +# class Meta: +# proxy = True +# verbose_name = _("Candidate (Legacy)") +# verbose_name_plural = _("Candidates (Legacy)") + class TrainingMaterial(Base): title = models.CharField(max_length=255, verbose_name=_("Title")) @@ -865,14 +1045,19 @@ class ZoomMeeting(Base): # Timestamps def __str__(self): - return self.topic @ property + return self.topic + + @property def get_job(self): return self.interview.job @property def get_candidate(self): - return self.interview.candidate + return self.interview.application.person + @property + def candidate_full_name(self): + return self.interview.application.person.full_name @property def get_participants(self): @@ -1724,8 +1909,8 @@ class InterviewSchedule(Base): related_name="interview_schedules", db_index=True, ) - candidates = models.ManyToManyField( - Candidate, related_name="interview_schedules", blank=True, null=True + applications = models.ManyToManyField( + Application, related_name="interview_schedules", blank=True, null=True ) start_date = models.DateField( db_index=True, verbose_name=_("Start Date") @@ -1770,8 +1955,8 @@ class InterviewSchedule(Base): class ScheduledInterview(Base): """Stores individual scheduled interviews""" - candidate = models.ForeignKey( - Candidate, + application = models.ForeignKey( + Application, on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True, @@ -1814,13 +1999,13 @@ class ScheduledInterview(Base): updated_at = models.DateTimeField(auto_now=True) def __str__(self): - return f"Interview with {self.candidate.name} for {self.job.title}" + return f"Interview with {self.application.person.full_name} for {self.job.title}" class Meta: indexes = [ models.Index(fields=["job", "status"]), models.Index(fields=["interview_date", "interview_time"]), - models.Index(fields=["candidate", "job"]), + models.Index(fields=["application", "job"]), ] @@ -2042,7 +2227,7 @@ class Message(Base): class Document(Base): - """Model for storing candidate documents""" + """Model for storing documents using Generic Foreign Key""" class DocumentType(models.TextChoices): RESUME = "resume", _("Resume") @@ -2054,14 +2239,19 @@ class Document(Base): EXPERIENCE = "experience", _("Experience Letter") OTHER = "other", _("Other") - candidate = models.ForeignKey( - Candidate, + # Generic Foreign Key fields + content_type = models.ForeignKey( + ContentType, on_delete=models.CASCADE, - related_name="documents", - verbose_name=_("Candidate"), + verbose_name=_("Content Type"), ) + object_id = models.PositiveIntegerField( + verbose_name=_("Object ID"), + ) + content_object = GenericForeignKey('content_type', 'object_id') + file = models.FileField( - upload_to="candidate_documents/%Y/%m/", + upload_to="documents/%Y/%m/", verbose_name=_("Document File"), validators=[validate_image_size], ) @@ -2089,11 +2279,22 @@ class Document(Base): verbose_name_plural = _("Documents") ordering = ["-created_at"] indexes = [ - models.Index(fields=["candidate", "document_type", "created_at"]), + models.Index(fields=["content_type", "object_id", "document_type", "created_at"]), ] def __str__(self): - return f"{self.get_document_type_display()} - {self.candidate.name}" + try: + if hasattr(self.content_object, 'full_name'): + object_name = self.content_object.full_name + elif hasattr(self.content_object, 'title'): + object_name = self.content_object.title + elif hasattr(self.content_object, '__str__'): + object_name = str(self.content_object) + else: + object_name = f"Object {self.object_id}" + return f"{self.get_document_type_display()} - {object_name}" + except: + return f"{self.get_document_type_display()} - {self.object_id}" @property def file_size(self): diff --git a/recruitment/serializers.py b/recruitment/serializers.py index ea52220..6387523 100644 --- a/recruitment/serializers.py +++ b/recruitment/serializers.py @@ -1,14 +1,14 @@ from rest_framework import serializers -from .models import JobPosting, Candidate +from .models import JobPosting, Application class JobPostingSerializer(serializers.ModelSerializer): class Meta: model = JobPosting fields = '__all__' -class CandidateSerializer(serializers.ModelSerializer): +class ApplicationSerializer(serializers.ModelSerializer): job_title = serializers.CharField(source='job.title', read_only=True) class Meta: - model = Candidate + model = Application fields = '__all__' diff --git a/recruitment/signals.py b/recruitment/signals.py index 4865c3e..1881f43 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -8,8 +8,9 @@ from django_q.tasks import async_task from django.db.models.signals import post_save from django.contrib.auth.models import User from django.utils import timezone -from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting,Notification,HiringAgency +from .models import FormField,FormStage,FormTemplate,Application,JobPosting,Notification,HiringAgency,Person from django.contrib.auth import get_user_model + logger = logging.getLogger(__name__) User = get_user_model() @@ -57,7 +58,7 @@ def format_job(sender, instance, created, **kwargs): # instance.form_template.is_active = False # instance.save() -@receiver(post_save, sender=Candidate) +@receiver(post_save, sender=Application) def score_candidate_resume(sender, instance, created, **kwargs): if instance.resume and not instance.is_resume_parsed: logger.info(f"Scoring resume for candidate {instance.pk}") @@ -415,10 +416,10 @@ def hiring_agency_created(sender, instance, created, **kwargs): user.save() instance.user = user instance.save() -@receiver(post_save, sender=Candidate) -def candidate_created(sender, instance, created, **kwargs): +@receiver(post_save, sender=Person) +def person_created(sender, instance, created, **kwargs): if created: - logger.info(f"New candidate created: {instance.pk} - {instance.email}") + logger.info(f"New Person created: {instance.pk} - {instance.email}") user = User.objects.create_user( username=instance.slug, first_name=instance.first_name, diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 06cb795..c3ef8a0 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -7,7 +7,7 @@ from PyPDF2 import PdfReader from datetime import datetime from django.db import transaction from .utils import create_zoom_meeting -from recruitment.models import Candidate +from recruitment.models import Application from . linkedin_service import LinkedInService from django.shortcuts import get_object_or_404 from . models import JobPosting @@ -244,8 +244,8 @@ def handle_reume_parsing_and_scoring(pk): # --- 1. Robust Object Retrieval (Prevents looping on DoesNotExist) --- try: - instance = Candidate.objects.get(pk=pk) - except Candidate.DoesNotExist: + instance = Application.objects.get(pk=pk) + except Application.DoesNotExist: # Exit gracefully if the candidate was deleted after the task was queued logger.warning(f"Candidate matching query does not exist for pk={pk}. Exiting task.") print(f"Candidate matching query does not exist for pk={pk}. Exiting task.") @@ -453,7 +453,7 @@ def create_interview_and_meeting( Synchronous task for a single interview slot, dispatched by django-q. """ try: - candidate = Candidate.objects.get(pk=candidate_id) + candidate = Application.objects.get(pk=candidate_id) job = JobPosting.objects.get(pk=job_id) schedule = InterviewSchedule.objects.get(pk=schedule_id) @@ -476,7 +476,7 @@ def create_interview_and_meeting( password=result["meeting_details"]["password"] ) ScheduledInterview.objects.create( - candidate=candidate, + application=Application, job=job, zoom_meeting=zoom_meeting, schedule=schedule, @@ -484,11 +484,11 @@ def create_interview_and_meeting( interview_time=slot_time ) # Log success or use Django-Q result system for monitoring - logger.info(f"Successfully scheduled interview for {candidate.name}") + logger.info(f"Successfully scheduled interview for {Application.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']}") + logger.error(f"Zoom API failed for {Application.name}: {result['message']}") return False # Task failed except Exception as e: @@ -703,14 +703,14 @@ def sync_candidate_to_source_task(candidate_id, source_id): try: # Get the candidate and source - candidate = Candidate.objects.get(pk=candidate_id) + application = Application.objects.get(pk=candidate_id) source = Source.objects.get(pk=source_id) # Initialize sync service sync_service = CandidateSyncService() # Perform the sync operation - result = sync_service.sync_candidate_to_source(candidate, source) + result = sync_service.sync_candidate_to_source(application, source) # Log the operation IntegrationLog.objects.create( @@ -718,7 +718,7 @@ def sync_candidate_to_source_task(candidate_id, source_id): action=IntegrationLog.ActionChoices.SYNC, endpoint=source.sync_endpoint or "unknown", method=source.sync_method or "POST", - request_data={"candidate_id": candidate_id, "candidate_name": candidate.name}, + request_data={"candidate_id": candidate_id, "application_name": application.name}, response_data=result, status_code="SUCCESS" if result.get('success') else "ERROR", error_message=result.get('error') if not result.get('success') else None, @@ -730,8 +730,8 @@ def sync_candidate_to_source_task(candidate_id, source_id): logger.info(f"Sync completed for candidate {candidate_id} to source {source_id}: {result}") return result - except Candidate.DoesNotExist: - error_msg = f"Candidate not found: {candidate_id}" + except Application.DoesNotExist: + error_msg = f"Application not found: {candidate_id}" logger.error(error_msg) return {"success": False, "error": error_msg} diff --git a/recruitment/tests.py b/recruitment/tests.py index 20feb89..afadf48 100644 --- a/recruitment/tests.py +++ b/recruitment/tests.py @@ -1,5 +1,5 @@ from django.test import TestCase, Client -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.urls import reverse from django.utils import timezone from django.core.files.uploadedfile import SimpleUploadedFile @@ -7,6 +7,8 @@ from datetime import datetime, time, timedelta import json from unittest.mock import patch, MagicMock +User = get_user_model() + from .models import ( JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview, @@ -14,11 +16,11 @@ from .models import ( ) from .forms import ( JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm, - CandidateStageForm, InterviewScheduleForm + CandidateStageForm, InterviewScheduleForm, CandidateSignupForm ) from .views import ( ZoomMeetingListView, ZoomMeetingCreateView, job_detail, candidate_screening_view, - candidate_exam_view, candidate_interview_view, submit_form, api_schedule_candidate_meeting + candidate_exam_view, candidate_interview_view, api_schedule_candidate_meeting ) from .views_frontend import CandidateListView, JobListView from .utils import create_zoom_meeting, get_candidates_from_request @@ -46,14 +48,21 @@ class BaseTestCase(TestCase): location_country='Saudi Arabia', description='Job description', qualifications='Job qualifications', + application_deadline=timezone.now() + timedelta(days=30), created_by=self.user ) - self.candidate = Candidate.objects.create( + # Create a person first + from .models import Person + person = Person.objects.create( first_name='John', last_name='Doe', email='john@example.com', - phone='1234567890', + phone='1234567890' + ) + + self.candidate = Candidate.objects.create( + person=person, resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'), job=self.job, stage='Applied' @@ -231,28 +240,6 @@ class ViewTests(BaseTestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, 'success') - def test_submit_form(self): - """Test submit_form view""" - # Create a form template first - template = FormTemplate.objects.create( - job=self.job, - name='Test Template', - created_by=self.user, - is_active=True - ) - - data = { - 'field_1': 'John', # Assuming field ID 1 corresponds to First Name - 'field_2': 'Doe', # Assuming field ID 2 corresponds to Last Name - 'field_3': 'john@example.com', # Email - } - - response = self.client.post( - reverse('application_submit', kwargs={'template_id': template.id}), - data - ) - # After successful submission, should redirect to success page - self.assertEqual(response.status_code, 302) class FormTests(BaseTestCase): @@ -268,13 +255,13 @@ class FormTests(BaseTestCase): 'location_city': 'Riyadh', 'location_state': 'Riyadh', 'location_country': 'Saudi Arabia', - 'description': 'Job description', + 'description': 'Job description with at least 20 characters to meet validation requirements', 'qualifications': 'Job qualifications', 'salary_range': '5000-7000', 'application_deadline': '2025-12-31', 'max_applications': '100', 'open_positions': '2', - 'hash_tags': '#hiring, #jobopening' + 'hash_tags': '#hiring,#jobopening' } form = JobPostingForm(data=form_data) self.assertTrue(form.is_valid()) @@ -315,24 +302,51 @@ class FormTests(BaseTestCase): form_data = { 'stage': 'Exam' } - form = CandidateStageForm(data=form_data, candidate=self.candidate) + form = CandidateStageForm(data=form_data, instance=self.candidate) self.assertTrue(form.is_valid()) def test_interview_schedule_form(self): """Test InterviewScheduleForm""" + # Update candidate to Interview stage first + self.candidate.stage = 'Interview' + self.candidate.save() + form_data = { 'candidates': [self.candidate.id], 'start_date': (timezone.now() + timedelta(days=1)).date(), 'end_date': (timezone.now() + timedelta(days=7)).date(), 'working_days': [0, 1, 2, 3, 4], # Monday to Friday - 'start_time': '09:00', - 'end_time': '17:00', - 'interview_duration': 60, - 'buffer_time': 15 } form = InterviewScheduleForm(slug=self.job.slug, data=form_data) self.assertTrue(form.is_valid()) + def test_candidate_signup_form_valid(self): + """Test CandidateSignupForm with valid data""" + form_data = { + 'first_name': 'John', + 'last_name': 'Doe', + 'email': 'john.doe@example.com', + 'phone': '+1234567890', + 'password': 'SecurePass123', + 'confirm_password': 'SecurePass123' + } + form = CandidateSignupForm(data=form_data) + self.assertTrue(form.is_valid()) + + def test_candidate_signup_form_password_mismatch(self): + """Test CandidateSignupForm with password mismatch""" + form_data = { + 'first_name': 'John', + 'last_name': 'Doe', + 'email': 'john.doe@example.com', + 'phone': '+1234567890', + 'password': 'SecurePass123', + 'confirm_password': 'DifferentPass123' + } + form = CandidateSignupForm(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn('Passwords do not match', str(form.errors)) + class IntegrationTests(BaseTestCase): """Integration tests for multiple components""" @@ -340,11 +354,14 @@ class IntegrationTests(BaseTestCase): def test_candidate_journey(self): """Test the complete candidate journey from application to interview""" # 1. Create candidate - candidate = Candidate.objects.create( + person = Person.objects.create( first_name='Jane', last_name='Smith', email='jane@example.com', - phone='9876543210', + phone='9876543210' + ) + candidate = Candidate.objects.create( + person=person, resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'), job=self.job, stage='Applied' @@ -449,11 +466,15 @@ class PerformanceTests(BaseTestCase): """Test pagination with large datasets""" # Create many candidates for i in range(100): - Candidate.objects.create( + person = Person.objects.create( first_name=f'Candidate{i}', last_name=f'Test{i}', email=f'candidate{i}@example.com', - phone=f'123456789{i}', + phone=f'123456789{i}' + ) + Candidate.objects.create( + person=person, + resume=SimpleUploadedFile(f'resume{i}.pdf', b'file_content', content_type='application/pdf'), job=self.job, stage='Applied' ) @@ -594,13 +615,17 @@ class TestFactories: @staticmethod def create_candidate(**kwargs): job = TestFactories.create_job_posting() + person = Person.objects.create( + first_name='Test', + last_name='Candidate', + email='test@example.com', + phone='1234567890' + ) defaults = { - 'first_name': 'Test', - 'last_name': 'Candidate', - 'email': 'test@example.com', - 'phone': '1234567890', + 'person': person, 'job': job, - 'stage': 'Applied' + 'stage': 'Applied', + 'resume': SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf') } defaults.update(kwargs) return Candidate.objects.create(**defaults) diff --git a/recruitment/urls.py b/recruitment/urls.py index b1a0ccd..d8083d1 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -7,6 +7,12 @@ from . import views_source urlpatterns = [ path("", views_frontend.dashboard_view, name="dashboard"), # Job URLs (using JobPosting model) + path("persons/", views.PersonListView.as_view(), name="person_list"), + path("persons/create/", views.PersonCreateView.as_view(), name="person_create"), + path("persons//", views.PersonDetailView.as_view(), name="person_detail"), + path("persons//update/", views.PersonUpdateView.as_view(), name="person_update"), + path("persons//delete/", views.PersonDeleteView.as_view(), name="person_delete"), + path("jobs/", views_frontend.JobListView.as_view(), name="job_list"), path("jobs/create/", views.create_job, name="job_create"), path( @@ -38,31 +44,31 @@ urlpatterns = [ ), # Candidate URLs path( - "candidates/", views_frontend.CandidateListView.as_view(), name="candidate_list" + "candidates/", views_frontend.ApplicationListView.as_view(), name="candidate_list" ), path( "candidates/create/", - views_frontend.CandidateCreateView.as_view(), + views_frontend.ApplicationCreateView.as_view(), name="candidate_create", ), path( "candidates/create//", - views_frontend.CandidateCreateView.as_view(), + views_frontend.ApplicationCreateView.as_view(), name="candidate_create_for_job", ), path( "jobs//candidates/", - views_frontend.JobCandidatesListView.as_view(), + views_frontend.JobApplicationListView.as_view(), name="job_candidates_list", ), path( "candidates//update/", - views_frontend.CandidateUpdateView.as_view(), + views_frontend.ApplicationUpdateView.as_view(), name="candidate_update", ), path( "candidates//delete/", - views_frontend.CandidateDeleteView.as_view(), + views_frontend.ApplicationDeleteView.as_view(), name="candidate_delete", ), path( @@ -478,6 +484,16 @@ urlpatterns = [ views.candidate_portal_dashboard, name="candidate_portal_dashboard", ), + path( + "portal/dashboard/", + views.agency_portal_dashboard, + name="agency_portal_dashboard", + ), + path( + "portal/persons/", + views.agency_portal_persons_list, + name="agency_portal_persons_list", + ), path( "portal/assignment//", views.agency_portal_assignment_detail, @@ -571,7 +587,7 @@ urlpatterns = [ path("api/unread-count/", views.api_unread_count, name="api_unread_count"), # Documents - path("documents/upload//", views.document_upload, name="document_upload"), + path("documents/upload//", views.document_upload, name="document_upload"), path("documents//delete/", views.document_delete, name="document_delete"), path("documents//download/", views.document_download, name="document_download"), ] diff --git a/recruitment/views.py b/recruitment/views.py index e333af2..feedbda 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -1,12 +1,22 @@ import json -from rich import print - from django.utils.translation import gettext as _ from django.contrib.auth import get_user_model, authenticate, login, logout from django.contrib.auth.decorators import login_required from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.mixins import LoginRequiredMixin -from .forms import StaffUserCreationForm,ToggleAccountForm, JobPostingStatusForm,LinkedPostContentForm,CandidateEmailForm +from .decorators import ( + agency_user_required, + candidate_user_required, + staff_user_required, + staff_or_agency_required, + staff_or_candidate_required, + AgencyRequiredMixin, + CandidateRequiredMixin, + StaffRequiredMixin, + StaffOrAgencyRequiredMixin, + StaffOrCandidateRequiredMixin +) +from .forms import StaffUserCreationForm,ToggleAccountForm, JobPostingStatusForm,LinkedPostContentForm,CandidateEmailForm,InterviewForm,ProfileImageUploadForm,ParticipantsSelectForm from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from django.http import HttpResponse, JsonResponse @@ -42,19 +52,20 @@ from .forms import ( HiringAgencyForm, AgencyJobAssignmentForm, AgencyAccessLinkForm, - AgencyCandidateSubmissionForm, + AgencyApplicationSubmissionForm, AgencyLoginForm, PortalLoginForm, MessageForm, + PersonForm ) from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from rest_framework import viewsets from django.contrib import messages from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from .linkedin_service import LinkedInService -from .serializers import JobPostingSerializer, CandidateSerializer +from .serializers import JobPostingSerializer, ApplicationSerializer from django.shortcuts import get_object_or_404, render, redirect -from django.views.generic import CreateView, UpdateView, DetailView, ListView +from django.views.generic import CreateView, UpdateView, DetailView, ListView,DeleteView from .utils import ( create_zoom_meeting, delete_zoom_meeting, @@ -76,7 +87,8 @@ from .models import ( InterviewSchedule, BreakTime, ZoomMeeting, - Candidate, + Application, + Person, JobPosting, ScheduledInterview, JobPostingImage, @@ -101,22 +113,70 @@ from django_q.tasks import async_task from django.db.models import Prefetch from django.db.models import Q, Count, Avg from django.db.models import FloatField +from django.urls import reverse_lazy logger = logging.getLogger(__name__) User = get_user_model() +class PersonListView(StaffRequiredMixin, ListView): + model = Person + template_name = "people/person_list.html" + context_object_name = "people_list" + + +class PersonCreateView(CreateView): + model = Person + template_name = "people/create_person.html" + form_class = PersonForm + # success_url = reverse_lazy("person_list") + + def form_valid(self, form): + if 'HX-Request' in self.request.headers: + instance = form.save() + view = self.request.POST.get("view") + if view == "portal": + slug = self.request.POST.get("agency") + if slug: + agency = HiringAgency.objects.get(slug=slug) + print(agency) + instance.agency = agency + instance.save() + return redirect("agency_portal_persons_list") + if view == "job": + return redirect("candidate_create") + return super().form_valid(form) + + +class PersonDetailView(DetailView): + model = Person + template_name = "people/person_detail.html" + context_object_name = "person" + + +class PersonUpdateView(StaffRequiredMixin, UpdateView): + model = Person + template_name = "people/update_person.html" + form_class = PersonForm + success_url = reverse_lazy("person_list") + + +class PersonDeleteView(StaffRequiredMixin, DeleteView): + model = Person + template_name = "people/delete_person.html" + success_url = reverse_lazy("person_list") + class JobPostingViewSet(viewsets.ModelViewSet): queryset = JobPosting.objects.all() serializer_class = JobPostingSerializer class CandidateViewSet(viewsets.ModelViewSet): - queryset = Candidate.objects.all() - serializer_class = CandidateSerializer + queryset = Application.objects.all() + serializer_class = ApplicationSerializer -class ZoomMeetingCreateView(LoginRequiredMixin, CreateView): +class ZoomMeetingCreateView(StaffRequiredMixin, CreateView): model = ZoomMeeting template_name = "meetings/create_meeting.html" form_class = ZoomMeetingForm @@ -156,7 +216,7 @@ class ZoomMeetingCreateView(LoginRequiredMixin, CreateView): return redirect(reverse("create_meeting", kwargs={"slug": instance.slug})) -class ZoomMeetingListView(LoginRequiredMixin, ListView): +class ZoomMeetingListView(StaffRequiredMixin, ListView): model = ZoomMeeting template_name = "meetings/list_meetings.html" context_object_name = "meetings" @@ -170,7 +230,7 @@ class ZoomMeetingListView(LoginRequiredMixin, ListView): queryset = queryset.prefetch_related( Prefetch( "interview", # related_name from ZoomMeeting to ScheduledInterview - queryset=ScheduledInterview.objects.select_related("candidate", "job"), + queryset=ScheduledInterview.objects.select_related("application", "job"), to_attr="interview_details", # Changed to not start with underscore ) ) @@ -194,8 +254,8 @@ class ZoomMeetingListView(LoginRequiredMixin, ListView): 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) + Q(interview__application__first_name__icontains=candidate_name) + | Q(interview__application__last_name__icontains=candidate_name) ) return queryset @@ -208,13 +268,13 @@ class ZoomMeetingListView(LoginRequiredMixin, ListView): return context -class ZoomMeetingDetailsView(LoginRequiredMixin, DetailView): +class ZoomMeetingDetailsView(StaffRequiredMixin, DetailView): model = ZoomMeeting template_name = "meetings/meeting_details.html" context_object_name = "meeting" -class ZoomMeetingUpdateView(LoginRequiredMixin, UpdateView): +class ZoomMeetingUpdateView(StaffRequiredMixin, UpdateView): model = ZoomMeeting form_class = ZoomMeetingForm context_object_name = "meeting" @@ -309,6 +369,7 @@ def ZoomMeetingDeleteView(request, slug): @login_required +@staff_user_required def create_job(request): """Create a new job posting""" @@ -341,6 +402,7 @@ def create_job(request): @login_required +@staff_user_required def edit_job(request, slug): """Edit an existing job posting""" job = get_object_or_404(JobPosting, slug=slug) @@ -366,19 +428,19 @@ SCORE_PATH = "ai_analysis_data__analysis_data__match_score" HIGH_POTENTIAL_THRESHOLD = 75 -@login_required +@staff_user_required def job_detail(request, slug): """View details of a specific job""" job = get_object_or_404(JobPosting, slug=slug) - # Get all candidates for this job, ordered by most recent - applicants = job.candidates.all().order_by("-created_at") + # Get all applications for this job, ordered by most recent + applicants = job.applications.all().order_by("-created_at") - # Count candidates by stage for summary statistics + # Count applications by stage for summary statistics total_applicant = applicants.count() applied_count = applicants.filter(stage="Applied").count() - exam_count = applicants.filter(stage="Exam").count + exam_count = applicants.filter(stage="Exam").count() interview_count = applicants.filter(stage="Interview").count() @@ -529,6 +591,7 @@ def job_detail(request, slug): @login_required +@staff_user_required def job_image_upload(request, slug): # only for handling the post request job = get_object_or_404(JobPosting, slug=slug) @@ -570,6 +633,7 @@ def job_image_upload(request, slug): @login_required +@staff_user_required def edit_linkedin_post_content(request, slug): job = get_object_or_404(JobPosting, slug=slug) linkedin_content_form = LinkedPostContentForm(instance=job) @@ -602,10 +666,8 @@ def application_detail(request, slug): return render(request, "forms/application_detail.html", {"job": job}) -from django_q.tasks import async_task - - @login_required +@staff_user_required def post_to_linkedin(request, slug): """Post a job to LinkedIn""" job = get_object_or_404(JobPosting, slug=slug) @@ -702,6 +764,7 @@ def application_success(request, slug): @ensure_csrf_cookie @login_required +@staff_user_required def form_builder(request, template_slug=None): """Render the form builder interface""" context = {} @@ -823,6 +886,7 @@ def load_form_template(request, template_slug): @login_required +@staff_user_required def form_templates_list(request): """List all form templates for the current user""" query = request.GET.get("q", "") @@ -844,6 +908,7 @@ def form_templates_list(request): @login_required +@staff_user_required def create_form_template(request): """Create a new form template""" if request.method == "POST": @@ -863,6 +928,7 @@ def create_form_template(request): @login_required +@staff_user_required @require_http_methods(["GET"]) def list_form_templates(request): """List all form templates for the current user""" @@ -873,6 +939,49 @@ def list_form_templates(request): @login_required +@staff_user_required +def form_submission_details(request, template_id, slug): + """Display detailed view of a specific form submission""" + # Get form template and verify ownership + template = get_object_or_404(FormTemplate, id=template_id) + # Get the specific submission + submission = get_object_or_404(FormSubmission, slug=slug, template=template) + + # Get all stages with their fields + stages = template.stages.prefetch_related("fields").order_by("order") + + # Get all responses for this submission, ordered by field order + responses = submission.responses.select_related("field").order_by("field__order") + + # Group responses by stage + stage_responses = {} + for stage in stages: + stage_responses[stage.id] = { + "stage": stage, + "responses": responses.filter(field__stage=stage), + } + + return render( + request, + "forms/form_submission_details.html", + { + "template": template, + "submission": submission, + "stages": stages, + "responses": responses, + "stage_responses": stage_responses, + }, + ) + # return redirect("application_detail", slug=job.slug) + + # return render( + # request, + # "forms/application_submit_form.html", + # {"template_slug": template_slug, "job_id": job_id}, + # ) + +@login_required +@staff_user_required @require_http_methods(["DELETE"]) def delete_form_template(request, template_id): """Delete a form template""" @@ -882,7 +991,8 @@ def delete_form_template(request, template_id): {"success": True, "message": "Form template deleted successfully!"} ) - +@login_required +@staff_user_required def application_submit_form(request, template_slug): """Display the form as a step-by-step wizard""" template = get_object_or_404(FormTemplate, slug=template_slug, is_active=True) @@ -913,6 +1023,7 @@ def application_submit_form(request, template_slug): ) + @csrf_exempt @require_POST def application_submit(request, template_slug): @@ -926,7 +1037,7 @@ def application_submit(request, template_slug): form_template=template ) - current_count = job_posting.candidates.count() + current_count = job_posting.applications.count() if current_count >= job_posting.max_applications: template.is_active = False template.save() @@ -983,7 +1094,7 @@ def application_submit(request, template_slug): submission.applicant_email = email.display_value submission.save() # time=timezone.now() - Candidate.objects.create( + Application.objects.create( first_name=first_name.display_value, last_name=last_name.display_value, email=email.display_value, @@ -1024,6 +1135,7 @@ def application_submit(request, template_slug): @login_required +@staff_user_required def form_template_submissions_list(request, slug): """List all submissions for a specific form template""" template = get_object_or_404(FormTemplate, slug=slug) @@ -1045,6 +1157,7 @@ def form_template_submissions_list(request, slug): @login_required +@staff_user_required def form_template_all_submissions(request, template_id): """Display all submissions for a form template in table format""" template = get_object_or_404(FormTemplate, id=template_id) @@ -1138,8 +1251,9 @@ def _handle_get_request(request, slug, job): # 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 + candidates_to_load = Application.objects.filter(pk__in=selected_ids) + print(candidates_to_load) + form.initial["applications"] = candidates_to_load return render( request, @@ -1159,7 +1273,7 @@ def _handle_preview_submission(request, slug, job): if form.is_valid(): # Get the form data - candidates = form.cleaned_data["candidates"] + applications = form.cleaned_data["applications"] start_date = form.cleaned_data["start_date"] end_date = form.cleaned_data["end_date"] working_days = form.cleaned_data["working_days"] @@ -1191,18 +1305,18 @@ def _handle_preview_submission(request, slug, job): 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, + buffer_time=buffer_time or 5, + break_start_time=break_start_time or None, + break_end_time=break_end_time or None, ) # 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): + if len(available_slots) < len(applications): messages.error( request, - f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}", + f"Not enough available slots. Required: {len(applications)}, Available: {len(available_slots)}", ) return render( request, @@ -1212,10 +1326,10 @@ def _handle_preview_submission(request, slug, job): # Create a preview schedule preview_schedule = [] - for i, candidate in enumerate(candidates): + for i, candidate in enumerate(applications): slot = available_slots[i] preview_schedule.append( - {"candidate": candidate, "date": slot["date"], "time": slot["time"]} + {"applications": applications, "date": slot["date"], "time": slot["time"]} ) # Save the form data to session for later use @@ -1229,7 +1343,7 @@ def _handle_preview_submission(request, slug, job): "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], + "candidate_ids": [c.id for c in applications], } request.session[SESSION_DATA_KEY] = schedule_data @@ -1302,7 +1416,7 @@ def _handle_confirm_schedule(request, slug, job): return redirect("schedule_interviews", slug=slug) # 3. Setup candidates and get slots - candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"]) + candidates = Application.objects.filter(id__in=schedule_data["candidate_ids"]) schedule.candidates.set(candidates) available_slots = get_available_time_slots( schedule @@ -1326,7 +1440,6 @@ def _handle_confirm_schedule(request, slug, job): ) 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!", @@ -1356,7 +1469,7 @@ def confirm_schedule_interviews_view(request, slug): return _handle_confirm_schedule(request, slug, job) -@login_required +@staff_user_required def candidate_screening_view(request, slug): """ Manage candidate tiers and stage transitions @@ -1425,7 +1538,7 @@ def candidate_screening_view(request, slug): return render(request, "recruitment/candidate_screening_view.html", context) -@login_required +@staff_user_required def candidate_exam_view(request, slug): """ Manage candidate tiers and stage transitions @@ -1435,9 +1548,9 @@ def candidate_exam_view(request, slug): return render(request, "recruitment/candidate_exam_view.html", context) -@login_required +@staff_user_required def update_candidate_exam_status(request, slug): - candidate = get_object_or_404(Candidate, slug=slug) + candidate = get_object_or_404(Application, slug=slug) if request.method == "POST": form = CandidateExamDateForm(request.POST, instance=candidate) if form.is_valid(): @@ -1452,7 +1565,7 @@ def update_candidate_exam_status(request, slug): ) -@login_required +@staff_user_required def bulk_update_candidate_exam_status(request, slug): job = get_object_or_404(JobPosting, slug=slug) status = request.headers.get("status") @@ -1472,15 +1585,15 @@ def bulk_update_candidate_exam_status(request, slug): def candidate_criteria_view_htmx(request, pk): - candidate = get_object_or_404(Candidate, pk=pk) + candidate = get_object_or_404(Application, pk=pk) return render( request, "includes/candidate_modal_body.html", {"candidate": candidate} ) -@login_required +@staff_user_required def candidate_set_exam_date(request, slug): - candidate = get_object_or_404(Candidate, slug=slug) + candidate = get_object_or_404(Application, slug=slug) candidate.exam_date = timezone.now() candidate.save() messages.success( @@ -1489,7 +1602,7 @@ def candidate_set_exam_date(request, slug): return redirect("candidate_screening_view", slug=candidate.job.slug) -@login_required +@staff_user_required def candidate_update_status(request, slug): job = get_object_or_404(JobPosting, slug=slug) mark_as = request.POST.get("mark_as") @@ -1497,7 +1610,7 @@ def candidate_update_status(request, slug): if mark_as != "----------": candidate_ids = request.POST.getlist("candidate_ids") print(candidate_ids) - if c := Candidate.objects.filter(pk__in=candidate_ids): + if c := Application.objects.filter(pk__in=candidate_ids): if mark_as == "Exam": c.update( exam_date=timezone.now(), @@ -1555,7 +1668,7 @@ def candidate_update_status(request, slug): return response -@login_required +@staff_user_required def candidate_interview_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) @@ -1595,10 +1708,10 @@ def candidate_interview_view(request, slug): return render(request, "recruitment/candidate_interview_view.html", context) -@login_required +@staff_user_required def reschedule_meeting_for_candidate(request, slug, candidate_id, meeting_id): job = get_object_or_404(JobPosting, slug=slug) - candidate = get_object_or_404(Candidate, pk=candidate_id) + candidate = get_object_or_404(Application, pk=candidate_id) meeting = get_object_or_404(ZoomMeeting, pk=meeting_id) form = ZoomMeetingForm(instance=meeting) @@ -1634,10 +1747,10 @@ def reschedule_meeting_for_candidate(request, slug, candidate_id, meeting_id): return render(request, "meetings/reschedule_meeting.html", context) -@login_required +@staff_user_required def delete_meeting_for_candidate(request, slug, candidate_pk, meeting_id): job = get_object_or_404(JobPosting, slug=slug) - candidate = get_object_or_404(Candidate, pk=candidate_pk) + candidate = get_object_or_404(Application, pk=candidate_pk) meeting = get_object_or_404(ZoomMeeting, pk=meeting_id) if request.method == "POST": result = delete_zoom_meeting(meeting.meeting_id) @@ -1667,13 +1780,13 @@ def delete_meeting_for_candidate(request, slug, candidate_pk, meeting_id): return render(request, "meetings/delete_meeting_form.html", context) -@login_required +@staff_user_required def interview_calendar_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) # Get all scheduled interviews for this job scheduled_interviews = ScheduledInterview.objects.filter(job=job).select_related( - "candidate", "zoom_meeting" + "applicaton", "zoom_meeting" ) # Convert interviews to calendar events @@ -1727,7 +1840,7 @@ def interview_calendar_view(request, slug): return render(request, "recruitment/interview_calendar.html", context) -@login_required +@staff_user_required def interview_detail_view(request, slug, interview_id): job = get_object_or_404(JobPosting, slug=slug) interview = get_object_or_404(ScheduledInterview, id=interview_id, job=job) @@ -1748,7 +1861,7 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): Returns JSON response for modal update. """ job = get_object_or_404(JobPosting, slug=job_slug) - candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + candidate = get_object_or_404(Application, pk=candidate_pk, job=job) topic = f"Interview: {job.title} with {candidate.name}" start_time_str = request.POST.get("start_time") @@ -1828,7 +1941,7 @@ def schedule_candidate_meeting(request, job_slug, candidate_pk): POST: Handled by api_schedule_candidate_meeting. """ job = get_object_or_404(JobPosting, slug=job_slug) - candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + candidate = get_object_or_404(Application, pk=candidate_pk, job=job) if request.method == "POST": return api_schedule_candidate_meeting(request, job_slug, candidate_pk) @@ -1853,7 +1966,7 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): Handles GET to render form and POST to process scheduling. """ job = get_object_or_404(JobPosting, slug=job_slug) - candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + candidate = get_object_or_404(Application, pk=candidate_pk, job=job) if request.method == "GET": # This GET is for HTMX to fetch the form @@ -1940,7 +2053,7 @@ def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_ scheduled_interview = get_object_or_404( ScheduledInterview.objects.select_related("zoom_meeting"), pk=interview_pk, - candidate__pk=candidate_pk, + application__pk=candidate_pk, job=job, ) zoom_meeting = scheduled_interview.zoom_meeting @@ -1954,7 +2067,7 @@ def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_ } context = { "job": job, - "candidate": scheduled_interview.candidate, + "candidate": scheduled_interview.application, "scheduled_interview": scheduled_interview, # Pass for conditional logic in template "initial_data": initial_data, "action_url": reverse( @@ -2067,11 +2180,11 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): Handles POST to process the rescheduling of a meeting. """ job = get_object_or_404(JobPosting, slug=job_slug) - candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + application = get_object_or_404(Application, pk=candidate_pk, job=job) scheduled_interview = get_object_or_404( ScheduledInterview.objects.select_related("zoom_meeting"), pk=interview_pk, - candidate=candidate, + application=application, job=job, ) zoom_meeting = scheduled_interview.zoom_meeting @@ -2082,7 +2195,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): # If candidate.has_future_meeting is True, it implies they have at least one other upcoming meeting, # or the specific meeting being rescheduled is itself in the future. # We can refine this logic if needed, e.g., check for meetings *other than* the current `interview_pk`. - has_other_future_meetings = candidate.has_future_meeting + has_other_future_meetings = application.has_future_meeting # More precise check: if the current meeting being rescheduled is in the future, then by definition # the candidate will have a future meeting (this one). The UI might want to know if there are *others*. # For now, `candidate.has_future_meeting` is a good general indicator. @@ -2096,7 +2209,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): # Use a default topic if not provided, keeping with the original structure if not new_topic: - new_topic = f"Interview: {job.title} with {candidate.name}" + new_topic = f"Interview: {job.title} with {application.name}" # Ensure new_start_time is in the future if new_start_time <= timezone.now(): @@ -2108,7 +2221,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): { # Reusing the same form template "form": form, "job": job, - "candidate": candidate, + "application": application, "scheduled_interview": scheduled_interview, "initial_topic": new_topic, "initial_start_time": new_start_time.strftime("%Y-%m-%dT%H:%M") @@ -2175,7 +2288,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): scheduled_interview.save() messages.success( request, - f"Meeting for {candidate.name} rescheduled successfully.", + f"Meeting for {application.name} rescheduled successfully.", ) else: # If fetching details fails, update with form data and log a warning @@ -2193,7 +2306,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): scheduled_interview.save() messages.success( request, - f"Meeting for {candidate.name} rescheduled. (Note: Could not refresh all details from Zoom.)", + f"Meeting for {application.name} rescheduled. (Note: Could not refresh all details from Zoom.)", ) return redirect("candidate_interview_view", slug=job.slug) @@ -2209,7 +2322,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): { "form": form, "job": job, - "candidate": candidate, + "application": application, "scheduled_interview": scheduled_interview, "initial_topic": new_topic, "initial_start_time": new_start_time.strftime("%Y-%m-%dT%H:%M") @@ -2235,7 +2348,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): { "form": form, "job": job, - "candidate": candidate, + "application": application, "scheduled_interview": scheduled_interview, "initial_topic": request.POST.get("topic", new_topic), "initial_start_time": request.POST.get( @@ -2270,7 +2383,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): { "form": form, "job": job, - "candidate": candidate, + "application": application, "scheduled_interview": scheduled_interview, # Pass to template for title/differentiation "action_url": reverse( "reschedule_candidate_meeting", @@ -2291,7 +2404,7 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): Handles POST to process the form, create a meeting, and redirect back. """ job = get_object_or_404(JobPosting, slug=slug) - candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + candidate = get_object_or_404(Application, pk=candidate_pk, job=job) if request.method == "POST": form = ZoomMeetingForm(request.POST) @@ -2343,7 +2456,7 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): ) # Create a ScheduledInterview record ScheduledInterview.objects.create( - candidate=candidate, + application=candidate, job=job, zoom_meeting=zoom_meeting_instance, interview_date=start_time_val.date(), @@ -2506,7 +2619,7 @@ def is_superuser_check(user): return user.is_superuser -@user_passes_test(is_superuser_check) +@staff_user_required def create_staff_user(request): if request.method == "POST": form = StaffUserCreationForm(request.POST) @@ -2524,7 +2637,7 @@ def create_staff_user(request): return render(request, "user/create_staff.html", {"form": form}) -@user_passes_test(is_superuser_check) +@staff_user_required def admin_settings(request): staffs = User.objects.filter(is_superuser=False) form = ToggleAccountForm() @@ -2535,7 +2648,7 @@ def admin_settings(request): from django.contrib.auth.forms import SetPasswordForm -@user_passes_test(is_superuser_check) +@staff_user_required def set_staff_password(request, pk): user = get_object_or_404(User, pk=pk) print(request.POST) @@ -2557,7 +2670,7 @@ def set_staff_password(request, pk): ) -@user_passes_test(is_superuser_check) +@staff_user_required def account_toggle_status(request, pk): user = get_object_or_404(User, pk=pk) if request.method == "POST": @@ -2589,6 +2702,7 @@ def account_toggle_status(request, pk): @csrf_exempt +@staff_user_required def zoom_webhook_view(request): print(request.headers) print(settings.ZOOM_WEBHOOK_API_KEY) @@ -2605,7 +2719,7 @@ def zoom_webhook_view(request): # Meeting Comments Views -@login_required +@staff_user_required def add_meeting_comment(request, slug): """Add a comment to a meeting""" meeting = get_object_or_404(ZoomMeeting, slug=slug) @@ -2646,7 +2760,7 @@ def add_meeting_comment(request, slug): return redirect("meeting_details", slug=slug) -@login_required +@staff_user_required def edit_meeting_comment(request, slug, comment_id): """Edit a meeting comment""" meeting = get_object_or_404(ZoomMeeting, slug=slug) @@ -2682,7 +2796,7 @@ def edit_meeting_comment(request, slug, comment_id): return render(request, "includes/edit_comment_form.html", context) -@login_required +@staff_user_required def delete_meeting_comment(request, slug, comment_id): """Delete a meeting comment""" meeting = get_object_or_404(ZoomMeeting, slug=slug) @@ -2728,7 +2842,7 @@ def delete_meeting_comment(request, slug, comment_id): return redirect("meeting_details", slug=slug) -@login_required +@staff_user_required def set_meeting_candidate(request, slug): meeting = get_object_or_404(ZoomMeeting, slug=slug) if request.method == "POST" and "HX-Request" not in request.headers: @@ -2745,10 +2859,10 @@ def set_meeting_candidate(request, slug): form = InterviewForm() if job: - form.fields["candidate"].queryset = Candidate.objects.filter(job=job) + form.fields["candidate"].queryset = Application.objects.filter(job=job) else: - form.fields["candidate"].queryset = Candidate.objects.none() + form.fields["candidate"].queryset = Application.objects.none() form.fields["job"].widget.attrs.update( { "hx-get": reverse("set_meeting_candidate", kwargs={"slug": slug}), @@ -2762,7 +2876,7 @@ def set_meeting_candidate(request, slug): # Hiring Agency CRUD Views -@login_required +@staff_user_required def agency_list(request): """List all hiring agencies with search and pagination""" search_query = request.GET.get("q", "") @@ -2792,7 +2906,7 @@ def agency_list(request): return render(request, "recruitment/agency_list.html", context) -@login_required +@staff_user_required def agency_create(request): """Create a new hiring agency""" if request.method == "POST": @@ -2814,13 +2928,13 @@ def agency_create(request): return render(request, "recruitment/agency_form.html", context) -@login_required +@staff_user_required def agency_detail(request, slug): """View details of a specific hiring agency""" agency = get_object_or_404(HiringAgency, slug=slug) # Get candidates associated with this agency - candidates = Candidate.objects.filter(hiring_agency=agency).order_by("-created_at") + candidates = Application.objects.filter(hiring_agency=agency).order_by("-created_at") # Statistics total_candidates = candidates.count() @@ -2841,7 +2955,7 @@ def agency_detail(request, slug): return render(request, "recruitment/agency_detail.html", context) -@login_required +@staff_user_required def agency_update(request, slug): """Update an existing hiring agency""" agency = get_object_or_404(HiringAgency, slug=slug) @@ -2866,7 +2980,7 @@ def agency_update(request, slug): return render(request, "recruitment/agency_form.html", context) -@login_required +@staff_user_required def agency_delete(request, slug): """Delete a hiring agency""" agency = get_object_or_404(HiringAgency, slug=slug) @@ -2887,7 +3001,7 @@ def agency_delete(request, slug): # Notification Views -# @login_required +# @staff_user_required # def notification_list(request): # """List all notifications for the current user""" # # Get filter parameters @@ -2933,7 +3047,7 @@ def agency_delete(request, slug): # return render(request, 'recruitment/notification_list.html', context) -# @login_required +# @staff_user_required # def notification_detail(request, notification_id): # """View details of a specific notification""" # notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) @@ -2949,7 +3063,7 @@ def agency_delete(request, slug): # return render(request, 'recruitment/notification_detail.html', context) -# @login_required +# @staff_user_required # def notification_mark_read(request, notification_id): # """Mark a notification as read""" # notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) @@ -2964,7 +3078,7 @@ def agency_delete(request, slug): # return redirect('notification_list') -# @login_required +# @staff_user_required # def notification_mark_unread(request, notification_id): # """Mark a notification as unread""" # notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) @@ -2979,7 +3093,7 @@ def agency_delete(request, slug): # return redirect('notification_list') -# @login_required +# @staff_user_required # def notification_delete(request, notification_id): # """Delete a notification""" # notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) @@ -2999,7 +3113,7 @@ def agency_delete(request, slug): # return render(request, 'recruitment/notification_confirm_delete.html', context) -# @login_required +# @staff_user_required # def notification_mark_all_read(request): # """Mark all notifications as read for the current user""" # if request.method == 'POST': @@ -3026,7 +3140,7 @@ def agency_delete(request, slug): # return render(request, 'recruitment/notification_confirm_all_read.html', context) -# @login_required +# @staff_user_required # def api_notification_count(request): # """API endpoint to get unread notification count and recent notifications""" # # Get unread notifications @@ -3075,7 +3189,7 @@ def agency_delete(request, slug): # }) -# @login_required +# @staff_user_required # def notification_stream(request): # """SSE endpoint for real-time notifications""" # from django.http import StreamingHttpResponse @@ -3197,11 +3311,11 @@ def agency_delete(request, slug): # return render(request, 'recruitment/agency_candidates.html', context) -@login_required +@staff_user_required def agency_candidates(request, slug): """View all candidates from a specific agency""" agency = get_object_or_404(HiringAgency, slug=slug) - candidates = Candidate.objects.filter(hiring_agency=agency).order_by("-created_at") + candidates = Application.objects.filter(hiring_agency=agency).order_by("-created_at") # Filter by stage if provided stage_filter = request.GET.get("stage") @@ -3226,7 +3340,7 @@ def agency_candidates(request, slug): # Agency Portal Management Views -@login_required +@staff_user_required def agency_assignment_list(request): """List all agency job assignments""" search_query = request.GET.get("q", "") @@ -3259,7 +3373,7 @@ def agency_assignment_list(request): return render(request, "recruitment/agency_assignment_list.html", context) -@login_required +@staff_user_required def agency_assignment_create(request, slug=None): """Create a new agency job assignment""" agency = HiringAgency.objects.get(slug=slug) if slug else None @@ -3297,7 +3411,7 @@ def agency_assignment_create(request, slug=None): return render(request, "recruitment/agency_assignment_form.html", context) -@login_required +@staff_user_required def agency_assignment_detail(request, slug): """View details of a specific agency assignment""" assignment = get_object_or_404( @@ -3305,7 +3419,7 @@ def agency_assignment_detail(request, slug): ) # Get candidates submitted by this agency for this job - candidates = Candidate.objects.filter( + candidates = Application.objects.filter( hiring_agency=assignment.agency, job=assignment.job ).order_by("-created_at") @@ -3334,7 +3448,7 @@ def agency_assignment_detail(request, slug): return render(request, "recruitment/agency_assignment_detail.html", context) -@login_required +@staff_user_required def agency_assignment_update(request, slug): """Update an existing agency assignment""" assignment = get_object_or_404(AgencyJobAssignment, slug=slug) @@ -3359,7 +3473,7 @@ def agency_assignment_update(request, slug): return render(request, "recruitment/agency_assignment_form.html", context) -@login_required +@staff_user_required def agency_access_link_create(request): """Create access link for agency assignment""" if request.method == "POST": @@ -3386,7 +3500,7 @@ def agency_access_link_create(request): return render(request, "recruitment/agency_access_link_form.html", context) -@login_required +@staff_user_required def agency_access_link_detail(request, slug): """View details of an access link""" access_link = get_object_or_404( @@ -3402,7 +3516,7 @@ def agency_access_link_detail(request, slug): return render(request, "recruitment/agency_access_link_detail.html", context) -@login_required +@staff_user_required def agency_assignment_extend_deadline(request, slug): """Extend deadline for an agency assignment""" assignment = get_object_or_404(AgencyJobAssignment, slug=slug) @@ -3438,23 +3552,24 @@ def agency_assignment_extend_deadline(request, slug): # Agency Portal Views (for external agencies) +@agency_user_required def agency_portal_login(request): """Agency login page""" - if request.session.get("agency_assignment_id"): - return redirect("agency_portal_dashboard") + # if request.session.get("agency_assignment_id"): + # return redirect("agency_portal_dashboard") if request.method == "POST": form = AgencyLoginForm(request.POST) if form.is_valid(): # Check if validated_access_link attribute exists - if hasattr(form, "validated_access_link"): - access_link = form.validated_access_link - access_link.record_access() + # if hasattr(form, "validated_access_link"): + # access_link = form.validated_access_link + # access_link.record_access() # Store assignment in session - request.session["agency_assignment_id"] = access_link.assignment.id - request.session["agency_name"] = access_link.assignment.agency.name + # request.session["agency_assignment_id"] = access_link.assignment.id + # request.session["agency_name"] = access_link.assignment.agency.name messages.success(request, f"Welcome, {access_link.assignment.agency.name}!") return redirect("agency_portal_dashboard") @@ -3471,6 +3586,12 @@ def agency_portal_login(request): def portal_login(request): """Unified portal login for agency and candidate""" + if request.user.is_authenticated: + if request.user.user_type == "agency": + return redirect("agency_portal_dashboard") + if request.user.user_type == "candidate": + return redirect("candidate_portal_dashboard") + if request.method == "POST": form = PortalLoginForm(request.POST) @@ -3486,6 +3607,7 @@ def portal_login(request): print(user.user_type) if hasattr(user, "user_type") and user.user_type == user_type: login(request, user) + return redirect("agency_portal_dashboard") # if user_type == "agency": # # Check if user has agency profile @@ -3531,6 +3653,7 @@ def portal_login(request): return render(request, "recruitment/portal_login.html", context) +@candidate_user_required def candidate_portal_dashboard(request): """Candidate portal dashboard""" if not request.user.is_authenticated: @@ -3549,7 +3672,61 @@ def candidate_portal_dashboard(request): return render(request, "recruitment/candidate_portal_dashboard.html", context) -@login_required +@agency_user_required +def agency_portal_persons_list(request): + """Agency portal page showing all persons who come through this agency""" + try: + agency = request.user.agency_profile + except Exception as e: + print(e) + messages.error(request, "No agency profile found.") + return redirect("portal_login") + + # Get all applications for this agency + persons = Person.objects.filter(agency=agency) + # persons = Application.objects.filter( + # hiring_agency=agency + # ).select_related("job").order_by("-created_at") + + # Search functionality + search_query = request.GET.get("q", "") + if search_query: + persons = persons.filter( + Q(first_name__icontains=search_query) | + Q(last_name__icontains=search_query) | + Q(email__icontains=search_query) | + Q(phone__icontains=search_query) | + Q(job__title__icontains=search_query) + ) + + # Filter by stage if provided + stage_filter = request.GET.get("stage", "") + if stage_filter: + persons = persons.filter(stage=stage_filter) + + # Pagination + paginator = Paginator(persons, 20) # Show 20 persons per page + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + # Get stage choices for filter dropdown + stage_choices = Application.Stage.choices + person_form = PersonForm() + person_form.initial['agency'] = agency + + context = { + "agency": agency, + "page_obj": page_obj, + "search_query": search_query, + "stage_filter": stage_filter, + "stage_choices": stage_choices, + "total_persons": persons.count(), + "person_form": person_form, + } + return render(request, "recruitment/agency_portal_persons_list.html", context) + + +@agency_user_required def agency_portal_dashboard(request): """Agency portal dashboard showing all assignments for the agency""" # Get the current assignment to determine the agency @@ -3571,7 +3748,7 @@ def agency_portal_dashboard(request): # Calculate statistics for each assignment assignment_stats = [] for assignment in assignments: - candidates = Candidate.objects.filter( + candidates = Application.objects.filter( hiring_agency=agency, job=assignment.job ).order_by("-created_at") @@ -3606,16 +3783,17 @@ def agency_portal_dashboard(request): return render(request, "recruitment/agency_portal_dashboard.html", context) +@agency_user_required def agency_portal_submit_candidate_page(request, slug): """Dedicated page for submitting a candidate""" - assignment_id = request.session.get("agency_assignment_id") - if not assignment_id: - return redirect("agency_portal_login") + # assignment_id = request.session.get("agency_assignment_id") + # if not assignment_id: + # return redirect("agency_portal_login") # Get the specific assignment by slug and verify it belongs to the same agency - current_assignment = get_object_or_404( - AgencyJobAssignment.objects.select_related("agency"), id=assignment_id - ) + # current_assignment = get_object_or_404( + # AgencyJobAssignment.objects.select_related("agency"), slug=slug + # ) assignment = get_object_or_404( AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug @@ -3625,7 +3803,7 @@ def agency_portal_submit_candidate_page(request, slug): messages.error(request, "Maximum candidate limit reached for this assignment.") return redirect("agency_portal_assignment_detail", slug=assignment.slug) # Verify this assignment belongs to the same agency as the logged-in session - if assignment.agency.id != current_assignment.agency.id: + if assignment.agency.id != assignment.agency.id: messages.error( request, "Access denied: This assignment does not belong to your agency." ) @@ -3640,12 +3818,12 @@ def agency_portal_submit_candidate_page(request, slug): return redirect("agency_portal_assignment_detail", slug=assignment.slug) # Get total submitted candidates for this assignment - total_submitted = Candidate.objects.filter( + total_submitted = Application.objects.filter( hiring_agency=assignment.agency, job=assignment.job ).count() if request.method == "POST": - form = AgencyCandidateSubmissionForm(assignment, request.POST, request.FILES) + form = AgencyApplicationSubmissionForm(assignment, request.POST, request.FILES) if form.is_valid(): candidate = form.save(commit=False) candidate.hiring_source = "AGENCY" @@ -3684,7 +3862,7 @@ def agency_portal_submit_candidate_page(request, slug): else: messages.error(request, "Please correct errors below.") else: - form = AgencyCandidateSubmissionForm(assignment) + form = AgencyApplicationSubmissionForm(assignment) context = { "form": form, @@ -3694,6 +3872,7 @@ def agency_portal_submit_candidate_page(request, slug): return render(request, "recruitment/agency_portal_submit_candidate.html", context) +@agency_user_required def agency_portal_submit_candidate(request): """Handle candidate submission via AJAX (for embedded form)""" assignment_id = request.session.get("agency_assignment_id") @@ -3716,7 +3895,7 @@ def agency_portal_submit_candidate(request): return redirect("agency_portal_dashboard") if request.method == "POST": - form = AgencyCandidateSubmissionForm(assignment, request.POST, request.FILES) + form = AgencyApplicationSubmissionForm(assignment, request.POST, request.FILES) if form.is_valid(): candidate = form.save(commit=False) candidate.hiring_source = "AGENCY" @@ -3746,7 +3925,7 @@ def agency_portal_submit_candidate(request): else: messages.error(request, "Please correct errors below.") else: - form = AgencyCandidateSubmissionForm(assignment) + form = AgencyApplicationSubmissionForm(assignment) context = { "form": form, @@ -3759,18 +3938,21 @@ def agency_portal_submit_candidate(request): def agency_portal_assignment_detail(request, slug): """View details of a specific assignment - routes to admin or agency template""" - print(slug) # Check if this is an agency portal user (via session) - assignment_id = request.session.get("agency_assignment_id") - is_agency_user = bool(assignment_id) - return agency_assignment_detail_agency(request, slug, assignment_id) + # assignment_id = request.session.get("agency_assignment_id") + # is_agency_user = bool(assignment_id) + # return agency_assignment_detail_agency(request, slug, assignment_id) # if is_agency_user: # # Agency Portal User - Route to agency-specific template # else: # # Admin User - Route to admin template # return agency_assignment_detail_admin(request, slug) + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug + ) +@agency_user_required def agency_assignment_detail_agency(request, slug, assignment_id): """Handle agency portal assignment detail view""" # Get the assignment by slug and verify it belongs to same agency @@ -3790,7 +3972,7 @@ def agency_assignment_detail_agency(request, slug, assignment_id): return redirect("agency_portal_dashboard") # Get candidates submitted by this agency for this job - candidates = Candidate.objects.filter( + candidates = Application.objects.filter( hiring_agency=assignment.agency, job=assignment.job ).order_by("-created_at") @@ -3831,6 +4013,7 @@ def agency_assignment_detail_agency(request, slug, assignment_id): return render(request, "recruitment/agency_portal_assignment_detail.html", context) +@staff_user_required def agency_assignment_detail_admin(request, slug): """Handle admin assignment detail view""" assignment = get_object_or_404( @@ -3838,7 +4021,7 @@ def agency_assignment_detail_admin(request, slug): ) # Get candidates submitted by this agency for this job - candidates = Candidate.objects.filter( + candidates = Application.objects.filter( hiring_agency=assignment.agency, job=assignment.job ).order_by("-created_at") @@ -3857,6 +4040,7 @@ def agency_assignment_detail_admin(request, slug): return render(request, "recruitment/agency_assignment_detail.html", context) +@agency_user_required def agency_portal_edit_candidate(request, candidate_id): """Edit a candidate for agency portal""" assignment_id = request.session.get("agency_assignment_id") @@ -3871,7 +4055,7 @@ def agency_portal_edit_candidate(request, candidate_id): agency = current_assignment.agency # Get candidate and verify it belongs to this agency - candidate = get_object_or_404(Candidate, id=candidate_id, hiring_agency=agency) + candidate = get_object_or_404(Application, id=candidate_id, hiring_agency=agency) if request.method == "POST": # Handle form submission @@ -3917,6 +4101,7 @@ def agency_portal_edit_candidate(request, candidate_id): return redirect("agency_portal_dashboard") +@agency_user_required def agency_portal_delete_candidate(request, candidate_id): """Delete a candidate for agency portal""" assignment_id = request.session.get("agency_assignment_id") @@ -3931,7 +4116,7 @@ def agency_portal_delete_candidate(request, candidate_id): agency = current_assignment.agency # Get candidate and verify it belongs to this agency - candidate = get_object_or_404(Candidate, id=candidate_id, hiring_agency=agency) + candidate = get_object_or_404(Application, id=candidate_id, hiring_agency=agency) if request.method == "POST": try: @@ -3954,7 +4139,7 @@ def agency_portal_delete_candidate(request, candidate_id): # Message Views -@login_required +@staff_user_required def message_list(request): """List all messages for the current user""" # Get filter parameters @@ -4194,31 +4379,21 @@ def api_unread_count(request): # Document Views @login_required -def document_upload(request, candidate_id): - """Upload a document for a candidate""" - candidate = get_object_or_404(Candidate, pk=candidate_id) +def document_upload(request, application_id): + """Upload a document for an application""" + application = get_object_or_404(Application, pk=application_id) if request.method == "POST": if request.FILES.get('file'): document = Document.objects.create( - candidate=candidate, + content_object=application, # Use Generic Foreign Key to link to Application file=request.FILES['file'], document_type=request.POST.get('document_type', 'other'), description=request.POST.get('description', ''), uploaded_by=request.user, ) messages.success(request, f'Document "{document.get_document_type_display()}" uploaded successfully!') - - # Handle AJAX requests - # if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - # return JsonResponse({ - # 'success': True, - # 'message': 'Document uploaded successfully!', - # 'document_id': document.id, - # 'file_name': document.file.name, - # 'file_size': document.file_size, - # }) - return redirect('candidate_detail', slug=candidate.job.slug) + return redirect('candidate_detail', slug=application.job.slug) @login_required @@ -4226,8 +4401,14 @@ def document_delete(request, document_id): """Delete a document""" document = get_object_or_404(Document, id=document_id) - # Check permission - if document.candidate.job.assigned_to != request.user and not request.user.is_superuser: + # Check permission - document is now linked to Application via Generic Foreign Key + if hasattr(document.content_object, 'job'): + if document.content_object.job.assigned_to != request.user and not request.user.is_superuser: + messages.error(request, "You don't have permission to delete this document.") + return JsonResponse({'success': False, 'error': 'Permission denied'}) + job_slug = document.content_object.job.slug + else: + # Handle other content object types messages.error(request, "You don't have permission to delete this document.") return JsonResponse({'success': False, 'error': 'Permission denied'}) @@ -4240,7 +4421,7 @@ def document_delete(request, document_id): if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return JsonResponse({'success': True, 'message': 'Document deleted successfully!'}) else: - return redirect('candidate_detail', slug=document.candidate.job.slug) + return redirect('candidate_detail', slug=job_slug) return JsonResponse({'success': False, 'error': 'Method not allowed'}) @@ -4250,8 +4431,13 @@ def document_download(request, document_id): """Download a document""" document = get_object_or_404(Document, id=document_id) - # Check permission - if document.candidate.job.assigned_to != request.user and not request.user.is_superuser: + # Check permission - document is now linked to Application via Generic Foreign Key + if hasattr(document.content_object, 'job'): + if document.content_object.job.assigned_to != request.user and not request.user.is_superuser: + messages.error(request, "You don't have permission to download this document.") + return JsonResponse({'success': False, 'error': 'Permission denied'}) + else: + # Handle other content object types messages.error(request, "You don't have permission to download this document.") return JsonResponse({'success': False, 'error': 'Permission denied'}) @@ -4263,6 +4449,7 @@ def document_download(request, document_id): return JsonResponse({'success': False, 'error': 'File not found'}) +@login_required def portal_logout(request): """Logout from portal""" logout(request) @@ -4345,6 +4532,7 @@ def agency_access_link_reactivate(request, slug): return render(request, "recruitment/agency_access_link_confirm.html", context) +@agency_user_required def api_candidate_detail(request, candidate_id): """API endpoint to get candidate details for agency portal""" try: @@ -4361,7 +4549,7 @@ def api_candidate_detail(request, candidate_id): agency = current_assignment.agency # Get candidate and verify it belongs to this agency - candidate = get_object_or_404(Candidate, id=candidate_id, hiring_agency=agency) + candidate = get_object_or_404(Application, id=candidate_id, hiring_agency=agency) # Return candidate data response_data = { @@ -4380,13 +4568,13 @@ def api_candidate_detail(request, candidate_id): return JsonResponse({"success": False, "error": str(e)}) -@login_required +@staff_user_required def compose_candidate_email(request, job_slug, candidate_slug): """Compose email to participants about a candidate""" from .email_service import send_bulk_email job = get_object_or_404(JobPosting, slug=job_slug) - candidate = get_object_or_404(Candidate, slug=candidate_slug, job=job) + candidate = get_object_or_404(Application, slug=candidate_slug, job=job) if request.method == "POST": form = CandidateEmailForm(job, candidate, request.POST) if form.is_valid(): @@ -4515,7 +4703,7 @@ def compose_candidate_email(request, job_slug, candidate_slug): request, "includes/email_compose_form.html", {"form": form, "job": job, "candidate": candidate}, - ) + ) else: # GET request - show the form @@ -4529,7 +4717,7 @@ def compose_candidate_email(request, job_slug, candidate_slug): # Source CRUD Views -@login_required +@staff_user_required def source_list(request): """List all sources with search and pagination""" search_query = request.GET.get("q", "") @@ -4558,7 +4746,7 @@ def source_list(request): return render(request, "recruitment/source_list.html", context) -@login_required +@staff_user_required def source_create(request): """Create a new source""" if request.method == "POST": @@ -4580,7 +4768,7 @@ def source_create(request): return render(request, "recruitment/source_form.html", context) -@login_required +@staff_user_required def source_detail(request, slug): """View details of a specific source""" source = get_object_or_404(Source, slug=slug) @@ -4607,7 +4795,7 @@ def source_detail(request, slug): return render(request, "recruitment/source_detail.html", context) -@login_required +@staff_user_required def source_update(request, slug): """Update an existing source""" source = get_object_or_404(Source, slug=slug) @@ -4632,7 +4820,7 @@ def source_update(request, slug): return render(request, "recruitment/source_form.html", context) -@login_required +@staff_user_required def source_delete(request, slug): """Delete a source""" source = get_object_or_404(Source, slug=slug) @@ -4707,10 +4895,12 @@ def candidate_signup(request,slug): if request.method == "POST": form = CandidateSignupForm(request.POST) if form.is_valid(): - candidate = form.save(commit=False) - candidate.job = job - candidate.save() - return redirect("application_submit_form",template_slug=job.form_template.slug) + try: + application = form.save(job) + return redirect("application_success", slug=job.slug) + except Exception as e: + messages.error(request, f"Error creating application: {str(e)}") + return render(request, "recruitment/candidate_signup.html", {"form": form, "job": job}) form = CandidateSignupForm() - return render(request, "recruitment/candidate_signup.html", {"form": form, "job": job}) \ No newline at end of file + return render(request, "recruitment/candidate_signup.html", {"form": form, "job": job}) diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 569a3fe..519592f 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -30,6 +30,9 @@ from django.utils import timezone from datetime import timedelta import json +# Add imports for user type restrictions +from recruitment.decorators import StaffRequiredMixin, staff_user_required + from datastar_py.django import ( DatastarResponse, @@ -39,7 +42,7 @@ from datastar_py.django import ( # from rich import print from rich.markdown import CodeBlock -class JobListView(LoginRequiredMixin, ListView): +class JobListView(LoginRequiredMixin, StaffRequiredMixin, ListView): model = models.JobPosting template_name = 'jobs/job_list.html' context_object_name = 'jobs' @@ -47,7 +50,6 @@ class JobListView(LoginRequiredMixin, ListView): def get_queryset(self): queryset = super().get_queryset().order_by('-created_at') - # Handle search search_query = self.request.GET.get('search', '') if search_query: @@ -58,24 +60,23 @@ class JobListView(LoginRequiredMixin, ListView): ) # Filter for non-staff users - if not self.request.user.is_staff: - queryset = queryset.filter(status='Published') + # if not self.request.user.is_staff: + # queryset = queryset.filter(status='Published') - status=self.request.GET.get('status') + status = self.request.GET.get('status') if status: - queryset=queryset.filter(status=status) + queryset = queryset.filter(status=status) return queryset def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) context['search_query'] = self.request.GET.get('search', '') context['lang'] = get_language() return context -class JobCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): +class JobCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView): model = models.JobPosting form_class = forms.JobPostingForm template_name = 'jobs/create_job.html' @@ -83,7 +84,7 @@ class JobCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): success_message = 'Job created successfully.' -class JobUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): +class JobUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView): model = models.JobPosting form_class = forms.JobPostingForm template_name = 'jobs/edit_job.html' @@ -92,27 +93,25 @@ class JobUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): slug_url_kwarg = 'slug' -class JobDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): +class JobDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView): model = models.JobPosting template_name = 'jobs/partials/delete_modal.html' success_url = reverse_lazy('job_list') success_message = 'Job deleted successfully.' slug_url_kwarg = 'slug' -class JobCandidatesListView(LoginRequiredMixin, ListView): - model = models.Candidate +class JobApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView): + model = models.Application template_name = 'jobs/job_candidates_list.html' - context_object_name = 'candidates' + context_object_name = 'applications' paginate_by = 10 - - def get_queryset(self): # Get the job by slug self.job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug']) # Filter candidates for this specific job - queryset = models.Candidate.objects.filter(job=self.job) + queryset = models.Application.objects.filter(job=self.job) if self.request.GET.get('stage'): stage=self.request.GET.get('stage') @@ -132,7 +131,7 @@ class JobCandidatesListView(LoginRequiredMixin, ListView): # Filter for non-staff users if not self.request.user.is_staff: - return models.Candidate.objects.none() # Restrict for non-staff + return models.Application.objects.none() # Restrict for non-staff return queryset.order_by('-created_at') @@ -143,10 +142,10 @@ class JobCandidatesListView(LoginRequiredMixin, ListView): return context -class CandidateListView(LoginRequiredMixin, ListView): - model = models.Candidate +class ApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView): + model = models.Application template_name = 'recruitment/candidate_list.html' - context_object_name = 'candidates' + context_object_name = 'applications' paginate_by = 100 def get_queryset(self): @@ -156,22 +155,22 @@ class CandidateListView(LoginRequiredMixin, ListView): 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) | - Q(last_name__icontains=search_query) | - Q(email__icontains=search_query) | - Q(phone__icontains=search_query) | - Q(stage__icontains=search_query) | - Q(job__title__icontains=search_query) - ) + # if search_query: + # queryset = queryset.filter( + # Q(first_name__icontains=search_query) | + # Q(last_name__icontains=search_query) | + # Q(email__icontains=search_query) | + # Q(phone__icontains=search_query) | + # 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 + # if not self.request.user.is_staff: + # return models.Application.objects.none() # Restrict for non-staff return queryset.order_by('-created_at') @@ -184,9 +183,9 @@ class CandidateListView(LoginRequiredMixin, ListView): return context -class CandidateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): - model = models.Candidate - form_class = forms.CandidateForm +class ApplicationCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView): + model = models.Application + form_class = forms.ApplicationForm template_name = 'recruitment/candidate_create.html' success_url = reverse_lazy('candidate_list') success_message = 'Candidate created successfully.' @@ -204,18 +203,23 @@ class CandidateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): form.instance.job = job return super().form_valid(form) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.request.method == 'GET': + context['person_form'] = forms.PersonForm() + return context -class CandidateUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): - model = models.Candidate - form_class = forms.CandidateForm +class ApplicationUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView): + model = models.Application + form_class = forms.ApplicationForm template_name = 'recruitment/candidate_update.html' success_url = reverse_lazy('candidate_list') success_message = 'Candidate updated successfully.' slug_url_kwarg = 'slug' -class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): - model = models.Candidate +class ApplicationDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView): + model = models.Application template_name = 'recruitment/candidate_delete.html' success_url = reverse_lazy('candidate_list') success_message = 'Candidate deleted successfully.' @@ -225,28 +229,30 @@ class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): def retry_scoring_view(request,slug): from django_q.tasks import async_task - candidate = get_object_or_404(models.Candidate, slug=slug) + application = get_object_or_404(models.Application, slug=slug) async_task( 'recruitment.tasks.handle_reume_parsing_and_scoring', - candidate.pk, + application.pk, hook='recruitment.hooks.callback_ai_parsing', sync=True, ) - return redirect('candidate_detail', slug=candidate.slug) + return redirect('candidate_detail', slug=application.slug) @login_required +@staff_user_required def training_list(request): materials = models.TrainingMaterial.objects.all().order_by('-created_at') return render(request, 'recruitment/training_list.html', {'materials': materials}) @login_required +@staff_user_required def candidate_detail(request, slug): from rich.json import JSON - candidate = get_object_or_404(models.Candidate, slug=slug) + candidate = get_object_or_404(models.Application, slug=slug) try: parsed = ast.literal_eval(candidate.parsed_summary) except: @@ -255,7 +261,7 @@ def candidate_detail(request, slug): # Create stage update form for staff users stage_form = None if request.user.is_staff: - stage_form = forms.CandidateStageForm() + stage_form = forms.ApplicationStageForm() @@ -269,31 +275,33 @@ def candidate_detail(request, slug): @login_required +@staff_user_required def candidate_resume_template_view(request, slug): """Display formatted resume template for a candidate""" - candidate = get_object_or_404(models.Candidate, slug=slug) + application = get_object_or_404(models.Application, slug=slug) if not request.user.is_staff: messages.error(request, _("You don't have permission to view this page.")) return redirect('candidate_list') return render(request, 'recruitment/candidate_resume_template.html', { - 'candidate': candidate + 'application': application }) @login_required +@staff_user_required def candidate_update_stage(request, slug): """Handle HTMX stage update requests""" - candidate = get_object_or_404(models.Candidate, slug=slug) - form = forms.CandidateStageForm(request.POST, instance=candidate) + application = get_object_or_404(models.Application, slug=slug) + form = forms.ApplicationStageForm(request.POST, instance=application) if form.is_valid(): stage_value = form.cleaned_data['stage'] - candidate.stage = stage_value - candidate.save(update_fields=['stage']) - messages.success(request,"Candidate Stage Updated") - return redirect("candidate_detail",slug=candidate.slug) + application.stage = stage_value + application.save(update_fields=['stage']) + messages.success(request,"application Stage Updated") + return redirect("candidate_detail",slug=application.slug) -class TrainingListView(LoginRequiredMixin, ListView): +class TrainingListView(LoginRequiredMixin, StaffRequiredMixin, ListView): model = models.TrainingMaterial template_name = 'recruitment/training_list.html' context_object_name = 'materials' @@ -321,7 +329,7 @@ class TrainingListView(LoginRequiredMixin, ListView): return context -class TrainingCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): +class TrainingCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView): model = models.TrainingMaterial form_class = forms.TrainingMaterialForm template_name = 'recruitment/training_create.html' @@ -333,7 +341,7 @@ class TrainingCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): return super().form_valid(form) -class TrainingUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): +class TrainingUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView): model = models.TrainingMaterial form_class = forms.TrainingMaterialForm template_name = 'recruitment/training_update.html' @@ -342,13 +350,13 @@ class TrainingUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): slug_url_kwarg = 'slug' -class TrainingDetailView(LoginRequiredMixin, DetailView): +class TrainingDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView): model = models.TrainingMaterial template_name = 'recruitment/training_detail.html' context_object_name = 'material' slug_url_kwarg = 'slug' -class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): +class TrainingDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView): model = models.TrainingMaterial template_name = 'recruitment/training_delete.html' success_url = reverse_lazy('training_list') @@ -366,6 +374,7 @@ TARGET_TIME_TO_HIRE_DAYS = 45 # Used for the template visualization @login_required +@staff_user_required def dashboard_view(request): selected_job_pk = request.GET.get('selected_job_pk') @@ -374,7 +383,7 @@ def dashboard_view(request): # --- 1. BASE QUERYSETS & GLOBAL METRICS (UNFILTERED) --- all_jobs_queryset = models.JobPosting.objects.all().order_by('-created_at') - all_candidates_queryset = models.Candidate.objects.all() + all_candidates_queryset = models.Application.objects.all() # Global KPI Card Metrics total_jobs_global = all_jobs_queryset.count() @@ -383,7 +392,7 @@ def dashboard_view(request): # Data for Job App Count Chart (always for ALL jobs) job_titles = [job.title for job in all_jobs_queryset] - job_app_counts = [job.candidates.count() for job in all_jobs_queryset] + job_app_counts = [job.applications.count() for job in all_jobs_queryset] # --- 2. TIME SERIES: GLOBAL DAILY APPLICANTS --- @@ -453,7 +462,7 @@ def dashboard_view(request): open_positions_agg = job_scope_queryset.filter(status="ACTIVE").aggregate(total_open=Sum('open_positions')) total_open_positions = open_positions_agg['total_open'] or 0 average_applications_result = job_scope_queryset.annotate( - candidate_count=Count('candidates', distinct=True) + candidate_count=Count('applications', distinct=True) ).aggregate(avg_apps=Avg('candidate_count'))['avg_apps'] average_applications = round(average_applications_result or 0, 2) @@ -588,6 +597,7 @@ def dashboard_view(request): @login_required +@staff_user_required def candidate_offer_view(request, slug): """View for candidates in the Offer stage""" job = get_object_or_404(models.JobPosting, slug=slug) @@ -617,6 +627,7 @@ def candidate_offer_view(request, slug): @login_required +@staff_user_required def candidate_hired_view(request, slug): """View for hired candidates""" job = get_object_or_404(models.JobPosting, slug=slug) @@ -646,13 +657,15 @@ def candidate_hired_view(request, slug): @login_required +@staff_user_required def update_candidate_status(request, job_slug, candidate_slug, stage_type, status): """Handle exam/interview/offer status updates""" from django.utils import timezone job = get_object_or_404(models.JobPosting, slug=job_slug) - candidate = get_object_or_404(models.Candidate, slug=candidate_slug, job=job) + candidate = get_object_or_404(models.Application, slug=candidate_slug, job=job) print(stage_type) + print(status) print(request.method) if request.method == "POST": if stage_type == 'exam': @@ -711,6 +724,7 @@ STAGE_CONFIG = { @login_required +@staff_user_required def export_candidates_csv(request, job_slug, stage): """Export candidates for a specific stage as CSV""" job = get_object_or_404(models.JobPosting, slug=job_slug) @@ -724,9 +738,9 @@ def export_candidates_csv(request, job_slug, stage): # Filter candidates based on stage if stage == 'hired': - candidates = job.candidates.filter(**config['filter']) + candidates = job.applications.filter(**config['filter']) else: - candidates = job.candidates.filter(**config['filter']) + candidates = job.applications.filter(**config['filter']) # Handle search if provided search_query = request.GET.get('search', '') @@ -850,6 +864,7 @@ def export_candidates_csv(request, job_slug, stage): @login_required +@staff_user_required def sync_hired_candidates(request, job_slug): """Sync hired candidates to external sources using Django-Q""" from django_q.tasks import async_task @@ -888,6 +903,7 @@ def sync_hired_candidates(request, job_slug): @login_required +@staff_user_required def test_source_connection(request, source_id): """Test connection to an external source""" from .candidate_sync_service import CandidateSyncService @@ -922,6 +938,7 @@ def test_source_connection(request, source_id): @login_required +@staff_user_required def sync_task_status(request, task_id): """Check the status of a sync task""" from django_q.models import Task @@ -973,6 +990,7 @@ def sync_task_status(request, task_id): @login_required +@staff_user_required def sync_history(request, job_slug=None): """View sync history and logs""" from .models import IntegrationLog @@ -1007,7 +1025,7 @@ def sync_history(request, job_slug=None): #participants views -class ParticipantsListView(LoginRequiredMixin, ListView): +class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView): model = models.Participants template_name = 'participants/participants_list.html' context_object_name = 'participants' @@ -1036,13 +1054,13 @@ class ParticipantsListView(LoginRequiredMixin, ListView): context = super().get_context_data(**kwargs) context['search_query'] = self.request.GET.get('search', '') return context -class ParticipantsDetailView(LoginRequiredMixin, DetailView): +class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView): model = models.Participants template_name = 'participants/participants_detail.html' context_object_name = 'participant' slug_url_kwarg = 'slug' -class ParticipantsCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): +class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView): model = models.Participants form_class = forms.ParticipantsForm template_name = 'participants/participants_create.html' @@ -1058,7 +1076,7 @@ class ParticipantsCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateVie -class ParticipantsUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): +class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView): model = models.Participants form_class = forms.ParticipantsForm template_name = 'participants/participants_create.html' @@ -1066,7 +1084,7 @@ class ParticipantsUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateVie success_message = 'Participant updated successfully.' slug_url_kwarg = 'slug' -class ParticipantsDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView): +class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView): model = models.Participants success_url = reverse_lazy('participants_list') # Redirect to the participants list after success diff --git a/templates/base.html b/templates/base.html index 98ac233..fc17266 100644 --- a/templates/base.html +++ b/templates/base.html @@ -238,7 +238,15 @@ {% include "icons/users.html" %} - {% trans "Applicants" %} + {% trans "Applications" %} + + + + @@ -330,6 +338,7 @@ + + + + + + + + +{% endblock %} diff --git a/templates/people/person_detail.html b/templates/people/person_detail.html new file mode 100644 index 0000000..79c115a --- /dev/null +++ b/templates/people/person_detail.html @@ -0,0 +1,607 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block title %}{{ person.get_full_name }} - {{ block.super }}{% endblock %} + +{% block customCSS %} + +{% endblock %} + +{% block content %} +
+ + + + +
+
+
+ {% if person.profile_image %} + {{ person.get_full_name }} + {% else %} +
+ +
+ {% endif %} +
+
+

{{ person.get_full_name }}

+ {% if person.email %} +

+ {{ person.email }} +

+ {% endif %} +
+ {% if person.nationality %} + + {{ person.nationality }} + + {% endif %} + {% if person.gender %} + + {% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %} + + {% endif %} + {% if person.user %} + + {% trans "User Account" %} + + {% endif %} +
+ {% if user.is_staff %} +
+ + {% trans "Edit Person" %} + + +
+ {% endif %} +
+
+
+ +
+ +
+
+
+
+
{% trans "Personal Information" %}
+ +
+ + {% trans "Full Name" %}: + {{ person.get_full_name }} +
+ + {% if person.first_name %} +
+ + {% trans "First Name" %}: + {{ person.first_name }} +
+ {% endif %} + + {% if person.middle_name %} +
+ + {% trans "Middle Name" %}: + {{ person.middle_name }} +
+ {% endif %} + + {% if person.last_name %} +
+ + {% trans "Last Name" %}: + {{ person.last_name }} +
+ {% endif %} + + {% if person.date_of_birth %} +
+ + {% trans "Date of Birth" %}: + {{ person.date_of_birth }} +
+ {% endif %} + + {% if person.gender %} +
+ + {% trans "Gender" %}: + + {% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %} + +
+ {% endif %} + + {% if person.nationality %} +
+ + {% trans "Nationality" %}: + {{ person.nationality }} +
+ {% endif %} +
+
+
+
+ + +
+
+
+
+
{% trans "Contact Information" %}
+ + {% if person.email %} +
+ + {% trans "Email" %}: + + + {{ person.email }} + + +
+ {% endif %} + + {% if person.phone %} +
+ + {% trans "Phone" %}: + + + {{ person.phone }} + + +
+ {% endif %} + + {% if person.address %} +
+ + {% trans "Address" %}: + {{ person.address|linebreaksbr }} +
+ {% endif %} + + {% if person.linkedin_profile %} +
+ + {% trans "LinkedIn" %}: + + + {% trans "View Profile" %} + + + +
+ {% endif %} +
+
+
+
+
+ + +
+ +
+
+
+
+ {% trans "Applications" %} + {{ person.applications.count }} +
+ + {% if person.applications %} + {% for application in person.applications.all %} + + {% endfor %} + {% else %} +
+ +

{% trans "No applications found" %}

+
+ {% endif %} +
+
+
+ + +
+
+
+
+ {% trans "Documents" %} + {{ person.documents.count }} +
+ + {% if person.documents %} + {% for document in person.documents %} + + {% endfor %} + {% else %} +
+ +

{% trans "No documents found" %}

+
+ {% endif %} +
+
+
+
+ + +
+
+
+
+
+ {% trans "System Information" %} +
+
+
+
+ + {% trans "Created" %}: + {{ person.created_at|date:"d M Y H:i" }} +
+
+
+
+ + {% trans "Last Updated" %}: + {{ person.updated_at|date:"d M Y H:i" }} +
+
+ {% if person.user %} +
+
+ + {% trans "User Account" %}: + + + {{ person.user.username }} + + +
+
+ {% endif %} +
+
+
+
+
+ + +
+
+
+ + {% trans "Back to People" %} + + {% if user.is_staff %} +
+ + {% trans "Edit Person" %} + + +
+ {% endif %} +
+
+
+
+{% endblock %} + +{% block customJS %} + +{% endblock %} diff --git a/templates/people/person_list.html b/templates/people/person_list.html new file mode 100644 index 0000000..a212326 --- /dev/null +++ b/templates/people/person_list.html @@ -0,0 +1,411 @@ +{% extends "base.html" %} +{% load static i18n %} + +{% block title %}People - {{ block.super }}{% endblock %} + +{% block customCSS %} + +{% endblock %} + +{% block content %} +
+ +
+

+ {% trans "People Directory" %} +

+ + {% trans "Add New Person" %} + +
+ + +
+
+
+
+ +
+
+ + +
+
+
+ +
+
+ {% if request.GET.q %}{% endif %} + +
+ + +
+ +
+ + +
+ +
+
+ + {% if request.GET.q or request.GET.nationality or request.GET.gender %} + + {% trans "Clear" %} + + {% endif %} +
+
+
+
+
+
+
+ + {% if people_list %} +
+ + {% include "includes/_list_view_switcher.html" with list_id="person-list" %} + + +
+
+ + + + + + + + + + + + + + + + {% for person in people_list %} + + + + + + + + + + + + {% endfor %} + +
{% trans "Photo" %}{% trans "Name" %}{% trans "Email" %}{% trans "Phone" %}{% trans "Nationality" %}{% trans "Gender" %}{% trans "Agency" %}{% trans "Created" %}{% trans "Actions" %}
+ {% if person.profile_image %} + {{ person.get_full_name }} + {% else %} +
+ +
+ {% endif %} +
+ + {{ person.full_name }} + + {{ person.email|default:"N/A" }}{{ person.phone|default:"N/A" }} + {% if person.nationality %} + {{ person.nationality }} + {% else %} + N/A + {% endif %} + + {% if person.gender %} + + {% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %} + + {% else %} + N/A + {% endif %} + {{ person.agency.name|default:"N/A" }}{{ person.created_at|date:"d-m-Y" }} +
+ + + + {% if user.is_staff %} + + + + + {% endif %} +
+
+
+
+ + +
+ {% for person in people_list %} +
+
+
+
+
+ {% if person.profile_image %} + {{ person.get_full_name }} + {% else %} +
+ +
+ {% endif %} +
+
+
+ + {{ person.get_full_name }} + +
+

{{ person.email|default:"N/A" }}

+
+
+ +
+ {% if person.phone %} +
+ {{ person.phone }} +
+ {% endif %} + {% if person.nationality %} +
+ + {{ person.nationality }} +
+ {% endif %} + {% if person.gender %} +
+ + + {% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %} + +
+ {% endif %} + {% if person.date_of_birth %} +
+ {{ person.date_of_birth }} +
+ {% endif %} +
+ +
+
+ + {% trans "View" %} + + {% if user.is_staff %} + + {% trans "Edit" %} + + + {% endif %} +
+
+
+
+
+ {% endfor %} +
+
+ + + {% include "includes/paginator.html" %} + {% else %} + +
+
+ +

{% trans "No people found" %}

+

{% trans "Create your first person record." %}

+ {% if user.is_staff %} + + {% trans "Add Person" %} + + {% endif %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/people/update_person.html b/templates/people/update_person.html new file mode 100644 index 0000000..788cb56 --- /dev/null +++ b/templates/people/update_person.html @@ -0,0 +1,572 @@ +{% extends "base.html" %} +{% load static i18n crispy_forms_tags %} + +{% block title %}Update {{ person.get_full_name }} - {{ block.super }}{% endblock %} + +{% block customCSS %} + +{% endblock %} + +{% block content %} +
+
+ + + + +
+

+ {% trans "Update Person" %} +

+ +
+ + +
+
+
+
{% trans "Currently Editing" %}
+
+ {% if person.profile_image %} + {{ person.get_full_name }} + {% else %} +
+ +
+ {% endif %} +
+
{{ person.get_full_name }}
+ {% if person.email %} +

{{ person.email }}

+ {% endif %} + + {% trans "Created" %}: {{ person.created_at|date:"d M Y" }} • + {% trans "Last Updated" %}: {{ person.updated_at|date:"d M Y" }} + +
+
+
+
+
+ + +
+
+ {% if form.non_field_errors %} + + {% endif %} + + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +
+ {% csrf_token %} + + +
+
+
+
+ {% if person.profile_image %} + Current Profile +
{% trans "Click to change photo" %}
+

{% trans "Current photo will be replaced" %}

+ {% else %} + +
{% trans "Upload Profile Photo" %}
+

{% trans "Click to browse or drag and drop" %}

+ {% endif %} +
+ +
+ {% if person.profile_image %} +
+ + {% trans "Leave empty to keep current photo" %} + +
+ {% endif %} +
+
+ + +
+
+
+ {% trans "Personal Information" %} +
+
+
+ {{ form.first_name|as_crispy_field }} +
+
+ {{ form.middle_name|as_crispy_field }} +
+
+ {{ form.last_name|as_crispy_field }} +
+
+ + +
+
+
+ {% trans "Contact Information" %} +
+
+
+ {{ form.email|as_crispy_field }} +
+
+ {{ form.phone|as_crispy_field }} +
+
+ + +
+
+
+ {% trans "Additional Information" %} +
+
+
+ {{ form.date_of_birth|as_crispy_field }} +
+
+ {{ form.nationality|as_crispy_field }} +
+
+ {{ form.gender|as_crispy_field }} +
+
+ + +
+
+
+ {% trans "Address Information" %} +
+
+
+ {{ form.address|as_crispy_field }} +
+
+ + +
+
+
+ {% trans "Professional Profile" %} +
+
+
+
+ + + + {% trans "Optional: Add LinkedIn profile URL" %} + +
+
+
+ + +
+
+
+ +
+ + +
+
+
+
+
+
+
+
+
+{% endblock %} + +{% block customJS %} + +{% endblock %} diff --git a/templates/portal_base.html b/templates/portal_base.html index 0e1b28b..7386ebf 100644 --- a/templates/portal_base.html +++ b/templates/portal_base.html @@ -76,6 +76,24 @@ {% endif %} - +
@@ -210,10 +210,12 @@ {% trans "Share these credentials securely with the agency. They can use this information to log in and submit candidates." %}
-
- {% trans "View Access Links Details" %} - + {% if access_link %} + + {% trans "View Access Links Details" %} + + {% endif %} @@ -331,7 +333,7 @@ - +
@@ -488,14 +490,14 @@ function copyToClipboard(elementId) { function confirmDeactivate() { if (confirm('{% trans "Are you sure you want to deactivate this access link? Agencies will no longer be able to use it." %}')) { // Submit form to deactivate - window.location.href = '{% url "agency_access_link_deactivate" access_link.slug %}'; + window.location.href = ''; } } function confirmReactivate() { if (confirm('{% trans "Are you sure you want to reactivate this access link?" %}')) { // Submit form to reactivate - window.location.href = '{% url "agency_access_link_reactivate" access_link.slug %}'; + window.location.href = ''; } } diff --git a/templates/recruitment/agency_portal_persons_list.html b/templates/recruitment/agency_portal_persons_list.html new file mode 100644 index 0000000..8e26f28 --- /dev/null +++ b/templates/recruitment/agency_portal_persons_list.html @@ -0,0 +1,390 @@ +{% extends 'portal_base.html' %} +{% load static i18n crispy_forms_tags %} + +{% block title %}{% trans "Persons List" %} - ATS{% endblock %} +{% block customCSS %} + +{% endblock%} + +{% block content %} +
+
+
+

+ + {% trans "All Persons" %} +

+

+ {% trans "All persons who come through" %} {{ agency.name }} +

+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+
+
+ +
+

{{ total_persons }}

+

{% trans "Total Persons" %}

+
+
+
+
+
+
+
+ +
+

{{ page_obj|length }}

+

{% trans "Showing on this page" %}

+
+
+
+
+ + +
+
+ {% if page_obj %} +
+ + + + + + + + + + + + + + {% for person in page_obj %} + + + + + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Email" %}{% trans "Phone" %}{% trans "Job" %}{% trans "Stage" %}{% trans "Applied Date" %}{% trans "Actions" %}
+
+
+ {{ person.first_name|first|upper }}{{ person.last_name|first|upper }} +
+
+
{{ person.first_name }} {{ person.last_name }}
+ {% if person.address %} + {{ person.address|truncatechars:50 }} + {% endif %} +
+
+
+ + {{ person.email }} + + {{ person.phone|default:"-" }} + + {{ person.job.title|truncatechars:30 }} + + + {% with stage_class=person.stage|lower %} + + {{ person.get_stage_display }} + + {% endwith %} + {{ person.created_at|date:"Y-m-d" }} +
+ + + + +
+
+
+ {% else %} +
+ +
{% trans "No persons found" %}
+

+ {% if search_query or stage_filter %} + {% trans "Try adjusting your search or filter criteria." %} + {% else %} + {% trans "No persons have been added yet." %} + {% endif %} +

+ {% if not search_query and not stage_filter and agency.assignments.exists %} + + {% trans "Add First Person" %} + + {% endif %} +
+ {% endif %} +
+
+ + + {% if page_obj.has_other_pages %} + + {% endif %} +
+ + + + + + +{% endblock %} diff --git a/templates/recruitment/candidate_create.html b/templates/recruitment/candidate_create.html index 185ea9f..c31161b 100644 --- a/templates/recruitment/candidate_create.html +++ b/templates/recruitment/candidate_create.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static i18n crispy_forms_tags %} -{% block title %}Create Candidate - {{ block.super }}{% endblock %} +{% block title %}Create Application - {{ block.super }}{% endblock %} {% block customCSS %}