few bug fixes
This commit is contained in:
parent
6521cdf2be
commit
b9904b3ec8
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,10 +1,14 @@
|
|||||||
# jobs/linkedin_service.py
|
# jobs/linkedin_service.py
|
||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import quote
|
import re
|
||||||
|
from html import unescape
|
||||||
|
from urllib.parse import quote, urlencode
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from urllib.parse import urlencode, quote
|
import time
|
||||||
|
import random
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -14,7 +18,12 @@ class LinkedInService:
|
|||||||
self.client_secret = settings.LINKEDIN_CLIENT_SECRET
|
self.client_secret = settings.LINKEDIN_CLIENT_SECRET
|
||||||
self.redirect_uri = settings.LINKEDIN_REDIRECT_URI
|
self.redirect_uri = settings.LINKEDIN_REDIRECT_URI
|
||||||
self.access_token = None
|
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):
|
def get_auth_url(self):
|
||||||
"""Generate LinkedIn OAuth URL"""
|
"""Generate LinkedIn OAuth URL"""
|
||||||
params = {
|
params = {
|
||||||
@ -28,7 +37,6 @@ class LinkedInService:
|
|||||||
|
|
||||||
def get_access_token(self, code):
|
def get_access_token(self, code):
|
||||||
"""Exchange authorization code for access token"""
|
"""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"
|
url = "https://www.linkedin.com/oauth/v2/accessToken"
|
||||||
data = {
|
data = {
|
||||||
'grant_type': 'authorization_code',
|
'grant_type': 'authorization_code',
|
||||||
@ -42,12 +50,6 @@ class LinkedInService:
|
|||||||
response = requests.post(url, data=data, timeout=60)
|
response = requests.post(url, data=data, timeout=60)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
token_data = response.json()
|
token_data = response.json()
|
||||||
"""
|
|
||||||
Example response:{
|
|
||||||
"access_token": "AQXq8HJkLmNpQrStUvWxYz...",
|
|
||||||
"expires_in": 5184000
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
self.access_token = token_data.get('access_token')
|
self.access_token = token_data.get('access_token')
|
||||||
return self.access_token
|
return self.access_token
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -55,7 +57,7 @@ class LinkedInService:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def get_user_profile(self):
|
def get_user_profile(self):
|
||||||
"""Get user profile information"""
|
"""Get user profile information (used to get person URN)"""
|
||||||
if not self.access_token:
|
if not self.access_token:
|
||||||
raise Exception("No access token available")
|
raise Exception("No access token available")
|
||||||
|
|
||||||
@ -64,16 +66,32 @@ class LinkedInService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, headers=headers, timeout=60)
|
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)
|
response.raise_for_status()
|
||||||
return response.json() # returns a dict from json response (deserialize)
|
return response.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting user profile: {e}")
|
logger.error(f"Error getting user profile: {e}")
|
||||||
raise
|
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):
|
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"
|
url = "https://api.linkedin.com/v2/assets?action=registerUpload"
|
||||||
headers = {
|
headers = {
|
||||||
'Authorization': f'Bearer {self.access_token}',
|
'Authorization': f'Bearer {self.access_token}',
|
||||||
@ -101,9 +119,8 @@ class LinkedInService:
|
|||||||
'asset': data['value']['asset']
|
'asset': data['value']['asset']
|
||||||
}
|
}
|
||||||
|
|
||||||
def upload_image_to_linkedin(self, upload_url, image_file):
|
def upload_image_to_linkedin(self, upload_url, image_file, asset_urn):
|
||||||
"""Step 2: Upload actual image file to LinkedIn"""
|
"""Step 2: Upload image file and poll for 'READY' status."""
|
||||||
# Open and read the Django ImageField
|
|
||||||
image_file.open()
|
image_file.open()
|
||||||
image_content = image_file.read()
|
image_content = image_file.read()
|
||||||
image_file.close()
|
image_file.close()
|
||||||
@ -114,90 +131,223 @@ class LinkedInService:
|
|||||||
|
|
||||||
response = requests.post(upload_url, headers=headers, data=image_content, timeout=60)
|
response = requests.post(upload_url, headers=headers, data=image_content, timeout=60)
|
||||||
response.raise_for_status()
|
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
|
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'<strong>(.*?)</strong>', r'*\1*', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'<b>(.*?)</b>', r'*\1*', text, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# 2. Handle Lists: Convert <li> tags into a bullet point
|
||||||
|
text = re.sub(r'</(ul|ol|div)>', '\n', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'<li[^>]*>', '• ', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'</li>', '\n', text, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# 3. Handle Paragraphs and Line Breaks
|
||||||
|
text = re.sub(r'</p>', '\n\n', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'<br/?>', '\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):
|
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:
|
if not self.access_token:
|
||||||
raise Exception("Not authenticated with LinkedIn")
|
raise Exception("Not authenticated with LinkedIn")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get user profile for person URN
|
|
||||||
profile = self.get_user_profile()
|
profile = self.get_user_profile()
|
||||||
person_urn = profile.get('sub')
|
person_urn = profile.get('sub')
|
||||||
|
|
||||||
if not person_urn:
|
if not person_urn:
|
||||||
raise Exception("Could not retrieve LinkedIn user ID")
|
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:
|
try:
|
||||||
image_upload = job_posting.files.first()
|
# Assuming correct model path: job_posting.related_model_name.first().image_field_name
|
||||||
has_image = image_upload and image_upload.linkedinpost_image
|
image_upload = job_posting.post_images.first().post_image
|
||||||
|
has_image = image_upload is not None
|
||||||
except Exception:
|
except Exception:
|
||||||
has_image = False
|
pass # No image available
|
||||||
|
|
||||||
if has_image:
|
if has_image:
|
||||||
# === POST WITH IMAGE ===
|
|
||||||
try:
|
try:
|
||||||
# Step 1: Register image upload
|
# Step 1: Register
|
||||||
upload_info = self.register_image_upload(person_urn)
|
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(
|
self.upload_image_to_linkedin(
|
||||||
upload_info['upload_url'],
|
upload_info['upload_url'],
|
||||||
image_upload.linkedinpost_image
|
image_upload,
|
||||||
|
asset_urn
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 3: Create post with image
|
# Step 3: Create post with image
|
||||||
return self.create_job_post_with_image(
|
return self.create_job_post_with_image(
|
||||||
job_posting,
|
job_posting, image_upload, person_urn, asset_urn
|
||||||
image_upload.linkedinpost_image,
|
|
||||||
person_urn,
|
|
||||||
upload_info['asset']
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Image upload failed: {e}")
|
logger.error(f"Image post failed, falling back to text: {e}")
|
||||||
# Fall back to text-only post if image upload fails
|
# Force fallback to text-only if image posting fails
|
||||||
has_image = False
|
has_image = False
|
||||||
|
|
||||||
# === FALLBACK TO URL/ARTICLE POST ===
|
# === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) ===
|
||||||
# Add unique timestamp to prevent duplicates
|
message = self._build_post_message(job_posting)
|
||||||
from django.utils import timezone
|
|
||||||
import random
|
|
||||||
unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})"
|
|
||||||
|
|
||||||
message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
|
|
||||||
if job_posting.department:
|
|
||||||
message_parts.append(f"**Department:** {job_posting.department}")
|
|
||||||
if job_posting.description:
|
|
||||||
message_parts.append(f"\n{job_posting.description}")
|
|
||||||
|
|
||||||
details = []
|
|
||||||
if job_posting.job_type:
|
|
||||||
details.append(f"💼 {job_posting.get_job_type_display()}")
|
|
||||||
if job_posting.get_location_display() != 'Not specified':
|
|
||||||
details.append(f"📍 {job_posting.get_location_display()}")
|
|
||||||
if job_posting.workplace_type:
|
|
||||||
details.append(f"🏠 {job_posting.get_workplace_type_display()}")
|
|
||||||
if job_posting.salary_range:
|
|
||||||
details.append(f"💰 {job_posting.salary_range}")
|
|
||||||
|
|
||||||
if details:
|
|
||||||
message_parts.append("\n" + " | ".join(details))
|
|
||||||
|
|
||||||
if job_posting.application_url:
|
|
||||||
message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
|
|
||||||
|
|
||||||
hashtags = self.hashtags_list(job_posting.hash_tags)
|
|
||||||
if job_posting.department:
|
|
||||||
dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
|
|
||||||
hashtags.insert(0, dept_hashtag)
|
|
||||||
|
|
||||||
message_parts.append("\n\n" + " ".join(hashtags))
|
|
||||||
message_parts.append(unique_suffix)
|
|
||||||
message = "\n".join(message_parts)
|
|
||||||
|
|
||||||
# 🔥 FIX URL - REMOVE TRAILING SPACES 🔥
|
|
||||||
url = "https://api.linkedin.com/v2/ugcPosts"
|
url = "https://api.linkedin.com/v2/ugcPosts"
|
||||||
headers = {
|
headers = {
|
||||||
'Authorization': f'Bearer {self.access_token}',
|
'Authorization': f'Bearer {self.access_token}',
|
||||||
@ -211,20 +361,14 @@ class LinkedInService:
|
|||||||
"specificContent": {
|
"specificContent": {
|
||||||
"com.linkedin.ugc.ShareContent": {
|
"com.linkedin.ugc.ShareContent": {
|
||||||
"shareCommentary": {"text": message},
|
"shareCommentary": {"text": message},
|
||||||
"shareMediaCategory": "ARTICLE",
|
"shareMediaCategory": "NONE", # Pure text post
|
||||||
"media": [{
|
|
||||||
"status": "READY",
|
|
||||||
"description": {"text": f"Apply for {job_posting.title} at our university!"},
|
|
||||||
"originalUrl": job_posting.application_url,
|
|
||||||
"title": {"text": job_posting.title}
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"visibility": {
|
"visibility": {
|
||||||
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@ -244,18 +388,4 @@ class LinkedInService:
|
|||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e),
|
'error': str(e),
|
||||||
'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
|
'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
|
|
||||||
|
|
||||||
|
|
||||||
@ -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]),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,6 +1,6 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
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.contrib.auth.models import User
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -249,8 +249,8 @@ class JobPosting(Base):
|
|||||||
|
|
||||||
|
|
||||||
class JobPostingImage(models.Model):
|
class JobPostingImage(models.Model):
|
||||||
job=models.ForeignKey('JobPosting',on_delete=models.CASCADE,related_name='post_images')
|
job=models.OneToOneField('JobPosting',on_delete=models.CASCADE,related_name='post_images')
|
||||||
post_image = models.ImageField(upload_to='post/')
|
post_image = models.ImageField(upload_to='post/',validators=[validate_image_size])
|
||||||
|
|
||||||
|
|
||||||
class Candidate(Base):
|
class Candidate(Base):
|
||||||
|
|||||||
@ -1501,9 +1501,14 @@ def candidate_screening_view(request, slug):
|
|||||||
Manage candidate tiers and stage transitions
|
Manage candidate tiers and stage transitions
|
||||||
"""
|
"""
|
||||||
job = get_object_or_404(JobPosting, slug=slug)
|
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)
|
# Get all candidates for this job, ordered by match score (descending)
|
||||||
candidates = job.candidates.filter(stage="Applied").order_by("-match_score")
|
candidates = job.candidates.filter(stage="Applied").order_by("-match_score")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Get tier categorization parameters
|
# Get tier categorization parameters
|
||||||
# tier1_count = int(request.GET.get("tier1_count", 100))
|
# tier1_count = int(request.GET.get("tier1_count", 100))
|
||||||
@ -1601,19 +1606,55 @@ def candidate_screening_view(request, slug):
|
|||||||
# messages.info(request, "All Tier 1 candidates are already marked as Candidates")
|
# messages.info(request, "All Tier 1 candidates are already marked as Candidates")
|
||||||
|
|
||||||
# Group candidates by current stage for display
|
# Group candidates by current stage for display
|
||||||
stage_groups = {
|
# stage_groups = {
|
||||||
"Applied": candidates.filter(stage="Applied"),
|
# "Applied": candidates.filter(stage="Applied"),
|
||||||
"Exam": candidates.filter(stage="Exam"),
|
# "Exam": candidates.filter(stage="Exam"),
|
||||||
"Interview": candidates.filter(stage="Interview"),
|
# "Interview": candidates.filter(stage="Interview"),
|
||||||
"Offer": candidates.filter(stage="Offer"),
|
# "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 = {
|
context = {
|
||||||
"job": job,
|
"job": job,
|
||||||
"candidates": candidates,
|
"candidates": candidates,
|
||||||
# "stage_groups": stage_groups,
|
# "stage_groups": stage_groups,
|
||||||
# "tier1_count": tier1_count,
|
# "tier1_count": tier1_count,
|
||||||
# "total_candidates": candidates.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)
|
return render(request, "recruitment/candidate_screening_view.html", context)
|
||||||
|
|||||||
@ -269,11 +269,14 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-main-action"
|
||||||
id="copyJobLinkButton"
|
id="copyJobLinkButton"
|
||||||
data-url="{{ job.application_url }}">
|
data-url="{{ job.application_url }}">
|
||||||
<i class="fas fa-link me-1"></i>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
{% trans "Copy and Share Public Link" %}
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{% trans "Share Public Link" %}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span id="copyFeedback" class="text-success ms-2 small" style="display:none;">
|
<span id="copyFeedback" class="text-success ms-2 small" style="display:none;">
|
||||||
@ -373,7 +376,7 @@
|
|||||||
{% include 'jobs/partials/applicant_tracking.html' %}
|
{% include 'jobs/partials/applicant_tracking.html' %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card shadow-sm no-hover" style="height:400px;">
|
<div class="card shadow-sm no-hover" style="height:350px;">
|
||||||
|
|
||||||
{# RIGHT TABS NAVIGATION #}
|
{# RIGHT TABS NAVIGATION #}
|
||||||
<ul class="nav nav-tabs right-column-tabs" id="rightJobTabs" role="tablist">
|
<ul class="nav nav-tabs right-column-tabs" id="rightJobTabs" role="tablist">
|
||||||
@ -428,13 +431,13 @@
|
|||||||
|
|
||||||
{% endif %} {% endcomment %}
|
{% endif %} {% endcomment %}
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-4">
|
||||||
<a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-main-action">
|
<a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-main-action">
|
||||||
<i class="fas fa-user-plus"></i> {% trans "Create Applicant" %}
|
<i class="fas fa-user-plus"></i> {% trans "Create Applicant" %}
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'candidate_screening_view' job.slug %}" class="btn btn-main-action">
|
<a href="{% url 'candidate_screening_view' job.slug %}" class="btn btn-main-action">
|
||||||
<i class="fas fa-layer-group"></i> {% trans "Manage Applicants" %}
|
<i class="fas fa-layer-group"></i> {% trans "Manage Applicants" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -11,11 +11,16 @@
|
|||||||
--kaauh-teal-dark: #004a53;
|
--kaauh-teal-dark: #004a53;
|
||||||
--kaauh-border: #eaeff3;
|
--kaauh-border: #eaeff3;
|
||||||
--kaauh-primary-text: #343a40;
|
--kaauh-primary-text: #343a40;
|
||||||
|
--kaauh-success: #28a745;
|
||||||
|
--kaauh-danger: #dc3545;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Primary Color Overrides */
|
/* Primary Color Overrides */
|
||||||
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
||||||
.bg-primary-theme { background-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 */
|
/* Enhanced Card Styling */
|
||||||
.card {
|
.card {
|
||||||
@ -66,6 +71,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.7px;
|
letter-spacing: 0.7px;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
.bg-DRAFT { background-color: #6c757d !important; }
|
.bg-DRAFT { background-color: #6c757d !important; }
|
||||||
.bg-ACTIVE { background-color: var(--kaauh-teal) !important; }
|
.bg-ACTIVE { background-color: var(--kaauh-teal) !important; }
|
||||||
@ -75,92 +81,112 @@
|
|||||||
|
|
||||||
/* --- TABLE ALIGNMENT AND SIZING FIXES --- */
|
/* --- TABLE ALIGNMENT AND SIZING FIXES --- */
|
||||||
.table {
|
.table {
|
||||||
table-layout: fixed; /* Ensures column widths are respected */
|
table-layout: fixed;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
.table thead th {
|
.table thead th {
|
||||||
color: var(--kaauh-primary-text);
|
color: var(--kaauh-primary-text);
|
||||||
font-weight: 500; /* Lighter weight for smaller font */
|
font-weight: 600;
|
||||||
font-size: 0.85rem; /* Smaller font size for header text */
|
font-size: 0.85rem;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
border-bottom: 2px solid var(--kaauh-border);
|
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 {
|
.table-hover tbody tr:hover {
|
||||||
background-color: #f3f7f9;
|
background-color: #f3f7f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Optimized Main Table Column Widths (Total must be 100%) */
|
/* 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) */
|
/* Candidate Management Header Row (The one with P/F) */
|
||||||
.table th:nth-child(3) { width: 8%; } /* Actions (Tight, icon buttons) */
|
.nested-metrics-row th {
|
||||||
.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 */
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
font-size: 0.75rem; /* Even smaller font for nested headers */
|
font-size: 0.75rem;
|
||||||
width: calc(100% / 7);
|
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-metrics-row th {
|
||||||
.nested-header-table thead th:nth-child(2),
|
width: calc(50% / 7);
|
||||||
.nested-header-table thead th:nth-child(5) {
|
|
||||||
width: calc(100% / 7);
|
|
||||||
}
|
}
|
||||||
.nested-header-table thead th:nth-child(3),
|
.nested-metrics-row th[colspan="2"] {
|
||||||
.nested-header-table thead th:nth-child(4) {
|
width: calc(50% / 7 * 2);
|
||||||
width: calc(100% / 7 * 2);
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inner Nested Table (P/F) */
|
/* Inner P/F Headers */
|
||||||
.nested-stage-metrics {
|
.nested-stage-metrics {
|
||||||
width: 100%;
|
display: flex;
|
||||||
border-collapse: collapse;
|
justify-content: space-around;
|
||||||
table-layout: fixed;
|
padding-top: 5px;
|
||||||
}
|
|
||||||
.nested-stage-metrics thead th {
|
|
||||||
padding: 0.1rem 0; /* Very minimal padding */
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--kaauh-teal-dark);
|
color: var(--kaauh-teal-dark);
|
||||||
font-size: 0.7rem; /* Smallest font size */
|
font-size: 0.7rem;
|
||||||
width: 50%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main TH for Candidate Management Header */
|
/* Main TH for Candidate Management Header Title */
|
||||||
.candidate-management-header {
|
.candidate-management-header-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0;
|
padding: 0.5rem 0.25rem;
|
||||||
border-left: 2px solid var(--kaauh-teal);
|
border-left: 2px solid var(--kaauh-teal);
|
||||||
border-right: 1px solid var(--kaauh-border) !important;
|
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 {
|
.candidate-data-cell {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
font-weight: 600;
|
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);
|
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 {
|
.candidate-data-cell a {
|
||||||
display: block;
|
display: block;
|
||||||
text-decoration: none;
|
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;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -215,63 +241,60 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% comment %} --- START OF TABLE VIEW (Data relied upon context variable 'jobs') --- {% endcomment %}
|
{# --- START OF JOB LIST CONTAINER --- #}
|
||||||
<div id="job-list">
|
<div id="job-list">
|
||||||
{% comment %} Placeholder for View Switcher {% endcomment %}
|
{# View Switcher (Contains the Card/Table buttons and JS/CSS logic) #}
|
||||||
{% include "includes/_list_view_switcher.html" with list_id="job-list" %}
|
{% include "includes/_list_view_switcher.html" with list_id="job-list" %}
|
||||||
|
|
||||||
|
{# 1. TABLE VIEW (Default Active) #}
|
||||||
<div class="table-view active">
|
<div class="table-view active">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="table-responsive ">
|
<div class="table-responsive ">
|
||||||
<table class="table table-hover align-middle mb-0 table-sm">
|
<table class="table table-hover align-middle mb-0 table-sm">
|
||||||
|
|
||||||
|
{# --- Corrected Multi-Row Header Structure --- #}
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">{% trans "Job ID" %}</th>
|
<th scope="col" rowspan="2" style="width: 22%;">{% trans "Job Title / ID" %}</th>
|
||||||
{% comment %} <th scope="col">{% trans "Job Title" %}</th>
|
<th scope="col" rowspan="2" style="width: 12%;">{% trans "Source" %}</th>
|
||||||
<th scope="col">{% trans "Status" %}</th> {% endcomment %}
|
<th scope="col" rowspan="2" style="width: 8%;">{% trans "Actions" %}</th>
|
||||||
<th scope="col">{% trans "Source" %}</th>
|
<th scope="col" rowspan="2" class="text-center" style="width: 8%;">{% trans "Manage Forms" %}</th>
|
||||||
<th scope="col">{% trans "Actions" %}</th>
|
|
||||||
<th scop="col" class="text-center">{% trans "Manage Forms" %}</th>
|
|
||||||
|
|
||||||
<th scope="col" colspan="7" class="candidate-management-header">
|
<th scope="col" colspan="7" class="candidate-management-header-title">
|
||||||
{% trans "Applicants Metrics" %}
|
{% trans "Applicants Metrics" %}
|
||||||
<table class="nested-header-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="width: 14.28%;">{% trans "Applied" %}</th>
|
|
||||||
<th style="width: 14.28%;">{% trans "Screened" %}</th>
|
|
||||||
|
|
||||||
<th colspan="2">{% trans "Exam" %}
|
|
||||||
<table class="nested-stage-metrics">
|
|
||||||
<thead>
|
|
||||||
<th>P</th>
|
|
||||||
<th>F</th>
|
|
||||||
</thead>
|
|
||||||
</table>
|
|
||||||
</th>
|
|
||||||
|
|
||||||
<th colspan="2">{% trans "Interview" %}
|
|
||||||
<table class="nested-stage-metrics">
|
|
||||||
<thead>
|
|
||||||
<th>P</th>
|
|
||||||
<th>F</th>
|
|
||||||
</thead>
|
|
||||||
</table>
|
|
||||||
</th>
|
|
||||||
<th style="width: 14.28%;">{% trans "Offer" %}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>
|
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<tr class="nested-metrics-row">
|
||||||
|
<th style="width: calc(50% / 7);">{% trans "Applied" %}</th>
|
||||||
|
<th style="width: calc(50% / 7);">{% trans "Screened" %}</th>
|
||||||
|
|
||||||
|
<th colspan="2" style="width: calc(50% / 7 * 2);">{% trans "Exam" %}
|
||||||
|
<div class="nested-stage-metrics">
|
||||||
|
<span>P</span>
|
||||||
|
<span>F</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th colspan="2" style="width: calc(50% / 7 * 2);">{% trans "Interview" %}
|
||||||
|
<div class="nested-stage-metrics">
|
||||||
|
<span>P</span>
|
||||||
|
<span>F</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th style="width: calc(50% / 7);">{% trans "Offer" %}</th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{% comment %} This loop relies on the 'jobs' variable passed from the Django view {% endcomment %}
|
|
||||||
{% for job in jobs %}
|
{% for job in jobs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fw-medium text-primary-theme">{{ job }}</td>
|
<td class="fw-medium text-primary-theme">
|
||||||
{% comment %} <td class="fw-medium text-primary-theme">{{ job.title }}</td>
|
{{ job.title }}
|
||||||
<td><span class="badge bg-{{ job.status }} status-badge">{{ job.status }}</span></td> {% endcomment %}
|
<br>
|
||||||
|
<small class="text-muted">{{ job.pk }} / </small>
|
||||||
|
<span class="badge bg-{{ job.status }} status-badge">{{ job.status }}</span>
|
||||||
|
</td>
|
||||||
<td>{{ job.get_source }}</td>
|
<td>{{ job.get_source }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
@ -283,10 +306,10 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-center">
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
{% if job.form_template %}
|
{% if job.form_template %}
|
||||||
<a href="{% url 'form_wizard' job.form_template.pk %}" class="btn btn-outline-primary" title="{% trans 'Preview' %}">
|
<a href="{% url 'form_wizard' job.form_template.pk %}" class="btn btn-outline-secondary" title="{% trans 'Preview' %}">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'form_builder' job.form_template.id %}" class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
|
<a href="{% url 'form_builder' job.form_template.id %}" class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
|
||||||
@ -299,7 +322,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{# CANDIDATE MANAGEMENT DATA - 7 SEPARATE COLUMNS CORRESPONDING TO THE HEADER #}
|
{# CANDIDATE MANAGEMENT DATA - URLS NEUTRALIZED #}
|
||||||
<td class="candidate-data-cell text-primary-theme"><a href="#" class="text-primary-theme">{% if job.metrics.applied %}{{ job.metrics.applied }}{% else %}-{% endif %}</a></td>
|
<td class="candidate-data-cell text-primary-theme"><a href="#" class="text-primary-theme">{% if job.metrics.applied %}{{ job.metrics.applied }}{% else %}-{% endif %}</a></td>
|
||||||
<td class="candidate-data-cell text-info"><a href="#" class="text-info">{% if job.metrics.screening %}{{ job.metrics.screening }}{% else %}-{% endif %}</a></td>
|
<td class="candidate-data-cell text-info"><a href="#" class="text-info">{% if job.metrics.screening %}{{ job.metrics.screening }}{% else %}-{% endif %}</a></td>
|
||||||
<td class="candidate-data-cell text-success"><a href="#" class="text-success">{% if job.metrics.exam_p %}{{ job.metrics.exam_p }}{% else %}-{% endif %}</a></td>
|
<td class="candidate-data-cell text-success"><a href="#" class="text-success">{% if job.metrics.exam_p %}{{ job.metrics.exam_p }}{% else %}-{% endif %}</a></td>
|
||||||
@ -314,7 +337,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# 2. CARD VIEW (Previously Missing) - Added Bootstrap row/col structure for layout #}
|
||||||
|
<div class="card-view row g-4">
|
||||||
|
{% for job in jobs %}
|
||||||
|
<div class="col-xl-4 col-lg-6 col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<h5 class="card-title mb-0">{{ job.title }}</h5>
|
||||||
|
<span class="badge bg-{{ job.status }} status-badge">{{ job.status }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-3">ID: {{ job.pk }} | Source: {{ job.get_source }}</p>
|
||||||
|
|
||||||
|
<ul class="list-unstyled small mb-3">
|
||||||
|
<li><i class="fas fa-users text-primary-theme me-2"></i>{% trans "Applicants" %}:{{ job.metrics.applied|default:"0" }}</li>
|
||||||
|
<li><i class="fas fa-clipboard-check text-success me-2"></i> {% trans "Offers Made" %}: {{ job.metrics.offer|default:"0" }}</li>
|
||||||
|
<li><i class="fas fa-file-alt text-info me-2"></i> {% trans "Form" %}:{% if job.form_template %}
|
||||||
|
<a href="{% url 'form_wizard' job.form_template.pk %}" class="text-info">{{ job.form_template.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{% trans "N/A" %}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-auto pt-3 border-top">
|
||||||
|
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary btn-sm" title="{% trans 'View' %}">
|
||||||
|
<i class="fas fa-eye me-1"></i> {% trans "Details" %}
|
||||||
|
</a>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a href="{% url 'job_update' job.slug %}" class="btn btn-outline-secondary" title="{% trans 'Edit Job' %}">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</a>
|
||||||
|
{% if job.form_template %}
|
||||||
|
<a href="{% url 'form_template_submissions_list' job.form_template.slug %}" class="btn btn-outline-secondary" title="{% trans 'Submissions' %}">
|
||||||
|
<i class="fas fa-file-alt"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{# --- END CARD VIEW --- #}
|
||||||
</div>
|
</div>
|
||||||
|
{# --- END OF JOB LIST CONTAINER --- #}
|
||||||
|
|
||||||
{% comment %} Fallback/Empty State {% endcomment %}
|
{% comment %} Fallback/Empty State {% endcomment %}
|
||||||
{% if not jobs and not job_list_data and not page_obj %}
|
{% if not jobs and not job_list_data and not page_obj %}
|
||||||
|
|||||||
@ -172,7 +172,12 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="container-fluid py-4">
|
<div class="container-fluid py-4">
|
||||||
|
<div class="applicant-tracking-timeline">
|
||||||
|
{% include 'jobs/partials/applicant_tracking.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||||
@ -189,16 +194,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="applicant-tracking-timeline">
|
|
||||||
{% include 'jobs/partials/applicant_tracking.html' %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-controls shadow-sm">
|
<div class="filter-controls shadow-sm">
|
||||||
<h4 class="h6 mb-3 fw-bold" style="color: var(--kaauh-primary-text);">
|
<h4 class="h6 mb-3 fw-bold" style="color: var(--kaauh-primary-text);">
|
||||||
<i class="fas fa-sort-numeric-up me-1"></i> {% trans "AI Scoring & Top Candidate Filter" %}
|
<i class="fas fa-sort-numeric-up me-1"></i> {% trans "AI Scoring & Top Candidate Filter" %}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<form method="post" class="mb-0">
|
<form method="GET" class="mb-0">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="row g-3 align-items-end">
|
<div class="row g-3 align-items-end">
|
||||||
|
|
||||||
@ -207,7 +210,7 @@
|
|||||||
{% trans "Min AI Score" %}
|
{% trans "Min AI Score" %}
|
||||||
</label>
|
</label>
|
||||||
<input type="number" name="min_ai_score" id="min_ai_score" class="form-control form-control-sm"
|
<input type="number" name="min_ai_score" id="min_ai_score" class="form-control form-control-sm"
|
||||||
value="{{ min_ai_score|default:'0' }}" min="0" max="100" step="1"
|
value="{{ min_ai_score}}" min="0" max="100" step="1"
|
||||||
placeholder="e.g., 75">
|
placeholder="e.g., 75">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
/* Main Action Button Style */
|
/* Main Action Button Style */
|
||||||
.btn-main-action{
|
.btn-main-action{
|
||||||
background-color: var(--kaauh-teal-dark); /* Changed to primary teal for main actions */
|
background-color: gray; /* Changed to primary teal for main actions */
|
||||||
border-color: var(--kaauh-teal);
|
border-color: var(--kaauh-teal);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user