more update and add qcluster
This commit is contained in:
parent
322c98222d
commit
916cbc4fcf
Binary file not shown.
@ -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.
|
||||
},
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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']
|
||||
|
||||
18
recruitment/migrations/0004_candidate_applicant_status.py
Normal file
18
recruitment/migrations/0004_candidate_applicant_status.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
155
recruitment/tasks.py
Normal file
155
recruitment/tasks.py
Normal file
@ -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}")
|
||||
Binary file not shown.
@ -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 []
|
||||
|
||||
@ -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/<slug:slug>/upload_image_simple/', views.job_image_upload, name='job_image_upload'),
|
||||
path('job/<slug:slug>/upload_image_simple/', views.job_image_upload, name='job_image_upload'),
|
||||
path('jobs/<slug:slug>/update/', views.edit_job, name='job_update'),
|
||||
# path('jobs/<slug:slug>/delete/', views., name='job_delete'),
|
||||
path('jobs/<slug:slug>/', views.job_detail, name='job_detail'),
|
||||
@ -32,7 +32,7 @@ urlpatterns = [
|
||||
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>/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/<int:template_id>/', 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/<slug:slug>/candidate-tiers/', views.candidate_tier_management_view, name='candidate_tier_management'),
|
||||
path('htmx/<int:pk>/candidate_criteria_view/', views.candidate_criteria_view_htmx, name='candidate_criteria_view_htmx'),
|
||||
|
||||
# path('forms/form/<int:template_id>/submit/', views.submit_form, name='submit_form'),
|
||||
# path('forms/form/<int:template_id>/', views.form_wizard_view, name='form_wizard'),
|
||||
path('forms/<int:form_id>/submissions/<int:submission_id>/', views.form_submission_details, name='form_submission_details'),
|
||||
path('forms/<int:template_id>/submissions/<slug:slug>/', views.form_submission_details, name='form_submission_details'),
|
||||
path('forms/template/<slug:slug>/submissions/', views.form_template_submissions_list, name='form_template_submissions_list'),
|
||||
path('forms/template/<int:template_id>/all-submissions/', views.form_template_all_submissions, name='form_template_all_submissions'),
|
||||
|
||||
# path('forms/<int:form_id>/', views.form_preview, name='form_preview'),
|
||||
# path('forms/<int:form_id>/submit/', views.form_submit, name='form_submit'),
|
||||
|
||||
@ -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})
|
||||
@ -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,
|
||||
|
||||
@ -9,8 +9,8 @@
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>Submission Details</h2>
|
||||
<a href="" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back
|
||||
<a href="{% url 'form_template_submissions_list' template.slug %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Submissions
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -51,62 +51,79 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-3">Responses</h5>
|
||||
{% get_all_responses_flat stage_responses as all_responses %}
|
||||
{% if all_responses %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field Label</th>
|
||||
<th>Response Value</th>
|
||||
<th>File</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for response in all_responses %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ response.field_label }}</strong>
|
||||
{% if response.required %}
|
||||
<span class="text-danger">*</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if response.uploaded_file %}
|
||||
<span class="text-primary">File: {{ response.uploaded_file.name }}</span>
|
||||
{% elif response.value %}
|
||||
{% if response.field_type == 'checkbox' and response.value|length > 0 %}
|
||||
<div>
|
||||
{% for val in response.value %}
|
||||
<span class="badge bg-secondary me-1">{{ val }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif response.field_type == 'radio' or response.field_type == 'select' %}
|
||||
<span class="badge bg-info">{{ response.value }}</span>
|
||||
{% else %}
|
||||
<p class="mb-0">{{ response.value|linebreaksbr }}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">Not provided</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if response.uploaded_file %}
|
||||
<a href="{{ response.uploaded_file.url }}" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<p>No responses found for this submission.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% with submission=submission %}
|
||||
{% get_all_responses_flat submission as flat_responses %}
|
||||
|
||||
{% if flat_responses %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-bordered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col" style="width: 150px;">Field Label</th>
|
||||
{% for response in flat_responses %}
|
||||
<th scope="col">{{ response.field_label }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Response Value</strong></td>
|
||||
{% for response in flat_responses %}
|
||||
<td>
|
||||
{% if response.uploaded_file %}
|
||||
<div>
|
||||
<span class="text-primary"><i class="fas fa-file"></i> {{ response.uploaded_file.name }}</span>
|
||||
<a href="{{ response.uploaded_file.url }}" class="btn btn-sm btn-outline-primary ms-2" target="_blank" title="Download File">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% elif response.value %}
|
||||
{% if response.field_type == 'checkbox' and response.value|length > 0 %}
|
||||
<div>
|
||||
{% for val in response.value %}
|
||||
<span class="badge bg-secondary me-1">{{ val }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif response.field_type == 'radio' or response.field_type == 'select' %}
|
||||
<span class="badge bg-info">{{ response.value }}</span>
|
||||
{% else %}
|
||||
<p class="mb-0">{{ response.value|linebreaksbr }}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">Not provided</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Stage</strong></td>
|
||||
{% for response in flat_responses %}
|
||||
<td>
|
||||
{{ response.stage_name|default:"N/A" }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Required</strong></td>
|
||||
{% for response in flat_responses %}
|
||||
<td>
|
||||
{% if response.required %}
|
||||
<span class="text-danger"><i class="fas fa-asterisk"></i> Yes</span>
|
||||
{% else %}
|
||||
<span>No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<p>No responses found for this submission.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -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;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
371
templates/forms/form_template_all_submissions.html
Normal file
371
templates/forms/form_template_all_submissions.html
Normal file
@ -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 %}
|
||||
<style>
|
||||
/* ================================================= */
|
||||
/* UI Variables (Matching Form Templates List) */
|
||||
/* ================================================= */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
--kaauh-gray-light: #f8f9fa;
|
||||
}
|
||||
|
||||
/* --- Typography and Color Overrides --- */
|
||||
.text-primary { color: var(--kaauh-teal) !important; }
|
||||
|
||||
/* --- Button Base Styles (Matching Form Templates List) --- */
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-main-action:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Secondary Button Style (for Edit/Preview) */
|
||||
.btn-outline-secondary {
|
||||
color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal);
|
||||
}
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
color: white;
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
}
|
||||
|
||||
/* Size Utilities (matching Bootstrap convention) */
|
||||
.btn-lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.btn-sm {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
}
|
||||
|
||||
/* --- Card and Layout Styles (Matching Form Templates List) --- */
|
||||
.card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
background-color: white;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: var(--kaauh-teal-dark) !important;
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
color: white !important;
|
||||
font-weight: 600;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.card-header h1 {
|
||||
color: white !important;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.card-header .fas {
|
||||
color: white !important;
|
||||
}
|
||||
.card-header .small {
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
/* --- Compact Table Styles --- */
|
||||
.table-responsive {
|
||||
border-radius: 0.5rem;
|
||||
overflow: auto;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.table {
|
||||
margin-bottom: 0;
|
||||
min-width: max-content;
|
||||
}
|
||||
.table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.table thead th {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
color: white;
|
||||
border-color: var(--kaauh-border);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table tbody td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
vertical-align: middle;
|
||||
border-color: var(--kaauh-border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.table tbody tr {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.table tbody tr:hover {
|
||||
background-color: var(--kaauh-gray-light);
|
||||
}
|
||||
/* Compact form elements */
|
||||
.file-response {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.badge-response {
|
||||
margin: 0.05rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
.response-value p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-sm {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
/* --- Pagination Styling (Matching Form Templates List) --- */
|
||||
.pagination .page-item .page-link {
|
||||
color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-border);
|
||||
}
|
||||
.pagination .page-item.active .page-link {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
}
|
||||
.pagination .page-item:hover .page-link:not(.active) {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
.pagination-info {
|
||||
color: var(--kaauh-primary-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* --- Empty State Theming --- */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--kaauh-primary-text);
|
||||
border: 2px dashed var(--kaauh-border);
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--kaauh-gray-light);
|
||||
}
|
||||
.empty-state i {
|
||||
font-size: 3.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--kaauh-teal-dark);
|
||||
}
|
||||
.empty-state .btn-main-action .fas {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* --- Breadcrumb --- */
|
||||
.breadcrumb {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.breadcrumb-item a {
|
||||
color: var(--kaauh-teal-dark);
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.breadcrumb-item.active {
|
||||
color: var(--kaauh-primary-text);
|
||||
}
|
||||
|
||||
/* --- Response Value Styling --- */
|
||||
.response-value {
|
||||
word-break: break-word;
|
||||
max-width: 200px;
|
||||
}
|
||||
.file-response {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.badge-response {
|
||||
margin: 0.1rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'dashboard' %}">{% trans "Dashboard" %}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'form_templates_list' %}">{% trans "Form Templates" %}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'form_template_submissions_list' template.slug %}">{% trans "Submissions" %}</a></li>
|
||||
<li class="breadcrumb-item active">{% trans "All Submissions Table" %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h1 class="h3 mb-1 d-flex align-items-center">
|
||||
<i class="fas fa-table me-2"></i>
|
||||
{% trans "All Submissions for" %}: <span class="text-white ms-2">{{ template.name }}</span>
|
||||
</h1>
|
||||
<small class="text-white-50">Template ID: #{{ template.id }}</small>
|
||||
</div>
|
||||
<a href="{% url 'form_template_submissions_list' template.slug %}" class="btn btn-outline-light btn-sm">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Submissions" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if page_obj.object_list %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Submission ID" %}</th>
|
||||
<th scope="col">{% trans "Applicant Name" %}</th>
|
||||
<th scope="col">{% trans "Applicant Email" %}</th>
|
||||
<th scope="col">{% trans "Submitted At" %}</th>
|
||||
{% for field in fields %}
|
||||
<th scope="col">{{ field.label }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for submission in page_obj %}
|
||||
<tr>
|
||||
<td class="fw-medium">{{ submission.id }}</td>
|
||||
<td>{{ submission.applicant_name|default:"N/A" }}</td>
|
||||
<td>{{ submission.applicant_email|default:"N/A" }}</td>
|
||||
<td>{{ submission.submitted_at|date:"M d, Y H:i" }}</td>
|
||||
{% for field in fields %}
|
||||
{% get_field_response_for_submission submission field as response %}
|
||||
<td class="response-value">
|
||||
{% if response %}
|
||||
{% if response.uploaded_file %}
|
||||
<div class="file-response">
|
||||
|
||||
<a href="{{ response.uploaded_file.url }}" class="btn btn-sm btn-outline-primary" target="_blank" title="Download File">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% elif response.value %}
|
||||
{% if response.field.field_type == 'checkbox' and response.value|length > 0 %}
|
||||
<div>
|
||||
{% for val in response.value|to_list %}
|
||||
<span class="badge bg-secondary badge-response">{{ val }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif response.field.field_type == 'radio' or response.field.field_type == 'select' %}
|
||||
<span class="badge bg-info">{{ response.value }}</span>
|
||||
{% else %}
|
||||
<p class="mb-0">{{ response.value|linebreaksbr|truncatewords:10 }}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">Not provided</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">Not provided</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-center mt-4">
|
||||
<div class="pagination-info mb-3 mb-md-0">
|
||||
{% 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 %}
|
||||
</div>
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1" aria-label="First">
|
||||
<span aria-hidden="true">«</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}" aria-label="Previous">
|
||||
<span aria-hidden="true">‹</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item active">
|
||||
<span class="page-link">
|
||||
{% trans "Page" %} {{ page_obj.number }} {% trans "of" %} {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}" aria-label="Next">
|
||||
<span aria-hidden="true">›</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}" aria-label="Last">
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-inbox"></i>
|
||||
<h3 class="h5 mb-3">{% trans "No Submissions Found" %}</h3>
|
||||
<p class="text-muted mb-4">
|
||||
{% trans "There are no submissions for this form template yet." %}
|
||||
</p>
|
||||
<a href="{% url 'form_template_submissions_list' template.slug %}" class="btn btn-main-action">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Submissions" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -200,9 +200,14 @@
|
||||
</h1>
|
||||
<small class="text-white-50">Template ID: #{{ template.id }}</small>
|
||||
</div>
|
||||
<a href="{% url 'form_templates_list' %}" class="btn btn-outline-light btn-sm">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Templates" %}
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'form_template_all_submissions' template.id %}" class="btn btn-outline-light btn-sm">
|
||||
<i class="fas fa-table me-1"></i> {% trans "View All in Table" %}
|
||||
</a>
|
||||
<a href="{% url 'form_templates_list' %}" class="btn btn-outline-light btn-sm">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Templates" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if page_obj.object_list %}
|
||||
@ -231,10 +236,10 @@
|
||||
<td>{{ submission.applicant_email|default:"N/A" }}</td>
|
||||
<td>{{ submission.submitted_at|date:"M d, Y H:i" }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{% url 'form_submission_details' template_id=template.id submission_id=submission.id %}" class="btn btn-sm btn-outline-primary">
|
||||
<a href="{% url 'form_submission_details' template_id=submission.template.id slug=submission.slug %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
|
||||
</a>
|
||||
</td>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@ -260,7 +265,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a href="{% url 'form_submission_details' template_id=template.id submission_id=submission.id %}" class="btn btn-sm btn-outline-primary w-100">
|
||||
<a href="{% url 'form_submission_details' template_id=template.id slug=submission.slug %}" class="btn btn-sm btn-outline-primary w-100">
|
||||
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -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 || '');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
21
templates/includes/candidate_modal_body.html
Normal file
21
templates/includes/candidate_modal_body.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% load i18n %}
|
||||
<h5 class="modal-title" id="candidateviewModalLabel">{{ candidate.name }} - {% trans "Score" %}: <span class="badge bg-success"> {{ candidate.match_score }} </span></h5>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Strengths" %}</label>
|
||||
<textarea class="form-control" rows="3" readonly>{{ candidate.strengths }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Weaknesses" %}</label>
|
||||
<textarea class="form-control" rows="3" readonly>{{ candidate.weaknesses }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% 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>{{ key }}</span>
|
||||
<span class="badge bg-{{ value|yesno:"success,danger" }}">{{ value|yesno:"Yes,No" }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@ -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 @@
|
||||
<i class="fas fa-edit"></i> {% trans "Edit Job" %}
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#myModalForm">
|
||||
<i class="fas fa-image me-1"></i> {% trans "Upload Image for Post" %}
|
||||
</button>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -472,9 +472,9 @@
|
||||
{% trans "View All Applicants" %} ({{ total_candidates }})
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-main-action">
|
||||
<i class="fas fa-user-plus"></i> {% trans "Create Candidate" %}
|
||||
@ -486,7 +486,7 @@
|
||||
<div class="tab-pane fade" id="manage-pane" role="tabpanel" aria-labelledby="manage-tab">
|
||||
|
||||
{# LinkedIn Integration (Content from old card) #}
|
||||
|
||||
|
||||
|
||||
{# Applicant Form Management (Content from old card) #}
|
||||
<h5 class="mb-3"><i class="fas fa-clipboard-list me-2 text-primary"></i>{% trans "Form Management" %}</h5>
|
||||
@ -494,7 +494,7 @@
|
||||
<p class="text-muted small mb-3">
|
||||
{% trans "Manage the custom application forms associated with this job posting." %}
|
||||
</p>
|
||||
|
||||
|
||||
{% if not job.form_template %}
|
||||
<a href="{% url 'create_form_template' %}" class="btn btn-main-action">
|
||||
<i class="fas fa-plus-circle me-2"></i> {% trans "Create New Form Template" %}
|
||||
@ -505,7 +505,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -551,6 +551,43 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Applicant Form Management (Content from old card) #}
|
||||
<h5 class="mb-3"><i class="fas fa-clipboard-list me-2 text-primary"></i>{% trans "Form Management" %}</h5>
|
||||
<div class="d-grid gap-2">
|
||||
<p class="text-muted small mb-3">
|
||||
{% trans "Manage the custom application forms associated with this job posting." %}
|
||||
</p>
|
||||
|
||||
<a href="{% url 'create_form_template' %}" class="btn btn-main-action">
|
||||
<i class="fas fa-plus-circle me-2"></i> {% trans "Create New Form" %}
|
||||
</a>
|
||||
|
||||
<a href="" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-list-alt me-1"></i> {% trans "View All Existing Forms" %}
|
||||
</a>
|
||||
|
||||
<a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-main-action">
|
||||
<i class="fas fa-user-plus"></i> {% trans "Create Candidate" %}
|
||||
</a>
|
||||
<a href="{% url 'candidate_tier_management' job.slug %}" class="btn btn-main-action">
|
||||
<i class="fas fa-layer-group"></i> {% trans "Manage Tiers" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# TAB 3: INTERNAL INFO CONTENT #}
|
||||
<div class="tab-pane fade" id="internal-pane" role="tabpanel" aria-labelledby="internal-tab">
|
||||
<h5 class="mb-3"><i class="fas fa-info-circle me-2 text-secondary"></i>{% trans "Internal Information" %}</h5>
|
||||
<div class="small">
|
||||
<p class="mb-1"><strong>{% trans "Internal Job ID:" %}</strong> {{ job.internal_job_id }}</p>
|
||||
<p class="mb-1"><strong>{% trans "Created:" %}</strong> {{ job.created_at|date:"M d, Y" }}</p>
|
||||
<p class="mb-1"><strong>{% trans "Last Updated:" %}</strong> {{ job.updated_at|date:"M d, Y" }}</p>
|
||||
{% if job.reporting_to %}
|
||||
<p class="mb-0"><strong>{% trans "Reports To:" %}</strong> {{ job.reporting_to }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{% url 'job_list' %}" class="btn btn-outline-secondary w-100">
|
||||
<i class="fas fa-arrow-left"></i> {% trans "Back to Jobs" %}
|
||||
@ -598,4 +635,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
724
templates/recruitment/candidate_tier_management.html
Normal file
724
templates/recruitment/candidate_tier_management.html
Normal file
@ -0,0 +1,724 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}Candidate Tier Management - {{ job.title }} - ATS{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* Minimal Tier Management Styles */
|
||||
.tier-controls {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.tier-controls .form-row {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.tier-controls .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.bulk-update-controls {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.stage-groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.stage-group {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stage-group .stage-header {
|
||||
background-color: #495057;
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.stage-group .stage-body {
|
||||
padding: 0.75rem;
|
||||
min-height: 80px;
|
||||
}
|
||||
.stage-candidate {
|
||||
padding: 0.375rem;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
}
|
||||
.stage-candidate:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.match-score {
|
||||
font-weight: 600;
|
||||
color: #0056b3;
|
||||
}
|
||||
.btn-sm {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
/* Tab Styles for Tiers */
|
||||
.nav-tabs {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.nav-tabs .nav-link {
|
||||
border: none;
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.nav-tabs .nav-link:hover {
|
||||
border: none;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.nav-tabs .nav-link.active {
|
||||
color: #495057;
|
||||
background-color: #fff;
|
||||
border: none;
|
||||
border-bottom: 2px solid #007bff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tier-1 .nav-link {
|
||||
color: #155724;
|
||||
}
|
||||
.tier-1 .nav-link.active {
|
||||
border-bottom-color: #28a745;
|
||||
}
|
||||
.tier-2 .nav-link {
|
||||
color: #856404;
|
||||
}
|
||||
.tier-2 .nav-link.active {
|
||||
border-bottom-color: #ffc107;
|
||||
}
|
||||
.tier-3 .nav-link {
|
||||
color: #721c24;
|
||||
}
|
||||
.tier-3 .nav-link.active {
|
||||
border-bottom-color: #dc3545;
|
||||
}
|
||||
|
||||
/* Candidate Table Styles */
|
||||
.candidate-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
background-color: white;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.candidate-table thead {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.candidate-table th {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: #495057;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
.candidate-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.candidate-table tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.candidate-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.candidate-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.candidate-details {
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
.candidate-table-responsive {
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.stage-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.375rem;
|
||||
}
|
||||
.stage-Applied {
|
||||
background-color: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
.stage-Exam {
|
||||
background-color: #cce5ff;
|
||||
color: #004085;
|
||||
}
|
||||
.stage-Interview {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
.stage-Offer {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.exam-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
.exam-controls select,
|
||||
.exam-controls input {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
}
|
||||
.tier-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
color: #495057;
|
||||
margin-left: 0.375rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="fas fa-layer-group me-2"></i>
|
||||
{% trans "Candidate Tier Management" %} - {{ job.title }}
|
||||
</h1>
|
||||
<p class="text-muted mb-0">
|
||||
Total Candidates: {{ total_candidates }}
|
||||
</p>
|
||||
</div>
|
||||
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tier Controls -->
|
||||
<div class="tier-controls">
|
||||
<form method="post" class="mb-0">
|
||||
{% csrf_token %}
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="tier1_count">{% trans "Number of candidates in Tier 1 (Top N)" %}</label>
|
||||
<input type="number" name="tier1_count" id="tier1_count" class="form-control"
|
||||
value="{{ tier1_count }}" min="1" max="{{ total_candidates }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" name="update_tiers" class="btn btn-primary">
|
||||
<i class="fas fa-sync-alt me-1"></i> {% trans "Update Tiers" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Stage Update Controls -->
|
||||
<div class="bulk-update-controls">
|
||||
<h4 class="h5 mb-3">{% trans "Bulk Stage Update" %}</h4>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="form-row align-items-end">
|
||||
<div class="form-group">
|
||||
<label for="bulk_new_stage">{% trans "Update Selected Candidates to" %}</label>
|
||||
<select name="bulk_new_stage" id="bulk_new_stage" class="form-control" required>
|
||||
<option value="">{% trans "Select Stage" %}</option>
|
||||
<option value="Exam">{% trans "Exam" %}</option>
|
||||
<option value="Interview">{% trans "Interview" %}</option>
|
||||
<option value="Offer">{% trans "Offer" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" name="bulk_update_stage" class="btn btn-success">
|
||||
<i class="fas fa-tasks me-1"></i> {% trans "Update Selected" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Stage Groups -->
|
||||
{% comment %} <div class="stage-groups">
|
||||
{% for stage_name, stage_candidates in stage_groups.items %}
|
||||
<div class="stage-group">
|
||||
<div class="stage-header">
|
||||
{{ stage_name }}
|
||||
<span class="badge badge-light">{{ stage_candidates.count }}</span>
|
||||
</div>
|
||||
<div class="stage-body">
|
||||
{% for candidate in stage_candidates %}
|
||||
<div class="stage-candidate">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="selected_candidates"
|
||||
value="{{ candidate.id }}" id="candidate_{{ candidate.id }}">
|
||||
<label class="form-check-label d-flex justify-content-between align-items-start"
|
||||
for="candidate_{{ candidate.id }}">
|
||||
<div>
|
||||
<div class="fw-medium">{{ candidate.name }}</div>
|
||||
<div class="text-muted small">
|
||||
Score: <span class="match-score">{{ candidate.match_score|default:"0" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
{% if candidate.stage == "Exam" and candidate.exam_status %}
|
||||
<span class="badge bg-info">{{ candidate.get_exam_status_display }}</span>
|
||||
{% endif %}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle"
|
||||
type="button" data-bs-toggle="dropdown">
|
||||
{% trans "Actions" %}
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% for next_stage in candidate.get_available_stages %}
|
||||
<li>
|
||||
<button type="submit" name="update_stage"
|
||||
class="dropdown-item btn-sm"
|
||||
formaction="?candidate_id={{ candidate.id }}&new_stage={{ next_stage }}">
|
||||
Move to {{ next_stage }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if candidate.stage == "Exam" %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#examModal{{ candidate.id }}">
|
||||
{% trans "Update Exam Status" %}
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted text-center py-3">{% trans "No candidates in this stage" %}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div> {% endcomment %}
|
||||
|
||||
<!-- Tier Display -->
|
||||
<h2 class="h4 mb-3 mt-5">{% trans "Candidate Tiers" %}</h2>
|
||||
|
||||
<!-- Tabs for Candidate Tiers -->
|
||||
<ul class="nav nav-tabs tier-1" id="candidateTiersTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tier1-tab" data-bs-toggle="tab" data-bs-target="#tier1" type="button"
|
||||
role="tab" aria-controls="tier1" aria-selected="true">
|
||||
{% trans "Tier 1 - Top Candidates" %}
|
||||
<span class="tier-badge ms-1">{{ tier1_candidates|length }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tier2-tab" data-bs-toggle="tab" data-bs-target="#tier2" type="button"
|
||||
role="tab" aria-controls="tier2" aria-selected="false">
|
||||
{% trans "Tier 2 - Good Candidates" %}
|
||||
<span class="tier-badge ms-1">{{ tier2_candidates|length }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tier3-tab" data-bs-toggle="tab" data-bs-target="#tier3" type="button"
|
||||
role="tab" aria-controls="tier3" aria-selected="false">
|
||||
{% trans "Tier 3 - Other Candidates" %}
|
||||
<span class="tier-badge ms-1">{{ tier3_candidates|length }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content" id="candidateTiersTabContent">
|
||||
<!-- Tier 1 Tab -->
|
||||
<div class="tab-pane fade show active" id="tier1" role="tabpanel" aria-labelledby="tier1-tab">
|
||||
{% if tier1_candidates %}
|
||||
<div class="mb-3 d-flex justify-content-end">
|
||||
<button type="submit" name="mark_as_candidates"
|
||||
class="btn btn-sm btn-success"
|
||||
onclick="return confirm('Are you sure you want to mark all Tier 1 candidates as official candidates?')">
|
||||
<i class="fas fa-user-check me-1"></i> {% trans "Mark as Candidates" %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="candidate-table-responsive">
|
||||
<table class="candidate-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Contact" %}</th>
|
||||
<th>{% trans "AI Score" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th>{% trans "Stage" %}</th>
|
||||
<th>{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for candidate in tier1_candidates %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="candidate-name">{{ candidate.name }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="candidate-details">
|
||||
Email: {{ candidate.email }}<br>
|
||||
Phone: {{ candidate.phone }}<br>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{{ candidate.match_score|default:"0" }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {% if candidate.applicant_status == 'Candidate' %}bg-success{% else %}bg-secondary{% endif %}">
|
||||
{{ candidate.get_applicant_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="stage-badge stage-{{ candidate.stage }}">
|
||||
{{ candidate.get_stage_display }}
|
||||
</span>
|
||||
{% if candidate.stage == "Exam" and candidate.exam_status %}
|
||||
<br>
|
||||
<span class="badge bg-info">{{ candidate.get_exam_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-primary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateviewModal"
|
||||
hx-get="{% url 'candidate_criteria_view_htmx' candidate.pk %}"
|
||||
hx-target="#candidateviewModalBody"
|
||||
>
|
||||
{% include "icons/view.html" %}
|
||||
{% trans "View" %}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-muted py-3">{% trans "No candidates in Tier 1" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tier 2 Tab -->
|
||||
<div class="tab-pane fade" id="tier2" role="tabpanel" aria-labelledby="tier2-tab">
|
||||
{% if tier2_candidates %}
|
||||
<div class="candidate-table-responsive">
|
||||
<table class="candidate-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Contact" %}</th>
|
||||
<th>{% trans "AI Score" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th>{% trans "Stage" %}</th>
|
||||
<th>{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for candidate in tier2_candidates %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="candidate-name">{{ candidate.name }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="candidate-details">
|
||||
Email: {{ candidate.email }}<br>
|
||||
Phone: {{ candidate.phone }}<br>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{{ candidate.match_score|default:"0" }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {% if candidate.applicant_status == 'Candidate' %}bg-success{% else %}bg-secondary{% endif %}">
|
||||
{{ candidate.get_applicant_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="stage-badge stage-{{ candidate.stage }}">
|
||||
{{ candidate.get_stage_display }}
|
||||
</span>
|
||||
{% if candidate.stage == "Exam" and candidate.exam_status %}
|
||||
<br>
|
||||
<span class="badge bg-info">{{ candidate.get_exam_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<!-- Individual actions -->
|
||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||
{% if candidate.applicant_status == 'Applicant' %}
|
||||
<button type="submit" name="mark_as_candidate"
|
||||
class="btn btn-sm btn-success"
|
||||
formaction="?candidate_id={{ candidate.id }}&action=mark_as_candidate"
|
||||
title="{% trans 'Mark as Candidate' %}">
|
||||
<i class="fas fa-user-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% for next_stage in candidate.get_available_stages %}
|
||||
<button type="submit" name="update_stage"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
formaction="?candidate_id={{ candidate.id }}&new_stage={{ next_stage }}"
|
||||
title="{% trans 'Move to' %} {{ next_stage }}">
|
||||
{{ next_stage }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% if candidate.stage == "Exam" %}
|
||||
<button type="button" class="btn btn-sm btn-outline-info"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#examModal{{ candidate.id }}"
|
||||
title="{% trans 'Update Exam Status' %}">
|
||||
<i class="fas fa-clipboard-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Dropdown for additional actions -->
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button"
|
||||
data-bs-toggle="dropdown">
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% for next_stage in candidate.get_available_stages %}
|
||||
<li>
|
||||
<button type="submit" name="update_stage"
|
||||
class="dropdown-item btn-sm"
|
||||
formaction="?candidate_id={{ candidate.id }}&new_stage={{ next_stage }}">
|
||||
Move to {{ next_stage }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if candidate.stage == "Exam" %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#examModal{{ candidate.id }}">
|
||||
{% trans "Update Exam Status" %}
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-muted py-3">{% trans "No candidates in Tier 2" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tier 3 Tab -->
|
||||
<div class="tab-pane fade" id="tier3" role="tabpanel" aria-labelledby="tier3-tab">
|
||||
{% if tier3_candidates %}
|
||||
<div class="candidate-table-responsive">
|
||||
<table class="candidate-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Contact" %}</th>
|
||||
<th>{% trans "AI Score" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th>{% trans "Stage" %}</th>
|
||||
<th>{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for candidate in tier3_candidates %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="candidate-name">{{ candidate.name }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="candidate-details">
|
||||
Email: {{ candidate.email }}<br>
|
||||
Phone: {{ candidate.phone }}<br>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{{ candidate.match_score|default:"0" }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {% if candidate.applicant_status == 'Candidate' %}bg-success{% else %}bg-secondary{% endif %}">
|
||||
{{ candidate.get_applicant_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="stage-badge stage-{{ candidate.stage }}">
|
||||
{{ candidate.get_stage_display }}
|
||||
</span>
|
||||
{% if candidate.stage == "Exam" and candidate.exam_status %}
|
||||
<br>
|
||||
<span class="badge bg-info">{{ candidate.get_exam_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<!-- Individual actions -->
|
||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||
{% if candidate.applicant_status == 'Applicant' %}
|
||||
<button type="submit" name="mark_as_candidate"
|
||||
class="btn btn-sm btn-success"
|
||||
formaction="?candidate_id={{ candidate.id }}&action=mark_as_candidate"
|
||||
title="{% trans 'Mark as Candidate' %}">
|
||||
<i class="fas fa-user-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% for next_stage in candidate.get_available_stages %}
|
||||
<button type="submit" name="update_stage"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
formaction="?candidate_id={{ candidate.id }}&new_stage={{ next_stage }}"
|
||||
title="{% trans 'Move to' %} {{ next_stage }}">
|
||||
{{ next_stage }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% if candidate.stage == "Exam" %}
|
||||
<button type="button" class="btn btn-sm btn-outline-info"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#examModal{{ candidate.id }}"
|
||||
title="{% trans 'Update Exam Status' %}">
|
||||
<i class="fas fa-clipboard-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Dropdown for additional actions -->
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button"
|
||||
data-bs-toggle="dropdown">
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% for next_stage in candidate.get_available_stages %}
|
||||
<li>
|
||||
<button type="submit" name="update_stage"
|
||||
class="dropdown-item btn-sm"
|
||||
formaction="?candidate_id={{ candidate.id }}&new_stage={{ next_stage }}">
|
||||
Move to {{ next_stage }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if candidate.stage == "Exam" %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#examModal{{ candidate.id }}">
|
||||
{% trans "Update Exam Status" %}
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-muted py-3">{% trans "No candidates in Tier 3" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exam Status Update Modals -->
|
||||
{% for candidate in tier1_candidates|add:tier2_candidates|add:tier3_candidates %}
|
||||
{% if candidate.stage == "Exam" %}
|
||||
<div class="modal fade" id="examModal{{ candidate.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{% trans "Update Exam Status" %} - {{ candidate.name }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="candidate_id" value="{{ candidate.id }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Exam Status" %}</label>
|
||||
<select name="exam_status" class="form-select" required>
|
||||
<option value="">{% trans "Select Status" %}</option>
|
||||
<option value="Passed" {% if candidate.exam_status == "Passed" %}selected{% endif %}>
|
||||
{% trans "Passed" %}
|
||||
</option>
|
||||
<option value="Failed" {% if candidate.exam_status == "Failed" %}selected{% endif %}>
|
||||
{% trans "Failed" %}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Exam Date" %}</label>
|
||||
<input type="date" name="exam_date" class="form-control"
|
||||
value="{{ candidate.exam_date|date:"Y-m-d" }}">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
{% trans "Cancel" %}
|
||||
</button>
|
||||
<button type="submit" name="update_exam_status" class="btn btn-primary">
|
||||
{% trans "Update Status" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="modal fade modal-lg" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="candidateviewModalLabel">Form Settings</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="candidateviewModalBody" class="modal-body">
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block customJS %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
|
||||
<script>
|
||||
document.getElementById('tier1_count').addEventListener('change', function() {
|
||||
const max = {{ total_candidates }};
|
||||
if (this.value > max) {
|
||||
this.value = max;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock customJS %}
|
||||
Loading…
x
Reference in New Issue
Block a user