This commit is contained in:
ismail 2025-10-21 14:19:13 +03:00
parent f0ae8f46d9
commit 5921e30396
23 changed files with 2514 additions and 279 deletions

View File

@ -1,4 +1,4 @@
# Generated by Django 5.2.7 on 2025-10-19 15:50
# Generated by Django 5.2.6 on 2025-10-19 18:40
import django.core.validators
import django.db.models.deletion
@ -101,6 +101,8 @@ class Migration(migrations.Migration):
('topic', models.CharField(max_length=255, verbose_name='Topic')),
('meeting_id', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('meeting_id', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration')),
('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
('join_url', models.URLField(verbose_name='Join URL')),
@ -111,6 +113,7 @@ class Migration(migrations.Migration):
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')),
('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')),
],
options={
'abstract': False,
@ -160,6 +163,24 @@ class Migration(migrations.Migration):
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FormSubmission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('submitted_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('applicant_name', models.CharField(blank=True, help_text='Name of the applicant', max_length=200)),
('applicant_email', models.EmailField(blank=True, db_index=True, help_text='Email of the applicant', max_length=254)),
('submitted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='form_submissions', to=settings.AUTH_USER_MODEL)),
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='recruitment.formtemplate')),
],
options={
'verbose_name': 'Form Submission',
'verbose_name_plural': 'Form Submissions',
'ordering': ['-submitted_at'],
},
migrations.CreateModel(
name='FormSubmission',
fields=[
@ -194,6 +215,7 @@ class Migration(migrations.Migration):
('first_name', models.CharField(max_length=255, verbose_name='First Name')),
('last_name', models.CharField(max_length=255, verbose_name='Last Name')),
('email', models.EmailField(db_index=True, max_length=254, verbose_name='Email')),
('email', models.EmailField(db_index=True, max_length=254, verbose_name='Email')),
('phone', models.CharField(max_length=20, verbose_name='Phone')),
('address', models.TextField(max_length=200, verbose_name='Address')),
('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')),
@ -202,15 +224,18 @@ class Migration(migrations.Migration):
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
('applied', models.BooleanField(default=False, verbose_name='Applied')),
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')),
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')),
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')),
('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')),
('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status')),
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status')),
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')),
('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
('ai_analysis_data', models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data')),
('ai_analysis_data', models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data')),
('submitted_by_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_candidates', to='recruitment.hiringagency', verbose_name='Submitted by Agency')),
],
options={
@ -238,10 +263,12 @@ class Migration(migrations.Migration):
('application_url', models.URLField(blank=True, help_text='URL where candidates apply', null=True, validators=[django.core.validators.URLValidator()])),
('application_start_date', models.DateField(blank=True, null=True)),
('application_deadline', models.DateField(blank=True, db_index=True, null=True)),
('application_deadline', models.DateField(blank=True, db_index=True, null=True)),
('application_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)),
('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)),
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20)),
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20)),
('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])),
('linkedin_post_id', models.CharField(blank=True, help_text='LinkedIn post ID after posting', max_length=200)),
('linkedin_post_url', models.URLField(blank=True, help_text='Direct URL to LinkedIn post')),
@ -249,11 +276,13 @@ class Migration(migrations.Migration):
('linkedin_post_status', models.CharField(blank=True, help_text='Status of LinkedIn posting', max_length=50)),
('linkedin_posted_at', models.DateTimeField(blank=True, null=True)),
('published_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('published_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)),
('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)),
('joining_date', models.DateField(blank=True, help_text='Desired start date', null=True)),
('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')),
('max_applications', models.PositiveIntegerField(blank=True, default=1000, help_text='Maximum number of applications allowed', null=True)),
('max_applications', models.PositiveIntegerField(blank=True, default=1000, help_text='Maximum number of applications allowed', null=True)),
('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')),
('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
('cancelled_at', models.DateTimeField(blank=True, null=True)),
@ -271,18 +300,24 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
('end_date', models.DateField(db_index=True, verbose_name='End Date')),
('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
('end_date', models.DateField(db_index=True, verbose_name='End Date')),
('working_days', models.JSONField(verbose_name='Working Days')),
('start_time', models.TimeField(verbose_name='Start Time')),
('end_time', models.TimeField(verbose_name='End Time')),
('break_start_time', models.TimeField(blank=True, null=True, verbose_name='Break Start Time')),
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
('break_start_time', models.TimeField(blank=True, null=True, verbose_name='Break Start Time')),
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate')),
('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
],
@ -378,8 +413,10 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
('interview_time', models.TimeField(verbose_name='Interview Time')),
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')),
@ -417,7 +454,40 @@ class Migration(migrations.Migration):
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formfield')),
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formsubmission')),
],
),
migrations.CreateModel(
name='MeetingComment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meeting_comments', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recruitment.zoommeeting', verbose_name='Meeting')),
],
options={
'verbose_name': 'Meeting Comment',
'verbose_name_plural': 'Meeting Comments',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FieldResponse',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('value', models.JSONField(blank=True, help_text='Response value (stored as JSON)', null=True)),
('uploaded_file', models.FileField(blank=True, null=True, upload_to='form_uploads/')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formfield')),
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formsubmission')),
],
options={
'verbose_name': 'Field Response',
'verbose_name_plural': 'Field Responses',
'indexes': [models.Index(fields=['submission'], name='recruitment_submiss_474130_idx'), models.Index(fields=['field'], name='recruitment_field_i_097e5b_idx')],
'verbose_name': 'Field Response',
'verbose_name_plural': 'Field Responses',
'indexes': [models.Index(fields=['submission'], name='recruitment_submiss_474130_idx'), models.Index(fields=['field'], name='recruitment_field_i_097e5b_idx')],
@ -475,4 +545,56 @@ class Migration(migrations.Migration):
model_name='scheduledinterview',
index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'),
),
migrations.AddIndex(
model_name='formsubmission',
index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'),
),
migrations.AddIndex(
model_name='interviewschedule',
index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'),
),
migrations.AddIndex(
model_name='interviewschedule',
index=models.Index(fields=['end_date'], name='recruitment_end_dat_aeb00e_idx'),
),
migrations.AddIndex(
model_name='interviewschedule',
index=models.Index(fields=['created_by'], name='recruitment_created_d0bdcc_idx'),
),
migrations.AddIndex(
model_name='formtemplate',
index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'),
),
migrations.AddIndex(
model_name='formtemplate',
index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'),
),
migrations.AddIndex(
model_name='candidate',
index=models.Index(fields=['stage'], name='recruitment_stage_f1c6eb_idx'),
),
migrations.AddIndex(
model_name='candidate',
index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'),
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
),
migrations.AddIndex(
model_name='scheduledinterview',
index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'),
),
migrations.AddIndex(
model_name='scheduledinterview',
index=models.Index(fields=['interview_date', 'interview_time'], name='recruitment_intervi_7f5877_idx'),
),
migrations.AddIndex(
model_name='scheduledinterview',
index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'),
),
]

View File

@ -2,8 +2,9 @@ from django.db import models
from django.urls import reverse
from typing import List,Dict,Any
from django.utils import timezone
from django.db.models import FloatField,CharField
from django.db.models.functions import Cast
from django.db.models import FloatField,CharField,IntegerField
from django.db.models.functions import Cast,Coalesce
from django.db.models import F
from django.contrib.auth.models import User
from django.core.validators import URLValidator
from django_countries.fields import CountryField
@ -289,7 +290,7 @@ class JobPosting(Base):
return self.current_applications_count >= self.max_applications
@property
def all_candidates(self):
return self.candidates.annotate(sortable_score=Cast('ai_analysis_data__match_score',output_field=CharField())).order_by('-sortable_score')
return self.candidates.annotate(sortable_score=Coalesce(Cast('ai_analysis_data__analysis_data__match_score',output_field=IntegerField()),0)).order_by('-sortable_score')
@property
def screening_candidates(self):
return self.all_candidates.filter(stage="Applied")
@ -451,94 +452,99 @@ class Candidate(Base):
Generic method to set any single key-value pair and save.
"""
self.ai_analysis_data[key] = value
self.save(update_fields=['ai_analysis_data'])
# self.save(update_fields=['ai_analysis_data'])
# ====================================================================
# ✨ PROPERTIES (GETTERS)
# ====================================================================
@property
def resume_data(self):
return self.ai_analysis_data.get('resume_data', {})
@property
def analysis_data(self):
return self.ai_analysis_data.get('analysis_data', {})
@property
def match_score(self) -> int:
"""1. A score from 0 to 100 representing how well the candidate fits the role."""
return self.ai_analysis_data.get('match_score', 0)
return self.analysis_data.get('match_score', 0)
@property
def years_of_experience(self) -> float:
"""4. The total number of years of professional experience as a numerical value."""
return self.ai_analysis_data.get('years_of_experience', 0.0)
return self.analysis_data.get('years_of_experience', 0.0)
@property
def soft_skills_score(self) -> int:
"""15. A score (0-100) for inferred non-technical skills."""
return self.ai_analysis_data.get('soft_skills_score', 0)
return self.analysis_data.get('soft_skills_score', 0)
@property
def industry_match_score(self) -> int:
"""16. A score (0-100) for the relevance of the candidate's industry experience."""
# Renamed to clarify: experience_industry_match
return self.ai_analysis_data.get('experience_industry_match', 0)
return self.analysis_data.get('experience_industry_match', 0)
# --- Properties for Funnel & Screening Efficiency ---
@property
def min_requirements_met(self) -> bool:
"""14. Boolean (true/false) indicating if all non-negotiable minimum requirements are met."""
return self.ai_analysis_data.get('min_req_met_bool', False)
return self.analysis_data.get('min_req_met_bool', False)
@property
def screening_stage_rating(self) -> str:
"""13. A standardized rating (e.g., "A - Highly Qualified", "B - Qualified")."""
return self.ai_analysis_data.get('screening_stage_rating', 'N/A')
return self.analysis_data.get('screening_stage_rating', 'N/A')
@property
def top_3_keywords(self) -> List[str]:
"""10. A list of the three most dominant and relevant technical skills or technologies."""
return self.ai_analysis_data.get('top_3_keywords', [])
return self.analysis_data.get('top_3_keywords', [])
@property
def most_recent_job_title(self) -> str:
"""8. The candidate's most recent or current professional job title."""
return self.ai_analysis_data.get('most_recent_job_title', 'N/A')
return self.analysis_data.get('most_recent_job_title', 'N/A')
# --- Properties for Structured Detail ---
@property
def criteria_checklist(self) -> Dict[str, str]:
"""5 & 6. An object rating the candidate's match for each specific criterion."""
return self.ai_analysis_data.get('criteria_checklist', {})
return self.analysis_data.get('criteria_checklist', {})
@property
def professional_category(self) -> str:
"""7. The most fitting professional field or category for the individual."""
return self.ai_analysis_data.get('category', 'N/A')
return self.analysis_data.get('category', 'N/A')
@property
def language_fluency(self) -> List[Dict[str, str]]:
"""12. A list of languages and their fluency levels mentioned."""
return self.ai_analysis_data.get('language_fluency', [])
return self.analysis_data.get('language_fluency', [])
# --- Properties for Summaries and Narrative ---
@property
def strengths(self) -> str:
"""2. A brief summary of why the candidate is a strong fit."""
return self.ai_analysis_data.get('strengths', '')
return self.analysis_data.get('strengths', '')
@property
def weaknesses(self) -> str:
"""3. A brief summary of where the candidate falls short or what criteria are missing."""
return self.ai_analysis_data.get('weaknesses', '')
return self.analysis_data.get('weaknesses', '')
@property
def job_fit_narrative(self) -> str:
"""11. A single, concise sentence summarizing the core fit."""
return self.ai_analysis_data.get('job_fit_narrative', '')
return self.analysis_data.get('job_fit_narrative', '')
@property
def recommendation(self) -> str:
"""9. Provide a detailed final recommendation for the candidate."""
# Using a more descriptive name to avoid conflict with potential built-in methods
return self.ai_analysis_data.get('recommendation', '')
return self.analysis_data.get('recommendation', '')
@property
def name(self):

View File

