few bug fixes

This commit is contained in:
Faheed 2025-10-14 13:53:34 +03:00
parent 6521cdf2be
commit b9904b3ec8
12 changed files with 479 additions and 208 deletions

View File

@ -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 LinkedIns 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!* Were 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

View File

@ -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]),
),
]

View File

@ -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):

View File

@ -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)

View File

@ -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>

View File

@ -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 %}

View File

@ -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>

View File

@ -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;