diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad516dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.pyc +*.pyd +*.pyo + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# Django stuff: +*.log +*.pot +*.sqlite3 +local_settings.py +db.sqlite3 + +# Virtual environment +venv/ +env/ + +# IDE files +.idea/ +.vscode/ +*.swp +*.bak +*.swo + +# OS generated files +.DS_Store +Thumbs.db + +# Testing +.tox/ +.coverage +.pytest_cache/ +htmlcov/ + +# Media and Static files (if served locally and not meant for version control) +media/ +static/ + +# Deployment files +*.tar.gz +*.zip \ No newline at end of file diff --git a/NorahUniversity/__init__.py b/NorahUniversity/__init__.py index e69de29..a02b1a7 100644 --- a/NorahUniversity/__init__.py +++ b/NorahUniversity/__init__.py @@ -0,0 +1,10 @@ +# to make sure that the celery loads whenever in run my project +#Celery app is loaded and configured as soon as Django starts. + +from .celery import app as celery_app + + +# so that the @shared_task decorator will use this app in all the tasks.py files +__all__ = ('celery_app',) + + diff --git a/NorahUniversity/__pycache__/__init__.cpython-312.pyc b/NorahUniversity/__pycache__/__init__.cpython-312.pyc index dadc35c..2c0dca1 100644 Binary files a/NorahUniversity/__pycache__/__init__.cpython-312.pyc and b/NorahUniversity/__pycache__/__init__.cpython-312.pyc differ diff --git a/NorahUniversity/__pycache__/celery.cpython-312.pyc b/NorahUniversity/__pycache__/celery.cpython-312.pyc new file mode 100644 index 0000000..82f0ca8 Binary files /dev/null and b/NorahUniversity/__pycache__/celery.cpython-312.pyc differ diff --git a/NorahUniversity/__pycache__/settings.cpython-312.pyc b/NorahUniversity/__pycache__/settings.cpython-312.pyc index 910ff06..99c93be 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-312.pyc and b/NorahUniversity/__pycache__/settings.cpython-312.pyc differ diff --git a/NorahUniversity/__pycache__/urls.cpython-312.pyc b/NorahUniversity/__pycache__/urls.cpython-312.pyc index 44636bf..9cb4a11 100644 Binary files a/NorahUniversity/__pycache__/urls.cpython-312.pyc and b/NorahUniversity/__pycache__/urls.cpython-312.pyc differ diff --git a/NorahUniversity/__pycache__/wsgi.cpython-312.pyc b/NorahUniversity/__pycache__/wsgi.cpython-312.pyc index 4bf53b5..f5f09cb 100644 Binary files a/NorahUniversity/__pycache__/wsgi.cpython-312.pyc and b/NorahUniversity/__pycache__/wsgi.cpython-312.pyc differ diff --git a/NorahUniversity/celery.py b/NorahUniversity/celery.py new file mode 100644 index 0000000..ffefeb6 --- /dev/null +++ b/NorahUniversity/celery.py @@ -0,0 +1,23 @@ +import os +from celery import Celery + + +# to tell the celery program which is seperate from where to find our Django projects settings +os.environ.setdefault('DJANGO_SETTINGS_MODULE','NorahUniversity.settings') + + +# create a Celery app instance + +app=Celery('NorahUniversity') + + + +# load the celery app connfiguration from the projects settings: + +app.config_from_object('django.conf:settings',namespace='CELERY') + + + # Auto discover the tasks from the django apps: + +app.autodiscover_tasks() + diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index cee4284..962943d 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -38,7 +38,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', - 'recruitment', + 'recruitment.apps.RecruitmentConfig', 'corsheaders', 'django.contrib.sites', 'allauth', @@ -51,6 +51,7 @@ INSTALLED_APPS = [ 'crispy_bootstrap5', 'django_extensions', 'template_partials', + 'django_countries' ] SITE_ID = 1 @@ -190,15 +191,6 @@ SOCIALACCOUNT_PROVIDERS = { } } -UNFOLD = { - "DASHBOARD_CALLBACK": "recruitment.utils.dashboard_callback", - "STYLES": [ - lambda request: static("unfold/css/styles.css"), - ], - "SCRIPTS": [ - lambda request: static("unfold/js/app.js"), - ], -} ZOOM_ACCOUNT_ID = 'HoGikHXsQB2GNDC5Rvyw9A' ZOOM_CLIENT_ID = 'brC39920R8C8azfudUaQgA' @@ -209,4 +201,16 @@ SECRET_TOKEN = '6KdTGyF0SSCSL_V4Xa34aw' DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB -CORS_ALLOW_CREDENTIALS = True \ No newline at end of file +CORS_ALLOW_CREDENTIALS = True + + +# Celery + Redis for long running background i will be using it +CELERY_BROKER_URL = 'redis://127.0.0.1:6379/0' +CELERY_RESULT_BACKEND = 'redis://127.0.0.1:6379/0' + + + + +LINKEDIN_CLIENT_ID = '867jwsiyem1504' +LINKEDIN_CLIENT_SECRET = 'WPL_AP1.QNH5lYnfRSQpp0Qp.GO8Srw==' +LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/jobs/linkedin/callback/' diff --git a/db.sqlite3 b/db.sqlite3 index d0de1b9..064b297 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/recruitment/__pycache__/__init__.cpython-312.pyc b/recruitment/__pycache__/__init__.cpython-312.pyc index e783af7..c8fbc2d 100644 Binary files a/recruitment/__pycache__/__init__.cpython-312.pyc and b/recruitment/__pycache__/__init__.cpython-312.pyc differ diff --git a/recruitment/__pycache__/admin.cpython-312.pyc b/recruitment/__pycache__/admin.cpython-312.pyc index 2e36972..7e73a6f 100644 Binary files a/recruitment/__pycache__/admin.cpython-312.pyc and b/recruitment/__pycache__/admin.cpython-312.pyc differ diff --git a/recruitment/__pycache__/apps.cpython-312.pyc b/recruitment/__pycache__/apps.cpython-312.pyc index 407f8f0..f3d65f3 100644 Binary files a/recruitment/__pycache__/apps.cpython-312.pyc and b/recruitment/__pycache__/apps.cpython-312.pyc differ diff --git a/recruitment/__pycache__/forms.cpython-312.pyc b/recruitment/__pycache__/forms.cpython-312.pyc index df4ec17..a421df6 100644 Binary files a/recruitment/__pycache__/forms.cpython-312.pyc and b/recruitment/__pycache__/forms.cpython-312.pyc differ diff --git a/recruitment/__pycache__/linkedin_service.cpython-312.pyc b/recruitment/__pycache__/linkedin_service.cpython-312.pyc new file mode 100644 index 0000000..8350c01 Binary files /dev/null and b/recruitment/__pycache__/linkedin_service.cpython-312.pyc differ diff --git a/recruitment/__pycache__/models.cpython-312.pyc b/recruitment/__pycache__/models.cpython-312.pyc index 87b71ba..f67832d 100644 Binary files a/recruitment/__pycache__/models.cpython-312.pyc and b/recruitment/__pycache__/models.cpython-312.pyc differ diff --git a/recruitment/__pycache__/serializers.cpython-312.pyc b/recruitment/__pycache__/serializers.cpython-312.pyc index 2e912aa..fed459d 100644 Binary files a/recruitment/__pycache__/serializers.cpython-312.pyc and b/recruitment/__pycache__/serializers.cpython-312.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-312.pyc b/recruitment/__pycache__/signals.cpython-312.pyc new file mode 100644 index 0000000..0af7a5b Binary files /dev/null and b/recruitment/__pycache__/signals.cpython-312.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-312.pyc b/recruitment/__pycache__/urls.cpython-312.pyc index 6078647..8fd07cf 100644 Binary files a/recruitment/__pycache__/urls.cpython-312.pyc and b/recruitment/__pycache__/urls.cpython-312.pyc differ diff --git a/recruitment/__pycache__/utils.cpython-312.pyc b/recruitment/__pycache__/utils.cpython-312.pyc index 49355f7..fc89d14 100644 Binary files a/recruitment/__pycache__/utils.cpython-312.pyc and b/recruitment/__pycache__/utils.cpython-312.pyc differ diff --git a/recruitment/__pycache__/validators.cpython-312.pyc b/recruitment/__pycache__/validators.cpython-312.pyc new file mode 100644 index 0000000..70d42fc Binary files /dev/null and b/recruitment/__pycache__/validators.cpython-312.pyc differ diff --git a/recruitment/__pycache__/views.cpython-312.pyc b/recruitment/__pycache__/views.cpython-312.pyc index 7d76d56..cb165ec 100644 Binary files a/recruitment/__pycache__/views.cpython-312.pyc and b/recruitment/__pycache__/views.cpython-312.pyc differ diff --git a/recruitment/__pycache__/views_frontend.cpython-312.pyc b/recruitment/__pycache__/views_frontend.cpython-312.pyc index 1f7b1ed..7ee2519 100644 Binary files a/recruitment/__pycache__/views_frontend.cpython-312.pyc and b/recruitment/__pycache__/views_frontend.cpython-312.pyc differ diff --git a/recruitment/apps.py b/recruitment/apps.py index d9396da..807e16a 100644 --- a/recruitment/apps.py +++ b/recruitment/apps.py @@ -4,3 +4,5 @@ from django.apps import AppConfig class RecruitmentConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'recruitment' + def ready(self): + import recruitment.signals diff --git a/recruitment/forms.py b/recruitment/forms.py index 4c6ad9b..9faadaa 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -4,7 +4,7 @@ from crispy_forms.helper import FormHelper from django.core.validators import URLValidator from django.utils.translation import gettext_lazy as _ from crispy_forms.layout import Layout, Submit, HTML, Div, Field -from .models import ZoomMeeting, Candidate,Job,TrainingMaterial,JobPosting +from .models import ZoomMeeting, Candidate,TrainingMaterial,JobPosting class CandidateForm(forms.ModelForm): class Meta: diff --git a/recruitment/linkedin_service.py b/recruitment/linkedin_service.py index 49877a1..d1fb4f8 100644 --- a/recruitment/linkedin_service.py +++ b/recruitment/linkedin_service.py @@ -248,77 +248,7 @@ class LinkedInService: 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500 } - # def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn): - # """Step 3: Create post with uploaded image""" - # 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' - # } - - # # Build the same message as before - # 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 = "\n".join(message_parts) - - # # Create image post payload - # 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 - # }] - # } - # }, - # "visibility": { - # "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC" - # } - # } - - # response = requests.post(url, headers=headers, json=payload, timeout=30) - # 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 hashtags_list(self,hash_tags_str): """Convert comma-separated hashtags string to list""" @@ -331,222 +261,4 @@ class LinkedInService: return tags - # def create_job_post(self, job_posting): - # """Create a job announcement post on LinkedIn (with image support)""" - # 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 - # try: - # image_upload = job_posting.files.first() - # has_image = image_upload and image_upload.linkedinpost_image - # except Exception: - # has_image = False - - # if has_image: - # # === POST WITH IMAGE === - # upload_info = self.register_image_upload(person_urn) - # self.upload_image_to_linkedin( - # upload_info['upload_url'], - # image_upload.linkedinpost_image - # ) - # return self.create_job_post_with_image( - # job_posting, - # image_upload.linkedinpost_image, - # person_urn, - # upload_info['asset'] - # ) - - # else: - # # === 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)})" - - # 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) # π₯ Add 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}', - # '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": "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} - # }] - # } - # }, - # "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', '') - # # π₯ FIX POST URL - REMOVE TRAILING SPACES π₯ - # 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 - # } - - # except Exception as e: - # logger.error(f"Error creating LinkedIn post: {e}") - # return { - # 'success': False, - # 'error': str(e), - # 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500 - # } - # def create_job_post(self, job_posting): - # """Create a job announcement post on LinkedIn""" - # 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: # uniform resource name used to uniquely identify linked-id for internal systems and apis - # raise Exception("Could not retrieve LinkedIn user ID") - - # # Build professional job post message - # 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}") - - # # Add job details - # 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)) - - # # Add application link - # if job_posting.application_url: - # message_parts.append(f"\nπ **Apply now:** {job_posting.application_url}") - - # # Add hashtags - # hashtags = ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"] - # if job_posting.department: - # dept_hashtag = f"#{job_posting.department.replace(' ', '')}" - # hashtags.insert(0, dept_hashtag) - - # message_parts.append("\n\n" + " ".join(hashtags)) - # message = "\n".join(message_parts) - - # # Create LinkedIn post - # 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": "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} - # }] - # } - # }, - # "visibility": { - # "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC" - # } - # } - - # response = requests.post(url, headers=headers, json=payload, timeout=60) - # response.raise_for_status() - - # # Extract post ID from response - # 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 - # } - - # except Exception as e: - # logger.error(f"Error creating LinkedIn post: {e}") - # return { - # 'success': False, - # 'error': str(e), - # 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500 - # } \ No newline at end of file + \ No newline at end of file diff --git a/recruitment/management/__pycache__/__init__.cpython-312.pyc b/recruitment/management/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..7308a1c Binary files /dev/null and b/recruitment/management/__pycache__/__init__.cpython-312.pyc differ diff --git a/recruitment/migrations/0013_candidate_criteria_checklist_candidate_match_score_and_more.py b/recruitment/migrations/0013_candidate_criteria_checklist_candidate_match_score_and_more.py new file mode 100644 index 0000000..b1cfc32 --- /dev/null +++ b/recruitment/migrations/0013_candidate_criteria_checklist_candidate_match_score_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.7 on 2025-10-05 13:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0012_form_formsubmission_uploadedfile'), + ] + + operations = [ + migrations.AddField( + model_name='candidate', + name='criteria_checklist', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='candidate', + name='match_score', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='candidate', + name='strengths', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='candidate', + name='weaknesses', + field=models.TextField(blank=True), + ), + ] diff --git a/recruitment/migrations/0014_source_jobposting_source.py b/recruitment/migrations/0014_source_jobposting_source.py new file mode 100644 index 0000000..cfda728 --- /dev/null +++ b/recruitment/migrations/0014_source_jobposting_source.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.7 on 2025-10-05 16:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0013_candidate_criteria_checklist_candidate_match_score_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Source', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(choices=[('ATS', 'Applicant Tracking System'), ('ERP', 'ERP system')], max_length=100, verbose_name='Source Type')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Source', + 'verbose_name_plural': 'Sources', + }, + ), + migrations.AddField( + model_name='jobposting', + name='source', + field=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'), + ), + ] diff --git a/recruitment/migrations/0015_hiringagency_candidate_submitted_by_agency_and_more.py b/recruitment/migrations/0015_hiringagency_candidate_submitted_by_agency_and_more.py new file mode 100644 index 0000000..91ce2f3 --- /dev/null +++ b/recruitment/migrations/0015_hiringagency_candidate_submitted_by_agency_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.7 on 2025-10-05 16:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0014_source_jobposting_source'), + ] + + operations = [ + migrations.CreateModel( + name='HiringAgency', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Hiring Agency', + 'verbose_name_plural': 'Hiring Agencies', + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='candidate', + name='submitted_by_agency', + field=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'), + ), + migrations.AddField( + model_name='jobposting', + name='hiring_agency', + field=models.ForeignKey(blank=True, help_text='External agency responsible for sourcing candidates for this role', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency'), + ), + ] diff --git a/recruitment/migrations/0016_alter_source_options_hiringagency_address_and_more.py b/recruitment/migrations/0016_alter_source_options_hiringagency_address_and_more.py new file mode 100644 index 0000000..7354ad0 --- /dev/null +++ b/recruitment/migrations/0016_alter_source_options_hiringagency_address_and_more.py @@ -0,0 +1,58 @@ +# Generated by Django 5.2.7 on 2025-10-06 10:48 + +import django_countries.fields +import django_extensions.db.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0015_hiringagency_candidate_submitted_by_agency_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='source', + options={'ordering': ['name'], 'verbose_name': 'Source', 'verbose_name_plural': 'Sources'}, + ), + migrations.AddField( + model_name='hiringagency', + name='address', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='hiringagency', + name='country', + field=django_countries.fields.CountryField(blank=True, max_length=2, null=True), + ), + migrations.AddField( + model_name='hiringagency', + name='slug', + field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'), + ), + migrations.AlterField( + model_name='hiringagency', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'), + ), + migrations.AlterField( + model_name='hiringagency', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Updated at'), + ), + migrations.RemoveField( + model_name='jobposting', + name='hiring_agency', + ), + migrations.AlterField( + model_name='source', + name='name', + field=models.CharField(help_text='e.g., ATS, ERP ', max_length=100, unique=True, verbose_name='Source Name'), + ), + migrations.AddField( + model_name='jobposting', + name='hiring_agency', + field=models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', null=True, related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency'), + ), + ] diff --git a/recruitment/migrations/0017_alter_jobposting_hiring_agency.py b/recruitment/migrations/0017_alter_jobposting_hiring_agency.py new file mode 100644 index 0000000..da4e051 --- /dev/null +++ b/recruitment/migrations/0017_alter_jobposting_hiring_agency.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-10-06 10:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0016_alter_source_options_hiringagency_address_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='jobposting', + name='hiring_agency', + field=models.ManyToManyField(help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency'), + ), + ] diff --git a/recruitment/migrations/0018_alter_jobposting_hiring_agency.py b/recruitment/migrations/0018_alter_jobposting_hiring_agency.py new file mode 100644 index 0000000..07c349f --- /dev/null +++ b/recruitment/migrations/0018_alter_jobposting_hiring_agency.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-10-06 11:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0017_alter_jobposting_hiring_agency'), + ] + + operations = [ + migrations.AlterField( + model_name='jobposting', + name='hiring_agency', + field=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'), + ), + ] diff --git a/recruitment/migrations/0019_merge_20251006_1224.py b/recruitment/migrations/0019_merge_20251006_1224.py new file mode 100644 index 0000000..a706fa2 --- /dev/null +++ b/recruitment/migrations/0019_merge_20251006_1224.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.7 on 2025-10-06 12:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0013_formfield_formstage_remove_formsubmission_form_and_more'), + ('recruitment', '0018_alter_jobposting_hiring_agency'), + ] + + operations = [ + ] diff --git a/recruitment/migrations/__pycache__/0001_initial.cpython-312.pyc b/recruitment/migrations/__pycache__/0001_initial.cpython-312.pyc index 16890ed..0a882a2 100644 Binary files a/recruitment/migrations/__pycache__/0001_initial.cpython-312.pyc and b/recruitment/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0002_trainingmaterial.cpython-312.pyc b/recruitment/migrations/__pycache__/0002_trainingmaterial.cpython-312.pyc index 3caa555..a68d9e1 100644 Binary files a/recruitment/migrations/__pycache__/0002_trainingmaterial.cpython-312.pyc and b/recruitment/migrations/__pycache__/0002_trainingmaterial.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0003_candidate_updated_at_job_updated_at_and_more.cpython-312.pyc b/recruitment/migrations/__pycache__/0003_candidate_updated_at_job_updated_at_and_more.cpython-312.pyc index 7837ca3..6b5a791 100644 Binary files a/recruitment/migrations/__pycache__/0003_candidate_updated_at_job_updated_at_and_more.cpython-312.pyc and b/recruitment/migrations/__pycache__/0003_candidate_updated_at_job_updated_at_and_more.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0004_remove_candidate_status_candidate_applied.cpython-312.pyc b/recruitment/migrations/__pycache__/0004_remove_candidate_status_candidate_applied.cpython-312.pyc index 6f2e485..5c2f438 100644 Binary files a/recruitment/migrations/__pycache__/0004_remove_candidate_status_candidate_applied.cpython-312.pyc and b/recruitment/migrations/__pycache__/0004_remove_candidate_status_candidate_applied.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0005_zoommeeting.cpython-312.pyc b/recruitment/migrations/__pycache__/0005_zoommeeting.cpython-312.pyc new file mode 100644 index 0000000..f5fcc8a Binary files /dev/null and b/recruitment/migrations/__pycache__/0005_zoommeeting.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0006_jobposting_alter_candidate_options_alter_job_options_and_more.cpython-312.pyc b/recruitment/migrations/__pycache__/0006_jobposting_alter_candidate_options_alter_job_options_and_more.cpython-312.pyc new file mode 100644 index 0000000..ab445a7 Binary files /dev/null and b/recruitment/migrations/__pycache__/0006_jobposting_alter_candidate_options_alter_job_options_and_more.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0007_alter_jobposting_status.cpython-312.pyc b/recruitment/migrations/__pycache__/0007_alter_jobposting_status.cpython-312.pyc new file mode 100644 index 0000000..35501e4 Binary files /dev/null and b/recruitment/migrations/__pycache__/0007_alter_jobposting_status.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0008_jobposting_published_at_alter_jobposting_status.cpython-312.pyc b/recruitment/migrations/__pycache__/0008_jobposting_published_at_alter_jobposting_status.cpython-312.pyc new file mode 100644 index 0000000..3da5401 Binary files /dev/null and b/recruitment/migrations/__pycache__/0008_jobposting_published_at_alter_jobposting_status.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0009_candidate_slug_job_slug_jobposting_slug_and_more.cpython-312.pyc b/recruitment/migrations/__pycache__/0009_candidate_slug_job_slug_jobposting_slug_and_more.cpython-312.pyc new file mode 100644 index 0000000..594a752 Binary files /dev/null and b/recruitment/migrations/__pycache__/0009_candidate_slug_job_slug_jobposting_slug_and_more.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0010_remove_candidate_name.cpython-312.pyc b/recruitment/migrations/__pycache__/0010_remove_candidate_name.cpython-312.pyc new file mode 100644 index 0000000..fda42e8 Binary files /dev/null and b/recruitment/migrations/__pycache__/0010_remove_candidate_name.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0011_alter_candidate_stage.cpython-312.pyc b/recruitment/migrations/__pycache__/0011_alter_candidate_stage.cpython-312.pyc new file mode 100644 index 0000000..b9294cd Binary files /dev/null and b/recruitment/migrations/__pycache__/0011_alter_candidate_stage.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0012_form_formsubmission_uploadedfile.cpython-312.pyc b/recruitment/migrations/__pycache__/0012_form_formsubmission_uploadedfile.cpython-312.pyc new file mode 100644 index 0000000..372cae6 Binary files /dev/null and b/recruitment/migrations/__pycache__/0012_form_formsubmission_uploadedfile.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0013_candidate_criteria_checklist_candidate_match_score_and_more.cpython-312.pyc b/recruitment/migrations/__pycache__/0013_candidate_criteria_checklist_candidate_match_score_and_more.cpython-312.pyc new file mode 100644 index 0000000..41656b9 Binary files /dev/null and b/recruitment/migrations/__pycache__/0013_candidate_criteria_checklist_candidate_match_score_and_more.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0013_formfield_formstage_remove_formsubmission_form_and_more.cpython-312.pyc b/recruitment/migrations/__pycache__/0013_formfield_formstage_remove_formsubmission_form_and_more.cpython-312.pyc new file mode 100644 index 0000000..4c63586 Binary files /dev/null and b/recruitment/migrations/__pycache__/0013_formfield_formstage_remove_formsubmission_form_and_more.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0014_source_jobposting_source.cpython-312.pyc b/recruitment/migrations/__pycache__/0014_source_jobposting_source.cpython-312.pyc new file mode 100644 index 0000000..d3c978a Binary files /dev/null and b/recruitment/migrations/__pycache__/0014_source_jobposting_source.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0015_hiringagency_candidate_submitted_by_agency_and_more.cpython-312.pyc b/recruitment/migrations/__pycache__/0015_hiringagency_candidate_submitted_by_agency_and_more.cpython-312.pyc new file mode 100644 index 0000000..6ff14ba Binary files /dev/null and b/recruitment/migrations/__pycache__/0015_hiringagency_candidate_submitted_by_agency_and_more.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0016_alter_source_options_hiringagency_address_and_more.cpython-312.pyc b/recruitment/migrations/__pycache__/0016_alter_source_options_hiringagency_address_and_more.cpython-312.pyc new file mode 100644 index 0000000..a5f4837 Binary files /dev/null and b/recruitment/migrations/__pycache__/0016_alter_source_options_hiringagency_address_and_more.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0017_alter_jobposting_hiring_agency.cpython-312.pyc b/recruitment/migrations/__pycache__/0017_alter_jobposting_hiring_agency.cpython-312.pyc new file mode 100644 index 0000000..250d210 Binary files /dev/null and b/recruitment/migrations/__pycache__/0017_alter_jobposting_hiring_agency.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0018_alter_jobposting_hiring_agency.cpython-312.pyc b/recruitment/migrations/__pycache__/0018_alter_jobposting_hiring_agency.cpython-312.pyc new file mode 100644 index 0000000..a339fa6 Binary files /dev/null and b/recruitment/migrations/__pycache__/0018_alter_jobposting_hiring_agency.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/0019_merge_20251006_1224.cpython-312.pyc b/recruitment/migrations/__pycache__/0019_merge_20251006_1224.cpython-312.pyc new file mode 100644 index 0000000..7591704 Binary files /dev/null and b/recruitment/migrations/__pycache__/0019_merge_20251006_1224.cpython-312.pyc differ diff --git a/recruitment/migrations/__pycache__/__init__.cpython-312.pyc b/recruitment/migrations/__pycache__/__init__.cpython-312.pyc index 98ca403..c4ac56c 100644 Binary files a/recruitment/migrations/__pycache__/__init__.cpython-312.pyc and b/recruitment/migrations/__pycache__/__init__.cpython-312.pyc differ diff --git a/recruitment/models.py b/recruitment/models.py index 3fad9b8..e1a6159 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -6,6 +6,7 @@ from django.core.validators import URLValidator from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import RandomCharField from django.core.exceptions import ValidationError +from django_countries.fields import CountryField class Base(models.Model): created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at')) @@ -15,22 +16,22 @@ class Base(models.Model): class Meta: abstract = True -# Create your models here. -class Job(Base): - title = models.CharField(max_length=255, verbose_name=_('Title')) - description_en = models.TextField(verbose_name=_('Description English')) - description_ar = models.TextField(verbose_name=_('Description Arabic')) - is_published = models.BooleanField(default=False, verbose_name=_('Published')) - posted_to_linkedin = models.BooleanField(default=False, verbose_name=_('Posted to LinkedIn')) - created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at')) - updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated at')) +# # Create your models here. +# class Job(Base): +# title = models.CharField(max_length=255, verbose_name=_('Title')) +# description_en = models.TextField(verbose_name=_('Description English')) +# description_ar = models.TextField(verbose_name=_('Description Arabic')) +# is_published = models.BooleanField(default=False, verbose_name=_('Published')) +# posted_to_linkedin = models.BooleanField(default=False, verbose_name=_('Posted to LinkedIn')) +# created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at')) +# updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated at')) - class Meta: - verbose_name = _('Job') - verbose_name_plural = _('Jobs') +# class Meta: +# verbose_name = _('Job') +# verbose_name_plural = _('Jobs') - def __str__(self): - return self.title +# def __str__(self): +# return self.title class JobPosting(Base): # Basic Job Information @@ -103,6 +104,23 @@ class JobPosting(Base): start_date = models.DateField(null=True, blank=True, help_text="Desired start date") open_positions = models.PositiveIntegerField(default=1, help_text="Number of open positions for this job") + source = models.ForeignKey( + 'Source', + on_delete=models.SET_NULL, # Recommended: If a source is deleted, job's source is set to NULL + related_name='job_postings', + null=True, + blank=True, + help_text="The system or channel from which this job posting originated or was first published." + ) + + hiring_agency = models.ManyToManyField( + 'HiringAgency', + blank=True, + related_name='jobs', + verbose_name=_('Hiring Agency'), + help_text=_("External agency responsible for sourcing candidates for this role") + ) + class Meta: ordering = ['-created_at'] verbose_name = "Job Posting" @@ -114,7 +132,7 @@ class JobPosting(Base): def save(self, *args, **kwargs): # Generate unique internal job ID if not exists if not self.internal_job_id: - prefix = "UNIV" + prefix = "KAAUH" year = timezone.now().year # Get next sequential number last_job = JobPosting.objects.filter( @@ -188,6 +206,22 @@ class Candidate(Base): offer_status = models.CharField(choices=Status.choices,max_length=100, null=True, blank=True, verbose_name=_('Offer Status')) join_date = models.DateField(null=True, blank=True, verbose_name=_('Join Date')) + # Scoring fields (populated by signal) + match_score = models.IntegerField(null=True, blank=True) + strengths = models.TextField(blank=True) + weaknesses = models.TextField(blank=True) + criteria_checklist = models.JSONField(default=dict, blank=True) + + + submitted_by_agency = models.ForeignKey( + 'HiringAgency', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='submitted_candidates', + verbose_name=_('Submitted by Agency') + ) + class Meta: verbose_name = _('Candidate') verbose_name_plural = _('Candidates') @@ -472,4 +506,43 @@ class SharedFormTemplate(models.Model): verbose_name_plural = 'Shared Form Templates' def __str__(self): - return f"Shared: {self.template.name}" \ No newline at end of file + return f"Shared: {self.template.name}" + + +class Source(models.Model): + name = models.CharField( + max_length=100, + unique=True, + verbose_name=_('Source Name'), + help_text=_("e.g., ATS, ERP ") + ) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name + + class Meta: + verbose_name = _('Source') + verbose_name_plural = _('Sources') + ordering = ['name'] + +class HiringAgency(Base): + name = models.CharField(max_length=200, unique=True, verbose_name=_('Agency Name')) + contact_person = models.CharField(max_length=150, blank=True, verbose_name=_('Contact Person')) + email = models.EmailField(blank=True) + phone = models.CharField(max_length=20, blank=True) + website = models.URLField(blank=True) + notes = models.TextField(blank=True, help_text=_("Internal notes about the agency")) + country=CountryField(blank=True, null=True,blank_label=_('Select country')) + address=models.TextField(blank=True,null=True) + + def __str__(self): + return self.name + + class Meta: + verbose_name = _('Hiring Agency') + verbose_name_plural = _('Hiring Agencies') + ordering = ['name'] + + + \ No newline at end of file diff --git a/recruitment/signals.py b/recruitment/signals.py new file mode 100644 index 0000000..69f2b2d --- /dev/null +++ b/recruitment/signals.py @@ -0,0 +1,140 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from . import models + +# @receiver(post_save, sender=models.Candidate) +# def parse_resume(sender, instance, created, **kwargs): +# if instance.resume and not instance.summary: +# from .utils import extract_summary_from_pdf,match_resume_with_job_description +# summary = extract_summary_from_pdf(instance.resume.path) +# if 'error' not in summary: +# instance.summary = summary +# instance.save() + + # match_resume_with_job_description + +import logging +logger = logging.getLogger(__name__) +import os +from .utils import extract_text_from_pdf,score_resume_with_openrouter +import asyncio + + + +@receiver(post_save, sender=models.Candidate) +def score_candidate_resume(sender, instance, created, **kwargs): + # Skip if no resume or OpenRouter not configured + if instance.resume: + return + if kwargs.get('update_fields') is not None: + return + + # Optional: Only re-score if resume changed (advanced: track file hash) + # For simplicity, we score on every save with a resume + + try: + # Get absolute file path + file_path = instance.resume.path + if not os.path.exists(file_path): + logger.warning(f"Resume file not found: {file_path}") + return + + resume_text = extract_text_from_pdf(file_path) + # if not resume_text: + # instance.scoring_error = "Could not extract text from resume." + # instance.save(update_fields=['scoring_error']) + # return + job_detail=str(instance.job.description)+str(instance.job.qualifications) + prompt1 = f""" + You are an expert resume parser and summarizer. Given a resume in plain text format, extract and organize the following key-value information into a clean, valid JSON object: + + full_name: Full name of the candidate + current_title: Most recent or current job title + location: City and state (or country if outside the U.S.) + contact: Phone number and email (as a single string or separate fields) + linkedin: LinkedIn profile URL (if present) + github: GitHub or portfolio URL (if present) + summary: Brief professional profile or summary (1β2 sentences) + education: List of degrees, each with: + institution + degree + year + gpa (if provided) + relevant_courses (as a list, if mentioned) + skills: Grouped by category if possible (e.g., programming, big data, visualization), otherwise as a flat list of technologies/tools + experience: List of roles, each with: + company + job_title + location + start_date and end_date (or "Present" if applicable) + key_achievements (as a list of concise bullet points) + projects: List of notable projects (if clearly labeled), each with: + name + year + technologies_used + brief_description + Instructions: + + Be concise but preserve key details. + Normalize formatting (e.g., βJun. 2014β β β2014-06β). + Omit redundant or promotional language. + If a section is missing, omit the key or set it to null/empty list as appropriate. + Output only valid JSONβno markdown, no extra text. + Now, process the following resume text: + {resume_text} + """ + result = score_resume_with_openrouter(prompt1) + prompt = f""" + You are an expert technical recruiter. Your task is to score the following candidate for the role of a Senior Data Analyst based on the provided job criteria. + + **Job Criteria:** + {job_detail} + + **Candidate's Extracted Resume Json:** + \"\"\" + {result} + \"\"\" + + **Your Task:** + Provide a response in strict JSON format with the following keys: + 1. 'match_score': A score from 0 to 100 representing how well the candidate fits the role. + 2. 'strengths': A brief summary of why the candidate is a strong fit, referencing specific criteria. + 3. 'weaknesses': A brief summary of where the candidate falls short or what criteria are missing. + 4. 'criteria_checklist': An object where you rate the candidate's match for each specific criterion (e.g., {{'Python': 'Met', 'AWS': 'Not Mentioned'}}). + + + Only output valid JSON. Do not include any other text. + """ + + result1 = score_resume_with_openrouter(prompt) + + instance.parsed_summary = str(result) + + # Update candidate with scoring results + instance.match_score = result1.get('match_score') + instance.strengths = result1.get('strengths', '') + instance.weaknesses = result1.get('weaknesses', '') + instance.criteria_checklist = result1.get('criteria_checklist', {}) + + + + # Save only scoring-related fields to avoid recursion + instance.save(update_fields=[ + 'match_score', 'strengths', 'weaknesses', + 'criteria_checklist','parsed_summary' + ]) + + logger.info(f"Successfully scored resume for candidate {instance.id}") + + except Exception as e: + # error_msg = str(e)[:500] # Truncate to fit TextField + # instance.scoring_error = error_msg + # instance.save(update_fields=['scoring_error']) + logger.error(f"Failed to score resume for candidate {instance.id}: {e}") + + +# @receiver(post_save,sender=models.Candidate) +# def trigger_scoring(sender,intance,created,**kwargs): + + + \ No newline at end of file diff --git a/recruitment/templatetags/__pycache__/__init__.cpython-312.pyc b/recruitment/templatetags/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..364ecb7 Binary files /dev/null and b/recruitment/templatetags/__pycache__/__init__.cpython-312.pyc differ diff --git a/recruitment/templatetags/__pycache__/form_filters.cpython-312.pyc b/recruitment/templatetags/__pycache__/form_filters.cpython-312.pyc new file mode 100644 index 0000000..dc240d2 Binary files /dev/null and b/recruitment/templatetags/__pycache__/form_filters.cpython-312.pyc differ diff --git a/recruitment/utils.py b/recruitment/utils.py index b17a5cb..3524a1a 100644 --- a/recruitment/utils.py +++ b/recruitment/utils.py @@ -1,33 +1,103 @@ -import os -import fitz # PyMuPDF -import spacy -import requests +# import os +# import fitz # PyMuPDF +# import spacy +# import requests from recruitment import models from django.conf import settings -nlp = spacy.load("en_core_web_sm") +# nlp = spacy.load("en_core_web_sm") -def extract_text_from_pdf(pdf_path): +# def extract_text_from_pdf(pdf_path): +# text = "" +# with fitz.open(pdf_path) as doc: +# for page in doc: +# text += page.get_text() +# return text + +# def extract_summary_from_pdf(pdf_path): +# if not os.path.exists(pdf_path): +# return {'error': 'File not found'} + +# text = extract_text_from_pdf(pdf_path) +# doc = nlp(text) +# summary = { +# 'name': doc.ents[0].text if doc.ents else '', +# 'skills': [chunk.text for chunk in doc.noun_chunks if len(chunk.text.split()) > 1], +# 'summary': text[:500] +# } +# return summary + +import requests +from PyPDF2 import PdfReader +import os +import json +import logging +logger = logging.getLogger(__name__) + +OPENROUTER_API_KEY ='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' +OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free' + +if not OPENROUTER_API_KEY: + logger.warning("OPENROUTER_API_KEY not set. Resume scoring will be skipped.") + +def extract_text_from_pdf(file_path): + print("text extraction") text = "" - with fitz.open(pdf_path) as doc: - for page in doc: - text += page.get_text() - return text + try: + with open(file_path, "rb") as f: + reader = PdfReader(f) + for page in reader.pages: + text += (page.extract_text() or "") + except Exception as e: + logger.error(f"PDF extraction failed: {e}") + raise + return text.strip() -def extract_summary_from_pdf(pdf_path): - if not os.path.exists(pdf_path): - return {'error': 'File not found'} +def score_resume_with_openrouter(prompt): + print("model call") + response = requests.post( + url="https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + }, + data=json.dumps({ + "model": OPENROUTER_MODEL, + "messages": [{"role": "user", "content": prompt}], + }, + ) + ) + # print(response.status_code) + # print(response.json()) + res = {} + print(response.status_code) + if response.status_code == 200: + res = response.json() + content = res["choices"][0]['message']['content'] + try: + + content = content.replace("```json","").replace("```","") + + res = json.loads(content) + + except Exception as e: + print(e) - text = extract_text_from_pdf(pdf_path) - doc = nlp(text) - summary = { - 'name': doc.ents[0].text if doc.ents else '', - 'skills': [chunk.text for chunk in doc.noun_chunks if len(chunk.text.split()) > 1], - 'summary': text[:500] - } - return summary + # res = raw_output["choices"][0]["message"]["content"] + else: + print("error response") + return res + # print(f"rawraw_output) + # print(response) + +# def match_resume_with_job_description(resume, job_description,prompt=""): +# resume_doc = nlp(resume) +# job_doc = nlp(job_description) +# similarity = resume_doc.similarity(job_doc) +# return similarity + def dashboard_callback(request, context): total_jobs = models.Job.objects.count() total_candidates = models.Candidate.objects.count() diff --git a/recruitment/views.py b/recruitment/views.py index fe0e444..2f26c1b 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -14,7 +14,7 @@ from django.contrib import messages from django.core.paginator import Paginator from .linkedin_service import LinkedInService from .models import FormTemplate, FormStage, FormField,FieldResponse,FormSubmission -from .models import ZoomMeeting, Job, Candidate, JobPosting +from .models import ZoomMeeting, Candidate, JobPosting from .serializers import JobPostingSerializer, CandidateSerializer from django.shortcuts import get_object_or_404, render, redirect from django.views.generic import CreateView,UpdateView,DetailView,ListView diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 5f7b0db..353f5c9 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -214,9 +214,7 @@ def candidate_detail(request, slug): }) def candidate_update_stage(request, slug): - """Handle HTMX stage update requests""" - from time import sleep - sleep(5) + """Handle HTMX stage update requests""" try: if not request.user.is_staff: return render(request, 'recruitment/partials/error.html', {'error': 'Permission denied'}, status=403) diff --git a/requirements.txt b/requirements.txt index 38e0665..98ed8b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -132,3 +132,14 @@ wrapt wurst xlrd XlsxWriter +celery[redis] +redis +sentence-transformers +torch +pdfplumber +python-docx +PyMuPDF +pytesseract +Pillow +python-dotenv +django-countries \ No newline at end of file diff --git a/static/image/applicant/__init__.py b/static/image/applicant/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/static/image/applicant/__pycache__/__init__.cpython-312.pyc b/static/image/applicant/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..3e50e87 Binary files /dev/null and b/static/image/applicant/__pycache__/__init__.cpython-312.pyc differ diff --git a/static/image/applicant/__pycache__/admin.cpython-312.pyc b/static/image/applicant/__pycache__/admin.cpython-312.pyc new file mode 100644 index 0000000..c57cd4a Binary files /dev/null and b/static/image/applicant/__pycache__/admin.cpython-312.pyc differ diff --git a/static/image/applicant/__pycache__/apps.cpython-312.pyc b/static/image/applicant/__pycache__/apps.cpython-312.pyc new file mode 100644 index 0000000..c134cdd Binary files /dev/null and b/static/image/applicant/__pycache__/apps.cpython-312.pyc differ diff --git a/static/image/applicant/__pycache__/forms.cpython-312.pyc b/static/image/applicant/__pycache__/forms.cpython-312.pyc new file mode 100644 index 0000000..cbee2e9 Binary files /dev/null and b/static/image/applicant/__pycache__/forms.cpython-312.pyc differ diff --git a/static/image/applicant/__pycache__/forms_builder.cpython-312.pyc b/static/image/applicant/__pycache__/forms_builder.cpython-312.pyc new file mode 100644 index 0000000..dfa9f76 Binary files /dev/null and b/static/image/applicant/__pycache__/forms_builder.cpython-312.pyc differ diff --git a/static/image/applicant/__pycache__/models.cpython-312.pyc b/static/image/applicant/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..bb29aff Binary files /dev/null and b/static/image/applicant/__pycache__/models.cpython-312.pyc differ diff --git a/static/image/applicant/__pycache__/urls.cpython-312.pyc b/static/image/applicant/__pycache__/urls.cpython-312.pyc new file mode 100644 index 0000000..7ae23f9 Binary files /dev/null and b/static/image/applicant/__pycache__/urls.cpython-312.pyc differ diff --git a/static/image/applicant/__pycache__/views.cpython-312.pyc b/static/image/applicant/__pycache__/views.cpython-312.pyc new file mode 100644 index 0000000..58cd1ef Binary files /dev/null and b/static/image/applicant/__pycache__/views.cpython-312.pyc differ diff --git a/static/image/applicant/admin.py b/static/image/applicant/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/static/image/applicant/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/static/image/applicant/apps.py b/static/image/applicant/apps.py new file mode 100644 index 0000000..27badf7 --- /dev/null +++ b/static/image/applicant/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApplicantConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'applicant' diff --git a/static/image/applicant/forms.py b/static/image/applicant/forms.py new file mode 100644 index 0000000..5c5b0b5 --- /dev/null +++ b/static/image/applicant/forms.py @@ -0,0 +1,22 @@ +from django import forms +from .models import ApplicantForm, FormField + +class ApplicantFormCreateForm(forms.ModelForm): + class Meta: + model = ApplicantForm + fields = ['name', 'description'] + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control'}), + 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + } + +class FormFieldForm(forms.ModelForm): + class Meta: + model = FormField + fields = ['label', 'field_type', 'required', 'help_text', 'choices'] + widgets = { + 'label': forms.TextInput(attrs={'class': 'form-control'}), + 'field_type': forms.Select(attrs={'class': 'form-control'}), + 'help_text': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}), + 'choices': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Option1, Option2, Option3'}), + } \ No newline at end of file diff --git a/static/image/applicant/forms_builder.py b/static/image/applicant/forms_builder.py new file mode 100644 index 0000000..c3a43e7 --- /dev/null +++ b/static/image/applicant/forms_builder.py @@ -0,0 +1,49 @@ +from django import forms +from .models import FormField + +# applicant/forms_builder.py +def create_dynamic_form(form_instance): + fields = {} + + for field in form_instance.fields.all(): + field_kwargs = { + 'label': field.label, + 'required': field.required, + 'help_text': field.help_text + } + + # Use stable field_name instead of database ID + field_key = field.field_name + + if field.field_type == 'text': + fields[field_key] = forms.CharField(**field_kwargs) + elif field.field_type == 'email': + fields[field_key] = forms.EmailField(**field_kwargs) + elif field.field_type == 'phone': + fields[field_key] = forms.CharField(**field_kwargs) + elif field.field_type == 'number': + fields[field_key] = forms.IntegerField(**field_kwargs) + elif field.field_type == 'date': + fields[field_key] = forms.DateField(**field_kwargs) + elif field.field_type == 'textarea': + fields[field_key] = forms.CharField( + widget=forms.Textarea, + **field_kwargs + ) + elif field.field_type in ['select', 'radio']: + choices = [(c.strip(), c.strip()) for c in field.choices.split(',') if c.strip()] + if not choices: + choices = [('', '---')] + if field.field_type == 'select': + fields[field_key] = forms.ChoiceField(choices=choices, **field_kwargs) + else: + fields[field_key] = forms.ChoiceField( + choices=choices, + widget=forms.RadioSelect, + **field_kwargs + ) + elif field.field_type == 'checkbox': + field_kwargs['required'] = False + fields[field_key] = forms.BooleanField(**field_kwargs) + + return type('DynamicApplicantForm', (forms.Form,), fields) \ No newline at end of file diff --git a/static/image/applicant/migrations/0001_initial.py b/static/image/applicant/migrations/0001_initial.py new file mode 100644 index 0000000..d7437c3 --- /dev/null +++ b/static/image/applicant/migrations/0001_initial.py @@ -0,0 +1,70 @@ +# Generated by Django 5.2.6 on 2025-10-01 21:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('jobs', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ApplicantForm', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text="Form version name (e.g., 'Version A', 'Version B' etc)", max_length=200)), + ('description', models.TextField(blank=True, help_text='Optional description of this form version')), + ('is_active', models.BooleanField(default=False, help_text='Only one form can be active per job')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('job_posting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applicant_forms', to='jobs.jobposting')), + ], + options={ + 'verbose_name': 'Application Form', + 'verbose_name_plural': 'Application Forms', + 'ordering': ['-created_at'], + 'unique_together': {('job_posting', 'name')}, + }, + ), + migrations.CreateModel( + name='ApplicantSubmission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('submitted_at', models.DateTimeField(auto_now_add=True)), + ('data', models.JSONField()), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('score', models.FloatField(default=0, help_text='Ranking score for the applicant submission')), + ('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='applicant.applicantform')), + ('job_posting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='jobs.jobposting')), + ], + options={ + 'verbose_name': 'Applicant Submission', + 'verbose_name_plural': 'Applicant Submissions', + 'ordering': ['-submitted_at'], + }, + ), + migrations.CreateModel( + name='FormField', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('label', models.CharField(max_length=255)), + ('field_type', models.CharField(choices=[('text', 'Text'), ('email', 'Email'), ('phone', 'Phone'), ('number', 'Number'), ('date', 'Date'), ('select', 'Dropdown'), ('radio', 'Radio Buttons'), ('checkbox', 'Checkbox'), ('textarea', 'Paragraph Text'), ('file', 'File Upload'), ('image', 'Image Upload')], max_length=20)), + ('required', models.BooleanField(default=True)), + ('help_text', models.TextField(blank=True)), + ('choices', models.TextField(blank=True, help_text='Comma-separated options for select/radio fields')), + ('order', models.IntegerField(default=0)), + ('field_name', models.CharField(blank=True, max_length=100)), + ('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='applicant.applicantform')), + ], + options={ + 'verbose_name': 'Form Field', + 'verbose_name_plural': 'Form Fields', + 'ordering': ['order'], + }, + ), + ] diff --git a/static/image/applicant/migrations/__init__.py b/static/image/applicant/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/static/image/applicant/migrations/__pycache__/0001_initial.cpython-312.pyc b/static/image/applicant/migrations/__pycache__/0001_initial.cpython-312.pyc new file mode 100644 index 0000000..3091ce8 Binary files /dev/null and b/static/image/applicant/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/static/image/applicant/migrations/__pycache__/0002_formfield_field_name.cpython-312.pyc b/static/image/applicant/migrations/__pycache__/0002_formfield_field_name.cpython-312.pyc new file mode 100644 index 0000000..bb11c8f Binary files /dev/null and b/static/image/applicant/migrations/__pycache__/0002_formfield_field_name.cpython-312.pyc differ diff --git a/static/image/applicant/migrations/__pycache__/0003_applicantsubmission_score.cpython-312.pyc b/static/image/applicant/migrations/__pycache__/0003_applicantsubmission_score.cpython-312.pyc new file mode 100644 index 0000000..67abc7e Binary files /dev/null and b/static/image/applicant/migrations/__pycache__/0003_applicantsubmission_score.cpython-312.pyc differ diff --git a/static/image/applicant/migrations/__pycache__/0004_alter_applicantform_name_alter_formfield_choices_and_more.cpython-312.pyc b/static/image/applicant/migrations/__pycache__/0004_alter_applicantform_name_alter_formfield_choices_and_more.cpython-312.pyc new file mode 100644 index 0000000..2a2430a Binary files /dev/null and b/static/image/applicant/migrations/__pycache__/0004_alter_applicantform_name_alter_formfield_choices_and_more.cpython-312.pyc differ diff --git a/static/image/applicant/migrations/__pycache__/__init__.cpython-312.pyc b/static/image/applicant/migrations/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..e38639e Binary files /dev/null and b/static/image/applicant/migrations/__pycache__/__init__.cpython-312.pyc differ diff --git a/static/image/applicant/models.py b/static/image/applicant/models.py new file mode 100644 index 0000000..6b35d2f --- /dev/null +++ b/static/image/applicant/models.py @@ -0,0 +1,144 @@ +# models.py +from django.db import models +from django.core.exceptions import ValidationError +from jobs.models import JobPosting +from django.urls import reverse + +class ApplicantForm(models.Model): + """Multiple dynamic forms per job posting, only one active at a time""" + job_posting = models.ForeignKey( + JobPosting, + on_delete=models.CASCADE, + related_name='applicant_forms' + ) + name = models.CharField( + max_length=200, + help_text="Form version name (e.g., 'Version A', 'Version B' etc)" + ) + description = models.TextField( + blank=True, + help_text="Optional description of this form version" + ) + is_active = models.BooleanField( + default=False, + help_text="Only one form can be active per job" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('job_posting', 'name') + ordering = ['-created_at'] + verbose_name = "Application Form" + verbose_name_plural = "Application Forms" + + def __str__(self): + status = "(Active)" if self.is_active else "(Inactive)" + return f"{self.name} for {self.job_posting.title} {status}" + + def clean(self): + """Ensure only one active form per job""" + if self.is_active: + existing_active = self.job_posting.applicant_forms.filter( + is_active=True + ).exclude(pk=self.pk) + if existing_active.exists(): + raise ValidationError( + "Only one active application form is allowed per job posting." + ) + super().clean() + + def activate(self): + """Set this form as active and deactivate others""" + self.is_active = True + self.save() + # Deactivate other forms + self.job_posting.applicant_forms.exclude(pk=self.pk).update( + is_active=False + ) + + def get_public_url(self): + """Returns the public application URL for this job's active form""" + return reverse('applicant:apply_form', args=[self.job_posting.internal_job_id]) + + +class FormField(models.Model): + FIELD_TYPES = [ + ('text', 'Text'), + ('email', 'Email'), + ('phone', 'Phone'), + ('number', 'Number'), + ('date', 'Date'), + ('select', 'Dropdown'), + ('radio', 'Radio Buttons'), + ('checkbox', 'Checkbox'), + ('textarea', 'Paragraph Text'), + ('file', 'File Upload'), + ('image', 'Image Upload'), + ] + + form = models.ForeignKey( + ApplicantForm, + related_name='fields', + on_delete=models.CASCADE + ) + label = models.CharField(max_length=255) + field_type = models.CharField(max_length=20, choices=FIELD_TYPES) + required = models.BooleanField(default=True) + help_text = models.TextField(blank=True) + choices = models.TextField( + blank=True, + help_text="Comma-separated options for select/radio fields" + ) + order = models.IntegerField(default=0) + field_name = models.CharField(max_length=100, blank=True) + + class Meta: + ordering = ['order'] + verbose_name = "Form Field" + verbose_name_plural = "Form Fields" + + def __str__(self): + return f"{self.label} ({self.field_type}) in {self.form.name}" + + def save(self, *args, **kwargs): + if not self.field_name: + # Create a stable field name from label (e.g., "Full Name" β "full_name") + import re + # Use Unicode word characters, including Arabic, for field_name + self.field_name = re.sub( + r'[^\w]+', + '_', + self.label.lower(), + flags=re.UNICODE + ).strip('_') + # Ensure uniqueness within the form + base_name = self.field_name + counter = 1 + while FormField.objects.filter( + form=self.form, + field_name=self.field_name + ).exists(): + self.field_name = f"{base_name}_{counter}" + counter += 1 + super().save(*args, **kwargs) + + +class ApplicantSubmission(models.Model): + job_posting = models.ForeignKey(JobPosting, on_delete=models.CASCADE) + form = models.ForeignKey(ApplicantForm, on_delete=models.CASCADE) + submitted_at = models.DateTimeField(auto_now_add=True) + data = models.JSONField() + ip_address = models.GenericIPAddressField(null=True, blank=True) + score = models.FloatField( + default=0, + help_text="Ranking score for the applicant submission" + ) + + class Meta: + ordering = ['-submitted_at'] + verbose_name = "Applicant Submission" + verbose_name_plural = "Applicant Submissions" + + def __str__(self): + return f"Submission for {self.job_posting.title} at {self.submitted_at}" \ No newline at end of file diff --git a/static/image/applicant/templates/applicant/apply_form.html b/static/image/applicant/templates/applicant/apply_form.html new file mode 100644 index 0000000..eae2993 --- /dev/null +++ b/static/image/applicant/templates/applicant/apply_form.html @@ -0,0 +1,94 @@ +{% extends 'base.html' %} + +{% block title %} + Apply: {{ job.title }} +{% endblock %} + +{% block content %} +
+ Your final step to apply for this position. +
+ +{{ applicant_form.description }}
+ {% endif %} + + ++ You are creating a new form structure for job: {{ job.title }} +
+ + +Drag fields from the left panel to build your form
++ Internal Job ID: **{{ job.internal_job_id }}** +
++ {{ form.description|default:"β No description provided. β" }} +
+No application forms have been created yet for this job.
+Click the button above to define a new form structure.
+Review the job details on the left, then click the button below to submit your application.
+ + Apply for this Position + ++ You'll be redirected to our secure application form where you can upload your resume and provide additional details. +
+Position: {{ job.title }}
+Job ID: {{ job.internal_job_id }}
+Department: {{ job.department|default:"Not specified" }}
+ {% if job.application_deadline %} +Application Deadline: {{ job.application_deadline|date:"F j, Y" }}
+ {% endif %} ++ We appreciate your interest in joining our team. Our hiring team will review your application + and contact you if there's a potential match for this position. +
+ + {% comment %} {% endcomment %} +{{ total_jobs }}
- {% endcomponent %} - - {% component "unfold/components/card.html" with title="Total Candidates" %} -{{ total_candidates }}
- {% endcomponent %} - - {% component "unfold/components/card.html" with title="Average Applications/Job" %} -{{ average_applications }}
- {% endcomponent %} -