@ -1,3 +1,4 @@
import re
import os
import json
import logging
@ -11,22 +12,31 @@ from . linkedin_service import LinkedInService
from django.shortcuts import get_object_or_404
from . models import JobPosting
from django.utils import timezone
from . models import InterviewSchedule,ScheduledInterview,ZoomMeeting
from .models import ScheduledInterview, ZoomMeeting, Candidate, JobPosting, InterviewSchedule
# Add python-docx import for Word document processing
try:
from docx import Document
DOCX_AVAILABLE = True
except ImportError:
DOCX_AVAILABLE = False
logger = logging.getLogger(__name__)
logger.warning("python-docx not available. Word document processing will be disabled.")
logger = logging.getLogger(__name__)
OPENROUTER_API_KEY ='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'
# OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free'
# OPENROUTER_MODEL = 'openai/gpt-oss-20b:free'
OPENROUTER_MODEL = 'openai/gpt-oss-20b'
OPENROUTER_MODEL = 'openai/gpt-oss-20b:free'
# OPENROUTER_MODEL = 'openai/gpt-oss-20b'
# OPENROUTER_MODEL = 'mistralai/mistral-small-3.2-24b-instruct:free'
if not OPENROUTER_API_KEY:
logger.warning("OPENROUTER_API_KEY not set. Resume scoring will be skipped.")
def extract_text_from_pdf(file_path):
print("text extraction")
"""Extract text from PDF files"""
print("PDF text extraction")
text = ""
try:
with open(file_path, "rb") as f:
@ -38,6 +48,58 @@ def extract_text_from_pdf(file_path):
raise
return text.strip()
def extract_text_from_word(file_path):
"""Extract text from Word documents (.docx)"""
if not DOCX_AVAILABLE:
raise ImportError("python-docx is not installed. Please install it with: pip install python-docx")
print("Word text extraction")
text = ""
try:
doc = Document(file_path)
# Extract text from paragraphs
for paragraph in doc.paragraphs:
text += paragraph.text + "\n"
# Extract text from tables
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
text += cell.text + "\t"
text += "\n"
# Extract text from headers and footers
for section in doc.sections:
# Header
if section.header:
for paragraph in section.header.paragraphs:
text += "[HEADER] " + paragraph.text + "\n"
# Footer
if section.footer:
for paragraph in section.footer.paragraphs:
text += "[FOOTER] " + paragraph.text + "\n"
except Exception as e:
logger.error(f"Word extraction failed: {e}")
raise
return text.strip()
def extract_text_from_document(file_path):
"""Extract text from documents based on file type"""
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
file_ext = os.path.splitext(file_path)[1].lower()
if file_ext == '.pdf':
return extract_text_from_pdf(file_path)
elif file_ext == '.docx':
return extract_text_from_word(file_path)
else:
raise ValueError(f"Unsupported file type: {file_ext}. Only .pdf and .docx files are supported.")
def ai_handler(prompt):
print("model call")
response = requests.post(
@ -116,7 +178,7 @@ def ai_handler(prompt):
# Instructions:
# Be concise but preserve key details.
# Normalize formatting (e.g., “Jun. 2014” → “2014-06”).
# Normalize formatting (e.g., "Jun. 2014" → "2014-06").
# Omit redundant or promotional language.
# If a section is missing, omit the key or set it to null/empty list as appropriate.
# Output only valid JSON—no markdown, no extra text.
@ -208,6 +270,20 @@ def ai_handler(prompt):
# instance.save(update_fields=['is_resume_parsed'])
# logger.error(f"Failed to score resume for candidate:{instance.pk} {e}")
def safe_cast_to_float(value, default=0.0):
"""Safely converts a value (int, float, or string) to a float."""
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
# Remove non-numeric characters except the decimal point
cleaned_value = re.sub(r'[^\d.]', '', value)
try:
# Ensure we handle empty strings after cleaning
return float(cleaned_value) if cleaned_value else default
except ValueError:
return default
return default
def handle_reume_parsing_and_scoring(pk):
"""
Optimized Django-Q task to parse a resume, score the candidate against a job,
@ -235,7 +311,8 @@ def handle_reume_parsing_and_scoring(pk):
# Consider marking the task as unsuccessful but don't re-queue
return
resume_text = extract_text_from_pdf(file_path)
# Use the new unified document parser
resume_text = extract_text_from_document(file_path)
job_detail = f"{instance.job.description} {instance.job.qualifications}"
except Exception as e:
@ -248,8 +325,8 @@ def handle_reume_parsing_and_scoring(pk):
You are an expert AI system functioning as both a Resume Parser and a Technical Recruiter.
Your task is to:
1. **PARSE**: Extract all key-value information from the provided RESUME TEXT into a clean JSON structure under the key 'parsed_data'.
2. **SCORE**: Analyze the parsed data against the JOB CRITERIA and generate a comprehensive score and analysis under the key 'scoring_data'.
1. **PARSE**: Extract all key-value information from the provided RESUME TEXT into a clean JSON structure under the key 'resume_data', preserving the original text and it's formatting and dont add any extra text.
2. **SCORE**: Analyze the parsed data against the JOB CRITERIA and generate a comprehensive score and analysis under the key 'analysis_data'.
**JOB CRITERIA:**
{job_detail}
@ -260,7 +337,8 @@ def handle_reume_parsing_and_scoring(pk):
**STRICT JSON OUTPUT INSTRUCTIONS:**
Output a single, valid JSON object with ONLY the following two top-level keys:
1. "parsed_data": {{
1. "resume_data": {{
"full_name": "Full name of the candidate",
"current_title": "Most recent or current job title",
"location": "City and state",
@ -271,9 +349,9 @@ def handle_reume_parsing_and_scoring(pk):
"education": [{{
"institution": "Institution name",
"degree": "Degree name",
"year": "Year of graduation",
"year": "Year of graduation" (if provided) or '',
"gpa": "GPA (if provided)",
"relevant_courses": ["list", "of", "courses"]
"relevant_courses": ["list", "of", "courses"](if provided) or []
}}],
"skills": {{
"category_1": ["skill_a", "skill_b"],
@ -285,32 +363,37 @@ def handle_reume_parsing_and_scoring(pk):
"location": "Location",
"start_date": "YYYY-MM",
"end_date": "YYYY-MM or Present",
"key_achievements": ["concise", "bullet", "points"]
"key_achievements": ["concise bullet points"] (if provided) or []
}}],
"projects": [{{
"name": "Project name",
"year": "Year",
"technologies_used": ["list", "of", "tech"],
"technologies_used": ["list", "of", "tech"] (if provided) or [],
"brief_description": "description"
}}]
}}
2. "scoring_data": {{
"match_score": "Score 0-100",
2. "analysis_data": {{
"match_score": "Integer Score 0-100",
"strengths": "Brief summary of strengths",
"weaknesses": "Brief summary of weaknesses",
"years_of_experience": "Total years of experience (float, e.g., 6.5)",
"criteria_checklist": {{ "Python": "Met", "AWS": "Not Mentioned"}},
"category": "Most fitting professional field (e.g., Data Science)",
"category": "Most fitting professional field (e.g., Data Science), only output the category name and no other text example ('Software Development', 'correct') , ('Software Development and devops','wrong') ('Software Development / Backend Development','wrong')",
"most_recent_job_title": "Candidate's most recent job title",
"recommendation": "Detailed hiring recommendation narrative",
"top_3_keywords": ["keyword1", "keyword2", "keyword3"],
"job_fit_narrative": "Single, concise summary sentence",
"language_fluency": ["language: fluency_level"],
"screening_stage_rating": "Standardized rating (e.g., A - Highly Qualified)",
"screening_stage_rating": "Standardized rating (Highly Qualified, Qualified , Partially Qualified, Not Qualified)",
"min_req_met_bool": "Boolean (true/false)",
"soft_skills_score": "Score 0-100 for inferred non-technical skills",
"experience_industry_match": "Score 0-100 for industry relevance"
"soft_skills_score": "Integer Score 0-100 for inferred non-technical skills",
"experience_industry_match": "Integer Score 0-100 for industry relevance",
"seniority_level_match": "Integer Score 0-100 for alignment with JD's seniority level",
"red_flags": ["List of any potential concerns (if any): e.g., 'Employment gap 1 year', 'Frequent job hopping', 'Missing required certification'"],
"employment_stability_score": "Integer Score 0-100 (Higher is more stable/longer tenure) (if possible)",
"transferable_skills_narrative": "A brief sentence describing the relevance of non-core experience (if applicable).",
"cultural_fit_keywords": ["A list of 3-5 keywords extracted from the resume (if possible) (e.g., 'team-player', 'mentored', 'cross-functional')"]
}}
If a top-level key or its required fields are missing, set the field to null, an empty list, or an empty object as appropriate.
@ -330,8 +413,8 @@ def handle_reume_parsing_and_scoring(pk):
data = json.loads(data)
print(data)
parsed_summary = data.get('parsed_data', {})
scoring_result = data.get('scoring_data', {})
# parsed_summary = data.get('parsed_data', {})
# scoring_result = data.get('scoring_data', {})
except Exception as e:
logger.error(f"AI handler failed for candidate {instance.pk}: {e}")
@ -342,37 +425,64 @@ def handle_reume_parsing_and_scoring(pk):
with transaction.atomic():
# Map JSON keys to model fields with appropriate defaults
update_map = {
'match_score': ('match_score', 0),
'years_of_experience': ('years_of_experience', 0.0),
'soft_skills_score': ('soft_skills_score', 0),
'experience_industry_match': ('experience_industry_match', 0),
# update_map = {
# 'match_score': ('match_score', 0),
# 'years_of_experience': ('years_of_experience', 0.0),
# 'soft_skills_score': ('soft_skills_score', 0),
# 'experience_industry_match': ('experience_industry_match', 0),
'min_req_met_bool': ('min_req_met_bool', False),
'screening_stage_rating': ('screening_stage_rating', 'N/A'),
'most_recent_job_title': ('most_recent_job_title', 'N/A'),
'top_3_keywords': ('top_3_keywords', []),
# 'min_req_met_bool': ('min_req_met_bool', False),
# 'screening_stage_rating': ('screening_stage_rating', 'N/A'),
# 'most_recent_job_title': ('most_recent_job_title', 'N/A'),
# 'top_3_keywords': ('top_3_keywords', []),
'strengths': ('strengths', ''),
'weaknesses': ('weaknesses', ''),
'job_fit_narrative': ('job_fit_narrative', ''),
'recommendation': ('recommendation', ''),
# 'strengths': ('strengths', ''),
# 'weaknesses': ('weaknesses', ''),
# 'job_fit_narrative': ('job_fit_narrative', ''),
# 'recommendation': ('recommendation', ''),
'criteria_checklist': ('criteria_checklist', {}),
'language_fluency': ('language_fluency', []),
'category': ('category', 'N/A'),
}
# 'criteria_checklist': ('criteria_checklist', {}),
# 'language_fluency': ('language_fluency', []),
# 'category': ('category', 'N/A'),
# }
# Apply scoring results to the instance
for model_field, (json_key, default_value) in update_map.items():
instance.ai_analysis_data[model_field] = scoring_result.get(json_key, default_value)
# for model_field, (json_key, default_value) in update_map.items():
# instance.ai_analysis_data[model_field] = scoring_result.get(json_key, default_value)
# instance.set_field(model_field, scoring_result.get(json_key, default_value))
# instance.set_field("match_score" , int(safe_cast_to_float(scoring_result.get('match_score', 0), default=0)))
# instance.set_field("years_of_experience" , safe_cast_to_float(scoring_result.get('years_of_experience', 0.0)))
# instance.set_field("soft_skills_score" , int(safe_cast_to_float(scoring_result.get('soft_skills_score', 0), default=0)))
# instance.set_field("experience_industry_match" , int(safe_cast_to_float(scoring_result.get('experience_industry_match', 0), default=0)))
# # Other Model Fields
# instance.set_field("min_req_met_bool" , scoring_result.get('min_req_met_bool', False))
# instance.set_field("screening_stage_rating" , scoring_result.get('screening_stage_rating', 'N/A'))
# instance.set_field("category" , scoring_result.get('category', 'N/A'))
# instance.set_field("most_recent_job_title" , scoring_result.get('most_recent_job_title', 'N/A'))
# instance.set_field("top_3_keywords" , scoring_result.get('top_3_keywords', []))
# instance.set_field("strengths" , scoring_result.get('strengths', ''))
# instance.set_field("weaknesses" , scoring_result.get('weaknesses', ''))
# instance.set_field("job_fit_narrative" , scoring_result.get('job_fit_narrative', ''))
# instance.set_field("recommendation" , scoring_result.get('recommendation', ''))
# instance.set_field("criteria_checklist" , scoring_result.get('criteria_checklist', {}))
# instance.set_field("language_fluency" , scoring_result.get('language_fluency', []))
# 2. Update the Full JSON Field (ai_analysis_data)
if instance.ai_analysis_data is None:
instance.ai_analysis_data = {}
# Save both structured outputs into the single JSONField for completeness
instance.ai_analysis_data = data
# instance.ai_analysis_data['parsed_data'] = parsed_summary
# instance.ai_analysis_data['scoring_data'] = scoring_result
# Apply parsing results
instance.parsed_summary = json.dumps(parsed_summary)
# instance.parsed_summary = json.dumps(parsed_summary)
instance.is_resume_parsed = True
instance.save(update_fields=['ai_analysis_data','parsed_summary', 'is_resume_parsed'])
instance.save(update_fields=['ai_analysis_data', 'is_resume_parsed'])
logger.info(f"Successfully scored and saved analysis for candidate {instance.id}")
print(f"Successfully scored and saved analysis for candidate {instance.id}")
@ -530,7 +640,3 @@ def linkedin_post_task(job_slug, access_token):
job.linkedin_post_status = f"CRITICAL_ERROR: {str(e)}"
job.save()
return False

View File

@ -0,0 +1,11 @@
from django import template
register = template.Library()
@register.filter(name='split_language')
def split_language(value):
"""Split language string and return proficiency level"""
if ':' in value:
parts = value.split(':', 1) # Split only on first colon
return parts[1].strip() if len(parts) > 1 else value
return value

View File

@ -0,0 +1,10 @@
from django import template
register = template.Library()
@register.filter(name='safe_get')
def safe_get(dictionary, key):
"""Safely get a value from a dictionary, returning empty string if key doesn't exist"""
if dictionary and key in dictionary:
return dictionary[key]
return ""

View File

@ -32,6 +32,7 @@ urlpatterns = [
path('candidates/<slug:slug>/update/', views_frontend.CandidateUpdateView.as_view(), name='candidate_update'),
path('candidates/<slug:slug>/delete/', views_frontend.CandidateDeleteView.as_view(), name='candidate_delete'),
path('candidate/<slug:slug>/view/', views_frontend.candidate_detail, name='candidate_detail'),
path('candidate/<slug:slug>/resume-template/', views_frontend.candidate_resume_template_view, name='candidate_resume_template'),
path('candidate/<slug:slug>/update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'),
# Training URLs

View File

@ -1,21 +1,25 @@
import json
import requests
from django.utils.translation import gettext as _
from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django import forms
from rich import print
from django.template.loader import render_to_string
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.http import HttpResponse, JsonResponse
from datetime import datetime,time,timedelta
from django.views import View
from django.db.models import Q
from django.urls import reverse
from django.conf import settings
from django.utils import timezone
from django.db.models import FloatField,CharField, DurationField
from django.db.models.functions import Cast
from django.db.models.fields.json import KeyTextTransform
from django.db.models.expressions import ExpressionWrapper
from django.db.models import Count, Avg, F,Q
from .forms import (
CandidateExamDateForm,
InterviewForm,
@ -386,24 +390,83 @@ def job_detail(request, slug):
messages.error(request, "Failed to update status due to validation errors.")
category_data = applicants.filter(
major_category_name__isnull=False,
ai_analysis_data__match_score__isnull=False # This was part of the original query, ensure it's intentional
).values('major_category_name').annotate(
candidate_count=Count('id'),
ai_analysis_data__avg_match_score=Avg('match_score', output_field=FloatField())
).order_by('major_category_name')
# --- 2. Quality Metrics (JSON Aggregation) ---
# Filter for candidates who have been scored and annotate with a sortable score
candidates_with_score = applicants.filter(is_resume_parsed=True).annotate(
# Extract the score as TEXT
score_as_text=KeyTextTransform(
'match_score',
KeyTextTransform('resume_data', F('ai_analysis_data'))
)
).annotate(
# Cast the extracted text score to a FloatField for numerical operations
sortable_score=Cast('score_as_text', output_field=FloatField())
)
# Aggregate: Average Match Score
avg_match_score_result = candidates_with_score.aggregate(
avg_score=Avg('sortable_score')
)['avg_score']
avg_match_score = round(avg_match_score_result or 0, 1)
# Metric: High Potential Count (Score >= 75)
high_potential_count = candidates_with_score.filter(
sortable_score__gte=75
).count()
high_potential_ratio = round((high_potential_count / total_applicant) * 100, 1) if total_applicant > 0 else 0
# --- 3. Time Metrics (Duration Aggregation) ---
# Metric: Average Time from Applied to Interview (T2I)
t2i_candidates = applicants.filter(
interview_date__isnull=False
).annotate(
time_to_interview=ExpressionWrapper(
F('interview_date') - F('created_at'),
output_field=DurationField()
)
)
avg_t2i_duration = t2i_candidates.aggregate(
avg_t2i=Avg('time_to_interview')
)['avg_t2i']
# Convert timedelta to days
avg_t2i_days = round(avg_t2i_duration.total_seconds() / (60*60*24), 1) if avg_t2i_duration else 0
# Metric: Average Time in Exam Stage
t_in_exam_candidates = applicants.filter(
exam_date__isnull=False, interview_date__isnull=False
).annotate(
time_in_exam=ExpressionWrapper(
F('interview_date') - F('exam_date'),
output_field=DurationField()
)
)
avg_t_in_exam_duration = t_in_exam_candidates.aggregate(
avg_t_in_exam=Avg('time_in_exam')
)['avg_t_in_exam']
# Convert timedelta to days
avg_t_in_exam_days = round(avg_t_in_exam_duration.total_seconds() / (60*60*24), 1) if avg_t_in_exam_duration else 0
category_data = applicants.filter(
ai_analysis_data__analysis_data__category__isnull=False
).values('ai_analysis_data__analysis_data__category').annotate(
candidate_count=Count('id'),
category=Cast('ai_analysis_data__analysis_data__category',output_field=CharField())
).order_by('ai_analysis_data__analysis_data__category')
# Prepare data for Chart.js
categories = [item['major_category_name'] for item in category_data]
print(category_data)
categories = [item['category'] for item in category_data]
candidate_counts = [item['candidate_count'] for item in category_data]
avg_scores = [round(item['avg_match_score'], 2) if item['avg_match_score'] is not None else 0 for item in category_data]
# avg_scores = [round(item['avg_match_score'], 2) if item['avg_match_score'] is not None else 0 for item in category_data]
context = {
"job": job,
"applicants": applicants,
"total_applicants": total_applicant,
"total_applicants": total_applicant, # This was total_candidates in the prompt, using total_applicant for consistency
"applied_count": applied_count,
'exam_count':exam_count,
"interview_count": interview_count,
@ -412,7 +475,13 @@ def job_detail(request, slug):
'image_upload_form':image_upload_form,
'categories': categories,
'candidate_counts': candidate_counts,
'avg_scores': avg_scores
# 'avg_scores': avg_scores,
# New statistics
'avg_match_score': avg_match_score,
'high_potential_count': high_potential_count,
'high_potential_ratio': high_potential_ratio,
'avg_t2i_days': avg_t2i_days,
'avg_t_in_exam_days': avg_t_in_exam_days,
}
return render(request, "jobs/job_detail.html", context)
@ -754,23 +823,6 @@ def form_wizard_view(request, template_id):
"""Display the form as a step-by-step wizard"""
template = get_object_or_404(FormTemplate, pk=template_id, is_active=True)
job_id = template.job.internal_job_id
job=template.job
is_limit_exceeded=job.is_application_limit_reached
if is_limit_exceeded:
messages.error(
request,
'Application limit reached: This job is no longer accepting new applications. Please explore other available positions.'
)
return redirect('job_detail_candidate',slug=job.slug)
if job.is_expired:
messages.error(
request,
'Application deadline passed: This job is no longer accepting new applications. Please explore other available positions.'
)
return redirect('job_detail_candidate',slug=job.slug)
return render(
request,
"forms/form_wizard.html",
@ -782,8 +834,6 @@ def form_wizard_view(request, template_id):
def submit_form(request, template_id):
"""Handle form submission"""
template = get_object_or_404(FormTemplate, id=template_id)
if request.method == "POST":
try:
with transaction.atomic():
@ -791,6 +841,8 @@ def submit_form(request, template_id):
current_count = job_posting.candidates.count()
if current_count >= job_posting.max_applications:
template.is_active = False
template.save()
return JsonResponse(
{"success": False, "message": "Application limit reached for this job."}
)
@ -1201,7 +1253,10 @@ def candidate_screening_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
candidates = job.screening_candidates
# Get filter parameters
min_ai_score_str = request.GET.get('min_ai_score')
min_experience_str = request.GET.get('min_experience')
screening_rating = request.GET.get('screening_rating')
tier1_count_str = request.GET.get('tier1_count')
try:
@ -1211,6 +1266,11 @@ def candidate_screening_view(request, slug):
else:
min_ai_score = 0
if min_experience_str:
min_experience = float(min_experience_str)
else:
min_experience = 0
if tier1_count_str:
tier1_count = int(tier1_count_str)
else:
@ -1219,11 +1279,18 @@ def candidate_screening_view(request, slug):
except ValueError:
# This catches if the user enters non-numeric text (e.g., "abc")
min_ai_score = 0
min_experience = 0
tier1_count = 0
# You can now safely use min_ai_score and tier1_count as integers (0 or greater)
# Apply filters
if min_ai_score > 0:
candidates = candidates.filter(match_score__gte=min_ai_score)
candidates = candidates.filter(ai_analysis_data__analysis_data__match_score__gte=min_ai_score)
if min_experience > 0:
candidates = candidates.filter(ai_analysis_data__analysis_data__years_of_experience__gte=min_experience)
if screening_rating:
candidates = candidates.filter(ai_analysis_data__analysis_data__screening_stage_rating=screening_rating)
if tier1_count > 0:
candidates = candidates[:tier1_count]
@ -1232,6 +1299,8 @@ def candidate_screening_view(request, slug):
"job": job,
"candidates": candidates,
'min_ai_score':min_ai_score,
'min_experience':min_experience,
'screening_rating':screening_rating,
'tier1_count':tier1_count,
"current_stage" : "Applied"
}
@ -1950,34 +2019,24 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk):
from django.core.exceptions import ObjectDoesNotExist
def user_profile_image_update(request, pk):
user = get_object_or_404(User, pk=pk)
# 2. Ensure Profile exists and get the instance
try:
profile_instance = user.profile
except ObjectDoesNotExist:
profile_instance = Profile.objects.create(user=user)
instance =user.profile
except ObjectDoesNotExist as e:
Profile.objects.create(user=user)
if request.method == 'POST':
profile_form = ProfileImageUploadForm(
request.POST,
request.FILES,
instance=profile_instance # <--- USE profile_instance HERE
)
profile_form = ProfileImageUploadForm(request.POST, request.FILES, instance=user.profile)
if profile_form.is_valid():
profile_form.save()
messages.success(request, 'Image uploaded successfully.')
return redirect('user_detail', pk=user.pk)
messages.success(request, 'Image uploaded successfully')
return redirect('user_detail', pk=user.pk)
else:
messages.error(request, 'An error occurred while uploading the image. Please check the errors below.')
else:
#
profile_form = ProfileImageUploadForm(instance=profile_instance)
profile_form = ProfileImageUploadForm(instance=user.profile)
context = {
'profile_form': profile_form,
'user': user,
@ -1986,15 +2045,9 @@ def user_profile_image_update(request, pk):
def user_detail(request, pk):
user = get_object_or_404(User, pk=pk)
try:
profile_instance = user.profile
profile_form = ProfileImageUploadForm(instance=profile_instance)
except:
profile_form = ProfileImageUploadForm()
profile_form = ProfileImageUploadForm()
if request.method == 'POST':
first_name=request.POST.get('first_name')
last_name=request.POST.get('last_name')
@ -2123,33 +2176,11 @@ def set_staff_password(request,pk):
@user_passes_test(is_superuser_check)
def account_toggle_status(request,pk):
user=get_object_or_404(User,pk=pk)
if request.method=='POST':
print(user.is_active)
form=ToggleAccountForm(request.POST)
if form.is_valid():
if user.is_active:
user.is_active=False
user.save()
messages.success(request,f'Staff with email: {user.email} deactivated successfully')
return redirect('admin_settings')
else:
user.is_active=True
user.save()
messages.success(request,f'Staff with email: {user.email} activated successfully')
return redirect('admin_settings')
else:
messages.error(f'Please correct the error below')
# @login_required
# def user_detail(requests,pk):
# user=get_object_or_404(User,pk=pk)
# return render(requests,'user/profile.html')
@login_required
def user_detail(requests,pk):
user=get_object_or_404(User,pk=pk)
return render(requests,'user/profile.html')
@csrf_exempt
@ -2235,9 +2266,70 @@ def edit_meeting_comment(request, slug, comment_id):
form = MeetingCommentForm(instance=comment)
def delete_meeting_comment(request):
pass
@login_required
def delete_meeting_comment(request, slug, comment_id):
"""Delete a meeting comment"""
meeting = get_object_or_404(ZoomMeeting, slug=slug)
comment = get_object_or_404(MeetingComment, id=comment_id, meeting=meeting)
# Check if user is the author
if comment.author != request.user and not request.user.is_staff:
messages.error(request, 'You can only delete your own comments.')
return redirect('meeting_details', slug=slug)
if request.method == 'POST':
comment.delete()
messages.success(request, 'Comment deleted successfully!')
# HTMX response - return just the comment section
if 'HX-Request' in request.headers:
return render(request, 'includes/comment_list.html', {
'comments': meeting.comments.all().order_by('-created_at'),
'meeting': meeting
})
return redirect('meeting_details', slug=slug)
# HTMX response - return the delete confirmation modal
if 'HX-Request' in request.headers:
return render(request, 'includes/delete_comment_form.html', {
'meeting': meeting,
'comment': comment,
'delete_url': reverse('delete_meeting_comment', kwargs={'slug': slug, 'comment_id': comment_id})
})
return redirect('meeting_details', slug=slug)
def set_meeting_candidate(request):
pass
@login_required
def set_meeting_candidate(request,slug):
meeting = get_object_or_404(ZoomMeeting, slug=slug)
if request.method == 'POST' and 'HX-Request' not in request.headers:
form = InterviewForm(request.POST)
if form.is_valid():
candidate = form.save(commit=False)
candidate.zoom_meeting = meeting
candidate.interview_date = meeting.start_time.date()
candidate.interview_time = meeting.start_time.time()
candidate.save()
messages.success(request, 'Candidate added successfully!')
return redirect('list_meetings')
job = request.GET.get("job")
form = InterviewForm()
if job:
form.fields['candidate'].queryset = Candidate.objects.filter(job=job)
else:
form.fields['candidate'].queryset = Candidate.objects.none()
form.fields['job'].widget.attrs.update({
'hx-get': reverse('set_meeting_candidate', kwargs={'slug': slug}),
'hx-target': '#div_id_candidate',
'hx-select': '#div_id_candidate',
'hx-swap': 'outerHTML'
})
context = {
"form": form,
"meeting": meeting
}
return render(request, 'meetings/set_candidate_form.html', context)

View File

@ -2,8 +2,10 @@ import json
from django.shortcuts import render, get_object_or_404,redirect
from django.contrib import messages
from django.http import JsonResponse
from django.db.models.fields.json import KeyTextTransform
from recruitment.utils import json_to_markdown_table
from django.db.models import Count, Avg, F, FloatField
from django.db.models.functions import Cast
from . import models
from django.utils.translation import get_language
from . import forms
@ -247,6 +249,20 @@ def candidate_detail(request, slug):
'stage_form': stage_form,
})
@login_required
def candidate_resume_template_view(request, slug):
"""Display formatted resume template for a candidate"""
candidate = get_object_or_404(models.Candidate, slug=slug)
if not request.user.is_staff:
messages.error(request, _("You don't have permission to view this page."))
return redirect('candidate_list')
return render(request, 'recruitment/candidate_resume_template.html', {
'candidate': candidate
})
@login_required
def candidate_update_stage(request, slug):
"""Handle HTMX stage update requests"""
@ -323,24 +339,78 @@ class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
@login_required
def dashboard_view(request):
total_jobs = models.JobPosting.objects.count()
total_candidates = models.Candidate.objects.count()
jobs = models.JobPosting.objects.all()
# --- Performance Optimization: Aggregate Data in ONE Query ---
# 1. Base Job Query: Get all jobs and annotate with candidate count
jobs_with_counts = models.JobPosting.objects.annotate(
candidate_count=Count('candidates')
).order_by('-candidate_count')
total_jobs = jobs_with_counts.count()
total_candidates = models.Candidate.objects.count()
job_titles = [job.title for job in jobs_with_counts]
job_app_counts = [job.candidate_count for job in jobs_with_counts]
average_applications = round(jobs_with_counts.aggregate(
avg_apps=Avg('candidate_count')
)['avg_apps'] or 0, 2)
# 5. New: Candidate Quality & Funnel Metrics
# Assuming 'match_score' is a direct IntegerField/FloatField on the Candidate model
# (based on the final, optimized version of handle_reume_parsing_and_scoring)
# Average Match Score (Overall Quality)
candidates_with_score = models.Candidate.objects.filter(
# Filter only candidates that have been parsed/scored
is_resume_parsed=True
).annotate(
score_as_text=KeyTextTransform(
'match_score',
KeyTextTransform('scoring_data', F('ai_analysis_data'))
)
).annotate(
# Cast the extracted text score to a FloatField so AVG() can operate on it.
sortable_score=Cast('score_as_text', output_field=FloatField())
)
# 2b. AGGREGATE using the newly created 'sortable_score' field
avg_match_score_result = candidates_with_score.aggregate(
avg_score=Avg('sortable_score')
)['avg_score']
avg_match_score = round(avg_match_score_result or 0, 1)
# 2c. Use the annotated QuerySet for other metrics
# Scored Candidates Ratio (Now simpler, as we filtered the QuerySet)
total_scored = candidates_with_score.count()
scored_ratio = round((total_scored / total_candidates) * 100, 1) if total_candidates > 0 else 0
# High Potential Candidates (Filter the annotated QuerySet)
high_potential_count = candidates_with_score.filter(
sortable_score__gte=75
).count()
high_potential_ratio = round((high_potential_count / total_candidates) * 100, 1) if total_candidates > 0 else 0
job_titles = [job.title for job in jobs]
job_app_counts = [job.candidates.count() for job in jobs]
average_applications = round(sum(job_app_counts) / total_jobs, 2) if total_jobs > 0 else 0
context = {
'total_jobs': total_jobs,
'total_candidates': total_candidates,
'job_titles': job_titles,
'job_app_counts': job_app_counts,
'average_applications': average_applications,
# Chart Data
'job_titles': json.dumps(job_titles),
'job_app_counts': json.dumps(job_app_counts),
# New Analytical Metrics (FIXED)
'avg_match_score': avg_match_score,
'high_potential_count': high_potential_count,
'high_potential_ratio': high_potential_ratio,
'scored_ratio': scored_ratio,
}
return render(request, 'recruitment/dashboard.html', context)
@login_required
def candidate_offer_view(request, slug):
"""View for candidates in the Offer stage"""

View File

@ -1,31 +1,123 @@
{% load i18n %}
<h5> {% trans "AI Score" %}: <span class="badge bg-success"><i class="fas fa-robot me-1"></i> {{ candidate.match_score }}</span> <span class="badge bg-success"><i class="fas fa-graduation-cap me-1"></i> {{ candidate.professional_category }} </span></h5>
<h5> {% trans "AI Score" %}: <span class="badge bg-success"><i class="fas fa-robot me-1"></i> {{ candidate.match_score }}%</span> <span class="badge bg-success"><i class="fas fa-graduation-cap me-1"></i> {{ candidate.professional_category }} </span></h5>
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="fas fa-briefcase me-2 text-primary"></i>
<small class="text-muted">{% trans "Job Fit" %}</small>
</div>
<p class="mb-1">{{ candidate.job_fit_narrative }}</p>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="fas fa-star me-2 text-warning"></i>
<small class="text-muted">{% trans "Top Keywords" %}</small>
</div>
<div class="d-flex flex-wrap gap-1">
{% for keyword in candidate.top_3_keywords %}
<span class="badge bg-info text-dark me-1">{{ keyword }}</span>
{% endfor %}
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="fas fa-clock me-2 text-info"></i>
<small class="text-muted">{% trans "Experience" %}</small>
</div>
<p class="mb-1"><strong>{{ candidate.years_of_experience }}</strong> {% trans "years" %}</p>
<p class="mb-0"><strong>{% trans "Recent Role:" %}</strong> {{ candidate.most_recent_job_title }}</p>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="fas fa-chart-line me-2 text-success"></i>
<small class="text-muted">{% trans "Skills" %}</small>
</div>
<p class="mb-1"><strong>{% trans "Soft Skills:" %}</strong> {{ candidate.soft_skills_score }}%</p>
<p class="mb-0"><strong>{% trans "Industry Match:" %}</strong>
<span class="badge {% if candidate.industry_match_score >= 70 %}bg-success{% elif candidate.industry_match_score >= 40 %}bg-warning{% else %}bg-danger{% endif %}">
{{ candidate.industry_match_score }}%
</span>
</p>
</div>
</div>
<div class="mb-3">
<label class="form-label"><i class="fas fa-comment me-1 text-info"></i> {% trans "Recommendation" %}</label>
<textarea class="form-control" rows="10" readonly>{{ candidate.recommendation }}</textarea>
</div>
<div class="mb-3">
<label class="form-label"><i class="fas fa-thumbs-up me-1 text-success"></i> {% trans "Strengths" %}</label>
<textarea class="form-control" rows="6" readonly>{{ candidate.strengths }}</textarea>
</div>
<div class="mb-3">
<label class="form-label"><i class="fas fa-thumbs-down me-1 text-danger"></i> {% trans "Weaknesses" %}</label>
<textarea class="form-control" rows="6" readonly>{{ candidate.weaknesses }}</textarea>
</div>
<div class="mb-3">
<label class="form-label"><i class="fas fa-list-check me-1"></i> {% trans "Criteria Checklist" %}</label>
<ul class="list-group">
{% for key, value in candidate.criteria_checklist.items %}
<li class="list-group-item d-flex justify-content-between">
<span class="fw-" style="font-size: smaller;">{{ key }}</span>
{% if value == 'Met' %}
<span class="badge bg-success"><i class="fas fa-check me-1"></i> Yes</span>
{% else %}
<span class="badge bg-danger"><i class="fas fa-times me-1"></i> Not Mentioned</span>
{% endif %}
</li>
{% endfor %}
</ul>
<textarea class="form-control" rows="6" readonly>{{ candidate.recommendation }}</textarea>
</div>
<div class="mb-3">
<label class="form-label"><i class="fas fa-thumbs-up me-1 text-success"></i> {% trans "Strengths" %}</label>
<textarea class="form-control" rows="4" readonly>{{ candidate.strengths }}</textarea>
</div>
<div class="mb-3">
<label class="form-label"><i class="fas fa-thumbs-down me-1 text-danger"></i> {% trans "Weaknesses" %}</label>
<textarea class="form-control" rows="4" readonly>{{ candidate.weaknesses }}</textarea>
</div>
<div class="mb-3">
<label class="form-label"><i class="fas fa-list-check me-1"></i> {% trans "Criteria Assessment" %}</label>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>{% trans "Criteria" %}</th>
<th>{% trans "Status" %}</th>
</tr>
</thead>
<tbody>
{% for criterion, status in candidate.criteria_checklist.items %}
<tr>
<td>{{ criterion }}</td>
<td>
{% if status == "Met" %}
<span class="badge bg-success"><i class="fas fa-check me-1"></i> {% trans "Met" %}</span>
{% elif status == "Not Met" %}
<span class="badge bg-danger"><i class="fas fa-times me-1"></i> {% trans "Not Met" %}</span>
{% else %}
<span class="badge bg-secondary">{{ status }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="fas fa-check-circle me-2 text-success"></i>
<small class="text-muted">{% trans "Minimum Requirements" %}</small>
</div>
{% if candidate.min_requirements_met %}
<span class="badge bg-success">{% trans "Met" %}</span>
{% else %}
<span class="badge bg-danger">{% trans "Not Met" %}</span>
{% endif %}
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="fas fa-star me-2 text-warning"></i>
<small class="text-muted">{% trans "Screening Rating" %}</small>
</div>
<span class="badge bg-secondary">{{ candidate.screening_stage_rating }}</span>
</div>
</div>
{% if candidate.language_fluency %}
<div class="mb-3">
<label class="form-label"><i class="fas fa-language me-1 text-info"></i> {% trans "Language Fluency" %}</label>
<div class="d-flex flex-wrap gap-2">
{% for language in candidate.language_fluency %}
<span class="badge bg-light text-dark">{{ language }}</span>
{% endfor %}
</div>
</div>
{% endif %}

View File

@ -0,0 +1,540 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Resume - Abdullah Sami Bakhsh</title>
<style>
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-light-bg: #f9fbfd;
--kaauh-border: #eaeff3;
--text-primary: #2c3e50;
--text-secondary: #6c757d;
--white: #ffffff;
--danger: #dc3545;
--warning: #ffc107;
--success: #28a745;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, var(--kaauh-light-bg) 0%, #e3f2fd 100%);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
padding: 20px;
}
.resume-container {
max-width: 1200px;
margin: 0 auto;
background: var(--white);
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 99, 110, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
color: var(--white);
padding: 40px;
position: relative;
overflow: hidden;
}
.header::before {
content: '';
position: absolute;
top: -50%;
right: -10%;
width: 500px;
height: 500px;
background: rgba(255, 255, 255, 0.05);
border-radius: 50%;
}
.header-content {
position: relative;
z-index: 1;
}
.name {
font-size: 2.5em;
font-weight: 700;
margin-bottom: 10px;
}
.title {
font-size: 1.3em;
opacity: 0.9;
margin-bottom: 20px;
}
.contact-info {
display: flex;
flex-wrap: wrap;
gap: 20px;
font-size: 0.95em;
}
.contact-item {
display: flex;
align-items: center;
gap: 8px;
}
.main-content {
display: grid;
grid-template-columns: 1fr 350px;
gap: 30px;
padding: 40px;
}
.left-column {
display: flex;
flex-direction: column;
gap: 30px;
}
.right-column {
display: flex;
flex-direction: column;
gap: 30px;
}
.section {
background: var(--white);
border-radius: 12px;
padding: 25px;
border: 1px solid var(--kaauh-border);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.section:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 99, 110, 0.1);
}
.section-title {
font-size: 1.3em;
font-weight: 600;
color: var(--kaauh-teal);
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid var(--kaauh-teal);
display: flex;
align-items: center;
gap: 10px;
}
.summary {
line-height: 1.8;
color: var(--text-secondary);
}
.experience-item {
margin-bottom: 25px;
padding-bottom: 20px;
border-bottom: 1px solid var(--kaauh-border);
}
.experience-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.job-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
}
.job-title {
font-weight: 600;
color: var(--kaauh-teal-dark);
font-size: 1.1em;
}
.company {
color: var(--text-secondary);
font-weight: 500;
}
.job-period {
color: var(--text-secondary);
font-size: 0.9em;
white-space: nowrap;
}
.achievements {
margin-top: 10px;
padding-left: 20px;
}
.achievements li {
margin-bottom: 5px;
color: var(--text-secondary);
}
.education-item {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid var(--kaauh-border);
}
.education-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.degree {
font-weight: 600;
color: var(--kaauh-teal-dark);
}
.institution {
color: var(--text-secondary);
font-size: 0.95em;
}
.skills-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.skill-tag {
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
color: var(--white);
padding: 6px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 500;
}
.language-item {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--kaauh-border);
}
.language-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.language-name {
font-weight: 500;
}
.proficiency {
color: var(--text-secondary);
font-size: 0.9em;
}
.score-card {
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
color: var(--white);
text-align: center;
padding: 30px 20px;
border-radius: 12px;
}
.match-score {
font-size: 3em;
font-weight: 700;
margin-bottom: 10px;
}
.score-label {
font-size: 1.1em;
opacity: 0.9;
}
.recommendation {
background: var(--kaauh-light-bg);
border-left: 4px solid var(--danger);
padding: 15px;
border-radius: 8px;
margin-top: 15px;
}
.recommendation-title {
font-weight: 600;
color: var(--danger);
margin-bottom: 8px;
}
.recommendation-text {
color: var(--text-secondary);
font-size: 0.95em;
}
.keyword-tag {
background: var(--kaauh-light-bg);
color: var(--kaauh-teal-dark);
padding: 4px 10px;
border-radius: 15px;
font-size: 0.85em;
font-weight: 500;
border: 1px solid var(--kaauh-border);
}
.strengths-weaknesses {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.strength-box {
background: rgba(40, 167, 69, 0.1);
border: 1px solid var(--success);
padding: 15px;
border-radius: 8px;
}
.weakness-box {
background: rgba(220, 53, 69, 0.1);
border: 1px solid var(--danger);
padding: 15px;
border-radius: 8px;
}
.box-title {
font-weight: 600;
margin-bottom: 8px;
font-size: 0.9em;
}
.strength-box .box-title {
color: var(--success);
}
.weakness-box .box-title {
color: var(--danger);
}
.box-content {
font-size: 0.9em;
color: var(--text-secondary);
}
@media (max-width: 968px) {
.main-content {
grid-template-columns: 1fr;
}
.strengths-weaknesses {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.name {
font-size: 2em;
}
.main-content {
padding: 20px;
}
.section {
padding: 20px;
}
.job-header {
flex-direction: column;
gap: 5px;
}
}
</style>
</head>
<body>
<div class="resume-container">
<div class="header">
<div class="header-content">
<h1 class="name">Abdullah Sami Bakhsh</h1>
<div class="title">Head, Recruitment & Training</div>
<div class="contact-info">
<div class="contact-item">
<span>📱</span>
<span>+966 561 168 180</span>
</div>
<div class="contact-item">
<span>✉️</span>
<span>Bakhsh.Abdullah@Outlook.com</span>
</div>
<div class="contact-item">
<span>📍</span>
<span>Saudi Arabia</span>
</div>
</div>
</div>
</div>
<div class="main-content">
<div class="left-column">
<div class="section">
<h2 class="section-title">
<span>📋</span> Professional Summary
</h2>
<p class="summary">
Strategic and resultsdriven HR leader with over 11 years of experience in human resources, specializing in business partnering, workforce planning, talent acquisition, training & development, and organizational effectiveness.
</p>
</div>
<div class="section">
<h2 class="section-title">
<span>💼</span> Work Experience
</h2>
<div class="experience-item">
<div class="job-header">
<div>
<div class="job-title">Head, Recruitment & Training</div>
<div class="company">TASNEE - DOWNSTREAM & METALLURGY SBUs</div>
</div>
<div class="job-period">Oct 2024 - Present</div>
</div>
<ul class="achievements">
<li>Led recruitment and training initiatives across downstream and metallurgical divisions</li>
<li>Developed workforce analytics program</li>
</ul>
</div>
<div class="experience-item">
<div class="job-header">
<div>
<div class="job-title">Human Resources Business Partner</div>
<div class="company">TASNEE METALLURGY SBU</div>
</div>
<div class="job-period">Oct 2015 - Present</div>
</div>
<ul class="achievements">
<li>Implemented HR strategies aligning with business goals</li>
<li>Optimized recruitment processes</li>
<li>Ensured regulatory compliance</li>
</ul>
</div>
<div class="experience-item">
<div class="job-header">
<div>
<div class="job-title">Specialist, Recruitment</div>
<div class="company">MARAFIQ</div>
</div>
<div class="job-period">Jul 2011 - Feb 2013</div>
</div>
<ul class="achievements">
<li>Performed recruitment for various roles</li>
<li>Improved selection processes</li>
</ul>
</div>
</div>
<div class="section">
<h2 class="section-title">
<span>🎓</span> Education
</h2>
<div class="education-item">
<div class="degree">Associate Diploma in People Management Level 5</div>
<div class="institution">Chartered Institute of Personnel and Development (CIPD)</div>
</div>
<div class="education-item">
<div class="degree">Bachelor's Degree in Chemical Engineering Technology</div>
<div class="institution">Yanbu Industrial College</div>
</div>
</div>
</div>
<div class="right-column">
<div class="score-card">
<div class="match-score">10%</div>
<div class="score-label">Match Score</div>
</div>
<div class="section">
<h2 class="section-title">
<span>🔍</span> Assessment
</h2>
<div class="strengths-weaknesses">
<div class="strength-box">
<div class="box-title">Strengths</div>
<div class="box-content">Extensive HR leadership and project management experience</div>
</div>
<div class="weakness-box">
<div class="box-title">Weaknesses</div>
<div class="box-content">Lack of IT infrastructure, cybersecurity, and relevant certifications</div>
</div>
</div>
<div class="recommendation">
<div class="recommendation-title">Recommendation</div>
<div class="recommendation-text">Candidate does not meet the IT management requirements; not recommended for interview.</div>
</div>
</div>
<div class="section">
<h2 class="section-title">
<span>💡</span> Top Keywords
</h2>
<div class="skills-container">
<span class="keyword-tag">HR</span>
<span class="keyword-tag">Recruitment</span>
<span class="keyword-tag">Training</span>
</div>
</div>
<div class="section">
<h2 class="section-title">
<span>🛠️</span> Skills
</h2>
<div class="skills-container">
<span class="skill-tag">Workforce Analytics</span>
<span class="skill-tag">Succession Planning</span>
<span class="skill-tag">Organizational Development</span>
<span class="skill-tag">Recruitment & Selection</span>
<span class="skill-tag">Employee Engagement</span>
<span class="skill-tag">Training Needs Analysis</span>
<span class="skill-tag">Performance Management</span>
<span class="skill-tag">Time Management</span>
<span class="skill-tag">Negotiation Skills</span>
<span class="skill-tag">SAP & HRIS Systems</span>
</div>
</div>
<div class="section">
<h2 class="section-title">
<span>🌍</span> Languages
</h2>
<div class="language-item">
<span class="language-name">Arabic</span>
<span class="proficiency">Native</span>
</div>
<div class="language-item">
<span class="language-name">English</span>
<span class="proficiency">Fluent</span>
</div>
<div class="language-item">
<span class="language-name">Japanese</span>
<span class="proficiency">Intermediate</span>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -171,6 +171,52 @@
border: 1px solid;
}
/* Enhanced styling for the Job Avg. Score card */
.stats .card:first-child { /* Targets the first card in the .stats div (Job Avg. Score) */
border-left: 5px solid var(--kaauh-teal); /* A distinctive left border */
background: linear-gradient(to right bottom, rgba(0, 99, 110, 0.05), rgba(255, 255, 255, 0));
}
.stats .card:first-child .card-header {
background-color: rgba(0, 99, 110, 0.1); /* Subtle background for header */
border-bottom: 2px solid var(--kaauh-teal); /* Emphasized border */
border-left: none; /* Remove inherited border from parent if any */
border-top: none;
border-right: none;
}
.stats .card:first-child .card-header h3 {
color: var(--kaauh-teal); /* Match the theme color */
font-weight: 700;
font-size: 1.1rem; /* Slightly larger header */
margin-bottom: 0;
}
.stats .card:first-child .stat-icon {
color: var(--kaauh-teal); /* Match the theme color */
font-size: 1.2rem; /* Slightly larger icon */
vertical-align: middle;
}
.stats .card:first-child .stat-value {
font-size: 2.5rem; /* Larger, more prominent value */
font-weight: 700;
color: var(--kaauh-teal); /* Theme color for the value */
text-align: center;
margin: 1rem 0; /* Spacing around the value */
text-shadow: 0 1px 2px rgba(0,0,0,0.05); /* Subtle text shadow for depth */
}
.stats .card:first-child .stat-caption {
text-align: center;
color: #6c757d; /* Muted color for caption */
font-size: 0.9rem;
font-weight: 500;
margin-top: 0.5rem;
border-top: 1px solid rgba(0, 99, 110, 0.2); /* Subtle top border for caption area */
padding-top: 0.5rem;
}
@ -400,6 +446,37 @@
{% include 'jobs/partials/applicant_tracking.html' %}
</div>
</div>
<div class="stats">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-star stat-icon"></i> {% trans "Job Avg. Score" %}</h3>
</div>
<div class="stat-value">{{ avg_match_score|floatformat:1 }}</div>
<div class="stat-caption">{% trans "Average AI Match Score (0-100)" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-trophy stat-icon" style="color: var(--color-success);"></i> {% trans "High Potential Count" %}</h3>
</div>
<div class="stat-value">{{ high_potential_count }}</div>
<div class="stat-caption">{% trans "Candidates with Score ≥ 75%" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-calendar-alt stat-icon" style="color: var(--kaauh-teal-light);"></i> {% trans "Avg. Time to Interview" %}</h3>
</div>
<div class="stat-value">{{ avg_t2i_days|floatformat:1 }}d</div>
<div class="stat-caption">{% trans "Applied to Interview (Total Funnel Speed)" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-hourglass-half stat-icon" style="color: var(--color-info);"></i> {% trans "Avg. Exam Review Time" %}</h3>
</div>
<div class="stat-value">{{ avg_t_in_exam_days|floatformat:1 }}d</div>
<div class="stat-caption">{% trans "Days spent between Exam and Interview" %}</div>
</div>
</div>
<div class="card shadow-sm no-hover" style="height:350px;">
{# RIGHT TABS NAVIGATION #}

View File

@ -128,7 +128,7 @@
text-align: center;
border-left: 1px solid var(--kaauh-border);
}
/* Metrics Sub-Column Widths (7 total sub-columns, total 30%) */
/* We have 5 main metrics: Applied, Screened, Exam, Interview, Offer.
* Let's allocate the 30% evenly: 30% / 5 = 6% per metric column.
@ -159,7 +159,7 @@
border-left: 1px solid var(--kaauh-border);
}
/* Adds a distinctive vertical line before the metrics group (7th column) */
.table tbody tr td:nth-child(7) {
.table tbody tr td:nth-child(7) {
border-left: 2px solid var(--kaauh-teal);
}
@ -234,8 +234,7 @@
</div>
<div class="col-md-4">
<div class="filter-buttons">
<button type="submit" class="btn btn-main-action btn-lg">
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-filter me-1"></i> {% trans "Apply Filters" %}
</button>
{% if job_filter or search_query %}
@ -279,11 +278,11 @@
</tr>
<tr class="nested-metrics-row">
<th scope="col">{% trans "Applied" %}</th>
<th scope="col">{% trans "Screened" %}</th>
<th scope="col">{% trans "Exam" %}</th>
<th scope="col">{% trans "Interview" %}</th>
<th scope="col">{% trans "Offer" %}</th>
<th style="width: calc(50% / 7);">{% trans "All Applicants" %}</th>
<th style="width: calc(50% / 7);">{% trans "Screened" %}</th>
<th style="width: calc(50% / 7 * 2);">{% trans "Exam" %}</th>
<th style="width: calc(50% / 7 * 2);">{% trans "Interview" %}</th>
<th style="width: calc(50% / 7);">{% trans "Offer" %}</th>
</tr>
</thead>
@ -326,11 +325,11 @@
</td>
{# CANDIDATE MANAGEMENT DATA - URLS NEUTRALIZED #}
<td class="candidate-data-cell text-primary-theme"><a href="#" class="text-primary-theme">{% if job.all_candidates_count %}{{job.all_candidates_count}}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-info"><a href="{% url 'candidate_screening_view' job.slug %}" class="text-info">{% if job.screening_candidates_count %}{{ job.screening_candidates_count }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-success"><a href="{% url 'candidate_screening_view' job.slug %}" class="text-success">{% if job.exam_candidates_count %}{{ job.exam_candidates_count}}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-success"><a href="{% url 'candidate_exam_view' job.slug %}" class="text-success">{% if job.interview_candidates_count %}{{ job.interview_candidates_count}}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-success"><a href="{% url 'candidate_interview_view' job.slug %}" class="text-success">{% if job.offer_candidates_count%}{{ job.offer_candidates_count }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-primary-theme"><a href="#" class="text-primary-theme">{% if job.all_candidates.count %}{{ job.all_candidates.count }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-info"><a href="{% url 'candidate_screening_view' job.slug %}" class="text-info">{% if job.screening_candidates.count %}{{ job.screening_candidates.count }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-success"><a href="{% url 'candidate_screening_view' job.slug %}" class="text-success">{% if job.exam_candidates.count %}{{ job.exam_candidates.count }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-success"><a href="{% url 'candidate_exam_view' job.slug %}" class="text-success">{% if job.interview_candidates.count %}{{ job.interview_candidates.count }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-success"><a href="{% url 'candidate_interview_view' job.slug %}" class="text-success">{% if job.offer_candidates.count %}{{ job.offer_candidates.count }}{% else %}-{% endif %}</a></td>
</tr>
{% endfor %}
</tbody>

View File

@ -298,10 +298,22 @@
<p class="mb-1"><strong>{{ candidate.resume.name }}</strong></p>
<small class="text-muted">{{ candidate.resume.name|truncatechars:30 }}</small>
</div>
<a href="{{ candidate.resume.url }}" download class="btn btn-main-action">
<i class="fas fa-download me-1"></i>
{% trans "Download Resume" %}
</a>
<div class="d-flex flex-column gap-2">
<div class="d-flex gap-2">
<a href="{{ candidate.resume.url }}" target="_blank" class="btn btn-outline-primary">
<i class="fas fa-eye me-1"></i>
{% trans "View Resume" %}
</a>
<a href="{{ candidate.resume.url }}" download class="btn btn-main-action">
<i class="fas fa-download me-1"></i>
{% trans "Download Resume" %}
</a>
</div>
<a href="{% url 'candidate_resume_template' candidate.slug %}" class="btn btn-outline-info">
<i class="fas fa-file-alt me-1"></i>
{% trans "View Formatted Resume" %}
</a>
</div>
</div>
</div>
{% endif %}

View File

@ -159,34 +159,31 @@
</form>
</div>
</div>
<div class="col-md-6">
{% url 'candidate_list' as candidate_list_url %}
<form method="GET" class="row g-3 align-items-end h-100">
{% if search_query %}<input type="hidden" name="q" value="{{ search_query }}">{% endif %}
{# Filter Group #}
<div class="col-md-4 col-6">
<label for="job_filter" class="form-label small text-muted">{% trans "Filter by Job" %}</label>
<select name="job" id="job_filter" class="form-select form-select-sm">
<option value="">{% trans "All Jobs" %}</option>
{% for job in available_jobs %}
<option value="{{ job.slug }}" {% if job_filter == job.slug %}selected{% endif %}>{{ job.title }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4 col-6">
<label for="stage_filter" class="form-label small text-muted">{% trans "Filter by Stage" %}</label>
<select name="stage" id="stage_filter" class="form-select form-select-sm">
<option value="">{% trans "All Stages" %}</option>
<option value="Applied" {% if stage_filter == 'Applied' %}selected{% endif %}>{% trans "Applied" %}</option>
<option value="Exam" {% if stage_filter == 'Exam' %}selected{% endif %}>{% trans "Exam" %}</option>
<option value="Interview" {% if stage_filter == 'Interview' %}selected{% endif %}>{% trans "Interview" %}</option>
<option value="Offer" {% if stage_filter == 'Offer' %}selected{% endif %}>{% trans "Offer" %}</option>
</select>
</div>
<div class="col-md-4">
<label for="job_filter" class="form-label small text-muted">{% trans "Filter by Job" %}</label>
<div class="d-flex gap-2">
<select name="job" id="job_filter" class="form-select form-select-sm">
<option value="">{% trans "All Jobs" %}</option>
{% for job in available_jobs %}
<option value="{{ job.slug }}" {% if job_filter == job.slug %}selected{% endif %}>{{ job.title }}</option>
{% endfor %}
</select>
<select name="stage" id="stage_filter" class="form-select form-select-sm">
<option value="">{% trans "All Stages" %}</option>
<option value="Applied" {% if stage_filter == 'Applied' %}selected{% endif %}>{% trans "Applied" %}</option>
<option value="Exam" {% if stage_filter == 'Exam' %}selected{% endif %}>{% trans "Exam" %}</option>
<option value="Interview" {% if stage_filter == 'Interview' %}selected{% endif %}>{% trans "Interview" %}</option>
<option value="Offer" {% if stage_filter == 'Offer' %}selected{% endif %}>{% trans "Offer" %}</option>
</select>
</div>
</div>
{# Buttons Group (pushed to the right/bottom) #}
<div class="col-md-4 d-flex justify-content-end align-self-end">

View File

@ -0,0 +1,932 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ candidate.resume_data.full_name|default:"Candidate" }} - Candidate Profile</title>
<!-- Use a modern icon set -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
/* --- Color Variables --- */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-light-bg: #f9fbfd;
--kaauh-border: #eaeff3;
--score-green: #10b981;
--score-yellow: #f59e0b;
--score-red: #ef4444;
--cert-gold: #d97706;
--color-white: #ffffff;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
}
/* --- General Layout and Typography --- */
body {
background-color: var(--kaauh-light-bg);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
margin: 0;
}
.container {
max-width: 1280px; /* max-w-7xl */
margin: 0 auto;
padding: 1rem; /* p-4 */
}
.content-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem; /* gap-6 */
}
/* Responsive Layout for large screens (lg:grid-cols-3) */
@media (min-width: 1024px) {
.content-grid {
grid-template-columns: 2fr 1fr;
}
}
/* --- Card Styles --- */
.card-section {
background-color: var(--color-white);
border-radius: 0.75rem; /* rounded-xl */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); /* shadow-md */
padding: 1.5rem; /* p-6 */
border: 1px solid var(--kaauh-border);
margin-bottom: 1.5rem; /* space-y-6 */
}
.section-title {
font-size: 1.5rem; /* text-2xl */
font-weight: bold;
color: var(--color-gray-800);
margin-bottom: 1rem; /* mb-4 */
display: flex;
align-items: center;
}
.section-title i {
color: var(--kaauh-teal);
margin-right: 0.75rem; /* mr-3 */
}
/* --- Header Section --- */
.header-box {
background: linear-gradient(145deg, var(--kaauh-teal), var(--kaauh-teal-dark));
color: var(--color-white);
padding: 2rem; /* p-8 */
border-radius: 0.75rem; /* rounded-xl */
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); /* shadow-2xl */
margin-bottom: 1.5rem; /* mb-6 */
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
}
@media (min-width: 768px) {
.header-box {
flex-direction: row;
align-items: center;
}
}
.header-info h1 {
font-size: 2.25rem; /* text-4xl */
font-weight: 800; /* font-extrabold */
margin-bottom: 0.25rem; /* mb-1 */
}
.header-info p {
font-size: 1.25rem; /* text-xl */
color: rgba(204, 251, 252, 0.9); /* text-teal-100 */
margin-bottom: 1rem; /* mb-4 */
}
.contact-details {
display: flex;
flex-wrap: wrap;
gap: 1.5rem; /* gap-6 */
font-size: 0.875rem; /* text-sm */
}
.contact-item {
display: flex;
align-items: center;
opacity: 0.8;
}
.contact-item i {
margin-right: 0.5rem; /* mr-2 */
}
.contact-item a:hover {
text-decoration: underline;
}
/* Match Score Box */
.score-box {
background-color: rgba(255, 255, 255, 0.2); /* bg-white bg-opacity-20 */
border-radius: 0.75rem;
padding: 1rem; /* p-4 */
text-align: center;
width: 8rem; /* w-32 */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06); /* shadow-inner */
}
.score-value {
font-size: 2.25rem; /* text-4xl */
font-weight: bold;
margin-bottom: 0.25rem;
}
.score-text {
font-size: 0.875rem; /* text-sm */
}
.assessment-rating {
font-size: 0.75rem; /* text-xs */
margin-top: 0.25rem; /* mt-1 */
font-weight: 600; /* font-semibold */
}
/* --- Dynamic Color Classes (Match Score) --- */
.score-red { color: var(--score-red); }
.score-yellow { color: var(--score-yellow); }
.score-green { color: var(--score-green); }
.text-green-check { color: var(--score-green); }
.text-yellow-exclaim { color: var(--score-yellow); }
.text-red-x { color: var(--score-red); }
/* --- Summary Section --- */
.summary-text {
color: var(--color-gray-700);
line-height: 1.625; /* leading-relaxed */
border-left: 4px solid var(--kaauh-teal-dark);
padding-left: 1rem; /* pl-4 */
}
/* --- Experience Section --- */
.experience-item {
margin-bottom: 1.5rem; /* mb-6 */
padding-bottom: 1.5rem; /* pb-6 */
border-bottom: 1px solid var(--color-gray-100);
}
.experience-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.experience-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem; /* mb-2 */
}
.experience-header h3 {
font-size: 1.25rem; /* text-xl */
font-weight: bold;
color: var(--color-gray-800);
}
.experience-header p {
color: var(--kaauh-teal);
font-weight: 600; /* font-semibold */
}
.experience-tag {
background-color: var(--kaauh-light-bg);
color: var(--kaauh-teal);
padding: 0.25rem 0.75rem; /* px-3 py-1 */
border-radius: 9999px; /* rounded-full */
font-size: 0.875rem; /* text-sm */
font-weight: 500; /* font-medium */
border: 1px solid var(--kaauh-border);
}
.experience-meta {
color: var(--color-gray-600);
margin-bottom: 0.75rem; /* mb-3 */
font-size: 0.875rem; /* text-sm */
}
.experience-meta i {
margin-right: 0.5rem; /* mr-2 */
}
.experience-meta span {
margin-left: 1rem; /* ml-4 */
}
.achievement-list {
list-style: none;
padding: 0;
margin-top: 0;
margin-bottom: 0;
color: var(--color-gray-700);
}
.achievement-list li {
display: flex;
align-items: flex-start;
margin-bottom: 0.25rem;
}
.achievement-list i {
color: var(--kaauh-teal-dark);
margin-top: 0.25rem; /* mt-1 */
margin-right: 0.5rem; /* mr-2 */
flex-shrink: 0;
}
/* --- Education Section --- */
.education-item {
display: flex;
align-items: flex-start;
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--color-gray-100);
transition: background-color 0.15s ease-in-out; /* transition duration-150 */
padding: 0.5rem; /* p-2 */
margin: -0.5rem; /* -m-2 */
border-radius: 0.5rem; /* rounded-lg */
}
.education-item:hover {
background-color: var(--kaauh-light-bg); /* hover:bg-kaauh-light-bg */
}
.education-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.icon-badge {
background-color: #e0f2f7; /* teal-100 */
padding: 0.75rem; /* p-3 */
border-radius: 9999px;
margin-right: 1rem; /* mr-4 */
flex-shrink: 0;
}
.icon-badge i {
color: var(--kaauh-teal);
}
.education-details {
flex-grow: 1;
}
.education-details h3 {
font-size: 1.125rem; /* text-lg */
font-weight: bold;
color: var(--color-gray-800);
}
.education-details p {
color: var(--kaauh-teal);
font-weight: 500;
}
.education-details .meta {
color: var(--color-gray-600);
font-size: 0.875rem;
}
.education-details .meta i {
margin-right: 0.25rem;
}
/* --- Projects Section --- */
.project-item {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-gray-100);
}
.project-item:last-child {
border-bottom: none;
}
.project-item h3 {
font-size: 1.125rem;
font-weight: bold;
color: var(--color-gray-800);
}
.project-item .description {
color: var(--color-gray-600);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem; /* gap-2 */
}
.tag-item {
background-color: #e0f2f7; /* bg-teal-100 */
color: var(--kaauh-teal);
padding: 0.25rem 0.75rem; /* px-3 py-1 */
border-radius: 9999px;
font-size: 0.75rem; /* text-xs */
font-weight: 500;
border: 1px solid var(--kaauh-border);
}
/* --- Analysis Section --- */
.analysis-summary {
background-color: #fffbeb; /* bg-yellow-50 */
border-left: 4px solid var(--score-yellow);
padding: 1rem; /* p-4 */
border-radius: 0 0.5rem 0.5rem 0; /* rounded-r-lg */
}
.analysis-summary h3 {
font-weight: 600;
color: #b45309; /* text-yellow-800 */
margin-bottom: 0.25rem;
}
.analysis-summary p {
color: #a16207; /* text-yellow-700 */
font-size: 0.875rem;
}
.analysis-metric, .criteria-item {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--color-gray-100);
}
.analysis-metric:last-child, .criteria-item:last-child {
border-bottom: none;
}
.metric-label {
color: var(--color-gray-600);
display: flex;
align-items: center;
}
.metric-label i {
margin-right: 0.5rem;
font-size: 0.875rem;
color: var(--kaauh-teal);
}
.metric-value {
font-weight: bold;
color: var(--color-gray-900);
}
.metric-title {
font-weight: 500;
color: var(--color-gray-700);
}
/* Progress Bar */
.progress-container {
width: 100%;
background-color: var(--color-gray-200);
border-radius: 9999px;
height: 0.75rem; /* h-3 */
overflow: hidden;
}
.progress-bar {
height: 0.75rem;
border-radius: 9999px;
transition: width 1s ease-in-out;
}
/* Language fluency bar */
.language-bar {
height: 0.5rem;
border-radius: 9999px;
background-color: var(--kaauh-teal);
}
/* --- Strength/Weakness/Flag Boxes --- */
.narrative-box {
padding-top: 1rem;
margin-top: 1rem;
}
.red-flag-box {
border-top: 1px solid #fee2e2; /* border-red-100 */
}
.strength-box {
border-top: 1px solid #d1fae5; /* border-green-100 */
}
.flag-title {
font-weight: 600;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
font-size: 1.125rem;
}
.flag-title i {
margin-right: 0.5rem;
font-size: 1.125rem;
}
.flag-title.red { color: #b91c1c; } /* text-red-700 */
.flag-title.green { color: #065f46; } /* text-green-700 */
.flag-title.red i { color: #ef4444; } /* text-red-500 */
.narrative-text {
color: var(--color-gray-700);
font-size: 0.875rem;
}
/* --- Keywords Section --- */
.keyword-tag {
background-color: #e0f2f7; /* bg-teal-100 */
color: var(--kaauh-teal-dark);
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid var(--kaauh-border);
}
.cultural-tag {
background-color: #f3e8ff; /* bg-purple-100 */
color: #6b21a8; /* text-purple-800 */
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.keyword-subheader {
font-weight: 600;
color: var(--color-gray-700);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
}
.keyword-subheader i {
margin-right: 0.5rem;
color: var(--kaauh-teal);
}
/* Specific Fixes */
.max-w-50-percent {
max-width: 50%;
}
/* Animation Re-implementation */
@keyframes fade-in-grow {
from { width: 0%; }
}
.progress-bar-animated {
animation: fade-in-grow 1s ease-in-out forwards;
}
</style>
</head>
<body class="bg-kaauh-light-bg font-sans">
<div class="container">
<!-- Header Section -->
<header class="header-box">
<div class="header-info">
<h1>{{ candidate.resume_data.full_name|default:"Candidate Name" }}</h1>
<p>{{ candidate.resume_data.current_title|default:"Professional Title" }}</p>
<div class="contact-details">
<div class="contact-item">
<i class="fas fa-map-marker-alt"></i>
<span>{{ candidate.resume_data.location|default:"Location" }}</span>
</div>
<!-- Displaying the raw contact string (which contains both phone and email in the example) -->
<div class="contact-item">
<i class="fas fa-id-card"></i>
<span title="Contact Information: Phone and Email">{{ candidate.resume_data.contact|default:"Contact Information" }}</span>
</div>
<!-- GitHub and LinkedIn links for quick access (null in example but included for completeness) -->
{% if candidate.resume_data.linkedin %}
<div class="contact-item">
<i class="fab fa-linkedin"></i>
<a href="{{ candidate.resume_data.linkedin }}" target="_blank">LinkedIn</a>
</div>
{% endif %}
{% if candidate.resume_data.github %}
<div class="contact-item">
<i class="fab fa-github"></i>
<a href="{{ candidate.resume_data.github }}" target="_blank">GitHub</a>
</div>
{% endif %}
</div>
</div>
<div class="score-box">
<div class="score-value">{{ candidate.analysis_data.match_score|default:0 }}%</div>
<div class="score-text">Match Score</div>
<div class="assessment-rating
{% if candidate.analysis_data.match_score|default:0 < 50 %}score-red{% elif candidate.analysis_data.match_score|default:0 < 75 %}score-yellow{% else %}score-green{% endif %}">
<!-- scoring_data.screening_stage_rating -->
{{ candidate.analysis_data.screening_stage_rating|default:"Assessment" }}
</div>
</div>
</header>
<!-- Main Content Grid -->
<div class="content-grid">
<!-- Left Column: Primary Content -->
<div class="space-y-6">
<!-- Summary Section -->
<section class="card-section">
<h2 class="section-title">
<i class="fas fa-user-circle"></i>
Summary
</h2>
<p class="summary-text">
<!-- candidate.resume_data.summary, falling back to scoring_data.job_fit_narrative -->
{{ candidate.resume_data.summary|default:"Professional summary not available." }}
</p>
</section>
<!-- Experience Section -->
<section class="card-section">
<h2 class="section-title" style="margin-bottom: 1.5rem;">
<i class="fas fa-briefcase"></i>
Experience
</h2>
{% for experience in candidate.resume_data.experience %}
<div class="experience-item">
<div class="experience-header">
<div>
<h3>{{ experience.job_title }}</h3>
<p>{{ experience.company }}</p>
</div>
<span class="experience-tag">
{% if experience.end_date == "Present" %}Present{% else %}{{ experience.end_date|default:"Current" }}{% endif %}
</span>
</div>
<p class="experience-meta">
<i class="fas fa-calendar-alt"></i>
{% if experience.start_date %}{{ experience.start_date }}{% endif %} -
{% if experience.end_date and experience.end_date != "Present" %}{{ experience.end_date }}{% else %}Present{% endif %}
<!-- candidate.resume_data.experience[].location -->
{% if experience.location %}<span style="margin-left: 1rem;"><i class="fas fa-map-pin"></i>{{ experience.location }}</span>{% endif %}
</p>
{% if experience.key_achievements %}
<ul class="achievement-list">
{% for achievement in experience.key_achievements %}
<li><i class="fas fa-caret-right"></i>{{ achievement }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
</section>
<!-- Education Section -->
<section class="card-section">
<h2 class="section-title" style="margin-bottom: 1.5rem;">
<i class="fas fa-graduation-cap"></i>
Education
</h2>
{% for education in candidate.resume_data.education %}
<div class="education-item">
<div class="icon-badge">
<i class="fas fa-certificate"></i>
</div>
<div class="education-details">
<h3>{{ education.degree }}</h3>
<p>{{ education.institution }}</p>
{% if education.year %}
<p class="meta"><i class="fas fa-calendar-alt"></i> {{ education.year }}</p>
{% endif %}
{% if education.gpa %}
<p class="meta"><i class="fas fa-award"></i> GPA: {{ education.gpa }}</p>
{% endif %}
<!-- candidate.resume_data.education[].relevant_courses -->
{% if education.relevant_courses %}
<p class="meta" style="margin-top: 0.25rem;">Courses: {{ education.relevant_courses|join:", " }}</p>
{% endif %}
</div>
</div>
{% endfor %}
</section>
<!-- Projects Section -->
<section class="card-section">
<h2 class="section-title">
<i class="fas fa-project-diagram"></i>
Projects
</h2>
{% for project in candidate.resume_data.projects %}
<div class="project-item">
<h3>{{ project.name }}</h3>
<p class="description">{{ project.brief_description }}</p>
{% if project.technologies_used %}
<div class="tag-list">
{% for tech in project.technologies_used %}
<span class="tag-item">{{ tech }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
{% if not candidate.resume_data.projects %}
<p style="color: var(--color-gray-500); font-size: 0.875rem;">No projects detailed in the resume.</p>
{% endif %}
</section>
<!-- Keywords Section (NOW IN THE LEFT COLUMN) -->
{% if candidate.analysis_data.top_3_keywords or candidate.analysis_data.cultural_fit_keywords %}
<section class="card-section" style="margin-top: 0;">
<h2 class="section-title">
<i class="fas fa-tags"></i>
Keywords
</h2>
{% if candidate.analysis_data.top_3_keywords %}
<div style="margin-bottom: 1rem;">
<h3 class="keyword-subheader"><i class="fas fa-key"></i>Top Keywords (Job Match)</h3>
<div class="tag-list">
<!-- scoring_data.top_3_keywords -->
{% for keyword in candidate.analysis_data.top_3_keywords %}
<span class="keyword-tag">{{ keyword }}</span>
{% endfor %}
</div>
</div>
{% endif %}
{% if candidate.analysis_data.cultural_fit_keywords %}
<div>
<h3 class="keyword-subheader"><i class="fas fa-users"></i>Cultural Fit Keywords</h3>
<div class="tag-list">
<!-- scoring_data.cultural_fit_keywords -->
{% for keyword in candidate.analysis_data.cultural_fit_keywords %}
<span class="cultural-tag">{{ keyword }}</span>
{% endfor %}
</div>
</div>
{% endif %}
</section>
{% endif %}
</div>
<!-- Right Column: Skills & Analysis -->
<div class="space-y-6">
<!-- Analysis Section -->
<section class="card-section">
<h2 class="section-title">
<i class="fas fa-chart-line"></i>
Analysis
</h2>
{% if candidate.analysis_data.category %}
<div class="analysis-metric" style="margin-bottom: 0.75rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--color-gray-100);">
<span class="metric-title">Target Role Category:</span>
<!-- scoring_data.category -->
<span class="metric-value" style="color: var(--kaauh-teal);">{{ candidate.analysis_data.category }}</span>
</div>
{% endif %}
<div style="margin-bottom: 1rem;">
<div class="analysis-metric" style="margin-bottom: 0.5rem; border-bottom: none;">
<span class="metric-title">Match Score</span>
<span class="metric-value">{{ candidate.analysis_data.match_score|default:0 }}/100</span>
</div>
<div class="progress-container">
<div class="progress-bar progress-bar-animated"
style="width: {{ candidate.analysis_data.match_score|default:0 }}%; background-color:
{% if candidate.analysis_data.match_score|default:0 < 50 %}var(--score-red){% elif candidate.analysis_data.match_score|default:0 < 75 %}var(--score-yellow){% else %}var(--score-green){% endif %}">
</div>
</div>
</div>
{% if candidate.analysis_data.red_flags %}
<div class="narrative-box red-flag-box">
<h3 class="flag-title red"><i class="fas fa-flag"></i>Red Flags</h3>
<!-- scoring_data.red_flags -->
<p class="narrative-text">{{ candidate.analysis_data.red_flags|join:". "|default:"None." }}</p>
</div>
{% endif %}
{% if candidate.analysis_data.strengths %}
<div class="narrative-box strength-box">
<h3 class="flag-title green"><i class="fas fa-circle-check"></i>Strengths</h3>
<!-- scoring_data.strengths -->
<p class="narrative-text">{{ candidate.analysis_data.strengths }}</p>
</div>
{% endif %}
{% if candidate.analysis_data.weaknesses %}
<div class="narrative-box" style="margin-bottom: 1rem;">
<h3 class="flag-title red"><i class="fas fa-triangle-exclamation"></i>Weaknesses</h3>
<!-- scoring_data.weaknesses -->
<p class="narrative-text">{{ candidate.analysis_data.weaknesses }}</p>
</div>
{% endif %}
{% if candidate.analysis_data.recommendation %}
<div class="analysis-summary">
<h3 style="font-size: 0.875rem;">Recommendation</h3>
<!-- scoring_data.recommendation -->
<p style="font-size: 0.875rem;">{{ candidate.analysis_data.recommendation }}</p>
</div>
{% endif %}
</section>
<!-- Criteria Checklist Section -->
{% if candidate.analysis_data.criteria_checklist %}
<section class="card-section">
<h2 class="section-title">
<i class="fas fa-list-check"></i>
Required Criteria Check
</h2>
<div style="margin-top: 0.75rem;">
{% for criterion, status in candidate.analysis_data.criteria_checklist.items %}
<div class="criteria-item">
<span class="text-gray-700">{{ criterion }}</span>
<span class="metric-value" style="font-size: 0.875rem;">
{% if status == 'Met' %}<span class="text-green-check"><i class="fas fa-check-circle"></i> Met</span>
{% elif status == 'Not Mentioned' %}<span class="text-yellow-exclaim"><i class="fas fa-exclamation-circle"></i> Not Mentioned</span>
{% else %}<span class="text-red-x"><i class="fas fa-times-circle"></i> {{ status }}</span>
{% endif %}
</span>
</div>
{% endfor %}
</div>
</section>
{% endif %}
<!-- Skills Section -->
<section class="card-section">
<h2 class="section-title">
<i class="fas fa-tools"></i>
Skills
</h2>
{% if candidate.resume_data.skills %}
{% for category, skills in candidate.resume_data.skills.items %}
<div style="margin-bottom: 1rem;">
<!-- candidate.resume_data.skills -->
<h3 class="keyword-subheader" style="color: var(--color-gray-700);"><i class="fas fa-list-alt" style="color: transparent;"></i>{{ category|cut:"_"|title }}</h3>
<div class="tag-list">
{% for skill in skills %}
<span class="tag-item" style="color: var(--kaauh-teal-dark);">{{ skill }}</span>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<p style="color: var(--color-gray-500); font-size: 0.875rem;">Skills information not available.</p>
{% endif %}
</section>
<!-- Languages Section -->
<section class="card-section">
<h2 class="section-title">
<i class="fas fa-language"></i>
Languages
</h2>
{% if candidate.analysis_data.language_fluency %}
{% for language in candidate.analysis_data.language_fluency %}
<div style="margin-bottom: 0.75rem;">
<div class="analysis-metric" style="margin-bottom: 0.25rem; border-bottom: none;">
<!-- scoring_data.language_fluency -->
<span class="metric-title">{{ language }}</span>
</div>
<div class="progress-container" style="height: 0.5rem;">
{% with fluency_check=language|lower %}
<div class="language-bar"
style="width: {% if 'native' in fluency_check %}100{% elif 'fluent' in fluency_check %}85{% elif 'intermediate' in fluency_check %}50{% elif 'basic' in fluency_check %}25{% else %}10{% endif %}%">
</div>
{% endwith %}
</div>
</div>
{% endfor %}
{% else %}
<p style="color: var(--color-gray-500); font-size: 0.875rem;">Language information not available.</p>
{% endif %}
</section>
<!-- Key Metrics -->
<section class="card-section">
<h2 class="section-title">
<i class="fas fa-chart-pie"></i>
Key Metrics
</h2>
<div style="margin-top: 0.75rem;">
{% if candidate.analysis_data.min_req_met_bool is not none %}
<div class="analysis-metric">
<span class="metric-label"><i class="fas fa-shield-halved"></i>Min Requirements Met:</span>
<!-- scoring_data.min_req_met_bool -->
<span class="metric-value {% if candidate.analysis_data.min_req_met_bool %}text-green-check{% else %}text-red-x{% endif %}">
{% if candidate.analysis_data.min_req_met_bool %}<i class="fas fa-check-circle"></i> Yes{% else %}<i class="fas fa-times-circle"></i> No{% endif %}
</span>
</div>
{% endif %}
{% if candidate.analysis_data.years_of_experience is not none %}
<div class="analysis-metric">
<span class="metric-label"><i class="fas fa-clock"></i>Total Experience:</span>
<!-- scoring_data.years_of_experience -->
<span class="metric-value">{{ candidate.analysis_data.years_of_experience|floatformat:1 }} years</span>
</div>
{% endif %}
{% if candidate.analysis_data.most_recent_job_title %}
<div class="analysis-metric">
<span class="metric-label"><i class="fas fa-id-badge"></i>Most Recent Title (Scoring):</span>
<!-- scoring_data.most_recent_job_title (explicitly added) -->
<span class="metric-value max-w-50-percent" style="text-align: right;">{{ candidate.analysis_data.most_recent_job_title }}</span>
</div>
{% endif %}
{% if candidate.analysis_data.seniority_level_match is not none %}
<div class="analysis-metric">
<span class="metric-label"><i class="fas fa-user-tie"></i>Seniority Match:</span>
<!-- scoring_data.seniority_level_match -->
<span class="metric-value">{{ candidate.analysis_data.seniority_level_match|default:0 }}/100</span>
</div>
{% endif %}
{% if candidate.analysis_data.soft_skills_score is not none %}
<div class="analysis-metric">
<span class="metric-label"><i class="fas fa-handshake"></i>Soft Skills Score:</span>
<!-- scoring_data.soft_skills_score -->
<span class="metric-value">{{ candidate.analysis_data.soft_skills_score|default:0 }}/100</span>
</div>
{% endif %}
{% if candidate.analysis_data.employment_stability_score is not none %}
<div class="analysis-metric">
<span class="metric-label"><i class="fas fa-anchor"></i>Stability Score:</span>
<!-- scoring_data.employment_stability_score -->
<span class="metric-value">{{ candidate.analysis_data.employment_stability_score|default:0 }}/100</span>
</div>
{% endif %}
{% if candidate.analysis_data.experience_industry_match is not none %}
<div class="analysis-metric" style="border-bottom: none; padding-bottom: 0;">
<span class="metric-label"><i class="fas fa-industry"></i>Industry Match:</span>
<!-- scoring_data.experience_industry_match -->
<span class="metric-value">{{ candidate.analysis_data.experience_industry_match|default:0 }}/100</span>
</div>
{% endif %}
</div>
{% if candidate.analysis_data.transferable_skills_narrative %}
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-gray-100); font-size: 0.875rem; color: var(--color-gray-500);">
<i class="fas fa-puzzle-piece" style="margin-right: 0.25rem;"></i> Transferable Skills:
<!-- scoring_data.transferable_skills_narrative -->
{{ candidate.analysis_data.transferable_skills_narrative }}
</div>
{% endif %}
</section>
</div>
</div>
<script>
// Simple progress bar animation
window.addEventListener('load', () => {
const progressBars = document.querySelectorAll('.progress-bar');
progressBars.forEach(bar => {
// The width is already set in the style attribute via DTL
const width = bar.style.width;
// Temporarily set to 0 to prepare for animation
bar.style.width = '0%';
// Add class to trigger CSS animation defined in the style block
bar.classList.add('progress-bar-animated');
});
// Note: The progress-bar-animated class with the CSS keyframe will handle the width transition.
// The original setTimeout logic is replaced by the CSS animation for smoother performance.
});
</script>
</body>
</html>

View File

@ -43,6 +43,36 @@
placeholder="e.g., 75" style="width: 120px;">
</div>
<div class="col-auto">
<label for="min_experience" class="form-label small text-muted mb-1">
{% trans "Min Years Exp" %}
</label>
<input type="number" name="min_experience" id="min_experience" class="form-control form-control-sm"
value="{{ min_experience }}" min="0" step="0.5" step="0.5"
placeholder="e.g., 2" style="width: 120px;">
</div>
<div class="col-auto">
<label for="screening_rating" class="form-label small text-muted mb-1">
{% trans "Screening Rating" %}
</label>
<select name="screening_rating" id="screening_rating" class="form-select form-select-sm" style="width: 120px;">
<option value="">{% trans "Any Rating" %}</option>
<option value="Highly Qualified" {% if screening_rating == "Highly Qualified" %}selected{% endif %}>
Highly Qualified
</option>
<option value="Qualified" {% if screening_rating == "Qualified" %}selected{% endif %}>
Qualified
</option>
<option value="Partially Qualified" {% if screening_rating == "Partially Qualified" %}selected{% endif %}>
Partially Qualified
</option>
<option value="Not Qualified" {% if screening_rating == "Not Qualified" %}selected{% endif %}>
Not Qualified
</option>
</select>
</div>
<div class="col-auto">
<label for="tier1_count" class="form-label small text-muted mb-1">
{% trans "Top N Candidates" %}
@ -279,4 +309,4 @@
}
});
</script>
{% endblock %}
{% endblock %}

View File

@ -28,31 +28,31 @@
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
}
.card-header {
font-weight: 600;
padding: 1.25rem;
border-bottom: 1px solid var(--kaauh-border);
background-color: #f8f9fa;
background-color: #f8f9fa;
}
/* Stats Grid Layout - Six columns for better detail display */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
/* Stat Card Specific Styling */
.stat-value {
font-size: 2.8rem;
font-size: 2.8rem;
text-align: center;
color: var(--kaauh-teal-dark);
color: var(--kaauh-teal-dark);
font-weight: 700;
padding: 1rem 1rem 0.5rem;
padding: 1rem 1rem 0.5rem;
}
.stat-caption {
font-size: 0.9rem;
text-align: center;
@ -86,11 +86,11 @@
{% block content %}
<div class="container-fluid py-4">
<h1 class="mb-4" style="color: var(--kaauh-teal-dark); font-weight: 700;">{% trans "Recruitment Overview" %} 🚀</h1>
<h1 class="mb-4" style="color: var(--kaauh-teal-dark); font-weight: 700;">{% trans "Recruitment Intelligence" %} 🧠</h1>
<div class="stats">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-briefcase stat-icon"></i> {% trans "Total Jobs" %}</h3>
@ -98,7 +98,7 @@
<div class="stat-value">{{ total_jobs }}</div>
<div class="stat-caption">{% trans "Active & Drafted Positions" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-users stat-icon"></i> {% trans "Total Candidates" %}</h3>
@ -106,45 +106,45 @@
<div class="stat-value">{{ total_candidates }}</div>
<div class="stat-caption">{% trans "All Profiles in ATS" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-chart-line stat-icon"></i> {% trans "Avg. Apps per Job" %}</h3>
</div>
<div class="stat-value">{{ average_applications|floatformat:1 }}</div>
<div class="stat-caption">{% trans "Key Efficiency Metric" %}</div>
<div class="stat-caption">{% trans "Efficiency Metric" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-arrow-alt-circle-up stat-icon" style="color: var(--color-success);"></i> {% trans "Active Listings" %}</h3>
<h3><i class="fas fa-star stat-icon" style="color: var(--color-warning);"></i> {% trans "Avg. Match Score" %}</h3>
</div>
<div class="stat-value">22</div>
<div class="stat-caption">{% trans "Jobs currently open for application" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-clock stat-icon" style="color: var(--color-info);"></i> {% trans "Time to Hire" %}</h3>
</div>
<div class="stat-value">35d</div>
<div class="stat-caption">{% trans "Average days from apply to offer" %}</div>
<div class="stat-value">{{ avg_match_score|floatformat:1 }}</div>
<div class="stat-caption">{% trans "Average AI Score (0-100)" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-handshake stat-icon" style="color: var(--kaauh-teal-light);"></i> {% trans "Offer Acceptance" %}</h3>
<h3><i class="fas fa-trophy stat-icon" style="color: var(--color-success);"></i> {% trans "High Potential" %}</h3>
</div>
<div class="stat-value">85%</div>
<div class="stat-caption">{% trans "Successful offers vs. Total offers" %}</div>
<div class="stat-value">{{ high_potential_count }}</div>
<div class="stat-caption">{% trans "Candidates with Score ≥ 75 ({{ high_potential_ratio }}%)" %}</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-cogs stat-icon" style="color: var(--color-info);"></i> {% trans "Scored Profiles" %}</h3>
</div>
<div class="stat-value">{{ scored_ratio|floatformat:1 }}%</div>
<div class="stat-caption">{% trans "Percent of profiles processed by AI" %}</div>
</div>
</div>
<div class="card shadow-lg">
<div class="card-header">
<h2 class="d-flex align-items-center mb-0">
<i class="fas fa-chart-bar stat-icon"></i>
{% trans "Applications Volume by Job" %}
{% trans "Top 5 Application Volume" %}
</h2>
</div>
<div class="chart-container">
@ -155,18 +155,21 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// if the strings are visible to the user. 'Applications' is used for Chart.js here.
// Pass context data safely to JavaScript
const jobTitles = JSON.parse('{{ job_titles|escapejs }}').slice(0, 5); // Take top 5
const jobAppCounts = JSON.parse('{{ job_app_counts|escapejs }}').slice(0, 5); // Take top 5
const ctx = document.getElementById('applicationsChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'bar',
data: {
labels: {{ job_titles|safe }},
// Use the parsed and sliced data
labels: jobTitles,
datasets: [{
// For simplicity, we are leaving it as-is for now, assuming the context provides the translated labels.
label: '{% trans "Applications" %}',
data: {{ job_app_counts|safe }},
label: '{% trans "Applications" %}',
data: jobAppCounts,
// Use the defined CSS variable for consistency
backgroundColor: ' #00636e', // Green theme
borderColor: ' #004a53',
borderWidth: 1,
@ -175,18 +178,30 @@
},
options: {
responsive: true,
aspectRatio: 2.5, // Make the chart wider
plugins: {
legend: {
labels: {
color: '#333333'
}
display: false // Hide legend since there's only one dataset
},
title: {
display: true,
text: 'Top 5 Most Applied Jobs',
font: {
size: 16
},
color: 'var(--kaauh-primary-text)'
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Total Applications'
},
ticks: {
color: '#333333'
color: '#333333',
precision: 0 // Ensure y-axis labels are integers
},
grid: {
color: '#e0e0e0'
@ -197,7 +212,7 @@
color: '#333333'
},
grid: {
color: '#e0e0e0'
display: false
}
}
}

123
test_word_integration.py Normal file
View File

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
Test script to verify Word document integration in recruitment/tasks.py
"""
import os
import sys
import tempfile
# Add the project directory to Python path
sys.path.insert(0, '/home/ismail/projects/ats/kaauh_ats')
# Import the tasks module
try:
from recruitment.tasks import extract_text_from_document, extract_text_from_pdf, extract_text_from_word
print("✓ Successfully imported text extraction functions")
except ImportError as e:
print(f"✗ Failed to import functions: {e}")
sys.exit(1)
def test_pdf_extraction():
"""Test PDF text extraction with a sample PDF"""
print("\n--- Testing PDF Extraction ---")
# Create a temporary PDF file for testing
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp_pdf:
try:
# Create a simple PDF content (this would normally be a real PDF)
tmp_pdf.write(b"%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\n")
tmp_pdf_path = tmp_pdf.name
# Test the PDF extraction
text = extract_text_from_pdf(tmp_pdf_path)
print(f"✓ PDF extraction completed. Text length: {len(text)}")
# Clean up
os.unlink(tmp_pdf_path)
except Exception as e:
print(f"✗ PDF extraction failed: {e}")
def test_word_extraction():
"""Test Word text extraction with a sample Word document"""
print("\n--- Testing Word Extraction ---")
try:
# Check if python-docx is available
from recruitment.tasks import DOCX_AVAILABLE
if not DOCX_AVAILABLE:
print("⚠ python-docx not available. Skipping Word extraction test.")
return
# Create a temporary Word file for testing
with tempfile.NamedTemporaryFile(suffix='.docx', delete=False) as tmp_docx:
try:
# Create a simple Word document content
tmp_docx.write(b'PK\x03\x04') # Basic DOCX header
tmp_docx_path = tmp_docx.name
# Test the Word extraction
text = extract_text_from_word(tmp_docx_path)
print(f"✓ Word extraction completed. Text length: {len(text)}")
# Clean up
os.unlink(tmp_docx_path)
except Exception as e:
print(f"✗ Word extraction failed: {e}")
# Clean up on failure
if os.path.exists(tmp_docx.name):
os.unlink(tmp_docx.name)
except Exception as e:
print(f"✗ Word extraction setup failed: {e}")
def test_unified_document_parser():
"""Test the unified document parser"""
print("\n--- Testing Unified Document Parser ---")
# Test with non-existent file
try:
extract_text_from_document('/nonexistent/file.pdf')
print("✗ Should have failed for non-existent file")
except FileNotFoundError:
print("✓ Correctly handled non-existent file")
except Exception as e:
print(f"✗ Unexpected error for non-existent file: {e}")
# Test with unsupported file type
with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as tmp_txt:
try:
tmp_txt.write(b'This is a text file')
tmp_txt_path = tmp_txt.name
try:
extract_text_from_document(tmp_txt_path)
print("✗ Should have failed for unsupported file type")
except ValueError as e:
print(f"✓ Correctly handled unsupported file type: {e}")
except Exception as e:
print(f"✗ Unexpected error for unsupported file type: {e}")
# Clean up
os.unlink(tmp_txt_path)
except Exception as e:
print(f"✗ Test setup failed: {e}")
def main():
"""Run all tests"""
print("Starting Word Document Integration Tests...")
test_pdf_extraction()
test_word_extraction()
test_unified_document_parser()
print("\n--- Test Summary ---")
print("Integration tests completed. Check the output above for any errors.")
print("\nNote: For full Word document processing, ensure python-docx is installed:")
print("pip install python-docx")
if __name__ == "__main__":
main()