From f71a202ed3606d299f9ac6515247662b6d3370b4 Mon Sep 17 00:00:00 2001 From: ismail Date: Wed, 29 Oct 2025 16:46:24 +0300 Subject: [PATCH] add agency and assignments --- .../__pycache__/settings.cpython-313.pyc | Bin 8180 -> 8192 bytes .../__pycache__/urls.cpython-313.pyc | Bin 2658 -> 2919 bytes NorahUniversity/urls.py | 13 +- recruitment/__pycache__/admin.cpython-313.pyc | Bin 12148 -> 12464 bytes recruitment/__pycache__/forms.cpython-313.pyc | Bin 31968 -> 52188 bytes .../__pycache__/models.cpython-313.pyc | Bin 66283 -> 79728 bytes .../__pycache__/signals.cpython-313.pyc | Bin 4339 -> 5962 bytes recruitment/__pycache__/urls.cpython-313.pyc | Bin 12886 -> 17022 bytes recruitment/__pycache__/views.cpython-313.pyc | Bin 91713 -> 139062 bytes .../views_frontend.cpython-313.pyc | Bin 37130 -> 36975 bytes recruitment/admin.py | 18 +- recruitment/admin_sync.py | 342 ----- recruitment/candidate_sync_service.py | 80 +- recruitment/email_service.py | 146 ++ recruitment/forms.py | 500 ++++++- .../management/commands/debug_agency_login.py | 55 + .../commands/setup_test_agencies.py | 122 ++ .../commands/verify_notifications.py | 112 ++ ...red_date_source_custom_headers_and_more.py | 48 + .../0004_alter_integrationlog_method.py | 18 + ...itted_by_agency_candidate_hiring_agency.py | 18 + ...agencyaccesslink_agencymessage_and_more.py | 129 ++ ..._candidate_source_candidate_source_type.py | 24 + ...e_remove_candidate_source_type_and_more.py | 32 + ...y_agencymessage_recipient_user_and_more.py | 59 + .../0010_remove_agency_message_model.py | 16 + .../__pycache__/0001_initial.cpython-313.pyc | Bin 36251 -> 36251 bytes recruitment/models.py | 299 +++- recruitment/signals.py | 42 +- recruitment/tasks.py | 27 +- recruitment/urls.py | 71 +- recruitment/views.py | 1286 ++++++++++++++++- recruitment/views_frontend.py | 21 +- templates/agency_base.html | 203 +++ templates/base.html | 358 ++++- .../jobs/partials/applicant_tracking.html | 2 +- .../agency_access_link_confirm.html | 82 ++ .../agency_access_link_detail.html | 230 +++ .../recruitment/agency_access_link_form.html | 152 ++ .../recruitment/agency_assignment_detail.html | 492 +++++++ .../recruitment/agency_assignment_form.html | 251 ++++ .../recruitment/agency_assignment_list.html | 237 +++ .../recruitment/agency_confirm_delete.html | 409 ++++++ templates/recruitment/agency_detail.html | 534 +++++++ templates/recruitment/agency_form.html | 426 ++++++ templates/recruitment/agency_list.html | 262 ++++ .../agency_portal_assignment_detail.html | 720 +++++++++ .../recruitment/agency_portal_dashboard.html | 241 +++ .../recruitment/agency_portal_login.html | 342 +++++ .../agency_portal_submit_candidate.html | 569 ++++++++ .../recruitment/candidate_hired_view.html | 31 +- templates/recruitment/candidate_list.html | 33 +- .../notification_confirm_all_read.html | 66 + .../notification_confirm_delete.html | 41 + .../recruitment/notification_detail.html | 216 +++ templates/recruitment/notification_list.html | 231 +++ test_agency_access_links.py | 67 + test_agency_assignments.py | 98 ++ test_agency_crud.py | 204 +++ test_agency_isolation.py | 278 ++++ test_sse.html | 216 +++ test_sse_notifications.py | 57 + test_urls.py | 46 + 63 files changed, 10073 insertions(+), 499 deletions(-) delete mode 100644 recruitment/admin_sync.py create mode 100644 recruitment/email_service.py create mode 100644 recruitment/management/commands/debug_agency_login.py create mode 100644 recruitment/management/commands/setup_test_agencies.py create mode 100644 recruitment/management/commands/verify_notifications.py create mode 100644 recruitment/migrations/0003_candidate_hired_date_source_custom_headers_and_more.py create mode 100644 recruitment/migrations/0004_alter_integrationlog_method.py create mode 100644 recruitment/migrations/0005_rename_submitted_by_agency_candidate_hiring_agency.py create mode 100644 recruitment/migrations/0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more.py create mode 100644 recruitment/migrations/0007_candidate_source_candidate_source_type.py create mode 100644 recruitment/migrations/0008_remove_candidate_source_remove_candidate_source_type_and_more.py create mode 100644 recruitment/migrations/0009_agencymessage_priority_agencymessage_recipient_user_and_more.py create mode 100644 recruitment/migrations/0010_remove_agency_message_model.py create mode 100644 templates/agency_base.html create mode 100644 templates/recruitment/agency_access_link_confirm.html create mode 100644 templates/recruitment/agency_access_link_detail.html create mode 100644 templates/recruitment/agency_access_link_form.html create mode 100644 templates/recruitment/agency_assignment_detail.html create mode 100644 templates/recruitment/agency_assignment_form.html create mode 100644 templates/recruitment/agency_assignment_list.html create mode 100644 templates/recruitment/agency_confirm_delete.html create mode 100644 templates/recruitment/agency_detail.html create mode 100644 templates/recruitment/agency_form.html create mode 100644 templates/recruitment/agency_list.html create mode 100644 templates/recruitment/agency_portal_assignment_detail.html create mode 100644 templates/recruitment/agency_portal_dashboard.html create mode 100644 templates/recruitment/agency_portal_login.html create mode 100644 templates/recruitment/agency_portal_submit_candidate.html create mode 100644 templates/recruitment/notification_confirm_all_read.html create mode 100644 templates/recruitment/notification_confirm_delete.html create mode 100644 templates/recruitment/notification_detail.html create mode 100644 templates/recruitment/notification_list.html create mode 100644 test_agency_access_links.py create mode 100644 test_agency_assignments.py create mode 100644 test_agency_crud.py create mode 100644 test_agency_isolation.py create mode 100644 test_sse.html create mode 100644 test_sse_notifications.py create mode 100644 test_urls.py diff --git a/NorahUniversity/__pycache__/settings.cpython-313.pyc b/NorahUniversity/__pycache__/settings.cpython-313.pyc index 43dc4f61f3b7327d47d54b348c44f458d0907274..4ae63cf778d53147469d759383cbaa32c8ee5bfe 100644 GIT binary patch delta 881 zcmX|8*-{fx5S=?&BmpdBD@zNDQUn5I5`nO<$__5^2r8lyOvHhZgw6os7Tj=sK<7c9 z6hFbrqxuV$7B83w^%JbpC(E+zJBriQ-RJf>(|vp9N9Rh{_V2m5ZiSyOrN1v_)jZk0 zQ>?$P2#ZKL?S+%dP=QLnp#AVrIjX3Fy%J8gYE2~%YM?S(1urz8O4aaD&4denZ?>ZN z1bK0QH16VK_oJ2$ppI&pum8u_Jr!ONFn6hqsfK_mZ=om@KqEDv$)}(>C^)tR1zI&Z zrV(w_gm!912eqJ+TG2&q=%#k|4j$2ogVcpX)Quh zVT|A~T)f5+MlKvpVGoWWh~t_KzzK|EjL}J^PH8GmvyW>zG>9{pz*#<>!+9%DB4p($ zT(EK&5i4KBw9Gyy4dD`WUSlRG{A`S7t+k@O)*NE2Q4kNNB$43Kyj7r}m>8NELQ+$= zG7P@Z0v54^%UEW71y^wm*KtF$BZU>*#4QWo#vRD9KU*MkRzHO-l z|GhFy00-H6{i^B2r4}vfz+n_S1eGzz7av z6h*wiAy!2gLp8>67;(+Dj3WS!;+W>bakfrqDo%2o(u(N-PU8$F`0gww9X^Ni4kvKI z;fqK*tmBg8h?ho?!W6Gz#6^@3tF*J$G_N&-S*}r#aWYxXvk%)W!%IqN8iRB+{HbqBkricisr#8kCNxU=Hko)%|!?C zkVaE#@KK^gD2i_&oyerJHKuOmKe@ha&VMNOiK3-q(@I#z#Yp~R$%^WY^S?o`^|J7W zd{H(hCiA|nJH=31ni&aM3C?YfRj`JPjYMHncHT`HS^Puo{vh$-8uXyJ!mSDa>aSH?uj?y7uT`An8PXW+3p z%gAO-BKvb?r!ehmf5Wyl>9`jReUVNRhjDk G#r_`%((q3J diff --git a/NorahUniversity/__pycache__/urls.cpython-313.pyc b/NorahUniversity/__pycache__/urls.cpython-313.pyc index 5d18bab8f8c8c64db68349844e778c7337cae5de..2aebbd2def23fa0d757ac8269b4f0f2c9b0e67a2 100644 GIT binary patch delta 1232 zcmZvb&rj1}7{}lK*w#_HF+`Bb*xXdMIaWfPk%gd9Nk~gL^u0FB!NwR$m%J?+&}1CF z7=w+87w??(z{NjfI}w){qaKW&mT>U4_`dJfHU{1_eZJ54`+45C$(#Nd`!(J?lcXVl z*UNNo`$Z6dpQLft_&}Wf;Q)9CAb^Yt6b3O0i`WF|S(Q_G#8b_wKE;pxRP$;;5s;t+ zQIPgNbx0ABNVQ)bRwN`*eJ`MflrRd@Mo>qT2#O?t9!Y}2;A}Lik7_f{7kL#1bw*1c z^%n>P163A%^u}65t*4_G2d1B#uNJ7%DJ*y1@1;rNcBE zxiE{+Z1lqH68^S1=X9^aF?e}_qdSjdVt(3-90ey_T|ObqNTKj8irLhDjB9Dm{rTF z8#NPaU%9edH!|!=m;1mr58~#&u_;?+^NGA@TBt}=sa{!5ho1n`p0Y?N$v7 z;a?vh9=|$bWv6V^8u`tJv4woZ33-l%>KnE{&ASu$1uz{ zkoyV}`1y76NmyYXe&>Z&Fnhdqnp4|3wWU?seDw=2yo$fipGFJqXyNl(n^&>Z3Z{?q er`hFpcDc2>-sVePc{0o%irqVJht~Ode8@kEsp*CQ delta 989 zcmZvbF>ljA6vyxExQXL9Nt2L3Luhb;5GWz48blX_7*IJg`4$F?s!c;MQeEj{Kp;fe zSh^sK8NNfm05cV_^R*TAv9qa;c6bYeLJmK(CJB3()xb#UBNiX?hAaWvxBTFp9_ znhSq+yNik@g2Vio6a}8#Wzi( zm*ZD@W6Xsd2)cJywMl~NreqaqdqcZsBP52-dWljcLGOLZDyrV$mG;%&XdR?Bo*2)9 z!SfM7W;BSRARKZwws-e}5TG!65%#x4wlxZ(!Qs8s2Q3cLEw44P*r3mz2GQOij2Qea zXkzU?*$#*Mo&J9Kh^2TXuPPA>c9_A7)&Ckh^V#s4_;dPi{3Ua+%#RK~j-yZToaz|k hpQ!N_HO0O5?eL|!iOp%NQqbO5)799p9;Uir{|9<4pNIee diff --git a/NorahUniversity/urls.py b/NorahUniversity/urls.py index 5fe7bfa..bf381ce 100644 --- a/NorahUniversity/urls.py +++ b/NorahUniversity/urls.py @@ -1,8 +1,7 @@ - -from recruitment import views +from recruitment import views,views_frontend from django.conf import settings from django.contrib import admin -from recruitment.admin_sync import sync_admin_site + from django.urls import path, include from django.conf.urls.static import static from django.views.generic import RedirectView @@ -16,7 +15,6 @@ router.register(r'candidates', views.CandidateViewSet) # 1. URLs that DO NOT have a language prefix (admin, API, static files) urlpatterns = [ path('admin/', admin.site.urls), - path('sync-admin/', sync_admin_site.urls), path('api/', include(router.urls)), path('accounts/', include('allauth.urls')), @@ -34,7 +32,12 @@ urlpatterns = [ path('api/templates/save/', views.save_form_template, name='save_form_template'), path('api/templates//', views.load_form_template, name='load_form_template'), path('api/templates//delete/', views.delete_form_template, name='delete_form_template'), - path('api/webhook/',views.zoom_webhook_view,name='zoom_webhook_view') + path('api/webhook/',views.zoom_webhook_view,name='zoom_webhook_view'), + + path('sync/task//status/', views_frontend.sync_task_status, name='sync_task_status'), + path('sync/history/', views_frontend.sync_history, name='sync_history'), + path('sync/history//', views_frontend.sync_history, name='sync_history_job'), + ] urlpatterns += i18n_patterns( diff --git a/recruitment/__pycache__/admin.cpython-313.pyc b/recruitment/__pycache__/admin.cpython-313.pyc index 37bb7955f7a30c2c429c237ceb3b2965b119e88b..02337a2544f18d682d4cad23a7fb541e9d1fe86d 100644 GIT binary patch delta 3869 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 diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index a8ad4c625c19b721653bb3b89ee725138ad6c671..3730ed60962e16c2cf8250544d5334d51d615237 100644 GIT binary patch literal 52188 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 delta 8477 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 gq!XRr*l=>gN3>r_AKezYw)1 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%0A-t@?RrB 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 delta 16105 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

)M!!cORoKn7FU~0M3fDVM$2;=U!XwoMQnNhqc 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 jDG`*fgsl%|)%$|4DDdxpC; diff --git a/recruitment/__pycache__/signals.cpython-313.pyc b/recruitment/__pycache__/signals.cpython-313.pyc index cfbef5c741967ec91e3586ca071596724dd7caec..3cc942f64599d8899325bc8c0ef6f585036c1fc3 100644 GIT binary patch delta 2244 zcmah~O>7fa5PoZ~?REVBlg&TbgwSA8)(L?E2~9~UkQi{PY)soE)LQI~z2L0Vx9g_( zVh%k}sZ~X{1$s-RN`O?UK{=E|4?Uu)s%$05)(TWr>V;cSkb0!^9GjXHsV~ZS-uq@| zznwQTyVdh6@7{8`nh>l}WRkxZGpkjTMlZxS45 zyb&MvMf}(w3E)5^h=Y-4+#F#s%b|HR&6991uU<6YdWhOS!)@~n>>cyXg7c_JaJ8dD zHDlafLxO_yEI{rCsG|l2s!pB;q;nAAt{Tc#QEvmfD@BETx|^Em2vG(dQzZ<)j3zHi z`K%&6A)G>1U8Y9ehBKPgfKs3ZCpwzOL? z3-nPlhf?qtvgj6OCY;}cr3B8&+M=Xrd^(%Gtnvvlles7+F2_YVz9eE*R#N0`2&jL+ z?0&Zo;$j1X3*=40i~Mcc@sk4pqOcI4LMDTkfgb~9hz?Rs+|f{jG88?+Ogb7 zFzG+q*xqRpe#dg7VY*(~93p0gSH)217(G|*GvYrAWhdGZ3Zs9|Vst+suVw(JBvU&U zDIOZldFsbCt8jf^`>nGR=#j7Cv5*+SGDQhiQXb&q)4g;%4N8D01;HjTuz7VF?89cE ziMJ}2M%osLGu^!7p(M7Y{$#F! z=nJrhjBxp;YJ?oj6)yTD3B2-Vp63meXCgW13?;jq_k>C0y002GsBp+?nB=5kTa;84 z3fiExD@!mvRIMOtIdywJ#l=+CFz0aQ3293C_vg=_=ert56qgVaX(?GHAP6T%xR3+C z0jBsc5i=kRJIoS!Nrp*cFodH5jcTx99FI=TOrDxNF+MXn6^)-5KQVC<$H1dtm@|@M zbfqLM-pD#m2*#7Lx|9*G7=8jnD+1RSXI61(Nm7y_>wmhL5V4V`$KjQIFGYp%BdTOF zlIcX|Q-K%XK7G43xNpE-)`;A-nn9rrp`UH4}nFy)Ea z(!^|eB2k)16i+9ML1}|o*tDWRN7>(3^7obf{Uv|@HCx5u-)J9PcMMke!3}5c%Hfi; zFi^F-IdFZT($ZaS=`XeP-=XgWiY@zp-hDq^9*dU7qU$45#p&4kNbFv7aqdE?<-YK7Kcw2gQp)d6Psbk3nnEK>ejD%3g_FCmqaC%4=lDoQ# zsw}p@Im|JLn9E3G_#=Se0+ITIE_=J2kI}$m)bj^AUq-1Z(FT~{|EoP11bOj delta 681 zcmY+B&ubGw6vt=wNA^edM`G+6YmG^pLbuYSs70GrMG&!D@zAt-SqMwIq?9(5w_6b} z1@-8q45)vCy&BM?7f&7p4_;P+Sn$wWCG_UOd1(&qgZ=Q{e82nV&CJ(IuVnR1QztyW z|Ngf&I=yS~vB?s5fWqNQU}NqgCJ~o8BJ5J+0aL81 zjp)~SIgRTzwxV4%j$}#G4`pJEL$&i(D~L45J{n8Tb#8$h9M81}K@=As2Ad&htYE$w zuAwxCm`B`19NXD9vw9aNh63Ue0^br3hxzHr60^D0j)P|0h~l6XMl!eHpg8Fr>w>(< z>slwU_Nd_I+4|7V#nk2&zpbd>6Z-oY*w#c??6!0M@fA$*3IWdv*kX@B74s>MM4jg? zwl~6C@Q9cAbkPfrLy=PYlUQ9+>XLGojQv55cS+?JS@=N~*k{L2{}t|0YW17_2cBq% AegFUf diff --git a/recruitment/__pycache__/urls.cpython-313.pyc b/recruitment/__pycache__/urls.cpython-313.pyc index b9ccd8121bc839b92b36e251b42041e8eb0d6cd8..bb49a1810955468546bba68c2d1d32ad86180602 100644 GIT binary patch literal 17022 zcmb`Od3;=JS;r^oNoj7|v`v~c&DJ!{zD<^o?lV2TCI z369B!ekzS;cI>B9*5xy$@ZIR8@cuimA98RuUP{x!Sw zui*SE!M`rvf4qugs)0EzVC)=I1I!r#Q_C@Rz%<1(JN9)w=YJaf&+XE`f%88D{w=%o zZ{+-&z`u2u{%1M=bKu{$OaErhzXj$~$1eTPbN;R1-?dBs3!Hx&`1kD6zn$~%0RP@y z`gd~vUEtpz??2wnF+ISD*6oWN(+iAfP445E{;8y~!T7v(9IFGI{~-7e$NP^Dam+9< zBLe0U$BY0oCSXQ6W(?|cS-^~Q%;nd>OaL<}h%?E>xdO~p0dtjOt^sphz+C5;8^BBn zm?@5#24+UU%y5hY=G09AbCYA7z|0DmS&nf5;}$S(j*)?x6EJfeGY`yyfLY)e4=}d` z%q@;tgt~hLjF)44!1x7>pJM{RED4w;j=2p?P{0H^CIn1az=Szw8JHCTv%)cVfVnGR z?sCj3Fp7XtIOcg^?g^NC9PW8MzT zI|R%-IOf-Yd8dGRC&#=Cn0E`9cXP~pfO#xn9&^mE1M^-1^Inemjj6q3?~7-4T;Jcv z`Tr*Pzkiqh@8|q~3;fr1>A%MLe*pYHxJ&;Ja{j*!{vX<<|A#pL-vR#*$NP_em}5Qy z%({SC=a`Q|Uwur#e2im$7nqL=n2&SJ?*a1(0rLrt`F&tE1k47<`~fhZ6fmFUm_G#O zQv&8w9P>xOd|JSKnq&SLm`wq*$uWNd%x475XE^3hf%&X}`7Fo$88Dv{FrVX?KL_TC zfO*0(fAJca&ja&$L7czj;(P&^F9?{w;+QW2^F;yk*BtW_FfR$1zu}lK0rMpR^S2!H zWnjK6VE&F{z5>iw1kB%aj0%h@UX%nt?3zj4e@fcde2`FD=_DKI|~F#o|ZIxs&KFt2dT ze*&Wmm{&RGzkm_7;eR6&T%G@)vY-8k1}RI3NK4M}!jBU0C1u4U-w8S9f&qV6_Paq& zVkz)&1SJm$^Fe3W6YxXg0Za@9mV+)iVv8iZouP%^v*QqWB(+WLmo6;g|~q9Z(S^C%!gZY0^N2zB7x5 zMdCB#bHSkj9cVc?jM4Hm54Y@<>A2-QFiFX_V#JJL+FsjmHr|TGcu)>4`{b&y41=8w z;o}zPK+R!l$|*~V$q{Q%6$-7-nGln4B+40+Do4nftscdVMi=8|lSgr*#gl!q9EKXe7(7Uq7Kiz+WSUP#6{?JP zI#}7lW1>otMH&xPSoF}gni;CYP?bW<&uDJrJ=QG+MJum<=~P%atJJ&*2ZG4WN$~Xq3F^9tww31 zFqecy5e3WviL+ogX&ZrQ0$aE61Wzo(rTCtK~ z`Hn`B?>c=(fHXnsjwP^G`S=pVN=oq#^$5(($w4E+(P$B($vb*4c!IJ!-aDGCRC0O4 zqtQ$9-K9V<41+fuJjWKLR=X>-VvnSwz^~$vuFML9Sfw@l$VxDBF-9U}5MHK?nVBQJ zx+J$+2_UxGBgYJy3&Q-A3Ss7)w9a-fYm6SraQ3y(R8I0&q+`S=$do*;_$dz z?XZo8t6IMP`tD-RY8wh7~8V6Gr+>$D)u-dxaV{*nA= z`6$(KImfjW8^%U6M{;(OmHN17v&-H^bAz%<>WB$>@|lcMyb$)?#riER!e}y&0kC#? z;Ed#P8lyDlDT;&oD`gmkJBUW{kQ~NR;Q{m5Q50xKQwD zEErao8WYuechE6q&Mj-}j%1lh(FMz#cuFtbo&R5Q$%9DI19`Me#ynQ8{T#VOXT4P$ zHrS#KM{69(iE@G}PXC-$j0W8?^L$$GC@UyHH&=6<2UhF6rXH+XVh80ss~gRw5yova zJ7s#8*~A{=p1B0)?8GYvmqTz8Vx0qB64_5*%~4#!G?uay49t1FvUNRXiPz)C+iXB_ z6uJ`#x~&Sp%#K%;Jg3VQSoYJK^!z;3!q`i!@}~=RWuLjNM)n&!Ci~8C$V|qf!5zWe zPuUGXIb!N&{3v1EUNYkdT%V&LzANz{c4idBcO}b=NaxJF?3dw7id?lQuR^mQG2iKl z-9gioJRWximR9L!2bb5g#6Gcr3r(Zn9jr8^z!PizZ?dWC>uiF*4(QjCLs&c=0V{L{LsVgNq2V zFdFq)S(wc$h+wp|{TZ3dcj6k?wNtxs5y z}=0_9k$ki%X#iD+EiwT{Lrl=5Q$~!~x;PSA-z8U2T3K7+nXU2jBa>rOv zT{%dva89rJh0_>!4oo#Za~?4m>*Fxovy2iNoTVsv1{)otF=y3?F`CQ_qL{fG+IJ4X zPNs0#imn%@VvB-6bl-Q-(}9v{M2&aP+u2=7F+5>!4J^ZlnWM2hCM)FzWtA)Lg?y6HFMJXmeX|6tJaIp#s13PmD&pW69M_;E~ zso~RijI->=NSV9`MnXxc;FYJvGRMdETCp7|CpH2rKfU!~&RM*QInyg2tH-hJyu!Y6 zf*Djk(rKp5?~b! zo}kd-%f|P8E06H5PpKUGD+tWxqLFXWXvjr z4Z@{!+3``h98k+ewxNEPNRu<1YqC5S7_VWp;>}8)YMW_M@>C7r>UX; zB|z{DHI39XQFE4>bJR3b(?ZR8YFeqeKusGp?bLKo(@9MimM=)C^EFNX-y6!_-`&W`vqiYR0G;2NOvdXTQQ)J#iS{xlS-rxUM!* zxR$pVI@4JLXT;r0upSw4mk+GS4O*fpYNn}~p~gYYO=_If%u?f`#!Zb(%^WrJ)GScr zq2?Afi`00j@loTaCP2*+HMgk=QWK&kOwBSiE7ZJ5%_=ntHP2IXkD3>#xlhdlFmRs^ zdf|6)_(bRrebn|UB-tX3_Jx2?wtGT8_<7h4H|tyQbrx<=@PGV)m_7w%S8&-ArUz_0 zU7)I$RwMgbd;$8qY-?~G;?Q5#LnDW5HhoV*Lc+_o9QMDomu)9rwq@zk#GXTm-<0+} zOno?`9V{mY%Qv!#bY9;>(_h(VOG`}FrT1iqXwbauj_4HQina%4&>eZ7O@*G9- z9;a)^%gFJvjRVwqKT{5Xq9)rn(zV))r1qjZe3M8{{UFm)n09Jy;v+L3nAxl((nb9c z6H=Lw^H|pMs!3k;#uSk{^fV@A(44X!b!$h9$Yf^DiAcBg zdIp`|8ERnA83q-vr)Z@YNvYYhjZAA|TJ1)wR)3S!W7~AHw&_aL&obm3L#j7Yw3-Q0 zGoem}iL|UYGp#KF#E03B{8~l>$!O3r&XbJuTNxLg1ZdZ^CorgkLTYmM;~6ctmE^W+ zxjiJeXDhc?y~M^?Ct_WQ<*!w11)ZdzQ!5xC1p`|JL+WLjNOO8OVm*kl*6Sx_{aV== zDI41=yR6<|eSQ(K-e+Kahz(N=Dj;hac9LP&G8#!n<5tGmCx?hMqF+L21Y^PY*NWOm zQJYrOONx58iu%=2Cy{3LQN+d&V}o#*)D3HOlca8PtM01mU|lwj*k#1nOdBM%gIeun zQhRx;c2b@85y`JlAU3%TQ^#FIa_d)6ouVp?*d#5plw_8w_6tO6)2C6KLD&4XqK{O6 zpnBszk$Q9oN^X?0)!b?=x1Hp+Yq@We4`Q9k~-LCdZp*;N~`ICSbE6vHT1ZzO3o9i*n? zr92{C)t6CRq2j~LM-5t9B}uDP>wAgRr{6*GE_$9^YtT-eC#TMWqmh5>Yz2|iL{_UK(#HAjo|IR?TI+PQDu{8lGH;#vT59j zYL{5;Mzsgk#?3tK>@+z$t-7Bl(mnkms=cWC)W8crv)PssCh5$ie_#!pPF!L0^iHDQ zhx9PgXEsx`rYX`irMeU%J+EIvbp+M=jTx=sI%&ABI#-FL=%c7!M%Agh79b}#u8;H& zwM3D&Z44Wa7@wLVGG+U7~yJV1ejF&1e&QNMxE z3_`UV4O;yKsfS)+WpK3aqs$Nu^Hz&07*U5RPFN^F$ z)sJf3Mz?nQDme{P^a0%v+7tBv0!s+AY|2`zle9Xa^qz!XR{Fj~{Wd})d)T-rT3gmi zx=2ZvI^ZDEO??!%<1th_o^)$n4$|dN=hz-Fj_OrZFR7!G+URXEdK+?pp~`ZA4!(xe zb)?4CiR;?LDw$XXsy)%ks99)5q^6K^t8RHuJ{9!c~wI*_POBRzxkf_f_e z^kTv&)#)FguO=#tem+rmAU%uph&pyf8(Se`D?r^(m|>J7QFkHb-WGERV-C@nmlK1G z4khX`(q2kGJVB2k<>Ywz#z7*r>OR!`7^#kqh%r(>re0-z9zb=8dFDKl-xGLyV7-}0 zjrwg=;I7A3@(C@yjHH(tD`E)6Fcb6Wa%m@-cC~@68_OuJV2&m0Nm^+eDQ#1G*t&5C z)w`&ctruzK-K4x*9hfDOOJ7B`L!!B6J(|+eYe;&Hmfl3to3_%=J;^1~CB2iK*IwzO zkeZzJcu>nZOLER?Iqf8;eJiK)B|AM{bR*S+RL*0sme)-3nzg)6lGnMF*ZtBZB2DQR zk?KY2)Y_C*)Io|mw4#1e)W1~(ODro_A7cH_zy=T-M677-o>tONN?=1{tNakE!>E?6 zw`di^q+(c|SRj%|zl7=}s_Y!sOYE?lGv6zyUPbl7lXR{925G;c&a%OO4b>@B+n+RO zoet6oQv)`s2l_OsH&H#anWQz1lO~wb1kS>9M4H#1NA(`6 zY;hbV^`q+5Ady1)3#i^lwS2u>tL!J0(06Q)eSm6P5*u^decO|8%R=?sX1CVjA}ubM zrHcu}Y?fY1)H@OCMW~q03p_Y_h;&QuL$x1OR_#Gj1>?_8q<}tv>L9AD3vZC78<2r3 zp@U`6nWzsTG`u5p2_ZLyV67<9vg%1zy?PFMZA6z*oWlt00z&7E+QC|P9@Pa@%hnsT ziV;!)w*oc>J*eJ7m9>FPT4X4qFS&h4% zH33w^sIpVTHPU!Zy~zgoGO8=6Hg2Zl&JWqk3EixPdJ^?J2zBjcy;!*}Yt_A^8s-=t zh4->v>_@efE=@h8yhj~khn4|UM^I(8zepKZjPE==6W!v%%avs)& zdqjFcccJP=l?|Iw(gY)(m03o04plZs`$!pd20QW3qq=}9yC1fZ!Z!6H+l)O-ef|F6 Ihv8=Ze;gVwVE_OC delta 2178 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�a8}u8umvF44R}B(?EB|ZK8t3(J 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 KmWjoJ)$zX!IuLdM diff --git a/recruitment/__pycache__/views_frontend.cpython-313.pyc b/recruitment/__pycache__/views_frontend.cpython-313.pyc index 02a6e7b0917358e9a89c1d902f14b5b442ef7679..2fd1d55fb4815c56ff8a32eba074135542ee9122 100644 GIT binary patch delta 2046 zcmZ`&ZA?>F7(VB=_x8I~=!f!E6hyBgQz!}u@-ak~D8^jt*s8&f7Ojr9oLfOESP(JA z4XHa(T()IyF>%ZO%yh|;{ZsdAKb9C!2- zlOYle&;?1P1#fzIQj8Hw8~c9KSQT?*a4<;v<(NNqMoz!**|D&hu|%YfV{yDaojUfA zYpa8mdH@eVighI$AgTmt0N4qzi-Bk$EfBW?>;Y(_UmY(!-VR|0Kqmvm@J2WmBqO2V zsAwds>2HM8YJe5}Mwm7+U^TBa+2pT&d!@V-s#cC;kt&GcctKsE0;r-PcY8aRFGyU-wm*zemWe+HPlKvP5F90vLWY( zX(rQ1zazz_{IW-+6mO${6OZr~==9K?a;uO{pks0~9!h^Bzu>Wlem&NV+v%@keF-n$ zz=3fQszAP?Y=n?xlFVLSveateE?`vUvsTG2*=jk-Ar&@pRcJl(*|S*)>5#t?p|Fsr zWt9qit{m?ba*9?|&q=}+-b^tzZ|KHVP* z(4lh&coJiOM(>~7f=B4nbDmbSVhx<~hfhTMhif5ECJ~mP@Yy)ZVzL#0XMaKp0S+-x z_$Ud5W3+F)%(M+?C?ZihIo@QPV&Q#uIPwql{&=s|j4XvI@y5Of=Bi(c+|)5KT2kGy z1>N0Z>~v}Fde(LpYVMgai|-Zkov!rliT5!UsVC9YJPMj;0qR-HUNQz@oPlUmoCE%F zAO!B~m&d{b{W846fgqWNoUQbBqFy);VS+wRI9p&<5#-1jG7wbEFBQx{)+7U2$C^}R z2kC^3CV@((>n7XqTAzWfs6*0vH7j>dpG`UG!4D*tS6$+A>%`DkjUSBKoz$%{eYQ4Ny@Zzi@r*?JF z#VJRjR8XtSxdz;MVcKYaoA+h3E8`y}MsYtSC&BPq*%U5@uI z>#ME=9cNC5?M2t2W2RKUd8Ty5-SotCjakGB7d@?L!D;ly^p`~h?n2ZN50fV&aQHLy z;Kc?!N@p%^OMnqXi&C)c)}crs_^wQ@K;n5QX$F{p>Gu$+H+vnX*8ucvO)+;xf^u&p zw)bpEj*+ZG7%A#WBb30~$O@()%KH(lW%Eh8V`j7KLq?f2%rG@9m_&aFh~K!V-Abb~ z>k<$3mgHdS@B@AEqJYe{`Ih;XjH4piopIJD_hlUG7JOHIv)viXn)##iM>CExV0Nv> zlmoLvWoEk{JJe~rI!$&%lar~D2m11d1tl40X~tE)gdbcItQl9y!uZwkrBAx}(vKXT zqfe4`!pE?uVY+_yl<6Y~7XYr%>$7``)id0H!?+3bXY}c;$lqe&o^i4d3?(IN&0fqpk0Mz#ar$cU0Wv&c=oBng|30LqMc0Jc1&bG+0XfZ$+Q^o%RH~HG5 delta 2155 zcmZ`)OHfl+7{2Ew_l6{p2P9y5B%lZcF%|;ysDPjaORZKeL8n0hB`AnDa1wk#SCke*+{`dKO>)|NO7> zpY#1Uzu(|5e8L-^r=;jOwyK=}9Qif)g@LpYKbYDclP<_{RlvhN>2M(1n#4=0Y9g!L zFt^tVH?~>Sl3EhL+?qwSu(8!{7ORQJh zM=vEi)w1A8i|w;GPD*hL6{?t?&p8ZG+Mb&kO=>uF$U7MD_Bcp1+1K0arJepjcVO5L zd)g1f%RxIlYp>${_rcs|vs|^!*`AmS9+?a7w9K0e=FRmJEimLCfKS^th70%pq7rB} zJ4rapV<93+vk&N)$*@L*8+ri{z_D<7_gXEiZpi>ycaLhh_2fZ zEaIy%L`F|LKcf%^cPC`}*Y0p6-Bml_Jc4M7yJpdULlk z2fDJ{h&J5UU2Y&d;kpk>niby`-@)9rzQBMts@v`z?52TZUO7OUux`Sg!v4Xsij}=a z$@=({(dBy`;Y)I9HHOz9w7`QwnQQ>pP@_IE-^9?`C6;_Pw3$@G&qJ9-31Jv}hLI$y z>sdaP!%C}=Hzklq%f-Fu(hMG2%Z7xBHE^AkJR1Iyz7R+@JU&@N%E5GM-@B6DEwGVL zEe%RO=P}1dfn$RqrAbC7FIgmWHJ{Jrah}vAn}+LN$8oZ_!rS1n!u+X>LX0tCjGwo2 zQmQOAi1~}Zy`-0`RoS^-;;@2YtoApscPk*<3 z*w;DaM5~`3V~s?Gm@H#c#Rvj6jmM&U7{JZZ9DNDyqc@e|yU}Xh7!$wYInMtzBnNk? zO`Ihcjs#C+XExZldp2F8UUjc%O{1u~&k-iy7X^m!)yed*`SdU$x8VF(b^0)-JC5LD zWp>dMD4v8L#%$sQipB8vm@8)##UKNRMtO~>*6$mphrFycIsq-?nR!#_ahic&!}8?D z7H!1yPorr%e2$HQ+O2BULa8$i#`kI9wo7Ydk0PmM_WhFuX4AoBf0|0%y#ube3Kg5k zY?`=DBko?y{EUe?T&$v$#Qe0fAn9arw;qi0d^W-Aznkf@M#!h@NJ=3rM z2t`1+1TSXl?8?|Fo67AC;+)foIafLBho6yp6m`l-)lRk#1+D{{_f8 B@n--4 diff --git a/recruitment/admin.py b/recruitment/admin.py index ff1f176..cd2c8a1 100644 --- a/recruitment/admin.py +++ b/recruitment/admin.py @@ -5,7 +5,8 @@ from django.utils import timezone from .models import ( JobPosting, Candidate, TrainingMaterial, ZoomMeeting, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, - SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment + SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment, + AgencyAccessLink, AgencyJobAssignment ) class FormFieldInline(admin.TabularInline): @@ -77,20 +78,21 @@ class IntegrationLogAdmin(admin.ModelAdmin): class HiringAgencyAdmin(admin.ModelAdmin): list_display = ['name', 'contact_person', 'email', 'phone', 'country', 'created_at'] list_filter = ['country', 'created_at'] - search_fields = ['name', 'contact_person', 'email', 'phone', 'notes'] - readonly_fields = ['created_at', 'updated_at'] + search_fields = ['name', 'contact_person', 'email', 'phone', 'description'] + readonly_fields = ['slug', 'created_at', 'updated_at'] fieldsets = ( ('Basic Information', { - 'fields': ('name', 'contact_person', 'email', 'phone', 'website') + 'fields': ('name', 'slug', 'contact_person', 'email', 'phone', 'website') }), ('Location Details', { - 'fields': ('country', 'address') + 'fields': ('country', 'city', 'address') }), ('Additional Information', { - 'fields': ('notes', 'created_at', 'updated_at') + 'fields': ('description', 'created_at', 'updated_at') }), ) save_on_top = True + prepopulated_fields = {'slug': ('name',)} @admin.register(JobPosting) @@ -282,7 +284,9 @@ admin.site.register(FormField) admin.site.register(FieldResponse) admin.site.register(InterviewSchedule) admin.site.register(Profile) -# admin.site.register(HiringAgency) +admin.site.register(AgencyAccessLink) +admin.site.register(AgencyJobAssignment) +# AgencyMessage admin removed - model has been deleted admin.site.register(JobPostingImage) diff --git a/recruitment/admin_sync.py b/recruitment/admin_sync.py deleted file mode 100644 index 812766b..0000000 --- a/recruitment/admin_sync.py +++ /dev/null @@ -1,342 +0,0 @@ -""" -Admin interface for sync management -""" -from django.contrib import admin -from django_q.models import Task, Schedule -from django.utils.html import format_html -from django.urls import reverse -from django.utils.safestring import mark_safe -import json - - -class SyncTaskAdmin(admin.ModelAdmin): - """Admin interface for monitoring sync tasks""" - - list_display = [ - 'id', 'task_name', 'task_status', 'started_display', - 'stopped_display', 'result_display', 'actions_display' - ] - list_filter = ['success', 'stopped', 'group'] - search_fields = ['name', 'func', 'group'] - readonly_fields = [ - 'id', 'name', 'func', 'args', 'kwargs', 'started', 'stopped', - 'result', 'success', 'group', 'attempt_count', 'retries', - 'time_taken', 'stopped_early' - ] - - def task_name(self, obj): - """Display task name with group if available""" - if obj.group: - return f"{obj.name} ({obj.group})" - return obj.name - task_name.short_description = 'Task Name' - - def task_status(self, obj): - """Display task status with color coding""" - if obj.success: - color = 'green' - status = 'SUCCESS' - elif obj.stopped: - color = 'red' - status = 'FAILED' - else: - color = 'orange' - status = 'PENDING' - - return format_html( - '{}', - color, status - ) - task_status.short_description = 'Status' - - def started_display(self, obj): - """Format started time""" - if obj.started: - return obj.started.strftime('%Y-%m-%d %H:%M:%S') - return '--' - started_display.short_description = 'Started' - - def stopped_display(self, obj): - """Format stopped time""" - if obj.stopped: - return obj.stopped.strftime('%Y-%m-%d %H:%M:%S') - return '--' - stopped_display.short_description = 'Stopped' - - def result_display(self, obj): - """Display result summary""" - if not obj.result: - return '--' - - try: - result = json.loads(obj.result) if isinstance(obj.result, str) else obj.result - - if isinstance(result, dict): - if 'summary' in result: - summary = result['summary'] - return format_html( - "Sources: {}, Success: {}, Failed: {}", - summary.get('total_sources', 0), - summary.get('successful', 0), - summary.get('failed', 0) - ) - elif 'error' in result: - return format_html( - 'Error: {}', - result['error'][:100] - ) - - return str(result)[:100] + '...' if len(str(result)) > 100 else str(result) - - except (json.JSONDecodeError, TypeError): - return str(obj.result)[:100] + '...' if len(str(obj.result)) > 100 else str(obj.result) - - result_display.short_description = 'Result Summary' - - def actions_display(self, obj): - """Display action buttons""" - actions = [] - - if obj.group: - # Link to view all tasks in this group - url = reverse('admin:django_q_task_changelist') + f'?group__exact={obj.group}' - actions.append( - f'View Group' - ) - - return mark_safe(' '.join(actions)) - - actions_display.short_description = 'Actions' - - def has_add_permission(self, request): - """Disable adding tasks through admin""" - return False - - def has_change_permission(self, request, obj=None): - """Disable editing tasks through admin""" - return False - - def has_delete_permission(self, request, obj=None): - """Allow deleting tasks""" - return True - - -class SyncScheduleAdmin(admin.ModelAdmin): - """Admin interface for managing scheduled sync tasks""" - - list_display = [ - 'name', 'func', 'schedule_type', 'next_run_display', - 'repeats_display', 'enabled_display' - ] - list_filter = ['repeats', 'schedule_type', 'enabled'] - search_fields = ['name', 'func'] - readonly_fields = ['last_run', 'next_run'] - - fieldsets = ( - ('Basic Information', { - 'fields': ('name', 'func', 'enabled') - }), - ('Schedule Configuration', { - 'fields': ( - 'schedule_type', 'repeats', 'cron', 'next_run', - 'minutes', 'hours', 'days', 'weeks' - ) - }), - ('Task Arguments', { - 'fields': ('args', 'kwargs'), - 'classes': ('collapse',) - }), - ('Runtime Information', { - 'fields': ('last_run', 'next_run'), - 'classes': ('collapse',) - }) - ) - - def schedule_type_display(self, obj): - """Display schedule type with icon""" - icons = { - 'O': '🕐', # Once - 'I': '🔄', # Interval - 'C': '📅', # Cron - 'D': '📆', # Daily - 'W': '📋', # Weekly - 'M': '📊', # Monthly - 'Y': '📈', # Yearly - 'H': '⏰', # Hourly - 'Q': '📈', # Quarterly - } - - icon = icons.get(obj.schedule_type, '❓') - type_names = { - 'O': 'Once', - 'I': 'Interval', - 'C': 'Cron', - 'D': 'Daily', - 'W': 'Weekly', - 'M': 'Monthly', - 'Y': 'Yearly', - 'H': 'Hourly', - 'Q': 'Quarterly', - } - - name = type_names.get(obj.schedule_type, obj.schedule_type) - return format_html('{} {}', icon, name) - - schedule_type_display.short_description = 'Schedule Type' - - def next_run_display(self, obj): - """Format next run time""" - if obj.next_run: - return obj.next_run.strftime('%Y-%m-%d %H:%M:%S') - return '--' - - next_run_display.short_description = 'Next Run' - - def repeats_display(self, obj): - """Display repeat count""" - if obj.repeats == -1: - return '∞ (Forever)' - return str(obj.repeats) - - repeats_display.short_description = 'Repeats' - - def enabled_display(self, obj): - """Display enabled status with color""" - if obj.enabled: - return format_html( - '✓ Enabled' - ) - else: - return format_html( - '✗ Disabled' - ) - - enabled_display.short_description = 'Status' - - -# Custom admin site for sync management -class SyncAdminSite(admin.AdminSite): - """Custom admin site for sync management""" - site_header = 'ATS Sync Management' - site_title = 'Sync Management' - index_title = 'Sync Task Management' - - def get_urls(self): - """Add custom URLs for sync management""" - from django.urls import path - from django.shortcuts import render - from django.http import JsonResponse - from recruitment.candidate_sync_service import CandidateSyncService - - urls = super().get_urls() - - custom_urls = [ - path('sync-dashboard/', self.admin_view(self.sync_dashboard), name='sync_dashboard'), - path('api/sync-stats/', self.admin_view(self.sync_stats), name='sync_stats'), - ] - - return custom_urls + urls - - def sync_dashboard(self, request): - """Custom sync dashboard view""" - from django_q.models import Task - from django.db.models import Count, Q - from django.utils import timezone - from datetime import timedelta - - # Get sync statistics - now = timezone.now() - last_24h = now - timedelta(hours=24) - last_7d = now - timedelta(days=7) - - # Task counts - total_tasks = Task.objects.filter(func__contains='sync_hired_candidates').count() - successful_tasks = Task.objects.filter( - func__contains='sync_hired_candidates', - success=True - ).count() - failed_tasks = Task.objects.filter( - func__contains='sync_hired_candidates', - success=False, - stopped__isnull=False - ).count() - pending_tasks = Task.objects.filter( - func__contains='sync_hired_candidates', - success=False, - stopped__isnull=True - ).count() - - # Recent activity - recent_tasks = Task.objects.filter( - func__contains='sync_hired_candidates' - ).order_by('-started')[:10] - - # Success rate over time - last_24h_tasks = Task.objects.filter( - func__contains='sync_hired_candidates', - started__gte=last_24h - ) - last_24h_success = last_24h_tasks.filter(success=True).count() - - last_7d_tasks = Task.objects.filter( - func__contains='sync_hired_candidates', - started__gte=last_7d - ) - last_7d_success = last_7d_tasks.filter(success=True).count() - - context = { - **self.each_context(request), - 'title': 'Sync Dashboard', - 'total_tasks': total_tasks, - 'successful_tasks': successful_tasks, - 'failed_tasks': failed_tasks, - 'pending_tasks': pending_tasks, - 'success_rate': (successful_tasks / total_tasks * 100) if total_tasks > 0 else 0, - 'last_24h_success_rate': (last_24h_success / last_24h_tasks.count() * 100) if last_24h_tasks.count() > 0 else 0, - 'last_7d_success_rate': (last_7d_success / last_7d_tasks.count() * 100) if last_7d_tasks.count() > 0 else 0, - 'recent_tasks': recent_tasks, - } - - return render(request, 'admin/sync_dashboard.html', context) - - def sync_stats(self, request): - """API endpoint for sync statistics""" - from django_q.models import Task - from django.utils import timezone - from datetime import timedelta - - now = timezone.now() - last_24h = now - timedelta(hours=24) - - stats = { - 'total_tasks': Task.objects.filter(func__contains='sync_hired_candidates').count(), - 'successful_24h': Task.objects.filter( - func__contains='sync_hired_candidates', - success=True, - started__gte=last_24h - ).count(), - 'failed_24h': Task.objects.filter( - func__contains='sync_hired_candidates', - success=False, - stopped__gte=last_24h - ).count(), - 'pending_tasks': Task.objects.filter( - func__contains='sync_hired_candidates', - success=False, - stopped__isnull=True - ).count(), - } - - return JsonResponse(stats) - - -# Create custom admin site -sync_admin_site = SyncAdminSite(name='sync_admin') - -# Register models with custom admin site -sync_admin_site.register(Task, SyncTaskAdmin) -sync_admin_site.register(Schedule, SyncScheduleAdmin) - -# Also register with default admin site for access -admin.site.register(Task, SyncTaskAdmin) -admin.site.register(Schedule, SyncScheduleAdmin) diff --git a/recruitment/candidate_sync_service.py b/recruitment/candidate_sync_service.py index 1ce3e78..65a84a3 100644 --- a/recruitment/candidate_sync_service.py +++ b/recruitment/candidate_sync_service.py @@ -35,9 +35,7 @@ class CandidateSyncService: } # Get all hired candidates for this job - hired_candidates = list(job.candidates.filter( - offer_status='Accepted' - ).select_related('job')) + hired_candidates = list(job.hired_candidates.select_related('job')) results['total_candidates'] = len(hired_candidates) @@ -172,48 +170,48 @@ class CandidateSyncService: 'email': candidate.email, 'phone': candidate.phone, 'address': candidate.address, - 'applied_at': candidate.created_at.isoformat(), - 'hired_date': candidate.offer_date.isoformat() if candidate.offer_date else None, - 'join_date': candidate.join_date.isoformat() if candidate.join_date else None, + # 'applied_at': candidate.created_at.isoformat(), + # 'hired_date': candidate.offer_date.isoformat() if candidate.offer_date else None, + # 'join_date': candidate.join_date.isoformat() if candidate.join_date else None, }, - 'job': { - 'id': job.id, - 'internal_job_id': job.internal_job_id, - 'title': job.title, - 'department': job.department, - 'job_type': job.job_type, - 'workplace_type': job.workplace_type, - 'location': job.get_location_display(), - }, - 'ai_analysis': { - 'match_score': candidate.match_score, - 'years_of_experience': candidate.years_of_experience, - 'screening_rating': candidate.screening_stage_rating, - 'professional_category': candidate.professional_category, - 'top_skills': candidate.top_3_keywords, - 'strengths': candidate.strengths, - 'weaknesses': candidate.weaknesses, - 'recommendation': candidate.recommendation, - 'job_fit_narrative': candidate.job_fit_narrative, - }, - 'sync_metadata': { - 'synced_at': timezone.now().isoformat(), - 'sync_source': 'KAAUH-ATS', - 'sync_version': '1.0' - } + # 'job': { + # 'id': job.id, + # 'internal_job_id': job.internal_job_id, + # 'title': job.title, + # 'department': job.department, + # 'job_type': job.job_type, + # 'workplace_type': job.workplace_type, + # 'location': job.get_location_display(), + # }, + # 'ai_analysis': { + # 'match_score': candidate.match_score, + # 'years_of_experience': candidate.years_of_experience, + # 'screening_rating': candidate.screening_stage_rating, + # 'professional_category': candidate.professional_category, + # 'top_skills': candidate.top_3_keywords, + # 'strengths': candidate.strengths, + # 'weaknesses': candidate.weaknesses, + # 'recommendation': candidate.recommendation, + # 'job_fit_narrative': candidate.job_fit_narrative, + # }, + # 'sync_metadata': { + # 'synced_at': timezone.now().isoformat(), + # 'sync_source': 'KAAUH-ATS', + # 'sync_version': '1.0' + # } } - # Add resume information if available - if candidate.resume: - data['candidate']['resume'] = { - 'filename': candidate.resume.name, - 'size': candidate.resume.size, - 'url': candidate.resume.url if hasattr(candidate.resume, 'url') else None - } + # # Add resume information if available + # if candidate.resume: + # data['candidate']['resume'] = { + # 'filename': candidate.resume.name, + # 'size': candidate.resume.size, + # 'url': candidate.resume.url if hasattr(candidate.resume, 'url') else None + # } - # Add additional AI analysis data if available - if candidate.ai_analysis_data: - data['ai_analysis']['full_analysis'] = candidate.ai_analysis_data + # # Add additional AI analysis data if available + # if candidate.ai_analysis_data: + # data['ai_analysis']['full_analysis'] = candidate.ai_analysis_data return data diff --git a/recruitment/email_service.py b/recruitment/email_service.py new file mode 100644 index 0000000..4780934 --- /dev/null +++ b/recruitment/email_service.py @@ -0,0 +1,146 @@ +""" +Email service for sending notifications related to agency messaging. +""" + +from django.core.mail import send_mail +from django.conf import settings +from django.template.loader import render_to_string +from django.utils.html import strip_tags +import logging + +logger = logging.getLogger(__name__) + + +class EmailService: + """ + Service class for handling email notifications + """ + + def send_email(self, recipient_email, subject, body, html_body=None): + """ + Send email using Django's send_mail function + + Args: + recipient_email: Email address to send to + subject: Email subject + body: Plain text email body + html_body: HTML email body (optional) + + Returns: + dict: Result with success status and error message if failed + """ + try: + send_mail( + subject=subject, + message=body, + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa'), + recipient_list=[recipient_email], + html_message=html_body, + fail_silently=False, + ) + + logger.info(f"Email sent successfully to {recipient_email}") + return {'success': True} + + except Exception as e: + error_msg = f"Failed to send email to {recipient_email}: {str(e)}" + logger.error(error_msg) + return {'success': False, 'error': error_msg} + + +def send_agency_welcome_email(agency, access_link=None): + """ + Send welcome email to a new agency with portal access information. + + Args: + agency: HiringAgency instance + access_link: AgencyAccessLink instance (optional) + + Returns: + bool: True if email was sent successfully, False otherwise + """ + try: + if not agency.email: + logger.warning(f"No email found for agency {agency.id}") + return False + + context = { + 'agency': agency, + 'access_link': access_link, + 'portal_url': getattr(settings, 'AGENCY_PORTAL_URL', 'https://kaauh.edu.sa/portal/'), + } + + # Render email templates + html_message = render_to_string('recruitment/emails/agency_welcome.html', context) + plain_message = strip_tags(html_message) + + # Send email + send_mail( + subject='Welcome to KAAUH Recruitment Portal', + message=plain_message, + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa'), + recipient_list=[agency.email], + html_message=html_message, + fail_silently=False, + ) + + logger.info(f"Welcome email sent to agency {agency.email}") + return True + + except Exception as e: + logger.error(f"Failed to send agency welcome email: {str(e)}") + return False + + +def send_assignment_notification_email(assignment, message_type='created'): + """ + Send email notification about assignment changes. + + Args: + assignment: AgencyJobAssignment instance + message_type: Type of notification ('created', 'updated', 'deadline_extended') + + Returns: + bool: True if email was sent successfully, False otherwise + """ + try: + if not assignment.agency.email: + logger.warning(f"No email found for agency {assignment.agency.id}") + return False + + context = { + 'assignment': assignment, + 'agency': assignment.agency, + 'job': assignment.job, + 'message_type': message_type, + 'portal_url': getattr(settings, 'AGENCY_PORTAL_URL', 'https://kaauh.edu.sa/portal/'), + } + + # Render email templates + html_message = render_to_string('recruitment/emails/assignment_notification.html', context) + plain_message = strip_tags(html_message) + + # Determine subject based on message type + subjects = { + 'created': f'New Job Assignment: {assignment.job.title}', + 'updated': f'Assignment Updated: {assignment.job.title}', + 'deadline_extended': f'Deadline Extended: {assignment.job.title}', + } + subject = subjects.get(message_type, f'Assignment Notification: {assignment.job.title}') + + # Send email + send_mail( + subject=subject, + message=plain_message, + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa'), + recipient_list=[assignment.agency.email], + html_message=html_message, + fail_silently=False, + ) + + logger.info(f"Assignment notification email sent to {assignment.agency.email} for {message_type}") + return True + + except Exception as e: + logger.error(f"Failed to send assignment notification email: {str(e)}") + return False diff --git a/recruitment/forms.py b/recruitment/forms.py index 6f4e28a..109e836 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -10,13 +10,15 @@ import re from .models import ( ZoomMeeting, Candidate,TrainingMaterial,JobPosting, FormTemplate,InterviewSchedule,BreakTime,JobPostingImage, - Profile,MeetingComment,ScheduledInterview,Source + Profile,MeetingComment,ScheduledInterview,Source,HiringAgency, + AgencyJobAssignment, AgencyAccessLink ) # from django_summernote.widgets import SummernoteWidget from django_ckeditor_5.widgets import CKEditor5Widget import secrets import string from django.core.exceptions import ValidationError +from django.utils import timezone def generate_api_key(length=32): """Generate a secure API key""" @@ -170,13 +172,15 @@ class SourceForm(forms.ModelForm): class CandidateForm(forms.ModelForm): class Meta: model = Candidate - fields = ['job', 'first_name', 'last_name', 'phone', 'email', 'resume',] + fields = ['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')}), @@ -184,6 +188,8 @@ class CandidateForm(forms.ModelForm): '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'}), } def __init__(self, *args, **kwargs): @@ -206,8 +212,11 @@ class CandidateForm(forms.ModelForm): 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') + ) class CandidateStageForm(forms.ModelForm): @@ -643,5 +652,492 @@ class CandidateExamDateForm(forms.ModelForm): 'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}), } +class HiringAgencyForm(forms.ModelForm): + """Form for creating and editing hiring agencies""" + + class Meta: + model = HiringAgency + fields = [ + 'name', 'contact_person', 'email', 'phone', + 'website', 'country', 'address', 'notes' + ] + widgets = { + 'name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter agency name', + 'required': True + }), + 'contact_person': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter contact person name' + }), + 'email': forms.EmailInput(attrs={ + 'class': 'form-control', + 'placeholder': 'agency@example.com' + }), + 'phone': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': '+966 50 123 4567' + }), + 'website': forms.URLInput(attrs={ + 'class': 'form-control', + 'placeholder': 'https://www.agency.com' + }), + 'country': forms.Select(attrs={ + 'class': 'form-select' + }), + 'address': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Enter agency address' + }), + 'notes': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Internal notes about the agency' + }), + } + labels = { + 'name': _('Agency Name'), + 'contact_person': _('Contact Person'), + 'email': _('Email Address'), + 'phone': _('Phone Number'), + 'website': _('Website'), + 'country': _('Country'), + 'address': _('Address'), + 'notes': _('Internal Notes'), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'form-horizontal' + self.helper.label_class = 'col-md-3' + self.helper.field_class = 'col-md-9' + + self.helper.layout = Layout( + Field('name', css_class='form-control'), + Field('contact_person', css_class='form-control'), + Row( + Column('email', css_class='col-md-6'), + Column('phone', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Field('website', css_class='form-control'), + Field('country', css_class='form-control'), + Field('address', css_class='form-control'), + Field('notes', css_class='form-control'), + Div( + Submit('submit', _('Save Agency'), css_class='btn btn-main-action'), + css_class='col-12 mt-4' + ) + ) + + def clean_name(self): + """Ensure agency name is unique""" + name = self.cleaned_data.get('name') + if name: + instance = self.instance + if not instance.pk: # Creating new instance + if HiringAgency.objects.filter(name=name).exists(): + raise ValidationError('An agency with this name already exists.') + else: # Editing existing instance + if HiringAgency.objects.filter(name=name).exclude(pk=instance.pk).exists(): + raise ValidationError('An agency with this name already exists.') + return name.strip() + + def clean_email(self): + """Validate email format and uniqueness""" + email = self.cleaned_data.get('email') + if email: + # Check email format + if not '@' in email or '.' not in email.split('@')[1]: + raise ValidationError('Please enter a valid email address.') + + # Check uniqueness (optional - remove if multiple agencies can have same email) + instance = self.instance + if not instance.pk: # Creating new instance + if HiringAgency.objects.filter(email=email).exists(): + raise ValidationError('An agency with this email already exists.') + else: # Editing existing instance + if HiringAgency.objects.filter(email=email).exclude(pk=instance.pk).exists(): + raise ValidationError('An agency with this email already exists.') + return email.lower().strip() if email else email + + def clean_phone(self): + """Validate phone number format""" + phone = self.cleaned_data.get('phone') + if phone: + # Remove common formatting characters + clean_phone = ''.join(c for c in phone if c.isdigit() or c in '+') + if len(clean_phone) < 10: + raise ValidationError('Phone number must be at least 10 digits long.') + return phone.strip() if phone else phone + + def clean_website(self): + """Validate website URL""" + website = self.cleaned_data.get('website') + if website: + if not website.startswith(('http://', 'https://')): + website = 'https://' + website + validator = URLValidator() + try: + validator(website) + except ValidationError: + raise ValidationError('Please enter a valid website URL.') + return website +class AgencyJobAssignmentForm(forms.ModelForm): + """Form for creating and editing agency job assignments""" + + class Meta: + model = AgencyJobAssignment + fields = [ + 'agency', 'job', 'max_candidates', 'deadline_date','admin_notes' + ] + widgets = { + 'agency': forms.Select(attrs={'class': 'form-select'}), + 'job': forms.Select(attrs={'class': 'form-select'}), + 'max_candidates': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': 1, + 'placeholder': 'Maximum number of candidates' + }), + 'deadline_date': forms.DateTimeInput(attrs={ + 'class': 'form-control', + 'type': 'datetime-local' + }), + 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'status': forms.Select(attrs={'class': 'form-select'}), + 'admin_notes': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Internal notes about this assignment' + }), + } + labels = { + 'agency': _('Agency'), + 'job': _('Job Posting'), + 'max_candidates': _('Maximum Candidates'), + 'deadline_date': _('Deadline Date'), + 'is_active': _('Is Active'), + 'status': _('Status'), + 'admin_notes': _('Admin Notes'), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'form-horizontal' + self.helper.label_class = 'col-md-3' + self.helper.field_class = 'col-md-9' + + # Filter jobs to only show active jobs + self.fields['job'].queryset = JobPosting.objects.filter( + status='ACTIVE' + ).order_by('-created_at') + + self.helper.layout = Layout( + Row( + Column('agency', css_class='col-md-6'), + Column('job', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Row( + Column('max_candidates', css_class='col-md-6'), + Column('deadline_date', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Row( + Column('is_active', css_class='col-md-6'), + Column('status', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Field('admin_notes', css_class='form-control'), + Div( + Submit('submit', _('Save Assignment'), css_class='btn btn-main-action'), + css_class='col-12 mt-4' + ) + ) + + def clean_deadline_date(self): + """Validate deadline date is in the future""" + deadline_date = self.cleaned_data.get('deadline_date') + if deadline_date and deadline_date <= timezone.now(): + raise ValidationError('Deadline date must be in the future.') + return deadline_date + + def clean_max_candidates(self): + """Validate maximum candidates is positive""" + max_candidates = self.cleaned_data.get('max_candidates') + if max_candidates and max_candidates <= 0: + raise ValidationError('Maximum candidates must be greater than 0.') + return max_candidates + + def clean(self): + """Check for duplicate assignments""" + cleaned_data = super().clean() + agency = cleaned_data.get('agency') + job = cleaned_data.get('job') + + if agency and job: + # Check if this assignment already exists + existing = AgencyJobAssignment.objects.filter( + agency=agency, job=job + ).exclude(pk=self.instance.pk).first() + + if existing: + raise ValidationError( + f'This job is already assigned to {agency.name}. ' + f'Current status: {existing.get_status_display()}' + ) + + return cleaned_data + + +class AgencyAccessLinkForm(forms.ModelForm): + """Form for creating and managing agency access links""" + + class Meta: + model = AgencyAccessLink + fields = [ + 'assignment', 'expires_at', 'is_active' + ] + widgets = { + 'assignment': forms.Select(attrs={'class': 'form-select'}), + 'expires_at': forms.DateTimeInput(attrs={ + 'class': 'form-control', + 'type': 'datetime-local' + }), + 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + } + labels = { + 'assignment': _('Assignment'), + 'expires_at': _('Expires At'), + 'is_active': _('Is Active'), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'form-horizontal' + self.helper.label_class = 'col-md-3' + self.helper.field_class = 'col-md-9' + + # Filter assignments to only show active ones without existing links + self.fields['assignment'].queryset = AgencyJobAssignment.objects.filter( + is_active=True, + status='ACTIVE' + ).exclude( + access_link__isnull=False + ).order_by('-created_at') + + self.helper.layout = Layout( + Field('assignment', css_class='form-control'), + Field('expires_at', css_class='form-control'), + Field('is_active', css_class='form-check-input'), + Div( + Submit('submit', _('Create Access Link'), css_class='btn btn-main-action'), + css_class='col-12 mt-4' + ) + ) + + def clean_expires_at(self): + """Validate expiration date is in the future""" + expires_at = self.cleaned_data.get('expires_at') + if expires_at and expires_at <= timezone.now(): + raise ValidationError('Expiration date must be in the future.') + return expires_at + + +# Agency messaging forms removed - AgencyMessage model has been deleted + + +class AgencyCandidateSubmissionForm(forms.ModelForm): + """Form for agencies to submit candidates (simplified - resume + basic info)""" + + class Meta: + model = Candidate + fields = [ + 'first_name', 'last_name', 'email', 'phone', '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', + 'accept': '.pdf,.doc,.docx', + 'required': True + }), + } + labels = { + 'first_name': _('First Name'), + 'last_name': _('Last Name'), + 'email': _('Email Address'), + 'phone': _('Phone Number'), + 'resume': _('Resume'), + } + + def __init__(self, assignment, *args, **kwargs): + super().__init__(*args, **kwargs) + self.assignment = assignment + self.helper = FormHelper() + self.helper.form_method = 'post' + 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' + ) + ) + + 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 + ).first() + + if existing_candidate: + 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): + """Validate resume file""" + resume = self.cleaned_data.get('resume') + if resume: + # Check file size (max 5MB) + if resume.size > 5 * 1024 * 1024: + raise ValidationError('Resume file size must be less than 5MB.') + + # Check file extension + allowed_extensions = ['.pdf', '.doc', '.docx'] + file_extension = resume.name.lower().split('.')[-1] + if f'.{file_extension}' not in allowed_extensions: + raise ValidationError( + 'Resume must be in PDF, DOC, or DOCX format.' + ) + return resume + + def save(self, commit=True): + """Override save to set additional fields""" + instance = super().save(commit=False) + + # Set required fields for agency submission + instance.job = self.assignment.job + instance.hiring_agency = self.assignment.agency + instance.stage = Candidate.Stage.APPLIED + instance.applicant_status = Candidate.ApplicantType.CANDIDATE + instance.applied = True + + if commit: + instance.save() + # Increment the assignment's submitted count + self.assignment.increment_submission_count() + return instance + + +class AgencyLoginForm(forms.Form): + """Form for agencies to login with token and password""" + + token = forms.CharField( + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter your access token' + }), + label=_('Access Token'), + required=True + ) + + password = forms.CharField( + widget=forms.PasswordInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter your password' + }), + label=_('Password'), + required=True + ) + + # def __init__(self, *args, **kwargs): + # super().__init__(*args, **kwargs) + # self.helper = FormHelper() + # self.helper.form_method = 'post' + # self.helper.form_class = 'g-3' + + # self.helper.layout = Layout( + # Field('token', css_class='form-control'), + # Field('password', css_class='form-control'), + # Div( + # Submit('submit', _('Login'), css_class='btn btn-main-action w-100'), + # css_class='col-12 mt-4' + # ) + # ) + + def clean(self): + """Validate token and password combination""" + cleaned_data = super().clean() + token = cleaned_data.get('token') + password = cleaned_data.get('password') + if token and password: + try: + access_link = AgencyAccessLink.objects.get( + unique_token=token, + is_active=True + ) + + if not access_link.is_valid: + if access_link.is_expired: + raise ValidationError('This access link has expired.') + else: + raise ValidationError('This access link is no longer active.') + + if access_link.access_password != password: + raise ValidationError('Invalid password.') + + # Store the access_link for use in the view + self.validated_access_link = access_link + except AgencyAccessLink.DoesNotExist: + print("Access link does not exist") + raise ValidationError('Invalid access token.') + + return cleaned_data diff --git a/recruitment/management/commands/debug_agency_login.py b/recruitment/management/commands/debug_agency_login.py new file mode 100644 index 0000000..922ddf7 --- /dev/null +++ b/recruitment/management/commands/debug_agency_login.py @@ -0,0 +1,55 @@ +from django.core.management.base import BaseCommand +from recruitment.models import AgencyAccessLink, AgencyJobAssignment, HiringAgency +from django.utils import timezone + +class Command(BaseCommand): + help = 'Debug agency login issues by checking existing access links' + + def handle(self, *args, **options): + self.stdout.write("=== Agency Access Link Debug ===") + + # Check total counts + total_links = AgencyAccessLink.objects.count() + total_assignments = AgencyJobAssignment.objects.count() + total_agencies = HiringAgency.objects.count() + + self.stdout.write(f"Total Access Links: {total_links}") + self.stdout.write(f"Total Assignments: {total_assignments}") + self.stdout.write(f"Total Agencies: {total_agencies}") + self.stdout.write("") + + if total_links == 0: + self.stdout.write("❌ NO ACCESS LINKS FOUND!") + self.stdout.write("This is likely the cause of 'Invalid token or password' error.") + self.stdout.write("") + self.stdout.write("To fix this:") + self.stdout.write("1. Create an agency first") + self.stdout.write("2. Create a job assignment for the agency") + self.stdout.write("3. Create an access link for the assignment") + return + + # Show existing links + self.stdout.write("📋 Existing Access Links:") + for link in AgencyAccessLink.objects.all(): + assignment = link.assignment + agency = assignment.agency if assignment else None + job = assignment.job if assignment else None + + self.stdout.write(f" 📍 Token: {link.unique_token}") + self.stdout.write(f" Password: {link.access_password}") + self.stdout.write(f" Active: {link.is_active}") + self.stdout.write(f" Expires: {link.expires_at}") + self.stdout.write(f" Agency: {agency.name if agency else 'None'}") + self.stdout.write(f" Job: {job.title if job else 'None'}") + self.stdout.write(f" Valid: {link.is_valid}") + self.stdout.write("") + + # Show assignments without links + self.stdout.write("📋 Assignments WITHOUT Access Links:") + assignments_without_links = AgencyJobAssignment.objects.filter(access_link__isnull=True) + for assignment in assignments_without_links: + self.stdout.write(f" 📍 {assignment.agency.name} - {assignment.job.title}") + self.stdout.write(f" Status: {assignment.status}") + self.stdout.write(f" Active: {assignment.is_active}") + self.stdout.write(f" Can Submit: {assignment.can_submit}") + self.stdout.write("") diff --git a/recruitment/management/commands/setup_test_agencies.py b/recruitment/management/commands/setup_test_agencies.py new file mode 100644 index 0000000..a02152a --- /dev/null +++ b/recruitment/management/commands/setup_test_agencies.py @@ -0,0 +1,122 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth.models import User +from recruitment.models import HiringAgency, AgencyJobAssignment, JobPosting +from django.utils import timezone +import random + +class Command(BaseCommand): + help = 'Set up test agencies and assignments for messaging system testing' + + def handle(self, *args, **options): + self.stdout.write('Setting up test agencies and assignments...') + + # Create test admin user if not exists + admin_user, created = User.objects.get_or_create( + username='testadmin', + defaults={ + 'email': 'admin@test.com', + 'first_name': 'Test', + 'last_name': 'Admin', + 'is_staff': True, + 'is_superuser': True, + } + ) + if created: + admin_user.set_password('admin123') + admin_user.save() + self.stdout.write(self.style.SUCCESS('Created test admin user: testadmin/admin123')) + + # Create test agencies + agencies_data = [ + { + 'name': 'Tech Talent Solutions', + 'contact_person': 'John Smith', + 'email': 'contact@techtalent.com', + 'phone': '+966501234567', + 'website': 'https://techtalent.com', + 'notes': 'Leading technology recruitment agency specializing in IT and software development roles.', + 'country': 'SA' + }, + { + 'name': 'Healthcare Recruiters Ltd', + 'contact_person': 'Sarah Johnson', + 'email': 'info@healthcarerecruiters.com', + 'phone': '+966502345678', + 'website': 'https://healthcarerecruiters.com', + 'notes': 'Specialized healthcare recruitment agency for medical professionals and healthcare staff.', + 'country': 'SA' + }, + { + 'name': 'Executive Search Partners', + 'contact_person': 'Michael Davis', + 'email': 'partners@execsearch.com', + 'phone': '+966503456789', + 'website': 'https://execsearch.com', + 'notes': 'Premium executive search firm for senior management and C-level positions.', + 'country': 'SA' + } + ] + + created_agencies = [] + for agency_data in agencies_data: + agency, created = HiringAgency.objects.get_or_create( + name=agency_data['name'], + defaults=agency_data + ) + if created: + self.stdout.write(self.style.SUCCESS(f'Created agency: {agency.name}')) + created_agencies.append(agency) + + # Get or create some sample jobs + jobs = [] + job_titles = [ + 'Senior Software Engineer', + 'Healthcare Administrator', + 'Marketing Manager', + 'Data Analyst', + 'HR Director' + ] + + for title in job_titles: + job, created = JobPosting.objects.get_or_create( + internal_job_id=f'KAAUH-2025-{len(jobs)+1:06d}', + defaults={ + 'title': title, + 'description': f'Description for {title} position', + 'qualifications': f'Requirements for {title}', + 'location_city': 'Riyadh', + 'location_country': 'Saudi Arabia', + 'job_type': 'FULL_TIME', + 'workplace_type': 'ON_SITE', + 'application_deadline': timezone.now().date() + timezone.timedelta(days=60), + 'status': 'ACTIVE', + 'created_by': admin_user.username + } + ) + if created: + self.stdout.write(self.style.SUCCESS(f'Created job: {job.title}')) + jobs.append(job) + + # Create agency assignments + for i, agency in enumerate(created_agencies): + for j, job in enumerate(jobs[:2]): # Assign 2 jobs per agency + assignment, created = AgencyJobAssignment.objects.get_or_create( + agency=agency, + job=job, + defaults={ + 'max_candidates': 5, + 'deadline_date': timezone.now() + timezone.timedelta(days=30), + 'status': 'ACTIVE', + 'is_active': True + } + ) + if created: + self.stdout.write(self.style.SUCCESS( + f'Created assignment: {agency.name} -> {job.title}' + )) + + self.stdout.write(self.style.SUCCESS('Test agencies and assignments setup complete!')) + self.stdout.write('\nSummary:') + self.stdout.write(f'- Agencies: {HiringAgency.objects.count()}') + self.stdout.write(f'- Jobs: {JobPosting.objects.count()}') + self.stdout.write(f'- Assignments: {AgencyJobAssignment.objects.count()}') diff --git a/recruitment/management/commands/verify_notifications.py b/recruitment/management/commands/verify_notifications.py new file mode 100644 index 0000000..ed1c6f7 --- /dev/null +++ b/recruitment/management/commands/verify_notifications.py @@ -0,0 +1,112 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth.models import User +from recruitment.models import Notification, HiringAgency +import datetime + + +class Command(BaseCommand): + help = 'Verify the notification system is working correctly' + + def add_arguments(self, parser): + parser.add_argument( + '--detailed', + action='store_true', + help='Show detailed breakdown of notifications', + ) + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS('🔍 Verifying Notification System')) + self.stdout.write('=' * 50) + + # Check notification counts + total_notifications = Notification.objects.count() + pending_notifications = Notification.objects.filter(status='PENDING').count() + sent_notifications = Notification.objects.filter(status='SENT').count() + failed_notifications = Notification.objects.filter(status='FAILED').count() + + self.stdout.write(f'\n📊 Notification Counts:') + self.stdout.write(f' Total Notifications: {total_notifications}') + self.stdout.write(f' Pending: {pending_notifications}') + self.stdout.write(f' Sent: {sent_notifications}') + self.stdout.write(f' Failed: {failed_notifications}') + + # Agency messaging system has been removed - replaced by Notification system + self.stdout.write(f'\n💬 Message System:') + self.stdout.write(f' Agency messaging system has been replaced by Notification system') + + # Check admin user notifications + admin_users = User.objects.filter(is_staff=True) + self.stdout.write(f'\n👤 Admin Users ({admin_users.count()}):') + + for admin in admin_users: + admin_notifications = Notification.objects.filter(recipient=admin).count() + admin_unread = Notification.objects.filter(recipient=admin, status='PENDING').count() + self.stdout.write(f' {admin.username}: {admin_notifications} notifications ({admin_unread} unread)') + + # Check agency notifications + # Note: Current Notification model only supports User recipients, not agencies + # Agency messaging system has been removed + agencies = HiringAgency.objects.all() + self.stdout.write(f'\n🏢 Agencies ({agencies.count()}):') + + for agency in agencies: + self.stdout.write(f' {agency.name}: Agency messaging system has been removed') + + # Check notification types + if options['detailed']: + self.stdout.write(f'\n📋 Detailed Notification Breakdown:') + + # By type + for notification_type in ['email', 'in_app']: + count = Notification.objects.filter(notification_type=notification_type).count() + if count > 0: + self.stdout.write(f' {notification_type}: {count}') + + # By status + for status in ['pending', 'sent', 'read', 'failed', 'retrying']: + count = Notification.objects.filter(status=status).count() + if count > 0: + self.stdout.write(f' {status}: {count}') + + # System health check + self.stdout.write(f'\n🏥 System Health Check:') + + issues = [] + + # Check for failed notifications + if failed_notifications > 0: + issues.append(f'{failed_notifications} failed notifications') + + # Check for admin users without notifications + admin_with_no_notifications = admin_users.filter( + notifications__isnull=True + ).count() + if admin_with_no_notifications > 0 and total_notifications > 0: + issues.append(f'{admin_with_no_notifications} admin users with no notifications') + + if issues: + self.stdout.write(self.style.WARNING(' ⚠️ Issues found:')) + for issue in issues: + self.stdout.write(f' - {issue}') + else: + self.stdout.write(self.style.SUCCESS(' ✅ No issues detected')) + + # Recent activity + recent_notifications = Notification.objects.filter( + created_at__gte=datetime.datetime.now() - datetime.timedelta(hours=24) + ).count() + + self.stdout.write(f'\n🕐 Recent Activity (last 24 hours):') + self.stdout.write(f' New notifications: {recent_notifications}') + + # Summary + self.stdout.write(f'\n📋 Summary:') + if total_notifications > 0 and failed_notifications == 0: + self.stdout.write(self.style.SUCCESS(' ✅ Notification system is working correctly')) + elif failed_notifications > 0: + self.stdout.write(self.style.WARNING(' ⚠️ Notification system has some failures')) + else: + self.stdout.write(self.style.WARNING(' ⚠️ No notifications found - system may not be active')) + + self.stdout.write('\n' + '=' * 50) + self.stdout.write(self.style.SUCCESS('✨ Verification complete!')) diff --git a/recruitment/migrations/0003_candidate_hired_date_source_custom_headers_and_more.py b/recruitment/migrations/0003_candidate_hired_date_source_custom_headers_and_more.py new file mode 100644 index 0000000..7c999ad --- /dev/null +++ b/recruitment/migrations/0003_candidate_hired_date_source_custom_headers_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.4 on 2025-10-26 13:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0002_candidate_retry'), + ] + + operations = [ + migrations.AddField( + model_name='candidate', + name='hired_date', + field=models.DateField(blank=True, null=True, verbose_name='Hired Date'), + ), + migrations.AddField( + model_name='source', + name='custom_headers', + field=models.TextField(blank=True, help_text='JSON object with custom HTTP headers for sync requests', null=True, verbose_name='Custom Headers'), + ), + migrations.AddField( + model_name='source', + name='supports_outbound_sync', + field=models.BooleanField(default=False, help_text='Whether this source supports receiving candidate data from ATS', verbose_name='Supports Outbound Sync'), + ), + migrations.AddField( + model_name='source', + name='sync_endpoint', + field=models.URLField(blank=True, help_text='Endpoint URL for sending candidate data (for outbound sync)', null=True, verbose_name='Sync Endpoint'), + ), + migrations.AddField( + model_name='source', + name='sync_method', + field=models.CharField(blank=True, choices=[('POST', 'POST'), ('PUT', 'PUT')], default='POST', help_text='HTTP method for outbound sync requests', max_length=10, verbose_name='Sync Method'), + ), + migrations.AddField( + model_name='source', + name='test_method', + field=models.CharField(blank=True, choices=[('GET', 'GET'), ('POST', 'POST')], default='GET', help_text='HTTP method for connection testing', max_length=10, verbose_name='Test Method'), + ), + migrations.AlterField( + model_name='candidate', + name='stage', + field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired')], db_index=True, default='Applied', max_length=100, verbose_name='Stage'), + ), + ] diff --git a/recruitment/migrations/0004_alter_integrationlog_method.py b/recruitment/migrations/0004_alter_integrationlog_method.py new file mode 100644 index 0000000..e4ab1d0 --- /dev/null +++ b/recruitment/migrations/0004_alter_integrationlog_method.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-10-26 13:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0003_candidate_hired_date_source_custom_headers_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='integrationlog', + name='method', + field=models.CharField(blank=True, max_length=50, verbose_name='HTTP Method'), + ), + ] diff --git a/recruitment/migrations/0005_rename_submitted_by_agency_candidate_hiring_agency.py b/recruitment/migrations/0005_rename_submitted_by_agency_candidate_hiring_agency.py new file mode 100644 index 0000000..48ea4b0 --- /dev/null +++ b/recruitment/migrations/0005_rename_submitted_by_agency_candidate_hiring_agency.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-10-26 14:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0004_alter_integrationlog_method'), + ] + + operations = [ + migrations.RenameField( + model_name='candidate', + old_name='submitted_by_agency', + new_name='hiring_agency', + ), + ] diff --git a/recruitment/migrations/0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more.py b/recruitment/migrations/0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more.py new file mode 100644 index 0000000..8c1c20c --- /dev/null +++ b/recruitment/migrations/0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more.py @@ -0,0 +1,129 @@ +# Generated by Django 5.2.6 on 2025-10-26 14:51 + +import django.db.models.deletion +import django_extensions.db.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0005_rename_submitted_by_agency_candidate_hiring_agency'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AgencyJobAssignment', + 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')), + ('max_candidates', models.PositiveIntegerField(help_text='Maximum candidates agency can submit for this job', verbose_name='Maximum Candidates')), + ('candidates_submitted', models.PositiveIntegerField(default=0, help_text='Number of candidates submitted so far', verbose_name='Candidates Submitted')), + ('assigned_date', models.DateTimeField(auto_now_add=True, verbose_name='Assigned Date')), + ('deadline_date', models.DateTimeField(help_text='Deadline for agency to submit candidates', verbose_name='Deadline Date')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('status', models.CharField(choices=[('ACTIVE', 'Active'), ('COMPLETED', 'Completed'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='ACTIVE', max_length=20, verbose_name='Status')), + ('deadline_extended', models.BooleanField(default=False, verbose_name='Deadline Extended')), + ('original_deadline', models.DateTimeField(blank=True, help_text='Original deadline before extensions', null=True, verbose_name='Original Deadline')), + ('admin_notes', models.TextField(blank=True, help_text='Internal notes about this assignment', verbose_name='Admin Notes')), + ('agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_assignments', to='recruitment.hiringagency', verbose_name='Agency')), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agency_assignments', to='recruitment.jobposting', verbose_name='Job')), + ], + options={ + 'verbose_name': 'Agency Job Assignment', + 'verbose_name_plural': 'Agency Job Assignments', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='AgencyAccessLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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')), + ('unique_token', models.CharField(editable=False, max_length=64, unique=True, verbose_name='Unique Token')), + ('access_password', models.CharField(help_text='Password for agency access', max_length=32, verbose_name='Access Password')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('expires_at', models.DateTimeField(help_text='When this access link expires', verbose_name='Expires At')), + ('last_accessed', models.DateTimeField(blank=True, null=True, verbose_name='Last Accessed')), + ('access_count', models.PositiveIntegerField(default=0, verbose_name='Access Count')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('assignment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='access_link', to='recruitment.agencyjobassignment', verbose_name='Assignment')), + ], + options={ + 'verbose_name': 'Agency Access Link', + 'verbose_name_plural': 'Agency Access Links', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='AgencyMessage', + 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')), + ('subject', models.CharField(max_length=200, verbose_name='Subject')), + ('message', models.TextField(verbose_name='Message')), + ('message_type', models.CharField(choices=[('INFO', 'Information'), ('WARNING', 'Warning'), ('EXTENSION', 'Deadline Extension'), ('GENERAL', 'General')], default='GENERAL', max_length=20, verbose_name='Message Type')), + ('is_read', models.BooleanField(default=False, verbose_name='Is Read')), + ('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')), + ('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.agencyjobassignment', verbose_name='Assignment')), + ('recipient_agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to='recruitment.hiringagency', verbose_name='Recipient Agency')), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')), + ], + options={ + 'verbose_name': 'Agency Message', + 'verbose_name_plural': 'Agency Messages', + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'), + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['job', 'status'], name='recruitment_job_id_d798a8_idx'), + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['deadline_date'], name='recruitment_deadlin_57d3b4_idx'), + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['is_active'], name='recruitment_is_acti_93b919_idx'), + ), + migrations.AlterUniqueTogether( + name='agencyjobassignment', + unique_together={('agency', 'job')}, + ), + migrations.AddIndex( + model_name='agencyaccesslink', + index=models.Index(fields=['unique_token'], name='recruitment_unique__f91e76_idx'), + ), + migrations.AddIndex( + model_name='agencyaccesslink', + index=models.Index(fields=['expires_at'], name='recruitment_expires_954ed9_idx'), + ), + migrations.AddIndex( + model_name='agencyaccesslink', + index=models.Index(fields=['is_active'], name='recruitment_is_acti_4b0804_idx'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['assignment', 'is_read'], name='recruitment_assignm_4f518d_idx'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['recipient_agency', 'is_read'], name='recruitment_recipie_427b10_idx'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['sender'], name='recruitment_sender__97dd96_idx'), + ), + ] diff --git a/recruitment/migrations/0007_candidate_source_candidate_source_type.py b/recruitment/migrations/0007_candidate_source_candidate_source_type.py new file mode 100644 index 0000000..5f83b42 --- /dev/null +++ b/recruitment/migrations/0007_candidate_source_candidate_source_type.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.6 on 2025-10-27 11:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='candidate', + name='source', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.source', verbose_name='Source'), + ), + migrations.AddField( + model_name='candidate', + name='source_type', + field=models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Source'), + ), + ] diff --git a/recruitment/migrations/0008_remove_candidate_source_remove_candidate_source_type_and_more.py b/recruitment/migrations/0008_remove_candidate_source_remove_candidate_source_type_and_more.py new file mode 100644 index 0000000..591252c --- /dev/null +++ b/recruitment/migrations/0008_remove_candidate_source_remove_candidate_source_type_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.6 on 2025-10-27 11:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0007_candidate_source_candidate_source_type'), + ] + + operations = [ + migrations.RemoveField( + model_name='candidate', + name='source', + ), + migrations.RemoveField( + model_name='candidate', + name='source_type', + ), + migrations.AddField( + model_name='candidate', + name='hiring_source', + field=models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source'), + ), + migrations.AlterField( + model_name='candidate', + name='hiring_agency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.hiringagency', verbose_name='Hiring Agency'), + ), + ] diff --git a/recruitment/migrations/0009_agencymessage_priority_agencymessage_recipient_user_and_more.py b/recruitment/migrations/0009_agencymessage_priority_agencymessage_recipient_user_and_more.py new file mode 100644 index 0000000..5b27532 --- /dev/null +++ b/recruitment/migrations/0009_agencymessage_priority_agencymessage_recipient_user_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.6 on 2025-10-27 20:26 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0008_remove_candidate_source_remove_candidate_source_type_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='agencymessage', + name='priority', + field=models.CharField(choices=[('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High'), ('URGENT', 'Urgent')], default='MEDIUM', max_length=10, verbose_name='Priority'), + ), + migrations.AddField( + model_name='agencymessage', + name='recipient_user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient User'), + ), + migrations.AddField( + model_name='agencymessage', + name='send_email', + field=models.BooleanField(default=False, verbose_name='Send Email Notification'), + ), + migrations.AddField( + model_name='agencymessage', + name='send_sms', + field=models.BooleanField(default=False, verbose_name='Send SMS Notification'), + ), + migrations.AddField( + model_name='agencymessage', + name='sender_agency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to='recruitment.hiringagency', verbose_name='Sender Agency'), + ), + migrations.AddField( + model_name='agencymessage', + name='sender_type', + field=models.CharField(choices=[('ADMIN', 'Admin'), ('AGENCY', 'Agency')], default='ADMIN', max_length=10, verbose_name='Sender Type'), + ), + migrations.AlterField( + model_name='agencymessage', + name='sender', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['sender_type', 'created_at'], name='recruitment_sender__14b136_idx'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['priority', 'created_at'], name='recruitment_priorit_80d9f1_idx'), + ), + ] diff --git a/recruitment/migrations/0010_remove_agency_message_model.py b/recruitment/migrations/0010_remove_agency_message_model.py new file mode 100644 index 0000000..f042dcf --- /dev/null +++ b/recruitment/migrations/0010_remove_agency_message_model.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.6 on 2025-10-29 10:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0009_agencymessage_priority_agencymessage_recipient_user_and_more'), + ] + + operations = [ + migrations.DeleteModel( + name='AgencyMessage', + ), + ] diff --git a/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc b/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc index 46b6a0f9217b6233caf9684172c45f65176e41d3..8d49e501f96f38e198262db6b7e7b656884ee136 100644 GIT binary patch delta 22 ccmbO|n`!oJChpI?yj%=Ga9`oyM(zo{08#Y^X#fBK delta 22 ccmbO|n`!oJChpI?yj%=G;Pmv*M(zo{08%~&j{pDw diff --git a/recruitment/models.py b/recruitment/models.py index 7958a96..a7674b5 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -319,6 +319,9 @@ class JobPosting(Base): @property def accepted_candidates(self): return self.all_candidates.filter(offer_status="Accepted") + @property + def hired_candidates(self): + return self.all_candidates.filter(stage="Hired") # counts @property @@ -355,6 +358,7 @@ class Candidate(Base): EXAM = "Exam", _("Exam") INTERVIEW = "Interview", _("Interview") OFFER = "Offer", _("Offer") + HIRED = "Hired", _("Hired") class ExamStatus(models.TextChoices): PASSED = "Passed", _("Passed") @@ -436,6 +440,7 @@ class Candidate(Base): 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_data = models.JSONField( verbose_name="AI Analysis Data", @@ -443,22 +448,23 @@ class Candidate(Base): help_text="Full JSON output from the resume scoring model." )# {'resume_data': {}, 'analysis_data': {}} - # Scoring fields (populated by signal) - # match_score = models.IntegerField(db_index=True, null=True, blank=True) # Added index - # strengths = models.TextField(blank=True) - # weaknesses = models.TextField(blank=True) - # criteria_checklist = models.JSONField(default=dict, blank=True) - # major_category_name = models.TextField(db_index=True, blank=True, verbose_name=_("Major Category Name")) # Added index - # recommendation = models.TextField(blank=True, verbose_name=_("Recommendation")) - submitted_by_agency = models.ForeignKey( + retry = models.SmallIntegerField(verbose_name="Resume Parsing Retry",default=3) + hiring_source = models.CharField( + max_length=255, + null=True, + blank=True, + verbose_name=_("Hiring Source"), + choices=[("Public", "Public"), ("Internal", "Internal"), ("Agency", "Agency")], + default="Public", + ) + hiring_agency = models.ForeignKey( "HiringAgency", on_delete=models.SET_NULL, null=True, blank=True, - related_name="submitted_candidates", - verbose_name=_("Submitted by Agency"), + related_name="candidates", + verbose_name=_("Hiring Agency"), ) - retry = models.SmallIntegerField(verbose_name="Resume Parsing Retry",default=3) class Meta: verbose_name = _("Candidate") @@ -1173,7 +1179,7 @@ class IntegrationLog(Base): max_length=20, choices=ActionChoices.choices, verbose_name=_("Action") ) endpoint = models.CharField(max_length=255, blank=True, verbose_name=_("Endpoint")) - method = models.CharField(max_length=10, blank=True, verbose_name=_("HTTP Method")) + method = models.CharField(max_length=50, blank=True, verbose_name=_("HTTP Method")) request_data = models.JSONField( blank=True, null=True, verbose_name=_("Request Data") ) @@ -1233,6 +1239,275 @@ class HiringAgency(Base): ordering = ["name"] +class AgencyJobAssignment(Base): + """Assigns specific jobs to agencies with limits and deadlines""" + + class AssignmentStatus(models.TextChoices): + ACTIVE = "ACTIVE", _("Active") + COMPLETED = "COMPLETED", _("Completed") + EXPIRED = "EXPIRED", _("Expired") + CANCELLED = "CANCELLED", _("Cancelled") + + agency = models.ForeignKey( + HiringAgency, + on_delete=models.CASCADE, + related_name="job_assignments", + verbose_name=_("Agency") + ) + job = models.ForeignKey( + JobPosting, + on_delete=models.CASCADE, + related_name="agency_assignments", + verbose_name=_("Job") + ) + + # Limits & Controls + max_candidates = models.PositiveIntegerField( + verbose_name=_("Maximum Candidates"), + help_text=_("Maximum candidates agency can submit for this job") + ) + candidates_submitted = models.PositiveIntegerField( + default=0, + verbose_name=_("Candidates Submitted"), + help_text=_("Number of candidates submitted so far") + ) + + # Timeline + assigned_date = models.DateTimeField(auto_now_add=True, verbose_name=_("Assigned Date")) + deadline_date = models.DateTimeField( + verbose_name=_("Deadline Date"), + help_text=_("Deadline for agency to submit candidates") + ) + + # Status & Extensions + is_active = models.BooleanField(default=True, verbose_name=_("Is Active")) + status = models.CharField( + max_length=20, + choices=AssignmentStatus.choices, + default=AssignmentStatus.ACTIVE, + verbose_name=_("Status") + ) + + # Extension tracking + deadline_extended = models.BooleanField( + default=False, + verbose_name=_("Deadline Extended") + ) + original_deadline = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Original Deadline"), + help_text=_("Original deadline before extensions") + ) + + # Admin notes + admin_notes = models.TextField( + blank=True, + verbose_name=_("Admin Notes"), + help_text=_("Internal notes about this assignment") + ) + + class Meta: + verbose_name = _("Agency Job Assignment") + verbose_name_plural = _("Agency Job Assignments") + ordering = ["-created_at"] + indexes = [ + models.Index(fields=['agency', 'status']), + models.Index(fields=['job', 'status']), + models.Index(fields=['deadline_date']), + models.Index(fields=['is_active']), + ] + unique_together = ['agency', 'job'] # Prevent duplicate assignments + + def __str__(self): + return f"{self.agency.name} - {self.job.title}" + + @property + def days_remaining(self): + """Calculate days remaining until deadline""" + if not self.deadline_date: + return 0 + delta = self.deadline_date.date() - timezone.now().date() + return max(0, delta.days) + + @property + def is_currently_active(self): + """Check if assignment is currently active""" + return ( + self.status == 'ACTIVE' and + self.deadline_date and + self.deadline_date > timezone.now() and + self.candidates_submitted < self.max_candidates + ) + + @property + def can_submit(self): + """Check if candidates can still be submitted""" + return self.is_currently_active + + def clean(self): + """Validate assignment constraints""" + if self.deadline_date and self.deadline_date <= timezone.now(): + raise ValidationError(_("Deadline date must be in the future")) + + if self.max_candidates <= 0: + raise ValidationError(_("Maximum candidates must be greater than 0")) + + if self.candidates_submitted > self.max_candidates: + raise ValidationError(_("Candidates submitted cannot exceed maximum candidates")) + + @property + def remaining_slots(self): + """Return number of remaining candidate slots""" + return max(0, self.max_candidates - self.candidates_submitted) + + @property + def is_expired(self): + """Check if assignment has expired""" + return self.deadline_date and self.deadline_date <= timezone.now() + + @property + def is_full(self): + """Check if assignment has reached maximum candidates""" + return self.candidates_submitted >= self.max_candidates + + @property + def can_submit(self): + """Check if agency can still submit candidates""" + return (self.is_active and + not self.is_expired and + not self.is_full and + self.status == self.AssignmentStatus.ACTIVE) + + def increment_submission_count(self): + """Safely increment the submitted candidates count""" + if self.can_submit: + self.candidates_submitted += 1 + self.save(update_fields=['candidates_submitted']) + + # Check if assignment is now complete + # if self.candidates_submitted >= self.max_candidates: + # self.status = self.AssignmentStatus.COMPLETED + # self.save(update_fields=['status']) + return True + return False + + def extend_deadline(self, new_deadline): + """Extend the deadline for this assignment""" + # Convert database deadline to timezone-aware for comparison + deadline_aware = timezone.make_aware(self.deadline_date) if timezone.is_naive(self.deadline_date) else self.deadline_date + if new_deadline > deadline_aware: + if not self.deadline_extended: + self.original_deadline = self.deadline_date + self.deadline_extended = True + self.deadline_date = new_deadline + self.save(update_fields=['deadline_date', 'original_deadline', 'deadline_extended']) + return True + return False + + +class AgencyAccessLink(Base): + """Secure access links for agencies to submit candidates""" + + assignment = models.OneToOneField( + AgencyJobAssignment, + on_delete=models.CASCADE, + related_name="access_link", + verbose_name=_("Assignment") + ) + + # Security + unique_token = models.CharField( + max_length=64, + unique=True, + editable=False, + verbose_name=_("Unique Token") + ) + access_password = models.CharField( + max_length=32, + verbose_name=_("Access Password"), + help_text=_("Password for agency access") + ) + + # Timeline + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) + expires_at = models.DateTimeField( + verbose_name=_("Expires At"), + help_text=_("When this access link expires") + ) + last_accessed = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Last Accessed") + ) + + # Usage tracking + access_count = models.PositiveIntegerField( + default=0, + verbose_name=_("Access Count") + ) + is_active = models.BooleanField( + default=True, + verbose_name=_("Is Active") + ) + + class Meta: + verbose_name = _("Agency Access Link") + verbose_name_plural = _("Agency Access Links") + ordering = ["-created_at"] + indexes = [ + models.Index(fields=['unique_token']), + models.Index(fields=['expires_at']), + models.Index(fields=['is_active']), + ] + + def __str__(self): + return f"Access Link for {self.assignment}" + + def clean(self): + """Validate access link constraints""" + if self.expires_at and self.expires_at <= timezone.now(): + raise ValidationError(_("Expiration date must be in the future")) + + @property + def is_expired(self): + """Check if access link has expired""" + return self.expires_at and self.expires_at <= timezone.now() + + @property + def is_valid(self): + """Check if access link is valid and active""" + return self.is_active and not self.is_expired + + def record_access(self): + """Record an access to this link""" + self.last_accessed = timezone.now() + self.access_count += 1 + self.save(update_fields=['last_accessed', 'access_count']) + + def generate_token(self): + """Generate a unique secure token""" + import secrets + self.unique_token = secrets.token_urlsafe(48) + + def generate_password(self): + """Generate a random password""" + import secrets + import string + alphabet = string.ascii_letters + string.digits + self.access_password = ''.join(secrets.choice(alphabet) for _ in range(12)) + + def save(self, *args, **kwargs): + """Override save to generate token and password if not set""" + if not self.unique_token: + self.generate_token() + if not self.access_password: + self.generate_password() + super().save(*args, **kwargs) + + + + class BreakTime(models.Model): """Model to store break times for a schedule""" diff --git a/recruitment/signals.py b/recruitment/signals.py index 5f6b623..7f73baf 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -6,7 +6,9 @@ from django_q.tasks import schedule from django.dispatch import receiver from django_q.tasks import async_task from django.db.models.signals import post_save -from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting +from django.contrib.auth.models import User +from django.utils import timezone +from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting,Notification logger = logging.getLogger(__name__) @@ -49,7 +51,7 @@ def format_job(sender, instance, created, **kwargs): def score_candidate_resume(sender, instance, created, **kwargs): if not instance.is_resume_parsed: logger.info(f"Scoring resume for candidate {instance.pk}") - async_task( + async_task( 'recruitment.tasks.handle_reume_parsing_and_scoring', instance.pk, hook='recruitment.hooks.callback_ai_parsing' @@ -351,4 +353,38 @@ def create_default_stages(sender, instance, created, **kwargs): # required=False, # order=3, # is_predefined=True - # ) \ No newline at end of file + # ) + + +# AgencyMessage signal handler removed - model has been deleted + +# SSE notification cache for real-time updates +SSE_NOTIFICATION_CACHE = {} + +@receiver(post_save, sender=Notification) +def notification_created(sender, instance, created, **kwargs): + """Signal handler for when a notification is created""" + if created: + logger.info(f"New notification created: {instance.id} for user {instance.recipient.username}") + + # Store notification in cache for SSE + user_id = instance.recipient.id + if user_id not in SSE_NOTIFICATION_CACHE: + SSE_NOTIFICATION_CACHE[user_id] = [] + + notification_data = { + 'id': instance.id, + 'message': instance.message[:100] + ('...' if len(instance.message) > 100 else ''), + 'type': instance.get_notification_type_display(), + 'status': instance.get_status_display(), + 'time_ago': 'Just now', + 'url': f"/notifications/{instance.id}/" + } + + SSE_NOTIFICATION_CACHE[user_id].append(notification_data) + + # Keep only last 50 notifications per user in cache + if len(SSE_NOTIFICATION_CACHE[user_id]) > 50: + SSE_NOTIFICATION_CACHE[user_id] = SSE_NOTIFICATION_CACHE[user_id][-50:] + + logger.info(f"Notification cached for SSE: {notification_data}") diff --git a/recruitment/tasks.py b/recruitment/tasks.py index ab66b0b..ac2e6ad 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -583,23 +583,24 @@ def sync_hired_candidates_task(job_slug): # Initialize sync service sync_service = CandidateSyncService() + print(sync_service) # Perform the sync operation results = sync_service.sync_hired_candidates_to_all_sources(job) - + print(results) # Log the sync operation - IntegrationLog.objects.create( - source=None, # This is a multi-source sync operation - action=IntegrationLog.ActionChoices.SYNC, - endpoint="multi_source_sync", - method="BACKGROUND_TASK", - request_data={"job_slug": job_slug, "candidate_count": job.accepted_candidates.count()}, - response_data=results, - status_code="SUCCESS" if results.get('summary', {}).get('failed', 0) == 0 else "PARTIAL", - ip_address="127.0.0.1", # Background task - user_agent="Django-Q Background Task", - processing_time=results.get('summary', {}).get('total_duration', 0) - ) + # IntegrationLog.objects.create( + # source=None, # This is a multi-source sync operation + # action=IntegrationLog.ActionChoices.SYNC, + # endpoint="multi_source_sync", + # method="BACKGROUND_TASK", + # request_data={"job_slug": job_slug, "candidate_count": job.accepted_candidates.count()}, + # response_data=results, + # status_code="SUCCESS" if results.get('summary', {}).get('failed', 0) == 0 else "PARTIAL", + # ip_address="127.0.0.1", # Background task + # user_agent="Django-Q Background Task", + # processing_time=results.get('summary', {}).get('total_duration', 0) + # ) logger.info(f"Background sync completed for job {job_slug}: {results}") return results diff --git a/recruitment/urls.py b/recruitment/urls.py index dbc0b4e..5ea13e2 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -77,9 +77,6 @@ urlpatterns = [ # Sync URLs path('jobs//sync-hired-candidates/', views_frontend.sync_hired_candidates, name='sync_hired_candidates'), path('sources//test-connection/', views_frontend.test_source_connection, name='test_source_connection'), - path('sync/task//status/', views_frontend.sync_task_status, name='sync_task_status'), - path('sync/history/', views_frontend.sync_history, name='sync_history'), - path('sync/history//', views_frontend.sync_history, name='sync_history_job'), path('jobs///reschedule_meeting_for_candidate//', views.reschedule_meeting_for_candidate, name='reschedule_meeting_for_candidate'), @@ -152,4 +149,72 @@ urlpatterns = [ path('meetings//comments//delete/', views.delete_meeting_comment, name='delete_meeting_comment'), path('meetings//set_meeting_candidate/', views.set_meeting_candidate, name='set_meeting_candidate'), + + # Hiring Agency URLs + path('agencies/', views.agency_list, name='agency_list'), + path('agencies/create/', views.agency_create, name='agency_create'), + path('agencies//', views.agency_detail, name='agency_detail'), + path('agencies//update/', views.agency_update, name='agency_update'), + path('agencies//delete/', views.agency_delete, name='agency_delete'), + path('agencies//candidates/', views.agency_candidates, name='agency_candidates'), + # path('agencies//send-message/', views.agency_detail_send_message, name='agency_detail_send_message'), + + # Agency Assignment Management URLs + path('agency-assignments/', views.agency_assignment_list, name='agency_assignment_list'), + path('agency-assignments/create/', views.agency_assignment_create, name='agency_assignment_create'), + path('agency-assignments//create/', views.agency_assignment_create, name='agency_assignment_create'), + path('agency-assignments//', views.agency_assignment_detail, name='agency_assignment_detail'), + path('agency-assignments//update/', views.agency_assignment_update, name='agency_assignment_update'), + path('agency-assignments//extend-deadline/', views.agency_assignment_extend_deadline, name='agency_assignment_extend_deadline'), + + # Agency Access Link URLs + path('agency-access-links/create/', views.agency_access_link_create, name='agency_access_link_create'), + path('agency-access-links//', views.agency_access_link_detail, name='agency_access_link_detail'), + path('agency-access-links//deactivate/', views.agency_access_link_deactivate, name='agency_access_link_deactivate'), + path('agency-access-links//reactivate/', views.agency_access_link_reactivate, name='agency_access_link_reactivate'), + + # Admin Message Center URLs (messaging functionality removed) + # path('admin/messages/', views.admin_message_center, name='admin_message_center'), + # path('admin/messages/compose/', views.admin_compose_message, name='admin_compose_message'), + # path('admin/messages//', views.admin_message_detail, name='admin_message_detail'), + # path('admin/messages//reply/', views.admin_message_reply, name='admin_message_reply'), + # path('admin/messages//mark-read/', views.admin_mark_message_read, name='admin_mark_message_read'), + # path('admin/messages//delete/', views.admin_delete_message, name='admin_delete_message'), + + # Agency Portal URLs (for external agencies) + path('portal/login/', views.agency_portal_login, name='agency_portal_login'), + path('portal/dashboard/', views.agency_portal_dashboard, name='agency_portal_dashboard'), + path('portal/assignment//', views.agency_portal_assignment_detail, name='agency_portal_assignment_detail'), + path('portal/assignment//submit-candidate/', views.agency_portal_submit_candidate_page, name='agency_portal_submit_candidate_page'), + path('portal/submit-candidate/', views.agency_portal_submit_candidate, name='agency_portal_submit_candidate'), + path('portal/logout/', views.agency_portal_logout, name='agency_portal_logout'), + + # Agency Portal Candidate Management URLs + path('portal/candidates//edit/', views.agency_portal_edit_candidate, name='agency_portal_edit_candidate'), + path('portal/candidates//delete/', views.agency_portal_delete_candidate, name='agency_portal_delete_candidate'), + + # API URLs for messaging (removed) + # path('api/agency/messages//', views.api_agency_message_detail, name='api_agency_message_detail'), + # path('api/agency/messages//mark-read/', views.api_agency_mark_message_read, name='api_agency_mark_message_read'), + + # API URLs for candidate management + path('api/candidate//', views.api_candidate_detail, name='api_candidate_detail'), + + # Admin Notification API + path('api/admin/notification-count/', views.api_notification_count, name='admin_notification_count'), + + # Agency Notification API + path('api/agency/notification-count/', views.api_notification_count, name='api_agency_notification_count'), + + # SSE Notification Stream + path('api/notifications/stream/', views.notification_stream, name='notification_stream'), + + # Notification URLs + path('notifications/', views.notification_list, name='notification_list'), + path('notifications//', views.notification_detail, name='notification_detail'), + path('notifications//mark-read/', views.notification_mark_read, name='notification_mark_read'), + path('notifications//mark-unread/', views.notification_mark_unread, name='notification_mark_unread'), + path('notifications//delete/', views.notification_delete, name='notification_delete'), + path('notifications/mark-all-read/', views.notification_mark_all_read, name='notification_mark_all_read'), + path('api/notification-count/', views.api_notification_count, name='api_notification_count'), ] diff --git a/recruitment/views.py b/recruitment/views.py index 9d535fa..5b004e5 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -3,6 +3,7 @@ import json from django.utils.translation import gettext as _ from django.contrib.auth.models import User 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 rich import print @@ -33,7 +34,11 @@ from .forms import ( StaffUserCreationForm, MeetingCommentForm, ToggleAccountForm, - + HiringAgencyForm, + AgencyCandidateSubmissionForm, + AgencyLoginForm, + AgencyAccessLinkForm, + AgencyJobAssignmentForm ) from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from rest_framework import viewsets @@ -68,7 +73,10 @@ from .models import ( JobPosting, ScheduledInterview, JobPostingImage, - Profile,MeetingComment + Profile,MeetingComment,HiringAgency, + AgencyJobAssignment, + AgencyAccessLink, + Notification ) import logging from datastar_py.django import ( @@ -509,8 +517,6 @@ def kaauh_career(request): return render(request,'jobs/career.html',{'active_jobs':active_jobs}) - - # job detail facing the candidate: def application_detail(request, slug): job = get_object_or_404(JobPosting, slug=slug) @@ -588,7 +594,7 @@ def linkedin_callback(request): try: service = LinkedInService() - # get_access_token(code)->It makes a POST request to LinkedIn’s token endpoint with parameters + # get_access_token(code)->It makes a POST request to LinkedIn's token endpoint with parameters access_token = service.get_access_token(code) request.session["linkedin_access_token"] = access_token request.session["linkedin_authenticated"] = True @@ -1326,6 +1332,7 @@ def candidate_exam_view(request, slug): } return render(request, "recruitment/candidate_exam_view.html", context) + @login_required def update_candidate_exam_status(request, slug): candidate = get_object_or_404(Candidate, slug=slug) @@ -1384,7 +1391,7 @@ def candidate_update_status(request, slug): @login_required def candidate_interview_view(request,slug): - job = get_object_or_404(JobPosting,slug=slug) + job = get_object_or_404(JobPosting, slug=slug) context = {"job":job,"candidates":job.interview_candidates,'current_stage':'Interview'} return render(request,"recruitment/candidate_interview_view.html",context) @@ -1437,6 +1444,7 @@ def delete_meeting_for_candidate(request,slug,candidate_pk,meeting_id): context = {"job":job,"candidate":candidate,"meeting":meeting,'delete_url':reverse("delete_meeting_for_candidate",kwargs={"slug":job.slug,"candidate_pk":candidate_pk,"meeting_id":meeting_id})} return render(request,"meetings/delete_meeting_form.html",context) + @login_required def interview_calendar_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) @@ -1491,6 +1499,7 @@ def interview_calendar_view(request, slug): return render(request, 'recruitment/interview_calendar.html', context) + @login_required def interview_detail_view(request, slug, interview_id): job = get_object_or_404(JobPosting, slug=slug) @@ -1507,6 +1516,7 @@ def interview_detail_view(request, slug, interview_id): return render(request, 'recruitment/interview_detail.html', context) + # Candidate Meeting Scheduling/Rescheduling Views @require_POST def api_schedule_candidate_meeting(request, job_slug, candidate_pk): @@ -1766,6 +1776,7 @@ def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_ messages.error(request, result["message"]) return JsonResponse({'success': False, 'error': result["message"]}, status=400) + # The original schedule_candidate_meeting and reschedule_candidate_meeting (without api_ prefix) # can be removed if their only purpose was to be called by the JS onclicks. # If they were intended for other direct URL access, they can be kept as simple redirects @@ -1806,7 +1817,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): new_start_time = form.cleaned_data.get('start_time') new_duration = form.cleaned_data.get('duration') - # Use a default topic if not provided, keeping the original structure + # 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}" @@ -1893,16 +1904,16 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): else: # Form validation errors return render(request, "recruitment/schedule_meeting_form.html", { - 'form': form, - 'job': job, - 'candidate': candidate, - 'scheduled_interview': scheduled_interview, - 'initial_topic': request.POST.get('topic', new_topic), - 'initial_start_time': request.POST.get('start_time', new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else ''), - 'initial_duration': request.POST.get('duration', new_duration), - 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), - 'has_future_meeting': has_other_future_meetings - }) + 'form': form, + 'job': job, + 'candidate': candidate, + 'scheduled_interview': scheduled_interview, + 'initial_topic': request.POST.get('topic', new_topic), + 'initial_start_time': request.POST.get('start_time', new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else ''), + 'initial_duration': request.POST.get('duration', new_duration), + 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), + 'has_future_meeting': has_other_future_meetings + }) else: # GET request # Pre-populate form with existing meeting details initial_data = { @@ -1924,7 +1935,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): def schedule_meeting_for_candidate(request, slug, candidate_pk): """ Handles GET to display a simple form for scheduling a meeting for a candidate. - Handles POST to process the form, create the meeting, and redirect back. + 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) @@ -2038,7 +2049,7 @@ def user_profile_image_update(request, pk): messages.success(request, 'Image uploaded successfully') return redirect('user_detail', pk=user.pk) else: - messages.error(request, 'An error occurred while uploading the image. Please check the errors below.') + messages.error(request, 'An error occurred while uploading image. Please check the errors below.') else: profile_form = ProfileImageUploadForm(instance=user.profile) @@ -2149,8 +2160,6 @@ def create_staff_user(request): - - @user_passes_test(is_superuser_check) def admin_settings(request): staffs=User.objects.filter(is_superuser=False) @@ -2265,24 +2274,26 @@ def add_meeting_comment(request, slug): return redirect('meeting_details', slug=slug) + + @login_required def edit_meeting_comment(request, slug, comment_id): """Edit a meeting comment""" meeting = get_object_or_404(ZoomMeeting, slug=slug) comment = get_object_or_404(MeetingComment, id=comment_id, meeting=meeting) - # Check if user is the author - if comment.author != request.user: + # Check if user is author + if comment.author != request.user and not request.user.is_staff: messages.error(request, 'You can only edit your own comments.') return redirect('meeting_details', slug=slug) if request.method == 'POST': form = MeetingCommentForm(request.POST, instance=comment) if form.is_valid(): - form.save() + comment = form.save() messages.success(request, 'Comment updated successfully!') - # HTMX response - return just the comment section + # HTMX response - return just comment section if 'HX-Request' in request.headers: return render(request, 'includes/comment_list.html', { 'comments': meeting.comments.all().order_by('-created_at'), @@ -2292,14 +2303,15 @@ def edit_meeting_comment(request, slug, comment_id): return redirect('meeting_details', slug=slug) else: form = MeetingCommentForm(instance=comment) - print("hi") + context = { 'form': form, 'meeting': meeting, - 'comment':comment + 'comment': comment } return render(request, 'includes/edit_comment_form.html', context) + @login_required def delete_meeting_comment(request, slug, comment_id): """Delete a meeting comment""" @@ -2318,9 +2330,9 @@ def delete_meeting_comment(request, slug, comment_id): # HTMX response - return just the comment section if 'HX-Request' in request.headers: return render(request, 'includes/comment_list.html', { - 'comments': meeting.comments.all().order_by('-created_at'), - 'meeting': meeting - }) + 'comments': meeting.comments.all().order_by('-created_at'), + 'meeting': meeting + }) return redirect('meeting_details', slug=slug) @@ -2367,3 +2379,1217 @@ def set_meeting_candidate(request,slug): "meeting": meeting } return render(request, 'meetings/set_candidate_form.html', context) + + +# Hiring Agency CRUD Views +@login_required +def agency_list(request): + """List all hiring agencies with search and pagination""" + search_query = request.GET.get('q', '') + agencies = HiringAgency.objects.all() + + if search_query: + agencies = agencies.filter( + Q(name__icontains=search_query) | + Q(contact_person__icontains=search_query) | + Q(email__icontains=search_query) | + Q(country__icontains=search_query) + ) + + # Order by most recently created + agencies = agencies.order_by('-created_at') + + # Pagination + paginator = Paginator(agencies, 10) # Show 10 agencies per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + context = { + 'page_obj': page_obj, + 'search_query': search_query, + 'total_agencies': agencies.count(), + } + return render(request, 'recruitment/agency_list.html', context) + + +@login_required +def agency_create(request): + """Create a new hiring agency""" + if request.method == 'POST': + form = HiringAgencyForm(request.POST) + if form.is_valid(): + agency = form.save() + messages.success(request, f'Agency "{agency.name}" created successfully!') + return redirect('agency_detail', slug=agency.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = HiringAgencyForm() + + context = { + 'form': form, + 'title': 'Create New Agency', + 'button_text': 'Create Agency', + } + return render(request, 'recruitment/agency_form.html', context) + + +@login_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') + + # Statistics + total_candidates = candidates.count() + active_candidates = candidates.filter(stage__in=['Applied', 'Screening', 'Exam', 'Interview', 'Offer']).count() + hired_candidates = candidates.filter(stage='Hired').count() + rejected_candidates = candidates.filter(stage='Rejected').count() + + context = { + 'agency': agency, + 'candidates': candidates[:10], # Show recent 10 candidates + 'total_candidates': total_candidates, + 'active_candidates': active_candidates, + 'hired_candidates': hired_candidates, + 'rejected_candidates': rejected_candidates, + } + return render(request, 'recruitment/agency_detail.html', context) + + +@login_required +def agency_update(request, slug): + """Update an existing hiring agency""" + agency = get_object_or_404(HiringAgency, slug=slug) + + if request.method == 'POST': + form = HiringAgencyForm(request.POST, instance=agency) + if form.is_valid(): + agency = form.save() + messages.success(request, f'Agency "{agency.name}" updated successfully!') + return redirect('agency_detail', slug=agency.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = HiringAgencyForm(instance=agency) + + context = { + 'form': form, + 'agency': agency, + 'title': f'Edit Agency: {agency.name}', + 'button_text': 'Update Agency', + } + return render(request, 'recruitment/agency_form.html', context) + + +@login_required +def agency_delete(request, slug): + """Delete a hiring agency""" + agency = get_object_or_404(HiringAgency, slug=slug) + + if request.method == 'POST': + agency_name = agency.name + agency.delete() + messages.success(request, f'Agency "{agency_name}" deleted successfully!') + return redirect('agency_list') + + context = { + 'agency': agency, + 'title': 'Delete Agency', + 'message': f'Are you sure you want to delete the agency "{agency.name}"?', + 'cancel_url': reverse('agency_detail', kwargs={'slug': agency.slug}), + } + return render(request, 'recruitment/agency_confirm_delete.html', context) + + +# Notification Views +@login_required +def notification_list(request): + """List all notifications for the current user""" + # Get filter parameters + status_filter = request.GET.get('status', '') + type_filter = request.GET.get('type', '') + + # Base queryset + notifications = Notification.objects.filter(recipient=request.user).order_by('-created_at') + + # Apply filters + if status_filter: + if status_filter == 'unread': + notifications = notifications.filter(status=Notification.Status.PENDING) + elif status_filter == 'read': + notifications = notifications.filter(status=Notification.Status.READ) + elif status_filter == 'sent': + notifications = notifications.filter(status=Notification.Status.SENT) + + if type_filter: + if type_filter == 'in_app': + notifications = notifications.filter(notification_type=Notification.NotificationType.IN_APP) + elif type_filter == 'email': + notifications = notifications.filter(notification_type=Notification.NotificationType.EMAIL) + + # Pagination + paginator = Paginator(notifications, 20) # Show 20 notifications per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + # Statistics + total_notifications = notifications.count() + unread_notifications = notifications.filter(status=Notification.Status.PENDING).count() + email_notifications = notifications.filter(notification_type=Notification.NotificationType.EMAIL).count() + + context = { + 'page_obj': page_obj, + 'total_notifications': total_notifications, + 'unread_notifications': unread_notifications, + 'email_notifications': email_notifications, + 'status_filter': status_filter, + 'type_filter': type_filter, + } + return render(request, 'recruitment/notification_list.html', context) + + +@login_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) + + # Mark as read if it was pending + if notification.status == Notification.Status.PENDING: + notification.status = Notification.Status.READ + notification.save(update_fields=['status']) + + context = { + 'notification': notification, + } + return render(request, 'recruitment/notification_detail.html', context) + + +@login_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) + + if notification.status == Notification.Status.PENDING: + notification.status = Notification.Status.READ + notification.save(update_fields=['status']) + + if 'HX-Request' in request.headers: + return HttpResponse(status=200) # HTMX success response + + return redirect('notification_list') + + +@login_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) + + if notification.status == Notification.Status.READ: + notification.status = Notification.Status.PENDING + notification.save(update_fields=['status']) + + if 'HX-Request' in request.headers: + return HttpResponse(status=200) # HTMX success response + + return redirect('notification_list') + + +@login_required +def notification_delete(request, notification_id): + """Delete a notification""" + notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) + + if request.method == 'POST': + notification.delete() + messages.success(request, 'Notification deleted successfully!') + return redirect('notification_list') + + # For GET requests, show confirmation page + context = { + 'notification': notification, + 'title': 'Delete Notification', + 'message': f'Are you sure you want to delete this notification?', + 'cancel_url': reverse('notification_detail', kwargs={'notification_id': notification.id}), + } + return render(request, 'recruitment/notification_confirm_delete.html', context) + + +@login_required +def notification_mark_all_read(request): + """Mark all notifications as read for the current user""" + if request.method == 'POST': + Notification.objects.filter( + recipient=request.user, + status=Notification.Status.PENDING + ).update(status=Notification.Status.READ) + + messages.success(request, 'All notifications marked as read!') + return redirect('notification_list') + + # For GET requests, show confirmation page + unread_count = Notification.objects.filter( + recipient=request.user, + status=Notification.Status.PENDING + ).count() + + context = { + 'unread_count': unread_count, + 'title': 'Mark All as Read', + 'message': f'Are you sure you want to mark all {unread_count} notifications as read?', + 'cancel_url': reverse('notification_list'), + } + return render(request, 'recruitment/notification_confirm_all_read.html', context) + + +@login_required +def api_notification_count(request): + """API endpoint to get unread notification count and recent notifications""" + # Get unread notifications + unread_notifications = Notification.objects.filter( + recipient=request.user, + status=Notification.Status.PENDING + ).order_by('-created_at') + + # Get recent notifications (last 5) + recent_notifications = Notification.objects.filter( + recipient=request.user + ).order_by('-created_at')[:5] + + # Prepare recent notifications data + recent_data = [] + for notification in recent_notifications: + time_ago = '' + if notification.created_at: + from datetime import datetime, timezone + now = timezone.now() + diff = now - notification.created_at + + if diff.days > 0: + time_ago = f'{diff.days}d ago' + elif diff.seconds > 3600: + hours = diff.seconds // 3600 + time_ago = f'{hours}h ago' + elif diff.seconds > 60: + minutes = diff.seconds // 60 + time_ago = f'{minutes}m ago' + else: + time_ago = 'Just now' + + recent_data.append({ + 'id': notification.id, + 'message': notification.message[:100] + ('...' if len(notification.message) > 100 else ''), + 'type': notification.get_notification_type_display(), + 'status': notification.get_status_display(), + 'time_ago': time_ago, + 'url': reverse('notification_detail', kwargs={'notification_id': notification.id}) + }) + + return JsonResponse({ + 'count': unread_notifications.count(), + 'recent_notifications': recent_data + }) + + +@login_required +def notification_stream(request): + """SSE endpoint for real-time notifications""" + from django.http import StreamingHttpResponse + import json + import time + from .signals import SSE_NOTIFICATION_CACHE + + def event_stream(): + """Generator function for SSE events""" + user_id = request.user.id + last_notification_id = 0 + + # Get initial last notification ID + last_notification = Notification.objects.filter( + recipient=request.user + ).order_by('-id').first() + if last_notification: + last_notification_id = last_notification.id + + # Send any cached notifications first + cached_notifications = SSE_NOTIFICATION_CACHE.get(user_id, []) + for cached_notification in cached_notifications: + if cached_notification['id'] > last_notification_id: + yield f"event: new_notification\n" + yield f"data: {json.dumps(cached_notification)}\n\n" + last_notification_id = cached_notification['id'] + + while True: + try: + # Check for new notifications from cache first + cached_notifications = SSE_NOTIFICATION_CACHE.get(user_id, []) + new_cached = [n for n in cached_notifications if n['id'] > last_notification_id] + + for notification_data in new_cached: + yield f"event: new_notification\n" + yield f"data: {json.dumps(notification_data)}\n\n" + last_notification_id = notification_data['id'] + + # Also check database for any missed notifications + new_notifications = Notification.objects.filter( + recipient=request.user, + id__gt=last_notification_id + ).order_by('id') + + if new_notifications.exists(): + for notification in new_notifications: + # Prepare notification data + time_ago = '' + if notification.created_at: + now = timezone.now() + diff = now - notification.created_at + + if diff.days > 0: + time_ago = f'{diff.days}d ago' + elif diff.seconds > 3600: + hours = diff.seconds // 3600 + time_ago = f'{hours}h ago' + elif diff.seconds > 60: + minutes = diff.seconds // 60 + time_ago = f'{minutes}m ago' + else: + time_ago = 'Just now' + + notification_data = { + 'id': notification.id, + 'message': notification.message[:100] + ('...' if len(notification.message) > 100 else ''), + 'type': notification.get_notification_type_display(), + 'status': notification.get_status_display(), + 'time_ago': time_ago, + 'url': reverse('notification_detail', kwargs={'notification_id': notification.id}) + } + + # Send SSE event + yield f"event: new_notification\n" + yield f"data: {json.dumps(notification_data)}\n\n" + + last_notification_id = notification.id + + # Update count after sending new notifications + unread_count = Notification.objects.filter( + recipient=request.user, + status=Notification.Status.PENDING + ).count() + + count_data = {'count': unread_count} + yield f"event: count_update\n" + yield f"data: {json.dumps(count_data)}\n\n" + + # Send heartbeat every 30 seconds + yield f"event: heartbeat\n" + yield f"data: {json.dumps({'timestamp': int(time.time())})}\n\n" + + # Wait before next check + time.sleep(5) # Check every 5 seconds + + except Exception as e: + # Send error event and continue + error_data = {'error': str(e)} + yield f"event: error\n" + yield f"data: {json.dumps(error_data)}\n\n" + time.sleep(10) # Wait longer on error + + response = StreamingHttpResponse( + event_stream(), + content_type='text/event-stream' + ) + + # Set SSE headers + response['Cache-Control'] = 'no-cache' + response['X-Accel-Buffering'] = 'no' # Disable buffering for nginx + response['Connection'] = 'keep-alive' + + context = { + 'agency': agency, + 'page_obj': page_obj, + 'stage_filter': stage_filter, + 'total_candidates': candidates.count(), + } + return render(request, 'recruitment/agency_candidates.html', context) + + +@login_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') + + # Filter by stage if provided + stage_filter = request.GET.get('stage') + if stage_filter: + candidates = candidates.filter(stage=stage_filter) + + # Get total candidates before pagination for accurate count + total_candidates = candidates.count() + + # Pagination + paginator = Paginator(candidates, 20) # Show 20 candidates per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + context = { + 'agency': agency, + 'page_obj': page_obj, + 'stage_filter': stage_filter, + 'total_candidates': total_candidates, + } + return render(request, 'recruitment/agency_candidates.html', context) + + + + +# Agency Portal Management Views +@login_required +def agency_assignment_list(request): + """List all agency job assignments""" + search_query = request.GET.get('q', '') + status_filter = request.GET.get('status', '') + + assignments = AgencyJobAssignment.objects.select_related( + 'agency', 'job' + ).order_by('-created_at') + + if search_query: + assignments = assignments.filter( + Q(agency__name__icontains=search_query) | + Q(job__title__icontains=search_query) + ) + + if status_filter: + assignments = assignments.filter(status=status_filter) + + # Pagination + paginator = Paginator(assignments, 15) # Show 15 assignments per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + context = { + 'page_obj': page_obj, + 'search_query': search_query, + 'status_filter': status_filter, + 'total_assignments': assignments.count(), + } + return render(request, 'recruitment/agency_assignment_list.html', context) + + +@login_required +def agency_assignment_create(request,slug=None): + """Create a new agency job assignment""" + agency = HiringAgency.objects.get(slug=slug) if slug else None + + if request.method == 'POST': + form = AgencyJobAssignmentForm(request.POST) + # if agency: + # form.instance.agency = agency + if form.is_valid(): + assignment = form.save() + messages.success(request, f'Assignment created for {assignment.agency.name} - {assignment.job.title}!') + return redirect('agency_assignment_detail', slug=assignment.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = AgencyJobAssignmentForm() + try: + from django.forms import HiddenInput + form.initial['agency'] = agency + form.fields['agency'].widget = HiddenInput() + except HiringAgency.DoesNotExist: + pass + + context = { + 'form': form, + 'title': 'Create New Assignment', + 'button_text': 'Create Assignment', + } + return render(request, 'recruitment/agency_assignment_form.html', context) + + +@login_required +def agency_assignment_detail(request, slug): + """View details of a specific agency assignment""" + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + slug=slug + ) + + # Get candidates submitted by this agency for this job + candidates = Candidate.objects.filter( + hiring_agency=assignment.agency, + job=assignment.job + ).order_by('-created_at') + + # Get access link if exists + access_link = getattr(assignment, 'access_link', None) + + # Get messages for this assignment + + + total_candidates = candidates.count() + max_candidates = assignment.max_candidates + circumference = 326.73 # 2 * π * r where r=52 + + if max_candidates > 0: + progress_percentage = (total_candidates / max_candidates) + stroke_dashoffset = circumference - (circumference * progress_percentage) + else: + stroke_dashoffset = circumference + + context = { + 'assignment': assignment, + 'candidates': candidates, + 'access_link': access_link, + + 'total_candidates': candidates.count(), + 'stroke_dashoffset': stroke_dashoffset, + } + return render(request, 'recruitment/agency_assignment_detail.html', context) + + +@login_required +def agency_assignment_update(request, slug): + """Update an existing agency assignment""" + assignment = get_object_or_404(AgencyJobAssignment, slug=slug) + + if request.method == 'POST': + form = AgencyJobAssignmentForm(request.POST, instance=assignment) + if form.is_valid(): + assignment = form.save() + messages.success(request, f'Assignment updated successfully!') + return redirect('agency_assignment_detail', slug=assignment.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = AgencyJobAssignmentForm(instance=assignment) + + context = { + 'form': form, + 'assignment': assignment, + 'title': f'Edit Assignment: {assignment.agency.name} - {assignment.job.title}', + 'button_text': 'Update Assignment', + } + return render(request, 'recruitment/agency_assignment_form.html', context) + + +@login_required +def agency_access_link_create(request): + """Create access link for agency assignment""" + if request.method == 'POST': + form = AgencyAccessLinkForm(request.POST) + if form.is_valid(): + access_link = form.save() + messages.success(request, f'Access link created for {access_link.assignment.agency.name}!') + return redirect('agency_assignment_detail', slug=access_link.assignment.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = AgencyAccessLinkForm() + + context = { + 'form': form, + 'title': 'Create Access Link', + 'button_text': 'Create Link', + } + return render(request, 'recruitment/agency_access_link_form.html', context) + + +@login_required +def agency_access_link_detail(request, slug): + """View details of an access link""" + access_link = get_object_or_404( + AgencyAccessLink.objects.select_related('assignment__agency', 'assignment__job'), + slug=slug + ) + + context = { + 'access_link': access_link, + } + return render(request, 'recruitment/agency_access_link_detail.html', context) + + + + + + + + + + + + + + + + +@login_required +def agency_assignment_extend_deadline(request, slug): + """Extend deadline for an agency assignment""" + assignment = get_object_or_404(AgencyJobAssignment, slug=slug) + + if request.method == 'POST': + new_deadline = request.POST.get('new_deadline') + if new_deadline: + try: + from datetime import datetime + new_deadline_dt = datetime.fromisoformat(new_deadline.replace('Z', '+00:00')) + # Ensure the new deadline is timezone-aware + if timezone.is_naive(new_deadline_dt): + new_deadline_dt = timezone.make_aware(new_deadline_dt) + + if assignment.extend_deadline(new_deadline_dt): + messages.success(request, f'Deadline extended to {new_deadline_dt.strftime("%Y-%m-%d %H:%M")}!') + else: + messages.error(request, 'New deadline must be later than current deadline.') + except ValueError: + messages.error(request, 'Invalid date format.') + else: + messages.error(request, 'Please provide a new deadline.') + + return redirect('agency_assignment_detail', slug=assignment.slug) + + +# Agency Portal Views (for external agencies) +def agency_portal_login(request): + """Agency login page""" + 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() + + # Store assignment in session + 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') + else: + messages.error(request, 'Invalid token or password.') + else: + form = AgencyLoginForm() + + context = { + 'form': form, + } + return render(request, 'recruitment/agency_portal_login.html', context) + + +def agency_portal_dashboard(request): + """Agency portal dashboard showing all assignments for the agency""" + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return redirect('agency_portal_login') + + # Get the current assignment to determine the agency + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_id + ) + + agency = current_assignment.agency + + # Get ALL assignments for this agency + assignments = AgencyJobAssignment.objects.filter( + agency=agency + ).select_related('job').order_by('-created_at') + + # Calculate statistics for each assignment + assignment_stats = [] + for assignment in assignments: + candidates = Candidate.objects.filter( + hiring_agency=agency, + job=assignment.job + ).order_by('-created_at') + + unread_messages = 0 + + assignment_stats.append({ + 'assignment': assignment, + 'candidates': candidates, + 'candidate_count': candidates.count(), + 'unread_messages': unread_messages, + 'days_remaining': assignment.days_remaining, + 'is_active': assignment.is_currently_active, + 'can_submit': assignment.can_submit, + }) + + # Get overall statistics + total_candidates = sum(stats['candidate_count'] for stats in assignment_stats) + total_unread_messages = sum(stats['unread_messages'] for stats in assignment_stats) + active_assignments = sum(1 for stats in assignment_stats if stats['is_active']) + + context = { + 'agency': agency, + 'current_assignment': current_assignment, + 'assignment_stats': assignment_stats, + 'total_assignments': assignments.count(), + 'active_assignments': active_assignments, + 'total_candidates': total_candidates, + 'total_unread_messages': total_unread_messages, + } + return render(request, 'recruitment/agency_portal_dashboard.html', context) + + +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') + + # 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 + ) + + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + slug=slug + ) + + if assignment.is_full: + 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: + messages.error(request, 'Access denied: This assignment does not belong to your agency.') + return redirect('agency_portal_dashboard') + + # Check if assignment allows submission + if not assignment.can_submit: + messages.error(request, 'Cannot submit candidates: Assignment is not active, expired, or full.') + return redirect('agency_portal_assignment_detail', slug=assignment.slug) + + # Get total submitted candidates for this assignment + total_submitted = Candidate.objects.filter( + hiring_agency=assignment.agency, + job=assignment.job + ).count() + + if request.method == 'POST': + form = AgencyCandidateSubmissionForm(assignment, request.POST, request.FILES) + if form.is_valid(): + candidate = form.save(commit=False) + candidate.hiring_source = 'AGENCY' + candidate.hiring_agency = assignment.agency + candidate.save() + assignment.increment_submission_count() + + # Handle AJAX requests + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'message': f'Candidate {candidate.name} submitted successfully!', + 'candidate_id': candidate.id + }) + else: + messages.success(request, f'Candidate {candidate.name} submitted successfully!') + return redirect('agency_portal_assignment_detail', slug=assignment.slug) + else: + # Handle form validation errors for AJAX + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + error_messages = [] + for field, errors in form.errors.items(): + for error in errors: + error_messages.append(f'{field}: {error}') + return JsonResponse({ + 'success': False, + 'message': 'Please correct the following errors: ' + '; '.join(error_messages) + }) + else: + messages.error(request, 'Please correct errors below.') + else: + form = AgencyCandidateSubmissionForm(assignment) + + context = { + 'form': form, + 'assignment': assignment, + 'total_submitted': total_submitted, + } + return render(request, 'recruitment/agency_portal_submit_candidate.html', context) + + +def agency_portal_submit_candidate(request): + """Handle candidate submission via AJAX (for embedded form)""" + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return redirect('agency_portal_login') + + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + id=assignment_id + ) + if assignment.is_full: + messages.error(request, 'Maximum candidate limit reached for this assignment.') + return redirect('agency_portal_assignment_detail', slug=assignment.slug) + + # Check if assignment allows submission + if not assignment.can_submit: + messages.error(request, 'Cannot submit candidates: Assignment is not active, expired, or full.') + return redirect('agency_portal_dashboard') + + if request.method == 'POST': + form = AgencyCandidateSubmissionForm(assignment, request.POST, request.FILES) + if form.is_valid(): + candidate = form.save(commit=False) + candidate.hiring_source = 'AGENCY' + candidate.hiring_agency = assignment.agency + candidate.save() + + # Increment the assignment's submitted count + assignment.increment_submission_count() + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': True, 'message': f'Candidate {candidate.name} submitted successfully!'}) + else: + messages.success(request, f'Candidate {candidate.name} submitted successfully!') + return redirect('agency_portal_dashboard') + else: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': False, 'message': 'Please correct the errors below.'}) + else: + messages.error(request, 'Please correct errors below.') + else: + form = AgencyCandidateSubmissionForm(assignment) + + context = { + 'form': form, + 'assignment': assignment, + 'title': f'Submit Candidate for {assignment.job.title}', + 'button_text': 'Submit Candidate', + } + return render(request, 'recruitment/agency_portal_submit_candidate.html', context) + + + + +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) + # 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) + + +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 + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + slug=slug + ) + + # Verify this assignment belongs to the same agency as the logged-in session + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_id + ) + + if assignment.agency.id != current_assignment.agency.id: + messages.error(request, 'Access denied: This assignment does not belong to your agency.') + return redirect('agency_portal_dashboard') + + # Get candidates submitted by this agency for this job + candidates = Candidate.objects.filter( + hiring_agency=assignment.agency, + job=assignment.job + ).order_by('-created_at') + + # Get messages for this assignment + messages = [] + + # Mark messages as read + # No messages to mark as read + + # Pagination for candidates + paginator = Paginator(candidates, 20) # Show 20 candidates per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + # Pagination for messages + message_paginator = Paginator(messages, 15) # Show 15 messages per page + message_page_number = request.GET.get('message_page') + message_page_obj = message_paginator.get_page(message_page_number) + + # Calculate progress ring offset for circular progress indicator + total_candidates = candidates.count() + max_candidates = assignment.max_candidates + circumference = 326.73 # 2 * π * r where r=52 + + if max_candidates > 0: + progress_percentage = (total_candidates / max_candidates) + stroke_dashoffset = circumference - (circumference * progress_percentage) + else: + stroke_dashoffset = circumference + + context = { + 'assignment': assignment, + 'page_obj': page_obj, + 'message_page_obj': message_page_obj, + 'total_candidates': total_candidates, + 'stroke_dashoffset': stroke_dashoffset, + } + return render(request, 'recruitment/agency_portal_assignment_detail.html', context) + + +def agency_assignment_detail_admin(request, slug): + """Handle admin assignment detail view""" + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + slug=slug + ) + + # Get candidates submitted by this agency for this job + candidates = Candidate.objects.filter( + hiring_agency=assignment.agency, + job=assignment.job + ).order_by('-created_at') + + # Get access link if exists + access_link = getattr(assignment, 'access_link', None) + + # Get messages for this assignment + messages = [] + + context = { + 'assignment': assignment, + 'candidates': candidates, + 'access_link': access_link, + 'total_candidates': candidates.count(), + } + return render(request, 'recruitment/agency_assignment_detail.html', context) + + +def agency_portal_edit_candidate(request, candidate_id): + """Edit a candidate for agency portal""" + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return redirect('agency_portal_login') + + # Get current assignment to determine agency + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_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) + + if request.method == 'POST': + # Handle form submission + candidate.first_name = request.POST.get('first_name', candidate.first_name) + candidate.last_name = request.POST.get('last_name', candidate.last_name) + candidate.email = request.POST.get('email', candidate.email) + candidate.phone = request.POST.get('phone', candidate.phone) + candidate.address = request.POST.get('address', candidate.address) + + # Handle resume upload if provided + if 'resume' in request.FILES: + candidate.resume = request.FILES['resume'] + + try: + candidate.save() + messages.success(request, f'Candidate {candidate.name} updated successfully!') + return redirect('agency_assignment_detail', slug=candidate.job.agencyjobassignment_set.first().slug) + except Exception as e: + messages.error(request, f'Error updating candidate: {e}') + + # For GET requests or POST errors, return JSON response for AJAX + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'candidate': { + 'id': candidate.id, + 'first_name': candidate.first_name, + 'last_name': candidate.last_name, + 'email': candidate.email, + 'phone': candidate.phone, + 'address': candidate.address, + } + }) + + # Fallback for non-AJAX requests + return redirect('agency_portal_dashboard') + + +def agency_portal_delete_candidate(request, candidate_id): + """Delete a candidate for agency portal""" + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return redirect('agency_portal_login') + + # Get current assignment to determine agency + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_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) + + if request.method == 'POST': + try: + candidate_name = candidate.name + candidate.delete() + + current_assignment.candidates_submitted -= 1 + current_assignment.status = current_assignment.AssignmentStatus.ACTIVE + current_assignment.save(update_fields=['candidates_submitted','status']) + + messages.success(request, f'Candidate {candidate_name} removed successfully!') + return JsonResponse({'success': True}) + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) + + # For GET requests, return error + return JsonResponse({'success': False, 'error': 'Method not allowed'}) + + +def agency_portal_logout(request): + """Logout from agency portal""" + if 'agency_assignment_id' in request.session: + del request.session['agency_assignment_id'] + if 'agency_name' in request.session: + del request.session['agency_name'] + + messages.success(request, 'You have been logged out.') + return redirect('agency_portal_login') + + +@login_required +def agency_access_link_deactivate(request, slug): + """Deactivate an agency access link""" + access_link = get_object_or_404( + AgencyAccessLink.objects.select_related('assignment__agency', 'assignment__job'), + slug=slug + ) + + if request.method == 'POST': + access_link.is_active = False + access_link.save(update_fields=['is_active']) + + messages.success( + request, + f'Access link for {access_link.assignment.agency.name} - {access_link.assignment.job.title} has been deactivated.' + ) + + # Handle HTMX requests + if 'HX-Request' in request.headers: + return HttpResponse(status=200) # HTMX success response + + return redirect('agency_assignment_detail', slug=access_link.assignment.slug) + + # For GET requests, show confirmation page + context = { + 'access_link': access_link, + 'title': 'Deactivate Access Link', + 'message': f'Are you sure you want to deactivate the access link for {access_link.assignment.agency.name}?', + 'cancel_url': reverse('agency_assignment_detail', kwargs={'slug': access_link.assignment.slug}), + } + return render(request, 'recruitment/agency_access_link_confirm.html', context) + + +@login_required +def agency_access_link_reactivate(request, slug): + """Reactivate an agency access link""" + access_link = get_object_or_404( + AgencyAccessLink.objects.select_related('assignment__agency', 'assignment__job'), + slug=slug + ) + + if request.method == 'POST': + access_link.is_active = True + access_link.save(update_fields=['is_active']) + + messages.success( + request, + f'Access link for {access_link.assignment.agency.name} - {access_link.assignment.job.title} has been reactivated.' + ) + + # Handle HTMX requests + if 'HX-Request' in request.headers: + return HttpResponse(status=200) # HTMX success response + + return redirect('agency_assignment_detail', slug=access_link.assignment.slug) + + # For GET requests, show confirmation page + context = { + 'access_link': access_link, + 'title': 'Reactivate Access Link', + 'message': f'Are you sure you want to reactivate the access link for {access_link.assignment.agency.name}?', + 'cancel_url': reverse('agency_assignment_detail', kwargs={'slug': access_link.assignment.slug}), + } + return render(request, 'recruitment/agency_access_link_confirm.html', context) + + + + + + + + + + + + + + + + + +def api_candidate_detail(request, candidate_id): + """API endpoint to get candidate details for agency portal""" + try: + # Get candidate from session-based agency access + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return JsonResponse({'success': False, 'error': 'Access denied'}) + + # Get current assignment to determine agency + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_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) + + # Return candidate data + response_data = { + 'success': True, + 'id': candidate.id, + 'first_name': candidate.first_name, + 'last_name': candidate.last_name, + 'email': candidate.email, + 'phone': candidate.phone, + 'address': candidate.address, + } + + return JsonResponse(response_data) + + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 7c4abbd..ff1a119 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -478,7 +478,7 @@ def candidate_hired_view(request, slug): job = get_object_or_404(models.JobPosting, slug=slug) # Filter candidates with offer_status = 'Accepted' - candidates = job.candidates.filter(offer_status='Accepted') + candidates = job.hired_candidates # Handle search search_query = request.GET.get('search', '') @@ -721,7 +721,7 @@ def sync_hired_candidates(request, job_slug): group=f"sync_job_{job_slug}", timeout=300 # 5 minutes timeout ) - + print("task_id",task_id) # Return immediate response with task ID for tracking return JsonResponse({ 'status': 'queued', @@ -783,18 +783,19 @@ def sync_task_status(request, task_id): try: # Get the task from Django-Q - task = Task.objects.get(id=task_id) + task = Task.objects.get(pk=task_id) + print("task",task) # Determine status based on task state - if task.success(): + if task.success: status = 'completed' message = 'Sync completed successfully' result = task.result - elif task.stopped(): + elif task.stopped: status = 'failed' message = 'Sync task failed or was stopped' result = task.result - elif task.started(): + elif task.started: status = 'running' message = 'Sync is currently running' result = None @@ -802,15 +803,15 @@ def sync_task_status(request, task_id): status = 'pending' message = 'Sync task is queued and waiting to start' result = None - + print("result",result) return JsonResponse({ 'status': status, 'message': message, 'result': result, 'task_id': task_id, - 'started': task.started(), - 'stopped': task.stopped(), - 'success': task.success() + 'started': task.started, + 'stopped': task.stopped, + 'success': task.success }) except Task.DoesNotExist: diff --git a/templates/agency_base.html b/templates/agency_base.html new file mode 100644 index 0000000..e5f2451 --- /dev/null +++ b/templates/agency_base.html @@ -0,0 +1,203 @@ +{% load static i18n %} +{% get_current_language as LANGUAGE_CODE %} + + + + + + + + {% block title %}{% trans 'KAAUH Agency Portal' %}{% endblock %} + + {% comment %} Load correct Bootstrap CSS file for RTL/LTR {% endcomment %} + {% if LANGUAGE_CODE == 'ar' %} + + {% else %} + + {% endif %} + + + + + + {% block customCSS %}{% endblock %} + + + + {% comment %}

+
+
+
+
+
+
+
+ {% trans 'Saudi Vision 2030' %} + +
+
+
جامعة الأميرة نورة بنت عبدالرحمن الأكاديمية
+
ومستشفى الملك عبدالله بن عبدالرحمن التخصصي
+
Princess Nourah bint Abdulrahman University
+
King Abdullah bin Abdulaziz University Hospital
+
+
+ KAAUH Logo +
+
+
+
{% endcomment %} + + + + +
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% block content %} + {% endblock %} +
+ +
+ +
+ + {% include 'includes/delete_modal.html' %} + + + + + + + + {% block customJS %}{% endblock %} + + diff --git a/templates/base.html b/templates/base.html index 2b3ea67..59cc177 100644 --- a/templates/base.html +++ b/templates/base.html @@ -9,7 +9,7 @@ {% block title %}{% trans 'University ATS' %}{% endblock %} - {% comment %} Load the correct Bootstrap CSS file for RTL/LTR {% endcomment %} + {% comment %} Load correct Bootstrap CSS file for RTL/LTR {% endcomment %} {% if LANGUAGE_CODE == 'ar' %} {% else %} @@ -99,6 +99,30 @@