merge
This commit is contained in:
parent
f0ae8f46d9
commit
5921e30396
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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'),
|
||||
),
|
||||
]
|
||||
|
||||
Binary file not shown.
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
11
recruitment/templatetags/candidate_filters.py
Normal file
11
recruitment/templatetags/candidate_filters.py
Normal 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
|
||||
10
recruitment/templatetags/safe_dict.py
Normal file
10
recruitment/templatetags/safe_dict.py
Normal 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 ""
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"""
|
||||
|
||||
@ -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 %}
|
||||
|
||||
540
templates/includes/candidate_resume_template.html
Normal file
540
templates/includes/candidate_resume_template.html
Normal 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 results‑driven 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>
|
||||
@ -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 #}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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">
|
||||
|
||||
932
templates/recruitment/candidate_resume_template.html
Normal file
932
templates/recruitment/candidate_resume_template.html
Normal 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>
|
||||
@ -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 %}
|
||||
|
||||
@ -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
123
test_word_integration.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user