diff --git a/NorahUniversity/__pycache__/settings.cpython-312.pyc b/NorahUniversity/__pycache__/settings.cpython-312.pyc
index de5ab87..2d7be7f 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 cac990a..3a455bc 100644
Binary files a/NorahUniversity/__pycache__/urls.cpython-312.pyc and b/NorahUniversity/__pycache__/urls.cpython-312.pyc differ
diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py
index 5c00084..d741e46 100644
--- a/NorahUniversity/settings.py
+++ b/NorahUniversity/settings.py
@@ -135,9 +135,9 @@ WSGI_APPLICATION = 'NorahUniversity.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
- 'NAME': 'norahuniversity',
- 'USER': 'norahuniversity',
- 'PASSWORD': 'norahuniversity',
+ 'NAME': 'haikal_db',
+ 'USER': 'faheed',
+ 'PASSWORD': 'Faheed@215',
'HOST': '127.0.0.1',
'PORT': '5432',
}
diff --git a/NorahUniversity/urls.py b/NorahUniversity/urls.py
index 10dfb34..f7a39da 100644
--- a/NorahUniversity/urls.py
+++ b/NorahUniversity/urls.py
@@ -23,9 +23,11 @@ urlpatterns = [
# path('', include('recruitment.urls')),
path("ckeditor5/", include('django_ckeditor_5.urls')),
- path('form//', views.form_wizard_view, name='form_wizard'),
- path('form//submit/', views.submit_form, name='submit_form'),
-
+ path('application//', views.application_submit_form, name='application_submit_form'),
+ path('application//submit/', views.application_submit, name='application_submit'),
+ path('application//apply/', views.application_detail, name='application_detail'),
+ path('application//success/', views.application_success, name='application_success'),
+
path('api/templates/', views.list_form_templates, name='list_form_templates'),
path('api/templates/save/', views.save_form_template, name='save_form_template'),
path('api/templates//', views.load_form_template, name='load_form_template'),
diff --git a/recruitment/__pycache__/linkedin_service.cpython-312.pyc b/recruitment/__pycache__/linkedin_service.cpython-312.pyc
index d949652..19d53fa 100644
Binary files a/recruitment/__pycache__/linkedin_service.cpython-312.pyc and b/recruitment/__pycache__/linkedin_service.cpython-312.pyc differ
diff --git a/recruitment/__pycache__/signals.cpython-312.pyc b/recruitment/__pycache__/signals.cpython-312.pyc
index 32db21f..5dbffd2 100644
Binary files a/recruitment/__pycache__/signals.cpython-312.pyc 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 082c450..00b718a 100644
Binary files a/recruitment/__pycache__/urls.cpython-312.pyc and b/recruitment/__pycache__/urls.cpython-312.pyc differ
diff --git a/recruitment/__pycache__/views.cpython-312.pyc b/recruitment/__pycache__/views.cpython-312.pyc
index fc41342..5eb571d 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 2a9ae28..85f9dab 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/linkedin_service.py b/recruitment/linkedin_service.py
index b6702b9..e275f2d 100644
--- a/recruitment/linkedin_service.py
+++ b/recruitment/linkedin_service.py
@@ -5,16 +5,15 @@ from html import unescape
from urllib.parse import quote, urlencode
import requests
import logging
-from django.conf import settings
import time
-import random
-from django.utils import timezone
+from django.conf import settings
logger = logging.getLogger(__name__)
-# Define a constant for the API version for better maintenance
+# Define constants
LINKEDIN_API_VERSION = '2.0.0'
-LINKEDIN_VERSION = '202409' # Modern API version for header control
+LINKEDIN_VERSION = '202409'
+MAX_POST_CHARS = 3000 # LinkedIn's maximum character limit for shareCommentary
class LinkedInService:
def __init__(self):
@@ -23,11 +22,11 @@ class LinkedInService:
self.redirect_uri = settings.LINKEDIN_REDIRECT_URI
self.access_token = None
# Configuration for image processing wait time
- self.ASSET_STATUS_TIMEOUT = 15 # Max time (seconds) to wait for image processing
- self.ASSET_STATUS_INTERVAL = 2 # Check every 2 seconds
-
- # --- AUTHENTICATION & PROFILE ---
+ self.ASSET_STATUS_TIMEOUT = 15
+ self.ASSET_STATUS_INTERVAL = 2
+ # ---------------- AUTHENTICATION & PROFILE ----------------
+
def get_auth_url(self):
"""Generate LinkedIn OAuth URL"""
params = {
@@ -76,7 +75,7 @@ class LinkedInService:
logger.error(f"Error getting user profile: {e}")
raise
- # --- ASSET UPLOAD & STATUS ---
+ # ---------------- ASSET UPLOAD & STATUS ----------------
def get_asset_status(self, asset_urn):
"""Checks the status of a registered asset (image) to ensure it's READY."""
@@ -86,7 +85,7 @@ class LinkedInService:
'X-Restli-Protocol-Version': LINKEDIN_API_VERSION,
'LinkedIn-Version': LINKEDIN_VERSION,
}
-
+
try:
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
@@ -129,6 +128,7 @@ class LinkedInService:
"""Step 2: Upload image file and poll for 'READY' status."""
image_file.open()
image_content = image_file.read()
+ image_file.seek(0) # Reset pointer after reading
image_file.close()
headers = {
@@ -137,8 +137,8 @@ class LinkedInService:
response = requests.post(upload_url, headers=headers, data=image_content, timeout=60)
response.raise_for_status()
-
- # --- CRITICAL FIX: POLL FOR ASSET STATUS ---
+
+ # --- POLL FOR ASSET STATUS ---
start_time = time.time()
while time.time() - start_time < self.ASSET_STATUS_TIMEOUT:
try:
@@ -149,56 +149,55 @@ class LinkedInService:
return True
if status == "FAILED":
raise Exception(f"LinkedIn image processing failed for asset {asset_urn}")
-
+
logger.info(f"Asset {asset_urn} status: {status}. Waiting...")
time.sleep(self.ASSET_STATUS_INTERVAL)
-
+
except Exception as e:
logger.warning(f"Error during asset status check for {asset_urn}: {e}. Retrying.")
- time.sleep(self.ASSET_STATUS_INTERVAL * 2)
+ time.sleep(self.ASSET_STATUS_INTERVAL * 2)
- # If the loop times out, return True to attempt post, but log warning
logger.warning(f"Asset {asset_urn} timed out, but upload succeeded. Forcing post attempt.")
return True
- # --- POSTING LOGIC ---
+ # ---------------- POSTING UTILITIES ----------------
def clean_html_for_social_post(self, html_content):
- """Converts safe HTML to plain text with basic formatting (bullets, bold, newlines)."""
+ """Converts safe HTML to plain text with basic formatting."""
if not html_content:
return ""
text = html_content
-
+
# 1. Convert Bolding tags to *Markdown*
text = re.sub(r'(.*?) ', r'*\1*', text, flags=re.IGNORECASE)
text = re.sub(r'(.*?) ', r'*\1*', text, flags=re.IGNORECASE)
# 2. Handle Lists: Convert tags into a bullet point
- text = re.sub(r'(ul|ol|div)>', '\n', text, flags=re.IGNORECASE)
- text = re.sub(r' ]*>', 'β’ ', text, flags=re.IGNORECASE)
- text = re.sub(r' ', '\n', text, flags=re.IGNORECASE)
-
+ text = re.sub(r'(ul|ol|div)>', '\n', text, flags=re.IGNORECASE)
+ text = re.sub(r']*>', 'β’ ', text, flags=re.IGNORECASE)
+ text = re.sub(r' ', '\n', text, flags=re.IGNORECASE)
+
# 3. Handle Paragraphs and Line Breaks
text = re.sub(r'
', '\n\n', text, flags=re.IGNORECASE)
text = re.sub(r' ', '\n', text, flags=re.IGNORECASE)
-
+
# 4. Strip all remaining, unsupported HTML tags
clean_text = re.sub(r'<[^>]+>', '', text)
-
+
# 5. Unescape HTML entities
clean_text = unescape(clean_text)
-
+
# 6. Clean up excessive whitespace/newlines
clean_text = re.sub(r'(\n\s*){3,}', '\n\n', clean_text).strip()
-
+
return clean_text
def hashtags_list(self, hash_tags_str):
"""Convert comma-separated hashtags string to list"""
if not hash_tags_str:
return ["#HigherEd", "#Hiring", "#UniversityJobs"]
-
+
tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()]
tags = [tag if tag.startswith('#') else f'#{tag}' for tag in tags]
@@ -208,15 +207,18 @@ class LinkedInService:
return tags
def _build_post_message(self, job_posting):
- """Centralized logic to construct the professionally formatted text message."""
+ """
+ Constructs the final text message.
+ Includes a unique suffix for duplicate content prevention (422 fix).
+ """
message_parts = [
f"π₯ *Job Alert!* Weβre looking for a talented professional to join our team.",
f"π **{job_posting.title}** π",
]
-
+
if job_posting.department:
- message_parts.append(f"*{job_posting.department}*")
-
+ message_parts.append(f"*{job_posting.department}*")
+
message_parts.append("\n" + "=" * 25 + "\n")
# KEY DETAILS SECTION
@@ -229,7 +231,7 @@ class LinkedInService:
details_list.append(f"π Workplace: {job_posting.get_workplace_type_display()}")
if job_posting.salary_range:
details_list.append(f"π° Salary: {job_posting.salary_range}")
-
+
if details_list:
message_parts.append("*Key Information*:")
message_parts.extend(details_list)
@@ -239,11 +241,13 @@ class LinkedInService:
clean_description = self.clean_html_for_social_post(job_posting.description)
if clean_description:
message_parts.append(f"π *About the Role:*\n{clean_description}")
-
+
# CALL TO ACTION
if job_posting.application_url:
message_parts.append(f"\n\n---")
- message_parts.append(f"π **APPLY NOW:** {job_posting.application_url}")
+ # CRITICAL: Include the URL explicitly in the text body.
+ # When media_category is NONE, LinkedIn often makes these URLs clickable.
+ message_parts.append(f"π **APPLY NOW:** {job_posting.application_url}")
# HASHTAGS
hashtags = self.hashtags_list(job_posting.hash_tags)
@@ -252,19 +256,38 @@ class LinkedInService:
hashtags.insert(0, dept_hashtag)
message_parts.append("\n" + " ".join(hashtags))
-
- if len(message_parts)>=3000:
- message_parts=message_parts[0:2980]+"........"
-
- return "\n".join(message_parts)
+
+ final_message = "\n".join(message_parts)
+
+ # --- FIX: ADD UNIQUE SUFFIX AND HANDLE LENGTH (422 fix) ---
+ unique_suffix = f"\n\n| Ref: {int(time.time())}"
+
+ available_length = MAX_POST_CHARS - len(unique_suffix)
+
+ if len(final_message) > available_length:
+ logger.warning("Post message truncated due to character limit.")
+ final_message = final_message[:available_length - 3] + "..."
+
+ return final_message + unique_suffix
+
+
+ # ---------------- MAIN POSTING METHODS ----------------
def _send_ugc_post(self, person_urn, job_posting, media_category="NONE", media_list=None):
"""
- New private method to handle the final UGC post request (text or image).
- This eliminates the duplication between create_job_post and create_job_post_with_image.
+ Private method to handle the final UGC post request.
+ CRITICAL FIX: Avoids ARTICLE category if not using an image to prevent 402 errors.
"""
-
+
message = self._build_post_message(job_posting)
+
+ # --- FIX FOR 402: Force NONE if no image is present. ---
+ if media_category != "IMAGE":
+ # We explicitly force pure text share to avoid LinkedIn's link crawler
+ # which triggers the commercial 402 error on job reposts.
+ media_category = "NONE"
+ media_list = None
+ # --------------------------------------------------------
url = "https://api.linkedin.com/v2/ugcPosts"
headers = {
@@ -280,8 +303,8 @@ class LinkedInService:
"shareMediaCategory": media_category,
}
}
-
- if media_list:
+
+ if media_list and media_category == "IMAGE":
specific_content["com.linkedin.ugc.ShareContent"]["media"] = media_list
payload = {
@@ -294,8 +317,13 @@ class LinkedInService:
}
response = requests.post(url, headers=headers, json=payload, timeout=60)
+
+ # Log 402/422 details
+ if response.status_code in [402, 422]:
+ logger.error(f"{response.status_code} UGC Post Error Detail: {response.text}")
+
response.raise_for_status()
-
+
post_id = response.headers.get('x-restli-id', '')
post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
@@ -308,18 +336,21 @@ class LinkedInService:
def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn):
- """Step 3: Creates the final LinkedIn post payload with the image asset."""
+ """Creates the final LinkedIn post payload with the image asset."""
+
+ if not job_posting.application_url:
+ raise ValueError("Application URL is required for image link share on LinkedIn.")
- # Prepare the media list for the _send_ugc_post helper
+ # Media list for IMAGE category (retains link details)
+ # Note: This is an exception where we MUST provide link details for the image card
media_list = [{
"status": "READY",
"media": asset_urn,
"description": {"text": job_posting.title},
- "originalUrl": job_posting.application_url,
+ "originalUrl": job_posting.application_url,
"title": {"text": "Apply Now"}
}]
- # Use the helper method to send the post
return self._send_ugc_post(
person_urn=person_urn,
job_posting=job_posting,
@@ -344,47 +375,44 @@ class LinkedInService:
# Check for image and attempt post
try:
- # Assuming correct model path: job_posting.related_model_name.first().image_field_name
image_upload = job_posting.post_images.first().post_image
has_image = image_upload is not None
except Exception:
- pass # No image available
+ pass
if has_image:
try:
- # Step 1: Register
+ # Steps 1, 2, 3 for image post
upload_info = self.register_image_upload(person_urn)
asset_urn = upload_info['asset']
-
- # Step 2: Upload and WAIT FOR READY (Crucial for 422 fix)
self.upload_image_to_linkedin(
upload_info['upload_url'],
- image_upload,
+ image_upload,
asset_urn
)
-
- # Step 3: Create post with image
+
return self.create_job_post_with_image(
job_posting, image_upload, person_urn, asset_urn
)
except Exception as e:
logger.error(f"Image post failed, falling back to text: {e}")
- # Force fallback to text-only if image posting fails
- has_image = False
+ has_image = False
# === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) ===
- # Use the single helper method here
+ # The _send_ugc_post method now ensures this is a PURE text post
+ # to avoid the 402/ARTICLE-related issues.
return self._send_ugc_post(
person_urn=person_urn,
job_posting=job_posting,
- media_category="NONE"
+ media_category="NONE"
)
except Exception as e:
logger.error(f"Error creating LinkedIn post: {e}")
+ status_code = getattr(getattr(e, 'response', None), 'status_code', 500)
return {
'success': False,
'error': str(e),
- 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
+ 'status_code': status_code
}
\ No newline at end of file
diff --git a/recruitment/management/__pycache__/__init__.cpython-312.pyc b/recruitment/management/__pycache__/__init__.cpython-312.pyc
index 7308a1c..22977f0 100644
Binary files a/recruitment/management/__pycache__/__init__.cpython-312.pyc and b/recruitment/management/__pycache__/__init__.cpython-312.pyc differ
diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py
new file mode 100644
index 0000000..fcbfe6e
--- /dev/null
+++ b/recruitment/migrations/0001_initial.py
@@ -0,0 +1,476 @@
+# Generated by Django 5.2.7 on 2025-10-22 16:33
+
+import django.core.validators
+import django.db.models.deletion
+import django_ckeditor_5.fields
+import django_countries.fields
+import django_extensions.db.fields
+import recruitment.validators
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='BreakTime',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('start_time', models.TimeField(verbose_name='Start Time')),
+ ('end_time', models.TimeField(verbose_name='End Time')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='FormStage',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('name', models.CharField(help_text='Name of the stage', max_length=200)),
+ ('order', models.PositiveIntegerField(default=0, help_text='Order of the stage in the form')),
+ ('is_predefined', models.BooleanField(default=False, help_text='Whether this is a default resume stage')),
+ ],
+ options={
+ 'verbose_name': 'Form Stage',
+ 'verbose_name_plural': 'Form Stages',
+ 'ordering': ['order'],
+ },
+ ),
+ migrations.CreateModel(
+ name='HiringAgency',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')),
+ ('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')),
+ ('email', models.EmailField(blank=True, max_length=254)),
+ ('phone', models.CharField(blank=True, max_length=20)),
+ ('website', models.URLField(blank=True)),
+ ('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
+ ('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)),
+ ('address', models.TextField(blank=True, null=True)),
+ ],
+ options={
+ 'verbose_name': 'Hiring Agency',
+ 'verbose_name_plural': 'Hiring Agencies',
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Source',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('name', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, unique=True, verbose_name='Source Name')),
+ ('source_type', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, verbose_name='Source Type')),
+ ('description', models.TextField(blank=True, help_text='A description of the source', verbose_name='Description')),
+ ('ip_address', models.GenericIPAddressField(blank=True, help_text='The IP address of the source', null=True, verbose_name='IP Address')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('api_key', models.CharField(blank=True, help_text='API key for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Key')),
+ ('api_secret', models.CharField(blank=True, help_text='API secret for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Secret')),
+ ('trusted_ips', models.TextField(blank=True, help_text='Comma-separated list of trusted IP addresses', null=True, verbose_name='Trusted IP Addresses')),
+ ('is_active', models.BooleanField(default=True, help_text='Whether this source is active for integration', verbose_name='Active')),
+ ('integration_version', models.CharField(blank=True, help_text='Version of the integration protocol', max_length=50, verbose_name='Integration Version')),
+ ('last_sync_at', models.DateTimeField(blank=True, help_text='Timestamp of the last successful synchronization', null=True, verbose_name='Last Sync At')),
+ ('sync_status', models.CharField(blank=True, choices=[('IDLE', 'Idle'), ('SYNCING', 'Syncing'), ('ERROR', 'Error'), ('DISABLED', 'Disabled')], default='IDLE', max_length=20, verbose_name='Sync Status')),
+ ],
+ options={
+ 'verbose_name': 'Source',
+ 'verbose_name_plural': 'Sources',
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ZoomMeeting',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('topic', models.CharField(max_length=255, verbose_name='Topic')),
+ ('meeting_id', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID')),
+ ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
+ ('duration', models.PositiveIntegerField(verbose_name='Duration')),
+ ('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
+ ('join_url', models.URLField(verbose_name='Join URL')),
+ ('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
+ ('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
+ ('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
+ ('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
+ ('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
+ ('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
+ ('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='FormField',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('label', models.CharField(help_text='Label for the field', max_length=200)),
+ ('field_type', models.CharField(choices=[('text', 'Text Input'), ('email', 'Email'), ('phone', 'Phone'), ('textarea', 'Text Area'), ('file', 'File Upload'), ('date', 'Date Picker'), ('select', 'Dropdown'), ('radio', 'Radio Buttons'), ('checkbox', 'Checkboxes')], help_text='Type of the field', max_length=20)),
+ ('placeholder', models.CharField(blank=True, help_text='Placeholder text', max_length=200)),
+ ('required', models.BooleanField(default=False, help_text='Whether the field is required')),
+ ('order', models.PositiveIntegerField(default=0, help_text='Order of the field in the stage')),
+ ('is_predefined', models.BooleanField(default=False, help_text='Whether this is a default field')),
+ ('options', models.JSONField(blank=True, default=list, help_text='Options for selection fields (stored as JSON array)')),
+ ('file_types', models.CharField(blank=True, help_text="Allowed file types (comma-separated, e.g., '.pdf,.doc,.docx')", max_length=200)),
+ ('max_file_size', models.PositiveIntegerField(default=5, help_text='Maximum file size in MB (default: 5MB)')),
+ ('multiple_files', models.BooleanField(default=False, help_text='Allow multiple files to be uploaded')),
+ ('max_files', models.PositiveIntegerField(default=1, help_text='Maximum number of files allowed (when multiple_files is True)')),
+ ('stage', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='recruitment.formstage')),
+ ],
+ options={
+ 'verbose_name': 'Form Field',
+ 'verbose_name_plural': 'Form Fields',
+ 'ordering': ['order'],
+ },
+ ),
+ migrations.CreateModel(
+ name='FormTemplate',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('name', models.CharField(help_text='Name of the form template', max_length=200)),
+ ('description', models.TextField(blank=True, help_text='Description of the form template')),
+ ('is_active', models.BooleanField(default=False, help_text='Whether this template is active')),
+ ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name': 'Form Template',
+ 'verbose_name_plural': 'Form Templates',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='FormSubmission',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('submitted_at', models.DateTimeField(auto_now_add=True, db_index=True)),
+ ('applicant_name', models.CharField(blank=True, help_text='Name of the applicant', max_length=200)),
+ ('applicant_email', models.EmailField(blank=True, db_index=True, help_text='Email of the applicant', max_length=254)),
+ ('submitted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='form_submissions', to=settings.AUTH_USER_MODEL)),
+ ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='recruitment.formtemplate')),
+ ],
+ options={
+ 'verbose_name': 'Form Submission',
+ 'verbose_name_plural': 'Form Submissions',
+ 'ordering': ['-submitted_at'],
+ },
+ ),
+ migrations.AddField(
+ model_name='formstage',
+ name='template',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='recruitment.formtemplate'),
+ ),
+ migrations.CreateModel(
+ name='Candidate',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('first_name', models.CharField(max_length=255, verbose_name='First Name')),
+ ('last_name', models.CharField(max_length=255, verbose_name='Last Name')),
+ ('email', models.EmailField(db_index=True, max_length=254, verbose_name='Email')),
+ ('phone', models.CharField(max_length=20, verbose_name='Phone')),
+ ('address', models.TextField(max_length=200, verbose_name='Address')),
+ ('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')),
+ ('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')),
+ ('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')),
+ ('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
+ ('applied', models.BooleanField(default=False, verbose_name='Applied')),
+ ('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')),
+ ('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')),
+ ('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
+ ('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')),
+ ('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
+ ('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status')),
+ ('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
+ ('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')),
+ ('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
+ ('ai_analysis_data', models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data')),
+ ('submitted_by_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_candidates', to='recruitment.hiringagency', verbose_name='Submitted by Agency')),
+ ],
+ options={
+ 'verbose_name': 'Candidate',
+ 'verbose_name_plural': 'Candidates',
+ },
+ ),
+ migrations.CreateModel(
+ name='JobPosting',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('title', models.CharField(max_length=200)),
+ ('department', models.CharField(blank=True, max_length=100)),
+ ('job_type', models.CharField(choices=[('FULL_TIME', 'Full-time'), ('PART_TIME', 'Part-time'), ('CONTRACT', 'Contract'), ('INTERNSHIP', 'Internship'), ('FACULTY', 'Faculty'), ('TEMPORARY', 'Temporary')], default='FULL_TIME', max_length=20)),
+ ('workplace_type', models.CharField(choices=[('ON_SITE', 'On-site'), ('REMOTE', 'Remote'), ('HYBRID', 'Hybrid')], default='ON_SITE', max_length=20)),
+ ('location_city', models.CharField(blank=True, max_length=100)),
+ ('location_state', models.CharField(blank=True, max_length=100)),
+ ('location_country', models.CharField(default='Saudia Arabia', max_length=100)),
+ ('description', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Description')),
+ ('qualifications', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
+ ('salary_range', models.CharField(blank=True, help_text='e.g., $60,000 - $80,000', max_length=200)),
+ ('benefits', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
+ ('application_url', models.URLField(blank=True, help_text='URL where candidates apply', null=True, validators=[django.core.validators.URLValidator()])),
+ ('application_deadline', models.DateField(db_index=True)),
+ ('application_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
+ ('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)),
+ ('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)),
+ ('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20)),
+ ('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])),
+ ('linkedin_post_id', models.CharField(blank=True, help_text='LinkedIn post ID after posting', max_length=200)),
+ ('linkedin_post_url', models.URLField(blank=True, help_text='Direct URL to LinkedIn post')),
+ ('posted_to_linkedin', models.BooleanField(default=False)),
+ ('linkedin_post_status', models.CharField(blank=True, help_text='Status of LinkedIn posting', max_length=50)),
+ ('linkedin_posted_at', models.DateTimeField(blank=True, null=True)),
+ ('published_at', models.DateTimeField(blank=True, db_index=True, null=True)),
+ ('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)),
+ ('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)),
+ ('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')),
+ ('max_applications', models.PositiveIntegerField(blank=True, default=1000, help_text='Maximum number of applications allowed', null=True)),
+ ('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')),
+ ('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
+ ('cancelled_at', models.DateTimeField(blank=True, null=True)),
+ ('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
+ ('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')),
+ ],
+ options={
+ 'verbose_name': 'Job Posting',
+ 'verbose_name_plural': 'Job Postings',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='InterviewSchedule',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
+ ('end_date', models.DateField(db_index=True, verbose_name='End Date')),
+ ('working_days', models.JSONField(verbose_name='Working Days')),
+ ('start_time', models.TimeField(verbose_name='Start Time')),
+ ('end_time', models.TimeField(verbose_name='End Time')),
+ ('break_start_time', models.TimeField(blank=True, null=True, verbose_name='Break Start Time')),
+ ('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
+ ('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
+ ('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
+ ('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate')),
+ ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
+ ],
+ ),
+ migrations.AddField(
+ model_name='formtemplate',
+ name='job',
+ field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'),
+ ),
+ migrations.AddField(
+ model_name='candidate',
+ name='job',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'),
+ ),
+ migrations.CreateModel(
+ name='JobPostingImage',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('post_image', models.ImageField(upload_to='post/', validators=[recruitment.validators.validate_image_size])),
+ ('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Profile',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size])),
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='SharedFormTemplate',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('is_public', models.BooleanField(default=False, help_text='Whether this template is publicly available')),
+ ('shared_with', models.ManyToManyField(blank=True, related_name='shared_templates', to=settings.AUTH_USER_MODEL)),
+ ('template', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='recruitment.formtemplate')),
+ ],
+ options={
+ 'verbose_name': 'Shared Form Template',
+ 'verbose_name_plural': 'Shared Form Templates',
+ },
+ ),
+ migrations.CreateModel(
+ name='IntegrationLog',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('action', models.CharField(choices=[('REQUEST', 'Request'), ('RESPONSE', 'Response'), ('ERROR', 'Error'), ('SYNC', 'Sync'), ('CREATE_JOB', 'Create Job'), ('UPDATE_JOB', 'Update Job')], max_length=20, verbose_name='Action')),
+ ('endpoint', models.CharField(blank=True, max_length=255, verbose_name='Endpoint')),
+ ('method', models.CharField(blank=True, max_length=10, verbose_name='HTTP Method')),
+ ('request_data', models.JSONField(blank=True, null=True, verbose_name='Request Data')),
+ ('response_data', models.JSONField(blank=True, null=True, verbose_name='Response Data')),
+ ('status_code', models.CharField(blank=True, max_length=10, verbose_name='Status Code')),
+ ('error_message', models.TextField(blank=True, verbose_name='Error Message')),
+ ('ip_address', models.GenericIPAddressField(verbose_name='IP Address')),
+ ('user_agent', models.CharField(blank=True, max_length=255, verbose_name='User Agent')),
+ ('processing_time', models.FloatField(blank=True, null=True, verbose_name='Processing Time (seconds)')),
+ ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integration_logs', to='recruitment.source', verbose_name='Source')),
+ ],
+ options={
+ 'verbose_name': 'Integration Log',
+ 'verbose_name_plural': 'Integration Logs',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='TrainingMaterial',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('title', models.CharField(max_length=255, verbose_name='Title')),
+ ('content', django_ckeditor_5.fields.CKEditor5Field(blank=True, verbose_name='Content')),
+ ('video_link', models.URLField(blank=True, verbose_name='Video Link')),
+ ('file', models.FileField(blank=True, upload_to='training_materials/', verbose_name='File')),
+ ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Created by')),
+ ],
+ options={
+ 'verbose_name': 'Training Material',
+ 'verbose_name_plural': 'Training Materials',
+ },
+ ),
+ migrations.CreateModel(
+ name='ScheduledInterview',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
+ ('interview_time', models.TimeField(verbose_name='Interview Time')),
+ ('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')),
+ ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
+ ('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')),
+ ('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='MeetingComment',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content')),
+ ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meeting_comments', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
+ ('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recruitment.zoommeeting', verbose_name='Meeting')),
+ ],
+ options={
+ 'verbose_name': 'Meeting Comment',
+ 'verbose_name_plural': 'Meeting Comments',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='FieldResponse',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
+ ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
+ ('value', models.JSONField(blank=True, help_text='Response value (stored as JSON)', null=True)),
+ ('uploaded_file', models.FileField(blank=True, null=True, upload_to='form_uploads/')),
+ ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formfield')),
+ ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formsubmission')),
+ ],
+ options={
+ 'verbose_name': 'Field Response',
+ 'verbose_name_plural': 'Field Responses',
+ 'indexes': [models.Index(fields=['submission'], name='recruitment_submiss_474130_idx'), models.Index(fields=['field'], name='recruitment_field_i_097e5b_idx')],
+ },
+ ),
+ migrations.AddIndex(
+ model_name='formsubmission',
+ index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='interviewschedule',
+ index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='interviewschedule',
+ index=models.Index(fields=['end_date'], name='recruitment_end_dat_aeb00e_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='interviewschedule',
+ index=models.Index(fields=['created_by'], name='recruitment_created_d0bdcc_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='formtemplate',
+ index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='formtemplate',
+ index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='candidate',
+ index=models.Index(fields=['stage'], name='recruitment_stage_f1c6eb_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='candidate',
+ index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='jobposting',
+ index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='jobposting',
+ index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='scheduledinterview',
+ index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='scheduledinterview',
+ index=models.Index(fields=['interview_date', 'interview_time'], name='recruitment_intervi_7f5877_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='scheduledinterview',
+ index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'),
+ ),
+ ]
diff --git a/recruitment/signals.py b/recruitment/signals.py
index ebba334..174cf35 100644
--- a/recruitment/signals.py
+++ b/recruitment/signals.py
@@ -26,7 +26,7 @@ def format_job(sender, instance, created, **kwargs):
schedule_type=Schedule.ONCE
).first()
- if instance.is_active and instance.application_deadline:
+ if instance.STATUS_CHOICES=='ACTIVE' and instance.application_deadline:
if not existing_schedule:
# Create a new schedule if one does not exist
schedule(
diff --git a/recruitment/tests.py b/recruitment/tests.py
index ecf0a7b..20feb89 100644
--- a/recruitment/tests.py
+++ b/recruitment/tests.py
@@ -248,7 +248,7 @@ class ViewTests(BaseTestCase):
}
response = self.client.post(
- reverse('submit_form', kwargs={'template_id': template.id}),
+ reverse('application_submit', kwargs={'template_id': template.id}),
data
)
# After successful submission, should redirect to success page
@@ -434,7 +434,7 @@ class IntegrationTests(BaseTestCase):
}
response = self.client.post(
- reverse('submit_form', kwargs={'template_id': template.id}),
+ reverse('application_submit', kwargs={'template_id': template.id}),
form_data
)
@@ -493,7 +493,7 @@ class AuthenticationTests(BaseTestCase):
)
response = self.client.post(
- reverse('submit_form', kwargs={'template_id': template.id}),
+ reverse('application_submit', kwargs={'template_id': template.id}),
{}
)
# Should redirect to login page
@@ -525,7 +525,7 @@ class EdgeCaseTests(BaseTestCase):
# Submit form twice
response1 = self.client.post(
- reverse('submit_form', kwargs={'template_id': template.id}),
+ reverse('application_submit', kwargs={'template_id': template.id}),
{'field_1': 'John', 'field_2': 'Doe'}
)
diff --git a/recruitment/tests_advanced.py b/recruitment/tests_advanced.py
index 70f1dac..9e3e4d1 100644
--- a/recruitment/tests_advanced.py
+++ b/recruitment/tests_advanced.py
@@ -744,7 +744,7 @@ class AdvancedIntegrationTests(TransactionTestCase):
}
response = self.client.post(
- reverse('submit_form', kwargs={'template_id': template.id}),
+ reverse('application_submit', kwargs={'template_id': template.id}),
submission_data
)
self.assertEqual(response.status_code, 302) # Redirect to success page
diff --git a/recruitment/urls.py b/recruitment/urls.py
index 604ad08..d3e418f 100644
--- a/recruitment/urls.py
+++ b/recruitment/urls.py
@@ -14,8 +14,7 @@ urlpatterns = [
path('jobs//update/', views.edit_job, name='job_update'),
# path('jobs//delete/', views., name='job_delete'),
path('jobs//', views.job_detail, name='job_detail'),
- path('jobs//candidate/', views.job_detail_candidate, name='job_detail_candidate'),
- path('jobs//candidate/application/success', views.application_success, name='application_success'),
+
path('careers/',views.kaauh_career,name='kaauh_career'),
# LinkedIn Integration URLs
@@ -83,8 +82,8 @@ urlpatterns = [
path('htmx//candidate_update_status/', views.candidate_update_status, name='candidate_update_status'),
- path('forms/form//submit/', views.submit_form, name='submit_form'),
- path('forms/form//', views.form_wizard_view, name='form_wizard'),
+ # path('forms/form//submit/', views.submit_form, name='submit_form'),
+ # path('forms/form//', views.form_wizard_view, name='form_wizard'),
path('forms//submissions//', views.form_submission_details, name='form_submission_details'),
path('forms/template//submissions/', views.form_template_submissions_list, name='form_template_submissions_list'),
path('forms/template//all-submissions/', views.form_template_all_submissions, name='form_template_all_submissions'),
@@ -140,6 +139,7 @@ urlpatterns = [
# Meeting Comments URLs
path('meetings//comments/add/', views.add_meeting_comment, name='add_meeting_comment'),
path('meetings//comments//edit/', views.edit_meeting_comment, name='edit_meeting_comment'),
+
path('meetings//comments//delete/', views.delete_meeting_comment, name='delete_meeting_comment'),
path('meetings//set_meeting_candidate/', views.set_meeting_candidate, name='set_meeting_candidate'),
diff --git a/recruitment/views.py b/recruitment/views.py
index 7689124..9d535fa 100644
--- a/recruitment/views.py
+++ b/recruitment/views.py
@@ -280,7 +280,7 @@ def create_job(request):
try:
job = form.save(commit=False)
job.save()
- job_apply_url_relative=reverse('job_detail_candidate',kwargs={'slug':job.slug})
+ job_apply_url_relative=reverse('application_detail',kwargs={'slug':job.slug})
job_apply_url_absolute=request.build_absolute_uri(job_apply_url_relative)
job.application_url=job_apply_url_absolute
# FormTemplate.objects.create(job=job, is_active=False, name=job.title,created_by=request.user)
@@ -512,9 +512,9 @@ def kaauh_career(request):
# job detail facing the candidate:
-def job_detail_candidate(request, slug):
+def application_detail(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
- return render(request, "forms/job_detail_candidate.html", {"job": job})
+ return render(request, "forms/application_detail.html", {"job": job})
from django_q.tasks import async_task
@@ -800,7 +800,7 @@ def delete_form_template(request, template_id):
)
-def form_wizard_view(request, template_slug):
+def application_submit_form(request, template_slug):
"""Display the form as a step-by-step wizard"""
template = get_object_or_404(FormTemplate, slug=template_slug, is_active=True)
job_id = template.job.internal_job_id
@@ -811,24 +811,24 @@ def form_wizard_view(request, template_slug):
request,
'Application limit reached: This job is no longer accepting new applications. Please explore other available positions.'
)
- return redirect('job_detail_candidate',slug=job.slug)
+ return redirect('application_detail',slug=job.slug)
if job.is_expired:
messages.error(
request,
'Application deadline passed: This job is no longer accepting new applications. Please explore other available positions.'
)
- return redirect('job_detail_candidate',slug=job.slug)
+ return redirect('application_detail',slug=job.slug)
return render(
request,
- "forms/form_wizard.html",
+ "forms/application_submit_form.html",
{"template_slug": template_slug, "job_id": job_id},
)
@csrf_exempt
@require_POST
-def submit_form(request, template_slug):
+def application_submit(request, template_slug):
"""Handle form submission"""
template = get_object_or_404(FormTemplate, slug=template_slug)
job = template.job
@@ -2292,7 +2292,13 @@ def edit_meeting_comment(request, slug, comment_id):
return redirect('meeting_details', slug=slug)
else:
form = MeetingCommentForm(instance=comment)
-
+ print("hi")
+ context = {
+ 'form': form,
+ 'meeting': meeting,
+ 'comment':comment
+ }
+ return render(request, 'includes/edit_comment_form.html', context)
@login_required
def delete_meeting_comment(request, slug, comment_id):
diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py
index 50c05e3..d204d97 100644
--- a/recruitment/views_frontend.py
+++ b/recruitment/views_frontend.py
@@ -394,6 +394,27 @@ def dashboard_view(request):
).count()
high_potential_ratio = round((high_potential_count / total_candidates) * 100, 1) if total_candidates > 0 else 0
+ jobs=models.JobPosting.objects.all().order_by('internal_job_id')
+ selected_job_id=request.GET.get('selected_job_id','')
+ candidate_stage=['APPLIED','EXAM','INTERVIEW','OFFER']
+ apply_count,exam_count,interview_count,offer_count=[0]*4
+
+ if selected_job_id:
+ job=jobs.get(internal_job_id=selected_job_id)
+ apply_count=job.screening_candidates_count
+ exam_count=job.exam_candidates_count
+ interview_count=job.interview_candidates_count
+ offer_count=job.offer_candidates_count
+ all_candidates_count=job.all_candidates_count
+
+ else: #default job
+ job=jobs.first()
+ apply_count=job.screening_candidates_count
+ exam_count=job.exam_candidates_count
+ interview_count=job.interview_candidates_count
+ offer_count=job.offer_candidates_count
+ all_candidates_count=job.all_candidates_count
+ candidates_count=[ apply_count,exam_count,interview_count,offer_count ]
context = {
'total_jobs': total_jobs,
@@ -409,6 +430,14 @@ def dashboard_view(request):
'high_potential_count': high_potential_count,
'high_potential_ratio': high_potential_ratio,
'scored_ratio': scored_ratio,
+ 'current_job_id':selected_job_id,
+ 'jobs':jobs,
+ 'all_candidates_count':all_candidates_count,
+ 'candidate_stage':json.dumps(candidate_stage),
+ 'candidates_count':json.dumps(candidates_count)
+ ,'my_job':job
+
+
}
return render(request, 'recruitment/dashboard.html', context)
@login_required
diff --git a/templates/forms/job_detail_candidate.html b/templates/forms/application_detail.html
similarity index 96%
rename from templates/forms/job_detail_candidate.html
rename to templates/forms/application_detail.html
index 0ecccf1..9b98017 100644
--- a/templates/forms/job_detail_candidate.html
+++ b/templates/forms/application_detail.html
@@ -42,7 +42,7 @@
{% trans "Review the job details, then apply below." %}
{% if job.form_template %}
-
+
{% trans "Apply for this Position" %}
{% endif %}
@@ -102,7 +102,7 @@
{% if job.form_template %}
-
+
{% trans "Apply for this Position" %}
{% endif %}
diff --git a/templates/forms/form_wizard.html b/templates/forms/application_submit_form
similarity index 99%
rename from templates/forms/form_wizard.html
rename to templates/forms/application_submit_form
index 1447255..fdc9972 100644
--- a/templates/forms/form_wizard.html
+++ b/templates/forms/application_submit_form
@@ -824,7 +824,7 @@
});
try {
- const response = await fetch(`/form/${state.templateId}/submit/`, {
+ const response = await fetch(`/application/${state.templateId}/submit/`, {
method: 'POST',
body: formData
// IMPORTANT: Do NOT set Content-Type header when using FormData
diff --git a/templates/forms/form_templates_list.html b/templates/forms/form_templates_list.html
index 7cecea0..8f9440e 100644
--- a/templates/forms/form_templates_list.html
+++ b/templates/forms/form_templates_list.html
@@ -231,7 +231,7 @@
-
+
@@ -286,7 +286,7 @@
{{ template.updated_at|date:"M d, Y" }}
Add Comment
{% if 'HX-Request' in request.headers %}
- Cancel
+ Cancel
{% endif %}
diff --git a/templates/includes/comment_list.html b/templates/includes/comment_list.html
index 40e37ef..5cda9a5 100644
--- a/templates/includes/comment_list.html
+++ b/templates/includes/comment_list.html
@@ -3,7 +3,7 @@
diff --git a/templates/includes/edit_comment_form.html b/templates/includes/edit_comment_form.html
index a689514..6c50988 100644
--- a/templates/includes/edit_comment_form.html
+++ b/templates/includes/edit_comment_form.html
@@ -15,7 +15,7 @@
{% endif %}
- Update Comment
+ Update Comment
{% if 'HX-Request' in request.headers %}
Cancel
{% endif %}
diff --git a/templates/jobs/career.html b/templates/jobs/career.html
index 62768fc..c122663 100644
--- a/templates/jobs/career.html
+++ b/templates/jobs/career.html
@@ -248,7 +248,7 @@
{% trans 'Apply' %}
diff --git a/templates/jobs/create_job.html b/templates/jobs/create_job.html
index 51cf403..20fbe2f 100644
--- a/templates/jobs/create_job.html
+++ b/templates/jobs/create_job.html
@@ -117,28 +117,21 @@
-
+
{% trans "Job Title" %} *
{{ form.title }}
{% if form.title.errors %}
{{ form.title.errors }}
{% endif %}
-
+
{% trans "Job Type" %} *
{{ form.job_type }}
{% if form.job_type.errors %}
{{ form.job_type.errors }}
{% endif %}
-
-
-
-
{% trans "Department" %}
- {{ form.department }}
- {% if form.department.errors %}
{{ form.department.errors }}
{% endif %}
-
-
+
{% trans "Workplace Type" %} *
@@ -146,33 +139,18 @@
{% if form.workplace_type.errors %}
{{ form.workplace_type.errors }}
{% endif %}
-
-
-
-
-
- {# ================================================= #}
- {# SECTION 2: INTERNAL AND PROMOTION #}
- {# ================================================= #}
-
-
-
-
-
-
{% trans "Position Number" %}
- {{ form.position_number }}
- {% if form.position_number.errors %}
{{ form.position_number.errors }}
{% endif %}
+
{% trans "Application Deadline" %}*
+ {{ form.application_deadline }}
+ {% if form.application_deadline.errors %}
{{ form.application_deadline.errors }}
{% endif %}
-
{% trans "Reports To" %}
- {{ form.reporting_to }}
- {% if form.reporting_to.errors %}
{{ form.reporting_to.errors }}
{% endif %}
+
{% trans "Department" %}
+ {{ form.department }}
+ {% if form.department.errors %}
{{ form.department.errors }}
{% endif %}
@@ -189,72 +167,14 @@
{% if form.max_applications.errors %}
{{ form.max_applications.errors }}
{% endif %}
-
-
-
-
-
{% trans "Hashtags (For Promotion/Search on Linkedin)" %}
- {{ form.hash_tags }}
- {% if form.hash_tags.errors %}
{{ form.hash_tags.errors }}
{% endif %}
-
{% trans "Comma-separated list of hashtags, e.g., #hiring, #professor" %}
-
-
- {# ================================================= #}
- {# SECTION 3: LOCATION AND DATES #}
- {# ================================================= #}
+
-
-
-
-
-
-
-
{% trans "City" %}
- {{ form.location_city }}
- {% if form.location_city.errors %}
{{ form.location_city.errors }}
{% endif %}
-
-
-
-
-
{% trans "State/Province" %}
- {{ form.location_state }}
- {% if form.location_state.errors %}
{{ form.location_state.errors }}
{% endif %}
-
-
-
-
-
{% trans "Country" %}
- {{ form.location_country }}
- {% if form.location_country.errors %}
{{ form.location_country.errors }}
{% endif %}
-
-
-
-
-
-
{% trans "Application Deadline" %}*
- {{ form.application_deadline }}
- {% if form.application_deadline.errors %}
{{ form.application_deadline.errors }}
{% endif %}
-
-
-
-
-
-
{% trans "Salary Range" %}
- {{ form.salary_range }}
- {% if form.salary_range.errors %}
{{ form.salary_range.errors }}
{% endif %}
-
-
-
-
-
-
+
{# ================================================= #}
{# SECTION 4: JOB CONTENT (CKEDITOR 5 Fields) #}
{# ================================================= #}
@@ -313,8 +233,90 @@
+ {# ================================================= #}
+ {# SECTION 2: INTERNAL AND PROMOTION #}
+ {# ================================================= #}
+
+
+
+
+
+
+
+
{% trans "Position Number" %}
+ {{ form.position_number }}
+ {% if form.position_number.errors %}
{{ form.position_number.errors }}
{% endif %}
+
+
+
+
+
{% trans "Reports To" %}
+ {{ form.reporting_to }}
+ {% if form.reporting_to.errors %}
{{ form.reporting_to.errors }}
{% endif %}
+
+
+
+
+
+
{% trans "Hashtags (For Promotion/Search on Linkedin)" %}
+ {{ form.hash_tags }}
+ {% if form.hash_tags.errors %}
{{ form.hash_tags.errors }}
{% endif %}
+
{% trans "Comma-separated list of hashtags, e.g., #hiring, #professor" %}
+
+
+
+
+
+
+ {# ================================================= #}
+ {# SECTION 3: LOCATION AND Salary #}
+ {# ================================================= #}
+
+
+
+
+
+
+
+
{% trans "City" %}
+ {{ form.location_city }}
+ {% if form.location_city.errors %}
{{ form.location_city.errors }}
{% endif %}
+
+
+
+
+
{% trans "State/Province" %}
+ {{ form.location_state }}
+ {% if form.location_state.errors %}
{{ form.location_state.errors }}
{% endif %}
+
+
+
+
+
{% trans "Country" %}
+ {{ form.location_country }}
+ {% if form.location_country.errors %}
{{ form.location_country.errors }}
{% endif %}
+
+
+
+
+
+
+
+
{% trans "Salary Range" %}
+ {{ form.salary_range }}
+ {% if form.salary_range.errors %}
{{ form.salary_range.errors }}
{% endif %}
+
+
+
+
+
+
{# ================================================= #}
{# ACTION BUTTONS #}
diff --git a/templates/jobs/edit_job.html b/templates/jobs/edit_job.html
index 51cf403..20fbe2f 100644
--- a/templates/jobs/edit_job.html
+++ b/templates/jobs/edit_job.html
@@ -117,28 +117,21 @@
-
+
{% trans "Job Title" %} *
{{ form.title }}
{% if form.title.errors %}
{{ form.title.errors }}
{% endif %}
-
+
{% trans "Job Type" %} *
{{ form.job_type }}
{% if form.job_type.errors %}
{{ form.job_type.errors }}
{% endif %}
-
-
-
-
{% trans "Department" %}
- {{ form.department }}
- {% if form.department.errors %}
{{ form.department.errors }}
{% endif %}
-
-
+
{% trans "Workplace Type" %} *
@@ -146,33 +139,18 @@
{% if form.workplace_type.errors %}
{{ form.workplace_type.errors }}
{% endif %}
-
-
-
-
-
- {# ================================================= #}
- {# SECTION 2: INTERNAL AND PROMOTION #}
- {# ================================================= #}
-
-
-
-
-
-
{% trans "Position Number" %}
- {{ form.position_number }}
- {% if form.position_number.errors %}
{{ form.position_number.errors }}
{% endif %}
+
{% trans "Application Deadline" %}*
+ {{ form.application_deadline }}
+ {% if form.application_deadline.errors %}
{{ form.application_deadline.errors }}
{% endif %}
-
{% trans "Reports To" %}
- {{ form.reporting_to }}
- {% if form.reporting_to.errors %}
{{ form.reporting_to.errors }}
{% endif %}
+
{% trans "Department" %}
+ {{ form.department }}
+ {% if form.department.errors %}
{{ form.department.errors }}
{% endif %}
@@ -189,72 +167,14 @@
{% if form.max_applications.errors %}
{{ form.max_applications.errors }}
{% endif %}
-
-
-
-
-
{% trans "Hashtags (For Promotion/Search on Linkedin)" %}
- {{ form.hash_tags }}
- {% if form.hash_tags.errors %}
{{ form.hash_tags.errors }}
{% endif %}
-
{% trans "Comma-separated list of hashtags, e.g., #hiring, #professor" %}
-
-
- {# ================================================= #}
- {# SECTION 3: LOCATION AND DATES #}
- {# ================================================= #}
+
-
-
-
-
-
-
-
{% trans "City" %}
- {{ form.location_city }}
- {% if form.location_city.errors %}
{{ form.location_city.errors }}
{% endif %}
-
-
-
-
-
{% trans "State/Province" %}
- {{ form.location_state }}
- {% if form.location_state.errors %}
{{ form.location_state.errors }}
{% endif %}
-
-
-
-
-
{% trans "Country" %}
- {{ form.location_country }}
- {% if form.location_country.errors %}
{{ form.location_country.errors }}
{% endif %}
-
-
-
-
-
-
{% trans "Application Deadline" %}*
- {{ form.application_deadline }}
- {% if form.application_deadline.errors %}
{{ form.application_deadline.errors }}
{% endif %}
-
-
-
-
-
-
{% trans "Salary Range" %}
- {{ form.salary_range }}
- {% if form.salary_range.errors %}
{{ form.salary_range.errors }}
{% endif %}
-
-
-
-
-
-
+
{# ================================================= #}
{# SECTION 4: JOB CONTENT (CKEDITOR 5 Fields) #}
{# ================================================= #}
@@ -313,8 +233,90 @@
+ {# ================================================= #}
+ {# SECTION 2: INTERNAL AND PROMOTION #}
+ {# ================================================= #}
+
+
+
+
+
+
+
+
{% trans "Position Number" %}
+ {{ form.position_number }}
+ {% if form.position_number.errors %}
{{ form.position_number.errors }}
{% endif %}
+
+
+
+
+
{% trans "Reports To" %}
+ {{ form.reporting_to }}
+ {% if form.reporting_to.errors %}
{{ form.reporting_to.errors }}
{% endif %}
+
+
+
+
+
+
{% trans "Hashtags (For Promotion/Search on Linkedin)" %}
+ {{ form.hash_tags }}
+ {% if form.hash_tags.errors %}
{{ form.hash_tags.errors }}
{% endif %}
+
{% trans "Comma-separated list of hashtags, e.g., #hiring, #professor" %}
+
+
+
+
+
+
+ {# ================================================= #}
+ {# SECTION 3: LOCATION AND Salary #}
+ {# ================================================= #}
+
+
+
+
+
+
+
+
{% trans "City" %}
+ {{ form.location_city }}
+ {% if form.location_city.errors %}
{{ form.location_city.errors }}
{% endif %}
+
+
+
+
+
{% trans "State/Province" %}
+ {{ form.location_state }}
+ {% if form.location_state.errors %}
{{ form.location_state.errors }}
{% endif %}
+
+
+
+
+
{% trans "Country" %}
+ {{ form.location_country }}
+ {% if form.location_country.errors %}
{{ form.location_country.errors }}
{% endif %}
+
+
+
+
+
+
+
+
{% trans "Salary Range" %}
+ {{ form.salary_range }}
+ {% if form.salary_range.errors %}
{{ form.salary_range.errors }}
{% endif %}
+
+
+
+
+
+
{# ================================================= #}
{# ACTION BUTTONS #}
diff --git a/templates/jobs/job_detail.html b/templates/jobs/job_detail.html
index 4b4ff5d..673fc50 100644
--- a/templates/jobs/job_detail.html
+++ b/templates/jobs/job_detail.html
@@ -4,7 +4,6 @@
{% block title %}{{ job.title }} - University ATS{% endblock %}
{% block customCSS %}
-
{% endblock %}
@@ -238,12 +152,12 @@
Home
Jobs
- Job Detail
+ Job Detail
- {# LEFT COLUMN: JOB DETAILS WITH TABS #}
+ {# LEFT COLUMN: JOB DETAILS (NO TABS) #}
@@ -252,276 +166,136 @@
{{ job.title }}
{% trans "JOB ID: "%}{{ job.internal_job_id }}
+
+ {# Deadline #}
+ {% if job.application_deadline %}
+
+
+ {% trans "Deadline:" %} {{ job.application_deadline }}
+
+ {% endif %}
-
-
- {# Corrected status badge logic to close the span correctly #}
- {% if job.status == "ACTIVE" %}
-
- {% elif job.status == "DRAFT" %}
-
- {% elif job.status == "CLOSED" %}
-
- {% elif job.status == "CANCELLED" %}
-
- {% elif job.status == "ARCHIVED" %}
-
- {% else %}
-
- {% endif %}
- {{ job.get_status_display }}
+
+
+ {# Status badge #}
+
+
+ {{ job.get_status_display }}
+
-
+
+
+
+ {# Share Public Link Button #}
+
+
+ {% trans "Share Public Link" %}
+
+
+
+ {% trans "Copied!" %}
- {# LEFT TABS NAVIGATION #}
-
-
-
- {% trans "Core Details" %}
-
-
-
-
- {% trans "Description & Requirements" %}
-
-
-
-
- {% trans "Application KPIs" %}
-
-
-
-
-
+ {# CONTENT: CORE DETAILS (No Tabs) #}
-
-
- {# TAB 1 CONTENT: CORE DETAILS #}
-
-
{% trans "Administrative & Location" %}
-
-
- {% trans "Department:" %} {{ job.department|default:"N/A" }}
-
-
- {% trans "Position No:" %} {{ job.position_number|default:"N/A" }}
-
-
- {% trans "Job Type:" %} {{ job.get_job_type_display }}
-
-
- {% trans "Workplace:" %} {{ job.get_workplace_type_display }}
-
-
- {% trans "Location:" %} {{ job.get_location_display }}
-
-
- {% trans "Created By:" %} {{ job.created_by|default:"N/A" }}
-
-
- {% trans "Created At:" %} {{ job.created_at|default:"N/A" }}
-
-
- {% trans "Updated At:" %} {{ job.updated_at|default:"N/A" }}
-
-
-
-
- {# Replaced bulky SVG with simpler Font Awesome icon #}
-
- {% trans "Share Public Link" %}
-
-
-
- {% trans "Copied!" %}
-
-
-
-
-
{% trans "Financial & Timeline" %}
-
- {% if job.salary_range %}
-
-
-
- {% trans "Salary:" %} {{ job.salary_range }}
-
-
- {% endif %}
- {% if job.start_date %}
-
-
-
- {% trans "Start Date:" %} {{ job.start_date }}
-
-
- {% endif %}
- {% if job.application_deadline %}
-
-
-
- {% trans "Deadline:" %} {{ job.application_deadline }}
- {% if job.is_expired %}
- {% trans "EXPIRED" %}
- {% endif %}
-
-
- {% endif %}
-
+
+
+
+
+ {% trans "Department:" %} {{ job.department|default:"N/A" }}
-
- {# TAB 2 CONTENT: DESCRIPTION & REQUIREMENTS #}
-
- {% if job.description %}
-
-
{% trans "Job Description" %}
-
{{ job.description|safe }}
-
- {% endif %}
- {% if job.qualifications %}
-
-
{% trans "Required Qualifications" %}
-
{{ job.qualifications|safe }}
-
- {% endif %}
- {% if job.benefits %}
-
-
{% trans "Benefits" %}
-
{{ job.benefits|safe}}
-
- {% endif %}
- {% if job.application_instructions %}
-
-
{% trans "Application Instructions" %}
-
{{ job.application_instructions|safe }}
-
- {% endif %}
-
+
+ {% trans "Position No:" %} {{ job.position_number|default:"N/A" }}
-
- {# TAB 3 CONTENT: APPLICATION KPIS #}
-
-
-
- {# 1. Job Avg. Score #}
-
-
-
-
-
{{ avg_match_score|floatformat:1 }}
-
{% trans "Avg. AI Score" %}
-
-
-
-
- {# 2. High Potential Count #}
-
-
-
-
-
{{ high_potential_count }}
-
{% trans "High Potential" %}
-
-
-
-
- {# 3. Avg. Time to Interview #}
-
-
-
-
-
{{ avg_t2i_days|floatformat:1 }}d
-
{% trans "Time to Interview" %}
-
-
-
-
- {# 4. Avg. Exam Review Time #}
-
-
-
-
-
{{ avg_t_in_exam_days|floatformat:1 }}d
-
{% trans "Avg. Exam Review" %}
-
-
-
-
-
-
- {% trans "KPIs based on completed applicant data." %}
-
-
+
+ {% trans "Job Type:" %} {{ job.get_job_type_display }}
+
+
+ {% trans "Workplace:" %} {{ job.get_workplace_type_display }}
+
+
+ {% trans "Location:" %} {{ job.get_location_display }}
+
+
+ {% trans "Salary:" %} {{ job.salary_range |default:"N/A" }}
+
+
+ {% trans "Created By:" %} {{ job.created_by|default:"N/A" }}
+
+
+ {% trans "Created At:" %} {{ job.created_at|default:"N/A" }}
+
+
+ {% trans "Updated At:" %} {{ job.updated_at|default:"N/A" }}
-
-
-
-
+
+ {# Description Blocks (Main Content) #}
+ {% if job.description %}
+
+
{% trans "Job Description" %}
+
{{ job.description|safe }}
+
+ {% endif %}
+ {% if job.qualifications %}
+
+
{% trans "Required Qualifications" %}
+
{{ job.qualifications|safe }}
+
+ {% endif %}
+ {% if job.benefits %}
+
+
{% trans "Benefits" %}
+
{{ job.benefits|safe}}
+
+ {% endif %}
+ {% if job.application_instructions %}
+
+
{% trans "Application Instructions" %}
+
{{ job.application_instructions|safe }}
+
+ {% endif %}
- {# FOOTER ACTIONS #}
-
- {# RIGHT COLUMN: TABBED CARDS #}
+ {# RIGHT COLUMN: TABBED CARDS #}
- {# New Card for Candidate Category Chart #}
-
- {# REMOVED: Standalone Applicant Tracking Card (It is now in a tab) #}
-
-
-
{# RIGHT TABS NAVIGATION #}
-
+
- {% trans "Applicants" %}
+ {% trans "Applicants" %}
- {% trans "Tracking" %}
+ {% trans "Tracking" %}
- {% trans "Form Template" %}
+ {% trans "Form Template" %}
@@ -531,70 +305,70 @@
-
+
{# TAB 1: APPLICANTS CONTENT #}
{% trans "Total Applicants" %} ({{ total_applicants }} )
-
- {# NEW TAB 2: APPLICANT TRACKING CONTENT #}
+ {# TAB 2: TRACKING CONTENT #}
-
{% trans "Pipeline Stages" %}
+
{% trans "Applicant Stages" %}
{% include 'jobs/partials/applicant_tracking.html' %}
-
{% trans "View the number of candidates currently in each stage of the hiring pipeline." %}
+
+ {% trans "The applicant tracking flow is defined by the attached Form Template. View the Form Template tab to manage stages and fields." %}
+
+ {# Placeholder for stage tracker component #}
{# TAB 3: MANAGEMENT (Form Template) CONTENT #}
-
{% trans "Form Management" %}
-
{# TAB 4: LINKEDIN INTEGRATION CONTENT #}
{% trans "LinkedIn Integration" %}
-
+
{% if job.posted_to_linkedin %}
-
+
{% trans "Posted successfully!" %}
{% if job.linkedin_post_url %}
-
+
{% trans "View on LinkedIn" %}
{% endif %}
-
+
{% trans "Posted on:" %} {{ job.linkedin_posted_at|date:"M d, Y" }}
{% else %}
- {% trans "This job has not been posted to LinkedIn yet." %}
+
{% trans "This job has not been posted to LinkedIn yet." %}
{% endif %}
+
+
+ {% trans "Upload Image for Post" %}
+
{% if not request.session.linkedin_authenticated %}
-
+
{% trans "You need to" %} {% trans "authenticate with LinkedIn" %} {% trans "first." %}
{% endif %}
@@ -623,6 +401,82 @@
+ {# Card 2: Candidate Category Chart #}
+
+
+ {# Card 3: KPIs #}
+
+
+
+
+
+
+ {# 1. Job Avg. Score #}
+
+
+
+
+
{{ avg_match_score|floatformat:1 }}
+
{% trans "Avg. AI Score" %}
+
+
+
+
+ {# 2. High Potential Count #}
+
+
+
+
+
{{ high_potential_count }}
+
{% trans "High Potential" %}
+
+
+
+
+ {# 3. Avg. Time to Interview #}
+
+
+
+
+
{{ avg_t2i_days|floatformat:1 }}d
+
{% trans "Time to Interview" %}
+
+
+
+
+ {# 4. Avg. Exam Review Time #}
+
+
+
+
+
{{ avg_t_in_exam_days|floatformat:1 }}d
+
{% trans "Avg. Exam Review" %}
+
+
+
+
+
+
+
+
+
diff --git a/templates/jobs/job_list.html b/templates/jobs/job_list.html
index 89ecd1c..c159c7c 100644
--- a/templates/jobs/job_list.html
+++ b/templates/jobs/job_list.html
@@ -311,7 +311,7 @@
{{ comment.content|safe }}
+{% trans "No comments yet. Be the first to comment!" %}
+ {% endif %} +