From 5f7af358dfd4b01cad94f2dae698342321b78b56 Mon Sep 17 00:00:00 2001 From: Faheed Date: Sun, 19 Oct 2025 17:22:11 +0300 Subject: [PATCH] update successfull --- .../__pycache__/settings.cpython-312.pyc | Bin 7918 -> 8243 bytes .../__pycache__/urls.cpython-312.pyc | Bin 2216 -> 2216 bytes NorahUniversity/settings.py | 21 +- NorahUniversity/urls.py | 2 + recruitment/__pycache__/forms.cpython-312.pyc | Bin 23641 -> 26293 bytes .../linkedin_service.cpython-312.pyc | Bin 16122 -> 16217 bytes .../__pycache__/signals.cpython-312.pyc | Bin 6657 -> 6657 bytes recruitment/__pycache__/urls.cpython-312.pyc | Bin 10086 -> 10425 bytes .../__pycache__/validators.cpython-312.pyc | Bin 1087 -> 1087 bytes recruitment/__pycache__/views.cpython-312.pyc | Bin 75893 -> 78367 bytes recruitment/forms.py | 67 ++++- recruitment/linkedin_service.py | 116 ++++---- recruitment/migrations/0001_initial.py | 24 +- .../0002_alter_jobposting_status.py | 18 -- ...ndidate_is_potential_candidate_and_more.py | 23 -- .../0003_alter_candidate_exam_date.py | 18 -- ...t_date_jobposting_joining_date_and_more.py | 23 -- .../0004_alter_candidate_interview_date.py | 18 -- ...emove_interviewschedule_breaks_and_more.py | 22 -- .../0006_zoommeeting_meeting_status.py | 18 -- ...eting_meeting_status_zoommeeting_status.py | 22 -- .../migrations/0008_zoommeeting_password.py | 18 -- .../migrations/0009_merge_20251013_1714.py | 14 - .../migrations/0009_merge_20251013_1718.py | 14 - .../0010_alter_scheduledinterview_schedule.py | 19 -- .../migrations/0010_merge_20251013_1819.py | 14 - ...0011_alter_jobpostingimage_job_and_more.py | 25 -- .../migrations/0012_merge_20251014_1403.py | 14 - .../0013_alter_formtemplate_created_by.py | 21 -- recruitment/tasks.py | 41 +++ recruitment/urls.py | 5 +- recruitment/validators.py | 2 +- recruitment/views.py | 146 +++++++--- requirements.txt | 3 +- templates/account/signup.html | 0 templates/base.html | 12 +- templates/meetings/list_meetings.html | 10 +- templates/user/admin_settings.html | 265 ++++++++++++++++++ templates/user/create_staff.html | 81 ++++++ templates/user/staff_password_create.html | 42 +++ 40 files changed, 716 insertions(+), 422 deletions(-) delete mode 100644 recruitment/migrations/0002_alter_jobposting_status.py delete mode 100644 recruitment/migrations/0002_candidate_is_potential_candidate_and_more.py delete mode 100644 recruitment/migrations/0003_alter_candidate_exam_date.py delete mode 100644 recruitment/migrations/0003_rename_start_date_jobposting_joining_date_and_more.py delete mode 100644 recruitment/migrations/0004_alter_candidate_interview_date.py delete mode 100644 recruitment/migrations/0005_remove_interviewschedule_breaks_and_more.py delete mode 100644 recruitment/migrations/0006_zoommeeting_meeting_status.py delete mode 100644 recruitment/migrations/0007_remove_zoommeeting_meeting_status_zoommeeting_status.py delete mode 100644 recruitment/migrations/0008_zoommeeting_password.py delete mode 100644 recruitment/migrations/0009_merge_20251013_1714.py delete mode 100644 recruitment/migrations/0009_merge_20251013_1718.py delete mode 100644 recruitment/migrations/0010_alter_scheduledinterview_schedule.py delete mode 100644 recruitment/migrations/0010_merge_20251013_1819.py delete mode 100644 recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py delete mode 100644 recruitment/migrations/0012_merge_20251014_1403.py delete mode 100644 recruitment/migrations/0013_alter_formtemplate_created_by.py delete mode 100644 templates/account/signup.html create mode 100644 templates/user/admin_settings.html create mode 100644 templates/user/create_staff.html create mode 100644 templates/user/staff_password_create.html diff --git a/NorahUniversity/__pycache__/settings.cpython-312.pyc b/NorahUniversity/__pycache__/settings.cpython-312.pyc index 790184711efb7d2bbd577dd7cd3fb4ec578657b3..8d89095cd5131ddbf3675cdb3562e6feb0d3316d 100644 GIT binary patch delta 1299 zcmYjPOH3PA6rE=qw-^}_zzK5tfj|M9-~>W|Bw%9v!3KX48>h`ngJU>@8NeULCN%EH z^q>CJw9`smSe5E+SCO;ru2L6tVRaSFrdd~Nn?-ls^v*a&ou_;6x#!+@Kcn~8eEn+Y zk=1Gu===BRpRJ!9Uv>7e?;nobVwDWzFJ}Y{*BI}InGaxupMil75+1@R91`QNz{!U( z#z#m-VWxAoYTyox!<8{`C(iLPu^s2j9fCmB&SHX((`^^YIZX2NxWFeUKJ_d44s)1&j#vGr)JfDS|U%~>v zjH~<#$sBc^hljhd$QR(Hd9K38JtT|pb1wqihb8VukOvULGFA|#eIjJr5k(Bwz$&<2 zH6o4#R*@uhgF>%X4S0>@X0?Maf#VilC+{}abeqB*-KMdw+YLasA~rRfG4miY*rElb z8k6V{+SV7^p@nvlr5ph{oypfK3?c-GdMm=PccET__ouz zOuqiec)PvQCT7#puESV1>%dvI6w<}wL0-nh|L~+!HRbX-Q8gE(Om4qW9+1Tid0#4J z#azj?nU}LgSE7{O+)U`<01;&u^fs=Iyezu3Ca(4L#*Uank*HiTzbm@@Sqt%tO}gG? z?KGN$^g`riWvFYM1(kT$9!n}ecRf%JPbh)377{hJsVUvf5OVa(cUpw{TzHyy`O6-Uy!1FBX4p#Ibn+pq*gRfuEE!!* zg=0&hXe#1g4aR&4^_WhCmZHgPsX)jdCc3wkos5RAC;cgZ#1jgu_7C_6)K`CWYR{k~?T41X=@-zO6pGXJ-|cJ6eMYbkeAnI(Y-*pI^pjJk8$$2n z7Y7@H{qfLfLl}56>}&{wPt8KNy>Z&z=vp|ml5@htG+TGSaKxBf>3iun)1OrJKV@-^ AMgRZ+ delta 950 zcmYjONl+6}5S^bu5GVwNAZl5HvW3-!RY3zGLzha`xtpDvR=@Ia}UbE}pDX`;S05zxut`-EV$R*Cf5)+IOz!JDo{g}s3i~g z>o)sUE21{L{*6P9P!y`>bneJ@T7#ohi(^p*$5#bo!>T}|<v7_4V- z8+ULQ_pCI`;yxbWp{*Zb4v#S}bu>*Acw(jFDUXt8!E$nC(Q=XxF3!}C1SAe7{L zr<$33o0H>o`T3vd0dpg-R`|`|dAG!%8Q-;3J)0em7!zS5RyUqV%pTZG!U-d$N1|$- zZ-&)W(9X<1yIysNX4y5HjQWnz(AF@*DW2+yY{pENBrU=Ier`f)~>Al7Yv@ z4J|gE(65^TcbkZs3+}4ICD}>4t*LkPW$9dzFk!xPR|?Ji>TanRk%n|_iq}o08xI&QkG%qg~0}%YY^d;lNM&23hj1MO-W1pd|D9F#$!2Lmufsv=P_9hEQhfrtu e1yS|Oyc(BTG(R&l3ow0TU}gj<;hUVvVGjVrOBetE delta 76 zcmZ1>xI&QkG%qg~0}$vxdXe#LBkv4$#+#FuvCq(!7vN`V;QpY(z{t~Cdy|EuL#Q+S ef~fjsUX9BvnxC1O1eiWDFfoFZ@J!C+um=E+q8A?k diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 6bb6388..c51ea0c 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -160,6 +160,23 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] + + +ACCOUNT_LOGIN_METHODS = ['email'] +ACCOUNT_SIGNUP_FIELDS = ['email*', 'password1*', 'password2*'] + +ACCOUNT_UNIQUE_EMAIL = True +ACCOUNT_EMAIL_VERIFICATION = 'none' +ACCOUNT_USER_MODEL_USERNAME_FIELD = None + +ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True + + +ACCOUNT_FORMS = {'signup': 'recruitment.forms.StaffSignupForm'} + + +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + # Crispy Forms Configuration CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" CRISPY_TEMPLATE_PACK = "bootstrap5" @@ -298,8 +315,8 @@ customColorPalette = [ }, ] -CKEDITOR_5_CUSTOM_CSS = 'path_to.css' # optional -CKEDITOR_5_FILE_STORAGE = "path_to_storage.CustomStorage" # optional +# CKEDITOR_5_CUSTOM_CSS = 'path_to.css' # optional +# CKEDITOR_5_FILE_STORAGE = "path_to_storage.CustomStorage" # optional CKEDITOR_5_CONFIGS = { 'default': { 'toolbar': { diff --git a/NorahUniversity/urls.py b/NorahUniversity/urls.py index d96e6c8..90cdd3a 100644 --- a/NorahUniversity/urls.py +++ b/NorahUniversity/urls.py @@ -16,6 +16,7 @@ urlpatterns = [ path('admin/', admin.site.urls), path('api/', include(router.urls)), path('accounts/', include('allauth.urls')), + path('i18n/', include('django.conf.urls.i18n')), # path('summernote/', include('django_summernote.urls')), # path('', include('recruitment.urls')), @@ -32,6 +33,7 @@ urlpatterns = [ urlpatterns += i18n_patterns( path('', include('recruitment.urls')), + ) # 2. URLs that DO have a language prefix (user-facing views) # This includes the root path (''), which is handled by 'recruitment.urls' diff --git a/recruitment/__pycache__/forms.cpython-312.pyc b/recruitment/__pycache__/forms.cpython-312.pyc index cea0bd578f4de5a5d2aea967a4d5a8f50609b291..bba0bac7017df0f4be1b5d51a2d9669c0ab20e98 100644 GIT binary patch delta 2219 zcmaJ@T}%{L6ux(6XaCtB*adb)oRtHoo+s&rMS%*z7tX>CZzGADYy^Xxf*aJIkUH?al6)d(S=R zo^#Ll&Dp(s8-M!~_WW2@=0q_5`18^5XZ3AyHogyPgwVB>Z{GIlF zcSE^^Z!p*KiXX=ynbjs*f)y<2E!UY9y{)9raWClYW^^k=x2@VbB>QC)b4m`7F3F{$ zAuKsBJ7R7XUBt1n6G(6y!4qk5XlQL(eF7%mK*W2sAOyy6j))fr5_0lvdbp_n6hT7T zvHr?j04!#ajAB?qF-B%%7753=ON_w&6MpI0(IawN6gW}`j)bQo0>W^d7ZqhJMWn6I zrmaT>D`}x7Td8QJ!be3L741}LKlcYhyP#^DAR)yKs>`V03#eSde{9mKZRx6VQYh&6 z;aA$t?d$3#9D>C-POp>{p>q~ctQnf#5wK=WM6{_xVU4e}W1@Z3#%sAYNAg3Q#ZW%W zQS%m1EP9zteHT9p`0``%^e~?ujw`%8BE}QEm;l73OT2tBuB4TyU?V9oFW$rhsS_^< z!&=-XG*xmQguswKIOvI%oel!`sFe;naB*hV#A0nxdA~aD0?Kr6i7mO0dK9HE~uwqCqID7;1a1ZnHUU_4OSUFLvki5SD_?(p-#Zu zB6`%pdY;Hb@r$IJ0&O`XDzaf4OpPYfGEuBxz?*~YrndgGD;F3jh0Qd)9Q9ShT*slY!RH7;bv6W0DE3E9LRTdK;7=O_bpbaC3As-;6?Pxc!$U2xB8HuOY^u0d8L3+U5 zuqfgOaloeIPeniYDD;BBqzRKqq!;3&%qyBO zjrJyG%NY1Poj}Xpz~tCucD_T+E_nsuKroaK*6YFg`7u4XMYT*h0rGr!vmV~Oa99s- z2aUH7TAvR!>Y>JkoqDJfG@e3rO};v+S4S7pUuV9|WtOUUfVvFS^l{haFSvbIdM10O z4i&r=s>`H-fcpf`+AdE)G@Scs`|OU(3CXOQ;n+7UlH!^N1M`Q2?4h$RS_89Cy>u3% zy3<-m5tR?@7B2X}?c%DRgdJSqJ_2E0$BZR(8dm9? zm^_=h_3o)_xWNb&SJA;#GEL%VqT*02ITaIId_R=gPt$mFYVtBh wl_G1Px*{78VGAT~aoFVMr;>g`}Kaf}z4B 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 Z0pKE+>|?6^?bd&(&j>`?2IOlLP=Z`31`W diff --git a/recruitment/__pycache__/urls.cpython-312.pyc b/recruitment/__pycache__/urls.cpython-312.pyc index adce72e1c30f16b7cc7755dcb141f50e4236ee83..b4efb3c48cebf0c4433a225f3f3038e92c4ac648 100644 GIT binary patch delta 1328 zcmZ{jOKcNI7=XRbSR%zXEycwleyv|@5MfEcNz+6$1kogo6TlCW+HD*QZyv;WQ?FCh z^pW;bRVfWIJ~T&qA`+-_m8zl_&Pa%AMp|hrSE%ZtsE4W=r1rp#*?)`#BCPDsyZ`sk zKmUwpA5Q#o&Uw$_c*Vl5`+xm6za>_kZtJY$N9mz`)oM|M!kU6Eu9^DztMJXOT{l~J ztSUg}6_$DB`7HCP@a+qk>nwA9hs>+bW@c<7A8&M2xA!?NIagO&HKkHnD9ved!-m3#@Y#BK%l)&ALKvCtMl5FS`O z@Ok}##ZQr1`_s2yP-~BUK}L_itw$C1SRdIfwpQFK*Ih=}FzFh`6Eix>QwKOFxX|_V zjp^^FZy(mtFx{ip7X1hMK<@(`-Le_d5Rr!PXii5n)CKMhaDBI9Mt_R*r*L*jM<3Gt zYVD4{jgR(0M(5VDA)hAlX&j%{QI7gybO4!2^u; z(7|a)EHuktsn3wci8PK=1sy3=;dN$TqUX4t=Q@bPF(Vu&;W$n%>ZnK;*c8=}hw%sC zJ>cIVZx$w9;lpC}WurGudeiv*k92gIe!`1>%I(p5#ONL)-D5a0r=xlL8MmMBJa(1M zH52Y+To0RjwhjC(?9IT86IR%*<8{U8Ns^u<&S*NS&@)!BiJfhd+wBigwNEu3vJ|1kfi z%V;}9+78tnIyynmwea>#hg-YrG2>W*982ISXg51Q}RAdv>~J8-(PV87g5F0vQ> E2?u$kM*si- delta 732 zcmXAn%WD%+6vk(gYtuB3f~h3aOfxf?N0QPsHV;IaxKQJZHl|2X+M)}QD&9bSRMCQv zg-~l3spFy>L0m}P*o1C$<6gRO2CfV)x-AMmZoxmmdym7+kKsGt{qDIl=i|(NBf1-j z3<&Jn{!MRwQa7Rr-}i7YyC3eGt3O)HJ>Pi|X+`~w+?kyv`Ipbe%EjZinOkJfy_fxh zK>ea9?g=v7u{}Yd2#bThZ>WD&?mY40Wj9{_FiFr^>c{q9C7tBeg8|`Ca5c4g#FKKa zl|PZq3#yy&eTfS-XB z7}t4vRp-^~z%^h`A7RQDU1t2L4>Jag3}eAaF*blL;JU#*?+m#LjocUc2T61)UTn;b zjX9-r1f8eLA`~C+q7&3!S3JGu>NV%|96|H+Aqx;qzM%rF0q4y;Q=XZ;!S|-2K*E#{ z5+=G8uQcnHW}St{1g+3%AGQx*)u6?5@)mFE0&o$Or1#WN|x_e%Ss{MUpsX778}lg(_!K! diff --git a/recruitment/__pycache__/validators.cpython-312.pyc b/recruitment/__pycache__/validators.cpython-312.pyc index 70d42fcdcce17114cfd5a9e18458e40606f1e3d0..56c5562aea7168110ac7b7dab06dc31caa8bd01a 100644 GIT binary patch delta 25 fcmdnbv7dwcG%qg~0}$x0`ZSUIFeBr}^99TRTxtiV delta 25 fcmdnbv7dwcG%qg~0}y<*c{GvxFeB5(^99TRV08!a diff --git a/recruitment/__pycache__/views.cpython-312.pyc b/recruitment/__pycache__/views.cpython-312.pyc index 364a7ee4a4c183ccb83aa0739075cdc768c08acb..cf28cc0eb5738529e4c9dfc47b37287713adf0fe 100644 GIT binary patch delta 16609 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 Jt&C>)e*rhC0E7Sl delta 14879 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 diff --git a/recruitment/forms.py b/recruitment/forms.py index 30f1ca9..bd79f8b 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -519,4 +519,69 @@ class ProfileImageUploadForm(forms.ModelForm): # class UserEditForms(forms.ModelForm): # class Meta: # model = User -# fields = ['first_name', 'last_name'] \ No newline at end of file +# fields = ['first_name', 'last_name'] + + +from django.contrib.auth.forms import UserCreationForm +# class StaffUserCreationForm(UserCreationForm): +# email = forms.EmailField(required=True) +# first_name = forms.CharField(max_length=30) +# last_name = forms.CharField(max_length=150) + +# class Meta: +# model = User +# fields = ("email", "first_name", "last_name", "password1", "password2") + +# def save(self, commit=True): +# user = super().save(commit=False) +# user.email = self.cleaned_data["email"] +# user.first_name = self.cleaned_data["first_name"] +# user.last_name = self.cleaned_data["last_name"] +# user.username = self.cleaned_data["email"] # or generate +# user.is_staff = True +# if commit: +# user.save() + # return user + +import re +class StaffUserCreationForm(UserCreationForm): + email = forms.EmailField(required=True) + first_name = forms.CharField(max_length=30, required=True) + last_name = forms.CharField(max_length=150, required=True) + + class Meta: + model = User + fields = ("email", "first_name", "last_name", "password1", "password2") + + def clean_email(self): + email = self.cleaned_data["email"] + if User.objects.filter(email=email).exists(): + raise forms.ValidationError("A user with this email already exists.") + return email + + def generate_username(self, email): + """Generate a valid, unique username from email.""" + prefix = email.split('@')[0].lower() + username = re.sub(r'[^a-z0-9._]', '', prefix) + if not username: + username = 'user' + base = username + counter = 1 + while User.objects.filter(username=username).exists(): + username = f"{base}{counter}" + counter += 1 + return username + + def save(self, commit=True): + user = super().save(commit=False) + user.email = self.cleaned_data["email"] + user.first_name = self.cleaned_data["first_name"] + user.last_name = self.cleaned_data["last_name"] + user.username = self.generate_username(user.email) # never use raw email if it has dots, etc. + user.is_staff = True + if commit: + user.save() + return user + + + diff --git a/recruitment/linkedin_service.py b/recruitment/linkedin_service.py index 8593fa7..d4095ae 100644 --- a/recruitment/linkedin_service.py +++ b/recruitment/linkedin_service.py @@ -12,6 +12,10 @@ from django.utils import timezone logger = logging.getLogger(__name__) +# Define a constant for the API version for better maintenance +LINKEDIN_API_VERSION = '2.0.0' +LINKEDIN_VERSION = '202409' # Modern API version for header control + class LinkedInService: def __init__(self): self.client_id = settings.LINKEDIN_CLIENT_ID @@ -79,7 +83,8 @@ class LinkedInService: 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' + 'X-Restli-Protocol-Version': LINKEDIN_API_VERSION, + 'LinkedIn-Version': LINKEDIN_VERSION, } try: @@ -96,7 +101,8 @@ class LinkedInService: headers = { 'Authorization': f'Bearer {self.access_token}', 'Content-Type': 'application/json', - 'X-Restli-Protocol-Version': '2.0.0' + 'X-Restli-Protocol-Version': LINKEDIN_API_VERSION, + 'LinkedIn-Version': LINKEDIN_VERSION, } payload = { @@ -138,9 +144,6 @@ class LinkedInService: 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 @@ -151,12 +154,10 @@ class LinkedInService: 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) + # 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 @@ -253,9 +254,12 @@ class LinkedInService: 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.""" + + 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. + """ message = self._build_post_message(job_posting) @@ -263,25 +267,24 @@ class LinkedInService: headers = { 'Authorization': f'Bearer {self.access_token}', 'Content-Type': 'application/json', - 'X-Restli-Protocol-Version': '2.0.0' + 'X-Restli-Protocol-Version': LINKEDIN_API_VERSION, + 'LinkedIn-Version': LINKEDIN_VERSION, } + specific_content = { + "com.linkedin.ugc.ShareContent": { + "shareCommentary": {"text": message}, + "shareMediaCategory": media_category, + } + } + + if media_list: + specific_content["com.linkedin.ugc.ShareContent"]["media"] = media_list + 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"} - }] - } - }, + "specificContent": specific_content, "visibility": { "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC" } @@ -300,6 +303,28 @@ class LinkedInService: 'status_code': response.status_code } + + 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.""" + + # Prepare the media list for the _send_ugc_post helper + media_list = [{ + "status": "READY", + "media": asset_urn, + "description": {"text": job_posting.title}, + "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, + media_category="IMAGE", + media_list=media_list + ) + + def create_job_post(self, job_posting): """Main method to create a job announcement post (Image or Text).""" if not self.access_token: @@ -346,41 +371,12 @@ class LinkedInService: has_image = False # === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) === - 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": "NONE", # Pure text post - } - }, - "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 - } + # Use the single helper method here + return self._send_ugc_post( + person_urn=person_urn, + job_posting=job_posting, + media_category="NONE" + ) except Exception as e: logger.error(f"Error creating LinkedIn post: {e}") diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py index 3a3b33a..1895908 100644 --- a/recruitment/migrations/0001_initial.py +++ b/recruitment/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-10-12 10:34 +# Generated by Django 5.2.7 on 2025-10-17 19:41 import django.core.validators import django.db.models.deletion @@ -105,10 +105,12 @@ class Migration(migrations.Migration): ('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, max_length=20, null=True, verbose_name='Status')), ], options={ 'abstract': False, @@ -185,7 +187,7 @@ class Migration(migrations.Migration): ('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(on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL)), + ('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', @@ -217,13 +219,14 @@ class Migration(migrations.Migration): ('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')], 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.DateField(blank=True, null=True, verbose_name='Exam Date')), + ('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.DateField(blank=True, null=True, verbose_name='Interview Date')), + ('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')), ('interview_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], 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')), @@ -257,11 +260,12 @@ class Migration(migrations.Migration): ('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_start_date', models.DateField(blank=True, null=True)), ('application_deadline', models.DateField(blank=True, null=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(blank=True, choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20, null=True)), + ('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], 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')), @@ -271,7 +275,7 @@ class Migration(migrations.Migration): ('published_at', models.DateTimeField(blank=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)), - ('start_date', models.DateField(blank=True, help_text='Desired start date', null=True)), + ('joining_date', models.DateField(blank=True, help_text='Desired start date', null=True)), ('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')), ('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')), @@ -296,10 +300,10 @@ class Migration(migrations.Migration): ('working_days', models.JSONField(verbose_name='Working Days')), ('start_time', models.TimeField(verbose_name='Start Time')), ('end_time', models.TimeField(verbose_name='End Time')), + ('breaks', models.JSONField(blank=True, default=list, verbose_name='Break Times')), ('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')), ('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')), ('created_at', models.DateTimeField(auto_now_add=True)), - ('breaks', models.ManyToManyField(blank=True, related_name='schedules', to='recruitment.breaktime')), ('candidates', models.ManyToManyField(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')), @@ -322,8 +326,8 @@ class Migration(migrations.Migration): name='JobPostingImage', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('post_image', models.ImageField(upload_to='post/')), - ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')), + ('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( @@ -405,7 +409,7 @@ class Migration(migrations.Migration): ('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(on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')), + ('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')), ], options={ diff --git a/recruitment/migrations/0002_alter_jobposting_status.py b/recruitment/migrations/0002_alter_jobposting_status.py deleted file mode 100644 index d2fa6de..0000000 --- a/recruitment/migrations/0002_alter_jobposting_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-12 10:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='jobposting', - name='status', - field=models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20), - ), - ] diff --git a/recruitment/migrations/0002_candidate_is_potential_candidate_and_more.py b/recruitment/migrations/0002_candidate_is_potential_candidate_and_more.py deleted file mode 100644 index 66870d2..0000000 --- a/recruitment/migrations/0002_candidate_is_potential_candidate_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.6 on 2025-10-12 12:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='candidate', - name='is_potential_candidate', - field=models.BooleanField(default=False, verbose_name='Potential Candidate'), - ), - migrations.AlterField( - model_name='jobposting', - name='status', - field=models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20), - ), - ] diff --git a/recruitment/migrations/0003_alter_candidate_exam_date.py b/recruitment/migrations/0003_alter_candidate_exam_date.py deleted file mode 100644 index 8cada1f..0000000 --- a/recruitment/migrations/0003_alter_candidate_exam_date.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.6 on 2025-10-12 15:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0002_candidate_is_potential_candidate_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='candidate', - name='exam_date', - field=models.DateTimeField(blank=True, null=True, verbose_name='Exam Date'), - ), - ] diff --git a/recruitment/migrations/0003_rename_start_date_jobposting_joining_date_and_more.py b/recruitment/migrations/0003_rename_start_date_jobposting_joining_date_and_more.py deleted file mode 100644 index 36a4a08..0000000 --- a/recruitment/migrations/0003_rename_start_date_jobposting_joining_date_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-12 13:18 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0002_alter_jobposting_status'), - ] - - operations = [ - migrations.RenameField( - model_name='jobposting', - old_name='start_date', - new_name='joining_date', - ), - migrations.AddField( - model_name='jobposting', - name='application_start_date', - field=models.DateField(blank=True, null=True), - ), - ] diff --git a/recruitment/migrations/0004_alter_candidate_interview_date.py b/recruitment/migrations/0004_alter_candidate_interview_date.py deleted file mode 100644 index ec8feb1..0000000 --- a/recruitment/migrations/0004_alter_candidate_interview_date.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.6 on 2025-10-12 15:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0003_alter_candidate_exam_date'), - ] - - operations = [ - migrations.AlterField( - model_name='candidate', - name='interview_date', - field=models.DateTimeField(blank=True, null=True, verbose_name='Interview Date'), - ), - ] diff --git a/recruitment/migrations/0005_remove_interviewschedule_breaks_and_more.py b/recruitment/migrations/0005_remove_interviewschedule_breaks_and_more.py deleted file mode 100644 index 5784ef2..0000000 --- a/recruitment/migrations/0005_remove_interviewschedule_breaks_and_more.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.2.6 on 2025-10-12 21:21 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0004_alter_candidate_interview_date'), - ] - - operations = [ - migrations.RemoveField( - model_name='interviewschedule', - name='breaks', - ), - migrations.AddField( - model_name='interviewschedule', - name='breaks', - field=models.JSONField(blank=True, default=list, verbose_name='Break Times'), - ), - ] diff --git a/recruitment/migrations/0006_zoommeeting_meeting_status.py b/recruitment/migrations/0006_zoommeeting_meeting_status.py deleted file mode 100644 index e80ac90..0000000 --- a/recruitment/migrations/0006_zoommeeting_meeting_status.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.6 on 2025-10-13 12:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0005_remove_interviewschedule_breaks_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='zoommeeting', - name='meeting_status', - field=models.CharField(choices=[('scheduled', 'Scheduled'), ('started', 'Started'), ('ended', 'Ended')], default='scheduled', max_length=20, verbose_name='Meeting Status'), - ), - ] diff --git a/recruitment/migrations/0007_remove_zoommeeting_meeting_status_zoommeeting_status.py b/recruitment/migrations/0007_remove_zoommeeting_meeting_status_zoommeeting_status.py deleted file mode 100644 index e171332..0000000 --- a/recruitment/migrations/0007_remove_zoommeeting_meeting_status_zoommeeting_status.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.2.6 on 2025-10-13 12:20 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0006_zoommeeting_meeting_status'), - ] - - operations = [ - migrations.RemoveField( - model_name='zoommeeting', - name='meeting_status', - ), - migrations.AddField( - model_name='zoommeeting', - name='status', - field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Status'), - ), - ] diff --git a/recruitment/migrations/0008_zoommeeting_password.py b/recruitment/migrations/0008_zoommeeting_password.py deleted file mode 100644 index 5420d0d..0000000 --- a/recruitment/migrations/0008_zoommeeting_password.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.6 on 2025-10-13 12:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0007_remove_zoommeeting_meeting_status_zoommeeting_status'), - ] - - operations = [ - migrations.AddField( - model_name='zoommeeting', - name='password', - field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Password'), - ), - ] diff --git a/recruitment/migrations/0009_merge_20251013_1714.py b/recruitment/migrations/0009_merge_20251013_1714.py deleted file mode 100644 index 25a93ae..0000000 --- a/recruitment/migrations/0009_merge_20251013_1714.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 5.2.6 on 2025-10-13 14:14 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0003_rename_start_date_jobposting_joining_date_and_more'), - ('recruitment', '0008_zoommeeting_password'), - ] - - operations = [ - ] diff --git a/recruitment/migrations/0009_merge_20251013_1718.py b/recruitment/migrations/0009_merge_20251013_1718.py deleted file mode 100644 index 43811e8..0000000 --- a/recruitment/migrations/0009_merge_20251013_1718.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-13 14:18 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0003_rename_start_date_jobposting_joining_date_and_more'), - ('recruitment', '0008_zoommeeting_password'), - ] - - operations = [ - ] diff --git a/recruitment/migrations/0010_alter_scheduledinterview_schedule.py b/recruitment/migrations/0010_alter_scheduledinterview_schedule.py deleted file mode 100644 index 0a24d5e..0000000 --- a/recruitment/migrations/0010_alter_scheduledinterview_schedule.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.2.6 on 2025-10-13 19:55 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0009_merge_20251013_1714'), - ] - - operations = [ - migrations.AlterField( - model_name='scheduledinterview', - name='schedule', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule'), - ), - ] diff --git a/recruitment/migrations/0010_merge_20251013_1819.py b/recruitment/migrations/0010_merge_20251013_1819.py deleted file mode 100644 index 6acb9b0..0000000 --- a/recruitment/migrations/0010_merge_20251013_1819.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-13 15:19 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0009_merge_20251013_1714'), - ('recruitment', '0009_merge_20251013_1718'), - ] - - operations = [ - ] diff --git a/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py b/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py deleted file mode 100644 index a961dd5..0000000 --- a/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py +++ /dev/null @@ -1,25 +0,0 @@ -# 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/migrations/0012_merge_20251014_1403.py b/recruitment/migrations/0012_merge_20251014_1403.py deleted file mode 100644 index 2827f2a..0000000 --- a/recruitment/migrations/0012_merge_20251014_1403.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 5.2.6 on 2025-10-14 11:03 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0010_alter_scheduledinterview_schedule'), - ('recruitment', '0011_alter_jobpostingimage_job_and_more'), - ] - - operations = [ - ] diff --git a/recruitment/migrations/0013_alter_formtemplate_created_by.py b/recruitment/migrations/0013_alter_formtemplate_created_by.py deleted file mode 100644 index cbdb0fb..0000000 --- a/recruitment/migrations/0013_alter_formtemplate_created_by.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.2.6 on 2025-10-14 11:24 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('recruitment', '0012_merge_20251014_1403'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterField( - model_name='formtemplate', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/recruitment/tasks.py b/recruitment/tasks.py index abfa760..b6c7b49 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -4,6 +4,11 @@ import logging import requests from PyPDF2 import PdfReader from recruitment.models import Candidate +from . linkedin_service import LinkedInService +from django.shortcuts import get_object_or_404 +from . models import JobPosting +from django.utils import timezone + logger = logging.getLogger(__name__) @@ -153,3 +158,39 @@ def handle_reume_parsing_and_scoring(pk): except Exception as e: logger.error(f"Failed to score resume for candidate {instance.id}: {e}") + + +def linkedin_post_task(job_slug, access_token): + # for linked post background tasks + + job=get_object_or_404(JobPosting,slug=job_slug) + + try: + service=LinkedInService() + service.access_token=access_token + # long running task + result=service.create_job_post(job) + + #update the jobposting object with the final result + if result['success']: + job.posted_to_linkedin=True + job.linkedin_post_id=result['post_id'] + job.linkedin_post_url=result['post_url'] + job.linkedin_post_status='SUCCESSS' + job.linkedin_posted_at=timezone.now() + else: + error_msg=result.get('error',"Unknown API error") + job.linkedin_post_status = 'FAILED' + logger.error(f"LinkedIn post failed for job {job_slug}: {error_msg}") + job.save() + return result['success'] + except Exception as e: + logger.error(f"Critical error in LinkedIn task for job {job_slug}: {e}", exc_info=True) + # Update job status with the critical error + job.linkedin_post_status = f"CRITICAL_ERROR: {str(e)}" + job.save() + return False + + + + diff --git a/recruitment/urls.py b/recruitment/urls.py index c9e9b9c..4838aee 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -109,6 +109,9 @@ urlpatterns = [ # users urls path('user/',views.user_detail,name='user_detail'), path('user/user_profile_image_update/',views.user_profile_image_update,name='user_profile_image_update'), - path('easy_logs/',views.easy_logs,name='easy_logs') + path('easy_logs/',views.easy_logs,name='easy_logs'), + path('settings/',views.admin_settings,name='admin_settings'), + path('staff/create',views.create_staff_user,name='create_staff_user'), + path('set_staff_password//',views.set_staff_password,name='set_staff_password') ] diff --git a/recruitment/validators.py b/recruitment/validators.py index 8648da6..6277b64 100644 --- a/recruitment/validators.py +++ b/recruitment/validators.py @@ -1,7 +1,7 @@ from django.core.exceptions import ValidationError def validate_image_size(image): - max_size_mb = 2 + max_size_mb = 1 if image.size > max_size_mb * 1024 * 1024: raise ValidationError(f"Image size should not exceed {max_size_mb}MB.") diff --git a/recruitment/views.py b/recruitment/views.py index e356d06..48c8715 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -22,6 +22,7 @@ from .forms import ( BreakTimeFormSet, JobPostingImageForm, ProfileImageUploadForm, + StaffUserCreationForm ) from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent @@ -56,7 +57,8 @@ from .models import ( Candidate, JobPosting, ScheduledInterview, - JobPostingImage + JobPostingImage, + Profile ) import logging from datastar_py.django import ( @@ -321,8 +323,12 @@ def job_detail(request, slug): status_form = JobPostingStatusForm(instance=job) - image_upload_form=JobPostingImageForm(instance=job.post_images) - + try: + # If the related object exists, use its instance data + image_upload_form = JobPostingImageForm(instance=job.post_images) + except Exception as e: + # If the related object does NOT exist, create a blank form + image_upload_form = JobPostingImageForm() # 2. Check for POST request (Status Update Submission) @@ -409,6 +415,8 @@ def job_detail_candidate(request, slug): return render(request, "forms/job_detail_candidate.html", {"job": job}) +from django_q.tasks import async_task + def post_to_linkedin(request, slug): """Post a job to LinkedIn""" job = get_object_or_404(JobPosting, slug=slug) @@ -417,47 +425,39 @@ def post_to_linkedin(request, slug): return redirect("job_list") if request.method == "POST": - try: - # Check if user is authenticated with LinkedIn - if "linkedin_access_token" not in request.session: + linkedin_access_token=request.session.get("linkedin_access_token") + # Check if user is authenticated with LinkedIn + if not "linkedin_access_token": messages.error(request, "Please authenticate with LinkedIn first.") return redirect("linkedin_login") - + try: + # Clear previous LinkedIn data for re-posting + #Prepare the job object for background processing job.posted_to_linkedin = False job.linkedin_post_id = "" job.linkedin_post_url = "" - job.linkedin_post_status = "" + job.linkedin_post_status = "QUEUED" job.linkedin_posted_at = None job.save() + + # ENQUEUE THE TASK + # Pass the function path, the job slug, and the token as arguments - # Initialize LinkedIn service - service = LinkedInService() - service.access_token = request.session["linkedin_access_token"] + async_task( + 'recruitment.tasks.linkedin_post_task', + job.slug, + linkedin_access_token + ) - # Post to LinkedIn - result = service.create_job_post(job) - if result["success"]: - # Update job with LinkedIn info - job.posted_to_linkedin = True - job.linkedin_post_id = result["post_id"] - job.linkedin_post_url = result["post_url"] - job.linkedin_post_status = "SUCCESS" - job.linkedin_posted_at = timezone.now() - job.save() - - messages.success(request, "Job posted to LinkedIn successfully!") - else: - error_msg = result.get("error", "Unknown error") - job.linkedin_post_status = f"ERROR: {error_msg}" - job.save() - messages.error(request, f"Error posting to LinkedIn: {error_msg}") + messages.success( + request, + _(f"✅ Job posting process for job with JOB ID: {job.internal_job_id} started! Check the job details page in a moment for the final status.") + ) except Exception as e: - logger.error(f"Error in post_to_linkedin: {e}") - job.linkedin_post_status = f"ERROR: {str(e)}" - job.save() - messages.error(request, f"Error posting to LinkedIn: {e}") + logger.error(f"Error enqueuing LinkedIn post: {e}") + messages.error(request, _("Failed to start the job posting process. Please try again.")) return redirect("job_detail", slug=job.slug) @@ -2355,9 +2355,15 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): }) +from django.core.exceptions import ObjectDoesNotExist def user_profile_image_update(request, pk): user = get_object_or_404(User, pk=pk) + try: + instance =user.profile + + except ObjectDoesNotExist as e: + Profile.objects.create(user=user) if request.method == 'POST': profile_form = ProfileImageUploadForm(request.POST, request.FILES, instance=user.profile) @@ -2380,7 +2386,9 @@ def user_profile_image_update(request, pk): def user_detail(request, pk): user = get_object_or_404(User, pk=pk) - profile_form = ProfileImageUploadForm(instance=user.profile) + + + profile_form = ProfileImageUploadForm() if request.method == 'POST': first_name=request.POST.get('first_name') last_name=request.POST.get('last_name') @@ -2406,7 +2414,7 @@ def easy_logs(request): """ logs_per_page = 20 - # 1. Determine the active tab and the corresponding model/queryset + active_tab = request.GET.get('tab', 'crud') if active_tab == 'login': @@ -2415,23 +2423,23 @@ def easy_logs(request): elif active_tab == 'request': queryset = RequestEvent.objects.order_by('-datetime') tab_title = _("HTTP Requests") - else: # Default is 'crud' + else: queryset = CRUDEvent.objects.order_by('-datetime') tab_title = _("Model Changes (CRUD)") active_tab = 'crud' - # 2. Apply Pagination + paginator = Paginator(queryset, logs_per_page) page = request.GET.get('page') try: - # Get the page object for the requested page number + logs_page = paginator.page(page) except PageNotAnInteger: - # If page is not an integer, deliver first page. + logs_page = paginator.page(1) except EmptyPage: - # If page is out of range, deliver last page of results. + logs_page = paginator.page(paginator.num_pages) context = { @@ -2443,3 +2451,63 @@ def easy_logs(request): return render(request, "includes/easy_logs.html", context) + + +from allauth.account.views import SignupView +from django.contrib.auth.decorators import user_passes_test + +def is_superuser_check(user): + return user.is_superuser + + +@user_passes_test(is_superuser_check) +def create_staff_user(request): + if request.method == 'POST': + + form = StaffUserCreationForm(request.POST) + print(form) + if form.is_valid(): + form.save() + messages.success( + request, + f"Staff user {form.cleaned_data['first_name']} {form.cleaned_data['last_name']} " + f"({form.cleaned_data['email']}) created successfully!" + ) + return redirect('admin_settings') + else: + form = StaffUserCreationForm() + return render(request, 'user/create_staff.html', {'form': form}) + + + + + + +@user_passes_test(is_superuser_check) +def admin_settings(request): + staffs=User.objects.filter(is_superuser=False) + context={ + 'staffs':staffs + } + return render(request,'user/admin_settings.html',context) + + +from django.contrib.auth.forms import SetPasswordForm + + +def set_staff_password(request,pk): + user=get_object_or_404(User,pk=pk) + print(request.POST) + if request.method=='POST': + form = SetPasswordForm(user, data=request.POST) + if form.is_valid(): + form.save() + messages.success(request,f'Password successfully changed') + else: + form=SetPasswordForm(user=user) + messages.error(request,f'Password does not match please try again.') + return redirect('set_staff_password',user=user) + + else: + form=SetPasswordForm(user=user) + return render(request,'user/staff_password_create.html',{'form':form,'user':user}) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 98ed8b4..5fee9fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -142,4 +142,5 @@ PyMuPDF pytesseract Pillow python-dotenv -django-countries \ No newline at end of file +django-countries +django-q2 \ No newline at end of file diff --git a/templates/account/signup.html b/templates/account/signup.html deleted file mode 100644 index e69de29..0000000 diff --git a/templates/base.html b/templates/base.html index 5651b7e..cdccb91 100644 --- a/templates/base.html +++ b/templates/base.html @@ -145,10 +145,14 @@
  • {% if request.user.is_authenticated %}
  • {% trans "My Profile" %}
  • + + + {% if request.user.is_superuser %} +
  • {% trans "Settings" %}
  • +
  • {% trans "Activity Log" %}
  • +
  • {% trans "Help & Support" %}
  • + {% endif %} {% endif %} -
  • {% trans "Settings" %}
  • -
  • {% trans "Activity Log" %}
  • -
  • {% trans "Help & Support" %}
  • {% comment %} CORRECTED LINKEDIN BLOCK {% endcomment %} {% if not request.session.linkedin_authenticated %} @@ -252,7 +256,7 @@
    {% if messages %} {% for message in messages %} -