diff --git a/NorahUniversity/__pycache__/settings.cpython-313.pyc b/NorahUniversity/__pycache__/settings.cpython-313.pyc index b6f4ada..48f3c69 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-313.pyc and b/NorahUniversity/__pycache__/settings.cpython-313.pyc differ diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 967338d..05cb7fd 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -55,7 +55,9 @@ INSTALLED_APPS = [ 'django_extensions', 'template_partials', 'django_countries', - 'django_celery_results' + 'django_celery_results', + 'django_q', + ] SITE_ID = 1 @@ -223,207 +225,31 @@ LINKEDIN_CLIENT_ID = '867jwsiyem1504' LINKEDIN_CLIENT_SECRET = 'WPL_AP1.QNH5lYnfRSQpp0Qp.GO8Srw==' LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/jobs/linkedin/callback/' -# customColorPalette = [ -# { -# 'color': 'hsl(4, 90%, 58%)', -# 'label': 'Red' -# }, -# { -# 'color': 'hsl(340, 82%, 52%)', -# 'label': 'Pink' -# }, -# { -# 'color': 'hsl(291, 64%, 42%)', -# 'label': 'Purple' -# }, -# { -# 'color': 'hsl(262, 52%, 47%)', -# 'label': 'Deep Purple' -# }, -# { -# 'color': 'hsl(231, 48%, 48%)', -# 'label': 'Indigo' -# }, -# { -# 'color': 'hsl(207, 90%, 54%)', -# 'label': 'Blue' -# }, -# ] -# #ckeditor_5 config setthings: -# CKEDITOR_5_CONFIGS = { -# 'default': { -# 'toolbar': { -# 'items': ['heading', '|', 'bold', 'italic', 'link', -# 'bulletedList', 'numberedList', 'blockQuote', 'imageUpload', ], -# } -# }, -# 'extends': { -# 'blockToolbar': [ -# 'paragraph', 'heading1', 'heading2', 'heading3', -# '|', -# 'bulletedList', 'numberedList', -# '|', -# 'blockQuote', -# ], -# 'toolbar': { -# 'items': ['heading', '|', 'outdent', 'indent', '|', 'bold', 'italic', 'link', 'underline', 'strikethrough', -# 'code','subscript', 'superscript', 'highlight', '|', 'codeBlock', 'sourceEditing', 'insertImage', -# 'bulletedList', 'numberedList', 'todoList', '|', 'blockQuote', 'imageUpload', '|', -# 'fontSize', 'fontFamily', 'fontColor', 'fontBackgroundColor', 'mediaEmbed', 'removeFormat', -# 'insertTable', -# ], -# 'shouldNotGroupWhenFull': 'true' -# }, -# 'image': { -# 'toolbar': ['imageTextAlternative', '|', 'imageStyle:alignLeft', -# 'imageStyle:alignRight', 'imageStyle:alignCenter', 'imageStyle:side', '|'], -# 'styles': [ -# 'full', -# 'side', -# 'alignLeft', -# 'alignRight', -# 'alignCenter', -# ] - -# }, -# 'table': { -# 'contentToolbar': [ 'tableColumn', 'tableRow', 'mergeTableCells', -# 'tableProperties', 'tableCellProperties' ], -# 'tableProperties': { -# 'borderColors': customColorPalette, -# 'backgroundColors': customColorPalette -# }, -# 'tableCellProperties': { -# 'borderColors': customColorPalette, -# 'backgroundColors': customColorPalette -# } -# }, -# 'heading' : { -# 'options': [ -# { 'model': 'paragraph', 'title': 'Paragraph', 'class': 'ck-heading_paragraph' }, -# { 'model': 'heading1', 'view': 'h1', 'title': 'Heading 1', 'class': 'ck-heading_heading1' }, -# { 'model': 'heading2', 'view': 'h2', 'title': 'Heading 2', 'class': 'ck-heading_heading2' }, -# { 'model': 'heading3', 'view': 'h3', 'title': 'Heading 3', 'class': 'ck-heading_heading3' } -# ] -# } -# }, -# 'list': { -# 'properties': { -# 'styles': 'true', -# 'startIndex': 'true', -# 'reversed': 'true', -# } -# } -# } - -# The customColorPalette constant must be defined before CKEDITOR_5_CONFIGS -customColorPalette = [ - { - 'color': 'hsl(4, 90%, 58%)', - 'label': 'Red' - }, - { - 'color': 'hsl(340, 82%, 52%)', - 'label': 'Pink' - }, - { - 'color': 'hsl(291, 64%, 42%)', - 'label': 'Purple' - }, - { - 'color': 'hsl(262, 52%, 47%)', - 'label': 'Deep Purple' - }, - { - 'color': 'hsl(231, 48%, 48%)', - 'label': 'Indigo' - }, - { - 'color': 'hsl(207, 90%, 54%)', - 'label': 'Blue' - }, -] - -CKEDITOR_5_CONFIGS = { - 'default': { - 'toolbar': { - 'items': ['heading', '|', 'bold', 'italic', 'link', - 'bulletedList', 'numberedList', 'blockQuote', 'imageUpload', ], - } - - }, - # Your existing 'extends' configuration remains unchanged - 'extends': { - 'blockToolbar': [ - 'paragraph', 'heading1', 'heading2', 'heading3', - '|', - 'bulletedList', 'numberedList', - '|', - 'blockQuote', - ], - 'toolbar': { - 'items': ['heading', '|', 'outdent', 'indent', '|', 'bold', 'italic', 'link', 'underline', 'strikethrough', - 'code','subscript', 'superscript', 'highlight', '|', 'codeBlock', 'sourceEditing', 'insertImage', - 'bulletedList', 'numberedList', 'todoList', '|', 'blockQuote', 'imageUpload', '|', - 'fontSize', 'fontFamily', 'fontColor', 'fontBackgroundColor', 'mediaEmbed', 'removeFormat', - 'insertTable', - ], - 'shouldNotGroupWhenFull': 'true' +Q_CLUSTER = { + 'name': 'KAAUH_CLUSTER', + 'workers': 4, + 'recycle': 500, + 'timeout': 60, + 'compress': True, + 'save_limit': 250, + 'queue_limit': 500, + 'cpu_affinity': 1, + 'label': 'Django Q2', + 'redis': { + 'host': '127.0.0.1', + 'port': 6379, + 'db': 0, }, + 'ALT_CLUSTERS': { + 'long': { + 'timeout': 3000, + 'retry': 3600, + 'max_attempts': 2, }, - 'image': { - 'toolbar': ['imageTextAlternative', '|', 'imageStyle:alignLeft', - 'imageStyle:alignRight', 'imageStyle:alignCenter', 'imageStyle:side', '|'], - 'styles': [ - 'full', - 'side', - 'alignLeft', - 'alignRight', - 'alignCenter', - ] - + 'short': { + 'timeout': 10, + 'max_attempts': 1, }, - 'table': { - 'contentToolbar': [ 'tableColumn', 'tableRow', 'mergeTableCells', - 'tableProperties', 'tableCellProperties' ], - 'tableProperties': { - 'borderColors': customColorPalette, - 'backgroundColors': customColorPalette - }, - 'tableCellProperties': { - 'borderColors': customColorPalette, - 'backgroundColors': customColorPalette - } - }, - 'heading' : { - 'options': [ - { 'model': 'paragraph', 'title': 'Paragraph', 'class': 'ck-heading_paragraph' }, - { 'model': 'heading1', 'view': 'h1', 'title': 'Heading 1', 'class': 'ck-heading_heading1' }, - { 'model': 'heading2', 'view': 'h2', 'title': 'Heading 2', 'class': 'ck-heading_heading2' }, - { 'model': 'heading3', 'view': 'h3', 'title': 'Heading 3', 'class': 'ck-heading_heading3' } - ] - } - }, - # Your existing 'list' configuration remains unchanged - 'list': { - 'properties': { - 'styles': 'true', - 'startIndex': 'true', - 'reversed': 'true', - } - }, - - # *** NEW 'comment' CONFIGURATION *** - 'comment': { - 'toolbar': { - 'items': [ - 'bold', 'italic', 'underline', 'link', - 'bulletedList', 'numberedList', 'blockQuote', - '|', 'undo', 'redo' - ], - }, - # You can add other specific configurations for a comment field here, - # such as disabling image upload or advanced features to keep it lightweight. - }, + } } \ No newline at end of file diff --git a/recruitment/__pycache__/admin.cpython-313.pyc b/recruitment/__pycache__/admin.cpython-313.pyc index a4f7fc8..ea1b2f9 100644 Binary files a/recruitment/__pycache__/admin.cpython-313.pyc and b/recruitment/__pycache__/admin.cpython-313.pyc differ diff --git a/recruitment/__pycache__/models.cpython-313.pyc b/recruitment/__pycache__/models.cpython-313.pyc index 7de510d..1236c5d 100644 Binary files a/recruitment/__pycache__/models.cpython-313.pyc and b/recruitment/__pycache__/models.cpython-313.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-313.pyc b/recruitment/__pycache__/signals.cpython-313.pyc index 530e3d0..3a9c25c 100644 Binary files a/recruitment/__pycache__/signals.cpython-313.pyc and b/recruitment/__pycache__/signals.cpython-313.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-313.pyc b/recruitment/__pycache__/urls.cpython-313.pyc index cce2bd9..f3436fb 100644 Binary files a/recruitment/__pycache__/urls.cpython-313.pyc and b/recruitment/__pycache__/urls.cpython-313.pyc differ diff --git a/recruitment/__pycache__/utils.cpython-313.pyc b/recruitment/__pycache__/utils.cpython-313.pyc index 13274c0..40cc895 100644 Binary files a/recruitment/__pycache__/utils.cpython-313.pyc and b/recruitment/__pycache__/utils.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index e01a336..eb48134 100644 Binary files a/recruitment/__pycache__/views.cpython-313.pyc and b/recruitment/__pycache__/views.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views_frontend.cpython-313.pyc b/recruitment/__pycache__/views_frontend.cpython-313.pyc index 7a1340c..e72323e 100644 Binary files a/recruitment/__pycache__/views_frontend.cpython-313.pyc and b/recruitment/__pycache__/views_frontend.cpython-313.pyc differ diff --git a/recruitment/admin.py b/recruitment/admin.py index 92200c8..bfcf6cc 100644 --- a/recruitment/admin.py +++ b/recruitment/admin.py @@ -143,7 +143,7 @@ class JobPostingAdmin(admin.ModelAdmin): @admin.register(Candidate) class CandidateAdmin(admin.ModelAdmin): - list_display = ['full_name', 'job', 'email', 'phone', 'stage', 'applied', 'created_at'] + list_display = ['full_name', 'job', 'email', 'phone', 'stage', 'applied','is_resume_parsed', 'created_at'] list_filter = ['stage', 'applied', 'created_at', 'job__department'] search_fields = ['first_name', 'last_name', 'email', 'phone'] readonly_fields = ['slug', 'created_at', 'updated_at'] diff --git a/recruitment/migrations/0004_candidate_applicant_status.py b/recruitment/migrations/0004_candidate_applicant_status.py new file mode 100644 index 0000000..866ba2e --- /dev/null +++ b/recruitment/migrations/0004_candidate_applicant_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2025-10-11 14:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0003_candidate_is_resume_parsed_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='candidate', + name='applicant_status', + field=models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status'), + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index d470e25..a6df23c 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -267,6 +267,10 @@ class Candidate(Base): ACCEPTED = "Accepted", _("Accepted") REJECTED = "Rejected", _("Rejected") + class ApplicantType(models.TextChoices): + APPLICANT = "Applicant", _("Applicant") + CANDIDATE = "Candidate", _("Candidate") + # Stage transition validation constants STAGE_SEQUENCE = { "Applied": ["Exam", "Interview", "Offer"], @@ -298,7 +302,14 @@ class Candidate(Base): choices=Stage.choices, verbose_name=_("Stage"), ) - + applicant_status = models.CharField( + choices=ApplicantType.choices, + default="Applicant", + max_length=100, + null=True, + blank=True, + verbose_name=_("Applicant Status"), + ) exam_date = models.DateField(null=True, blank=True, verbose_name=_("Exam Date")) exam_status = models.CharField( choices=ExamStatus.choices, diff --git a/recruitment/signals.py b/recruitment/signals.py index 2c6edb1..2649cb0 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -1,136 +1,21 @@ -from . import models -from django.urls import reverse +import logging from django.db import transaction from django.dispatch import receiver +from django_q.tasks import async_task from django.db.models.signals import post_save -from .models import FormField,FormStage,FormTemplate +from .models import FormField,FormStage,FormTemplate,Candidate -# @receiver(post_save, sender=models.Candidate) -# def parse_resume(sender, instance, created, **kwargs): -# if instance.resume and not instance.summary: -# from .utils import extract_summary_from_pdf,match_resume_with_job_description -# summary = extract_summary_from_pdf(instance.resume.path) -# if 'error' not in summary: -# instance.summary = summary -# instance.save() - - # match_resume_with_job_description - -import logging logger = logging.getLogger(__name__) -import os -from .utils import extract_text_from_pdf,score_resume_with_openrouter -import asyncio -@receiver(post_save, sender=models.Candidate) +@receiver(post_save, sender=Candidate) def score_candidate_resume(sender, instance, created, **kwargs): - if instance.is_resume_parsed: - return - try: - # Get absolute file path - file_path = instance.resume.path - if not os.path.exists(file_path): - logger.warning(f"Resume file not found: {file_path}") - return - - resume_text = extract_text_from_pdf(file_path) - # if not resume_text: - # instance.scoring_error = "Could not extract text from resume." - # instance.save(update_fields=['scoring_error']) - # return - job_detail=str(instance.job.description)+str(instance.job.qualifications) - prompt1 = f""" - You are an expert resume parser and summarizer. Given a resume in plain text format, extract and organize the following key-value information into a clean, valid JSON object: - - full_name: Full name of the candidate - current_title: Most recent or current job title - location: City and state (or country if outside the U.S.) - contact: Phone number and email (as a single string or separate fields) - linkedin: LinkedIn profile URL (if present) - github: GitHub or portfolio URL (if present) - summary: Brief professional profile or summary (1–2 sentences) - education: List of degrees, each with: - institution - degree - year - gpa (if provided) - relevant_courses (as a list, if mentioned) - skills: Grouped by category if possible (e.g., programming, big data, visualization), otherwise as a flat list of technologies/tools - experience: List of roles, each with: - company - job_title - location - start_date and end_date (or "Present" if applicable) - key_achievements (as a list of concise bullet points) - projects: List of notable projects (if clearly labeled), each with: - name - year - technologies_used - brief_description - Instructions: - - Be concise but preserve key details. - 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. - Now, process the following resume text: - {resume_text} - """ - result = score_resume_with_openrouter(prompt1) - prompt = f""" - You are an expert technical recruiter. Your task is to score the following candidate for the role of a Senior Data Analyst based on the provided job criteria. - - **Job Criteria:** - {job_detail} - - **Candidate's Extracted Resume Json:** - \"\"\" - {result} - \"\"\" - - **Your Task:** - Provide a response in strict JSON format with the following keys: - 1. 'match_score': A score from 0 to 100 representing how well the candidate fits the role. - 2. 'strengths': A brief summary of why the candidate is a strong fit, referencing specific criteria. - 3. 'weaknesses': A brief summary of where the candidate falls short or what criteria are missing. - 4. 'criteria_checklist': An object where you rate the candidate's match for each specific criterion (e.g., {{'Python': 'Met', 'AWS': 'Not Mentioned'}}). - - - Only output valid JSON. Do not include any other text. - """ - - result1 = score_resume_with_openrouter(prompt) - - instance.parsed_summary = str(result) - - # Update candidate with scoring results - instance.match_score = result1.get('match_score') - instance.strengths = result1.get('strengths', '') - instance.weaknesses = result1.get('weaknesses', '') - instance.criteria_checklist = result1.get('criteria_checklist', {}) - - instance.is_resume_parsed = True - - # Save only scoring-related fields to avoid recursion - instance.save(update_fields=[ - 'match_score', 'strengths', 'weaknesses', - 'criteria_checklist','parsed_summary', 'is_resume_parsed' - ]) - - logger.info(f"Successfully scored resume for candidate {instance.id}") - - except Exception as e: - # error_msg = str(e)[:500] # Truncate to fit TextField - # instance.scoring_error = error_msg - # instance.save(update_fields=['scoring_error']) - logger.error(f"Failed to score resume for candidate {instance.id}: {e}") - - -# @receiver(post_save,sender=models.Candidate) -# def trigger_scoring(sender,intance,created,**kwargs): - - + if not instance.is_resume_parsed: + logger.info(f"Scoring resume for candidate {instance.pk}") + async_task( + 'recruitment.tasks.handle_reume_parsing_and_scoring', + instance.pk, + # hook='myapp.tasks.email_sent_callback' # Optional callback + ) @receiver(post_save, sender=FormTemplate) def create_default_stages(sender, instance, created, **kwargs): diff --git a/recruitment/tasks.py b/recruitment/tasks.py new file mode 100644 index 0000000..abfa760 --- /dev/null +++ b/recruitment/tasks.py @@ -0,0 +1,155 @@ +import os +import json +import logging +import requests +from PyPDF2 import PdfReader +from recruitment.models import Candidate + +logger = logging.getLogger(__name__) + +OPENROUTER_API_KEY ='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' +OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-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") + text = "" + try: + with open(file_path, "rb") as f: + reader = PdfReader(f) + for page in reader.pages: + text += (page.extract_text() or "") + except Exception as e: + logger.error(f"PDF extraction failed: {e}") + raise + return text.strip() + +def ai_handler(prompt): + print("model call") + response = requests.post( + url="https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + }, + data=json.dumps({ + "model": OPENROUTER_MODEL, + "messages": [{"role": "user", "content": prompt}], + }, + ) + ) + res = {} + print(response.status_code) + if response.status_code == 200: + res = response.json() + content = res["choices"][0]['message']['content'] + try: + + content = content.replace("```json","").replace("```","") + + res = json.loads(content) + + except Exception as e: + print(e) + + # res = raw_output["choices"][0]["message"]["content"] + else: + print("error response") + return res + +def handle_reume_parsing_and_scoring(pk): + logger.info(f"Scoring resume for candidate {pk}") + try: + instance = Candidate.objects.get(pk=pk) + file_path = instance.resume.path + if not os.path.exists(file_path): + logger.warning(f"Resume file not found: {file_path}") + return + + resume_text = extract_text_from_pdf(file_path) + job_detail= f"{instance.job.description} {instance.job.qualifications}" + resume_parser_prompt = f""" + You are an expert resume parser and summarizer. Given a resume in plain text format, extract and organize the following key-value information into a clean, valid JSON object: + + full_name: Full name of the candidate + current_title: Most recent or current job title + location: City and state (or country if outside the U.S.) + contact: Phone number and email (as a single string or separate fields) + linkedin: LinkedIn profile URL (if present) + github: GitHub or portfolio URL (if present) + summary: Brief professional profile or summary (1–2 sentences) + education: List of degrees, each with: + institution + degree + year + gpa (if provided) + relevant_courses (as a list, if mentioned) + skills: Grouped by category if possible (e.g., programming, big data, visualization), otherwise as a flat list of technologies/tools + experience: List of roles, each with: + company + job_title + location + start_date and end_date (or "Present" if applicable) + key_achievements (as a list of concise bullet points) + projects: List of notable projects (if clearly labeled), each with: + name + year + technologies_used + brief_description + Instructions: + + Be concise but preserve key details. + 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. + Now, process the following resume text: + {resume_text} + """ + resume_parser_result = ai_handler(resume_parser_prompt) + resume_scoring_prompt = f""" + You are an expert technical recruiter. Your task is to score the following candidate for the role of a Senior Data Analyst based on the provided job criteria. + + **Job Criteria:** + {job_detail} + + **Candidate's Extracted Resume Json:** + \"\"\" + {resume_parser_result} + \"\"\" + + **Your Task:** + Provide a response in strict JSON format with the following keys: + 1. 'match_score': A score from 0 to 100 representing how well the candidate fits the role. + 2. 'strengths': A brief summary of why the candidate is a strong fit, referencing specific criteria. + 3. 'weaknesses': A brief summary of where the candidate falls short or what criteria are missing. + 4. 'criteria_checklist': An object where you rate the candidate's match for each specific criterion (e.g., {{'Python': 'Met', 'AWS': 'Not Mentioned'}}). + + + Only output valid JSON. Do not include any other text. + """ + + resume_scoring_result = ai_handler(resume_scoring_prompt) + + instance.parsed_summary = str(resume_parser_result) + + # Update candidate with scoring results + instance.match_score = resume_scoring_result.get('match_score') + instance.strengths = resume_scoring_result.get('strengths', '') + instance.weaknesses = resume_scoring_result.get('weaknesses', '') + instance.criteria_checklist = resume_scoring_result.get('criteria_checklist', {}) + + instance.is_resume_parsed = True + + # Save only scoring-related fields to avoid recursion + instance.save(update_fields=[ + 'match_score', 'strengths', 'weaknesses', + 'criteria_checklist','parsed_summary', 'is_resume_parsed' + ]) + + logger.info(f"Successfully scored resume for candidate {instance.id}") + + except Exception as e: + logger.error(f"Failed to score resume for candidate {instance.id}: {e}") diff --git a/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc b/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc index a5b4be1..c7fbf5f 100644 Binary files a/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc and b/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc differ diff --git a/recruitment/templatetags/form_filters.py b/recruitment/templatetags/form_filters.py index bf10384..33d5293 100644 --- a/recruitment/templatetags/form_filters.py +++ b/recruitment/templatetags/form_filters.py @@ -15,38 +15,60 @@ def get_stage_responses(stage_responses, stage_id): return [] @register.simple_tag -def get_all_responses_flat(stage_responses): +def get_all_responses_flat(submission): """ - Template tag to get all responses flattened for table display. - Usage: {% get_all_responses_flat stage_responses as all_responses %} + Template tag to get all responses from a FormSubmission flattened for table display. + Usage: {% get_all_responses_flat submission as flat_responses %} """ all_responses = [] - if stage_responses: - for stage_id, responses in stage_responses.items(): - if responses: # Check if responses list exists and is not empty - for response in responses: - # Check if response is an object or string - if hasattr(response, 'stage') and hasattr(response, 'field'): - stage_name = response.stage.name if hasattr(response.stage, 'name') else f"Stage {stage_id}" - field_label = response.field.label if hasattr(response.field, 'label') else "Unknown Field" - field_type = response.field.get_field_type_display() if hasattr(response.field, 'get_field_type_display') else "Unknown Type" - required = response.field.required if hasattr(response.field, 'required') else False - value = response.value if hasattr(response, 'value') else response - uploaded_file = response.uploaded_file if hasattr(response, 'uploaded_file') else None - else: - stage_name = f"Stage {stage_id}" - field_label = "Unknown Field" - field_type = "Text" - required = False - value = response - uploaded_file = None + if submission: + # Fetch all responses related to this submission, selecting related field and stage objects for efficiency + field_responses = submission.responses.all().select_related('field', 'field__stage').order_by('field__stage__order', 'field__order') - all_responses.append({ - 'stage_name': stage_name, - 'field_label': field_label, - 'field_type': field_type, - 'required': required, - 'value': value, - 'uploaded_file': uploaded_file - }) + for response in field_responses: + stage_name = "N/A" + field_label = "Unknown Field" + field_type = "Text" + required = False + value = None + uploaded_file = None + + if response.field: + field_label = response.field.label + field_type = response.field.get_field_type_display() + required = response.field.required + if response.field.stage: + stage_name = response.field.stage.name + + value = response.value + uploaded_file = response.uploaded_file + + all_responses.append({ + 'stage_name': stage_name, + 'field_label': field_label, + 'field_type': field_type, + 'required': required, + 'value': value, + 'uploaded_file': uploaded_file + }) return all_responses + + +@register.simple_tag +def get_field_response_for_submission(submission, field): + """ + Template tag to get the FieldResponse for a specific submission and field. + Usage: {% get_field_response_for_submission submission field as response %} + """ + try: + return submission.responses.filter(field=field).first() + except: + return None + +@register.filter +def to_list(data): + """ + Template tag to convert a string to a list. + Usage: {% to_list "item1,item2,item3" as list %} + """ + return data.split(",") if data else [] diff --git a/recruitment/urls.py b/recruitment/urls.py index f3340ca..023a178 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -9,7 +9,7 @@ urlpatterns = [ # Job URLs (using JobPosting model) path('jobs/', views_frontend.JobListView.as_view(), name='job_list'), path('jobs/create/', views.create_job, name='job_create'), - path('job//upload_image_simple/', views.job_image_upload, name='job_image_upload'), + path('job//upload_image_simple/', views.job_image_upload, name='job_image_upload'), path('jobs//update/', views.edit_job, name='job_update'), # path('jobs//delete/', views., name='job_delete'), path('jobs//', views.job_detail, name='job_detail'), @@ -32,7 +32,7 @@ urlpatterns = [ path('candidates//delete/', views_frontend.CandidateDeleteView.as_view(), name='candidate_delete'), path('candidate//view/', views_frontend.candidate_detail, name='candidate_detail'), path('candidate//update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'), - + # Training URLs path('training/', views_frontend.TrainingListView.as_view(), name='training_list'), @@ -64,11 +64,14 @@ urlpatterns = [ path('forms/builder//', views.form_builder, name='form_builder'), path('forms/', views.form_templates_list, name='form_templates_list'), path('forms/create-template/', views.create_form_template, name='create_form_template'), + path('jobs//candidate-tiers/', views.candidate_tier_management_view, name='candidate_tier_management'), + path('htmx//candidate_criteria_view/', views.candidate_criteria_view_htmx, name='candidate_criteria_view_htmx'), # path('forms/form//submit/', views.submit_form, name='submit_form'), # path('forms/form//', views.form_wizard_view, name='form_wizard'), - path('forms//submissions//', views.form_submission_details, name='form_submission_details'), + path('forms//submissions//', views.form_submission_details, name='form_submission_details'), path('forms/template//submissions/', views.form_template_submissions_list, name='form_template_submissions_list'), + path('forms/template//all-submissions/', views.form_template_all_submissions, name='form_template_all_submissions'), # path('forms//', views.form_preview, name='form_preview'), # path('forms//submit/', views.form_submit, name='form_submit'), diff --git a/recruitment/views.py b/recruitment/views.py index d83b636..a4da922 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -991,13 +991,38 @@ def form_template_submissions_list(request, slug): ) -def form_submission_details(request, template_id, submission_id): +def form_template_all_submissions(request, template_id): + """Display all submissions for a form template in table format""" + template = get_object_or_404(FormTemplate, id=template_id) + print(template) + # Get all submissions for this template + submissions = FormSubmission.objects.filter(template=template).order_by("-submitted_at") + + # Get all fields for this template, ordered by stage and field order + fields = FormField.objects.filter(stage__template=template).select_related('stage').order_by('stage__order', 'order') + + # Pagination + paginator = Paginator(submissions, 10) # Show 10 submissions per page + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + return render( + request, + "forms/form_template_all_submissions.html", + { + "template": template, + "page_obj": page_obj, + "fields": fields, + }, + ) + + +def form_submission_details(request, template_id, slug): """Display detailed view of a specific form submission""" # Get the form template and verify ownership - template = get_object_or_404(FormTemplate, id=template_id, created_by=request.user) - + template = get_object_or_404(FormTemplate, id=template_id) # Get the specific submission - submission = get_object_or_404(FormSubmission, id=submission_id, template=template) + submission = get_object_or_404(FormSubmission, slug=slug, template=template) # Get all stages with their fields stages = template.stages.prefetch_related("fields").order_by("order") @@ -1192,3 +1217,133 @@ def schedule_interviews_view(request, job_id): "interviews/schedule_interviews.html", {"form": form, "break_formset": break_formset, "job": job}, ) + + +def candidate_tier_management_view(request, slug): + """ + Manage candidate tiers and stage transitions + """ + job = get_object_or_404(JobPosting, slug=slug) + + # Get all candidates for this job, ordered by match score (descending) + candidates = job.candidates.all().order_by("-match_score") + + # Get tier categorization parameters + tier1_count = int(request.GET.get("tier1_count", 100)) + + # Categorize candidates into tiers + tier1_candidates = candidates[:tier1_count] if tier1_count > 0 else [] + remaining_candidates = candidates[tier1_count:] if tier1_count > 0 else [] + + if len(remaining_candidates) > 0: + # Tier 2: Next 50% of remaining candidates + tier2_count = max(1, len(remaining_candidates) // 2) + tier2_candidates = remaining_candidates[:tier2_count] + tier3_candidates = remaining_candidates[tier2_count:] + else: + tier2_candidates = [] + tier3_candidates = [] + + # Handle form submissions + if request.method == "POST": + # Update tier categorization + if "update_tiers" in request.POST: + tier1_count = int(request.POST.get("tier1_count", 100)) + messages.success(request, f"Tier categorization updated. Tier 1: {tier1_count} candidates") + return redirect("candidate_tier_management", slug=slug) + + # Update individual candidate stages + elif "update_stage" in request.POST: + candidate_id = request.POST.get("candidate_id") + new_stage = request.POST.get("new_stage") + candidate = get_object_or_404(Candidate, id=candidate_id, job=job) + + if candidate.can_transition_to(new_stage): + old_stage = candidate.stage + candidate.stage = new_stage + candidate.save() + messages.success(request, f"Updated {candidate.name} from {old_stage} to {new_stage}") + else: + messages.error(request, f"Cannot transition {candidate.name} from {candidate.stage} to {new_stage}") + + # Update exam status + elif "update_exam_status" in request.POST: + candidate_id = request.POST.get("candidate_id") + exam_status = request.POST.get("exam_status") + exam_date = request.POST.get("exam_date") + candidate = get_object_or_404(Candidate, id=candidate_id, job=job) + + if candidate.stage == "Exam": + candidate.exam_status = exam_status + if exam_date: + candidate.exam_date = exam_date + candidate.save() + messages.success(request, f"Updated exam status for {candidate.name}") + else: + messages.error(request, f"Can only update exam status for candidates in Exam stage") + + # Bulk stage update + elif "bulk_update_stage" in request.POST: + selected_candidates = request.POST.getlist("selected_candidates") + new_stage = request.POST.get("bulk_new_stage") + updated_count = 0 + + for candidate_id in selected_candidates: + candidate = get_object_or_404(Candidate, id=candidate_id, job=job) + if candidate.can_transition_to(new_stage): + candidate.stage = new_stage + candidate.save() + updated_count += 1 + + messages.success(request, f"Updated {updated_count} candidates to {new_stage} stage") + + # Mark individual candidate as Candidate + elif "mark_as_candidate" in request.POST: + candidate_id = request.POST.get("candidate_id") + candidate = get_object_or_404(Candidate, id=candidate_id, job=job) + + if candidate.applicant_status == "Applicant": + candidate.applicant_status = "Candidate" + candidate.save() + messages.success(request, f"Marked {candidate.name} as Candidate") + else: + messages.info(request, f"{candidate.name} is already marked as Candidate") + + # Mark all Tier 1 candidates as Candidates + elif "mark_as_candidates" in request.POST: + updated_count = 0 + for candidate in tier1_candidates: + if candidate.applicant_status == "Applicant": + candidate.applicant_status = "Candidate" + candidate.save() + updated_count += 1 + + if updated_count > 0: + messages.success(request, f"Marked {updated_count} Tier 1 candidates as Candidates") + else: + messages.info(request, "All Tier 1 candidates are already marked as Candidates") + + # Group candidates by current stage for display + stage_groups = { + "Applied": candidates.filter(stage="Applied"), + "Exam": candidates.filter(stage="Exam"), + "Interview": candidates.filter(stage="Interview"), + "Offer": candidates.filter(stage="Offer"), + } + + context = { + "job": job, + "tier1_candidates": tier1_candidates, + "tier2_candidates": tier2_candidates, + "tier3_candidates": tier3_candidates, + "stage_groups": stage_groups, + "tier1_count": tier1_count, + "total_candidates": candidates.count(), + } + + return render(request, "recruitment/candidate_tier_management.html", context) + +def candidate_criteria_view_htmx(request, pk): + candidate = get_object_or_404(Candidate, pk=pk) + print(candidate) + return render(request, "includes/candidate_modal_body.html", {"candidate": candidate}) \ No newline at end of file diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 7451276..5427f7b 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -223,7 +223,7 @@ def candidate_detail(request, slug): stage_form = forms.CandidateStageForm(candidate=candidate) # parsed = JSON(json.dumps(parsed), indent=2, highlight=True, skip_keys=False, ensure_ascii=False, check_circular=True, allow_nan=True, default=None, sort_keys=False) - parsed = json_to_markdown_table([parsed]) + # parsed = json_to_markdown_table([parsed]) return render(request, 'recruitment/candidate_detail.html', { 'candidate': candidate, 'parsed': parsed, diff --git a/templates/forms/form_submission_details.html b/templates/forms/form_submission_details.html index 0b38326..4846c18 100644 --- a/templates/forms/form_submission_details.html +++ b/templates/forms/form_submission_details.html @@ -9,8 +9,8 @@ @@ -51,62 +51,79 @@
Responses
- {% get_all_responses_flat stage_responses as all_responses %} - {% if all_responses %} -
- - - - - - - - - - {% for response in all_responses %} - - - - - - {% endfor %} - -
Field LabelResponse ValueFile
- {{ response.field_label }} - {% if response.required %} - * - {% endif %} - - {% if response.uploaded_file %} - File: {{ response.uploaded_file.name }} - {% elif response.value %} - {% if response.field_type == 'checkbox' and response.value|length > 0 %} -
- {% for val in response.value %} - {{ val }} - {% endfor %} -
- {% elif response.field_type == 'radio' or response.field_type == 'select' %} - {{ response.value }} - {% else %} -

