Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend
This commit is contained in:
commit
30acc14775
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,4 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@ -110,4 +110,3 @@ local_settings.py
|
||||
# If a rule in .gitignore ends with a directory separator (i.e. `/`
|
||||
# character), then remove the file in the remaining pattern string and all
|
||||
# files with the same name in subdirectories.
|
||||
>>>>>>> 29790ab (add external integration)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -55,7 +55,8 @@ INSTALLED_APPS = [
|
||||
'django_extensions',
|
||||
'template_partials',
|
||||
'django_countries',
|
||||
'django_celery_results'
|
||||
'django_celery_results',
|
||||
'django_q',
|
||||
]
|
||||
|
||||
SITE_ID = 1
|
||||
@ -223,129 +224,65 @@ 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',
|
||||
# ]
|
||||
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,
|
||||
},
|
||||
'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' }
|
||||
# ]
|
||||
# }
|
||||
# },
|
||||
# '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'
|
||||
},
|
||||
]
|
||||
{
|
||||
'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_CUSTOM_CSS = 'path_to.css' # optional
|
||||
CKEDITOR_5_FILE_STORAGE = "path_to_storage.CustomStorage" # optional
|
||||
CKEDITOR_5_CONFIGS = {
|
||||
'default': {
|
||||
'toolbar': {
|
||||
@ -354,7 +291,6 @@ CKEDITOR_5_CONFIGS = {
|
||||
}
|
||||
|
||||
},
|
||||
# Your existing 'extends' configuration remains unchanged
|
||||
'extends': {
|
||||
'blockToolbar': [
|
||||
'paragraph', 'heading1', 'heading2', 'heading3',
|
||||
@ -405,25 +341,14 @@ CKEDITOR_5_CONFIGS = {
|
||||
]
|
||||
}
|
||||
},
|
||||
# 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.
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Define a constant in settings.py to specify file upload permissions
|
||||
CKEDITOR_5_FILE_UPLOAD_PERMISSION = "staff" # Possible values: "staff", "authenticated", "any"
|
||||
BIN
db.sqlite3
BIN
db.sqlite3
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.
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']
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-11 11:04
|
||||
# Generated by Django 5.2.6 on 2025-10-12 10:34
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
@ -220,6 +220,7 @@ class Migration(migrations.Migration):
|
||||
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
|
||||
('applied', models.BooleanField(default=False, verbose_name='Applied')),
|
||||
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], default='Applied', max_length=100, verbose_name='Stage')),
|
||||
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')),
|
||||
('exam_date', models.DateField(blank=True, null=True, verbose_name='Exam Date')),
|
||||
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')),
|
||||
('interview_date', models.DateField(blank=True, null=True, verbose_name='Interview Date')),
|
||||
@ -321,9 +322,7 @@ class Migration(migrations.Migration):
|
||||
name='JobPostingImage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('post_image', models.ImageField(height_field='photo_height', upload_to='post/', width_field='photo_width')),
|
||||
('post_image_height', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('post_image_width', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('post_image', models.ImageField(upload_to='post/')),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')),
|
||||
],
|
||||
),
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-11 12:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='jobpostingimage',
|
||||
name='post_image_height',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='jobpostingimage',
|
||||
name='post_image_width',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='jobpostingimage',
|
||||
name='post_image',
|
||||
field=models.ImageField(upload_to='post/'),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@ -79,7 +79,7 @@ class JobPosting(Base):
|
||||
|
||||
# Job Details
|
||||
description = CKEditor5Field(
|
||||
'Description',
|
||||
'Description',
|
||||
config_name='extends' # Matches the config name you defined in settings.py
|
||||
)
|
||||
|
||||
@ -118,7 +118,7 @@ class JobPosting(Base):
|
||||
("ARCHIVED", "Archived"),
|
||||
]
|
||||
status = models.CharField(
|
||||
max_length=20, choices=STATUS_CHOICES, default="DRAFT", null=True, blank=True
|
||||
max_length=20, choices=STATUS_CHOICES, default="DRAFT"
|
||||
)
|
||||
|
||||
# hashtags for social media
|
||||
@ -250,7 +250,7 @@ class JobPosting(Base):
|
||||
class JobPostingImage(models.Model):
|
||||
job=models.ForeignKey('JobPosting',on_delete=models.CASCADE,related_name='post_images')
|
||||
post_image = models.ImageField(upload_to='post/')
|
||||
|
||||
|
||||
|
||||
class Candidate(Base):
|
||||
class Stage(models.TextChoices):
|
||||
@ -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