Merge pull request 'ui updates' (#3) from frontend into main
Reviewed-on: #3
This commit is contained in:
commit
f28ab751ef
59
.gitignore
vendored
Normal file
59
.gitignore
vendored
Normal file
@ -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
|
||||
@ -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',)
|
||||
|
||||
|
||||
Binary file not shown.
BIN
NorahUniversity/__pycache__/celery.cpython-312.pyc
Normal file
BIN
NorahUniversity/__pycache__/celery.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
23
NorahUniversity/celery.py
Normal file
23
NorahUniversity/celery.py
Normal file
@ -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()
|
||||
|
||||
@ -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
|
||||
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/'
|
||||
|
||||
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
recruitment/__pycache__/linkedin_service.cpython-312.pyc
Normal file
BIN
recruitment/__pycache__/linkedin_service.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
recruitment/__pycache__/signals.cpython-312.pyc
Normal file
BIN
recruitment/__pycache__/signals.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
recruitment/__pycache__/validators.cpython-312.pyc
Normal file
BIN
recruitment/__pycache__/validators.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
# }
|
||||
|
||||
BIN
recruitment/management/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
recruitment/management/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
@ -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),
|
||||
),
|
||||
]
|
||||
31
recruitment/migrations/0014_source_jobposting_source.py
Normal file
31
recruitment/migrations/0014_source_jobposting_source.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
14
recruitment/migrations/0019_merge_20251006_1224.py
Normal file
14
recruitment/migrations/0019_merge_20251006_1224.py
Normal file
@ -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 = [
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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}"
|
||||
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']
|
||||
|
||||
|
||||
|
||||
140
recruitment/signals.py
Normal file
140
recruitment/signals.py
Normal file
@ -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):
|
||||
|
||||
|
||||
|
||||
BIN
recruitment/templatetags/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
recruitment/templatetags/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -132,3 +132,14 @@ wrapt
|
||||
wurst
|
||||
xlrd
|
||||
XlsxWriter
|
||||
celery[redis]
|
||||
redis
|
||||
sentence-transformers
|
||||
torch
|
||||
pdfplumber
|
||||
python-docx
|
||||
PyMuPDF
|
||||
pytesseract
|
||||
Pillow
|
||||
python-dotenv
|
||||
django-countries
|
||||
0
static/image/applicant/__init__.py
Normal file
0
static/image/applicant/__init__.py
Normal file
BIN
static/image/applicant/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
static/image/applicant/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
static/image/applicant/__pycache__/admin.cpython-312.pyc
Normal file
BIN
static/image/applicant/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
static/image/applicant/__pycache__/apps.cpython-312.pyc
Normal file
BIN
static/image/applicant/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
static/image/applicant/__pycache__/forms.cpython-312.pyc
Normal file
BIN
static/image/applicant/__pycache__/forms.cpython-312.pyc
Normal file
Binary file not shown.
BIN
static/image/applicant/__pycache__/forms_builder.cpython-312.pyc
Normal file
BIN
static/image/applicant/__pycache__/forms_builder.cpython-312.pyc
Normal file
Binary file not shown.
BIN
static/image/applicant/__pycache__/models.cpython-312.pyc
Normal file
BIN
static/image/applicant/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
static/image/applicant/__pycache__/urls.cpython-312.pyc
Normal file
BIN
static/image/applicant/__pycache__/urls.cpython-312.pyc
Normal file
Binary file not shown.
BIN
static/image/applicant/__pycache__/views.cpython-312.pyc
Normal file
BIN
static/image/applicant/__pycache__/views.cpython-312.pyc
Normal file
Binary file not shown.
3
static/image/applicant/admin.py
Normal file
3
static/image/applicant/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
static/image/applicant/apps.py
Normal file
6
static/image/applicant/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApplicantConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'applicant'
|
||||
22
static/image/applicant/forms.py
Normal file
22
static/image/applicant/forms.py
Normal file
@ -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'}),
|
||||
}
|
||||
49
static/image/applicant/forms_builder.py
Normal file
49
static/image/applicant/forms_builder.py
Normal file
@ -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)
|
||||
70
static/image/applicant/migrations/0001_initial.py
Normal file
70
static/image/applicant/migrations/0001_initial.py
Normal file
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
static/image/applicant/migrations/__init__.py
Normal file
0
static/image/applicant/migrations/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
144
static/image/applicant/models.py
Normal file
144
static/image/applicant/models.py
Normal file
@ -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}"
|
||||
94
static/image/applicant/templates/applicant/apply_form.html
Normal file
94
static/image/applicant/templates/applicant/apply_form.html
Normal file
@ -0,0 +1,94 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}
|
||||
Apply: {{ job.title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
|
||||
{# --- 1. Job Header and Overview (Fixed/Static Info) --- #}
|
||||
<div class="card bg-light-subtle mb-4 p-4 border-0 rounded-3 shadow-sm">
|
||||
<h1 class="h2 fw-bold text-primary mb-1">{{ job.title }}</h1>
|
||||
|
||||
<p class="mb-3 text-muted">
|
||||
Your final step to apply for this position.
|
||||
</p>
|
||||
|
||||
<div class="d-flex gap-4 small text-secondary">
|
||||
<div>
|
||||
<i class="fas fa-building me-1"></i>
|
||||
<strong>Department:</strong> {{ job.department|default:"Not specified" }}
|
||||
</div>
|
||||
<div>
|
||||
<i class="fas fa-map-marker-alt me-1"></i>
|
||||
<strong>Location:</strong> {{ job.get_location_display }}
|
||||
</div>
|
||||
<div>
|
||||
<i class="fas fa-briefcase me-1"></i>
|
||||
<strong>Type:</strong> {{ job.get_job_type_display }} • {{ job.get_workplace_type_display }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- 2. Application Form Section --- #}
|
||||
<div class="card p-5 border-0 rounded-3 shadow">
|
||||
<h2 class="h3 fw-semibold mb-3">Application Details</h2>
|
||||
|
||||
{% if applicant_form.description %}
|
||||
<p class="text-muted mb-4">{{ applicant_form.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
{% for field in form %}
|
||||
<div class="form-group mb-4">
|
||||
{# Label Tag #}
|
||||
<label for="{{ field.id_for_label }}" class="form-label">
|
||||
{{ field.label }}
|
||||
{% if field.field.required %}<span class="text-danger">*</span>{% endif %}
|
||||
</label>
|
||||
|
||||
{# The Field Widget (Assumes form-control is applied in backend) #}
|
||||
{{ field }}
|
||||
|
||||
{# Field Errors #}
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback d-block">{{ field.errors }}</div>
|
||||
{% endif %}
|
||||
|
||||
{# Help Text #}
|
||||
{% if field.help_text %}
|
||||
<div class="form-text">{{ field.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# General Form Errors (Non-field errors) #}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger mb-4">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg mt-3 w-100">
|
||||
<i class="fas fa-paper-plane me-2"></i> Submit Application
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<footer class="mt-4 text-center">
|
||||
<a href="{% url 'applicant:review_job_detail' job.internal_job_id %}"
|
||||
class="btn btn-link text-secondary">
|
||||
← Review Job Details
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
68
static/image/applicant/templates/applicant/create_form.html
Normal file
68
static/image/applicant/templates/applicant/create_form.html
Normal file
@ -0,0 +1,68 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}
|
||||
Define Form for {{ job.title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8 col-md-10">
|
||||
|
||||
<div class="card shadow-lg border-0 p-4 p-md-5">
|
||||
|
||||
<h2 class="card-title text-center mb-4 text-dark">
|
||||
🛠️ New Application Form Configuration
|
||||
</h2>
|
||||
|
||||
<p class="text-center text-muted mb-4 border-bottom pb-3">
|
||||
You are creating a new form structure for job: <strong>{{ job.title }}</strong>
|
||||
</p>
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<fieldset class="mb-5">
|
||||
<legend class="h5 mb-3 text-secondary">Form Metadata</legend>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label for="{{ form.name.id_for_label }}" class="form-label required">
|
||||
Form Name
|
||||
</label>
|
||||
{# The field should already have form-control applied from the backend #}
|
||||
{{ form.name }}
|
||||
|
||||
{% if form.name.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.name.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label for="{{ form.description.id_for_label }}" class="form-label">
|
||||
Description
|
||||
</label>
|
||||
{# The field should already have form-control applied from the backend #}
|
||||
{{ form.description}}
|
||||
|
||||
{% if form.description.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.description.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="d-flex justify-content-end gap-3 pt-3">
|
||||
<a href="{% url 'applicant:job_forms_list' job.internal_job_id %}"
|
||||
class="btn btn-outline-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn univ-color btn-lg">
|
||||
Create Form & Continue →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
1020
static/image/applicant/templates/applicant/edit_form.html
Normal file
1020
static/image/applicant/templates/applicant/edit_form.html
Normal file
File diff suppressed because it is too large
Load Diff
103
static/image/applicant/templates/applicant/job_forms_list.html
Normal file
103
static/image/applicant/templates/applicant/job_forms_list.html
Normal file
@ -0,0 +1,103 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}
|
||||
Manage Forms | {{ job.title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
|
||||
<header class="mb-5 pb-3 border-bottom d-flex flex-column flex-md-row justify-content-between align-items-md-center">
|
||||
<div>
|
||||
<h2 class="h3 mb-1 ">
|
||||
<i class="fas fa-clipboard-list me-2 text-secondary"></i>
|
||||
Application Forms for <span class="text-success fw-bold">"{{ job.title }}"</span>
|
||||
</h2>
|
||||
<p class="text-muted small">
|
||||
Internal Job ID: **{{ job.internal_job_id }}**
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Primary Action Button using the theme color #}
|
||||
<a href="{% url 'applicant:create_form' job_id=job.internal_job_id %}"
|
||||
class="btn univ-color btn-lg shadow-sm mt-3 mt-md-0">
|
||||
<i class="fas fa-plus me-1"></i> Create New Form
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{% if forms %}
|
||||
|
||||
<div class="list-group">
|
||||
{% for form in forms %}
|
||||
|
||||
{# Custom styling based on active state #}
|
||||
<div class="list-group-item d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center p-3 mb-3 rounded shadow-sm
|
||||
{% if form.is_active %}border-success border-3 bg-light{% else %}border-secondary border-1{% endif %}">
|
||||
|
||||
{# Left Section: Form Details #}
|
||||
<div class="flex-grow-1 me-4 mb-2 mb-sm-0">
|
||||
<h4 class="h5 mb-1 d-inline-block">
|
||||
{{ form.name }}
|
||||
</h4>
|
||||
|
||||
{# Status Badge #}
|
||||
{% if form.is_active %}
|
||||
<span class="badge bg-success ms-2">
|
||||
<i class="fas fa-check-circle me-1"></i> Active Form
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-2">
|
||||
<i class="fas fa-times-circle me-1"></i> Inactive
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<p class="text-muted mt-1 mb-1 small">
|
||||
{{ form.description|default:"— No description provided. —" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Right Section: Actions #}
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
|
||||
{# Edit Structure Button #}
|
||||
<a href="{% url 'applicant:edit_form' form.id %}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-pen me-1"></i> Edit Structure
|
||||
</a>
|
||||
|
||||
{# Conditional Activation Button #}
|
||||
{% if not form.is_active %}
|
||||
<a href="{% url 'applicant:activate_form' form.id %}"
|
||||
class="btn btn-sm univ-color">
|
||||
<i class="fas fa-bolt me-1"></i> Activate Form
|
||||
</a>
|
||||
{% else %}
|
||||
{# Active indicator/Deactivate button placeholder #}
|
||||
<a href="#" class="btn btn-sm btn-outline-success" disabled>
|
||||
<i class="fas fa-star me-1"></i> Current Form
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5 bg-light rounded shadow-sm">
|
||||
<i class="fas fa-file-alt fa-4x text-muted mb-3"></i>
|
||||
<p class="lead mb-0">No application forms have been created yet for this job.</p>
|
||||
<p class="mt-2 mb-0 text-secondary">Click the button above to define a new form structure.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<footer class="text-end mt-5 pt-3 border-top">
|
||||
<a href="{% url 'jobs:job_detail' job.internal_job_id %}"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> Back to Job Details
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,129 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ job.title }} - University ATS{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-5">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h2>{{ job.title }}</h2>
|
||||
<span class="badge bg-{{ job.status|lower }} status-badge">
|
||||
{{ job.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Job Details -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<strong>Department:</strong> {{ job.department|default:"Not specified" }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Position Number:</strong> {{ job.position_number|default:"Not specified" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<strong>Job Type:</strong> {{ job.get_job_type_display }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Workplace:</strong> {{ job.get_workplace_type_display }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<strong>Location:</strong> {{ job.get_location_display }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Created By:</strong> {{ job.created_by|default:"Not specified" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if job.salary_range %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<strong>Salary Range:</strong> {{ job.salary_range }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.start_date %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<strong>Start Date:</strong> {{ job.start_date }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.application_deadline %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<strong>Application Deadline:</strong> {{ job.application_deadline }}
|
||||
{% if job.is_expired %}
|
||||
<span class="badge bg-danger">EXPIRED</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Description -->
|
||||
{% if job.description %}
|
||||
<div class="mb-3">
|
||||
<h5>Description</h5>
|
||||
<div>{{ job.description|linebreaks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.qualifications %}
|
||||
<div class="mb-3">
|
||||
<h5>Qualifications</h5>
|
||||
<div>{{ job.qualifications|linebreaks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.benefits %}
|
||||
<div class="mb-3">
|
||||
<h5>Benefits</h5>
|
||||
<div>{{ job.benefits|linebreaks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.application_instructions %}
|
||||
<div class="mb-3">
|
||||
<h5>Application Instructions</h5>
|
||||
<div>{{ job.application_instructions|linebreaks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
|
||||
<!-- Add this section below your existing job details -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5><i class="fas fa-file-signature"></i> Ready to Apply?</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Review the job details on the left, then click the button below to submit your application.</p>
|
||||
<a href="{% url 'applicant:apply_form' job.internal_job_id %}" class="btn btn-success btn-lg w-100">
|
||||
<i class="fas fa-paper-plane"></i> Apply for this Position
|
||||
</a>
|
||||
<p class="text-muted mt-2">
|
||||
<small>You'll be redirected to our secure application form where you can upload your resume and provide additional details.</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
35
static/image/applicant/templates/applicant/thank_you.html
Normal file
35
static/image/applicant/templates/applicant/thank_you.html
Normal file
@ -0,0 +1,35 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Application Submitted - {{ job.title }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div style="text-align: center; padding: 30px 0;">
|
||||
<div style="width: 80px; height: 80px; background: #d4edda; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 20px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="#28a745" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 style="color: #28a745; margin-bottom: 15px;">Thank You!</h1>
|
||||
<h2>Your application has been submitted successfully</h2>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 25px 0; text-align: left;">
|
||||
<p><strong>Position:</strong> {{ job.title }}</p>
|
||||
<p><strong>Job ID:</strong> {{ job.internal_job_id }}</p>
|
||||
<p><strong>Department:</strong> {{ job.department|default:"Not specified" }}</p>
|
||||
{% if job.application_deadline %}
|
||||
<p><strong>Application Deadline:</strong> {{ job.application_deadline|date:"F j, Y" }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p style="font-size: 18px; line-height: 1.6;">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{% comment %} <div style="margin-top: 30px;">
|
||||
<a href="/" class="btn btn-primary" style="margin-right: 10px;">Apply to Another Position</a>
|
||||
<a href="{% url 'jobs:job_detail' job.internal_job_id %}" class="btn btn-outline">View Job Details</a>
|
||||
</div> {% endcomment %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
0
static/image/applicant/templatetags/__init__.py
Normal file
0
static/image/applicant/templatetags/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
24
static/image/applicant/templatetags/mytags.py
Normal file
24
static/image/applicant/templatetags/mytags.py
Normal file
@ -0,0 +1,24 @@
|
||||
import json
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.filter(name='from_json')
|
||||
def from_json(json_string):
|
||||
"""
|
||||
Safely loads a JSON string into a Python object (list or dict).
|
||||
"""
|
||||
try:
|
||||
# The JSON string comes from the context and needs to be parsed
|
||||
return json.loads(json_string)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
# Handle cases where the string is invalid or None/empty
|
||||
return []
|
||||
|
||||
|
||||
@register.filter(name='split')
|
||||
def split_string(value, key=None):
|
||||
"""Splits a string by the given key (default is space)."""
|
||||
if key is None:
|
||||
return value.split()
|
||||
return value.split(key)
|
||||
14
static/image/applicant/templatetags/signals.py
Normal file
14
static/image/applicant/templatetags/signals.py
Normal file
@ -0,0 +1,14 @@
|
||||
# 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
|
||||
3
static/image/applicant/tests.py
Normal file
3
static/image/applicant/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
18
static/image/applicant/urls.py
Normal file
18
static/image/applicant/urls.py
Normal file
@ -0,0 +1,18 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'applicant'
|
||||
|
||||
urlpatterns = [
|
||||
# Form Management
|
||||
path('job/<str:job_id>/forms/', views.job_forms_list, name='job_forms_list'),
|
||||
path('job/<str:job_id>/forms/create/', views.create_form_for_job, name='create_form'),
|
||||
path('form/<int:form_id>/edit/', views.edit_form, name='edit_form'),
|
||||
path('field/<int:field_id>/delete/', views.delete_field, name='delete_field'),
|
||||
path('form/<int:form_id>/activate/', views.activate_form, name='activate_form'),
|
||||
|
||||
# Public Application
|
||||
path('apply/<str:job_id>/', views.apply_form_view, name='apply_form'),
|
||||
path('review/job/detail/<str:job_id>/',views.review_job_detail, name="review_job_detail"),
|
||||
path('apply/<str:job_id>/thank-you/', views.thank_you_view, name='thank_you'),
|
||||
]
|
||||
34
static/image/applicant/utils.py
Normal file
34
static/image/applicant/utils.py
Normal file
@ -0,0 +1,34 @@
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
175
static/image/applicant/views.py
Normal file
175
static/image/applicant/views.py
Normal file
@ -0,0 +1,175 @@
|
||||
# applicant/views.py (Updated edit_form function)
|
||||
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.contrib import messages
|
||||
from django.http import Http404, JsonResponse # <-- Import JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt # <-- Needed for JSON POST if not using FormData
|
||||
import json # <-- Import json
|
||||
from django.db import transaction # <-- Import transaction
|
||||
|
||||
# (Keep all your existing imports)
|
||||
from .models import ApplicantForm, FormField, ApplicantSubmission
|
||||
from .forms import ApplicantFormCreateForm, FormFieldForm
|
||||
from jobs.models import JobPosting
|
||||
from .forms_builder import create_dynamic_form
|
||||
|
||||
# ... (Keep all other functions like job_forms_list, create_form_for_job, etc.)
|
||||
# ...
|
||||
|
||||
|
||||
|
||||
# === FORM MANAGEMENT VIEWS ===
|
||||
|
||||
def job_forms_list(request, job_id):
|
||||
"""List all forms for a specific job"""
|
||||
job = get_object_or_404(JobPosting, internal_job_id=job_id)
|
||||
forms = job.applicant_forms.all()
|
||||
return render(request, 'applicant/job_forms_list.html', {
|
||||
'job': job,
|
||||
'forms': forms
|
||||
})
|
||||
|
||||
def create_form_for_job(request, job_id):
|
||||
"""Create a new form for a job"""
|
||||
job = get_object_or_404(JobPosting, internal_job_id=job_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ApplicantFormCreateForm(request.POST)
|
||||
if form.is_valid():
|
||||
applicant_form = form.save(commit=False)
|
||||
applicant_form.job_posting = job
|
||||
applicant_form.save()
|
||||
messages.success(request, 'Form created successfully!')
|
||||
return redirect('applicant:job_forms_list', job_id=job_id)
|
||||
else:
|
||||
form = ApplicantFormCreateForm()
|
||||
|
||||
return render(request, 'applicant/create_form.html', {
|
||||
'job': job,
|
||||
'form': form
|
||||
})
|
||||
|
||||
|
||||
@transaction.atomic # Ensures all fields are saved or none are
|
||||
def edit_form(request, form_id):
|
||||
"""Edit form details and manage fields, including dynamic builder save."""
|
||||
applicant_form = get_object_or_404(ApplicantForm, id=form_id)
|
||||
job = applicant_form.job_posting
|
||||
|
||||
if request.method == 'POST':
|
||||
# --- 1. Handle JSON data from the Form Builder (JavaScript) ---
|
||||
if request.content_type == 'application/json':
|
||||
try:
|
||||
field_data = json.loads(request.body)
|
||||
|
||||
# Clear existing fields for this form
|
||||
applicant_form.fields.all().delete()
|
||||
|
||||
# Create new fields from the JSON data
|
||||
for field_config in field_data:
|
||||
# Sanitize/ensure required fields are present
|
||||
FormField.objects.create(
|
||||
form=applicant_form,
|
||||
label=field_config.get('label', 'New Field'),
|
||||
field_type=field_config.get('field_type', 'text'),
|
||||
required=field_config.get('required', True),
|
||||
help_text=field_config.get('help_text', ''),
|
||||
choices=field_config.get('choices', ''),
|
||||
order=field_config.get('order', 0),
|
||||
# field_name will be auto-generated/re-generated on save() if needed
|
||||
)
|
||||
|
||||
return JsonResponse({'status': 'success', 'message': 'Form structure saved successfully!'})
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({'status': 'error', 'message': 'Invalid JSON data.'}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse({'status': 'error', 'message': f'Server error: {str(e)}'}, status=500)
|
||||
|
||||
# --- 2. Handle standard POST requests (e.g., saving form details) ---
|
||||
elif 'save_form_details' in request.POST: # Changed the button name for clarity
|
||||
form_details = ApplicantFormCreateForm(request.POST, instance=applicant_form)
|
||||
if form_details.is_valid():
|
||||
form_details.save()
|
||||
messages.success(request, 'Form details updated successfully!')
|
||||
return redirect('applicant:edit_form', form_id=form_id)
|
||||
|
||||
# Note: The 'add_field' branch is now redundant since we use the builder,
|
||||
# but you can keep it if you want the old manual way too.
|
||||
|
||||
# --- GET Request (or unsuccessful POST) ---
|
||||
form_details = ApplicantFormCreateForm(instance=applicant_form)
|
||||
# Get initial fields to load into the JS builder
|
||||
initial_fields_json = list(applicant_form.fields.values(
|
||||
'label', 'field_type', 'required', 'help_text', 'choices', 'order', 'field_name'
|
||||
))
|
||||
|
||||
return render(request, 'applicant/edit_form.html', {
|
||||
'applicant_form': applicant_form,
|
||||
'job': job,
|
||||
'form_details': form_details,
|
||||
'initial_fields_json': json.dumps(initial_fields_json)
|
||||
})
|
||||
|
||||
def delete_field(request, field_id):
|
||||
"""Delete a form field"""
|
||||
field = get_object_or_404(FormField, id=field_id)
|
||||
form_id = field.form.id
|
||||
field.delete()
|
||||
messages.success(request, 'Field deleted successfully!')
|
||||
return redirect('applicant:edit_form', form_id=form_id)
|
||||
|
||||
def activate_form(request, form_id):
|
||||
"""Activate a form (deactivates others automatically)"""
|
||||
applicant_form = get_object_or_404(ApplicantForm, id=form_id)
|
||||
applicant_form.activate()
|
||||
messages.success(request, f'Form "{applicant_form.name}" is now active!')
|
||||
return redirect('applicant:job_forms_list', job_id=applicant_form.job_posting.internal_job_id)
|
||||
|
||||
# === PUBLIC VIEWS (for applicants) ===
|
||||
|
||||
def apply_form_view(request, job_id):
|
||||
"""Public application form - serves active form"""
|
||||
job = get_object_or_404(JobPosting, internal_job_id=job_id, status='ACTIVE')
|
||||
|
||||
if job.is_expired():
|
||||
raise Http404("Application deadline has passed")
|
||||
|
||||
try:
|
||||
applicant_form = job.applicant_forms.get(is_active=True)
|
||||
except ApplicantForm.DoesNotExist:
|
||||
raise Http404("No active application form configured for this job")
|
||||
|
||||
DynamicForm = create_dynamic_form(applicant_form)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = DynamicForm(request.POST)
|
||||
if form.is_valid():
|
||||
ApplicantSubmission.objects.create(
|
||||
job_posting=job,
|
||||
form=applicant_form,
|
||||
data=form.cleaned_data,
|
||||
ip_address=request.META.get('REMOTE_ADDR')
|
||||
)
|
||||
return redirect('applicant:thank_you', job_id=job_id)
|
||||
else:
|
||||
form = DynamicForm()
|
||||
|
||||
return render(request, 'applicant/apply_form.html', {
|
||||
'form': form,
|
||||
'job': job,
|
||||
'applicant_form': applicant_form
|
||||
})
|
||||
|
||||
def review_job_detail(request,job_id):
|
||||
"""Public job detail view for applicants"""
|
||||
job = get_object_or_404(JobPosting, internal_job_id=job_id, status='ACTIVE')
|
||||
if job.is_expired():
|
||||
raise Http404("This job posting has expired.")
|
||||
return render(request,'applicant/review_job_detail.html',{'job':job})
|
||||
|
||||
|
||||
|
||||
|
||||
def thank_you_view(request, job_id):
|
||||
job = get_object_or_404(JobPosting, internal_job_id=job_id)
|
||||
return render(request, 'applicant/thank_you.html', {'job': job})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user