push bulk create meeting to to background and create zoom webhook

This commit is contained in:
ismail 2025-10-16 13:15:46 +03:00
parent 3682657527
commit a669564e6d
38 changed files with 1512 additions and 607 deletions

View File

@ -223,6 +223,7 @@ ZOOM_ACCOUNT_ID = 'HoGikHXsQB2GNDC5Rvyw9A'
ZOOM_CLIENT_ID = 'brC39920R8C8azfudUaQgA'
ZOOM_CLIENT_SECRET = 'rvfhjlbID4ychXPOvZ2lYsoAC0B0Ny2L'
SECRET_TOKEN = '6KdTGyF0SSCSL_V4Xa34aw'
ZOOM_WEBHOOK_API_KEY = "2GNDC5Rvyw9AHoGikHXsQB"
# Maximum file upload size (in bytes)
DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
@ -245,7 +246,7 @@ LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/jobs/linkedin/callback/'
Q_CLUSTER = {
'name': 'KAAUH_CLUSTER',
'workers': 4,
'workers': 8,
'recycle': 500,
'timeout': 60,
'compress': True,

View File

@ -28,6 +28,7 @@ urlpatterns = [
path('api/templates/save/', views.save_form_template, name='save_form_template'),
path('api/templates/<int:template_id>/', views.load_form_template, name='load_form_template'),
path('api/templates/<int:template_id>/delete/', views.delete_form_template, name='delete_form_template'),
path('api/webhook/',views.zoom_webhook_view,name='zoom_webhook_view')
]
urlpatterns += i18n_patterns(

View File

@ -197,8 +197,8 @@ class JobPostingForm(forms.ModelForm):
'location_city', 'location_state', 'location_country',
'description', 'qualifications', 'salary_range', 'benefits','application_start_date'
,'application_deadline', 'application_instructions',
'position_number', 'reporting_to', 'joining_date', 'status',
'created_by','open_positions','hash_tags'
'position_number', 'reporting_to', 'joining_date',
'created_by','open_positions','hash_tags','max_applications'
]
widgets = {
# Basic Information
@ -285,6 +285,11 @@ class JobPostingForm(forms.ModelForm):
'class': 'form-control',
'placeholder': 'University Administrator'
}),
'max_applications': forms.NumberInput(attrs={
'class': 'form-control',
'min': 1,
'placeholder': 'Maximum number of applicants'
}),
}
def __init__(self,*args,**kwargs):
@ -297,7 +302,7 @@ class JobPostingForm(forms.ModelForm):
if not self.instance.pk:# Creating new job posting
if not self.is_anonymous_user:
self.fields['created_by'].initial = 'University Administrator'
self.fields['status'].initial = 'Draft'
# self.fields['status'].initial = 'Draft'
self.fields['location_city'].initial='Riyadh'
self.fields['location_state'].initial='Riyadh Province'
self.fields['location_country'].initial='Saudi Arabia'
@ -409,64 +414,64 @@ class FormTemplateForm(forms.ModelForm):
Field('is_active', css_class='form-check-input'),
Submit('submit', _('Create Template'), css_class='btn btn-primary mt-3')
)
class BreakTimeForm(forms.ModelForm):
class Meta:
model = BreakTime
fields = ['start_time', 'end_time']
widgets = {
'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
}
# class BreakTimeForm(forms.ModelForm):
# class Meta:
# model = BreakTime
# fields = ['start_time', 'end_time']
# widgets = {
# 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
# 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
# }
BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
# BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
class InterviewScheduleForm(forms.ModelForm):
candidates = forms.ModelMultipleChoiceField(
queryset=Candidate.objects.none(),
widget=forms.CheckboxSelectMultiple,
required=True
)
working_days = forms.MultipleChoiceField(
choices=[
(0, 'Monday'),
(1, 'Tuesday'),
(2, 'Wednesday'),
(3, 'Thursday'),
(4, 'Friday'),
(5, 'Saturday'),
(6, 'Sunday'),
],
widget=forms.CheckboxSelectMultiple,
required=True
)
# class InterviewScheduleForm(forms.ModelForm):
# candidates = forms.ModelMultipleChoiceField(
# queryset=Candidate.objects.none(),
# widget=forms.CheckboxSelectMultiple,
# required=True
# )
# working_days = forms.MultipleChoiceField(
# choices=[
# (0, 'Monday'),
# (1, 'Tuesday'),
# (2, 'Wednesday'),
# (3, 'Thursday'),
# (4, 'Friday'),
# (5, 'Saturday'),
# (6, 'Sunday'),
# ],
# widget=forms.CheckboxSelectMultiple,
# required=True
# )
class Meta:
model = InterviewSchedule
fields = [
'candidates', 'start_date', 'end_date', 'working_days',
'start_time', 'end_time', 'interview_duration', 'buffer_time'
]
widgets = {
'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}),
'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}),
}
# class Meta:
# model = InterviewSchedule
# fields = [
# 'candidates', 'start_date', 'end_date', 'working_days',
# 'start_time', 'end_time', 'interview_duration', 'buffer_time'
# ]
# widgets = {
# 'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
# 'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
# 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
# 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
# 'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}),
# 'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}),
# }
def __init__(self, slug, *args, **kwargs):
super().__init__(*args, **kwargs)
# Filter candidates based on the selected job
self.fields['candidates'].queryset = Candidate.objects.filter(
job__slug=slug,
stage='Interview'
)
# def __init__(self, slug, *args, **kwargs):
# super().__init__(*args, **kwargs)
# # Filter candidates based on the selected job
# self.fields['candidates'].queryset = Candidate.objects.filter(
# job__slug=slug,
# stage='Interview'
# )
def clean_working_days(self):
working_days = self.cleaned_data.get('working_days')
# Convert string values to integers
return [int(day) for day in working_days]
# def clean_working_days(self):
# working_days = self.cleaned_data.get('working_days')
# # Convert string values to integers
# return [int(day) for day in working_days]
class JobPostingCancelReasonForm(forms.ModelForm):
@ -494,7 +499,74 @@ class CandidateExamDateForm(forms.ModelForm):
}
class BreakTimeForm(forms.Form):
"""
A simple Form used for the BreakTimeFormSet.
It is not a ModelForm because the data is stored directly in InterviewSchedule's JSONField,
not in a separate BreakTime model instance.
"""
start_time = forms.TimeField(
widget=forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
label="Start Time"
)
end_time = forms.TimeField(
widget=forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
label="End Time"
)
# Use the non-model form for the formset factory
BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
# --- InterviewScheduleForm remains unchanged ---
class InterviewScheduleForm(forms.ModelForm):
candidates = forms.ModelMultipleChoiceField(
queryset=Candidate.objects.none(),
widget=forms.CheckboxSelectMultiple,
required=True
)
working_days = forms.MultipleChoiceField(
choices=[
(0, 'Monday'), (1, 'Tuesday'), (2, 'Wednesday'), (3, 'Thursday'),
(4, 'Friday'), (5, 'Saturday'), (6, 'Sunday'),
],
widget=forms.CheckboxSelectMultiple,
required=True
)
class Meta:
model = InterviewSchedule
fields = [
'candidates', 'start_date', 'end_date', 'working_days',
'start_time', 'end_time', 'interview_duration', 'buffer_time',
'break_start_time', 'break_end_time'
]
widgets = {
'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}),
'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}),
'break_start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'break_end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
}
def __init__(self, slug, *args, **kwargs):
super().__init__(*args, **kwargs)
# Filter candidates based on the selected job
self.fields['candidates'].queryset = Candidate.objects.filter(
job__slug=slug,
stage='Interview'
)
def clean_working_days(self):
working_days = self.cleaned_data.get('working_days')
# Convert string values to integers
return [int(day) for day in working_days]
# --- ScheduleInterviewForCandiateForm remains unchanged ---
class ScheduleInterviewForCandiateForm(forms.ModelForm):
class Meta:
model = InterviewSchedule
fields = ['start_date', 'end_date', 'start_time', 'end_time', 'interview_duration', 'buffer_time']
@ -505,4 +577,6 @@ class ScheduleInterviewForCandiateForm(forms.ModelForm):
'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}),
'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}),
}
'break_start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'break_end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
}

1
recruitment/hooks.py Normal file
View File

@ -0,0 +1 @@

View File

@ -1,48 +0,0 @@
import os
import random
from django.core.management.base import BaseCommand
from faker import Faker
from recruitment.models import Job, Candidate
fake = Faker()
class Command(BaseCommand):
help = 'Generate 20 fake jobs and 50 fake candidates'
def handle(self, *args, **kwargs):
# Clear existing fake data (optional)
Job.objects.filter(title__startswith='Fake Job').delete()
Candidate.objects.filter(name__startswith='Candidate ').delete()
self.stdout.write("Creating fake jobs...")
jobs = []
for i in range(20):
job = Job.objects.create(
title=f"Fake Job {i+1}",
description_en=fake.paragraph(nb_sentences=5),
description_ar=fake.text(max_nb_chars=200),
is_published=True,
posted_to_linkedin=random.choice([True, False])
)
jobs.append(job)
self.stdout.write("Creating fake candidates...")
for i in range(50):
job = random.choice(jobs)
resume_path = f"resumes/fake_resume_{i+1}.pdf"
parsed = {
'name': fake.name(),
'skills': [fake.job() for _ in range(5)],
'summary': fake.text(max_nb_chars=300)
}
Candidate.objects.create(
job=job,
name=f"Candidate {i+1}",
email=fake.email(),
resume=resume_path, # You can create dummy files if needed
parsed_summary=str(parsed),
applied=random.choice([True, False])
)
self.stdout.write(self.style.SUCCESS("✔️ Successfully generated 20 jobs and 50 candidates"))

View File

