diff --git a/db.sqlite3 b/db.sqlite3
deleted file mode 100644
index 61fd87d..0000000
Binary files a/db.sqlite3 and /dev/null differ
diff --git a/recruitment/__pycache__/forms.cpython-312.pyc b/recruitment/__pycache__/forms.cpython-312.pyc
index 24fad7b..4c34029 100644
Binary files a/recruitment/__pycache__/forms.cpython-312.pyc and b/recruitment/__pycache__/forms.cpython-312.pyc differ
diff --git a/recruitment/__pycache__/linkedin_service.cpython-312.pyc b/recruitment/__pycache__/linkedin_service.cpython-312.pyc
index 56e8775..2eddf44 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__/models.cpython-312.pyc b/recruitment/__pycache__/models.cpython-312.pyc
index bf44977..fa7751d 100644
Binary files a/recruitment/__pycache__/models.cpython-312.pyc and b/recruitment/__pycache__/models.cpython-312.pyc differ
diff --git a/recruitment/__pycache__/urls.cpython-312.pyc b/recruitment/__pycache__/urls.cpython-312.pyc
index bca9467..f02d3e3 100644
Binary files a/recruitment/__pycache__/urls.cpython-312.pyc and b/recruitment/__pycache__/urls.cpython-312.pyc differ
diff --git a/recruitment/__pycache__/utils.cpython-312.pyc b/recruitment/__pycache__/utils.cpython-312.pyc
index 30b5486..52a180d 100644
Binary files a/recruitment/__pycache__/utils.cpython-312.pyc and b/recruitment/__pycache__/utils.cpython-312.pyc differ
diff --git a/recruitment/__pycache__/views.cpython-312.pyc b/recruitment/__pycache__/views.cpython-312.pyc
index 584200d..aff3e19 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 61b6f94..ea6f614 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 1e97f53..8593fa7 100644
--- a/recruitment/linkedin_service.py
+++ b/recruitment/linkedin_service.py
@@ -1,10 +1,14 @@
# jobs/linkedin_service.py
import uuid
-from urllib.parse import quote
+import re
+from html import unescape
+from urllib.parse import quote, urlencode
import requests
import logging
from django.conf import settings
-from urllib.parse import urlencode, quote
+import time
+import random
+from django.utils import timezone
logger = logging.getLogger(__name__)
@@ -14,7 +18,12 @@ class LinkedInService:
self.client_secret = settings.LINKEDIN_CLIENT_SECRET
self.redirect_uri = settings.LINKEDIN_REDIRECT_URI
self.access_token = None
+ # Configuration for image processing wait time
+ self.ASSET_STATUS_TIMEOUT = 15 # Max time (seconds) to wait for image processing
+ self.ASSET_STATUS_INTERVAL = 2 # Check every 2 seconds
+ # --- AUTHENTICATION & PROFILE ---
+
def get_auth_url(self):
"""Generate LinkedIn OAuth URL"""
params = {
@@ -28,7 +37,6 @@ class LinkedInService:
def get_access_token(self, code):
"""Exchange authorization code for access token"""
- # This function exchanges LinkedInβs temporary authorization code for a usable access token.
url = "https://www.linkedin.com/oauth/v2/accessToken"
data = {
'grant_type': 'authorization_code',
@@ -42,12 +50,6 @@ class LinkedInService:
response = requests.post(url, data=data, timeout=60)
response.raise_for_status()
token_data = response.json()
- """
- Example response:{
- "access_token": "AQXq8HJkLmNpQrStUvWxYz...",
- "expires_in": 5184000
- }
- """
self.access_token = token_data.get('access_token')
return self.access_token
except Exception as e:
@@ -55,7 +57,7 @@ class LinkedInService:
raise
def get_user_profile(self):
- """Get user profile information"""
+ """Get user profile information (used to get person URN)"""
if not self.access_token:
raise Exception("No access token available")
@@ -64,16 +66,32 @@ class LinkedInService:
try:
response = requests.get(url, headers=headers, timeout=60)
- response.raise_for_status() # Ensure we raise an error for bad responses(4xx, 5xx) and does nothing for 2xx(success)
- return response.json() # returns a dict from json response (deserialize)
+ response.raise_for_status()
+ return response.json()
except Exception as e:
logger.error(f"Error getting user profile: {e}")
raise
+ # --- ASSET UPLOAD & STATUS ---
+ def get_asset_status(self, asset_urn):
+ """Checks the status of a registered asset (image) to ensure it's READY."""
+ url = f"https://api.linkedin.com/v2/assets/{quote(asset_urn)}"
+ headers = {
+ 'Authorization': f'Bearer {self.access_token}',
+ 'X-Restli-Protocol-Version': '2.0.0'
+ }
+
+ try:
+ response = requests.get(url, headers=headers, timeout=10)
+ response.raise_for_status()
+ return response.json().get('status')
+ except Exception as e:
+ logger.error(f"Error checking asset status for {asset_urn}: {e}")
+ return "FAILED"
def register_image_upload(self, person_urn):
- """Step 1: Register image upload with LinkedIn"""
+ """Step 1: Register image upload with LinkedIn, getting the upload URL and asset URN."""
url = "https://api.linkedin.com/v2/assets?action=registerUpload"
headers = {
'Authorization': f'Bearer {self.access_token}',
@@ -101,9 +119,8 @@ class LinkedInService:
'asset': data['value']['asset']
}
- def upload_image_to_linkedin(self, upload_url, image_file):
- """Step 2: Upload actual image file to LinkedIn"""
- # Open and read the Django ImageField
+ def upload_image_to_linkedin(self, upload_url, image_file, asset_urn):
+ """Step 2: Upload image file and poll for 'READY' status."""
image_file.open()
image_content = image_file.read()
image_file.close()
@@ -114,90 +131,223 @@ class LinkedInService:
response = requests.post(upload_url, headers=headers, data=image_content, timeout=60)
response.raise_for_status()
+
+ # --- CRITICAL FIX: POLL FOR ASSET STATUS ---
+ start_time = time.time()
+ while time.time() - start_time < self.ASSET_STATUS_TIMEOUT:
+ try:
+ status = self.get_asset_status(asset_urn)
+ if status == "READY" or status == "PROCESSING":
+ # Exit successfully on READY, but also exit successfully on PROCESSING
+ # if the timeout is short, relying on the final API call to succeed.
+ # However, returning True on READY is safest.
+ if status == "READY":
+ logger.info(f"Asset {asset_urn} is READY. Proceeding.")
+ return True
+ if status == "FAILED":
+ raise Exception(f"LinkedIn image processing failed for asset {asset_urn}")
+
+ logger.info(f"Asset {asset_urn} status: {status}. Waiting...")
+ time.sleep(self.ASSET_STATUS_INTERVAL)
+
+ except Exception as e:
+ # If the status check fails for any reason (400, connection, etc.),
+ # we log it, wait a bit longer, and try again, instead of crashing.
+ logger.warning(f"Error during asset status check for {asset_urn}: {e}. Retrying.")
+ time.sleep(self.ASSET_STATUS_INTERVAL * 2)
+
+ # If the loop times out, force the post anyway (mimicking the successful manual fix)
+ logger.warning(f"Asset {asset_urn} timed out, but upload succeeded. Forcing post attempt.")
return True
+
+ # --- POSTING LOGIC ---
+
+ def clean_html_for_social_post(self, html_content):
+ """Converts safe HTML to plain text with basic formatting (bullets, bold, newlines)."""
+ if not html_content:
+ return ""
+
+ text = html_content
+
+ # 1. Convert Bolding tags to *Markdown*
+ text = re.sub(r'(.*?) ', r'*\1*', text, flags=re.IGNORECASE)
+ text = re.sub(r'(.*?) ', r'*\1*', text, flags=re.IGNORECASE)
+
+ # 2. Handle Lists: Convert
tags into a bullet point
+ text = re.sub(r'(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]
+
+ if not tags:
+ return ["#HigherEd", "#Hiring", "#UniversityJobs"]
+
+ return tags
+
+ def _build_post_message(self, job_posting):
+ """Centralized logic to construct the professionally formatted text message."""
+ message_parts = [
+ f"π₯ *Job Alert!* Weβre looking for a talented professional to join our team.",
+ f"π **{job_posting.title}** π",
+ ]
+
+ if job_posting.department:
+ message_parts.append(f"*{job_posting.department}*")
+
+ message_parts.append("\n" + "=" * 25 + "\n")
+
+ # KEY DETAILS SECTION
+ details_list = []
+ if job_posting.job_type:
+ details_list.append(f"πΌ Type: {job_posting.get_job_type_display()}")
+ if job_posting.get_location_display() != 'Not specified':
+ details_list.append(f"π Location: {job_posting.get_location_display()}")
+ if job_posting.workplace_type:
+ details_list.append(f"π Workplace: {job_posting.get_workplace_type_display()}")
+ if job_posting.salary_range:
+ details_list.append(f"π° Salary: {job_posting.salary_range}")
+
+ if details_list:
+ message_parts.append("*Key Information*:")
+ message_parts.extend(details_list)
+ message_parts.append("\n")
+
+ # DESCRIPTION SECTION
+ clean_description = self.clean_html_for_social_post(job_posting.description)
+ if clean_description:
+ message_parts.append(f"π *About the Role:*\n{clean_description}")
+
+ # CALL TO ACTION
+ if job_posting.application_url:
+ message_parts.append(f"\n\n---")
+ message_parts.append(f"π **APPLY NOW:** {job_posting.application_url}")
+
+ # HASHTAGS
+ hashtags = self.hashtags_list(job_posting.hash_tags)
+ if job_posting.department:
+ dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
+ hashtags.insert(0, dept_hashtag)
+
+ message_parts.append("\n" + " ".join(hashtags))
+
+ return "\n".join(message_parts)
+
+ def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn):
+ """Step 3: Creates the final LinkedIn post payload with the image asset."""
+
+ message = self._build_post_message(job_posting)
+
+ url = "https://api.linkedin.com/v2/ugcPosts"
+ headers = {
+ 'Authorization': f'Bearer {self.access_token}',
+ 'Content-Type': 'application/json',
+ 'X-Restli-Protocol-Version': '2.0.0'
+ }
+
+ payload = {
+ "author": f"urn:li:person:{person_urn}",
+ "lifecycleState": "PUBLISHED",
+ "specificContent": {
+ "com.linkedin.ugc.ShareContent": {
+ "shareCommentary": {"text": message},
+ "shareMediaCategory": "IMAGE",
+ "media": [{
+ "status": "READY",
+ "media": asset_urn,
+ "description": {"text": job_posting.title},
+ "originalUrl": job_posting.application_url,
+ "title": {"text": "Apply Now"}
+ }]
+ }
+ },
+ "visibility": {
+ "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
+ }
+ }
+
+ response = requests.post(url, headers=headers, json=payload, timeout=60)
+ response.raise_for_status()
+
+ post_id = response.headers.get('x-restli-id', '')
+ post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
+
+ return {
+ 'success': True,
+ 'post_id': post_id,
+ 'post_url': post_url,
+ 'status_code': response.status_code
+ }
+
def create_job_post(self, job_posting):
- """Create a job announcement post on LinkedIn (with image support)"""
+ """Main method to create a job announcement post (Image or Text)."""
if not self.access_token:
raise Exception("Not authenticated with LinkedIn")
try:
- # Get user profile for person URN
profile = self.get_user_profile()
person_urn = profile.get('sub')
-
if not person_urn:
raise Exception("Could not retrieve LinkedIn user ID")
- # Check if job has an image
+ asset_urn = None
+ has_image = False
+
+ # Check for image and attempt post
try:
- image_upload = job_posting.files.first()
- has_image = image_upload and image_upload.linkedinpost_image
+ # Assuming correct model path: job_posting.related_model_name.first().image_field_name
+ image_upload = job_posting.post_images.first().post_image
+ has_image = image_upload is not None
except Exception:
- has_image = False
+ pass # No image available
if has_image:
- # === POST WITH IMAGE ===
try:
- # Step 1: Register image upload
+ # Step 1: Register
upload_info = self.register_image_upload(person_urn)
+ asset_urn = upload_info['asset']
- # Step 2: Upload image
+ # Step 2: Upload and WAIT FOR READY (Crucial for 422 fix)
self.upload_image_to_linkedin(
upload_info['upload_url'],
- image_upload.linkedinpost_image
+ image_upload,
+ asset_urn
)
# Step 3: Create post with image
return self.create_job_post_with_image(
- job_posting,
- image_upload.linkedinpost_image,
- person_urn,
- upload_info['asset']
+ job_posting, image_upload, person_urn, asset_urn
)
except Exception as e:
- logger.error(f"Image upload failed: {e}")
- # Fall back to text-only post if image upload fails
- has_image = False
+ logger.error(f"Image post failed, falling back to text: {e}")
+ # Force fallback to text-only if image posting fails
+ has_image = False
- # === FALLBACK TO URL/ARTICLE POST ===
- # Add unique timestamp to prevent duplicates
- from django.utils import timezone
- import random
- unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})"
+ # === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) ===
+ message = self._build_post_message(job_posting)
- message_parts = [f"π **We're Hiring: {job_posting.title}**"]
- if job_posting.department:
- message_parts.append(f"**Department:** {job_posting.department}")
- if job_posting.description:
- message_parts.append(f"\n{job_posting.description}")
-
- details = []
- if job_posting.job_type:
- details.append(f"πΌ {job_posting.get_job_type_display()}")
- if job_posting.get_location_display() != 'Not specified':
- details.append(f"π {job_posting.get_location_display()}")
- if job_posting.workplace_type:
- details.append(f"π {job_posting.get_workplace_type_display()}")
- if job_posting.salary_range:
- details.append(f"π° {job_posting.salary_range}")
-
- if details:
- message_parts.append("\n" + " | ".join(details))
-
- if job_posting.application_url:
- message_parts.append(f"\nπ **Apply now:** {job_posting.application_url}")
-
- hashtags = self.hashtags_list(job_posting.hash_tags)
- if job_posting.department:
- dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
- hashtags.insert(0, dept_hashtag)
-
- message_parts.append("\n\n" + " ".join(hashtags))
- message_parts.append(unique_suffix)
- message = "\n".join(message_parts)
-
- # π₯ FIX URL - REMOVE TRAILING SPACES π₯
url = "https://api.linkedin.com/v2/ugcPosts"
headers = {
'Authorization': f'Bearer {self.access_token}',
@@ -211,20 +361,14 @@ class LinkedInService:
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": message},
- "shareMediaCategory": "ARTICLE",
- "media": [{
- "status": "READY",
- "description": {"text": f"Apply for {job_posting.title} at our university!"},
- "originalUrl": job_posting.application_url,
- "title": {"text": job_posting.title}
- }]
+ "shareMediaCategory": "NONE", # Pure text post
}
},
"visibility": {
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
}
}
-
+
response = requests.post(url, headers=headers, json=payload, timeout=60)
response.raise_for_status()
@@ -244,18 +388,4 @@ class LinkedInService:
'success': False,
'error': str(e),
'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
- }
-
-
-
- def hashtags_list(self,hash_tags_str):
- """Convert comma-separated hashtags string to list"""
- if not hash_tags_str:
- return [""]
- tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()]
- if not tags:
- return ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"]
-
- return tags
-
-
+ }
\ No newline at end of file
diff --git a/recruitment/migrations/0009_merge_20251013_1718.py b/recruitment/migrations/0009_merge_20251013_1718.py
new file mode 100644
index 0000000..43811e8
--- /dev/null
+++ b/recruitment/migrations/0009_merge_20251013_1718.py
@@ -0,0 +1,14 @@
+# Generated by Django 5.2.7 on 2025-10-13 14:18
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('recruitment', '0003_rename_start_date_jobposting_joining_date_and_more'),
+ ('recruitment', '0008_zoommeeting_password'),
+ ]
+
+ operations = [
+ ]
diff --git a/recruitment/migrations/0010_merge_20251013_1819.py b/recruitment/migrations/0010_merge_20251013_1819.py
new file mode 100644
index 0000000..6acb9b0
--- /dev/null
+++ b/recruitment/migrations/0010_merge_20251013_1819.py
@@ -0,0 +1,14 @@
+# Generated by Django 5.2.7 on 2025-10-13 15:19
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('recruitment', '0009_merge_20251013_1714'),
+ ('recruitment', '0009_merge_20251013_1718'),
+ ]
+
+ operations = [
+ ]
diff --git a/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py b/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py
new file mode 100644
index 0000000..a961dd5
--- /dev/null
+++ b/recruitment/migrations/0011_alter_jobpostingimage_job_and_more.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.2.7 on 2025-10-13 22:16
+
+import django.db.models.deletion
+import recruitment.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('recruitment', '0010_merge_20251013_1819'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='jobpostingimage',
+ name='job',
+ field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting'),
+ ),
+ migrations.AlterField(
+ model_name='jobpostingimage',
+ name='post_image',
+ field=models.ImageField(upload_to='post/', validators=[recruitment.validators.validate_image_size]),
+ ),
+ ]
diff --git a/recruitment/models.py b/recruitment/models.py
index d53801e..b662c0e 100644
--- a/recruitment/models.py
+++ b/recruitment/models.py
@@ -1,6 +1,6 @@
from django.db import models
from django.utils import timezone
-from .validators import validate_hash_tags
+from .validators import validate_hash_tags, validate_image_size
from django.contrib.auth.models import User
from django.core.validators import URLValidator
from django.utils.translation import gettext_lazy as _
@@ -249,8 +249,8 @@ class JobPosting(Base):
class JobPostingImage(models.Model):
- job=models.ForeignKey('JobPosting',on_delete=models.CASCADE,related_name='post_images')
- post_image = models.ImageField(upload_to='post/')
+ job=models.OneToOneField('JobPosting',on_delete=models.CASCADE,related_name='post_images')
+ post_image = models.ImageField(upload_to='post/',validators=[validate_image_size])
class Candidate(Base):
diff --git a/recruitment/views.py b/recruitment/views.py
index 964836a..a297a59 100644
--- a/recruitment/views.py
+++ b/recruitment/views.py
@@ -320,15 +320,22 @@ def job_detail(request, slug):
"""View details of a specific job"""
job = get_object_or_404(JobPosting, slug=slug)
-
+ print(job)
# Get all candidates for this job, ordered by most recent
applicants = job.candidates.all().order_by("-created_at")
+ print(applicants)
# Count candidates by stage for summary statistics
total_applicant = applicants.count()
+
applied_count = applicants.filter(stage="Applied").count()
+
+ exam_count=applicants.filter(stage="Exam").count
+
interview_count = applicants.filter(stage="Interview").count()
+
offer_count = applicants.filter(stage="Offer").count()
+
status_form = JobPostingStatusForm(instance=job)
image_upload_form=JobPostingImageForm(instance=job)
@@ -359,6 +366,7 @@ def job_detail(request, slug):
"applicants": applicants,
"total_applicants": total_applicant,
"applied_count": applied_count,
+ 'exam_count':exam_count,
"interview_count": interview_count,
"offer_count": offer_count,
'status_form':status_form,
@@ -747,6 +755,7 @@ def form_builder(request, template_id=None):
template = get_object_or_404(
FormTemplate, id=template_id, created_by=request.user
)
+ context['template']=template
context["template_id"] = template.id
context["template_name"] = template.name
return render(request, "forms/form_builder.html", context)
@@ -1492,9 +1501,14 @@ def candidate_screening_view(request, slug):
Manage candidate tiers and stage transitions
"""
job = get_object_or_404(JobPosting, slug=slug)
-
+ applied_count=job.candidates.filter(stage='Applied').count()
+ exam_count=job.candidates.filter(stage='Exam').count()
+ interview_count=job.candidates.filter(stage='interview').count()
+ offer_count=job.candidates.filter(stage='Offer').count()
# Get all candidates for this job, ordered by match score (descending)
candidates = job.candidates.filter(stage="Applied").order_by("-match_score")
+
+
# Get tier categorization parameters
# tier1_count = int(request.GET.get("tier1_count", 100))
@@ -1592,19 +1606,55 @@ def candidate_screening_view(request, slug):
# messages.info(request, "All Tier 1 candidates are already marked as Candidates")
# Group candidates by current stage for display
- stage_groups = {
- "Applied": candidates.filter(stage="Applied"),
- "Exam": candidates.filter(stage="Exam"),
- "Interview": candidates.filter(stage="Interview"),
- "Offer": candidates.filter(stage="Offer"),
- }
+ # stage_groups = {
+ # "Applied": candidates.filter(stage="Applied"),
+ # "Exam": candidates.filter(stage="Exam"),
+ # "Interview": candidates.filter(stage="Interview"),
+ # "Offer": candidates.filter(stage="Offer"),
+ # }
+ min_ai_score_str = request.GET.get('min_ai_score')
+ tier1_count_str = request.GET.get('tier1_count')
+
+ try:
+ # Check if the string value exists and is not an empty string before conversion
+ if min_ai_score_str:
+ min_ai_score = int(min_ai_score_str)
+ else:
+ min_ai_score = 0
+
+ if tier1_count_str:
+ tier1_count = int(tier1_count_str)
+ else:
+ tier1_count = 0
+
+ except ValueError:
+ # This catches if the user enters non-numeric text (e.g., "abc")
+ min_ai_score = 0
+ tier1_count = 0
+ print(min_ai_score)
+ print(tier1_count)
+ # You can now safely use min_ai_score and tier1_count as integers (0 or greater)
+ if min_ai_score > 0:
+ candidates = candidates.filter(match_score__gte=min_ai_score)
+ print(candidates)
+
+ if tier1_count > 0:
+ candidates = candidates[:tier1_count]
+
context = {
"job": job,
"candidates": candidates,
# "stage_groups": stage_groups,
# "tier1_count": tier1_count,
# "total_candidates": candidates.count(),
+ 'min_ai_score':min_ai_score,
+ 'tier1_count':tier1_count,
+ 'applied_count':applied_count,
+ 'exam_count':exam_count,
+ 'interview_count':interview_count,
+ 'offer_count':offer_count
+
}
return render(request, "recruitment/candidate_screening_view.html", context)
diff --git a/templates/base.html b/templates/base.html
index 8e39bac..dc0738e 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -307,7 +307,7 @@
{% endcomment %}
-
+
{% include "icons/jobs.html" %}
@@ -316,7 +316,7 @@
-
+ {% comment %}
@@ -327,18 +327,18 @@
-
+ {% endcomment %}
-
+
{% include "icons/users.html" %}
- {% trans "Candidates" %}
+ {% trans "Applicants" %}
-
+
@@ -351,7 +351,7 @@
-
+
diff --git a/templates/forms/form_builder.html b/templates/forms/form_builder.html
index 5caea5d..c7f86df 100644
--- a/templates/forms/form_builder.html
+++ b/templates/forms/form_builder.html
@@ -779,10 +779,15 @@
Home
/
+
Jobs
/
+
+ Job:({{template.job.title}})
+ /
+
Form Builder
diff --git a/templates/jobs/job_detail.html b/templates/jobs/job_detail.html
index 92a3658..2103fcc 100644
--- a/templates/jobs/job_detail.html
+++ b/templates/jobs/job_detail.html
@@ -160,126 +160,6 @@
border: 1px solid;
}
- /* ==================================== */
- /* MULTI-COLORED CANDIDATE STAGE TRACKER */
- /* ==================================== */
-
- .progress-stages {
- position: relative;
- padding: 1.5rem 0;
- }
-
- .progress-stages a {
- text-decoration: none;
- color: inherit;
- }
-
- .stage-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
- min-width: 60px;
- transition: all 0.3s ease;
- color: var(--stage-inactive);
- }
-
- .stage-icon {
- width: 36px;
- height: 36px;
- border-radius: 50%;
- background-color: #e9ecef;
- color: var(--stage-inactive);
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 0.9rem;
- z-index: 10;
- border: 2px solid white;
- box-shadow: 0 0 0 2px #e9ecef;
- transition: all 0.3s ease;
- }
-
- /* ---------------- STAGE SPECIFIC COLORS ---------------- */
-
- /* APPLIED STAGE (Teal) */
- .stage-item[data-stage="Applied"].completed .stage-icon,
- .stage-item[data-stage="Applied"].active .stage-icon {
- background-color: var(--stage-applied);
- color: white;
- }
- .stage-item[data-stage="Applied"].active { color: var(--stage-applied); }
-
- /* EXAM STAGE (Cyan/Info) */
- .stage-item[data-stage="Exam"].completed .stage-icon,
- .stage-item[data-stage="Exam"].active .stage-icon {
- background-color: var(--stage-exam);
- color: white;
- }
- .stage-item[data-stage="Exam"].active { color: var(--stage-exam); }
-
- /* INTERVIEW STAGE (Yellow/Warning) */
- .stage-item[data-stage="Interview"].completed .stage-icon,
- .stage-item[data-stage="Interview"].active .stage-icon {
- background-color: var(--stage-interview);
- color: var(--kaauh-primary-text); /* Dark text for light background */
- }
- .stage-item[data-stage="Interview"].active { color: var(--stage-interview); }
-
- /* OFFER STAGE (Green/Success) */
- .stage-item[data-stage="Offer"].completed .stage-icon,
- .stage-item[data-stage="Offer"].active .stage-icon {
- background-color: var(--stage-offer);
- color: white;
- }
- .stage-item[data-stage="Offer"].active { color: var(--stage-offer); }
-
- /* ---------------- GENERIC ACTIVE/COMPLETED STYLING ---------------- */
-
- /* Active State (Applies glow/scale to current stage) */
- .stage-item.active .stage-icon {
- box-shadow: 0 0 0 4px rgba(0, 99, 110, 0.4);
- transform: scale(1.1);
- }
- .stage-item.active .stage-count {
- font-weight: 700;
- }
-
- /* Completed State (Applies dark text color to completed stages) */
- .stage-item.completed {
- color: var(--kaauh-primary-text);
- }
-
- /* Connector Line */
- .stage-connector {
- flex-grow: 1;
- height: 3px;
- background-color: #e9ecef;
- margin: 0 0.5rem;
- position: relative;
- top: -18px;
- z-index: 1;
- transition: background-color 0.3s ease;
- }
-
- /* Line in completed state (Kept the line teal/primary for consistency) */
- .stage-connector.completed {
- background-color: var(--kaauh-teal);
- }
-
- /* Labels and counts */
- .stage-label {
- font-size: 0.75rem;
- margin-top: 0.4rem;
- font-weight: 500;
- white-space: nowrap;
- }
- .stage-count {
- font-size: 0.9rem;
- font-weight: 600;
- margin-top: 0.1rem;
- color: #6c757d;
- }
{% endblock %}
@@ -389,11 +269,14 @@
-
- {% trans "Copy and Share Public Link" %}
+
+
+
+
+ {% trans "Share Public Link" %}
@@ -487,69 +370,13 @@
{# RIGHT COLUMN: TABBED CARDS #}
-
-
-
{% trans "Applicant Tracking" %}
-
-
+
{# RIGHT TABS NAVIGATION #}
@@ -575,7 +402,7 @@
{# TAB 1: APPLICANTS CONTENT #}
{% trans "Total Applicants" %} ({{ total_applicants }} )
- {% if total_applicants > 0 %}
+ {% comment %} {% if total_applicants > 0 %}
diff --git a/templates/jobs/job_list.html b/templates/jobs/job_list.html
index aac62e9..e3285be 100644
--- a/templates/jobs/job_list.html
+++ b/templates/jobs/job_list.html
@@ -11,11 +11,16 @@
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
+ --kaauh-success: #28a745;
+ --kaauh-danger: #dc3545;
}
/* Primary Color Overrides */
.text-primary-theme { color: var(--kaauh-teal) !important; }
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
+ .text-success { color: var(--kaauh-success) !important; }
+ .text-danger { color: var(--kaauh-danger) !important; }
+ .text-info { color: #17a2b8 !important; }
/* Enhanced Card Styling */
.card {
@@ -66,6 +71,7 @@
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.7px;
+ color: white;
}
.bg-DRAFT { background-color: #6c757d !important; }
.bg-ACTIVE { background-color: var(--kaauh-teal) !important; }
@@ -75,92 +81,112 @@
/* --- TABLE ALIGNMENT AND SIZING FIXES --- */
.table {
- table-layout: fixed; /* Ensures column widths are respected */
+ table-layout: fixed;
width: 100%;
+ border-collapse: collapse;
}
.table thead th {
color: var(--kaauh-primary-text);
- font-weight: 500; /* Lighter weight for smaller font */
- font-size: 0.85rem; /* Smaller font size for header text */
+ font-weight: 600;
+ font-size: 0.85rem;
vertical-align: middle;
border-bottom: 2px solid var(--kaauh-border);
- padding: 0.5rem 0.25rem; /* Reduced vertical and horizontal padding */
+ padding: 0.5rem 0.25rem;
}
.table-hover tbody tr:hover {
background-color: #f3f7f9;
}
/* Optimized Main Table Column Widths (Total must be 100%) */
- .table th:nth-child(1) { width: 22%; } /* Job ID (Tight) */
+ .table th:nth-child(1) { width: 22%; }
+ .table th:nth-child(2) { width: 12%; }
+ .table th:nth-child(3) { width: 8%; }
+ .table th:nth-child(4) { width: 8%; }
+ .table th:nth-child(5) { width: 50%; }
- .table th:nth-child(2) { width: 12%; } /* Source (Tight) */
- .table th:nth-child(3) { width: 8%; } /* Actions (Tight, icon buttons) */
- .table th:nth-child(4) { width: 8%; } /* Form (Tight, icon buttons) */
- .table th:nth-child(5) { width: 50%; } /* Candidate Metrics (colspan=7) */
-
- /* NESTED TABLE STYLING FOR CANDIDATE MANAGEMENT HEADER */
- .nested-header-table {
- margin: 0;
- padding: 0;
- width: 100%;
- border-collapse: collapse;
- table-layout: fixed; /* CRITICAL for 1:1 data alignment */
- }
- .nested-header-table thead th {
- background-color: transparent;
- border: none;
- padding: 0.3rem 0 0.1rem 0; /* Reduced padding here too */
+ /* Candidate Management Header Row (The one with P/F) */
+ .nested-metrics-row th {
font-weight: 500;
- text-align: center;
color: #6c757d;
- font-size: 0.75rem; /* Even smaller font for nested headers */
- width: calc(100% / 7);
+ font-size: 0.75rem;
+ padding: 0.3rem 0;
+ border-bottom: 2px solid var(--kaauh-teal);
+ text-align: center;
+ border-left: 1px solid var(--kaauh-border);
}
- /* Explicit widths are technically defined by the 1/7 rule, but keeping them for clarity/safety */
- .nested-header-table thead th:nth-child(1),
- .nested-header-table thead th:nth-child(2),
- .nested-header-table thead th:nth-child(5) {
- width: calc(100% / 7);
+
+ .nested-metrics-row th {
+ width: calc(50% / 7);
}
- .nested-header-table thead th:nth-child(3),
- .nested-header-table thead th:nth-child(4) {
- width: calc(100% / 7 * 2);
+ .nested-metrics-row th[colspan="2"] {
+ width: calc(50% / 7 * 2);
+ position: relative;
}
- /* Inner Nested Table (P/F) */
+ /* Inner P/F Headers */
.nested-stage-metrics {
- width: 100%;
- border-collapse: collapse;
- table-layout: fixed;
- }
- .nested-stage-metrics thead th {
- padding: 0.1rem 0; /* Very minimal padding */
+ display: flex;
+ justify-content: space-around;
+ padding-top: 5px;
font-weight: 600;
color: var(--kaauh-teal-dark);
- font-size: 0.7rem; /* Smallest font size */
- width: 50%;
+ font-size: 0.7rem;
}
- /* Main TH for Candidate Management Header */
- .candidate-management-header {
+ /* Main TH for Candidate Management Header Title */
+ .candidate-management-header-title {
text-align: center;
- padding: 0;
+ padding: 0.5rem 0.25rem;
border-left: 2px solid var(--kaauh-teal);
border-right: 1px solid var(--kaauh-border) !important;
+ font-weight: 600;
+ color: var(--kaauh-teal-dark);
}
- /* Candidate Management Data Cells (7 columns total now) */
+ /* Candidate Management Data Cells (7 columns total) */
.candidate-data-cell {
text-align: center;
vertical-align: middle;
font-weight: 600;
- font-size: 0.9rem; /* Keep data readable */
+ font-size: 0.9rem;
+ padding: 0;
+ }
+ .table tbody td.candidate-data-cell:not(:first-child) {
border-left: 1px solid var(--kaauh-border);
}
+ .table tbody tr td:nth-child(5) {
+ border-left: 2px solid var(--kaauh-teal);
+ }
+
.candidate-data-cell a {
display: block;
text-decoration: none;
- padding: 0.4rem 0; /* Minimized vertical padding */
+ padding: 0.4rem 0.25rem;
+ }
+
+ /* Fix action button sizing */
+ .btn-group-sm > .btn {
+ padding: 0.2rem 0.4rem;
+ font-size: 0.75rem;
+ }
+
+ /* Additional CSS for Card View layout */
+ .card-view .card {
+ height: 100%;
+ }
+ .card-view .card-title {
+ color: var(--kaauh-teal-dark);
+ font-weight: 700;
+ font-size: 1.25rem;
+ }
+ .card-view .card-body {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ flex-grow: 1;
+ }
+ .card-view .list-unstyled li {
+ margin-bottom: 0.25rem;
}
{% endblock %}
@@ -215,63 +241,60 @@
- {% comment %} --- START OF TABLE VIEW (Data relied upon context variable 'jobs') --- {% endcomment %}
+ {# --- START OF JOB LIST CONTAINER --- #}
- {% comment %} Placeholder for View Switcher {% endcomment %}
- {% include "includes/_list_view_switcher.html" with list_id="job-list" %}
+ {# View Switcher (Contains the Card/Table buttons and JS/CSS logic) #}
+ {% include "includes/_list_view_switcher.html" with list_id="job-list" %}
+ {# 1. TABLE VIEW (Default Active) #}
+
+ {# --- Corrected Multi-Row Header Structure --- #}
- {% trans "Job ID" %}
- {% comment %} {% trans "Job Title" %}
- {% trans "Status" %} {% endcomment %}
- {% trans "Source" %}
- {% trans "Actions" %}
- {% trans "Manage Forms" %}
+ {% trans "Job Title / ID" %}
+ {% trans "Source" %}
+ {% trans "Actions" %}
+ {% trans "Manage Forms" %}
-
+
{% trans "Applicants Metrics" %}
-
+
+
+ {% trans "Applied" %}
+ {% trans "Screened" %}
+
+ {% trans "Exam" %}
+
+ P
+ F
+
+
+
+ {% trans "Interview" %}
+
+ P
+ F
+
+
+ {% trans "Offer" %}
+
+
- {% comment %} This loop relies on the 'jobs' variable passed from the Django view {% endcomment %}
{% for job in jobs %}
- {{ job }}
- {% comment %} {{ job.title }}
- {{ job.status }} {% endcomment %}
+
+ {{ job.title }}
+
+ {{ job.pk }} /
+ {{ job.status }}
+
{{ job.get_source }}
@@ -283,10 +306,10 @@
-
+
- {# CANDIDATE MANAGEMENT DATA - 7 SEPARATE COLUMNS CORRESPONDING TO THE HEADER #}
+ {# CANDIDATE MANAGEMENT DATA - URLS NEUTRALIZED #}
{% if job.metrics.applied %}{{ job.metrics.applied }}{% else %}-{% endif %}
{% if job.metrics.screening %}{{ job.metrics.screening }}{% else %}-{% endif %}
{% if job.metrics.exam_p %}{{ job.metrics.exam_p }}{% else %}-{% endif %}
@@ -314,7 +337,53 @@
+
+ {# 2. CARD VIEW (Previously Missing) - Added Bootstrap row/col structure for layout #}
+
+ {% for job in jobs %}
+
+
+
+
+
{{ job.title }}
+ {{ job.status }}
+
+
ID: {{ job.pk }} | Source: {{ job.get_source }}
+
+
+ {% trans "Applicants" %}:{{ job.metrics.applied|default:"0" }}
+ {% trans "Offers Made" %}: {{ job.metrics.offer|default:"0" }}
+ {% trans "Form" %}:{% if job.form_template %}
+ {{ job.form_template.name }}
+ {% else %}
+ {% trans "N/A" %}
+ {% endif %}
+
+
+
+
+
+ {% trans "Details" %}
+
+
+
+
+
+ {% if job.form_template %}
+
+
+
+ {% endif %}
+
+
+
+
+
+ {% endfor %}
+
+ {# --- END CARD VIEW --- #}
+ {# --- END OF JOB LIST CONTAINER --- #}
{% comment %} Fallback/Empty State {% endcomment %}
{% if not jobs and not job_list_data and not page_obj %}
diff --git a/templates/jobs/partials/applicant_tracking.html b/templates/jobs/partials/applicant_tracking.html
new file mode 100644
index 0000000..382652c
--- /dev/null
+++ b/templates/jobs/partials/applicant_tracking.html
@@ -0,0 +1,183 @@
+{% load static i18n %}
+
+
+
+
\ No newline at end of file
diff --git a/templates/recruitment/candidate_exam_view.html b/templates/recruitment/candidate_exam_view.html
index 048b73b..353eb92 100644
--- a/templates/recruitment/candidate_exam_view.html
+++ b/templates/recruitment/candidate_exam_view.html
@@ -5,143 +5,143 @@
{% block customCSS %}
{% endblock %}
{% block content %}
-
+
-
-
- {% trans "Exam" %} - {{ job.title }}
+
+
+ {% trans "Exam Management" %} - {{ job.title }}
-
- Total Candidates: {{ total_candidates }}
-
+
+ {% trans "Candidates in Exam Stage:" %} {{ total_candidates }}
+
{% trans "Back to Job" %}
-
-
+ {# APPLICANT TRACKING TIMELINE INCLUSION #}
+
+ {% include 'jobs/partials/applicant_tracking.html' %}
+
+ {# END APPLICANT TRACKING TIMELINE INCLUSION #}
+
+ {% comment %}
+
+ {% trans "Define Top Candidates (Tiers)" %}
+