{{ response.value|linebreaksbr }}

- {% endif %} - {% else %} - Not provided - {% endif %} -
- {% if response.uploaded_file %} - - Download - - {% endif %} -
-
- {% else %} -
-

No responses found for this submission.

-
- {% endif %} + {% with submission=submission %} + {% get_all_responses_flat submission as flat_responses %} + + {% if flat_responses %} +
+ + + + + {% for response in flat_responses %} + + {% endfor %} + + + + + + {% for response in flat_responses %} + + {% endfor %} + + + + {% for response in flat_responses %} + + {% endfor %} + + + + {% for response in flat_responses %} + + {% endfor %} + + +
Field Label{{ response.field_label }}
Response Value + {% if response.uploaded_file %} +
+ {{ response.uploaded_file.name }} + + + +
+ {% elif response.value %} + {% if response.field_type == 'checkbox' and response.value|length > 0 %} +
+ {% for val in response.value %} + {{ val }} + {% endfor %} +
+ {% elif response.field_type == 'radio' or response.field_type == 'select' %} + {{ response.value }} + {% else %} +

{{ response.value|linebreaksbr }}

+ {% endif %} + {% else %} + Not provided + {% endif %} +
Stage + {{ response.stage_name|default:"N/A" }} +
Required + {% if response.required %} + Yes + {% else %} + No + {% endif %} +
+
+ {% else %} +
+

