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
|
||||
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'<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):
|
||||
"""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
|
||||
|
||||
|
||||
}
|
||||
@ -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.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):
|
||||
|
||||
@ -1501,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))
|
||||
@ -1601,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)
|
||||
|
||||
@ -269,11 +269,14 @@
|
||||
<div class="col-md-6">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
class="btn btn-main-action"
|
||||
id="copyJobLinkButton"
|
||||
data-url="{{ job.application_url }}">
|
||||
<i class="fas fa-link me-1"></i>
|
||||
{% trans "Copy and Share Public Link" %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<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>
|
||||
|
||||
<span id="copyFeedback" class="text-success ms-2 small" style="display:none;">
|
||||
@ -373,7 +376,7 @@
|
||||
{% include 'jobs/partials/applicant_tracking.html' %}
|
||||
</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 #}
|
||||
<ul class="nav nav-tabs right-column-tabs" id="rightJobTabs" role="tablist">
|
||||
@ -428,13 +431,13 @@
|
||||
|
||||
{% 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">
|
||||
<i class="fas fa-user-plus"></i> {% trans "Create Applicant" %}
|
||||
</a>
|
||||
<a href="{% url 'candidate_screening_view' job.slug %}" class="btn btn-main-action">
|
||||
<i class="fas fa-layer-group"></i> {% trans "Manage Applicants" %}
|
||||
</a>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@ -215,63 +241,60 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% comment %} --- START OF TABLE VIEW (Data relied upon context variable 'jobs') --- {% endcomment %}
|
||||
{# --- START OF JOB LIST CONTAINER --- #}
|
||||
<div id="job-list">
|
||||
{% 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) #}
|
||||
<div class="table-view active">
|
||||
<div class="card shadow-sm">
|
||||
<div class="table-responsive ">
|
||||
<table class="table table-hover align-middle mb-0 table-sm">
|
||||
|
||||
{# --- Corrected Multi-Row Header Structure --- #}
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Job ID" %}</th>
|
||||
{% comment %} <th scope="col">{% trans "Job Title" %}</th>
|
||||
<th scope="col">{% trans "Status" %}</th> {% endcomment %}
|
||||
<th scope="col">{% trans "Source" %}</th>
|
||||
<th scope="col">{% trans "Actions" %}</th>
|
||||
<th scop="col" class="text-center">{% trans "Manage Forms" %}</th>
|
||||
<th scope="col" rowspan="2" style="width: 22%;">{% trans "Job Title / ID" %}</th>
|
||||
<th scope="col" rowspan="2" style="width: 12%;">{% trans "Source" %}</th>
|
||||
<th scope="col" rowspan="2" style="width: 8%;">{% trans "Actions" %}</th>
|
||||
<th scope="col" rowspan="2" class="text-center" style="width: 8%;">{% 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" %}
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<tbody>
|
||||
{% comment %} This loop relies on the 'jobs' variable passed from the Django view {% endcomment %}
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td class="fw-medium text-primary-theme">{{ job }}</td>
|
||||
{% comment %} <td class="fw-medium text-primary-theme">{{ job.title }}</td>
|
||||
<td><span class="badge bg-{{ job.status }} status-badge">{{ job.status }}</span></td> {% endcomment %}
|
||||
<td class="fw-medium text-primary-theme">
|
||||
{{ job.title }}
|
||||
<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>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
@ -283,10 +306,10 @@
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<td class="text-center">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% 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>
|
||||
</a>
|
||||
<a href="{% url 'form_builder' job.form_template.id %}" class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
|
||||
@ -299,7 +322,7 @@
|
||||
</div>
|
||||
</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-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>
|
||||
@ -314,7 +337,53 @@
|
||||
</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>
|
||||
{# --- END OF JOB LIST CONTAINER --- #}
|
||||
|
||||
{% comment %} Fallback/Empty State {% endcomment %}
|
||||
{% if not jobs and not job_list_data and not page_obj %}
|
||||
|
||||
@ -172,7 +172,12 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<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>
|
||||
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||
@ -189,16 +194,14 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="applicant-tracking-timeline">
|
||||
{% include 'jobs/partials/applicant_tracking.html' %}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="filter-controls shadow-sm">
|
||||
<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" %}
|
||||
</h4>
|
||||
|
||||
<form method="post" class="mb-0">
|
||||
<form method="GET" class="mb-0">
|
||||
{% csrf_token %}
|
||||
<div class="row g-3 align-items-end">
|
||||
|
||||
@ -207,7 +210,7 @@
|
||||
{% trans "Min AI Score" %}
|
||||
</label>
|
||||
<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">
|
||||
</div>
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
|
||||
/* Main Action Button Style */
|
||||
.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);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user