@ -0,0 +1,156 @@
import uuid
import random
from datetime import date, timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from faker import Faker
from recruitment.models import JobPosting, Candidate, Source, FormTemplate
class Command(BaseCommand):
help = 'Seeds the database with initial JobPosting and Candidate data using Faker.'
def add_arguments(self, parser):
# Add argument for the number of jobs to create, default is 5
parser.add_argument(
'--jobs',
type=int,
help='The number of JobPostings to create.',
default=5,
)
# Add argument for the number of candidates to create, default is 20
parser.add_argument(
'--candidates',
type=int,
help='The number of Candidate applications to create.',
default=20,
)
def handle(self, *args, **options):
# Get the desired counts from command line arguments
jobs_count = options['jobs']
candidates_count = options['candidates']
# Initialize Faker
fake = Faker('en_US') # Using en_US for general data, can be changed if needed
self.stdout.write("--- Starting Database Seeding ---")
self.stdout.write(f"Preparing to create {jobs_count} jobs and {candidates_count} candidates.")
# 1. Clear existing data (Optional, but useful for clean seeding)
Candidate.objects.all().delete()
JobPosting.objects.all().delete()
Source.objects.all().delete()
self.stdout.write(self.style.WARNING("Existing JobPostings and Candidates cleared."))
# 2. Create Foreign Key dependency: Source
default_source, created = Source.objects.get_or_create(
name="Career Website",
defaults={'name': 'Career Website'}
)
self.stdout.write(f"Using Source: {default_source.name}")
# --- Helper Chooser Lists ---
JOB_TYPES = [choice[0] for choice in JobPosting.JOB_TYPES]
WORKPLACE_TYPES = [choice[0] for choice in JobPosting.WORKPLACE_TYPES]
STATUS_CHOICES = [choice[0] for choice in JobPosting.STATUS_CHOICES]
DEPARTMENTS = ["Technology", "Marketing", "Finance", "HR", "Sales", "Research", "Operations"]
REPORTING_TO = ["CTO", "HR Manager", "Department Head", "VP of Sales"]
# 3. Generate JobPostings
created_jobs = []
for i in range(jobs_count):
# Dynamic job details
title = fake.job()
department = random.choice(DEPARTMENTS)
is_faculty = random.random() < 0.1 # 10% chance of being a faculty job
job_type = "FACULTY" if is_faculty else random.choice([t for t in JOB_TYPES if t != "FACULTY"])
# Generate realistic salary range
base_salary = random.randint(50, 200) * 1000
salary_range = f"${base_salary:,.0f} - ${base_salary + random.randint(10, 50) * 1000:,.0f}"
# Random dates
start_date = fake.date_object()
deadline_date = start_date + timedelta(days=random.randint(14, 60))
joining_date = deadline_date + timedelta(days=random.randint(30, 90))
# Use Faker's HTML generation for CKEditor5 fields
description_html = f"<h1>{title} Role</h1>" + "".join(f"<p>{fake.paragraph(nb_sentences=3, variable_nb_sentences=True)}</p>" for _ in range(3))
qualifications_html = "<ul>" + "".join(f"<li>{fake.sentence(nb_words=6)}</li>" for _ in range(random.randint(3, 5))) + "</ul>"
benefits_html = f"<p>Standard benefits include: {fake.sentence(nb_words=8)}</p>"
instructions_html = f"<p>To apply, visit: {fake.url()} and follow the steps below.</p>"
job_data = {
"title": title,
"department": department,
"job_type": job_type,
"workplace_type": random.choice(WORKPLACE_TYPES),
"location_city": fake.city(),
"location_state": fake.state_abbr(),
"location_country": "Saudia Arabia",
"description": description_html,
"qualifications": qualifications_html,
"salary_range": salary_range,
"benefits": benefits_html,
"application_url": fake.url(),
"application_start_date": start_date,
"application_deadline": deadline_date,
"application_instructions": instructions_html,
"created_by": "Faker Script",
"status": random.choice(STATUS_CHOICES),
"hash_tags": f"#{department.lower().replace(' ', '')},#jobopening,#{fake.word()}",
"position_number": f"{department[:3].upper()}{random.randint(100, 999)}",
"reporting_to": random.choice(REPORTING_TO),
"joining_date": joining_date,
"open_positions": random.randint(1, 5),
"source": default_source,
"published_at": timezone.now() if random.random() < 0.7 else None,
}
job = JobPosting.objects.create(
**job_data
)
FormTemplate.objects.create(job=job, name=f"{job.title} Form", description=f"Form for {job.title}",is_active=True)
created_jobs.append(job)
self.stdout.write(self.style.SUCCESS(f'Created JobPosting {i+1}/{jobs_count}: {job.title}'))
# 4. Generate Candidates
if created_jobs:
for i in range(candidates_count):
# Link candidate to a random job
target_job = random.choice(created_jobs)
first_name = fake.first_name()
last_name = fake.last_name()
candidate_data = {
"first_name": first_name,
"last_name": last_name,
# Create a plausible email based on name
"email": f"{first_name.lower()}.{last_name.lower()}@{fake.domain_name()}",
"phone": fake.phone_number(),
"address": fake.address(),
# Placeholder resume path
'match_score': random.randint(0, 100),
"resume": f"resumes/{last_name.lower()}_{target_job.internal_job_id}_{fake.file_name(extension='pdf')}",
"job": target_job,
}
Candidate.objects.create(**candidate_data)
self.stdout.write(self.style.NOTICE(
f'Created Candidate {i+1}/{candidates_count}: {first_name} for {target_job.title[:30]}...'
))
else:
self.stdout.write(self.style.WARNING("No jobs created, skipping candidate generation."))
self.stdout.write(self.style.SUCCESS('\n--- Database Seeding Complete! ---'))
# Summary output
self.stdout.write(f"Total JobPostings created: {JobPosting.objects.count()}")
self.stdout.write(f"Total Candidates created: {Candidate.objects.count()}")

View File