No responses found for this submission.

+
+ {% endif %} + {% endwith %}
@@ -119,6 +136,8 @@ border-top: none; font-weight: 600; color: #495057; + vertical-align: top; + white-space: nowrap; } .table td { vertical-align: top; @@ -126,5 +145,17 @@ .response-value { max-width: 300px; } +.table th:first-child, +.table td:first-child { + background-color: #f8f9fa; + font-weight: 600; +} +.table-striped > tbody > tr:nth-of-type(odd) > td { + background-color: rgba(0, 0, 0, 0.02); +} +.table-bordered th, +.table-bordered td { + border: 1px solid #dee2e6; +} {% endblock %} diff --git a/templates/forms/form_template_all_submissions.html b/templates/forms/form_template_all_submissions.html new file mode 100644 index 0000000..9b0e498 --- /dev/null +++ b/templates/forms/form_template_all_submissions.html @@ -0,0 +1,371 @@ +{% extends 'base.html' %} +{% load static i18n form_filters %} +{% load partials %} + +{% block title %}All Submissions for {{ template.name }} - ATS{% endblock %} + +{% block customCSS %} + +{% endblock %} + +{% block content %} +
+ + +
+
+
+

+ + {% trans "All Submissions for" %}: {{ template.name }} +

+ Template ID: #{{ template.id }} +
+ + {% trans "Back to Submissions" %} + +
+
+ {% if page_obj.object_list %} +
+ + + + + + + + {% for field in fields %} + + {% endfor %} + + + + {% for submission in page_obj %} + + + + + + {% for field in fields %} + {% get_field_response_for_submission submission field as response %} + + {% endfor %} + + {% endfor %} + +
{% trans "Submission ID" %}{% trans "Applicant Name" %}{% trans "Applicant Email" %}{% trans "Submitted At" %}{{ field.label }}
{{ submission.id }}{{ submission.applicant_name|default:"N/A" }}{{ submission.applicant_email|default:"N/A" }}{{ submission.submitted_at|date:"M d, Y H:i" }} + {% if response %} + {% if response.uploaded_file %} +
+ + + + +
+ {% elif response.value %} + {% if response.field.field_type == 'checkbox' and response.value|length > 0 %} +
+ {% for val in response.value|to_list %} + {{ val }} + {% endfor %} +
+ {% elif response.field.field_type == 'radio' or response.field.field_type == 'select' %} + {{ response.value }} + {% else %} +

