push bulk create meeting to to background and create zoom webhook
This commit is contained in:
parent
3682657527
commit
a669564e6d
Binary file not shown.
Binary file not shown.
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
1
recruitment/hooks.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -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"))
|
||||
156
recruitment/management/commands/seed.py
Normal file
156
recruitment/management/commands/seed.py
Normal 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()}")
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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',
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
Binary file not shown.
@ -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, [])
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
@ -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)}")
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
3
templates/icons/video.html
Normal file
3
templates/icons/video.html
Normal 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 |
27
templates/includes/paginator.html
Normal file
27
templates/includes/paginator.html
Normal 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 %}
|
||||
@ -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" }} — {{ 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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user