From 3e99bb3dc9b5be37e16011bc09d311cfc6fb0a39 Mon Sep 17 00:00:00 2001 From: Faheed Date: Thu, 23 Oct 2025 01:44:33 +0300 Subject: [PATCH 1/3] candidates pipeline status chart --- .../__pycache__/settings.cpython-312.pyc | Bin 8450 -> 8450 bytes .../__pycache__/urls.cpython-312.pyc | Bin 2339 -> 2656 bytes NorahUniversity/settings.py | 6 +- NorahUniversity/urls.py | 8 +- .../linkedin_service.cpython-312.pyc | Bin 16320 -> 17100 bytes .../__pycache__/signals.cpython-312.pyc | Bin 3307 -> 4352 bytes recruitment/__pycache__/urls.cpython-312.pyc | Bin 13018 -> 12482 bytes recruitment/__pycache__/views.cpython-312.pyc | Bin 92002 -> 92181 bytes .../views_frontend.cpython-312.pyc | Bin 23029 -> 24310 bytes recruitment/linkedin_service.py | 156 +++-- .../__pycache__/__init__.cpython-312.pyc | Bin 158 -> 162 bytes recruitment/migrations/0001_initial.py | 476 +++++++++++++ recruitment/signals.py | 2 +- recruitment/tests.py | 8 +- recruitment/tests_advanced.py | 2 +- recruitment/urls.py | 8 +- recruitment/views.py | 24 +- recruitment/views_frontend.py | 29 + ...candidate.html => application_detail.html} | 4 +- ...rm_wizard.html => application_submit_form} | 2 +- templates/forms/form_templates_list.html | 4 +- templates/includes/comment_form.html | 2 +- templates/includes/comment_list.html | 2 +- templates/includes/delete_comment_form.html | 2 +- templates/includes/edit_comment_form.html | 2 +- templates/jobs/career.html | 2 +- templates/jobs/create_job.html | 184 ++--- templates/jobs/edit_job.html | 184 ++--- templates/jobs/job_detail.html | 630 +++++++----------- templates/jobs/job_list.html | 4 +- templates/meetings/meeting_details.html | 383 ++++++----- templates/recruitment/candidate_detail.html | 240 ++++--- .../recruitment/candidate_exam_view.html | 211 +++++- .../recruitment/candidate_interview_view.html | 219 +++++- .../recruitment/candidate_offer_view.html | 213 +++++- .../candidate_resume_template.html | 11 +- .../recruitment/candidate_screening_view.html | 209 +++++- templates/recruitment/dashboard.html | 244 +++++-- 38 files changed, 2381 insertions(+), 1090 deletions(-) create mode 100644 recruitment/migrations/0001_initial.py rename templates/forms/{job_detail_candidate.html => application_detail.html} (96%) rename templates/forms/{form_wizard.html => application_submit_form} (99%) diff --git a/NorahUniversity/__pycache__/settings.cpython-312.pyc b/NorahUniversity/__pycache__/settings.cpython-312.pyc index de5ab8770bc27e1c49eef9b19b2aa201c3bd7d9c..2d7be7f88fcaa51659060e76fcca7f40fa30f4e0 100644 GIT binary patch delta 19 ZcmZp2YI5Q_&CAQh00c{}Z{+%+001?A1<(Kh delta 19 ZcmZp2YI5Q_&CAQh00c>UH*)<@001*_1#|!a diff --git a/NorahUniversity/__pycache__/urls.cpython-312.pyc b/NorahUniversity/__pycache__/urls.cpython-312.pyc index cac990ac57659f8f60d5974ad796aada713e2303..3a455bcac85223e3e61d1dac31fcc69ef96e0329 100644 GIT binary patch literal 2656 zcma);O>7fK6vxN*`ujU1c0y=kO7r1Byy3%OQUs!*C=e1LB&G79)@FBNud^S~>~5m0 zR0&Q6^+Hahr}jt{4jj4k(rYhX*^=o>QH81=dINE4Po3HIV&fR8t7!JU|NFf+GdnY$ z{1%Od0Di`O|5WY=0Qi$Q&ePX2-u>zU;3?n$=MsSED!N?6b_;INQ}kH2N1#M+(QDb1 z;1m5tzZfV6EZ-{x#ZWP1**+mGMv4*3_6t!lR*YG8K!}TpV!{R704DZZ-4>HDSsil* z(|&V7=&FvleaG5&+m;J_Brk;XCLOx{Zs+0N{ahG&t5fZ$V^vQ%R{QN_T6q!N25`^4 zIG3IC0Usqi7u)X@i(9co&sfrm_4SM$!86RY$LCmF|MP);yoTh#*?umCxf$E+JFn7KO_emEwg1JHuZnTTbU~aYtcf5=H6myq)a3{LB z&oFnn2RGK{%CTYwX7E>xSzmJ8T8)YB)o^@03P6hwK~MFI!?m$`?XdcM(%CCr6|Ns< zafSKAYIlX5Yawh1$)u|Cnqr1nM&fvmQK6#y ztX^oHVm5hN4>Bw(Ym%zadV~=KMpG*}4GD_wC535yfU7c6S(nSHOoBe!GXn^g@oM7Jl)Ndg-H{b?V9EKr2URmM z=Nv6Zp%h+)CS?sjhU=gNX_BNN_c=O3ntsv@kS0Z%G;Yi=PH|4M8C?AG+KsEF)#Y1e z$TpX6-(E38_g1d1+?ccUaLXovpTRp^0{nZPuE-*!*O>~09L=aoNrKz-2E%BT687jN z88MYriQj?<4|9hmqs(pYn1M^8%xMC=jL5A-Zbao5+`jR+T&};sCkG&d|Na1D2Vm*| zOdkOH0L;7&roJD2;Clsp-^CxspZNC%nnN?j&`blIcC_f8)*Q_nqxl9n|26=;!AFBn z@=s@XXP;i#y;7%6{7wZPrFPSMsUL@b7_KMB>eM)qhIh+*Qav?Yr*f~co8FzM9X(y| zJ5#65zV(qR$9A`CqZjJwLY=yZiCXZ~GuX^67`cVo@bMF zinp}vvwZX9oN;olcJrGCRkE~`&sLk$*Ny4xwZ*jt#ai`-_VUf)bH?zw+UIu~)Ln}k z`N`YN6pT!vcI94!TCud;3vY8~-k6!M-DVpUXKDEtTJyqP3*j!GycOBS(htyHk+I+~RiV8ioRBL^1UMNd~J^JUXmUbpm0Va+DSBRyx6 zET3&IHv-(t9%$8^iP/', views.form_wizard_view, name='form_wizard'), - path('form//submit/', views.submit_form, name='submit_form'), - + path('application//', views.application_submit_form, name='application_submit_form'), + path('application//submit/', views.application_submit, name='application_submit'), + path('application//apply/', views.application_detail, name='application_detail'), + path('application//success/', views.application_success, name='application_success'), + path('api/templates/', views.list_form_templates, name='list_form_templates'), path('api/templates/save/', views.save_form_template, name='save_form_template'), path('api/templates//', views.load_form_template, name='load_form_template'), diff --git a/recruitment/__pycache__/linkedin_service.cpython-312.pyc b/recruitment/__pycache__/linkedin_service.cpython-312.pyc index d9496524c8679e39f3479680fcdc79046be34bd8..19d53fabe8d1e18fec443a727cba79f0d4da1bfc 100644 GIT binary patch delta 4430 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 K-=GLprT!N<4i4V{ delta 3585 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 DBE5Dy delta 683 zcmY+C&ubGw6vt;aJDdGAoAgI&lZO0|YP%NdLBxVmMSJj21wAf>Wyua(+iX&2*F#0A z2Tux>GT==-*LV=}4|wsUXOY!{Hw7<|dhqDHCWkn%AKs7ed-LY8Pfq_TJ0B@Ekvu;? z{oTB)p8?ot`vE^B@?b$K)XzX|nMhaqRs|GdyA6D$)VZ@t|8kOnH zd8_x7VJtMBqTZ;$n;hloSl%4VPtA`6_L#sL6HM=7*A@+27I*L?i^#r~{EqjL_=5M< zUF51_LtRr{jhmuQsx~MxWoP?PTHIk?@B0iIw)|@n81IcA1Ty2F62+|AvtNF9I zT#{i9rF;eiP|}cxSHugwN}AFwiFf)9TvgI4M%X%MjlMufp& zviEe3tVqA!|4!dvToTU<&Bk(gFWCvw)+UEHWKOshbfT_*e>n}3?SvEYqfpz^Af?6K zkhPPO%lXMbup18BtOrEs|a5b-M56^Ts0BHKP_ vvsHdwy6~4Ik0k~cDg*-Nob4XbnDAM{pEj|=4snF&hz&eP5 diff --git a/recruitment/__pycache__/urls.cpython-312.pyc b/recruitment/__pycache__/urls.cpython-312.pyc index 082c450d4d319bf03165bc9f6a7a9f2246e91359..00b718aebf7bb551a756dae176107d05effe99b6 100644 GIT binary patch delta 2209 zcmZvdZAe>J7{_yy6ODGhY&B773}#Em?AAew6#8YX6bfa9at~fO(65E9urFs&2HU>u-t%-Ib%Kx2|NQ>X zb6)Pb*C&blQ(2F)vNAQw&yeufoMAnyQ2S@jZO6D)r%O7pr1uyP-pcqvry4YxbwR61 z2=UT{4yUwv>nSsSpf%y!+Wl&}PiGRU)CxSLyMs%S3(uip75s#p>Yv}xuL8o4^+6p< zc5+`Z?G84j=dVA_%kSPZNlyYL3-Drkg-)Lgar^D`JSFeP57S@AC5Bd|SMrX#kaxp8lHc}^YQEj{4Cp57T?@cd`(%+^t3rZ~u~;D{O~f`#57eP~4v`MDbeLWo)$-2`1cRn-|RZrp+c; zw;$U~LWW1_ea5Q<{Iq-si+1n+4t1nc4M8<@sq6!LHGW|KD0f61QIGD-F*S_giHhjH z_mxyoe$QUd#6MTuuQzG*8CMRh1!*KZ+G1WmIo1QBRO~f>LNg zMw86UnFTY4aoWoB67DPA3{qPYu{BBUqKu;KAZIqtys2{PA0+-kY2>tw=2b7X-==?c@_S|X-TiLX|sGD%t9& zEktY~DRN3ir`ai}IRiC?8+Ej(niN&1lpEK<&H`Kn=uYXWx1D&~A6jHI%9a2wa z>NybS!Fh1Aj+Ql(vSz7sOh(7q3eb0e)}@wcy-4bnN46}ZbL?H9mw@tH*-QMrQg~KI zb8Ho8o0dPsmumxBzJF^wU|*_*)@Bwpr!wF9>0N_aj<10H*MNGu6==k<=)gxFDm8ECO^GXl*J%4@JnKh&0C6 zAp#x!f7*Qva13zMU6D4Ak>)XJinpEuIt|pH3ewt9QadV5uBvZkK+9&p5XVg(vmpVZ z6URIzLr}+$rwdMscsyna&4M!+?SlV1DkF%U7ekBpA6H-IK#%8D&Lb+i#jo7Bax>?xq9fjYO!sVhiaL8*6IMl)<4 z=rZqoVC@7odWq3XjSa-uuw!hzXO__bI|ruo(Bc68)R%8qfw2qtN1r9*B8Yc%_#dAI zKl0V#3V*I4fZ(fzTm7cy4iHBXe@y(V-$OjgmrH%4SufBK(AIlK8aPe@$EE4>GFoAM N_%nYedQbVU@-JS$moNYT delta 2804 zcma)-ZA@F&8OOQyF_rpzuw6h@nvLJE(sLG zzeP3Jr1`U2opdv2Yqm~~y)1q+o4K2E4BVhHQQUi~MPD2x19&aRy7LFaS^p_Kh?jGU z@eetyU8S~@)8J{XJ*`QGU)=a{6bqe756jgjj`@pg?8+(U#Q*KQ8b?^0`zD9*ySa9G z%*dk}A1dQMI`h4{79~G?(dsygeR!(nM{=Q}0v+xu(94^2|JW&Cak`*fp8Dy@sqYIm zlthi02HWmu)TsHn^VO5%K>u2&>ADpOt6viH$ zN`5FiAHBr+=#=ww+4;pwoL~HhlUGjFm$EhU5^Lt9b@J-wSN5;>tBQ&^t_(jdK9|oQ z>XJ({kyv~!nn>Y}QX~F-aju<@E-$ANv(dH0((C-{`s{3cb@lMR#5`gwI*}T=orrI& zxRo4_GI;Srb2I*{x$c#f5k-$G!+6O&P8$!wq~;9nSl)9CGq^RhNfL zE#IYeS(;oS*RrRfHaD@kh5m?$rX{^!Rj7JyVC>RC3w4bX*SIjXETUUdF=Hh%yLWEe zM|BpWv+M;#hOD3V~&HxPW-vml*6;Q9(UVHe{@Ggn^FUtXv9BQO}Ne0fO~AEGp%gy zHCErUS3qsO#MUbWrbQH$E-}`|SVN|hHcgWz=y(-DYEbYwV%oNc25UCFd*VaU| zE_E>G#^2hES`WvB7yrptq4k02!TI(I9JD*R0X$`|aQI<*gp;e?E8C7!15XS*HM9~# z>odcp$Auyqkw(EZhCj7eXoDce@l#oZK!ow%?G@=OASRSrLN1@t(}qFPFen6PMHG{+ z0=>aH8SiUoiH(%l1pcy!x}*reX-GZ4Tm7l?7tYLEBI=Q%faU;|2}UP1b`xVaHTDx@ z|1+as7-RM4@z*tVX^UXHL~TRFHYAM4L==~9LV60)s|0fkH4hTAd{;L`l#&(!uK_N- z-$KhAq|A|#51vO_2Y4HxGozvPZc^`lViM7Wv;pvr0`HYq*dOo+alVK(m=aZ?2)l^=qHWxlIBIUAa$!?H-Y|lt_P?WC_5`* z(i9f1uZn0*@&V;~@E>a}+Fnot_|-a#)(;|pyXuVTVGtuwrSq|dcFmKndHJ+2abxVn z1y#~0n8uG=wa}&zX_6PQC8E1h2xu5+b0$tN2Fb;sFv)J@3ed@y)ASVJ8-N=#KHBUf z&Aul?BAS#U0H*=g?uDqsLmZwb4rK$P0A~QY4nnkJl5|W8QFc?aKw~HBPLSpaA(B?! z;BJ)^XNL6iIdHTe#A#=gbVlWsC%8Vg@_v;x4`q7PY=f$I*QwP@tX{#-e$RT)I85465Mx+yShPV9 z~)2!WWubxxD^Du_uu=Co+1K+NE`ox1C@AY#z8F(Yr)7-23rs0y^4-H>h=hSOFyZl#1cvRTNIE9(<^bv7t(*Vw3Wh2Q!DhxKIf24yi6 zktf`OH#qojaOi9fXjspK>Klkro4I>^_c0bsz8Iu7z22K~7vuctRz8fr%q=ynlkFGF zOuitCS$PZtO+u zKz*9*)4Tl`Rk@2c06E1V5ryen{TP+FjseZP!vPc%o&G$S(RjLuKjS9jC;W;l%&+hp zcd*=H;p|Xp@qQp6azj|^hLGe(b{2lN2KO&q3_L;;)GtWe>`1@N?RJ61ZToe9#%N~7 zqtlIp8QX!>?qEh)t`k7Ri$KKx?T>;P3)#3%g9Jc?()QRWMoV^Xko8~{MW?4POayBA w3KReZX7SYRClVRAFfw{=uT5rL!7QY}=+5|=nSn{*0~3h(!G&?UNjjq*0Jv4#lK=n! delta 510 zcmbPwf%VZjR^HRRyj%=GaPQ2I46%*8LHeR1S@}uvDXArinK|*viFqlRDTyVin~U_% zFbcVzNV^akaXmElVrc9I-?%I4@tXq-Zz)L2r{x#r7VBfvIN3QUk=4D@OIg`)s zmzex^3g_lCLA*LbVh~LrGQK>sDzPYK^7p!xf&xea@nxB*<&$^RC-UlTaNh2*(c=og z?FA0o$!ZPCVkja{xCL);@ZaE2*c{)mo`;9KxHKs@vm_p5)MmwA-+j}QH5t_>Pk5v{ z{apZ~;q=$uj46x@rU&>i`Z8D4uuk5%SZ4ACLEgz(#$wa&`!KF&s^pp8=gTO;Rmr2t zTP5b2l36lcE|^h#I-?(B3A3N3@brJdjFQuj`7ydNX|hch_GeU`e8fO-y1zf8823>o z28KHv3=GAe8KxKbGd>agAkM%e{Y8pFKxu{f6@KGRmdSpPq_-~$V02(+JTm=pFk?HA z$_`cc$WQ zrlGzVHa_f8k^t9BfTE{QKD#-OX4kj+N#Ek8ktCf>|^ z^WAgK{qDKv%>5&aKllKz{H&s)9D}2EW^ewT(ap+V`VBOkYT2h$+4b6d%j$oYY4Loh)^bwq^zNW*l z83L{`r;3eUhLUF1BS)U$vcD9hU0UXuk0E!nI&4H zBI1$;j5UP5qYouVyss>)mEjZsd_p{wIt_g)8;Zw+;ZTB$av_lyg5hK; zA*$4oB`AoY2(QvdhQpAug{w&Dngj4*kcx^6P`RB83G=hb5a8P8#f7-L2B7~9_z}pD z`K~O345DWdcp#__Aq;pv5luu+=3wEU4=tQDJ4z*bF3K;R^jVX0b38a<2j8Q;baPP~ z5JZ)T3CV=2<5CNY0zhqXSIKKWyW=aA)z=7S5fl+92)>c?zOLnOAytiHQiRPB!b!k$ zU>kW=1m7V*^#ora_yK}y2rVwguO1Y%eE%@}#XWX{lEHA0Hky=j1^1AO7lKd)p$-lm zf;zcDlBTVSBABk2X?reeW%>-1oFB4GC_1+b>IyO^zqB}Eov;5`$JLh-wR>er) z7`Z;O9$XnI7(FXmg|!vg&OF<>Vo(@sk!j5{tt;hEzh>Wi0Zmx zwI>gsQ?A`Itip6-^!n&aD7~*3)Ghrvf8kufkvpfD5HM)n;_kK1${-zFp zD6^Aq;g94GC!5av3C*fDkz4{8>Yg1q1 x_|MtJr#Ouf^%Nj=K;!bgX&2s*eKh?p&hj|^1b$zOY3N-|iSLeURs^g@Iu@!{qbM#+&UNCNc}v zFx4>Eu$QQU)Pq2l+T?|T!ju2Ga&e=JsksS?rAW@jI zdh<~?CPpSr&dKr~vnLC9$});h*7M93xy6!IoS%1#HKjDSptuO+2u*>>`d$)JXMxP3 zb08uKL|gz77eU1N$%$UZ+LwULTRbKCC5bul#mV_asVPNQL9&-Y#1#27P$YtBv!^#R6VnHd$&P-8<{zc`_}LoVzsNE02u)DGAZ@-w{4%%W1s2Dj zpQ;%dge-sF5_K!)0ct*IFYKYja!5khL!Bj=X|jNp$mU0WZA`4sfO7LT=LRlkWqdYS zG<+4~i^&JV)m2{sg>NzE<(C(|2C|A)gNU~v;vIig++GIx&7`W?&NdzyxN02x6Li HJWd|~O^As% diff --git a/recruitment/linkedin_service.py b/recruitment/linkedin_service.py index b6702b9..e275f2d 100644 --- a/recruitment/linkedin_service.py +++ b/recruitment/linkedin_service.py @@ -5,16 +5,15 @@ from html import unescape from urllib.parse import quote, urlencode import requests import logging -from django.conf import settings import time -import random -from django.utils import timezone +from django.conf import settings logger = logging.getLogger(__name__) -# Define a constant for the API version for better maintenance +# Define constants LINKEDIN_API_VERSION = '2.0.0' -LINKEDIN_VERSION = '202409' # Modern API version for header control +LINKEDIN_VERSION = '202409' +MAX_POST_CHARS = 3000 # LinkedIn's maximum character limit for shareCommentary class LinkedInService: def __init__(self): @@ -23,11 +22,11 @@ class LinkedInService: self.redirect_uri = settings.LINKEDIN_REDIRECT_URI self.access_token = None # Configuration for image processing wait time - self.ASSET_STATUS_TIMEOUT = 15 # Max time (seconds) to wait for image processing - self.ASSET_STATUS_INTERVAL = 2 # Check every 2 seconds - - # --- AUTHENTICATION & PROFILE --- + self.ASSET_STATUS_TIMEOUT = 15 + self.ASSET_STATUS_INTERVAL = 2 + # ---------------- AUTHENTICATION & PROFILE ---------------- + def get_auth_url(self): """Generate LinkedIn OAuth URL""" params = { @@ -76,7 +75,7 @@ class LinkedInService: logger.error(f"Error getting user profile: {e}") raise - # --- ASSET UPLOAD & STATUS --- + # ---------------- ASSET UPLOAD & STATUS ---------------- def get_asset_status(self, asset_urn): """Checks the status of a registered asset (image) to ensure it's READY.""" @@ -86,7 +85,7 @@ class LinkedInService: 'X-Restli-Protocol-Version': LINKEDIN_API_VERSION, 'LinkedIn-Version': LINKEDIN_VERSION, } - + try: response = requests.get(url, headers=headers, timeout=10) response.raise_for_status() @@ -129,6 +128,7 @@ class LinkedInService: """Step 2: Upload image file and poll for 'READY' status.""" image_file.open() image_content = image_file.read() + image_file.seek(0) # Reset pointer after reading image_file.close() headers = { @@ -137,8 +137,8 @@ class LinkedInService: response = requests.post(upload_url, headers=headers, data=image_content, timeout=60) response.raise_for_status() - - # --- CRITICAL FIX: POLL FOR ASSET STATUS --- + + # --- POLL FOR ASSET STATUS --- start_time = time.time() while time.time() - start_time < self.ASSET_STATUS_TIMEOUT: try: @@ -149,56 +149,55 @@ class LinkedInService: return True if status == "FAILED": raise Exception(f"LinkedIn image processing failed for asset {asset_urn}") - + logger.info(f"Asset {asset_urn} status: {status}. Waiting...") time.sleep(self.ASSET_STATUS_INTERVAL) - + except Exception as e: logger.warning(f"Error during asset status check for {asset_urn}: {e}. Retrying.") - time.sleep(self.ASSET_STATUS_INTERVAL * 2) + time.sleep(self.ASSET_STATUS_INTERVAL * 2) - # If the loop times out, return True to attempt post, but log warning logger.warning(f"Asset {asset_urn} timed out, but upload succeeded. Forcing post attempt.") return True - # --- POSTING LOGIC --- + # ---------------- POSTING UTILITIES ---------------- def clean_html_for_social_post(self, html_content): - """Converts safe HTML to plain text with basic formatting (bullets, bold, newlines).""" + """Converts safe HTML to plain text with basic formatting.""" if not html_content: return "" text = html_content - + # 1. Convert Bolding tags to *Markdown* text = re.sub(r'(.*?)', r'*\1*', text, flags=re.IGNORECASE) text = re.sub(r'(.*?)', r'*\1*', text, flags=re.IGNORECASE) # 2. Handle Lists: Convert
  • tags into a bullet point - text = re.sub(r'', '\n', text, flags=re.IGNORECASE) - text = re.sub(r']*>', 'β€’ ', text, flags=re.IGNORECASE) - text = re.sub(r'
  • ', '\n', text, flags=re.IGNORECASE) - + text = re.sub(r'', '\n', text, flags=re.IGNORECASE) + text = re.sub(r']*>', 'β€’ ', text, flags=re.IGNORECASE) + text = re.sub(r'', '\n', text, flags=re.IGNORECASE) + # 3. Handle Paragraphs and Line Breaks text = re.sub(r'

    ', '\n\n', text, flags=re.IGNORECASE) text = re.sub(r'
    ', '\n', text, flags=re.IGNORECASE) - + # 4. Strip all remaining, unsupported HTML tags clean_text = re.sub(r'<[^>]+>', '', text) - + # 5. Unescape HTML entities clean_text = unescape(clean_text) - + # 6. Clean up excessive whitespace/newlines clean_text = re.sub(r'(\n\s*){3,}', '\n\n', clean_text).strip() - + return clean_text def hashtags_list(self, hash_tags_str): """Convert comma-separated hashtags string to list""" if not hash_tags_str: return ["#HigherEd", "#Hiring", "#UniversityJobs"] - + tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()] tags = [tag if tag.startswith('#') else f'#{tag}' for tag in tags] @@ -208,15 +207,18 @@ class LinkedInService: return tags def _build_post_message(self, job_posting): - """Centralized logic to construct the professionally formatted text message.""" + """ + Constructs the final text message. + Includes a unique suffix for duplicate content prevention (422 fix). + """ message_parts = [ f"πŸ”₯ *Job Alert!* We’re looking for a talented professional to join our team.", f"πŸ‘‰ **{job_posting.title}** πŸ‘ˆ", ] - + if job_posting.department: - message_parts.append(f"*{job_posting.department}*") - + message_parts.append(f"*{job_posting.department}*") + message_parts.append("\n" + "=" * 25 + "\n") # KEY DETAILS SECTION @@ -229,7 +231,7 @@ class LinkedInService: details_list.append(f"🏠 Workplace: {job_posting.get_workplace_type_display()}") if job_posting.salary_range: details_list.append(f"πŸ’° Salary: {job_posting.salary_range}") - + if details_list: message_parts.append("*Key Information*:") message_parts.extend(details_list) @@ -239,11 +241,13 @@ class LinkedInService: clean_description = self.clean_html_for_social_post(job_posting.description) if clean_description: message_parts.append(f"πŸ”Ž *About the Role:*\n{clean_description}") - + # CALL TO ACTION if job_posting.application_url: message_parts.append(f"\n\n---") - message_parts.append(f"πŸ”— **APPLY NOW:** {job_posting.application_url}") + # CRITICAL: Include the URL explicitly in the text body. + # When media_category is NONE, LinkedIn often makes these URLs clickable. + message_parts.append(f"πŸ”— **APPLY NOW:** {job_posting.application_url}") # HASHTAGS hashtags = self.hashtags_list(job_posting.hash_tags) @@ -252,19 +256,38 @@ class LinkedInService: hashtags.insert(0, dept_hashtag) message_parts.append("\n" + " ".join(hashtags)) - - if len(message_parts)>=3000: - message_parts=message_parts[0:2980]+"........" - - return "\n".join(message_parts) + + final_message = "\n".join(message_parts) + + # --- FIX: ADD UNIQUE SUFFIX AND HANDLE LENGTH (422 fix) --- + unique_suffix = f"\n\n| Ref: {int(time.time())}" + + available_length = MAX_POST_CHARS - len(unique_suffix) + + if len(final_message) > available_length: + logger.warning("Post message truncated due to character limit.") + final_message = final_message[:available_length - 3] + "..." + + return final_message + unique_suffix + + + # ---------------- MAIN POSTING METHODS ---------------- def _send_ugc_post(self, person_urn, job_posting, media_category="NONE", media_list=None): """ - New private method to handle the final UGC post request (text or image). - This eliminates the duplication between create_job_post and create_job_post_with_image. + Private method to handle the final UGC post request. + CRITICAL FIX: Avoids ARTICLE category if not using an image to prevent 402 errors. """ - + message = self._build_post_message(job_posting) + + # --- FIX FOR 402: Force NONE if no image is present. --- + if media_category != "IMAGE": + # We explicitly force pure text share to avoid LinkedIn's link crawler + # which triggers the commercial 402 error on job reposts. + media_category = "NONE" + media_list = None + # -------------------------------------------------------- url = "https://api.linkedin.com/v2/ugcPosts" headers = { @@ -280,8 +303,8 @@ class LinkedInService: "shareMediaCategory": media_category, } } - - if media_list: + + if media_list and media_category == "IMAGE": specific_content["com.linkedin.ugc.ShareContent"]["media"] = media_list payload = { @@ -294,8 +317,13 @@ class LinkedInService: } response = requests.post(url, headers=headers, json=payload, timeout=60) + + # Log 402/422 details + if response.status_code in [402, 422]: + logger.error(f"{response.status_code} UGC Post Error Detail: {response.text}") + response.raise_for_status() - + post_id = response.headers.get('x-restli-id', '') post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else "" @@ -308,18 +336,21 @@ class LinkedInService: def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn): - """Step 3: Creates the final LinkedIn post payload with the image asset.""" + """Creates the final LinkedIn post payload with the image asset.""" + + if not job_posting.application_url: + raise ValueError("Application URL is required for image link share on LinkedIn.") - # Prepare the media list for the _send_ugc_post helper + # Media list for IMAGE category (retains link details) + # Note: This is an exception where we MUST provide link details for the image card media_list = [{ "status": "READY", "media": asset_urn, "description": {"text": job_posting.title}, - "originalUrl": job_posting.application_url, + "originalUrl": job_posting.application_url, "title": {"text": "Apply Now"} }] - # Use the helper method to send the post return self._send_ugc_post( person_urn=person_urn, job_posting=job_posting, @@ -344,47 +375,44 @@ class LinkedInService: # Check for image and attempt post try: - # Assuming correct model path: job_posting.related_model_name.first().image_field_name image_upload = job_posting.post_images.first().post_image has_image = image_upload is not None except Exception: - pass # No image available + pass if has_image: try: - # Step 1: Register + # Steps 1, 2, 3 for image post upload_info = self.register_image_upload(person_urn) asset_urn = upload_info['asset'] - - # Step 2: Upload and WAIT FOR READY (Crucial for 422 fix) self.upload_image_to_linkedin( upload_info['upload_url'], - image_upload, + image_upload, asset_urn ) - - # Step 3: Create post with image + return self.create_job_post_with_image( job_posting, image_upload, person_urn, asset_urn ) except Exception as e: logger.error(f"Image post failed, falling back to text: {e}") - # Force fallback to text-only if image posting fails - has_image = False + has_image = False # === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) === - # Use the single helper method here + # The _send_ugc_post method now ensures this is a PURE text post + # to avoid the 402/ARTICLE-related issues. return self._send_ugc_post( person_urn=person_urn, job_posting=job_posting, - media_category="NONE" + media_category="NONE" ) except Exception as e: logger.error(f"Error creating LinkedIn post: {e}") + status_code = getattr(getattr(e, 'response', None), 'status_code', 500) return { 'success': False, 'error': str(e), - 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500 + 'status_code': status_code } \ No newline at end of file diff --git a/recruitment/management/__pycache__/__init__.cpython-312.pyc b/recruitment/management/__pycache__/__init__.cpython-312.pyc index 7308a1c7a539847ca3f9b3957f6be20a077c41b8..22977f057ccf998b11c04914a47e202e8c068f9e 100644 GIT binary patch delta 44 ycmbQoxQLPaG%qg~0}#C3{bM4xv8bbdMt*Lpep+HiYHEspVo7m)UTXP7e}4cjD-N{) delta 40 ucmZ3)IFFJ0G%qg~0}y<*c{GvRSkP8KBR@A)KP@pMH8n-wF(i0mus;Cg{tO!c diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py new file mode 100644 index 0000000..fcbfe6e --- /dev/null +++ b/recruitment/migrations/0001_initial.py @@ -0,0 +1,476 @@ +# Generated by Django 5.2.7 on 2025-10-22 16:33 + +import django.core.validators +import django.db.models.deletion +import django_ckeditor_5.fields +import django_countries.fields +import django_extensions.db.fields +import recruitment.validators +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BreakTime', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_time', models.TimeField(verbose_name='Start Time')), + ('end_time', models.TimeField(verbose_name='End Time')), + ], + ), + migrations.CreateModel( + name='FormStage', + 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')), + ('name', models.CharField(help_text='Name of the stage', max_length=200)), + ('order', models.PositiveIntegerField(default=0, help_text='Order of the stage in the form')), + ('is_predefined', models.BooleanField(default=False, help_text='Whether this is a default resume stage')), + ], + options={ + 'verbose_name': 'Form Stage', + 'verbose_name_plural': 'Form Stages', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='HiringAgency', + 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')), + ('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')), + ('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')), + ('email', models.EmailField(blank=True, max_length=254)), + ('phone', models.CharField(blank=True, max_length=20)), + ('website', models.URLField(blank=True)), + ('notes', models.TextField(blank=True, help_text='Internal notes about the agency')), + ('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)), + ('address', models.TextField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'Hiring Agency', + 'verbose_name_plural': 'Hiring Agencies', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Source', + 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')), + ('name', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, unique=True, verbose_name='Source Name')), + ('source_type', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, verbose_name='Source Type')), + ('description', models.TextField(blank=True, help_text='A description of the source', verbose_name='Description')), + ('ip_address', models.GenericIPAddressField(blank=True, help_text='The IP address of the source', null=True, verbose_name='IP Address')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('api_key', models.CharField(blank=True, help_text='API key for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Key')), + ('api_secret', models.CharField(blank=True, help_text='API secret for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Secret')), + ('trusted_ips', models.TextField(blank=True, help_text='Comma-separated list of trusted IP addresses', null=True, verbose_name='Trusted IP Addresses')), + ('is_active', models.BooleanField(default=True, help_text='Whether this source is active for integration', verbose_name='Active')), + ('integration_version', models.CharField(blank=True, help_text='Version of the integration protocol', max_length=50, verbose_name='Integration Version')), + ('last_sync_at', models.DateTimeField(blank=True, help_text='Timestamp of the last successful synchronization', null=True, verbose_name='Last Sync At')), + ('sync_status', models.CharField(blank=True, choices=[('IDLE', 'Idle'), ('SYNCING', 'Syncing'), ('ERROR', 'Error'), ('DISABLED', 'Disabled')], default='IDLE', max_length=20, verbose_name='Sync Status')), + ], + options={ + 'verbose_name': 'Source', + 'verbose_name_plural': 'Sources', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ZoomMeeting', + 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')), + ('topic', models.CharField(max_length=255, verbose_name='Topic')), + ('meeting_id', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID')), + ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), + ('duration', models.PositiveIntegerField(verbose_name='Duration')), + ('timezone', models.CharField(max_length=50, verbose_name='Timezone')), + ('join_url', models.URLField(verbose_name='Join URL')), + ('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')), + ('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')), + ('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')), + ('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')), + ('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')), + ('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')), + ('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='FormField', + 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')), + ('label', models.CharField(help_text='Label for the field', max_length=200)), + ('field_type', models.CharField(choices=[('text', 'Text Input'), ('email', 'Email'), ('phone', 'Phone'), ('textarea', 'Text Area'), ('file', 'File Upload'), ('date', 'Date Picker'), ('select', 'Dropdown'), ('radio', 'Radio Buttons'), ('checkbox', 'Checkboxes')], help_text='Type of the field', max_length=20)), + ('placeholder', models.CharField(blank=True, help_text='Placeholder text', max_length=200)), + ('required', models.BooleanField(default=False, help_text='Whether the field is required')), + ('order', models.PositiveIntegerField(default=0, help_text='Order of the field in the stage')), + ('is_predefined', models.BooleanField(default=False, help_text='Whether this is a default field')), + ('options', models.JSONField(blank=True, default=list, help_text='Options for selection fields (stored as JSON array)')), + ('file_types', models.CharField(blank=True, help_text="Allowed file types (comma-separated, e.g., '.pdf,.doc,.docx')", max_length=200)), + ('max_file_size', models.PositiveIntegerField(default=5, help_text='Maximum file size in MB (default: 5MB)')), + ('multiple_files', models.BooleanField(default=False, help_text='Allow multiple files to be uploaded')), + ('max_files', models.PositiveIntegerField(default=1, help_text='Maximum number of files allowed (when multiple_files is True)')), + ('stage', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='recruitment.formstage')), + ], + options={ + 'verbose_name': 'Form Field', + 'verbose_name_plural': 'Form Fields', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='FormTemplate', + 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')), + ('name', models.CharField(help_text='Name of the form template', max_length=200)), + ('description', models.TextField(blank=True, help_text='Description of the form template')), + ('is_active', models.BooleanField(default=False, help_text='Whether this template is active')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Form Template', + 'verbose_name_plural': 'Form Templates', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='FormSubmission', + 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')), + ('submitted_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('applicant_name', models.CharField(blank=True, help_text='Name of the applicant', max_length=200)), + ('applicant_email', models.EmailField(blank=True, db_index=True, help_text='Email of the applicant', max_length=254)), + ('submitted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='form_submissions', to=settings.AUTH_USER_MODEL)), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='recruitment.formtemplate')), + ], + options={ + 'verbose_name': 'Form Submission', + 'verbose_name_plural': 'Form Submissions', + 'ordering': ['-submitted_at'], + }, + ), + migrations.AddField( + model_name='formstage', + name='template', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='recruitment.formtemplate'), + ), + migrations.CreateModel( + name='Candidate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('first_name', models.CharField(max_length=255, verbose_name='First Name')), + ('last_name', models.CharField(max_length=255, verbose_name='Last Name')), + ('email', models.EmailField(db_index=True, max_length=254, verbose_name='Email')), + ('phone', models.CharField(max_length=20, verbose_name='Phone')), + ('address', models.TextField(max_length=200, verbose_name='Address')), + ('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')), + ('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')), + ('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')), + ('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')), + ('applied', models.BooleanField(default=False, verbose_name='Applied')), + ('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')), + ('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')), + ('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')), + ('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')), + ('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')), + ('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status')), + ('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')), + ('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')), + ('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')), + ('ai_analysis_data', models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data')), + ('submitted_by_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_candidates', to='recruitment.hiringagency', verbose_name='Submitted by Agency')), + ], + options={ + 'verbose_name': 'Candidate', + 'verbose_name_plural': 'Candidates', + }, + ), + migrations.CreateModel( + name='JobPosting', + fields=[ + ('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')), + ('title', models.CharField(max_length=200)), + ('department', models.CharField(blank=True, max_length=100)), + ('job_type', models.CharField(choices=[('FULL_TIME', 'Full-time'), ('PART_TIME', 'Part-time'), ('CONTRACT', 'Contract'), ('INTERNSHIP', 'Internship'), ('FACULTY', 'Faculty'), ('TEMPORARY', 'Temporary')], default='FULL_TIME', max_length=20)), + ('workplace_type', models.CharField(choices=[('ON_SITE', 'On-site'), ('REMOTE', 'Remote'), ('HYBRID', 'Hybrid')], default='ON_SITE', max_length=20)), + ('location_city', models.CharField(blank=True, max_length=100)), + ('location_state', models.CharField(blank=True, max_length=100)), + ('location_country', models.CharField(default='Saudia Arabia', max_length=100)), + ('description', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Description')), + ('qualifications', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)), + ('salary_range', models.CharField(blank=True, help_text='e.g., $60,000 - $80,000', max_length=200)), + ('benefits', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)), + ('application_url', models.URLField(blank=True, help_text='URL where candidates apply', null=True, validators=[django.core.validators.URLValidator()])), + ('application_deadline', models.DateField(db_index=True)), + ('application_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)), + ('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)), + ('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20)), + ('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])), + ('linkedin_post_id', models.CharField(blank=True, help_text='LinkedIn post ID after posting', max_length=200)), + ('linkedin_post_url', models.URLField(blank=True, help_text='Direct URL to LinkedIn post')), + ('posted_to_linkedin', models.BooleanField(default=False)), + ('linkedin_post_status', models.CharField(blank=True, help_text='Status of LinkedIn posting', max_length=50)), + ('linkedin_posted_at', models.DateTimeField(blank=True, null=True)), + ('published_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)), + ('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)), + ('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')), + ('max_applications', models.PositiveIntegerField(blank=True, default=1000, help_text='Maximum number of applications allowed', null=True)), + ('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')), + ('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')), + ('cancelled_at', models.DateTimeField(blank=True, null=True)), + ('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')), + ('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')), + ], + options={ + 'verbose_name': 'Job Posting', + 'verbose_name_plural': 'Job Postings', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='InterviewSchedule', + 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')), + ('start_date', models.DateField(db_index=True, verbose_name='Start Date')), + ('end_date', models.DateField(db_index=True, verbose_name='End Date')), + ('working_days', models.JSONField(verbose_name='Working Days')), + ('start_time', models.TimeField(verbose_name='Start Time')), + ('end_time', models.TimeField(verbose_name='End Time')), + ('break_start_time', models.TimeField(blank=True, null=True, verbose_name='Break Start Time')), + ('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')), + ('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')), + ('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')), + ('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')), + ], + ), + migrations.AddField( + model_name='formtemplate', + name='job', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'), + ), + migrations.AddField( + model_name='candidate', + name='job', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'), + ), + migrations.CreateModel( + name='JobPostingImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('post_image', models.ImageField(upload_to='post/', validators=[recruitment.validators.validate_image_size])), + ('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')), + ], + ), + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size])), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='SharedFormTemplate', + 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')), + ('is_public', models.BooleanField(default=False, help_text='Whether this template is publicly available')), + ('shared_with', models.ManyToManyField(blank=True, related_name='shared_templates', to=settings.AUTH_USER_MODEL)), + ('template', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='recruitment.formtemplate')), + ], + options={ + 'verbose_name': 'Shared Form Template', + 'verbose_name_plural': 'Shared Form Templates', + }, + ), + migrations.CreateModel( + name='IntegrationLog', + 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')), + ('action', models.CharField(choices=[('REQUEST', 'Request'), ('RESPONSE', 'Response'), ('ERROR', 'Error'), ('SYNC', 'Sync'), ('CREATE_JOB', 'Create Job'), ('UPDATE_JOB', 'Update Job')], max_length=20, verbose_name='Action')), + ('endpoint', models.CharField(blank=True, max_length=255, verbose_name='Endpoint')), + ('method', models.CharField(blank=True, max_length=10, verbose_name='HTTP Method')), + ('request_data', models.JSONField(blank=True, null=True, verbose_name='Request Data')), + ('response_data', models.JSONField(blank=True, null=True, verbose_name='Response Data')), + ('status_code', models.CharField(blank=True, max_length=10, verbose_name='Status Code')), + ('error_message', models.TextField(blank=True, verbose_name='Error Message')), + ('ip_address', models.GenericIPAddressField(verbose_name='IP Address')), + ('user_agent', models.CharField(blank=True, max_length=255, verbose_name='User Agent')), + ('processing_time', models.FloatField(blank=True, null=True, verbose_name='Processing Time (seconds)')), + ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integration_logs', to='recruitment.source', verbose_name='Source')), + ], + options={ + 'verbose_name': 'Integration Log', + 'verbose_name_plural': 'Integration Logs', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='TrainingMaterial', + 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')), + ('title', models.CharField(max_length=255, verbose_name='Title')), + ('content', django_ckeditor_5.fields.CKEditor5Field(blank=True, verbose_name='Content')), + ('video_link', models.URLField(blank=True, verbose_name='Video Link')), + ('file', models.FileField(blank=True, upload_to='training_materials/', verbose_name='File')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Created by')), + ], + options={ + 'verbose_name': 'Training Material', + 'verbose_name_plural': 'Training Materials', + }, + ), + migrations.CreateModel( + name='ScheduledInterview', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')), + ('interview_time', models.TimeField(verbose_name='Interview Time')), + ('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')), + ('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')), + ('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')), + ], + ), + migrations.CreateModel( + name='MeetingComment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meeting_comments', to=settings.AUTH_USER_MODEL, verbose_name='Author')), + ('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recruitment.zoommeeting', verbose_name='Meeting')), + ], + options={ + 'verbose_name': 'Meeting Comment', + 'verbose_name_plural': 'Meeting Comments', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='FieldResponse', + 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')), + ('value', models.JSONField(blank=True, help_text='Response value (stored as JSON)', null=True)), + ('uploaded_file', models.FileField(blank=True, null=True, upload_to='form_uploads/')), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formfield')), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formsubmission')), + ], + options={ + 'verbose_name': 'Field Response', + 'verbose_name_plural': 'Field Responses', + 'indexes': [models.Index(fields=['submission'], name='recruitment_submiss_474130_idx'), models.Index(fields=['field'], name='recruitment_field_i_097e5b_idx')], + }, + ), + migrations.AddIndex( + model_name='formsubmission', + index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'), + ), + migrations.AddIndex( + model_name='interviewschedule', + index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'), + ), + migrations.AddIndex( + model_name='interviewschedule', + index=models.Index(fields=['end_date'], name='recruitment_end_dat_aeb00e_idx'), + ), + migrations.AddIndex( + model_name='interviewschedule', + index=models.Index(fields=['created_by'], name='recruitment_created_d0bdcc_idx'), + ), + migrations.AddIndex( + model_name='formtemplate', + index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'), + ), + migrations.AddIndex( + model_name='formtemplate', + index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'), + ), + migrations.AddIndex( + model_name='candidate', + index=models.Index(fields=['stage'], name='recruitment_stage_f1c6eb_idx'), + ), + migrations.AddIndex( + model_name='candidate', + index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'), + ), + migrations.AddIndex( + model_name='jobposting', + index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'), + ), + migrations.AddIndex( + model_name='jobposting', + index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'), + ), + migrations.AddIndex( + model_name='scheduledinterview', + index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'), + ), + migrations.AddIndex( + model_name='scheduledinterview', + index=models.Index(fields=['interview_date', 'interview_time'], name='recruitment_intervi_7f5877_idx'), + ), + migrations.AddIndex( + model_name='scheduledinterview', + index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'), + ), + ] diff --git a/recruitment/signals.py b/recruitment/signals.py index ebba334..174cf35 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -26,7 +26,7 @@ def format_job(sender, instance, created, **kwargs): schedule_type=Schedule.ONCE ).first() - if instance.is_active and instance.application_deadline: + if instance.STATUS_CHOICES=='ACTIVE' and instance.application_deadline: if not existing_schedule: # Create a new schedule if one does not exist schedule( diff --git a/recruitment/tests.py b/recruitment/tests.py index ecf0a7b..20feb89 100644 --- a/recruitment/tests.py +++ b/recruitment/tests.py @@ -248,7 +248,7 @@ class ViewTests(BaseTestCase): } response = self.client.post( - reverse('submit_form', kwargs={'template_id': template.id}), + reverse('application_submit', kwargs={'template_id': template.id}), data ) # After successful submission, should redirect to success page @@ -434,7 +434,7 @@ class IntegrationTests(BaseTestCase): } response = self.client.post( - reverse('submit_form', kwargs={'template_id': template.id}), + reverse('application_submit', kwargs={'template_id': template.id}), form_data ) @@ -493,7 +493,7 @@ class AuthenticationTests(BaseTestCase): ) response = self.client.post( - reverse('submit_form', kwargs={'template_id': template.id}), + reverse('application_submit', kwargs={'template_id': template.id}), {} ) # Should redirect to login page @@ -525,7 +525,7 @@ class EdgeCaseTests(BaseTestCase): # Submit form twice response1 = self.client.post( - reverse('submit_form', kwargs={'template_id': template.id}), + reverse('application_submit', kwargs={'template_id': template.id}), {'field_1': 'John', 'field_2': 'Doe'} ) diff --git a/recruitment/tests_advanced.py b/recruitment/tests_advanced.py index 70f1dac..9e3e4d1 100644 --- a/recruitment/tests_advanced.py +++ b/recruitment/tests_advanced.py @@ -744,7 +744,7 @@ class AdvancedIntegrationTests(TransactionTestCase): } response = self.client.post( - reverse('submit_form', kwargs={'template_id': template.id}), + reverse('application_submit', kwargs={'template_id': template.id}), submission_data ) self.assertEqual(response.status_code, 302) # Redirect to success page diff --git a/recruitment/urls.py b/recruitment/urls.py index 604ad08..d3e418f 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -14,8 +14,7 @@ urlpatterns = [ path('jobs//update/', views.edit_job, name='job_update'), # path('jobs//delete/', views., name='job_delete'), path('jobs//', views.job_detail, name='job_detail'), - path('jobs//candidate/', views.job_detail_candidate, name='job_detail_candidate'), - path('jobs//candidate/application/success', views.application_success, name='application_success'), + path('careers/',views.kaauh_career,name='kaauh_career'), # LinkedIn Integration URLs @@ -83,8 +82,8 @@ urlpatterns = [ path('htmx//candidate_update_status/', views.candidate_update_status, name='candidate_update_status'), - path('forms/form//submit/', views.submit_form, name='submit_form'), - path('forms/form//', views.form_wizard_view, name='form_wizard'), + # path('forms/form//submit/', views.submit_form, name='submit_form'), + # path('forms/form//', views.form_wizard_view, name='form_wizard'), path('forms//submissions//', views.form_submission_details, name='form_submission_details'), path('forms/template//submissions/', views.form_template_submissions_list, name='form_template_submissions_list'), path('forms/template//all-submissions/', views.form_template_all_submissions, name='form_template_all_submissions'), @@ -140,6 +139,7 @@ urlpatterns = [ # Meeting Comments URLs path('meetings//comments/add/', views.add_meeting_comment, name='add_meeting_comment'), path('meetings//comments//edit/', views.edit_meeting_comment, name='edit_meeting_comment'), + path('meetings//comments//delete/', views.delete_meeting_comment, name='delete_meeting_comment'), path('meetings//set_meeting_candidate/', views.set_meeting_candidate, name='set_meeting_candidate'), diff --git a/recruitment/views.py b/recruitment/views.py index 7689124..9d535fa 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -280,7 +280,7 @@ def create_job(request): try: job = form.save(commit=False) job.save() - job_apply_url_relative=reverse('job_detail_candidate',kwargs={'slug':job.slug}) + job_apply_url_relative=reverse('application_detail',kwargs={'slug':job.slug}) job_apply_url_absolute=request.build_absolute_uri(job_apply_url_relative) job.application_url=job_apply_url_absolute # FormTemplate.objects.create(job=job, is_active=False, name=job.title,created_by=request.user) @@ -512,9 +512,9 @@ def kaauh_career(request): # job detail facing the candidate: -def job_detail_candidate(request, slug): +def application_detail(request, slug): job = get_object_or_404(JobPosting, slug=slug) - return render(request, "forms/job_detail_candidate.html", {"job": job}) + return render(request, "forms/application_detail.html", {"job": job}) from django_q.tasks import async_task @@ -800,7 +800,7 @@ def delete_form_template(request, template_id): ) -def form_wizard_view(request, template_slug): +def application_submit_form(request, template_slug): """Display the form as a step-by-step wizard""" template = get_object_or_404(FormTemplate, slug=template_slug, is_active=True) job_id = template.job.internal_job_id @@ -811,24 +811,24 @@ def form_wizard_view(request, template_slug): request, 'Application limit reached: This job is no longer accepting new applications. Please explore other available positions.' ) - return redirect('job_detail_candidate',slug=job.slug) + return redirect('application_detail',slug=job.slug) if job.is_expired: messages.error( request, 'Application deadline passed: This job is no longer accepting new applications. Please explore other available positions.' ) - return redirect('job_detail_candidate',slug=job.slug) + return redirect('application_detail',slug=job.slug) return render( request, - "forms/form_wizard.html", + "forms/application_submit_form.html", {"template_slug": template_slug, "job_id": job_id}, ) @csrf_exempt @require_POST -def submit_form(request, template_slug): +def application_submit(request, template_slug): """Handle form submission""" template = get_object_or_404(FormTemplate, slug=template_slug) job = template.job @@ -2292,7 +2292,13 @@ 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 + } + return render(request, 'includes/edit_comment_form.html', context) @login_required def delete_meeting_comment(request, slug, comment_id): diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 50c05e3..d204d97 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -394,6 +394,27 @@ def dashboard_view(request): ).count() high_potential_ratio = round((high_potential_count / total_candidates) * 100, 1) if total_candidates > 0 else 0 + jobs=models.JobPosting.objects.all().order_by('internal_job_id') + selected_job_id=request.GET.get('selected_job_id','') + candidate_stage=['APPLIED','EXAM','INTERVIEW','OFFER'] + apply_count,exam_count,interview_count,offer_count=[0]*4 + + if selected_job_id: + job=jobs.get(internal_job_id=selected_job_id) + apply_count=job.screening_candidates_count + exam_count=job.exam_candidates_count + interview_count=job.interview_candidates_count + offer_count=job.offer_candidates_count + all_candidates_count=job.all_candidates_count + + else: #default job + job=jobs.first() + apply_count=job.screening_candidates_count + exam_count=job.exam_candidates_count + interview_count=job.interview_candidates_count + offer_count=job.offer_candidates_count + all_candidates_count=job.all_candidates_count + candidates_count=[ apply_count,exam_count,interview_count,offer_count ] context = { 'total_jobs': total_jobs, @@ -409,6 +430,14 @@ def dashboard_view(request): 'high_potential_count': high_potential_count, 'high_potential_ratio': high_potential_ratio, 'scored_ratio': scored_ratio, + 'current_job_id':selected_job_id, + 'jobs':jobs, + 'all_candidates_count':all_candidates_count, + 'candidate_stage':json.dumps(candidate_stage), + 'candidates_count':json.dumps(candidates_count) + ,'my_job':job + + } return render(request, 'recruitment/dashboard.html', context) @login_required diff --git a/templates/forms/job_detail_candidate.html b/templates/forms/application_detail.html similarity index 96% rename from templates/forms/job_detail_candidate.html rename to templates/forms/application_detail.html index 0ecccf1..9b98017 100644 --- a/templates/forms/job_detail_candidate.html +++ b/templates/forms/application_detail.html @@ -42,7 +42,7 @@

    {% trans "Review the job details, then apply below." %}

    {% if job.form_template %} - + {% trans "Apply for this Position" %} {% endif %} @@ -102,7 +102,7 @@
    {% if job.form_template %} - + {% trans "Apply for this Position" %} {% endif %} diff --git a/templates/forms/form_wizard.html b/templates/forms/application_submit_form similarity index 99% rename from templates/forms/form_wizard.html rename to templates/forms/application_submit_form index 1447255..fdc9972 100644 --- a/templates/forms/form_wizard.html +++ b/templates/forms/application_submit_form @@ -824,7 +824,7 @@ }); try { - const response = await fetch(`/form/${state.templateId}/submit/`, { + const response = await fetch(`/application/${state.templateId}/submit/`, { method: 'POST', body: formData // IMPORTANT: Do NOT set Content-Type header when using FormData diff --git a/templates/forms/form_templates_list.html b/templates/forms/form_templates_list.html index 7cecea0..8f9440e 100644 --- a/templates/forms/form_templates_list.html +++ b/templates/forms/form_templates_list.html @@ -231,7 +231,7 @@ {% endif %}
    - + {% if 'HX-Request' in request.headers %} {% endif %} diff --git a/templates/jobs/career.html b/templates/jobs/career.html index 62768fc..c122663 100644 --- a/templates/jobs/career.html +++ b/templates/jobs/career.html @@ -248,7 +248,7 @@ {% trans 'Apply' %} diff --git a/templates/jobs/create_job.html b/templates/jobs/create_job.html index 51cf403..20fbe2f 100644 --- a/templates/jobs/create_job.html +++ b/templates/jobs/create_job.html @@ -117,28 +117,21 @@
    -
    +
    {{ form.title }} {% if form.title.errors %}
    {{ form.title.errors }}
    {% endif %}
    -
    +
    {{ form.job_type }} {% if form.job_type.errors %}
    {{ form.job_type.errors }}
    {% endif %}
    - -
    -
    - - {{ form.department }} - {% if form.department.errors %}
    {{ form.department.errors }}
    {% endif %} -
    -
    +
    @@ -146,33 +139,18 @@ {% if form.workplace_type.errors %}
    {{ form.workplace_type.errors }}
    {% endif %}
    -
    -
    -
    - - - {# ================================================= #} - {# SECTION 2: INTERNAL AND PROMOTION #} - {# ================================================= #} - -
    -
    -
    {% trans "Internal & Promotion" %}
    -
    -
    -
    - - {{ form.position_number }} - {% if form.position_number.errors %}
    {{ form.position_number.errors }}
    {% endif %} + + {{ form.application_deadline }} + {% if form.application_deadline.errors %}
    {{ form.application_deadline.errors }}
    {% endif %}
    - - {{ form.reporting_to }} - {% if form.reporting_to.errors %}
    {{ form.reporting_to.errors }}
    {% endif %} + + {{ form.department }} + {% if form.department.errors %}
    {{ form.department.errors }}
    {% endif %}
    @@ -189,72 +167,14 @@ {% if form.max_applications.errors %}
    {{ form.max_applications.errors }}
    {% endif %}
    - - -
    -
    - - {{ form.hash_tags }} - {% if form.hash_tags.errors %}
    {{ form.hash_tags.errors }}
    {% endif %} -
    {% trans "Comma-separated list of hashtags, e.g., #hiring, #professor" %}
    -
    -
    - {# ================================================= #} - {# SECTION 3: LOCATION AND DATES #} - {# ================================================= #} + -
    -
    -
    {% trans "Location, Dates, & Salary" %}
    -
    -
    -
    -
    -
    - - {{ form.location_city }} - {% if form.location_city.errors %}
    {{ form.location_city.errors }}
    {% endif %} -
    -
    -
    -
    - - {{ form.location_state }} - {% if form.location_state.errors %}
    {{ form.location_state.errors }}
    {% endif %} -
    -
    -
    -
    - - {{ form.location_country }} - {% if form.location_country.errors %}
    {{ form.location_country.errors }}
    {% endif %} -
    -
    - -
    -
    - - {{ form.application_deadline }} - {% if form.application_deadline.errors %}
    {{ form.application_deadline.errors }}
    {% endif %} -
    -
    - -
    -
    - - {{ form.salary_range }} - {% if form.salary_range.errors %}
    {{ form.salary_range.errors }}
    {% endif %} -
    -
    - -
    -
    -
    + {# ================================================= #} {# SECTION 4: JOB CONTENT (CKEDITOR 5 Fields) #} {# ================================================= #} @@ -313,8 +233,90 @@ + {# ================================================= #} + {# SECTION 2: INTERNAL AND PROMOTION #} + {# ================================================= #} + +
    +
    +
    {% trans "Internal & Promotion" %}
    +
    +
    +
    +
    +
    + + {{ form.position_number }} + {% if form.position_number.errors %}
    {{ form.position_number.errors }}
    {% endif %} +
    +
    +
    +
    + + {{ form.reporting_to }} + {% if form.reporting_to.errors %}
    {{ form.reporting_to.errors }}
    {% endif %} +
    +
    + +
    +
    + + {{ form.hash_tags }} + {% if form.hash_tags.errors %}
    {{ form.hash_tags.errors }}
    {% endif %} +
    {% trans "Comma-separated list of hashtags, e.g., #hiring, #professor" %}
    +
    +
    +
    +
    +
    + + {# ================================================= #} + {# SECTION 3: LOCATION AND Salary #} + {# ================================================= #} + +
    +
    +
    {% trans "Location & Salary" %}
    +
    +
    +
    +
    +
    + + {{ form.location_city }} + {% if form.location_city.errors %}
    {{ form.location_city.errors }}
    {% endif %} +
    +
    +
    +
    + + {{ form.location_state }} + {% if form.location_state.errors %}
    {{ form.location_state.errors }}
    {% endif %} +
    +
    +
    +
    + + {{ form.location_country }} + {% if form.location_country.errors %}
    {{ form.location_country.errors }}
    {% endif %} +
    +
    + + + +
    +
    + + {{ form.salary_range }} + {% if form.salary_range.errors %}
    {{ form.salary_range.errors }}
    {% endif %} +
    +
    + +
    +
    +
    {# ================================================= #} {# ACTION BUTTONS #} diff --git a/templates/jobs/edit_job.html b/templates/jobs/edit_job.html index 51cf403..20fbe2f 100644 --- a/templates/jobs/edit_job.html +++ b/templates/jobs/edit_job.html @@ -117,28 +117,21 @@
    -
    +
    {{ form.title }} {% if form.title.errors %}
    {{ form.title.errors }}
    {% endif %}
    -
    +
    {{ form.job_type }} {% if form.job_type.errors %}
    {{ form.job_type.errors }}
    {% endif %}
    - -
    -
    - - {{ form.department }} - {% if form.department.errors %}
    {{ form.department.errors }}
    {% endif %} -
    -
    +
    @@ -146,33 +139,18 @@ {% if form.workplace_type.errors %}
    {{ form.workplace_type.errors }}
    {% endif %}
    -
    -
    -
    - - - {# ================================================= #} - {# SECTION 2: INTERNAL AND PROMOTION #} - {# ================================================= #} - -
    -
    -
    {% trans "Internal & Promotion" %}
    -
    -
    -
    - - {{ form.position_number }} - {% if form.position_number.errors %}
    {{ form.position_number.errors }}
    {% endif %} + + {{ form.application_deadline }} + {% if form.application_deadline.errors %}
    {{ form.application_deadline.errors }}
    {% endif %}
    - - {{ form.reporting_to }} - {% if form.reporting_to.errors %}
    {{ form.reporting_to.errors }}
    {% endif %} + + {{ form.department }} + {% if form.department.errors %}
    {{ form.department.errors }}
    {% endif %}
    @@ -189,72 +167,14 @@ {% if form.max_applications.errors %}
    {{ form.max_applications.errors }}
    {% endif %}
    - - -
    -
    - - {{ form.hash_tags }} - {% if form.hash_tags.errors %}
    {{ form.hash_tags.errors }}
    {% endif %} -
    {% trans "Comma-separated list of hashtags, e.g., #hiring, #professor" %}
    -
    -
    - {# ================================================= #} - {# SECTION 3: LOCATION AND DATES #} - {# ================================================= #} + -
    -
    -
    {% trans "Location, Dates, & Salary" %}
    -
    -
    -
    -
    -
    - - {{ form.location_city }} - {% if form.location_city.errors %}
    {{ form.location_city.errors }}
    {% endif %} -
    -
    -
    -
    - - {{ form.location_state }} - {% if form.location_state.errors %}
    {{ form.location_state.errors }}
    {% endif %} -
    -
    -
    -
    - - {{ form.location_country }} - {% if form.location_country.errors %}
    {{ form.location_country.errors }}
    {% endif %} -
    -
    - -
    -
    - - {{ form.application_deadline }} - {% if form.application_deadline.errors %}
    {{ form.application_deadline.errors }}
    {% endif %} -
    -
    - -
    -
    - - {{ form.salary_range }} - {% if form.salary_range.errors %}
    {{ form.salary_range.errors }}
    {% endif %} -
    -
    - -
    -
    -
    + {# ================================================= #} {# SECTION 4: JOB CONTENT (CKEDITOR 5 Fields) #} {# ================================================= #} @@ -313,8 +233,90 @@ + {# ================================================= #} + {# SECTION 2: INTERNAL AND PROMOTION #} + {# ================================================= #} + +
    +
    +
    {% trans "Internal & Promotion" %}
    +
    +
    +
    +
    +
    + + {{ form.position_number }} + {% if form.position_number.errors %}
    {{ form.position_number.errors }}
    {% endif %} +
    +
    +
    +
    + + {{ form.reporting_to }} + {% if form.reporting_to.errors %}
    {{ form.reporting_to.errors }}
    {% endif %} +
    +
    + +
    +
    + + {{ form.hash_tags }} + {% if form.hash_tags.errors %}
    {{ form.hash_tags.errors }}
    {% endif %} +
    {% trans "Comma-separated list of hashtags, e.g., #hiring, #professor" %}
    +
    +
    +
    +
    +
    + + {# ================================================= #} + {# SECTION 3: LOCATION AND Salary #} + {# ================================================= #} + +
    +
    +
    {% trans "Location & Salary" %}
    +
    +
    +
    +
    +
    + + {{ form.location_city }} + {% if form.location_city.errors %}
    {{ form.location_city.errors }}
    {% endif %} +
    +
    +
    +
    + + {{ form.location_state }} + {% if form.location_state.errors %}
    {{ form.location_state.errors }}
    {% endif %} +
    +
    +
    +
    + + {{ form.location_country }} + {% if form.location_country.errors %}
    {{ form.location_country.errors }}
    {% endif %} +
    +
    + + + +
    +
    + + {{ form.salary_range }} + {% if form.salary_range.errors %}
    {{ form.salary_range.errors }}
    {% endif %} +
    +
    + +
    +
    +
    {# ================================================= #} {# ACTION BUTTONS #} diff --git a/templates/jobs/job_detail.html b/templates/jobs/job_detail.html index 4b4ff5d..673fc50 100644 --- a/templates/jobs/job_detail.html +++ b/templates/jobs/job_detail.html @@ -4,7 +4,6 @@ {% block title %}{{ job.title }} - University ATS{% endblock %} {% block customCSS %} - {% endblock %} @@ -238,12 +152,12 @@
    - {# LEFT COLUMN: JOB DETAILS WITH TABS #} + {# LEFT COLUMN: JOB DETAILS (NO TABS) #}
    @@ -252,276 +166,136 @@

    {{ job.title }}

    {% trans "JOB ID: "%}{{ job.internal_job_id }} + + {# Deadline #} + {% if job.application_deadline %} +
    + + {% trans "Deadline:" %} {{ job.application_deadline }} +
    + {% endif %}
    -
    - - {# Corrected status badge logic to close the span correctly #} - {% if job.status == "ACTIVE" %} - - {% elif job.status == "DRAFT" %} - - {% elif job.status == "CLOSED" %} - - {% elif job.status == "CANCELLED" %} - - {% elif job.status == "ARCHIVED" %} - - {% else %} - - {% endif %} - {{ job.get_status_display }} +
    + + {# Status badge #} +
    + + {{ job.get_status_display }} + +
    + + {# Share Public Link Button #} + + +
    - {# LEFT TABS NAVIGATION #} - - + {# CONTENT: CORE DETAILS (No Tabs) #}
    -
    - - {# TAB 1 CONTENT: CORE DETAILS #} -
    -
    {% trans "Administrative & Location" %}
    -
    -
    - {% trans "Department:" %} {{ job.department|default:"N/A" }} -
    -
    - {% trans "Position No:" %} {{ job.position_number|default:"N/A" }} -
    -
    - {% trans "Job Type:" %} {{ job.get_job_type_display }} -
    -
    - {% trans "Workplace:" %} {{ job.get_workplace_type_display }} -
    -
    - {% trans "Location:" %} {{ job.get_location_display }} -
    -
    - {% trans "Created By:" %} {{ job.created_by|default:"N/A" }} -
    -
    - {% trans "Created At:" %} {{ job.created_at|default:"N/A" }} -
    -
    - {% trans "Updated At:" %} {{ job.updated_at|default:"N/A" }} -
    - -
    - - - -
    - -
    -
    {% trans "Financial & Timeline" %}
    -
    - {% if job.salary_range %} -
    -
    - - {% trans "Salary:" %} {{ job.salary_range }} -
    -
    - {% endif %} - {% if job.start_date %} -
    -
    - - {% trans "Start Date:" %} {{ job.start_date }} -
    -
    - {% endif %} - {% if job.application_deadline %} -
    -
    - - {% trans "Deadline:" %} {{ job.application_deadline }} - {% if job.is_expired %} - {% trans "EXPIRED" %} - {% endif %} -
    -
    - {% endif %} -
    + +
    {% trans "Administrative & Location" %} +
  • {% trans "Edit JOb" %}
    +
    +
    +
    + {% trans "Department:" %} {{ job.department|default:"N/A" }}
    - - {# TAB 2 CONTENT: DESCRIPTION & REQUIREMENTS #} -
    - {% if job.description %} -
    -
    {% trans "Job Description" %}
    -
    {{ job.description|safe }}
    -
    - {% endif %} - {% if job.qualifications %} -
    -
    {% trans "Required Qualifications" %}
    -
    {{ job.qualifications|safe }}
    -
    - {% endif %} - {% if job.benefits %} -
    -
    {% trans "Benefits" %}
    -
    {{ job.benefits|safe}}
    -
    - {% endif %} - {% if job.application_instructions %} -
    -
    {% trans "Application Instructions" %}
    -
    {{ job.application_instructions|safe }}
    -
    - {% endif %} - +
    + {% trans "Position No:" %} {{ job.position_number|default:"N/A" }}
    - - {# TAB 3 CONTENT: APPLICATION KPIS #} -
    -
    - - {# 1. Job Avg. Score #} -
    -
    -
    - -
    {{ avg_match_score|floatformat:1 }}
    - {% trans "Avg. AI Score" %} -
    -
    -
    - - {# 2. High Potential Count #} -
    -
    -
    - -
    {{ high_potential_count }}
    - {% trans "High Potential" %} -
    -
    -
    - - {# 3. Avg. Time to Interview #} -
    -
    -
    - -
    {{ avg_t2i_days|floatformat:1 }}d
    - {% trans "Time to Interview" %} -
    -
    -
    - - {# 4. Avg. Exam Review Time #} -
    -
    -
    - -
    {{ avg_t_in_exam_days|floatformat:1 }}d
    - {% trans "Avg. Exam Review" %} -
    -
    -
    -
    - -

    - {% trans "KPIs based on completed applicant data." %} -

    - +
    + {% trans "Job Type:" %} {{ job.get_job_type_display }} +
    +
    + {% trans "Workplace:" %} {{ job.get_workplace_type_display }} +
    +
    + {% trans "Location:" %} {{ job.get_location_display }} +
    +
    + {% trans "Salary:" %} {{ job.salary_range |default:"N/A" }} +
    +
    + {% trans "Created By:" %} {{ job.created_by|default:"N/A" }} +
    +
    + {% trans "Created At:" %} {{ job.created_at|default:"N/A" }} +
    +
    + {% trans "Updated At:" %} {{ job.updated_at|default:"N/A" }}
    -
    - - - + + {# Description Blocks (Main Content) #} + {% if job.description %} +
    +
    {% trans "Job Description" %}
    +
    {{ job.description|safe }}
    +
    + {% endif %} + {% if job.qualifications %} +
    +
    {% trans "Required Qualifications" %}
    +
    {{ job.qualifications|safe }}
    +
    + {% endif %} + {% if job.benefits %} +
    +
    {% trans "Benefits" %}
    +
    {{ job.benefits|safe}}
    +
    + {% endif %} + {% if job.application_instructions %} +
    +
    {% trans "Application Instructions" %}
    +
    {{ job.application_instructions|safe }}
    +
    + {% endif %}
    - {# FOOTER ACTIONS #} -
    - {# RIGHT COLUMN: TABBED CARDS #} + {# RIGHT COLUMN: TABBED CARDS #}
    - {# New Card for Candidate Category Chart #}
    -
    -
    - - {% trans "Candidate Categories & Scores" %} -
    -
    -
    -
    - -
    -
    -
    - - {# REMOVED: Standalone Applicant Tracking Card (It is now in a tab) #} - -
    - {# RIGHT TABS NAVIGATION #} -