{{ response.value|linebreaksbr|truncatewords:10 }}

+ {% endif %} + {% else %} + Not provided + {% endif %} + {% else %} + Not provided + {% endif %} +
+
+ + + {% if page_obj.has_other_pages %} +
+
+ {% blocktrans with start=page_obj.start_index end=page_obj.end_index total=page_obj.paginator.count %} + Showing {{ start }} to {{ end }} of {{ total }} results. + {% endblocktrans %} +
+ +
+ {% endif %} + {% else %} +
+ +

{% trans "No Submissions Found" %}

+

+ {% trans "There are no submissions for this form template yet." %} +

+ + {% trans "Back to Submissions" %} + +
+ {% endif %} +
+
+
+{% endblock %} diff --git a/templates/forms/form_template_submissions_list.html b/templates/forms/form_template_submissions_list.html index 77edba2..11df1ba 100644 --- a/templates/forms/form_template_submissions_list.html +++ b/templates/forms/form_template_submissions_list.html @@ -200,9 +200,14 @@ Template ID: #{{ template.id }} - - {% trans "Back to Templates" %} - +
{% if page_obj.object_list %} @@ -231,10 +236,10 @@ {{ submission.applicant_email|default:"N/A" }} {{ submission.submitted_at|date:"M d, Y H:i" }} - + {% trans "View Details" %} - + {% endfor %} @@ -260,7 +265,7 @@