@ -0,0 +1,33 @@
# Generated by Django 5.2.6 on 2025-10-15 10:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0013_alter_formtemplate_created_by'),
]
operations = [
migrations.AddField(
model_name='formtemplate',
name='close_at',
field=models.DateTimeField(blank=True, help_text='Date and time at which applications close', null=True),
),
migrations.AddField(
model_name='formtemplate',
name='max_applications',
field=models.PositiveIntegerField(default=1000, help_text='Maximum number of applications allowed'),
),
migrations.AlterField(
model_name='formtemplate',
name='created_at',
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name='formtemplate',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 5.2.6 on 2025-10-15 10:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0014_formtemplate_close_at_formtemplate_max_applications_and_more'),
]
operations = [
migrations.RemoveField(
model_name='formtemplate',
name='close_at',
),
migrations.AlterField(
model_name='formtemplate',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
),
migrations.AlterField(
model_name='formtemplate',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 5.2.6 on 2025-10-15 10:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0015_remove_formtemplate_close_at_and_more'),
]
operations = [
migrations.RemoveField(
model_name='formtemplate',
name='max_applications',
),
migrations.AddField(
model_name='jobposting',
name='max_applications',
field=models.PositiveIntegerField(default=1000, help_text='Maximum number of applications allowed'),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 5.2.6 on 2025-10-15 15:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0016_remove_formtemplate_max_applications_and_more'),
]
operations = [
migrations.RemoveField(
model_name='interviewschedule',
name='breaks',
),
migrations.AddField(
model_name='interviewschedule',
name='break_end',
field=models.TimeField(blank=True, null=True, verbose_name='Break End Time'),
),
migrations.AddField(
model_name='interviewschedule',
name='break_start',
field=models.TimeField(blank=True, null=True, verbose_name='Break Start Time'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.6 on 2025-10-15 15:55
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0017_remove_interviewschedule_breaks_and_more'),
]
operations = [
migrations.RenameField(
model_name='interviewschedule',
old_name='break_end',
new_name='break_end_time',
),
migrations.RenameField(
model_name='interviewschedule',
old_name='break_start',
new_name='break_start_time',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-10-15 16:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0018_rename_break_end_interviewschedule_break_end_time_and_more'),
]
operations = [
migrations.AlterField(
model_name='interviewschedule',
name='candidates',
field=models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-10-15 16:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0019_alter_interviewschedule_candidates'),
]
operations = [
migrations.AlterField(
model_name='interviewschedule',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
),
]

View File

@ -1,21 +1,15 @@
from django.db import models
from django.urls import reverse
from django.utils import timezone
from .validators import validate_hash_tags, validate_image_size
from django.db.models import JSONField
from django.contrib.auth.models import User
from django.core.validators import URLValidator
from django_countries.fields import CountryField
from django.core.exceptions import ValidationError
from django_ckeditor_5.fields import CKEditor5Field
from django.utils.translation import gettext_lazy as _
from django_extensions.db.fields import RandomCharField
from django.core.exceptions import ValidationError
from django_countries.fields import CountryField
from django.urls import reverse
# from ckeditor.fields import RichTextField
from django_ckeditor_5.fields import CKEditor5Field
class Profile(models.Model):
profile_image = models.ImageField(null=True, blank=True, upload_to="profile_pic/")
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
from .validators import validate_hash_tags, validate_image_size
class Base(models.Model):
@ -28,24 +22,9 @@ class Base(models.Model):
class Meta:
abstract = True
# # Create your models here.
# class Job(Base):
# title = models.CharField(max_length=255, verbose_name=_('Title'))
# description_en = models.TextField(verbose_name=_('Description English'))
# description_ar = models.TextField(verbose_name=_('Description Arabic'))
# is_published = models.BooleanField(default=False, verbose_name=_('Published'))
# posted_to_linkedin = models.BooleanField(default=False, verbose_name=_('Posted to LinkedIn'))
# created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at'))
# updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated at'))
# class Meta:
# verbose_name = _('Job')
# verbose_name_plural = _('Jobs')
# def __str__(self):
# return self.title
class Profile(models.Model):
profile_image = models.ImageField(null=True, blank=True, upload_to="profile_pic/")
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
class JobPosting(Base):
# Basic Job Information
@ -164,7 +143,9 @@ class JobPosting(Base):
blank=True,
help_text="The system or channel from which this job posting originated or was first published.",
)
max_applications = models.PositiveIntegerField(
default=1000, help_text="Maximum number of applications allowed"
)
hiring_agency = models.ManyToManyField(
"HiringAgency",
blank=True,
@ -246,6 +227,18 @@ class JobPosting(Base):
"form_wizard", kwargs={"slug": self.form_template.slug}
)
self.save()
@property
def current_applications_count(self):
"""Returns the current number of candidates associated with this job."""
return self.candidates.count()
@property
def is_application_limit_reached(self):
"""Checks if the current application count meets or exceeds the max limit."""
if self.max_applications == 0:
return True
return self.current_applications_count >= self.max_applications
class JobPostingImage(models.Model):
@ -375,43 +368,12 @@ class Candidate(Base):
return self.resume.size
return 0
# def clean(self):
# """Validate stage transitions"""
# # Only validate if this is an existing record (not being created)
# if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
# old_stage = self.__class__.objects.get(pk=self.pk).stage
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
# if self.stage not in allowed_next_stages:
# raise ValidationError(
# {
# "stage": f'Cannot transition from "{old_stage}" to "{self.stage}". '
# f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}"
# }
# )
# # Validate that the stage is a valid choice
# if self.stage not in [choice[0] for choice in self.Stage.choices]:
# raise ValidationError(
# {
# "stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}"
# }
# )
def save(self, *args, **kwargs):
"""Override save to ensure validation is called"""
self.clean() # Call validation before saving
super().save(*args, **kwargs)
# def can_transition_to(self, new_stage):
# """Check if a stage transition is allowed"""
# if not self.pk: # New record - can be in Applied stage
# return new_stage == "Applied"
# old_stage = self.__class__.objects.get(pk=self.pk).stage
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
# return new_stage in allowed_next_stages
def get_available_stages(self):
"""Get list of stages this candidate can transition to"""
if not self.pk: # New record
@ -489,6 +451,7 @@ class ZoomMeeting(Base):
SCHEDULED = "scheduled", _("Scheduled")
STARTED = "started", _("Started")
ENDED = "ended", _("Ended")
CANCELLED = "cancelled",_("Cancelled")
# Basic meeting details
topic = models.CharField(max_length=255, verbose_name=_("Topic"))
meeting_id = models.CharField(
@ -989,7 +952,7 @@ class InterviewSchedule(Base):
job = models.ForeignKey(
JobPosting, on_delete=models.CASCADE, related_name="interview_schedules"
)
candidates = models.ManyToManyField(Candidate, related_name="interview_schedules")
candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True,null=True)
start_date = models.DateField(verbose_name=_("Start Date"))
end_date = models.DateField(verbose_name=_("End Date"))
working_days = models.JSONField(
@ -998,7 +961,8 @@ class InterviewSchedule(Base):
start_time = models.TimeField(verbose_name=_("Start Time"))
end_time = models.TimeField(verbose_name=_("End Time"))
breaks = models.JSONField(default=list, blank=True, verbose_name=_('Break Times'))
break_start_time = models.TimeField(verbose_name=_("Break Start Time"),null=True,blank=True)
break_end_time = models.TimeField(verbose_name=_("Break End Time"),null=True,blank=True)
interview_duration = models.PositiveIntegerField(
verbose_name=_("Interview Duration (minutes)")
@ -1007,7 +971,6 @@ class InterviewSchedule(Base):
verbose_name=_("Buffer Time (minutes)"), default=0
)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Interview Schedule for {self.job.title}"
@ -1030,6 +993,7 @@ class ScheduledInterview(Base):
schedule = models.ForeignKey(
InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True
)
interview_date = models.DateField(verbose_name=_("Interview Date"))
interview_time = models.TimeField(verbose_name=_("Interview Time"))
status = models.CharField(

View File

@ -3,7 +3,10 @@ import json
import logging
import requests
from PyPDF2 import PdfReader
from datetime import datetime
from .utils import create_zoom_meeting
from recruitment.models import Candidate
from .models import ScheduledInterview, ZoomMeeting, Candidate, JobPosting, InterviewSchedule
logger = logging.getLogger(__name__)
@ -153,3 +156,124 @@ def handle_reume_parsing_and_scoring(pk):
except Exception as e:
logger.error(f"Failed to score resume for candidate {instance.id}: {e}")
def create_interview_and_meeting(
candidate_id,
job_id,
schedule_id,
slot_date,
slot_time,
duration
):
"""
Synchronous task for a single interview slot, dispatched by django-q.
"""
try:
candidate = Candidate.objects.get(pk=candidate_id)
job = JobPosting.objects.get(pk=job_id)
schedule = InterviewSchedule.objects.get(pk=schedule_id)
interview_datetime = datetime.combine(slot_date, slot_time)
meeting_topic = f"Interview for {job.title} - {candidate.name}"
# 1. External API Call (Slow)
result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
if result["status"] == "success":
# 2. Database Writes (Slow)
zoom_meeting = ZoomMeeting.objects.create(
topic=meeting_topic,
start_time=interview_datetime,
duration=duration,
meeting_id=result["meeting_details"]["meeting_id"],
join_url=result["meeting_details"]["join_url"],
zoom_gateway_response=result["zoom_gateway_response"],
)
ScheduledInterview.objects.create(
candidate=candidate,
job=job,
zoom_meeting=zoom_meeting,
schedule=schedule,
interview_date=slot_date,
interview_time=slot_time
)
# Log success or use Django-Q result system for monitoring
logger.info(f"Successfully scheduled interview for {candidate.name}")
return True # Task succeeded
else:
# Handle Zoom API failure (e.g., log it or notify administrator)
logger.error(f"Zoom API failed for {candidate.name}: {result['message']}")
return False # Task failed
except Exception as e:
# Catch any unexpected errors during database lookups or processing
logger.error(f"Critical error scheduling interview: {e}")
return False # Task failed
def handle_zoom_webhook_event(payload):
"""
Background task to process a Zoom webhook event and update the local ZoomMeeting status.
It handles: created, updated, started, ended, and deleted events.
"""
event_type = payload.get('event')
object_data = payload['payload']['object']
# Zoom often uses a long 'id' for the scheduled meeting and sometimes a 'uuid'.
# We rely on the unique 'id' that maps to your ZoomMeeting.meeting_id field.
meeting_id_zoom = str(object_data.get('id'))
print(meeting_id_zoom)
if not meeting_id_zoom:
logger.warning(f"Webhook received without a valid Meeting ID: {event_type}")
return False
try:
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
# and to simplify the logic flow.
meeting_instance = ZoomMeeting.objects.filter(meeting_id=meeting_id_zoom).first()
print(meeting_instance)
# --- 1. Creation and Update Events ---
if event_type == 'meeting.updated':
if meeting_instance:
# Update key fields from the webhook payload
meeting_instance.topic = object_data.get('topic', meeting_instance.topic)
# Check for and update status and time details
# if event_type == 'meeting.created':
# meeting_instance.status = 'scheduled'
# elif event_type == 'meeting.updated':
# Only update time fields if they are in the payload
print(object_data)
meeting_instance.start_time = object_data.get('start_time', meeting_instance.start_time)
meeting_instance.duration = object_data.get('duration', meeting_instance.duration)
meeting_instance.timezone = object_data.get('timezone', meeting_instance.timezone)
# Also update join_url, password, etc., if needed based on the payload structure
meeting_instance.status = 'scheduled'
meeting_instance.save(update_fields=['topic', 'start_time', 'duration', 'timezone', 'status'])
# --- 2. Status Change Events (Start/End) ---
elif event_type == 'meeting.started':
if meeting_instance:
meeting_instance.status = 'started'
meeting_instance.save(update_fields=['status'])
elif event_type == 'meeting.ended':
if meeting_instance:
meeting_instance.status = 'ended'
meeting_instance.save(update_fields=['status'])
# --- 3. Deletion Event (User Action) ---
elif event_type == 'meeting.deleted':
if meeting_instance:
# Mark as cancelled/deleted instead of physically deleting for audit trail
meeting_instance.status = 'cancelled'
meeting_instance.save(update_fields=['status'])
return True
except Exception as e:
logger.error(f"Failed to process Zoom webhook for {event_type} (ID: {meeting_id_zoom}): {e}", exc_info=True)
return False

View File

@ -72,3 +72,12 @@ def to_list(data):
Usage: {% to_list "item1,item2,item3" as list %}
"""
return data.split(",") if data else []
@register.filter
def get_schedule_candidate_ids(session, slug):
"""
Retrieves the list of candidate IDs stored in the session for a specific job slug.
"""
session_key = f"schedule_candidate_ids_{slug}"
# Returns the list of IDs (or an empty list if not found)
return session.get(session_key, [])

View File

@ -23,6 +23,7 @@ urlpatterns = [
path('jobs/linkedin/callback/', views.linkedin_callback, name='linkedin_callback'),
path('jobs/<slug:slug>/schedule-interviews/', views.schedule_interviews_view, name='schedule_interviews'),
path('jobs/<slug:slug>/confirm-schedule-interviews/', views.confirm_schedule_interviews_view, name='confirm_schedule_interviews_view'),
# Candidate URLs
path('candidates/', views_frontend.CandidateListView.as_view(), name='candidate_list'),
path('candidates/create/', views_frontend.CandidateCreateView.as_view(), name='candidate_create'),
@ -106,5 +107,5 @@ urlpatterns = [
path('jobs/<slug:slug>/candidates/<int:candidate_pk>/delete_meeting_for_candidate/<int:meeting_id>/', views.delete_meeting_for_candidate, name='delete_meeting_for_candidate'),
# users urls
path('user/<int:pk>',views.user_detail,name='user_detail')
path('user/<int:pk>',views.user_detail,name='user_detail'),
]

View File

@ -416,13 +416,12 @@ def schedule_interviews(schedule):
Returns the number of interviews successfully scheduled.
"""
candidates = list(schedule.candidates.all())
print(candidates)
if not candidates:
return 0
# Calculate available time slots
available_slots = get_available_time_slots(schedule)
print(available_slots)
if len(available_slots) < len(candidates):
raise ValueError(f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}")

View File

@ -59,6 +59,8 @@ from datastar_py.django import (
ServerSentEventGenerator as SSE,
read_signals,
)
from django.db import transaction
from django_q.tasks import async_task
logger = logging.getLogger(__name__)
@ -91,7 +93,6 @@ class ZoomMeetingCreateView(CreateView):
duration = instance.duration
result = create_zoom_meeting(topic, start_time, duration)
print(result)
if result["status"] == "success":
instance.meeting_id = result["meeting_details"]["meeting_id"]
instance.join_url = result["meeting_details"]["join_url"]
@ -119,18 +120,44 @@ class ZoomMeetingListView(ListView):
def get_queryset(self):
queryset = super().get_queryset().order_by("-start_time")
# Handle search
search_query = self.request.GET.get("search", "")
# Prefetch related interview data efficiently
from django.db.models import Prefetch
queryset = queryset.prefetch_related(
Prefetch(
'interview', # related_name from ZoomMeeting to ScheduledInterview
queryset=ScheduledInterview.objects.select_related('candidate', 'job'),
to_attr='interview_details' # Changed to not start with underscore
)
)
# Handle search by topic or meeting_id
search_query = self.request.GET.get("q", "") # Renamed from 'search' to 'q' for consistency
if search_query:
queryset = queryset.filter(
Q(topic__icontains=search_query) | Q(meeting_id__icontains=search_query)
)
# Handle filter by status
status_filter = self.request.GET.get("status", "")
if status_filter:
queryset = queryset.filter(status=status_filter)
# Handle search by candidate name
candidate_name = self.request.GET.get("candidate_name", "")
if candidate_name:
# Filter based on the name of the candidate associated with the meeting's interview
queryset = queryset.filter(
Q(interview__candidate__first_name__icontains=candidate_name) |
Q(interview__candidate__last_name__icontains=candidate_name)
)
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["search_query"] = self.request.GET.get("search", "")
context["search_query"] = self.request.GET.get("q", "")
context["status_filter"] = self.request.GET.get("status", "")
context["candidate_name_filter"] = self.request.GET.get("candidate_name", "")
return context
@ -922,27 +949,18 @@ def form_wizard_view(request, template_id):
@require_POST
def submit_form(request, template_id):
"""Handle form submission"""
print(f"Request method: {request}")
print(f"CSRF token in POST: {'csrfmiddlewaretoken' in request.POST}")
print(f"CSRF token value: {request.POST.get('csrfmiddlewaretoken', 'NOT FOUND')}")
print(f"POST data: {request.POST}")
print(f"FILES data: {request.FILES}")
template = get_object_or_404(FormTemplate, id=template_id)
if request.method == "POST":
try:
template = get_object_or_404(FormTemplate, id=template_id)
with transaction.atomic():
job_posting = JobPosting.objects.select_for_update().get(form_template=template)
# # Create form submission
# print({key: value for key, value in request.POST.items()})
# first_name = next((value for key, value in request.POST.items() if key == 'First Name'), None)
# last_name = next((value for key, value in request.POST.items() if key == 'Last Name'), None)
# email = next((value for key, value in request.POST.items() if key == 'Email Address'), None)
# phone = next((value for key, value in request.POST.items() if key == 'Phone Number'), None)
# address = next((value for key, value in request.POST.items() if key == 'Address'), None)
# resume = next((value for key, value in request.POST.items() if key == 'Resume Upload'), None)
# print(first_name, last_name, email, phone, address, resume)
# create candidate
submission = FormSubmission.objects.create(template=template)
current_count = job_posting.candidates.count()
if current_count >= job_posting.max_applications:
return JsonResponse(
{"success": False, "message": "Application limit reached for this job."}
)
submission = FormSubmission.objects.create(template=template)
# Process field responses
for field_id, value in request.POST.items():
if field_id.startswith("field_"):
@ -1099,237 +1117,577 @@ def form_submission_details(request, template_id, slug):
},
)
def schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
if request.method == "POST":
form = InterviewScheduleForm(slug, request.POST)
break_formset = BreakTimeFormSet(request.POST)
def _handle_get_request(request, slug, job):
"""
Handles GET requests, setting up forms and restoring candidate selections
from the session for persistence.
"""
SESSION_KEY = f"schedule_candidate_ids_{slug}"
form = InterviewScheduleForm(slug=slug)
# break_formset = BreakTimeFormSet(prefix='breaktime')
# Check if this is a confirmation request
if "confirm_schedule" in request.POST:
# Get the schedule data from session
schedule_data = request.session.get("interview_schedule_data")
if not schedule_data:
messages.error(request, "Session expired. Please try again.")
return redirect("schedule_interviews", slug=slug)
selected_ids = []
# Create the interview schedule
schedule = InterviewSchedule.objects.create(
job=job,
created_by=request.user,
start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
working_days=schedule_data["working_days"],
start_time=time.fromisoformat(schedule_data["start_time"]),
end_time=time.fromisoformat(schedule_data["end_time"]),
interview_duration=schedule_data["interview_duration"],
buffer_time=schedule_data["buffer_time"],
breaks=schedule_data["breaks"],
)
# 1. Capture IDs from HTMX request and store in session (when first clicked)
if "HX-Request" in request.headers:
candidate_ids = request.GET.getlist("candidate_ids")
if candidate_ids:
request.session[SESSION_KEY] = candidate_ids
selected_ids = candidate_ids
# Add candidates to the schedule
candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"])
schedule.candidates.set(candidates)
# 2. Restore IDs from session (on refresh or navigation)
if not selected_ids:
selected_ids = request.session.get(SESSION_KEY, [])
# Create temporary break time objects for slot calculation
temp_breaks = []
for break_data in schedule_data["breaks"]:
temp_breaks.append(
BreakTime(
start_time=datetime.strptime(
break_data["start_time"], "%H:%M:%S"
).time(),
end_time=datetime.strptime(
break_data["end_time"], "%H:%M:%S"
).time(),
)
)
# Get available slots
available_slots = get_available_time_slots(schedule)
# Create scheduled interviews
scheduled_count = 0
for i, candidate in enumerate(candidates):
if i < len(available_slots):
slot = available_slots[i]
interview_datetime = datetime.combine(slot['date'], slot['time'])
# Create Zoom meeting
meeting_topic = f"Interview for {job.title} - {candidate.name}"
start_time = interview_datetime
# zoom_meeting = create_zoom_meeting(
# topic=meeting_topic,
# start_time=start_time,
# duration=schedule.interview_duration
# )
result = create_zoom_meeting(meeting_topic, start_time, schedule.interview_duration)
if result["status"] == "success":
zoom_meeting = ZoomMeeting.objects.create(
topic=meeting_topic,
start_time=interview_datetime,
duration=schedule.interview_duration,
meeting_id=result["meeting_details"]["meeting_id"],
join_url=result["meeting_details"]["join_url"],
zoom_gateway_response=result["zoom_gateway_response"],
)
# Create scheduled interview record
ScheduledInterview.objects.create(
candidate=candidate,
job=job,
zoom_meeting=zoom_meeting,
schedule=schedule,
interview_date=slot['date'],
interview_time=slot['time']
)
else:
messages.error(request, result["message"])
schedule.delete()
return redirect("candidate_interview_view", slug=slug)
# Send email to candidate
# try:
# send_interview_email(scheduled_interview)
# except Exception as e:
# messages.warning(
# request,
# f"Interview scheduled for {candidate.name}, but failed to send email: {str(e)}"
# )
scheduled_count += 1
messages.success(
request, f"Successfully scheduled {scheduled_count} interviews."
)
# Clear the session data
if "interview_schedule_data" in request.session:
del request.session["interview_schedule_data"]
return redirect("job_detail", slug=slug)
# This is the initial form submission
if form.is_valid() and break_formset.is_valid():
# Get the form data
candidates = form.cleaned_data["candidates"]
start_date = form.cleaned_data["start_date"]
end_date = form.cleaned_data["end_date"]
working_days = form.cleaned_data["working_days"]
start_time = form.cleaned_data["start_time"]
end_time = form.cleaned_data["end_time"]
interview_duration = form.cleaned_data["interview_duration"]
buffer_time = form.cleaned_data["buffer_time"]
# Process break times
breaks = []
for break_form in break_formset:
if break_form.cleaned_data and not break_form.cleaned_data.get(
"DELETE"
):
breaks.append(
{
"start_time": break_form.cleaned_data[
"start_time"
].strftime("%H:%M:%S"),
"end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"),
}
)
# Create a temporary schedule object (not saved to DB)
temp_schedule = InterviewSchedule(
job=job,
start_date=start_date,
end_date=end_date,
working_days=working_days,
start_time=start_time,
end_time=end_time,
interview_duration=interview_duration,
buffer_time=buffer_time,
breaks=breaks,
)
# Create temporary break time objects
temp_breaks = []
for break_data in breaks:
temp_breaks.append(
BreakTime(
start_time=datetime.strptime(
break_data["start_time"], "%H:%M:%S"
).time(),
end_time=datetime.strptime(
break_data["end_time"], "%H:%M:%S"
).time(),
)
)
# Get available slots
available_slots = get_available_time_slots(temp_schedule)
if len(available_slots) < len(candidates):
messages.error(
request,
f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}",
)
return render(
request,
"interviews/schedule_interviews.html",
{"form": form, "break_formset": break_formset, "job": job},
)
# Create a preview schedule
preview_schedule = []
for i, candidate in enumerate(candidates):
slot = available_slots[i]
preview_schedule.append(
{"candidate": candidate, "date": slot["date"], "time": slot["time"]}
)
# Save the form data to session for later use
schedule_data = {
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"working_days": working_days,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"interview_duration": interview_duration,
"buffer_time": buffer_time,
"candidate_ids": [c.id for c in candidates],
"breaks": breaks,
}
request.session["interview_schedule_data"] = schedule_data
# Render the preview page
return render(
request,
"interviews/preview_schedule.html",
{
"job": job,
"schedule": preview_schedule,
"start_date": start_date,
"end_date": end_date,
"working_days": working_days,
"start_time": start_time,
"end_time": end_time,
"breaks": breaks,
"interview_duration": interview_duration,
"buffer_time": buffer_time,
},
)
else:
form = InterviewScheduleForm(slug=slug)
break_formset = BreakTimeFormSet()
if "HX-Request" in request.headers:
candidate_ids = request.GET.getlist("candidate_ids")
form.initial["candidates"] = Candidate.objects.filter(pk__in = candidate_ids)
# 3. Use the list of IDs to initialize the form
if selected_ids:
candidates_to_load = Candidate.objects.filter(pk__in=selected_ids)
form.initial["candidates"] = candidates_to_load
return render(
request,
"interviews/schedule_interviews.html",
{"form": form, "break_formset": break_formset, "job": job},
{"form": form, "job": job},
)
def _handle_preview_submission(request, slug, job):
"""
Handles the initial POST request (Preview Schedule).
Validates forms, calculates slots, saves data to session, and renders preview.
"""
SESSION_DATA_KEY = "interview_schedule_data"
form = InterviewScheduleForm(slug, request.POST)
# break_formset = BreakTimeFormSet(request.POST,prefix='breaktime')
if form.is_valid():
# Get the form data
candidates = form.cleaned_data["candidates"]
start_date = form.cleaned_data["start_date"]
end_date = form.cleaned_data["end_date"]
working_days = form.cleaned_data["working_days"]
start_time = form.cleaned_data["start_time"]
end_time = form.cleaned_data["end_time"]
interview_duration = form.cleaned_data["interview_duration"]
buffer_time = form.cleaned_data["buffer_time"]
break_start_time = form.cleaned_data["break_start_time"]
break_end_time = form.cleaned_data["break_end_time"]
# Process break times
# breaks = []
# for break_form in break_formset:
# print(break_form.cleaned_data)
# if break_form.cleaned_data and not break_form.cleaned_data.get("DELETE"):
# breaks.append(
# {
# "start_time": break_form.cleaned_data["start_time"].strftime("%H:%M:%S"),
# "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"),
# }
# )
# Create a temporary schedule object (not saved to DB)
temp_schedule = InterviewSchedule(
job=job,
start_date=start_date,
end_date=end_date,
working_days=working_days,
start_time=start_time,
end_time=end_time,
interview_duration=interview_duration,
buffer_time=buffer_time,
break_start_time=break_start_time,
break_end_time=break_end_time
)
# Get available slots (temp_breaks logic moved into get_available_time_slots if needed)
available_slots = get_available_time_slots(temp_schedule)
if len(available_slots) < len(candidates):
messages.error(
request,
f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}",
)
return render(
request,
"interviews/schedule_interviews.html",
{"form": form, "job": job},
)
# Create a preview schedule
preview_schedule = []
for i, candidate in enumerate(candidates):
slot = available_slots[i]
preview_schedule.append(
{"candidate": candidate, "date": slot["date"], "time": slot["time"]}
)
# Save the form data to session for later use
schedule_data = {
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"working_days": working_days,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"interview_duration": interview_duration,
"buffer_time": buffer_time,
"break_start_time": break_start_time.isoformat(),
"break_end_time": break_end_time.isoformat(),
"candidate_ids": [c.id for c in candidates],
}
request.session[SESSION_DATA_KEY] = schedule_data
# Render the preview page
return render(
request,
"interviews/preview_schedule.html",
{
"job": job,
"schedule": preview_schedule,
"start_date": start_date,
"end_date": end_date,
"working_days": working_days,
"start_time": start_time,
"end_time": end_time,
"break_start_time": break_start_time,
"break_end_time": break_end_time,
"interview_duration": interview_duration,
"buffer_time": buffer_time,
},
)
else:
# Re-render the form if validation fails
return render(
request,
"interviews/schedule_interviews.html",
{"form": form, "job": job},
)
def _handle_confirm_schedule(request, slug, job):
"""
Handles the final POST request (Confirm Schedule).
Creates the main schedule record and queues individual interviews asynchronously.
"""
SESSION_DATA_KEY = "interview_schedule_data"
SESSION_ID_KEY = f"schedule_candidate_ids_{slug}"
# 1. Get schedule data from session
schedule_data = request.session.get(SESSION_DATA_KEY)
if not schedule_data:
messages.error(request, "Session expired. Please try again.")
return redirect("schedule_interviews", slug=slug)
# 2. Create the Interview Schedule (Parent Record)
# NOTE: You MUST convert the time strings back to Python time objects here.
try:
schedule = InterviewSchedule.objects.create(
job=job,
created_by=request.user,
start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
working_days=schedule_data["working_days"],
start_time=time.fromisoformat(schedule_data["start_time"]),
end_time=time.fromisoformat(schedule_data["end_time"]),
interview_duration=schedule_data["interview_duration"],
buffer_time=schedule_data["buffer_time"],
# Use the simple break times saved in the session
# If the value is None (because required=False in form), handle it gracefully
break_start_time=schedule_data.get("break_start_time"),
break_end_time=schedule_data.get("break_end_time"),
)
except Exception as e:
# Handle database creation error
messages.error(request, f"Error creating schedule: {e}")
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
return redirect("schedule_interviews", slug=slug)
# 3. Setup candidates and get slots
candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"])
schedule.candidates.set(candidates)
available_slots = get_available_time_slots(schedule) # This should still be synchronous and fast
# 4. Queue scheduled interviews asynchronously (FAST RESPONSE)
queued_count = 0
for i, candidate in enumerate(candidates):
if i < len(available_slots):
slot = available_slots[i]
# Dispatch the individual creation task to the background queue
async_task(
"recruitment.tasks.create_interview_and_meeting",
candidate.pk,
job.pk,
schedule.pk,
slot['date'],
slot['time'],
schedule.interview_duration,
)
queued_count += 1
# 5. Success and Cleanup (IMMEDIATE RESPONSE)
messages.success(
request,
f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!"
)
# Clear both session data keys upon successful completion
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
return redirect("job_detail", slug=slug)
# def _handle_confirm_schedule(request, slug, job):
# """
# Handles the final POST request (Confirm Schedule).
# Creates all database records (Schedule, Meetings, Interviews) and clears sessions.
# """
# SESSION_DATA_KEY = "interview_schedule_data"
# SESSION_ID_KEY = f"schedule_candidate_ids_{slug}"
# # 1. Get schedule data from session
# schedule_data = request.session.get(SESSION_DATA_KEY)
# if not schedule_data:
# messages.error(request, "Session expired. Please try again.")
# return redirect("schedule_interviews", slug=slug)
# # 2. Create the Interview Schedule (Your existing logic)
# schedule = InterviewSchedule.objects.create(
# job=job,
# created_by=request.user,
# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
# working_days=schedule_data["working_days"],
# start_time=time.fromisoformat(schedule_data["start_time"]),
# end_time=time.fromisoformat(schedule_data["end_time"]),
# interview_duration=schedule_data["interview_duration"],
# buffer_time=schedule_data["buffer_time"],
# break_start_time=schedule_data["break_start_time"],
# break_end_time=schedule_data["break_end_time"],
# )
# # 3. Setup candidates and get slots
# candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"])
# schedule.candidates.set(candidates)
# available_slots = get_available_time_slots(schedule)
# # 4. Create scheduled interviews
# scheduled_count = 0
# for i, candidate in enumerate(candidates):
# if i < len(available_slots):
# slot = available_slots[i]
# interview_datetime = datetime.combine(slot['date'], slot['time'])
# meeting_topic = f"Interview for {job.title} - {candidate.name}"
# result = create_zoom_meeting(meeting_topic, interview_datetime, schedule.interview_duration)
# if result["status"] == "success":
# zoom_meeting = ZoomMeeting.objects.create(
# topic=meeting_topic,
# start_time=interview_datetime,
# duration=schedule.interview_duration,
# meeting_id=result["meeting_details"]["meeting_id"],
# join_url=result["meeting_details"]["join_url"],
# zoom_gateway_response=result["zoom_gateway_response"],
# )
# ScheduledInterview.objects.create(
# candidate=candidate,
# job=job,
# zoom_meeting=zoom_meeting,
# schedule=schedule,
# interview_date=slot['date'],
# interview_time=slot['time']
# )
# scheduled_count += 1
# else:
# messages.error(request, result["message"])
# schedule.delete()
# # Clear candidate IDs session key only on error return
# if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
# return redirect("candidate_interview_view", slug=slug)
# # 5. Success and Cleanup
# messages.success(
# request, f"Successfully scheduled {scheduled_count} interviews."
# )
# # Clear both session data keys upon successful completion
# if SESSION_DATA_KEY in request.session:
# del request.session[SESSION_DATA_KEY]
# if SESSION_ID_KEY in request.session:
# del request.session[SESSION_ID_KEY]
# return redirect("job_detail", slug=slug)
# --- Main View Function ---
def schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
if request.method == "POST":
# return _handle_confirm_schedule(request, slug, job)
return _handle_preview_submission(request, slug, job)
else:
return _handle_get_request(request, slug, job)
def confirm_schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
if request.method == "POST":
return _handle_confirm_schedule(request, slug, job)
# def schedule_interviews_view(request, slug):
# job = get_object_or_404(JobPosting, slug=slug)
# SESSION_KEY = f"schedule_candidate_ids_{slug}"
# if request.method == "POST":
# form = InterviewScheduleForm(slug, request.POST)
# break_formset = BreakTimeFormSet(request.POST)
# # Check if this is a confirmation request
# if "confirm_schedule" in request.POST:
# # Get the schedule data from session
# schedule_data = request.session.get("interview_schedule_data")
# if not schedule_data:
# messages.error(request, "Session expired. Please try again.")
# return redirect("schedule_interviews", slug=slug)
# # Create the interview schedule
# schedule = InterviewSchedule.objects.create(
# job=job,
# created_by=request.user,
# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
# working_days=schedule_data["working_days"],
# start_time=time.fromisoformat(schedule_data["start_time"]),
# end_time=time.fromisoformat(schedule_data["end_time"]),
# interview_duration=schedule_data["interview_duration"],
# buffer_time=schedule_data["buffer_time"],
# breaks=schedule_data["breaks"],
# )
# # Add candidates to the schedule
# candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"])
# schedule.candidates.set(candidates)
# # Create temporary break time objects for slot calculation
# temp_breaks = []
# for break_data in schedule_data["breaks"]:
# temp_breaks.append(
# BreakTime(
# start_time=datetime.strptime(
# break_data["start_time"], "%H:%M:%S"
# ).time(),
# end_time=datetime.strptime(
# break_data["end_time"], "%H:%M:%S"
# ).time(),
# )
# )
# # Get available slots
# available_slots = get_available_time_slots(schedule)
# # Create scheduled interviews
# scheduled_count = 0
# for i, candidate in enumerate(candidates):
# if i < len(available_slots):
# slot = available_slots[i]
# interview_datetime = datetime.combine(slot['date'], slot['time'])
# # Create Zoom meeting
# meeting_topic = f"Interview for {job.title} - {candidate.name}"
# start_time = interview_datetime
# # zoom_meeting = create_zoom_meeting(
# # topic=meeting_topic,
# # start_time=start_time,
# # duration=schedule.interview_duration
# # )
# result = create_zoom_meeting(meeting_topic, start_time, schedule.interview_duration)
# if result["status"] == "success":
# zoom_meeting = ZoomMeeting.objects.create(
# topic=meeting_topic,
# start_time=interview_datetime,
# duration=schedule.interview_duration,
# meeting_id=result["meeting_details"]["meeting_id"],
# join_url=result["meeting_details"]["join_url"],
# zoom_gateway_response=result["zoom_gateway_response"],
# )
# # Create scheduled interview record
# ScheduledInterview.objects.create(
# candidate=candidate,
# job=job,
# zoom_meeting=zoom_meeting,
# schedule=schedule,
# interview_date=slot['date'],
# interview_time=slot['time']
# )
# else:
# messages.error(request, result["message"])
# schedule.delete()
# return redirect("candidate_interview_view", slug=slug)
# # Send email to candidate
# # try:
# # send_interview_email(scheduled_interview)
# # except Exception as e:
# # messages.warning(
# # request,
# # f"Interview scheduled for {candidate.name}, but failed to send email: {str(e)}"
# # )
# scheduled_count += 1
# messages.success(
# request, f"Successfully scheduled {scheduled_count} interviews."
# )
# # Clear the session data
# if "interview_schedule_data" in request.session:
# del request.session["interview_schedule_data"]
# return redirect("job_detail", slug=slug)
# # This is the initial form submission
# if form.is_valid() and break_formset.is_valid():
# # Get the form data
# candidates = form.cleaned_data["candidates"]
# start_date = form.cleaned_data["start_date"]
# end_date = form.cleaned_data["end_date"]
# working_days = form.cleaned_data["working_days"]
# start_time = form.cleaned_data["start_time"]
# end_time = form.cleaned_data["end_time"]
# interview_duration = form.cleaned_data["interview_duration"]
# buffer_time = form.cleaned_data["buffer_time"]
# # Process break times
# breaks = []
# for break_form in break_formset:
# if break_form.cleaned_data and not break_form.cleaned_data.get(
# "DELETE"
# ):
# breaks.append(
# {
# "start_time": break_form.cleaned_data[
# "start_time"
# ].strftime("%H:%M:%S"),
# "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"),
# }
# )
# # Create a temporary schedule object (not saved to DB)
# temp_schedule = InterviewSchedule(
# job=job,
# start_date=start_date,
# end_date=end_date,
# working_days=working_days,
# start_time=start_time,
# end_time=end_time,
# interview_duration=interview_duration,
# buffer_time=buffer_time,
# breaks=breaks,
# )
# # Create temporary break time objects
# temp_breaks = []
# for break_data in breaks:
# temp_breaks.append(
# BreakTime(
# start_time=datetime.strptime(
# break_data["start_time"], "%H:%M:%S"
# ).time(),
# end_time=datetime.strptime(
# break_data["end_time"], "%H:%M:%S"
# ).time(),
# )
# )
# # Get available slots
# available_slots = get_available_time_slots(temp_schedule)
# if len(available_slots) < len(candidates):
# messages.error(
# request,
# f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}",
# )
# return render(
# request,
# "interviews/schedule_interviews.html",
# {"form": form, "break_formset": break_formset, "job": job},
# )
# # Create a preview schedule
# preview_schedule = []
# for i, candidate in enumerate(candidates):
# slot = available_slots[i]
# preview_schedule.append(
# {"candidate": candidate, "date": slot["date"], "time": slot["time"]}
# )
# # Save the form data to session for later use
# schedule_data = {
# "start_date": start_date.isoformat(),
# "end_date": end_date.isoformat(),
# "working_days": working_days,
# "start_time": start_time.isoformat(),
# "end_time": end_time.isoformat(),
# "interview_duration": interview_duration,
# "buffer_time": buffer_time,
# "candidate_ids": [c.id for c in candidates],
# "breaks": breaks,
# }
# request.session["interview_schedule_data"] = schedule_data
# # Render the preview page
# return render(
# request,
# "interviews/preview_schedule.html",
# {
# "job": job,
# "schedule": preview_schedule,
# "start_date": start_date,
# "end_date": end_date,
# "working_days": working_days,
# "start_time": start_time,
# "end_time": end_time,
# "breaks": breaks,
# "interview_duration": interview_duration,
# "buffer_time": buffer_time,
# },
# )
# else:
# form = InterviewScheduleForm(slug=slug)
# break_formset = BreakTimeFormSet()
# selected_ids = []
# # 1. Capture IDs from HTMX request and store in session (when first clicked from timeline)
# if "HX-Request" in request.headers:
# candidate_ids = request.GET.getlist("candidate_ids")
# if candidate_ids:
# request.session[SESSION_KEY] = candidate_ids
# selected_ids = candidate_ids
# # 2. Restore IDs from session (on refresh or navigation)
# if not selected_ids:
# selected_ids = request.session.get(SESSION_KEY, [])
# # 3. Use the list of IDs to initialize the form
# if selected_ids:
# # Load Candidate objects corresponding to the IDs
# candidates_to_load = Candidate.objects.filter(pk__in=selected_ids)
# # This line sets the selected values for {{ form.candidates }}
# form.initial["candidates"] = candidates_to_load
# return render(
# request,
# "interviews/schedule_interviews.html",
# {"form": form, "break_formset": break_formset, "job": job},
# )
# def schedule_interviews_view(request, slug):
# job = get_object_or_404(JobPosting, slug=slug)
@ -1487,7 +1845,7 @@ def candidate_screening_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
applied_count=job.candidates.filter(stage='Applied').count()
exam_count=job.candidates.filter(stage='Exam').count()
interview_count=job.candidates.filter(stage='interview').count()
interview_count=job.candidates.filter(stage='Interview').count()
offer_count=job.candidates.filter(stage='Offer').count()
# Get all candidates for this job, ordered by match score (descending)
candidates = job.candidates.filter(stage="Applied").order_by("-match_score")
@ -1635,8 +1993,8 @@ def candidate_screening_view(request, slug):
'applied_count':applied_count,
'exam_count':exam_count,
'interview_count':interview_count,
'offer_count':offer_count
'offer_count':offer_count,
"current_stage" : "Applied"
}
return render(request, "recruitment/candidate_screening_view.html", context)
@ -1647,9 +2005,21 @@ def candidate_exam_view(request, slug):
Manage candidate tiers and stage transitions
"""
job = get_object_or_404(JobPosting, slug=slug)
applied_count=job.candidates.filter(stage='Applied').count()
exam_count=job.candidates.filter(stage='Exam').count()
interview_count=job.candidates.filter(stage='Interview').count()
offer_count=job.candidates.filter(stage='Offer').count()
candidates = job.candidates.filter(stage="Exam").order_by("-match_score")
return render(request, "recruitment/candidate_exam_view.html", {"job": job, "candidates": candidates})
context = {
"job": job,
"candidates": candidates,
'applied_count':applied_count,
'exam_count':exam_count,
'interview_count':interview_count,
'offer_count':offer_count,
'current_stage' : "Exam"
}
return render(request, "recruitment/candidate_exam_view.html", context)
def update_candidate_exam_status(request, slug):
candidate = get_object_or_404(Candidate, slug=slug)
@ -1704,7 +2074,11 @@ def candidate_update_status(request, slug):
def candidate_interview_view(request,slug):
job = get_object_or_404(JobPosting,slug=slug)
context = {"job":job,"candidates":job.candidates.filter(stage="Interview").order_by("-match_score")}
applied_count=job.candidates.filter(stage='Applied').count()
exam_count=job.candidates.filter(stage='Exam').count()
interview_count=job.candidates.filter(stage='Interview').count()
offer_count=job.candidates.filter(stage='Offer').count()
context = {"job":job,"candidates":job.candidates.filter(stage="Interview").order_by("-match_score"),'applied_count':applied_count,'exam_count':exam_count,'interview_count':interview_count,'offer_count':offer_count,"current_stage":"Interview"}
return render(request,"recruitment/candidate_interview_view.html",context)
def reschedule_meeting_for_candidate(request,slug,candidate_id,meeting_id):
@ -2334,9 +2708,27 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk):
'job': job,
'candidate': candidate
})
def user_detail(requests,pk):
user=get_object_or_404(User,pk=pk)
return render(requests,'user/profile.html')
@csrf_exempt
def zoom_webhook_view(request):
print(request.headers)
print(settings.ZOOM_WEBHOOK_API_KEY)
# if api_key != settings.ZOOM_WEBHOOK_API_KEY:
# return HttpResponse(status=405)
if request.method == 'POST':
try:
payload = json.loads(request.body)
async_task("recruitment.tasks.handle_zoom_webhook_event", payload)
return HttpResponse(status=200)
except Exception:
# Bad data or internal server error
return HttpResponse(status=400)
return HttpResponse(status=405) # Method Not Allowed

View File

@ -134,13 +134,15 @@ class CandidateListView(LoginRequiredMixin, ListView):
model = models.Candidate
template_name = 'recruitment/candidate_list.html'
context_object_name = 'candidates'
paginate_by = 10
paginate_by = 100
def get_queryset(self):
queryset = super().get_queryset()
# Handle search
search_query = self.request.GET.get('search', '')
job = self.request.GET.get('job', '')
stage = self.request.GET.get('stage', '')
if search_query:
queryset = queryset.filter(
Q(first_name__icontains=search_query) |
@ -150,7 +152,10 @@ class CandidateListView(LoginRequiredMixin, ListView):
Q(stage__icontains=search_query) |
Q(job__title__icontains=search_query)
)
if job:
queryset = queryset.filter(job__slug=job)
if stage:
queryset = queryset.filter(stage=stage)
# Filter for non-staff users
if not self.request.user.is_staff:
return models.Candidate.objects.none() # Restrict for non-staff
@ -160,6 +165,9 @@ class CandidateListView(LoginRequiredMixin, ListView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '')
context['job_filter'] = self.request.GET.get('job', '')
context['stage_filter'] = self.request.GET.get('stage', '')
context['available_jobs'] = models.JobPosting.objects.all().order_by('created_at').distinct()
return context

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 heroicon">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>

After

Width:  |  Height:  |  Size: 412 B

View File

@ -0,0 +1,27 @@
{% if is_paginated %}
<nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if search_query %}&q={{ search_query }}{% endif %}{% if job_filter %}&job={{ job_filter }}{% endif %}">First</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if job_filter %}&job={{ job_filter }}{% endif %}">Previous</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }} 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 }}{% if search_query %}&q={{ search_query }}{% endif %}{% if job_filter %}&job={{ job_filter }}{% endif %}">Next</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if search_query %}&q={{ search_query }}{% endif %}{% if job_filter %}&job={{ job_filter }}{% endif %}">Last</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}

View File

@ -1,18 +1,22 @@
<!-- templates/interviews/preview_schedule.html -->
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container mt-4">
<h1>Interview Schedule Preview for {{ job.title }}</h1>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 page-header">
<i class="fas fa-calendar-check me-2"></i> Interview Schedule Preview for {{ job.title }}
</h1>
</div>
<div class="card mt-4">
<div class="card mt-4 shadow-sm">
<div class="card-body">
<h5>Schedule Details</h5>
<h5 class="card-title pb-2 border-bottom">Schedule Details</h5>
<div class="row">
<div class="col-md-6">
<p><strong>Period:</strong> {{ start_date|date:"F j, Y" }} to {{ end_date|date:"F j, Y" }}</p>
<p><strong>Working Days:</strong>
<p>
<strong>Working Days:</strong>
{% for day_id in working_days %}
{% if day_id == 0 %}Monday{% endif %}
{% if day_id == 1 %}Tuesday{% endif %}
@ -25,26 +29,32 @@
{% endfor %}
</p>
<p><strong>Working Hours:</strong> {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}</p>
</div>
<div class="col-md-6">
{% if breaks %}
<p><strong>Break Times:</strong></p>
<ul>
{% for break in breaks %}
<li>{{ break.start_time|time:"g:i A" }} to {{ break.end_time|time:"g:i A" }}</li>
{% endfor %}
</ul>
{% endif %}
<p><strong>Interview Duration:</strong> {{ interview_duration }} minutes</p>
<p><strong>Buffer Time:</strong> {{ buffer_time }} minutes</p>
</div>
<div class="col-md-6">
<p class="mb-2"><strong>Daily Break Times:</strong></p>
{% if breaks %}
<!-- New structured display for breaks -->
<div class="d-flex flex-column gap-1 mb-3 p-3 border rounded bg-light">
{% for break in breaks %}
<small class="text-dark">
<i class="far fa-clock me-1 text-muted"></i>
{{ break.start_time|time:"g:i A" }} &mdash; {{ break.end_time|time:"g:i A" }}
</small>
{% endfor %}
</div>
{% else %}
<p class="mb-3"><small class="text-muted">No daily breaks scheduled.</small></p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card mt-4 shadow-sm">
<div class="card-body">
<h5>Scheduled Interviews</h5>
<h5 class="card-title pb-2 border-bottom">Scheduled Interviews</h5>
<!-- Calendar View -->
<div id="calendar-container">
@ -75,7 +85,7 @@
</table>
</div>
<form method="post" class="mt-4">
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4">
{% csrf_token %}
<button type="submit" name="confirm_schedule" class="btn btn-success">
<i class="fas fa-check"></i> Confirm Schedule
@ -127,7 +137,9 @@ document.addEventListener('DOMContentLoaded', function() {
eventClick: function(info) {
// Show candidate details in a modal or alert
if (info.event.title !== 'Break') {
alert('Candidate: ' + info.event.title +
// IMPORTANT: Since alert() is forbidden, using console log as a fallback.
// In a production environment, this would be a custom modal dialog.
console.log('Candidate: ' + info.event.title +
'\nDate: ' + info.event.start.toLocaleDateString() +
'\nTime: ' + info.event.extendedProps.time +
'\nEmail: ' + info.event.extendedProps.email);
@ -138,4 +150,4 @@ document.addEventListener('DOMContentLoaded', function() {
calendar.render();
});
</script>
{% endblock %}
{% endblock %}

View File

@ -56,14 +56,13 @@
margin-top: 0.5rem;
}
/* --- FIX: SCROLLABLE CANDIDATE LIST --- */
/* FIX: SCROLLABLE CANDIDATE LIST */
.form-group select[multiple] {
max-height: 450px;
overflow-y: auto;
min-height: 250px;
padding: 0;
}
/* ------------------------------------- */
/* 3. Button Styling */
.btn-main-action {
@ -197,37 +196,18 @@
<div class="mt-4 pt-4 border-top">
<h5 class="section-header">{% trans "Daily Break Times" %}</h5>
<div id="break-times-container">
{{ break_formset.management_form }}
{% for hidden in break_formset.management_form.hidden_fields %}
{% if "TOTAL_FORMS" in hidden.id_for_label %}
<input type="hidden" name="{{ hidden.name }}" id="id_breaks-TOTAL_FORMS" value="{{ hidden.value }}">
{% else %}
{{ hidden }}
{% endif %}
{% endfor %}
{% for form in break_formset %}
<div class="break-time-form row mb-2 g-2">
<div class="col-5">
<label for="{{ form.start_time.id_for_label }}">{% trans "Start Time" %}</label>
{{ form.start_time }}
</div>
<div class="col-5">
<label for="{{ form.end_time.id_for_label }}">{% trans "End Time" %}</label>
{{ form.end_time }}
</div>
<div class="col-2 d-flex align-items-end">
{{ form.DELETE }}
<button type="button" class="btn btn-danger btn-sm remove-break w-100">
<i class="fas fa-trash-alt"></i> {% trans "Remove" %}
</button>
</div>
<div class="break-time-form row mb-2 g-2">
<div class="col-5">
<label for="{{ form.break_start_time.id_for_label }}">{% trans "Start Time" %}</label>
{{ form.break_start_time }}
</div>
{% endfor %}
<div class="col-5">
<label for="{{ form.break_end_time.id_for_label }}">{% trans "End Time" %}</label>
{{ form.break_end_time }}
</div>
</div>
</div>
<button type="button" id="add-break" class="btn btn-secondary btn-sm mt-3">
<i class="fas fa-plus me-1"></i> {% trans "Add Break" %}
</button>
</div>
</div>
@ -249,12 +229,13 @@
document.addEventListener('DOMContentLoaded', function() {
const addBreakBtn = document.getElementById('add-break');
const breakTimesContainer = document.getElementById('break-times-container');
// The ID is now guaranteed to be 'id_breaks-TOTAL_FORMS' thanks to the template fix
const totalFormsInput = document.getElementById('id_breaks-TOTAL_FORMS');
// Safety check added, though the template fix should resolve the core issue
// *** FIX: Hardcode formset prefix for reliability (requires matching Python change) ***
const FORMSET_PREFIX = 'breaktime';
const totalFormsInput = document.querySelector(`input[name="${FORMSET_PREFIX}-TOTAL_FORMS"]`);
if (!totalFormsInput) {
console.error("TOTAL_FORMS input not found. Cannot add break dynamically.");
console.error(`TOTAL_FORMS input with name ${FORMSET_PREFIX}-TOTAL_FORMS not found. Cannot add break dynamically. Please ensure formset prefix is set to '${FORMSET_PREFIX}' in the Python view and management_form is rendered.`);
return;
}
@ -262,20 +243,22 @@ document.addEventListener('DOMContentLoaded', function() {
const formCount = parseInt(totalFormsInput.value);
// Template for a new form, ensuring the correct classes are applied
// Use the hardcoded prefix in all new form fields
const newFormHtml = `
<div class="break-time-form row mb-2 g-2">
<div class="col-5">
<label for="id_breaks-${formCount}-start_time">Start Time</label>
<input type="time" name="breaks-${formCount}-start_time" class="form-control" id="id_breaks-${formCount}-start_time">
<label for="id_${FORMSET_PREFIX}-${formCount}-start_time">Start Time</label>
<input type="time" name="${FORMSET_PREFIX}-${formCount}-start_time" class="form-control" id="id_${FORMSET_PREFIX}-${formCount}-start_time">
</div>
<div class="col-5">
<label for="id_breaks-${formCount}-end_time">End Time</label>
<input type="time" name="breaks-${formCount}-end_time" class="form-control" id="id_breaks-${formCount}-end_time">
<label for="id_${FORMSET_PREFIX}-${formCount}-end_time">End Time</label>
<input type="time" name="${FORMSET_PREFIX}-${formCount}-end_time" class="form-control" id="id_${FORMSET_PREFIX}-${formCount}-end_time">
</div>
<div class="col-2 d-flex align-items-end">
<input type="hidden" name="breaks-${formCount}-id" id="id_breaks-${formCount}-id">
<input type="hidden" name="breaks-${formCount}-meeting" id="id_breaks-${formCount}-meeting">
<input type="checkbox" name="breaks-${formCount}-DELETE" id="id_breaks-${formCount}-DELETE" style="display:none;">
<!-- Hidden management fields for new forms (ID and DELETE) -->
<input type="hidden" name="${FORMSET_PREFIX}-${formCount}-id" id="id_${FORMSET_PREFIX}-${formCount}-id" value="">
<input type="checkbox" name="${FORMSET_PREFIX}-${formCount}-DELETE" id="id_${FORMSET_PREFIX}-${formCount}-DELETE" style="display:none;">
<button type="button" class="btn btn-danger btn-sm remove-break w-100">
<i class="fas fa-trash-alt"></i> Remove
</button>
@ -298,12 +281,17 @@ document.addEventListener('DOMContentLoaded', function() {
if (form) {
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
if (deleteCheckbox) {
// Check the DELETE box and hide the form
deleteCheckbox.checked = true;
form.style.display = 'none';
} else {
// If it's a new form, remove it entirely
form.remove();
}
form.style.display = 'none';
}
}
});
});
</script>
{% endblock %}
{% endblock %}

View File

@ -73,7 +73,7 @@
border: 1px solid #ced4da;
width: 100%;
padding: 0.375rem 0.75rem;
box-sizing: border-box;
box-sizing: border-box;
}
/* ================================================= */
@ -88,11 +88,11 @@
margin-right: -0.75rem !important;
/* General cleanup to maintain look */
box-sizing: border-box;
box-sizing: border-box;
margin-bottom: 0 !important;
border-radius: 0.5rem;
}
/* Set minimum heights for specific fields using sibling selector */
#id_description + .note-editor { min-height: 300px; }
#id_qualifications + .note-editor { min-height: 200px; }
@ -103,7 +103,7 @@
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="container-fluid py-4">
<h1 class="h3 mb-4 text-primary fw-bold">
{# UPDATED TITLE FOR EDIT CONTEXT #}
<i class="fas fa-edit me-2"></i> {% trans "Edit Job Posting" %}
@ -151,6 +151,20 @@
{% if form.workplace_type.errors %}<div class="text-danger small mt-1">{{ form.workplace_type.errors }}</div>{% endif %}
</div>
</div>
<div class="col-md-6">
<div>
<label for="{{ form.application_deadline.id_for_label }}" class="form-label">{% trans "Application Deadline" %}</label>
{{ form.application_deadline }}
{% if form.application_deadline.errors %}<div class="text-danger small mt-1">{{ form.application_deadline.errors }}</div>{% endif %}
</div>
</div>
<div class="col-md-6">
<div>
<label for="{{ form.max_applications.id_for_label }}" class="form-label">{% trans "Max Number Of Applicants" %}</label>
{{ form.max_applications }}
{% if form.max_applications.errors %}<div class="text-danger small mt-1">{{ form.max_applications.errors }}</div>{% endif %}
</div>
</div>
</div>
</div>
</div>
@ -257,14 +271,6 @@
{% if form.location_country.errors %}<div class="text-danger small mt-1">{{ form.location_country.errors }}</div>{% endif %}
</div>
</div>
<div class="col-md-4">
<div>
<label for="{{ form.application_deadline.id_for_label }}" class="form-label">{% trans "Application Deadline" %}</label>
{{ form.application_deadline }}
{% if form.application_deadline.errors %}<div class="text-danger small mt-1">{{ form.application_deadline.errors }}</div>{% endif %}
</div>
</div>
<div class="col-md-4">
<div>
<label for="{{ form.start_date.id_for_label }}" class="form-label">{% trans "Desired Start Date" %}</label>
@ -272,13 +278,6 @@
{% if form.start_date.errors %}<div class="text-danger small mt-1">{{ form.start_date.errors }}</div>{% endif %}
</div>
</div>
<div class="col-md-4">
<div>
<label for="{{ form.status.id_for_label }}" class="form-label">{% trans "Status" %}</label>
{{ form.status }}
{% if form.status.errors %}<div class="text-danger small mt-1">{{ form.status.errors }}</div>{% endif %}
</div>
</div>
</div>
</div>
</div>

View File

@ -11,8 +11,8 @@
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-success: #28a745;
--kaauh-danger: #dc3545;
--kaauh-success: #28a745;
--kaauh-danger: #dc3545;
}
/* Primary Color Overrides */
@ -81,42 +81,42 @@
/* --- TABLE ALIGNMENT AND SIZING FIXES --- */
.table {
table-layout: fixed;
table-layout: fixed;
width: 100%;
border-collapse: collapse;
border-collapse: collapse;
}
.table thead th {
color: var(--kaauh-primary-text);
font-weight: 600;
font-size: 0.85rem;
font-weight: 600;
font-size: 0.85rem;
vertical-align: middle;
border-bottom: 2px solid var(--kaauh-border);
padding: 0.5rem 0.25rem;
padding: 0.5rem 0.25rem;
}
.table-hover tbody tr:hover {
background-color: #f3f7f9;
}
/* Optimized Main Table Column Widths (Total must be 100%) */
.table th:nth-child(1) { width: 22%; }
.table th:nth-child(2) { width: 12%; }
.table th:nth-child(3) { width: 8%; }
.table th:nth-child(4) { width: 8%; }
.table th:nth-child(5) { width: 50%; }
.table th:nth-child(1) { width: 22%; }
.table th:nth-child(2) { width: 12%; }
.table th:nth-child(3) { width: 8%; }
.table th:nth-child(4) { width: 8%; }
.table th:nth-child(5) { width: 50%; }
/* Candidate Management Header Row (The one with P/F) */
.nested-metrics-row th {
font-weight: 500;
color: #6c757d;
font-size: 0.75rem;
font-size: 0.75rem;
padding: 0.3rem 0;
border-bottom: 2px solid var(--kaauh-teal);
border-bottom: 2px solid var(--kaauh-teal);
text-align: center;
border-left: 1px solid var(--kaauh-border);
}
.nested-metrics-row th {
width: calc(50% / 7);
width: calc(50% / 7);
}
.nested-metrics-row th[colspan="2"] {
width: calc(50% / 7 * 2);
@ -148,10 +148,10 @@
text-align: center;
vertical-align: middle;
font-weight: 600;
font-size: 0.9rem;
padding: 0;
font-size: 0.9rem;
padding: 0;
}
.table tbody td.candidate-data-cell:not(:first-child) {
.table tbody td.candidate-data-cell:not(:first-child) {
border-left: 1px solid var(--kaauh-border);
}
.table tbody tr td:nth-child(5) {
@ -161,15 +161,15 @@
.candidate-data-cell a {
display: block;
text-decoration: none;
padding: 0.4rem 0.25rem;
padding: 0.4rem 0.25rem;
}
/* Fix action button sizing */
.btn-group-sm > .btn {
padding: 0.2rem 0.4rem;
font-size: 0.75rem;
}
/* Additional CSS for Card View layout */
.card-view .card {
height: 100%;
@ -227,7 +227,7 @@
</div>
<div class="col-md-5">
<div class="filter-buttons">
<button type="submit" class="btn btn-main-action btn-lg">
<i class="fas fa-filter me-1"></i> {% trans "Apply Filters" %}
</button>
@ -236,8 +236,8 @@
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
</a>
{% endif %}
</div>
</div>
</form>
@ -249,19 +249,21 @@
{# --- START OF JOB LIST CONTAINER --- #}
<div id="job-list">
{# View Switcher (Contains the Card/Table buttons and JS/CSS logic) #}
{% include "includes/_list_view_switcher.html" with list_id="job-list" %}
{% include "includes/_list_view_switcher.html" with list_id="job-list" %}
{# 1. TABLE VIEW (Default Active) #}
<div class="table-view active">
<div class="card shadow-sm">
<div class="table-responsive ">
<table class="table table-hover align-middle mb-0 table-sm">
{# --- Corrected Multi-Row Header Structure --- #}
<thead>
<tr>
<th scope="col" rowspan="2" style="width: 22%;">{% trans "Job Title / ID" %}</th>
<th scope="col" rowspan="2" style="width: 12%;">{% trans "Source" %}</th>
<th scope="col" rowspan="2" style="width: 10%;">{% trans "Number Of Applicants" %}</th>
<th scope="col" rowspan="2" style="width: 8%;">{% trans "Application Deadline" %}</th>
<th scope="col" rowspan="2" style="width: 8%;">{% trans "Actions" %}</th>
<th scope="col" rowspan="2" class="text-center" style="width: 8%;">{% trans "Manage Forms" %}</th>
@ -269,7 +271,7 @@
{% trans "Applicants Metrics" %}
</th>
</tr>
<tr class="nested-metrics-row">
<th style="width: calc(50% / 7);">{% trans "Applied" %}</th>
<th style="width: calc(50% / 7);">{% trans "Screened" %}</th>
@ -290,7 +292,7 @@
<th style="width: calc(50% / 7);">{% trans "Offer" %}</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
@ -301,6 +303,8 @@
<span class="badge bg-{{ job.status }} status-badge">{{ job.status }}</span>
</td>
<td>{{ job.get_source }}</td>
<td>{{ job.max_applications }}</td>
<td>{{ job.application_deadline|date:"d-m-Y" }}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary" title="{% trans 'View' %}">
@ -342,9 +346,9 @@
</div>
</div>
</div>
{# 2. CARD VIEW (Previously Missing) - Added Bootstrap row/col structure for layout #}
<div class="card-view row g-4">
<div class="card-view row g-4">
{% for job in jobs %}
<div class="col-xl-4 col-lg-6 col-md-6">
<div class="card shadow-sm">
@ -389,8 +393,7 @@
{# --- END CARD VIEW --- #}
</div>
{# --- END OF JOB LIST CONTAINER --- #}
{% comment %} Fallback/Empty State {% endcomment %}
{% include "includes/paginator.html" %}
{% if not jobs and not job_list_data and not page_obj %}
<div class="text-center py-5 card shadow-sm">
<div class="card-body">

View File

@ -1,4 +1,4 @@
{% load static i18n %}
{% load static i18n %}
<style>
/* ==================================== */
@ -121,18 +121,17 @@
margin-top: 0.1rem;
color: #6c757d;
}
</style>
</style>
<div class="progress-stages">
<div class="d-flex justify-content-between align-items-center">
{% comment %} STAGE 1: Applied {% endcomment %}
<a href="{% url 'candidate_screening_view' job.slug %}"
class="stage-item {% if current_stage == 'Applied' %}active{% endif %} {% if current_stage != 'Applied' and candidate.stage_history_has.Applied %}completed{% endif %}"
class="stage-item {% if current_stage == 'Applied' %}active{% endif %}"
data-stage="Applied">
<div class="stage-icon ">
<i class="fas fa-file-signature cd_screening"></i>
<div class="stage-icon">
<i class="fas fa-file-signature cd_screening"></i>
</div>
<div class="stage-label cd_screening">{% trans "Screened" %}</div>
<div class="stage-count">{{ applied_count|default:"0" }}</div>
@ -143,7 +142,7 @@
{% comment %} STAGE 2: Exam {% endcomment %}
<a href="{% url 'candidate_exam_view' job.slug %}"
class="stage-item {% if current_stage == 'Exam' %}active{% endif %} {% if current_stage != 'Exam' and candidate.stage_history_has.Exam %}completed{% endif %}"
class="stage-item {% if current_stage == 'Exam' %}active{% endif %}"
data-stage="Exam">
<div class="stage-icon">
<i class="fas fa-clipboard-check cd_exam"></i>
@ -157,7 +156,7 @@
{% comment %} STAGE 3: Interview {% endcomment %}
<a href="{% url 'candidate_interview_view' job.slug %}"
class="stage-item {% if current_stage == 'Interview' %}active{% endif %} {% if current_stage != 'Interview' and candidate.stage_history_has.Interview %}completed{% endif %}"
class="stage-item {% if current_stage == 'Interview' %}active{% endif %}"
data-stage="Interview">
<div class="stage-icon">
<i class="fas fa-comments cd_interview"></i>

View File

@ -139,6 +139,26 @@
.text-muted.fa-3x {
color: var(--kaauh-teal-dark) !important;
}
@keyframes svg-pulse {
0% {
transform: scale(0.9);
opacity: 0.8;
}
50% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(0.9);
opacity: 0.8;
}
}
/* Apply the animation to the custom class */
.svg-pulse {
animation: svg-pulse 2s infinite ease-in-out;
transform-origin: center; /* Ensure scaling is centered */
}
</style>
{% endblock %}
@ -167,6 +187,7 @@
<div class="col-md-6">
<form method="GET" class="row g-3 align-items-end" >
{% if search_query %}<input type="hidden" name="q" value="{{ search_query }}">{% endif %}
{% if status_filter %}<input type="hidden" name="status" value="{{ status_filter }}">{% endif %}
<div class="col-md-3">
<label for="status" class="form-label small text-muted">{% trans "Filter by Status" %}</label>
@ -177,13 +198,16 @@
<option value="ended" {% if status_filter == 'ended' %}selected{% endif %}>{% trans "Ended" %}</option>
</select>
</div>
<div class="col-md-4">
<label for="candidate_name" class="form-label small text-muted">{% trans "Candidate Name" %}</label>
<input type="text" class="form-control form-control-sm" id="candidate_name" name="candidate_name" placeholder="{% trans 'Search by candidate...' %}" value="{{ candidate_name_filter }}">
</div>
<div class="col-md-5">
<div class="filter-buttons">
<button type="submit" class="btn btn-main-action btn-lg">
<i class="fas fa-filter me-1"></i> {% trans "Apply Filters" %}
</button>
{% if status_filter or search_query %}
{% if status_filter or search_query or candidate_name_filter %}
<a href="{% url 'list_meetings' %}" class="btn btn-outline-secondary btn-lg">
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
</a>
@ -214,9 +238,11 @@
</div>
<p class="card-text text-muted small mb-3">
<i class="fas fa-user"></i> {% trans "Candidate" %}: {% with interview=meeting.interview_details.first %}{% if interview %}{{ interview.candidate.name }}{% else %}{% trans "N/A" %}{% endif %}{% endwith %}<br>
<i class="fas fa-briefcase"></i> {% trans "Job" %}: {% with interview=meeting.interview_details.first %}{% if interview %}{{ interview.job.title }}{% else %}{% trans "N/A" %}{% endif %}{% endwith %}<br>
<i class="fas fa-hashtag"></i> {% trans "ID" %}: {{ meeting.meeting_id|default:meeting.id }}<br>
<i class="fas fa-clock"></i> {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }}<br>
<i class="fas fa-stopwatch"></i> {% trans "Duration" %}: {{ meeting.duration }} minutes
<i class="fas fa-clock"></i> {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }} ({{ meeting.timezone }})<br>
<i class="fas fa-stopwatch"></i> {% trans "Duration" %}: {{ meeting.duration }} minutes{% if meeting.password %}<br><i class="fas fa-lock"></i> {% trans "Password" %}: Yes{% endif %}
</p>
<div class="mt-auto pt-2 border-top">
@ -254,25 +280,40 @@
<table class="table table-hover">
<thead>
<tr>
<th scope="col" style="width: 30%;">{% trans "Topic" %}</th>
<th scope="col" style="width: 15%;">{% trans "ID" %}</th>
<th scope="col" style="width: 20%;">{% trans "Start Time" %}</th>
<th scope="col" style="width: 10%;">{% trans "Duration" %}</th>
<th scope="col" style="width: 15%;">{% trans "Status" %}</th>
<th scope="col" style="width: 10%;" class="text-end">{% trans "Actions" %}</th>
<th scope="col">{% trans "Topic" %}</th>
<th scope="col">{% trans "Candidate" %}</th>
<th scope="col">{% trans "Job" %}</th>
<th scope="col">{% trans "ID" %}</th>
<th scope="col">{% trans "Start Time" %}</th>
<th scope="col">{% trans "Duration" %}</th>
<th scope="col">{% trans "Status" %}</th>
<th scope="col" class="text-end">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for meeting in meetings %}
<tr>
<td><strong class="text-primary">{{ meeting.topic }}</strong></td>
<td>{{ meeting.meeting_id|default:meeting.id }}</td>
<td>{{ meeting.start_time|date:"M d, Y H:i" }}</td>
<td>{{ meeting.duration }} min</td>
<td>
<span class="status-badge bg-{{ meeting.status }}">
{{ meeting.status|title }}
</span>
{% with interview=meeting.interview_details.first %}
{% if interview %}{{ interview.candidate.name }}{% else %}{% trans "N/A" %}{% endif %}
{% endwith %}
</td>
<td>
{% with interview=meeting.interview_details.first %}
{% if interview %}{{ interview.job.title }}{% else %}{% trans "N/A" %}{% endif %}
{% endwith %}
</td>
<td>{{ meeting.meeting_id|default:meeting.id }}</td>
<td>{{ meeting.start_time|date:"M d, Y H:i" }} ({{ meeting.timezone }})</td>
<td>{{ meeting.duration }} min{% if meeting.password %} ({% trans "Password" %}){% endif %}</td>
<td>
{% if meeting.status == "started" %}
<span class="status-badge bg-{{ meeting.status }}">
{{ meeting.status|title }}
{% include "icons/video.html" %}
</span>
{% endif %}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
@ -344,4 +385,4 @@
</div>
{% endif %}
</div>
{% endblock %}
{% endblock %}

View File

@ -67,7 +67,7 @@
color: white;
border-color: var(--kaauh-teal-dark);
}
/* Card Specifics (Adapted from Job Card to Candidate Card) */
.candidate-card .card-title {
color: var(--kaauh-teal-dark);
@ -76,7 +76,7 @@
}
.candidate-card .card-text i {
color: var(--kaauh-teal);
width: 1.25rem;
width: 1.25rem;
}
/* Table & Card Badge Styling (Unified) */
@ -90,7 +90,7 @@
/* Status Badge Mapping (Using standard Bootstrap names where possible) */
.bg-primary { background-color: var(--kaauh-teal) !important; color: white !important;} /* Main job/stage badge */
.bg-success { background-color: #28a745 !important; color: white !important;}
.bg-success { background-color: #28a745 !important; color: white !important;}
.bg-warning { background-color: #ffc107 !important; color: #343a40 !important;}
/* Table Styling (Consistent with Reference) */
@ -112,7 +112,7 @@
.table-view .table tbody tr:hover {
background-color: var(--kaauh-gray-light);
}
/* Pagination Link Styling (Consistent) */
.pagination .page-item .page-link {
color: var(--kaauh-teal-dark);
@ -126,7 +126,7 @@
.pagination .page-item:hover .page-link:not(.active) {
background-color: #e9ecef;
}
/* Filter & Search Layout Adjustments */
.filter-buttons {
display: flex;
@ -161,18 +161,27 @@
</div>
<div class="col-md-6">
{% url 'candidate_list' as candidate_list_url %}
<form method="GET" class="row g-3 align-items-end" >
{% if search_query %}<input type="hidden" name="q" value="{{ search_query }}">{% endif %}
<div class="col-md-3">
<div class="col-md-4">
<label for="job_filter" class="form-label small text-muted">{% trans "Filter by Job" %}</label>
<select name="job" id="job_filter" class="form-select form-select-sm">
<option value="">{% trans "All Jobs" %}</option>
{% for job in available_jobs %} {# Assuming you pass a context variable 'available_jobs' #}
<div class="d-flex gap-2">
<select name="job" id="job_filter" class="form-select form-select-sm">
<option value="">{% trans "All Jobs" %}</option>
{% for job in available_jobs %}
<option value="{{ job.slug }}" {% if job_filter == job.slug %}selected{% endif %}>{{ job.title }}</option>
{% endfor %}
{% endfor %}
</select>
<select name="stage" id="stage_filter" class="form-select form-select-sm">
<option value="">{% trans "All Stages" %}</option>
<option value="Applied" {% if stage_filter == 'Applied' %}selected{% endif %}>{% trans "Applied" %}</option>
<option value="Exam" {% if stage_filter == 'Exam' %}selected{% endif %}>{% trans "Exam" %}</option>
<option value="Interview" {% if stage_filter == 'Interview' %}selected{% endif %}>{% trans "Interview" %}</option>
<option value="Offer" {% if stage_filter == 'Offer' %}selected{% endif %}>{% trans "Offer" %}</option>
</select>
</div>
</div>
<div class="col-md-5">
@ -260,7 +269,7 @@
<h5 class="card-title flex-grow-1 me-3"><a href="{% url 'candidate_detail' candidate.slug %}" class="text-decoration-none text-primary-theme ">{{ candidate.name }}</a></h5>
<span class="badge bg-primary">{{ candidate.stage }}</span>
</div>
<p class="card-text text-muted small">
<i class="fas fa-envelope"></i> {{ candidate.email }}<br>
<i class="fas fa-phone-alt"></i> {{ candidate.phone|default:"N/A" }}<br>
@ -293,33 +302,7 @@
</div>
{# Pagination (Standardized to Reference) #}
{% if is_paginated %}
<nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if search_query %}&q={{ search_query }}{% endif %}{% if job_filter %}&job={{ job_filter }}{% endif %}">First</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if job_filter %}&job={{ job_filter }}{% endif %}">Previous</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }} 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 }}{% if search_query %}&q={{ search_query }}{% endif %}{% if job_filter %}&job={{ job_filter }}{% endif %}">Next</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if search_query %}&q={{ search_query }}{% endif %}{% if job_filter %}&job={{ job_filter }}{% endif %}">Last</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% include "includes/paginator.html" %}
{% else %}
<div class="text-center py-5 card shadow-sm">
<div class="card-body">