From b9904b3ec8316d280a9f6938356fdc495cabba63 Mon Sep 17 00:00:00 2001 From: Faheed Date: Tue, 14 Oct 2025 13:53:34 +0300 Subject: [PATCH] few bug fixes --- recruitment/__pycache__/forms.cpython-312.pyc | Bin 22386 -> 22386 bytes .../linkedin_service.cpython-312.pyc | Bin 10862 -> 16122 bytes .../__pycache__/models.cpython-312.pyc | Bin 46937 -> 46990 bytes recruitment/__pycache__/views.cpython-312.pyc | Bin 51127 -> 52134 bytes recruitment/linkedin_service.py | 318 ++++++++++++------ ...0011_alter_jobpostingimage_job_and_more.py | 25 ++ recruitment/models.py | 6 +- recruitment/views.py | 55 ++- templates/jobs/job_detail.html | 15 +- templates/jobs/job_list.html | 253 +++++++++----- .../recruitment/candidate_screening_view.html | 13 +- templates/recruitment/training_create.html | 2 +- 12 files changed, 479 insertions(+), 208 deletions(-) create mode 100644 recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py diff --git a/recruitment/__pycache__/forms.cpython-312.pyc b/recruitment/__pycache__/forms.cpython-312.pyc index e8051b15dafff5263a62731b42436e2214176b5c..4c340290a228f0fb5abe347ffae9b0bf85f610ec 100644 GIT binary patch delta 22 ccmeygj`7nvM()$Ryj%=G5UcWTBX@c@09*bCCIA2c delta 22 ccmeygj`7nvM()$Ryj%=Gu#o-jM(*@*09_{sOaK4? diff --git a/recruitment/__pycache__/linkedin_service.cpython-312.pyc b/recruitment/__pycache__/linkedin_service.cpython-312.pyc index 56e8775f0831abcba7eaa5d4aff4b4851170266f..2eddf4499474f513a67999c46dee9547344127de 100644 GIT binary patch literal 16122 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 diff --git a/recruitment/__pycache__/models.cpython-312.pyc b/recruitment/__pycache__/models.cpython-312.pyc index a0ac22e0c8c9c1e7caabc2f72d1ef90a2e1aab16..fa7751ddb3c9c11d7c6ac218325ee65f3068a635 100644 GIT binary patch delta 9703 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 aYoxn+4!aM!zlqUFc!Rt9ne%bP=l=jKJJPTK delta 10008 zcmahv32#`tTaF*y!?a+3c#xb~>gECi2MRQQBASDY)+qxI@~bu`v` z^xmZ2BpT~H$zDTm3XSz1qc^oTmBvY)G_R@GMB`*nx;LXYgT@9=rZ=lMi^eIQY;R6) zju5AbYmajo_s6+XC4rx}W$|H6k};34X@E6Fu(QmBO$Tg-WPUOprHza0%_nRoV6!5$ z69t6L25e3gZ6RTE0hxwlE4?Lf9g}7Dr)4!j=G5RIw8znuX97 zKwG1Tt%RKg*wQHMEW(xnwmb@3O4tg(UKfQeBdiUuv!k%(gslW@m6RLNPX(c?0bLW( zPxW<#odej~C>A!t)&X{I6m~X0qbbW4l#Ja@kK5%8NRC0LJm?5G2jtLKnimB@$A6Tt zT5EzmGt&& ziHM{5R(z4LMhZAt4Gat6SJna8ANN6K?wxv-s1^`*n}J2~<5u0gGIV7nLH3LcFeHg# z%SsVf%7eP?s;s4hks#e zHj*g#Y!g7Zru1P0$)%$C%jt8qc3iicf0@p*b8wi8Ae!i=43CNE=?xka$p4wqXqe*7 z%K4Ja#>^N2!e(#auVgOnh%yr}Q1pK0l9<~!z>rGG+%2&_zbrX?POsz`_KYy6C)RRg z%^}{FWl=3x%e`4Y6|B5Ed$VBSj~p@chqHI4>;`tS06=6`o}IHsu!Q<^&ItnJro35I z0eJDrzu<=@;JbV-4Jqw_F(IC-d@!{ro3+jaqa|c94IU z_a|U_$^5L4HHvc*hn*}S%NiJqxagUJXF;AzW;17nujTGTk^~GLHchnkMcaVte9>Q< z7XSqq=P>?BQnYfw85ogC97dBHiX)&uf(g4)4)^9&6?pj?zETl zwdG4E&LuvH9s@tU`f;sIr=+d)_jQ4o;Gdmd=YYhJhhjj<9T0m1lFYIJ6HG+R957xc zJlSnY(P{<^!$QSgMNkYtiQnn(V+A-$j<90l1|*nDeC$npMDQ*A%9sEl@9E8VXy4bH z-_o1!t9mBqh2rzYzuNkae#- zv>KRGmMa3!$bw2@H{^4Pz0aSS)1E|fTmk~#bZ-vNsdX)nWdZLLZC6uwcWaBHZENaW z-P#fps!DR}n9~bf(UJeie_UIsvi&&1wvjKZvt5<7zNxvnwW|l1>({rgY;A^7EPHL3 z^(Xv^x-jd{qghwaEnW96AnZ16tVQOc^hr%!U8_5ro9sPG62O+umZqN8*hP#@s@wYl zJ>$a?`x}3BZsi1N=S$!L$@us1hbag?oHnv0un!P1{XN8q)90cZDl75eUy%HBkSrGg zCc#hs1t6H*=4Nt0v_l?*5HR$(Atet!3 zS*+-d^sX&vTrbHZUPaVBb)R22tu){9)AUc|i3k{3HALKhfH}#L;?$d$px^8KDGQI(8{5`=8&sN zFU*?a&&rUAMwJg|M(Xa775BgtG|7_Yhpe~Z+P5Qk8-S9y1H)>%=D%yk#^LY*GxxO& z>EQr_QC-6?w`9#QWG&aWc9NME77?Mq;E*%jlDO6wf2_4C%=FXN#u}p4C|Pnpl@46UWsE-Zdjz}Q?Ip+4D@2nA`%1BhXl|%`%6SPHxe0xVi$!kEV=$tMWlVn**YIA!ewZdoO?3ekzj&?1j5i@_Y zBS)y?A9pMjF7WcsTPA*iQ#0j}KCe5Vb{GyQRKEUk3PnXf%>0m<1LJB`kwLPNDS0#X z3KjThLr{aD7Qq;bz5qvy5c~!oe+xi{r?};+b7QVx!n>jJmH7#3u)m9Envlc3N?7_e zyi^i<+yRfo{tw1IimurYX7vSF7d(O!QGUV{sP_NK8{IC+FRq3Th{f^0thHQ68G4E; znB1)PP-5RW%%-wHDXkn5*ehnTsAdGUb{wl0_#Od>-SQ z*3Bc2u-h`P<`Sw+$;8AU6=uQVC%m%|J|*wQjX1ECW6MA)C1D4cQ}q%&pfR)v($}l9>_UxsY5a59fqQV)^a^| z1&OWpmYFSqag7=f;m*Lt-`cpSmlpNma$%GChu!^TGAf!q!Q}8Pv9kp#6jh%}TpUgJ zO)~Ie0kOyJl^C{SioOMoCD{3b^sY^n7OdBbz6*NKQ8>*&q!I(NYS?~gxT+PeM*`Y3#VFZx6mUv%IA=KA zE>?%4Cqw>a0Y?Bg_%r^+=1dbAS-;Z{UHS;~KyYT=u)eViCudXE8Guqg>Ku1KC=bH{ zKw_tHOEattxIUDkFZ9R_Cj^}ZiTVVdR-PBywq>7SID?361nvB@8!cMWTQ|?#S|RlD z#;x@UE_eqQ1`Yh&)=a^}FK#W(_aeE7fY?QX-~hj}b?QV2S}Z^U)rJjmi!11g`(N7&`XY@$8!7+Yb zS`jXe#vSg0<0!2G-$dIBpRN4pj>Tc^X2fWhahw)8meHQ^Y{pLDi-=}_hB#V-RA~fN z4l@ZZfZRUG6{SupH6jvQ5Rh9Q16CS#jN1o}s+v5eYVyj!aZSczT<0601;<%}efcKONBtp+=aNcpHc)Zr-+gyVeBTU>q3(0U4%x=90b?#DGPIJ=g5 zvNyc{3sH>7ClH+FulehQ?fhH+_l1-E_Tg&5z%LA6pF{eaVV+RJEOi(DG4b+~6_>y5?LfkU|;3V&H z_Dh3)58Od9GO(UWyGGn#VHUCcBS??gkA)CCi{Ju+=MX%P-~|K}T;vFt31`IBDWo|S z`bG{2YQZ}d3XH4}v{ir)F6Sq1%;)dzo-br`!>GA12bCPD3%T$Jr-m}bw)5qq1z^t` zM++1B06_^dG}>|PaC?Pkk4>FbmW-V!!cYsQmfcWY=B+MsiDezvm#qQSlke~0Uyl{e zXneDv^e;*8f;Z+uzeQ1U(nX#yKH`NPkCuSZe6`M8=(M`=RmY%BCjcR z(;f9^d%A04dJ*d6u6ZJSgM0VpPj+F4_8u%CKdZr|V|{;&&%aqcMwt1%H!qrg%KNXI zmqJwFJ?8w0Md~R-!`?$%9VF3sbkPGAM|0D8^>k zV+y$OKzsggrY)$hM6q>x^}2hM-WrDD1~9Ur>o+A z_Dc|rVf-nEu=Jzuz~HrG=p&Q|!4>}M9bQ#49|`=jE-$q1&W3ocg#0@B;e%!KrZ>s- zr6FkO_K&cBNyK_IS%N5Lx^doG{^x_U)NchUl4l*N%_kL7?I^Z4*lL|{P>>3_fAKfT-5Oh|M#yDcx46J=kKxQP=KT%L@lN7Bwf6_6@uB6wm;cbtiZ}qu`;lvX-8*ZM+Gt2q@~}z7ooN_EVNf6j)1{*z;X52oV94VX;q zl9&u;Ga~nRWQVT9R`Q1*Ei39H%5KRc0ik+E#A_PzlXQOh(M)Z4`0q}iYfcqwr*eW+ z??%399d7&#Dw2F|_^^a-WM_Xz8AKSyn!SvtoUS4Rk{j4?g?lPq~Md%C?f`t^_sMIIB;dYV?lIZgXK$;YK6W%miiqpU#g<&6z5s>sf!2k2~+p1p< z;DIX@x>b~u)5urd`&-9lJIs$y-p<0 zM6Rp`jYw`2BVQ%HsN}~VZ&u~24E^!(Pc^-GxUvcKAto5U^uccybm@CfbQcAF@8 zw_#x>Bbd2;&AH_()$NyDbNjK^v&8S0$nRYQUm>`FU@kv+F25FAC?y5%s&E$&fUt`> zsF8@$LP(+Q!|lj$CG_68vKk%Er#l6;G~naabX$j<>O!`s;ADex!+6(^4r*4-2bSvb zwE?hjCoy^dtt3wkWu`$YykrZec1{wgH*XY&629KSJIajpSl`Idq9K03+{|mORZNQ%F=*c6>yw zDydW3Q$3vWCES-)!bGH;hhRQ}tEsaQ7i|pnh87Dc79>K1-B!sTy-=#{L4=mSbwMmf z^C&s4olf6?zoy^Mq?+)5>iC(=^gQjkVr>#q&*k%;vrN$c#!RD@#`>jU^=rNXS>>v% z9BRZvKB@`dbx|TPgcpL#L0SDzguF?5{I(x}48CA;LMpOLT;q4aDc*!4l*YloMyYpM zZ{jm)m@b1!rPR{Wv7MTJItWm=PR%J*pev|cN-`A5q<>PBi2O)RRv0Pz#a=&tZCT3J zf!1JBoLmp^Z^n4Ic=#x;@S`}(M{%>c^>M*K^eKWt>K+=Rbb_%>?A~|CLHJ Ai~s-t diff --git a/recruitment/__pycache__/views.cpython-312.pyc b/recruitment/__pycache__/views.cpython-312.pyc index 38268c645482e9f70251eeb6b84c87c39108689f..aff3e195cb3ab6f8ed72b7e755c62a5848d927a3 100644 GIT binary patch delta 1489 zcmY*ZZA?>F7(VB=x3|6bmRm}1+qD8^E0BhX&KT#M!G$R->J+BYn3ynTJFN;`4prv7 z*t!obn+Y^0TerlRI)(71KyC;wZXyf1B~H`s!rD}R)L;Hv(2-?ve|C-^l%1qc-uHQ* z_nhaw_uNkQ==u}7qB~|Y56Jr0{pX>trSYPfOM;1{I`aGLJsqwX{0KLRM*I!$#K|i% z)Z2Ytn1D88X7(ZKJ*#c`Qu!a{b4qX=U&{@v@V3@rC$C;4T5E?P+%3_ z&mY+g04i0u!yEv3Zj&XV@FPo-E^b-l(hv|R!t+K};ryZ^xaeSv!YRBWC^|*2u!_NN zK5y(2!;U38z+a?@!}M8`wF|rg!q#Uo0A7GN27p zM^i+bGX>kj0dzbV=tR>benP7+7Ypr6Y`T(lVQrD40N>k&X^C z0nf9A#r~?uAhCC!C57`Oh=N5U5LTSuIxgY1X%*?`9=y&#CTH8OfWLyaVe}l2?<~WTf?JX}u+;4Dv^jGs(I;*sPj&9N?}w`J|6i6ikNiBLS0Y}4#(qdOP$66<^*xaZwvsq$5t zdv*8ryn97#`vbu>?<)N^q`7Llx8+^#*tSIxuzc**S-oRkZ;I8->g{>1q__2V&XJYN zGjdg0uDT|s#E%?i$q;Q^ECZsow>sr+_?6Ryrj)+vpT#$UXnzcJtTXR$#>A}M@ujf9 zk@QIiy~HysJPmem+iq^C(%r6v4NR3zeb|2*vh-VvsBHrWA-ss^27Ts1rf}bUc&g7s z{$%ofHe5F3Wv>$P8MSffX9%xieq@3jBSeonGx8xrh?>zdIE=TBo?|Bo{hfM$^de+` zAmOljdi)Cr2QjBc*dGZorhcVfhh}O8~z|P%c-r?7j(@}Po|?IIufE|AZi*>yNFsu)C8gjA-W%;-DnH(iW(?1637`h ewuSu1jt1(`Bem&P1!HVwU^9ID4vSCxQuiMRg0~R> delta 745 zcmY+AT}V_x7=~xg**UsD>RH#bfs*Ym+lo1*73Xe? z*(+IDCcswFx$l85#_8ld6{RW*nppL{XZQMFy-_eAeCA^`Ko412Lqw@izDRIgoA7@nqB{V09Gx* zP-CmVp%r&_cLnh^b}uNgLO!S-Q`lZxi{kI^NAp@FlBQmY;Lte zx4DzH!)ZA`B|8(cQGX)ufb4t~nw6kBkNoQ&^< zGo&Ma7R4FAMc>9dAsi$}v`T4;AB>SJT0PR392i>Dq@34Gts2`pLVnEClF##QxI|0~ z<>)R_-esqd*#lu6{1FyOVCC3fos8`4 diff --git a/recruitment/linkedin_service.py b/recruitment/linkedin_service.py index 1e97f53..8593fa7 100644 --- a/recruitment/linkedin_service.py +++ b/recruitment/linkedin_service.py @@ -1,10 +1,14 @@ # jobs/linkedin_service.py import uuid -from urllib.parse import quote +import re +from html import unescape +from urllib.parse import quote, urlencode import requests import logging from django.conf import settings -from urllib.parse import urlencode, quote +import time +import random +from django.utils import timezone logger = logging.getLogger(__name__) @@ -14,7 +18,12 @@ class LinkedInService: self.client_secret = settings.LINKEDIN_CLIENT_SECRET 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 --- + def get_auth_url(self): """Generate LinkedIn OAuth URL""" params = { @@ -28,7 +37,6 @@ class LinkedInService: def get_access_token(self, code): """Exchange authorization code for access token""" - # This function exchanges LinkedIn’s temporary authorization code for a usable access token. url = "https://www.linkedin.com/oauth/v2/accessToken" data = { 'grant_type': 'authorization_code', @@ -42,12 +50,6 @@ class LinkedInService: response = requests.post(url, data=data, timeout=60) response.raise_for_status() token_data = response.json() - """ - Example response:{ - "access_token": "AQXq8HJkLmNpQrStUvWxYz...", - "expires_in": 5184000 - } - """ self.access_token = token_data.get('access_token') return self.access_token except Exception as e: @@ -55,7 +57,7 @@ class LinkedInService: raise def get_user_profile(self): - """Get user profile information""" + """Get user profile information (used to get person URN)""" if not self.access_token: raise Exception("No access token available") @@ -64,16 +66,32 @@ class LinkedInService: try: response = requests.get(url, headers=headers, timeout=60) - response.raise_for_status() # Ensure we raise an error for bad responses(4xx, 5xx) and does nothing for 2xx(success) - return response.json() # returns a dict from json response (deserialize) + response.raise_for_status() + return response.json() except Exception as e: logger.error(f"Error getting user profile: {e}") raise + # --- ASSET UPLOAD & STATUS --- + def get_asset_status(self, asset_urn): + """Checks the status of a registered asset (image) to ensure it's READY.""" + url = f"https://api.linkedin.com/v2/assets/{quote(asset_urn)}" + headers = { + 'Authorization': f'Bearer {self.access_token}', + 'X-Restli-Protocol-Version': '2.0.0' + } + + try: + response = requests.get(url, headers=headers, timeout=10) + response.raise_for_status() + return response.json().get('status') + except Exception as e: + logger.error(f"Error checking asset status for {asset_urn}: {e}") + return "FAILED" def register_image_upload(self, person_urn): - """Step 1: Register image upload with LinkedIn""" + """Step 1: Register image upload with LinkedIn, getting the upload URL and asset URN.""" url = "https://api.linkedin.com/v2/assets?action=registerUpload" headers = { 'Authorization': f'Bearer {self.access_token}', @@ -101,9 +119,8 @@ class LinkedInService: 'asset': data['value']['asset'] } - def upload_image_to_linkedin(self, upload_url, image_file): - """Step 2: Upload actual image file to LinkedIn""" - # Open and read the Django ImageField + def upload_image_to_linkedin(self, upload_url, image_file, asset_urn): + """Step 2: Upload image file and poll for 'READY' status.""" image_file.open() image_content = image_file.read() image_file.close() @@ -114,90 +131,223 @@ class LinkedInService: response = requests.post(upload_url, headers=headers, data=image_content, timeout=60) response.raise_for_status() + + # --- CRITICAL FIX: POLL FOR ASSET STATUS --- + start_time = time.time() + while time.time() - start_time < self.ASSET_STATUS_TIMEOUT: + try: + status = self.get_asset_status(asset_urn) + if status == "READY" or status == "PROCESSING": + # Exit successfully on READY, but also exit successfully on PROCESSING + # if the timeout is short, relying on the final API call to succeed. + # However, returning True on READY is safest. + if status == "READY": + logger.info(f"Asset {asset_urn} is READY. Proceeding.") + 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: + # If the status check fails for any reason (400, connection, etc.), + # we log it, wait a bit longer, and try again, instead of crashing. + logger.warning(f"Error during asset status check for {asset_urn}: {e}. Retrying.") + time.sleep(self.ASSET_STATUS_INTERVAL * 2) + + # If the loop times out, force the post anyway (mimicking the successful manual fix) + logger.warning(f"Asset {asset_urn} timed out, but upload succeeded. Forcing post attempt.") return True + + # --- POSTING LOGIC --- + + def clean_html_for_social_post(self, html_content): + """Converts safe HTML to plain text with basic formatting (bullets, bold, newlines).""" + 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) + + # 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] + + if not tags: + return ["#HigherEd", "#Hiring", "#UniversityJobs"] + + return tags + + def _build_post_message(self, job_posting): + """Centralized logic to construct the professionally formatted text message.""" + 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("\n" + "=" * 25 + "\n") + + # KEY DETAILS SECTION + details_list = [] + if job_posting.job_type: + details_list.append(f"πŸ’Ό Type: {job_posting.get_job_type_display()}") + if job_posting.get_location_display() != 'Not specified': + details_list.append(f"πŸ“ Location: {job_posting.get_location_display()}") + if job_posting.workplace_type: + 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) + message_parts.append("\n") + + # DESCRIPTION SECTION + 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}") + + # HASHTAGS + hashtags = self.hashtags_list(job_posting.hash_tags) + if job_posting.department: + dept_hashtag = f"#{job_posting.department.replace(' ', '')}" + hashtags.insert(0, dept_hashtag) + + message_parts.append("\n" + " ".join(hashtags)) + + return "\n".join(message_parts) + + 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.""" + + message = self._build_post_message(job_posting) + + url = "https://api.linkedin.com/v2/ugcPosts" + headers = { + 'Authorization': f'Bearer {self.access_token}', + 'Content-Type': 'application/json', + 'X-Restli-Protocol-Version': '2.0.0' + } + + payload = { + "author": f"urn:li:person:{person_urn}", + "lifecycleState": "PUBLISHED", + "specificContent": { + "com.linkedin.ugc.ShareContent": { + "shareCommentary": {"text": message}, + "shareMediaCategory": "IMAGE", + "media": [{ + "status": "READY", + "media": asset_urn, + "description": {"text": job_posting.title}, + "originalUrl": job_posting.application_url, + "title": {"text": "Apply Now"} + }] + } + }, + "visibility": { + "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC" + } + } + + response = requests.post(url, headers=headers, json=payload, timeout=60) + 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 "" + + return { + 'success': True, + 'post_id': post_id, + 'post_url': post_url, + 'status_code': response.status_code + } + def create_job_post(self, job_posting): - """Create a job announcement post on LinkedIn (with image support)""" + """Main method to create a job announcement post (Image or Text).""" if not self.access_token: raise Exception("Not authenticated with LinkedIn") try: - # Get user profile for person URN profile = self.get_user_profile() person_urn = profile.get('sub') - if not person_urn: raise Exception("Could not retrieve LinkedIn user ID") - # Check if job has an image + asset_urn = None + has_image = False + + # Check for image and attempt post try: - image_upload = job_posting.files.first() - has_image = image_upload and image_upload.linkedinpost_image + # 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: - has_image = False + pass # No image available if has_image: - # === POST WITH IMAGE === try: - # Step 1: Register image upload + # Step 1: Register upload_info = self.register_image_upload(person_urn) + asset_urn = upload_info['asset'] - # Step 2: Upload image + # Step 2: Upload and WAIT FOR READY (Crucial for 422 fix) self.upload_image_to_linkedin( upload_info['upload_url'], - image_upload.linkedinpost_image + image_upload, + asset_urn ) # Step 3: Create post with image return self.create_job_post_with_image( - job_posting, - image_upload.linkedinpost_image, - person_urn, - upload_info['asset'] + job_posting, image_upload, person_urn, asset_urn ) except Exception as e: - logger.error(f"Image upload failed: {e}") - # Fall back to text-only post if image upload fails - has_image = False + logger.error(f"Image post failed, falling back to text: {e}") + # Force fallback to text-only if image posting fails + has_image = False - # === FALLBACK TO URL/ARTICLE POST === - # Add unique timestamp to prevent duplicates - from django.utils import timezone - import random - unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})" + # === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) === + message = self._build_post_message(job_posting) - message_parts = [f"πŸš€ **We're Hiring: {job_posting.title}**"] - if job_posting.department: - message_parts.append(f"**Department:** {job_posting.department}") - if job_posting.description: - message_parts.append(f"\n{job_posting.description}") - - details = [] - if job_posting.job_type: - details.append(f"πŸ’Ό {job_posting.get_job_type_display()}") - if job_posting.get_location_display() != 'Not specified': - details.append(f"πŸ“ {job_posting.get_location_display()}") - if job_posting.workplace_type: - details.append(f"🏠 {job_posting.get_workplace_type_display()}") - if job_posting.salary_range: - details.append(f"πŸ’° {job_posting.salary_range}") - - if details: - message_parts.append("\n" + " | ".join(details)) - - if job_posting.application_url: - message_parts.append(f"\nπŸ”— **Apply now:** {job_posting.application_url}") - - hashtags = self.hashtags_list(job_posting.hash_tags) - if job_posting.department: - dept_hashtag = f"#{job_posting.department.replace(' ', '')}" - hashtags.insert(0, dept_hashtag) - - message_parts.append("\n\n" + " ".join(hashtags)) - message_parts.append(unique_suffix) - message = "\n".join(message_parts) - - # πŸ”₯ FIX URL - REMOVE TRAILING SPACES πŸ”₯ url = "https://api.linkedin.com/v2/ugcPosts" headers = { 'Authorization': f'Bearer {self.access_token}', @@ -211,20 +361,14 @@ class LinkedInService: "specificContent": { "com.linkedin.ugc.ShareContent": { "shareCommentary": {"text": message}, - "shareMediaCategory": "ARTICLE", - "media": [{ - "status": "READY", - "description": {"text": f"Apply for {job_posting.title} at our university!"}, - "originalUrl": job_posting.application_url, - "title": {"text": job_posting.title} - }] + "shareMediaCategory": "NONE", # Pure text post } }, "visibility": { "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC" } } - + response = requests.post(url, headers=headers, json=payload, timeout=60) response.raise_for_status() @@ -244,18 +388,4 @@ class LinkedInService: 'success': False, 'error': str(e), 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500 - } - - - - def hashtags_list(self,hash_tags_str): - """Convert comma-separated hashtags string to list""" - if not hash_tags_str: - return [""] - tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()] - if not tags: - return ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"] - - return tags - - + } \ No newline at end of file diff --git a/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py b/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py new file mode 100644 index 0000000..a961dd5 --- /dev/null +++ b/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.7 on 2025-10-13 22:16 + +import django.db.models.deletion +import recruitment.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0010_merge_20251013_1819'), + ] + + operations = [ + migrations.AlterField( + model_name='jobpostingimage', + name='job', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting'), + ), + migrations.AlterField( + model_name='jobpostingimage', + name='post_image', + field=models.ImageField(upload_to='post/', validators=[recruitment.validators.validate_image_size]), + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index d53801e..b662c0e 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -1,6 +1,6 @@ from django.db import models from django.utils import timezone -from .validators import validate_hash_tags +from .validators import validate_hash_tags, validate_image_size from django.contrib.auth.models import User from django.core.validators import URLValidator from django.utils.translation import gettext_lazy as _ @@ -249,8 +249,8 @@ class JobPosting(Base): class JobPostingImage(models.Model): - job=models.ForeignKey('JobPosting',on_delete=models.CASCADE,related_name='post_images') - post_image = models.ImageField(upload_to='post/') + job=models.OneToOneField('JobPosting',on_delete=models.CASCADE,related_name='post_images') + post_image = models.ImageField(upload_to='post/',validators=[validate_image_size]) class Candidate(Base): diff --git a/recruitment/views.py b/recruitment/views.py index 3b5efa9..a297a59 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -1501,9 +1501,14 @@ def candidate_screening_view(request, slug): Manage candidate tiers and stage transitions """ job = get_object_or_404(JobPosting, slug=slug) - + applied_count=job.candidates.filter(stage='Applied').count() + exam_count=job.candidates.filter(stage='Exam').count() + interview_count=job.candidates.filter(stage='interview').count() + offer_count=job.candidates.filter(stage='Offer').count() # Get all candidates for this job, ordered by match score (descending) candidates = job.candidates.filter(stage="Applied").order_by("-match_score") + + # Get tier categorization parameters # tier1_count = int(request.GET.get("tier1_count", 100)) @@ -1601,19 +1606,55 @@ def candidate_screening_view(request, slug): # messages.info(request, "All Tier 1 candidates are already marked as Candidates") # Group candidates by current stage for display - stage_groups = { - "Applied": candidates.filter(stage="Applied"), - "Exam": candidates.filter(stage="Exam"), - "Interview": candidates.filter(stage="Interview"), - "Offer": candidates.filter(stage="Offer"), - } + # stage_groups = { + # "Applied": candidates.filter(stage="Applied"), + # "Exam": candidates.filter(stage="Exam"), + # "Interview": candidates.filter(stage="Interview"), + # "Offer": candidates.filter(stage="Offer"), + # } + min_ai_score_str = request.GET.get('min_ai_score') + tier1_count_str = request.GET.get('tier1_count') + + try: + # Check if the string value exists and is not an empty string before conversion + if min_ai_score_str: + min_ai_score = int(min_ai_score_str) + else: + min_ai_score = 0 + + if tier1_count_str: + tier1_count = int(tier1_count_str) + else: + tier1_count = 0 + + except ValueError: + # This catches if the user enters non-numeric text (e.g., "abc") + min_ai_score = 0 + tier1_count = 0 + print(min_ai_score) + print(tier1_count) + # You can now safely use min_ai_score and tier1_count as integers (0 or greater) + if min_ai_score > 0: + candidates = candidates.filter(match_score__gte=min_ai_score) + print(candidates) + + if tier1_count > 0: + candidates = candidates[:tier1_count] + context = { "job": job, "candidates": candidates, # "stage_groups": stage_groups, # "tier1_count": tier1_count, # "total_candidates": candidates.count(), + 'min_ai_score':min_ai_score, + 'tier1_count':tier1_count, + 'applied_count':applied_count, + 'exam_count':exam_count, + 'interview_count':interview_count, + 'offer_count':offer_count + } return render(request, "recruitment/candidate_screening_view.html", context) diff --git a/templates/jobs/job_detail.html b/templates/jobs/job_detail.html index 78d6579..2103fcc 100644 --- a/templates/jobs/job_detail.html +++ b/templates/jobs/job_detail.html @@ -269,11 +269,14 @@
    -
    +
    {# RIGHT TABS NAVIGATION #}
    - {% comment %} --- START OF TABLE VIEW (Data relied upon context variable 'jobs') --- {% endcomment %} + {# --- START OF JOB LIST CONTAINER --- #}
    - {% comment %} Placeholder for View Switcher {% endcomment %} - {% include "includes/_list_view_switcher.html" with list_id="job-list" %} + {# View Switcher (Contains the Card/Table buttons and JS/CSS logic) #} + {% include "includes/_list_view_switcher.html" with list_id="job-list" %} + {# 1. TABLE VIEW (Default Active) #}
    + + {# --- Corrected Multi-Row Header Structure --- #} - - {% comment %} - {% endcomment %} - - - + + + + - + + + + + + + + + + + - {% comment %} This loop relies on the 'jobs' variable passed from the Django view {% endcomment %} {% for job in jobs %} - - {% comment %} - {% endcomment %} + - - {# CANDIDATE MANAGEMENT DATA - 7 SEPARATE COLUMNS CORRESPONDING TO THE HEADER #} + {# CANDIDATE MANAGEMENT DATA - URLS NEUTRALIZED #} @@ -314,7 +337,53 @@ + + {# 2. CARD VIEW (Previously Missing) - Added Bootstrap row/col structure for layout #} +
    + {% for job in jobs %} +
    +
    +
    +
    +
    {{ job.title }}
    + {{ job.status }} +
    +

    ID: {{ job.pk }} | Source: {{ job.get_source }}

    + +
      +
    • {% trans "Applicants" %}:{{ job.metrics.applied|default:"0" }}
    • +
    • {% trans "Offers Made" %}: {{ job.metrics.offer|default:"0" }}
    • +
    • {% trans "Form" %}:{% if job.form_template %} + {{ job.form_template.name }} + {% else %} + {% trans "N/A" %} + {% endif %} +
    • +
    + +
    + + {% trans "Details" %} + +
    + + + + {% if job.form_template %} + + + + {% endif %} +
    +
    +
    +
    +
    + {% endfor %} +
    + {# --- END CARD VIEW --- #} + {# --- END OF JOB LIST CONTAINER --- #} {% comment %} Fallback/Empty State {% endcomment %} {% if not jobs and not job_list_data and not page_obj %} diff --git a/templates/recruitment/candidate_screening_view.html b/templates/recruitment/candidate_screening_view.html index 8ce3eed..ee5970c 100644 --- a/templates/recruitment/candidate_screening_view.html +++ b/templates/recruitment/candidate_screening_view.html @@ -172,7 +172,12 @@ {% endblock %} {% block content %} +
    +
    + {% include 'jobs/partials/applicant_tracking.html' %} +
    +

    @@ -189,16 +194,14 @@

    -
    - {% include 'jobs/partials/applicant_tracking.html' %} -
    +

    {% trans "AI Scoring & Top Candidate Filter" %}

    -
    + {% csrf_token %}
    @@ -207,7 +210,7 @@ {% trans "Min AI Score" %}
    diff --git a/templates/recruitment/training_create.html b/templates/recruitment/training_create.html index a09f19d..b4732ad 100644 --- a/templates/recruitment/training_create.html +++ b/templates/recruitment/training_create.html @@ -24,7 +24,7 @@ /* Main Action Button Style */ .btn-main-action{ - background-color: var(--kaauh-teal-dark); /* Changed to primary teal for main actions */ + background-color: gray; /* Changed to primary teal for main actions */ border-color: var(--kaauh-teal); color: white; font-weight: 600;
    {% trans "Job ID" %}{% trans "Job Title" %}{% trans "Status" %}{% trans "Source" %}{% trans "Actions" %}{% trans "Manage Forms" %}{% trans "Job Title / ID" %}{% trans "Source" %}{% trans "Actions" %}{% trans "Manage Forms" %} + {% trans "Applicants Metrics" %} - - - - - - - - - - - - -
    {% trans "Applied" %}{% trans "Screened" %}{% trans "Exam" %} - - - - - -
    PF
    -
    {% trans "Interview" %} - - - - - -
    PF
    -
    {% trans "Offer" %}
    {% trans "Applied" %}{% trans "Screened" %}{% trans "Exam" %} +
    + P + F +
    +
    {% trans "Interview" %} +
    + P + F +
    +
    {% trans "Offer" %}
    {{ job }}{{ job.title }}{{ job.status }} + {{ job.title }} +
    + {{ job.pk }} / + {{ job.status }} +
    {{ job.get_source }}
    @@ -283,10 +306,10 @@
    +
    {% if job.form_template %} - + @@ -299,7 +322,7 @@
    {% if job.metrics.applied %}{{ job.metrics.applied }}{% else %}-{% endif %} {% if job.metrics.screening %}{{ job.metrics.screening }}{% else %}-{% endif %} {% if job.metrics.exam_p %}{{ job.metrics.exam_p }}{% else %}-{% endif %}