diff --git a/templates/forms/form_wizard.html b/templates/forms/form_wizard.html index 73de014..3705ad6 100644 --- a/templates/forms/form_wizard.html +++ b/templates/forms/form_wizard.html @@ -881,17 +881,28 @@ formData.append('csrfmiddlewaretoken', csrfToken); // Add field responses - state.stages.forEach(stage => { + state.stages.forEach(stage => { stage.fields.forEach(field => { const value = state.formData[field.id]; - if (value !== undefined && value !== null) { - if (field.type === 'file' && value instanceof File) { + + // Always include the field, even if it's empty + if (field.type === 'file') { + if (value instanceof File) { formData.append(`field_${field.id}`, value); - } else if (field.type === 'checkbox') { + } else { + // Include empty file field + formData.append(`field_${field.id}`, ''); + } + } else if (field.type === 'checkbox') { + // For checkboxes, send empty array if no selection + if (Array.isArray(value) && value.length > 0) { formData.append(`field_${field.id}`, JSON.stringify(value)); } else { - formData.append(`field_${field.id}`, value); + formData.append(`field_${field.id}`, JSON.stringify([])); } + } else { + // For other field types, send the value or empty string + formData.append(`field_${field.id}`, value || ''); } }); }); diff --git a/templates/includes/candidate_modal_body.html b/templates/includes/candidate_modal_body.html new file mode 100644 index 0000000..6b32d24 --- /dev/null +++ b/templates/includes/candidate_modal_body.html @@ -0,0 +1,21 @@ +{% load i18n %} + +
+ + +
+
+ + +
+
+ +
    + {% for key, value in candidate.criteria_checklist.items %} +
  • + {{ key }} + {{ value|yesno:"Yes,No" }} +
  • + {% endfor %} +
+
\ No newline at end of file diff --git a/templates/jobs/job_detail.html b/templates/jobs/job_detail.html index 28ba0d1..4b9ce4e 100644 --- a/templates/jobs/job_detail.html +++ b/templates/jobs/job_detail.html @@ -136,7 +136,7 @@ } .right-column-tabs .nav-link { padding: 0.9rem 1rem; - font-size: 0.95rem; + font-size: 0.95rem; font-weight: 600; color: var(--kaauh-primary-text); border-radius: 0; @@ -409,11 +409,11 @@ {% trans "Edit Job" %} - + - + @@ -472,9 +472,9 @@ {% trans "View All Applicants" %} ({{ total_candidates }}) - + {% endif %} - + @@ -551,6 +551,43 @@ {% endif %} + + {# Applicant Form Management (Content from old card) #} +
{% trans "Form Management" %}
+
+

+ {% trans "Manage the custom application forms associated with this job posting." %} +

+ + + {% trans "Create New Form" %} + + + + {% trans "View All Existing Forms" %} + + + + {% trans "Create Candidate" %} + + + {% trans "Manage Tiers" %} + +
+ + + {# TAB 3: INTERNAL INFO CONTENT #} +
+
{% trans "Internal Information" %}
+
+

{% trans "Internal Job ID:" %} {{ job.internal_job_id }}

+

{% trans "Created:" %} {{ job.created_at|date:"M d, Y" }}

+

{% trans "Last Updated:" %} {{ job.updated_at|date:"M d, Y" }}

+ {% if job.reporting_to %} +

{% trans "Reports To:" %} {{ job.reporting_to }}

+ {% endif %} +
+
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/recruitment/candidate_tier_management.html b/templates/recruitment/candidate_tier_management.html new file mode 100644 index 0000000..42d10f5 --- /dev/null +++ b/templates/recruitment/candidate_tier_management.html @@ -0,0 +1,724 @@ +{% extends 'base.html' %} +{% load static i18n %} + +{% block title %}Candidate Tier Management - {{ job.title }} - ATS{% endblock %} + +{% block customCSS %} + +{% endblock %} + +{% block content %} +
+ + + +
+
+ {% csrf_token %} +
+
+ + +
+
+ +
+
+
+
+ + +
+

{% trans "Bulk Stage Update" %}

+
+ {% csrf_token %} +
+
+ + +
+
+ +
+
+
+
+ + + {% comment %}
+ {% for stage_name, stage_candidates in stage_groups.items %} +
+
+ {{ stage_name }} + {{ stage_candidates.count }} +
+
+ {% for candidate in stage_candidates %} +
+
+ + +
+
+ {% empty %} +

{% trans "No candidates in this stage" %}

+ {% endfor %} +
+
+ {% endfor %} +
{% endcomment %} + + +

{% trans "Candidate Tiers" %}

+ + + + + +
+ +
+ {% if tier1_candidates %} +
+ +
+
+ + + + + + + + + + + + + {% for candidate in tier1_candidates %} + + + + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Contact" %}{% trans "AI Score" %}{% trans "Status" %}{% trans "Stage" %}{% trans "Actions" %}
+
{{ candidate.name }}
+
+
+ Email: {{ candidate.email }}
+ Phone: {{ candidate.phone }}
+
+
+ {{ candidate.match_score|default:"0" }} + + + {{ candidate.get_applicant_status_display }} + + + + {{ candidate.get_stage_display }} + + {% if candidate.stage == "Exam" and candidate.exam_status %} +
+ {{ candidate.get_exam_status_display }} + {% endif %} +
+ +
+
+ {% else %} +

{% trans "No candidates in Tier 1" %}

+ {% endif %} +
+ + +
+ {% if tier2_candidates %} +
+ + + + + + + + + + + + + {% for candidate in tier2_candidates %} + + + + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Contact" %}{% trans "AI Score" %}{% trans "Status" %}{% trans "Stage" %}{% trans "Actions" %}
+
{{ candidate.name }}
+
+
+ Email: {{ candidate.email }}
+ Phone: {{ candidate.phone }}
+
+
+ {{ candidate.match_score|default:"0" }} + + + {{ candidate.get_applicant_status_display }} + + + + {{ candidate.get_stage_display }} + + {% if candidate.stage == "Exam" and candidate.exam_status %} +
+ {{ candidate.get_exam_status_display }} + {% endif %} +
+ +
+ {% if candidate.applicant_status == 'Applicant' %} + + {% endif %} + {% for next_stage in candidate.get_available_stages %} + + {% endfor %} + {% if candidate.stage == "Exam" %} + + {% endif %} +
+ + +
+
+ {% else %} +

{% trans "No candidates in Tier 2" %}

+ {% endif %} +
+ + +
+ {% if tier3_candidates %} +
+ + + + + + + + + + + + + {% for candidate in tier3_candidates %} + + + + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Contact" %}{% trans "AI Score" %}{% trans "Status" %}{% trans "Stage" %}{% trans "Actions" %}
+
{{ candidate.name }}
+
+
+ Email: {{ candidate.email }}
+ Phone: {{ candidate.phone }}
+
+
+ {{ candidate.match_score|default:"0" }} + + + {{ candidate.get_applicant_status_display }} + + + + {{ candidate.get_stage_display }} + + {% if candidate.stage == "Exam" and candidate.exam_status %} +
+ {{ candidate.get_exam_status_display }} + {% endif %} +
+ +
+ {% if candidate.applicant_status == 'Applicant' %} + + {% endif %} + {% for next_stage in candidate.get_available_stages %} + + {% endfor %} + {% if candidate.stage == "Exam" %} + + {% endif %} +
+ + +
+
+ {% else %} +

{% trans "No candidates in Tier 3" %}

+ {% endif %} +
+
+
+ + +{% for candidate in tier1_candidates|add:tier2_candidates|add:tier3_candidates %} +{% if candidate.stage == "Exam" %} + +{% endif %} +{% endfor %} + + +{% endblock %} + +{% block customJS %} + + +{% endblock customJS %}