Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend
This commit is contained in:
commit
2ff04cfa6e
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -492,3 +492,17 @@ class CandidateExamDateForm(forms.ModelForm):
|
|||||||
widgets = {
|
widgets = {
|
||||||
'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleInterviewForCandiateForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = InterviewSchedule
|
||||||
|
fields = ['start_date', 'end_date', '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'}),
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-13 19:55
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0009_merge_20251013_1714'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='scheduledinterview',
|
||||||
|
name='schedule',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule'),
|
||||||
|
),
|
||||||
|
]
|
||||||
14
recruitment/migrations/0012_merge_20251014_1403.py
Normal file
14
recruitment/migrations/0012_merge_20251014_1403.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-14 11:03
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0010_alter_scheduledinterview_schedule'),
|
||||||
|
('recruitment', '0011_alter_jobpostingimage_job_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
]
|
||||||
21
recruitment/migrations/0013_alter_formtemplate_created_by.py
Normal file
21
recruitment/migrations/0013_alter_formtemplate_created_by.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-14 11:24
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0012_merge_20251014_1403'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='formtemplate',
|
||||||
|
name='created_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -273,12 +273,12 @@ class Candidate(Base):
|
|||||||
CANDIDATE = "Candidate", _("Candidate")
|
CANDIDATE = "Candidate", _("Candidate")
|
||||||
|
|
||||||
# Stage transition validation constants
|
# Stage transition validation constants
|
||||||
STAGE_SEQUENCE = {
|
# STAGE_SEQUENCE = {
|
||||||
"Applied": ["Exam", "Interview", "Offer"],
|
# "Applied": ["Exam", "Interview", "Offer"],
|
||||||
"Exam": ["Interview", "Offer"],
|
# "Exam": ["Interview", "Offer"],
|
||||||
"Interview": ["Offer"],
|
# "Interview": ["Offer"],
|
||||||
"Offer": [], # Final stage - no further transitions
|
# "Offer": [], # Final stage - no further transitions
|
||||||
}
|
# }
|
||||||
|
|
||||||
job = models.ForeignKey(
|
job = models.ForeignKey(
|
||||||
JobPosting,
|
JobPosting,
|
||||||
@ -375,50 +375,50 @@ class Candidate(Base):
|
|||||||
return self.resume.size
|
return self.resume.size
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def clean(self):
|
# def clean(self):
|
||||||
"""Validate stage transitions"""
|
# """Validate stage transitions"""
|
||||||
# Only validate if this is an existing record (not being created)
|
# # 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:
|
# if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
|
||||||
old_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, [])
|
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||||
|
|
||||||
if self.stage not in allowed_next_stages:
|
# if self.stage not in allowed_next_stages:
|
||||||
raise ValidationError(
|
# raise ValidationError(
|
||||||
{
|
# {
|
||||||
"stage": f'Cannot transition from "{old_stage}" to "{self.stage}". '
|
# "stage": f'Cannot transition from "{old_stage}" to "{self.stage}". '
|
||||||
f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}"
|
# f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}"
|
||||||
}
|
# }
|
||||||
)
|
# )
|
||||||
|
|
||||||
# Validate that the stage is a valid choice
|
# # Validate that the stage is a valid choice
|
||||||
if self.stage not in [choice[0] for choice in self.Stage.choices]:
|
# if self.stage not in [choice[0] for choice in self.Stage.choices]:
|
||||||
raise ValidationError(
|
# raise ValidationError(
|
||||||
{
|
# {
|
||||||
"stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}"
|
# "stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}"
|
||||||
}
|
# }
|
||||||
)
|
# )
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Override save to ensure validation is called"""
|
"""Override save to ensure validation is called"""
|
||||||
self.clean() # Call validation before saving
|
self.clean() # Call validation before saving
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def can_transition_to(self, new_stage):
|
# def can_transition_to(self, new_stage):
|
||||||
"""Check if a stage transition is allowed"""
|
# """Check if a stage transition is allowed"""
|
||||||
if not self.pk: # New record - can be in Applied stage
|
# if not self.pk: # New record - can be in Applied stage
|
||||||
return new_stage == "Applied"
|
# return new_stage == "Applied"
|
||||||
|
|
||||||
old_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, [])
|
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||||
return new_stage in allowed_next_stages
|
# return new_stage in allowed_next_stages
|
||||||
|
|
||||||
def get_available_stages(self):
|
# def get_available_stages(self):
|
||||||
"""Get list of stages this candidate can transition to"""
|
# """Get list of stages this candidate can transition to"""
|
||||||
if not self.pk: # New record
|
# if not self.pk: # New record
|
||||||
return ["Applied"]
|
# return ["Applied"]
|
||||||
|
|
||||||
old_stage = self.__class__.objects.get(pk=self.pk).stage
|
# old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||||
return self.STAGE_SEQUENCE.get(old_stage, [])
|
# return self.STAGE_SEQUENCE.get(old_stage, [])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def submission(self):
|
def submission(self):
|
||||||
@ -441,6 +441,29 @@ class Candidate(Base):
|
|||||||
return schedule.zoom_meeting
|
return schedule.zoom_meeting
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_future_meeting(self):
|
||||||
|
"""
|
||||||
|
Checks if the candidate has any scheduled interviews for a future date/time.
|
||||||
|
"""
|
||||||
|
# Ensure timezone.now() is used for comparison
|
||||||
|
now = timezone.now()
|
||||||
|
# Check if any related ScheduledInterview has a future interview_date and interview_time
|
||||||
|
# We need to combine date and time for a proper datetime comparison if they are separate fields
|
||||||
|
future_meetings = self.scheduled_interviews.filter(
|
||||||
|
interview_date__gt=now.date()
|
||||||
|
).filter(
|
||||||
|
interview_time__gte=now.time()
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
# Also check for interviews happening later today
|
||||||
|
today_future_meetings = self.scheduled_interviews.filter(
|
||||||
|
interview_date=now.date(),
|
||||||
|
interview_time__gte=now.time()
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
return future_meetings or today_future_meetings
|
||||||
|
|
||||||
|
|
||||||
class TrainingMaterial(Base):
|
class TrainingMaterial(Base):
|
||||||
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||||
@ -521,7 +544,7 @@ class FormTemplate(Base):
|
|||||||
blank=True, help_text="Description of the form template"
|
blank=True, help_text="Description of the form template"
|
||||||
)
|
)
|
||||||
created_by = models.ForeignKey(
|
created_by = models.ForeignKey(
|
||||||
User, on_delete=models.CASCADE, related_name="form_templates"
|
User, on_delete=models.CASCADE, related_name="form_templates",null=True,blank=True
|
||||||
)
|
)
|
||||||
is_active = models.BooleanField(
|
is_active = models.BooleanField(
|
||||||
default=False, help_text="Whether this template is active"
|
default=False, help_text="Whether this template is active"
|
||||||
@ -1005,7 +1028,7 @@ class ScheduledInterview(Base):
|
|||||||
ZoomMeeting, on_delete=models.CASCADE, related_name="interview"
|
ZoomMeeting, on_delete=models.CASCADE, related_name="interview"
|
||||||
)
|
)
|
||||||
schedule = models.ForeignKey(
|
schedule = models.ForeignKey(
|
||||||
InterviewSchedule, on_delete=models.CASCADE, related_name="interviews"
|
InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True
|
||||||
)
|
)
|
||||||
interview_date = models.DateField(verbose_name=_("Interview Date"))
|
interview_date = models.DateField(verbose_name=_("Interview Date"))
|
||||||
interview_time = models.TimeField(verbose_name=_("Interview Time"))
|
interview_time = models.TimeField(verbose_name=_("Interview Time"))
|
||||||
|
|||||||
@ -3,10 +3,14 @@ from django.db import transaction
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django_q.tasks import async_task
|
from django_q.tasks import async_task
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from .models import FormField,FormStage,FormTemplate,Candidate
|
from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# @receiver(post_save, sender=JobPosting)
|
||||||
|
# def create_form_for_job(sender, instance, created, **kwargs):
|
||||||
|
# if created:
|
||||||
|
# FormTemplate.objects.create(job=instance, is_active=True, name=instance.title)
|
||||||
@receiver(post_save, sender=Candidate)
|
@receiver(post_save, sender=Candidate)
|
||||||
def score_candidate_resume(sender, instance, created, **kwargs):
|
def score_candidate_resume(sender, instance, created, **kwargs):
|
||||||
if not instance.is_resume_parsed:
|
if not instance.is_resume_parsed:
|
||||||
|
|||||||
@ -17,7 +17,6 @@ urlpatterns = [
|
|||||||
path('jobs/<slug:slug>/candidate/application/success', views.application_success, name='application_success'),
|
path('jobs/<slug:slug>/candidate/application/success', views.application_success, name='application_success'),
|
||||||
path('careers/',views.kaauh_career,name='kaauh_career'),
|
path('careers/',views.kaauh_career,name='kaauh_career'),
|
||||||
|
|
||||||
|
|
||||||
# LinkedIn Integration URLs
|
# LinkedIn Integration URLs
|
||||||
path('jobs/<slug:slug>/post-to-linkedin/', views.post_to_linkedin, name='post_to_linkedin'),
|
path('jobs/<slug:slug>/post-to-linkedin/', views.post_to_linkedin, name='post_to_linkedin'),
|
||||||
path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'),
|
path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'),
|
||||||
@ -34,7 +33,6 @@ urlpatterns = [
|
|||||||
path('candidate/<slug:slug>/view/', views_frontend.candidate_detail, name='candidate_detail'),
|
path('candidate/<slug:slug>/view/', views_frontend.candidate_detail, name='candidate_detail'),
|
||||||
path('candidate/<slug:slug>/update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'),
|
path('candidate/<slug:slug>/update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'),
|
||||||
|
|
||||||
|
|
||||||
# Training URLs
|
# Training URLs
|
||||||
path('training/', views_frontend.TrainingListView.as_view(), name='training_list'),
|
path('training/', views_frontend.TrainingListView.as_view(), name='training_list'),
|
||||||
path('training/create/', views_frontend.TrainingCreateView.as_view(), name='training_create'),
|
path('training/create/', views_frontend.TrainingCreateView.as_view(), name='training_create'),
|
||||||
@ -75,7 +73,8 @@ urlpatterns = [
|
|||||||
|
|
||||||
path('htmx/<int:pk>/candidate_criteria_view/', views.candidate_criteria_view_htmx, name='candidate_criteria_view_htmx'),
|
path('htmx/<int:pk>/candidate_criteria_view/', views.candidate_criteria_view_htmx, name='candidate_criteria_view_htmx'),
|
||||||
path('htmx/<slug:slug>/candidate_set_exam_date/', views.candidate_set_exam_date, name='candidate_set_exam_date'),
|
path('htmx/<slug:slug>/candidate_set_exam_date/', views.candidate_set_exam_date, name='candidate_set_exam_date'),
|
||||||
path('htmx/bulk_candidate_move_to_exam/', views.bulk_candidate_move_to_exam, name='bulk_candidate_move_to_exam'),
|
|
||||||
|
path('htmx/<slug:slug>/candidate_update_status/', views.candidate_update_status, name='candidate_update_status'),
|
||||||
|
|
||||||
path('forms/form/<int:template_id>/submit/', views.submit_form, name='submit_form'),
|
path('forms/form/<int:template_id>/submit/', views.submit_form, name='submit_form'),
|
||||||
path('forms/form/<int:template_id>/', views.form_wizard_view, name='form_wizard'),
|
path('forms/form/<int:template_id>/', views.form_wizard_view, name='form_wizard'),
|
||||||
@ -94,4 +93,12 @@ urlpatterns = [
|
|||||||
|
|
||||||
path('jobs/<slug:slug>/calendar/', views.interview_calendar_view, name='interview_calendar'),
|
path('jobs/<slug:slug>/calendar/', views.interview_calendar_view, name='interview_calendar'),
|
||||||
path('jobs/<slug:slug>/calendar/interview/<int:interview_id>/', views.interview_detail_view, name='interview_detail'),
|
path('jobs/<slug:slug>/calendar/interview/<int:interview_id>/', views.interview_detail_view, name='interview_detail'),
|
||||||
|
|
||||||
|
# Candidate Meeting Scheduling/Rescheduling URLs
|
||||||
|
path('jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/', views.schedule_candidate_meeting, name='schedule_candidate_meeting'),
|
||||||
|
path('api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/', views.api_schedule_candidate_meeting, name='api_schedule_candidate_meeting'),
|
||||||
|
path('jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/', views.reschedule_candidate_meeting, name='reschedule_candidate_meeting'),
|
||||||
|
path('api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/', views.api_reschedule_candidate_meeting, name='api_reschedule_candidate_meeting'),
|
||||||
|
# New URL for simple page-based meeting scheduling
|
||||||
|
path('jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting-page/', views.schedule_meeting_for_candidate, name='schedule_meeting_for_candidate'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -273,7 +273,7 @@ def get_zoom_meeting_details(meeting_id):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: A dictionary containing the meeting details or an error message.
|
dict: A dictionary containing the meeting details or an error message.
|
||||||
The 'start_time' in 'meeting_details' will be a Python datetime object.
|
Date/datetime fields in 'meeting_details' will be ISO format strings.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
access_token = get_access_token()
|
access_token = get_access_token()
|
||||||
@ -289,19 +289,26 @@ def get_zoom_meeting_details(meeting_id):
|
|||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
meeting_data = response.json()
|
meeting_data = response.json()
|
||||||
if 'start_time' in meeting_data and meeting_data['start_time']:
|
datetime_fields = [
|
||||||
try:
|
'start_time', 'created_at', 'updated_at',
|
||||||
# Convert ISO 8601 string (with 'Z' for UTC) to datetime object
|
'password_changed_at', 'host_join_before_start_time',
|
||||||
meeting_data['start_time'] = str(datetime.fromisoformat(
|
'audio_recording_start', 'recording_files_end' # Add any other known datetime fields
|
||||||
meeting_data['start_time'].replace('Z', '+00:00')
|
]
|
||||||
))
|
for field_name in datetime_fields:
|
||||||
except (ValueError, TypeError) as e:
|
if field_name in meeting_data and meeting_data[field_name] is not None:
|
||||||
logger.error(
|
try:
|
||||||
f"Failed to parse start_time '{meeting_data['start_time']}' for meeting {meeting_id}: {e}"
|
# Convert ISO 8601 string to datetime object, then back to ISO string
|
||||||
)
|
# This ensures consistent string format, handling 'Z' for UTC
|
||||||
meeting_data['start_time'] = None # Ensure it's None on failure
|
dt_obj = datetime.fromisoformat(meeting_data[field_name].replace('Z', '+00:00'))
|
||||||
else:
|
meeting_data[field_name] = dt_obj.isoformat()
|
||||||
meeting_data['start_time'] = None # Explicitly set to None if not present
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Could not parse or re-serialize datetime field '{field_name}' "
|
||||||
|
f"for meeting {meeting_id}: {e}. Original value: '{meeting_data[field_name]}'"
|
||||||
|
)
|
||||||
|
# Keep original string if re-serialization fails, or set to None
|
||||||
|
# meeting_data[field_name] = None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Meeting details retrieved successfully.",
|
"message": "Meeting details retrieved successfully.",
|
||||||
@ -563,3 +570,12 @@ def json_to_markdown_table(data_list):
|
|||||||
values = [str(row.get(header, "")) for header in headers]
|
values = [str(row.get(header, "")) for header in headers]
|
||||||
markdown += "| " + " | ".join(values) + " |\n"
|
markdown += "| " + " | ".join(values) + " |\n"
|
||||||
return markdown
|
return markdown
|
||||||
|
|
||||||
|
|
||||||
|
def get_candidates_from_request(request):
|
||||||
|
for c in request.POST.items():
|
||||||
|
try:
|
||||||
|
yield models.Candidate.objects.get(pk=c[0])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
yield None
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from rich import print
|
|||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
from django.http import JsonResponse
|
from django.http import HttpResponse, JsonResponse
|
||||||
from datetime import datetime,time,timedelta
|
from datetime import datetime,time,timedelta
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@ -31,6 +31,7 @@ from django.views.generic import CreateView, UpdateView, DetailView, ListView
|
|||||||
from .utils import (
|
from .utils import (
|
||||||
create_zoom_meeting,
|
create_zoom_meeting,
|
||||||
delete_zoom_meeting,
|
delete_zoom_meeting,
|
||||||
|
get_candidates_from_request,
|
||||||
update_zoom_meeting,
|
update_zoom_meeting,
|
||||||
get_zoom_meeting_details,
|
get_zoom_meeting_details,
|
||||||
schedule_interviews,
|
schedule_interviews,
|
||||||
@ -261,12 +262,13 @@ def create_job(request):
|
|||||||
else:
|
else:
|
||||||
job.created_by = request.POST.get("created_by", "").strip()
|
job.created_by = request.POST.get("created_by", "").strip()
|
||||||
if not job.created_by:
|
if not job.created_by:
|
||||||
job.created_by = "University Administrator"
|
job.created_by = request.user.username
|
||||||
|
|
||||||
job.save()
|
job.save()
|
||||||
job_apply_url_relative=reverse('job_detail_candidate',kwargs={'slug':job.slug})
|
job_apply_url_relative=reverse('job_detail_candidate',kwargs={'slug':job.slug})
|
||||||
job_apply_url_absolute=request.build_absolute_uri(job_apply_url_relative)
|
job_apply_url_absolute=request.build_absolute_uri(job_apply_url_relative)
|
||||||
job.application_url=job_apply_url_absolute
|
job.application_url=job_apply_url_absolute
|
||||||
|
FormTemplate.objects.create(job=job, is_active=True, name=job.title,created_by=request.user)
|
||||||
job.save()
|
job.save()
|
||||||
messages.success(request, f'Job "{job.title}" created successfully!')
|
messages.success(request, f'Job "{job.title}" created successfully!')
|
||||||
return redirect("job_list")
|
return redirect("job_list")
|
||||||
@ -1124,6 +1126,16 @@ def form_submission_details(request, template_id, slug):
|
|||||||
def schedule_interviews_view(request, slug):
|
def schedule_interviews_view(request, slug):
|
||||||
job = get_object_or_404(JobPosting, slug=slug)
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
|
|
||||||
|
# if request.method == "POST" and "Datastar-Request" in request.headers:
|
||||||
|
# form = InterviewScheduleForm(slug=slug)
|
||||||
|
# break_formset = BreakTimeFormSet()
|
||||||
|
# form.initial["candidates"] = get_candidates_from_request(request)
|
||||||
|
# def response():
|
||||||
|
# html = render_to_string("includes/schedule_interview_div.html",{"form": form, "break_formset": break_formset, "job": job})
|
||||||
|
# yield SSE.patch_elements(html,"#candidateviewModalBody")
|
||||||
|
# return DatastarResponse(response())
|
||||||
|
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = InterviewScheduleForm(slug, request.POST)
|
form = InterviewScheduleForm(slug, request.POST)
|
||||||
break_formset = BreakTimeFormSet(request.POST)
|
break_formset = BreakTimeFormSet(request.POST)
|
||||||
@ -1342,6 +1354,9 @@ def schedule_interviews_view(request, slug):
|
|||||||
else:
|
else:
|
||||||
form = InterviewScheduleForm(slug=slug)
|
form = InterviewScheduleForm(slug=slug)
|
||||||
break_formset = BreakTimeFormSet()
|
break_formset = BreakTimeFormSet()
|
||||||
|
print(request.headers)
|
||||||
|
if "Hx-Request" in request.headers:
|
||||||
|
form.initial["candidates"] = [Candidate.objects.get(pk=c[0]) for c in request.GET.items()]
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
@ -1634,12 +1649,10 @@ def candidate_screening_view(request, slug):
|
|||||||
# This catches if the user enters non-numeric text (e.g., "abc")
|
# This catches if the user enters non-numeric text (e.g., "abc")
|
||||||
min_ai_score = 0
|
min_ai_score = 0
|
||||||
tier1_count = 0
|
tier1_count = 0
|
||||||
print(min_ai_score)
|
|
||||||
print(tier1_count)
|
|
||||||
# You can now safely use min_ai_score and tier1_count as integers (0 or greater)
|
# You can now safely use min_ai_score and tier1_count as integers (0 or greater)
|
||||||
if min_ai_score > 0:
|
if min_ai_score > 0:
|
||||||
candidates = candidates.filter(match_score__gte=min_ai_score)
|
candidates = candidates.filter(match_score__gte=min_ai_score)
|
||||||
print(candidates)
|
|
||||||
|
|
||||||
if tier1_count > 0:
|
if tier1_count > 0:
|
||||||
candidates = candidates[:tier1_count]
|
candidates = candidates[:tier1_count]
|
||||||
@ -1662,13 +1675,7 @@ def candidate_screening_view(request, slug):
|
|||||||
return render(request, "recruitment/candidate_screening_view.html", context)
|
return render(request, "recruitment/candidate_screening_view.html", context)
|
||||||
|
|
||||||
|
|
||||||
def get_candidates_from_request(request):
|
|
||||||
for c in request.POST.items():
|
|
||||||
try:
|
|
||||||
yield Candidate.objects.get(pk=c[0])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(e)
|
|
||||||
yield None
|
|
||||||
def candidate_exam_view(request, slug):
|
def candidate_exam_view(request, slug):
|
||||||
"""
|
"""
|
||||||
Manage candidate tiers and stage transitions
|
Manage candidate tiers and stage transitions
|
||||||
@ -1691,7 +1698,6 @@ def update_candidate_exam_status(request, slug):
|
|||||||
def bulk_update_candidate_exam_status(request,slug):
|
def bulk_update_candidate_exam_status(request,slug):
|
||||||
job = get_object_or_404(JobPosting, slug=slug)
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
status = request.headers.get('status')
|
status = request.headers.get('status')
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
for candidate in get_candidates_from_request(request):
|
for candidate in get_candidates_from_request(request):
|
||||||
try:
|
try:
|
||||||
@ -1718,19 +1724,18 @@ def candidate_set_exam_date(request, slug):
|
|||||||
messages.success(request, f"Set exam date for {candidate.name} to {candidate.exam_date}")
|
messages.success(request, f"Set exam date for {candidate.name} to {candidate.exam_date}")
|
||||||
return redirect("candidate_screening_view", slug=candidate.job.slug)
|
return redirect("candidate_screening_view", slug=candidate.job.slug)
|
||||||
|
|
||||||
def bulk_candidate_move_to_exam(request):
|
def candidate_update_status(request, slug):
|
||||||
for candidate in get_candidates_from_request(request):
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
candidate.stage = "Exam"
|
mark_as = request.POST.get('mark_as')
|
||||||
candidate.applicant_status = "Candidate"
|
candidate_ids = request.POST.getlist("candidate_ids")
|
||||||
candidate.exam_date = timezone.now()
|
|
||||||
candidate.save()
|
|
||||||
|
|
||||||
messages.success(request, f"Candidates Moved to Exam stage")
|
if c := Candidate.objects.filter(pk__in = candidate_ids):
|
||||||
return redirect("candidate_screening_view", slug=candidate.job.slug)
|
c.update(stage=mark_as,exam_date=timezone.now(),applicant_status="Candidate" if mark_as in ["Exam","Interview","Offer"] else "Applicant")
|
||||||
# def response():
|
|
||||||
# yield SSE.patch_elements("","")
|
messages.success(request, f"Candidates Updated")
|
||||||
# yield SSE.execute_script("console.log('hello world');")
|
response = HttpResponse(redirect("candidate_screening_view", slug=job.slug))
|
||||||
# return DatastarResponse(response())
|
response.headers["HX-Refresh"] = "true"
|
||||||
|
return response
|
||||||
|
|
||||||
def candidate_interview_view(request,slug):
|
def candidate_interview_view(request,slug):
|
||||||
job = get_object_or_404(JobPosting,slug=slug)
|
job = get_object_or_404(JobPosting,slug=slug)
|
||||||
@ -1750,7 +1755,7 @@ def interview_calendar_view(request, slug):
|
|||||||
scheduled_interviews = ScheduledInterview.objects.filter(
|
scheduled_interviews = ScheduledInterview.objects.filter(
|
||||||
job=job
|
job=job
|
||||||
).select_related('candidate', 'zoom_meeting')
|
).select_related('candidate', 'zoom_meeting')
|
||||||
print(scheduled_interviews)
|
|
||||||
# Convert interviews to calendar events
|
# Convert interviews to calendar events
|
||||||
events = []
|
events = []
|
||||||
for interview in scheduled_interviews:
|
for interview in scheduled_interviews:
|
||||||
@ -1810,3 +1815,515 @@ def interview_detail_view(request, slug, interview_id):
|
|||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'recruitment/interview_detail.html', context)
|
return render(request, 'recruitment/interview_detail.html', context)
|
||||||
|
|
||||||
|
# Candidate Meeting Scheduling/Rescheduling Views
|
||||||
|
@require_POST
|
||||||
|
def api_schedule_candidate_meeting(request, job_slug, candidate_pk):
|
||||||
|
"""
|
||||||
|
Handle POST request to schedule a Zoom meeting for a candidate via HTMX.
|
||||||
|
Returns JSON response for modal update.
|
||||||
|
"""
|
||||||
|
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||||
|
candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job)
|
||||||
|
|
||||||
|
topic = f"Interview: {job.title} with {candidate.name}"
|
||||||
|
start_time_str = request.POST.get('start_time')
|
||||||
|
duration = int(request.POST.get('duration', 60))
|
||||||
|
|
||||||
|
if not start_time_str:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Start time is required.'}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse datetime from datetime-local input (YYYY-MM-DDTHH:MM)
|
||||||
|
# This will be in server's timezone, create_zoom_meeting will handle UTC conversion
|
||||||
|
naive_start_time = datetime.fromisoformat(start_time_str)
|
||||||
|
# Ensure it's timezone-aware if your system requires it, or let create_zoom_meeting handle it.
|
||||||
|
# For simplicity, assuming create_zoom_meeting handles naive datetimes or they are in UTC.
|
||||||
|
# If start_time is expected to be in a specific timezone, convert it here.
|
||||||
|
# e.g., start_time = timezone.make_aware(naive_start_time, timezone.get_current_timezone())
|
||||||
|
start_time = naive_start_time # Or timezone.make_aware(naive_start_time)
|
||||||
|
except ValueError:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Invalid date/time format for start time.'}, status=400)
|
||||||
|
|
||||||
|
if start_time <= timezone.now():
|
||||||
|
return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400)
|
||||||
|
|
||||||
|
result = create_zoom_meeting(topic=topic, start_time=start_time, duration=duration)
|
||||||
|
|
||||||
|
if result["status"] == "success":
|
||||||
|
zoom_meeting_details = result["meeting_details"]
|
||||||
|
zoom_meeting = ZoomMeeting.objects.create(
|
||||||
|
topic=topic,
|
||||||
|
start_time=start_time, # Store in local timezone
|
||||||
|
duration=duration,
|
||||||
|
meeting_id=zoom_meeting_details["meeting_id"],
|
||||||
|
join_url=zoom_meeting_details["join_url"],
|
||||||
|
password=zoom_meeting_details["password"],
|
||||||
|
# host_email=zoom_meeting_details["host_email"],
|
||||||
|
status=result["zoom_gateway_response"].get("status", "waiting"),
|
||||||
|
zoom_gateway_response=result["zoom_gateway_response"],
|
||||||
|
)
|
||||||
|
scheduled_interview = ScheduledInterview.objects.create(
|
||||||
|
candidate=candidate,
|
||||||
|
job=job,
|
||||||
|
zoom_meeting=zoom_meeting,
|
||||||
|
interview_date=start_time.date(),
|
||||||
|
interview_time=start_time.time(),
|
||||||
|
status='scheduled' # Or 'confirmed' depending on your workflow
|
||||||
|
)
|
||||||
|
messages.success(request, f"Meeting scheduled with {candidate.name}.")
|
||||||
|
|
||||||
|
# Return updated table row or a success message
|
||||||
|
# For HTMX, you might want to return a fragment of the updated table
|
||||||
|
# For now, returning JSON to indicate success and close modal
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Meeting scheduled successfully!',
|
||||||
|
'join_url': zoom_meeting.join_url,
|
||||||
|
'meeting_id': zoom_meeting.meeting_id,
|
||||||
|
'candidate_name': candidate.name,
|
||||||
|
'interview_datetime': start_time.strftime("%Y-%m-%d %H:%M")
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
messages.error(request, result["message"])
|
||||||
|
return JsonResponse({'success': False, 'error': result["message"]}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_candidate_meeting(request, job_slug, candidate_pk):
|
||||||
|
"""
|
||||||
|
GET: Render modal form to schedule a meeting. (For HTMX)
|
||||||
|
POST: Handled by api_schedule_candidate_meeting.
|
||||||
|
"""
|
||||||
|
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||||
|
candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
return api_schedule_candidate_meeting(request, job_slug, candidate_pk)
|
||||||
|
|
||||||
|
# GET request - render the form snippet for HTMX
|
||||||
|
context = {
|
||||||
|
'job': job,
|
||||||
|
'candidate': candidate,
|
||||||
|
'action_url': reverse('api_schedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk}),
|
||||||
|
'scheduled_interview': None, # Explicitly None for schedule
|
||||||
|
}
|
||||||
|
# Render just the form part, or the whole modal body content
|
||||||
|
return render(request, "includes/meeting_form.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def api_schedule_candidate_meeting(request, job_slug, candidate_pk):
|
||||||
|
"""
|
||||||
|
Handles GET to render form and POST to process scheduling.
|
||||||
|
"""
|
||||||
|
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||||
|
candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job)
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
# This GET is for HTMX to fetch the form
|
||||||
|
context = {
|
||||||
|
'job': job,
|
||||||
|
'candidate': candidate,
|
||||||
|
'action_url': reverse('api_schedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk}),
|
||||||
|
'scheduled_interview': None,
|
||||||
|
}
|
||||||
|
return render(request, "includes/meeting_form.html", context)
|
||||||
|
|
||||||
|
# POST logic (remains the same)
|
||||||
|
topic = f"Interview: {job.title} with {candidate.name}"
|
||||||
|
start_time_str = request.POST.get('start_time')
|
||||||
|
duration = int(request.POST.get('duration', 60))
|
||||||
|
|
||||||
|
if not start_time_str:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Start time is required.'}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
naive_start_time = datetime.fromisoformat(start_time_str)
|
||||||
|
start_time = naive_start_time
|
||||||
|
except ValueError:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Invalid date/time format for start time.'}, status=400)
|
||||||
|
|
||||||
|
if start_time <= timezone.now():
|
||||||
|
return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400)
|
||||||
|
|
||||||
|
result = create_zoom_meeting(topic=topic, start_time=start_time, duration=duration)
|
||||||
|
|
||||||
|
if result["status"] == "success":
|
||||||
|
zoom_meeting_details = result["meeting_details"]
|
||||||
|
zoom_meeting = ZoomMeeting.objects.create(
|
||||||
|
topic=topic,
|
||||||
|
start_time=start_time,
|
||||||
|
duration=duration,
|
||||||
|
meeting_id=zoom_meeting_details["meeting_id"],
|
||||||
|
join_url=zoom_meeting_details["join_url"],
|
||||||
|
password=zoom_meeting_details["password"],
|
||||||
|
host_email=zoom_meeting_details["host_email"],
|
||||||
|
status=result["zoom_gateway_response"].get("status", "waiting"),
|
||||||
|
zoom_gateway_response=result["zoom_gateway_response"],
|
||||||
|
)
|
||||||
|
scheduled_interview = ScheduledInterview.objects.create(
|
||||||
|
candidate=candidate,
|
||||||
|
job=job,
|
||||||
|
zoom_meeting=zoom_meeting,
|
||||||
|
interview_date=start_time.date(),
|
||||||
|
interview_time=start_time.time(),
|
||||||
|
status='scheduled'
|
||||||
|
)
|
||||||
|
messages.success(request, f"Meeting scheduled with {candidate.name}.")
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Meeting scheduled successfully!',
|
||||||
|
'join_url': zoom_meeting.join_url,
|
||||||
|
'meeting_id': zoom_meeting.meeting_id,
|
||||||
|
'candidate_name': candidate.name,
|
||||||
|
'interview_datetime': start_time.strftime("%Y-%m-%d %H:%M")
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
messages.error(request, result["message"])
|
||||||
|
return JsonResponse({'success': False, 'error': result["message"]}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk):
|
||||||
|
"""
|
||||||
|
Handles GET to render form and POST to process rescheduling.
|
||||||
|
"""
|
||||||
|
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||||
|
scheduled_interview = get_object_or_404(
|
||||||
|
ScheduledInterview.objects.select_related('zoom_meeting'),
|
||||||
|
pk=interview_pk,
|
||||||
|
candidate__pk=candidate_pk,
|
||||||
|
job=job
|
||||||
|
)
|
||||||
|
zoom_meeting = scheduled_interview.zoom_meeting
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
# This GET is for HTMX to fetch the form
|
||||||
|
initial_data = {
|
||||||
|
'topic': zoom_meeting.topic,
|
||||||
|
'start_time': zoom_meeting.start_time.strftime('%Y-%m-%dT%H:%M'),
|
||||||
|
'duration': zoom_meeting.duration,
|
||||||
|
}
|
||||||
|
context = {
|
||||||
|
'job': job,
|
||||||
|
'candidate': scheduled_interview.candidate,
|
||||||
|
'scheduled_interview': scheduled_interview, # Pass for conditional logic in template
|
||||||
|
'initial_data': initial_data,
|
||||||
|
'action_url': reverse('api_reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk})
|
||||||
|
}
|
||||||
|
return render(request, "includes/meeting_form.html", context)
|
||||||
|
|
||||||
|
# POST logic (remains the same)
|
||||||
|
new_start_time_str = request.POST.get('start_time')
|
||||||
|
new_duration = int(request.POST.get('duration', zoom_meeting.duration))
|
||||||
|
|
||||||
|
if not new_start_time_str:
|
||||||
|
return JsonResponse({'success': False, 'error': 'New start time is required.'}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
naive_new_start_time = datetime.fromisoformat(new_start_time_str)
|
||||||
|
new_start_time = naive_new_start_time
|
||||||
|
except ValueError:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Invalid date/time format for new start time.'}, status=400)
|
||||||
|
|
||||||
|
if new_start_time <= timezone.now():
|
||||||
|
return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400)
|
||||||
|
|
||||||
|
updated_data = {
|
||||||
|
"topic": f"Interview: {job.title} with {scheduled_interview.candidate.name}",
|
||||||
|
"start_time": new_start_time.isoformat() + "Z",
|
||||||
|
"duration": new_duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = update_zoom_meeting(zoom_meeting.meeting_id, updated_data)
|
||||||
|
|
||||||
|
if result["status"] == "success":
|
||||||
|
details_result = get_zoom_meeting_details(zoom_meeting.meeting_id)
|
||||||
|
if details_result["status"] == "success":
|
||||||
|
updated_zoom_details = details_result["meeting_details"]
|
||||||
|
zoom_meeting.topic = updated_zoom_details.get("topic", zoom_meeting.topic)
|
||||||
|
zoom_meeting.start_time = new_start_time
|
||||||
|
zoom_meeting.duration = new_duration
|
||||||
|
zoom_meeting.join_url = updated_zoom_details.get("join_url", zoom_meeting.join_url)
|
||||||
|
zoom_meeting.password = updated_zoom_details.get("password", zoom_meeting.password)
|
||||||
|
zoom_meeting.status = updated_zoom_details.get("status", zoom_meeting.status)
|
||||||
|
zoom_meeting.zoom_gateway_response = updated_zoom_details
|
||||||
|
zoom_meeting.save()
|
||||||
|
|
||||||
|
scheduled_interview.interview_date = new_start_time.date()
|
||||||
|
scheduled_interview.interview_time = new_start_time.time()
|
||||||
|
scheduled_interview.status = 'rescheduled'
|
||||||
|
scheduled_interview.save()
|
||||||
|
messages.success(request, f"Meeting for {scheduled_interview.candidate.name} rescheduled.")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Zoom meeting {zoom_meeting.meeting_id} updated, but failed to fetch latest details.")
|
||||||
|
zoom_meeting.start_time = new_start_time
|
||||||
|
zoom_meeting.duration = new_duration
|
||||||
|
zoom_meeting.save()
|
||||||
|
scheduled_interview.interview_date = new_start_time.date()
|
||||||
|
scheduled_interview.interview_time = new_start_time.time()
|
||||||
|
scheduled_interview.save()
|
||||||
|
messages.success(request, f"Meeting for {scheduled_interview.candidate.name} rescheduled. (Note: Could not refresh all details from Zoom.)")
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Meeting rescheduled successfully!',
|
||||||
|
'join_url': zoom_meeting.join_url,
|
||||||
|
'new_interview_datetime': new_start_time.strftime("%Y-%m-%d %H:%M")
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
messages.error(request, result["message"])
|
||||||
|
return JsonResponse({'success': False, 'error': result["message"]}, status=400)
|
||||||
|
|
||||||
|
# The original schedule_candidate_meeting and reschedule_candidate_meeting (without api_ prefix)
|
||||||
|
# can be removed if their only purpose was to be called by the JS onclicks.
|
||||||
|
# If they were intended for other direct URL access, they can be kept as simple redirects
|
||||||
|
# or wrappers to the api_ versions.
|
||||||
|
# For now, let's assume the api_ versions are the primary ones for HTMX.
|
||||||
|
|
||||||
|
|
||||||
|
def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk):
|
||||||
|
"""
|
||||||
|
Handles GET to display a form for rescheduling a meeting.
|
||||||
|
Handles POST to process the rescheduling of a meeting.
|
||||||
|
"""
|
||||||
|
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||||
|
candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job)
|
||||||
|
scheduled_interview = get_object_or_404(
|
||||||
|
ScheduledInterview.objects.select_related('zoom_meeting'),
|
||||||
|
pk=interview_pk,
|
||||||
|
candidate=candidate,
|
||||||
|
job=job
|
||||||
|
)
|
||||||
|
zoom_meeting = scheduled_interview.zoom_meeting
|
||||||
|
|
||||||
|
# Determine if the candidate has other future meetings
|
||||||
|
# This helps in providing context in the template
|
||||||
|
# Note: This checks for *any* future meetings for the candidate, not just the one being rescheduled.
|
||||||
|
# If candidate.has_future_meeting is True, it implies they have at least one other upcoming meeting,
|
||||||
|
# or the specific meeting being rescheduled is itself in the future.
|
||||||
|
# We can refine this logic if needed, e.g., check for meetings *other than* the current `interview_pk`.
|
||||||
|
has_other_future_meetings = candidate.has_future_meeting
|
||||||
|
# More precise check: if the current meeting being rescheduled is in the future, then by definition
|
||||||
|
# the candidate will have a future meeting (this one). The UI might want to know if there are *others*.
|
||||||
|
# For now, `candidate.has_future_meeting` is a good general indicator.
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = ZoomMeetingForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
new_topic = form.cleaned_data.get('topic')
|
||||||
|
new_start_time = form.cleaned_data.get('start_time')
|
||||||
|
new_duration = form.cleaned_data.get('duration')
|
||||||
|
|
||||||
|
# Use a default topic if not provided, keeping the original structure
|
||||||
|
if not new_topic:
|
||||||
|
new_topic = f"Interview: {job.title} with {candidate.name}"
|
||||||
|
|
||||||
|
# Ensure new_start_time is in the future
|
||||||
|
if new_start_time <= timezone.now():
|
||||||
|
messages.error(request, "Start time must be in the future.")
|
||||||
|
# Re-render form with error and initial data
|
||||||
|
return render(request, "recruitment/schedule_meeting_form.html", { # Reusing the same form template
|
||||||
|
'form': form,
|
||||||
|
'job': job,
|
||||||
|
'candidate': candidate,
|
||||||
|
'scheduled_interview': scheduled_interview,
|
||||||
|
'initial_topic': new_topic,
|
||||||
|
'initial_start_time': new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else '',
|
||||||
|
'initial_duration': new_duration,
|
||||||
|
'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}),
|
||||||
|
'has_future_meeting': has_other_future_meetings # Pass status for template
|
||||||
|
})
|
||||||
|
|
||||||
|
# Prepare data for Zoom API update
|
||||||
|
# The update_zoom_meeting expects start_time as ISO string with 'Z'
|
||||||
|
zoom_update_data = {
|
||||||
|
"topic": new_topic,
|
||||||
|
"start_time": new_start_time.isoformat() + "Z",
|
||||||
|
"duration": new_duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update Zoom meeting using utility function
|
||||||
|
zoom_update_result = update_zoom_meeting(zoom_meeting.meeting_id, zoom_update_data)
|
||||||
|
|
||||||
|
if zoom_update_result["status"] == "success":
|
||||||
|
# Fetch the latest details from Zoom after successful update
|
||||||
|
details_result = get_zoom_meeting_details(zoom_meeting.meeting_id)
|
||||||
|
|
||||||
|
if details_result["status"] == "success":
|
||||||
|
updated_zoom_details = details_result["meeting_details"]
|
||||||
|
# Update local ZoomMeeting record
|
||||||
|
zoom_meeting.topic = updated_zoom_details.get("topic", new_topic)
|
||||||
|
zoom_meeting.start_time = new_start_time # Store the original datetime
|
||||||
|
zoom_meeting.duration = new_duration
|
||||||
|
zoom_meeting.join_url = updated_zoom_details.get("join_url", zoom_meeting.join_url)
|
||||||
|
zoom_meeting.password = updated_zoom_details.get("password", zoom_meeting.password)
|
||||||
|
zoom_meeting.status = updated_zoom_details.get("status", zoom_meeting.status)
|
||||||
|
zoom_meeting.zoom_gateway_response = details_result.get("meeting_details")
|
||||||
|
zoom_meeting.save()
|
||||||
|
|
||||||
|
# Update ScheduledInterview record
|
||||||
|
scheduled_interview.interview_date = new_start_time.date()
|
||||||
|
scheduled_interview.interview_time = new_start_time.time()
|
||||||
|
scheduled_interview.status = 'rescheduled' # Or 'scheduled' if you prefer
|
||||||
|
scheduled_interview.save()
|
||||||
|
messages.success(request, f"Meeting for {candidate.name} rescheduled successfully.")
|
||||||
|
else:
|
||||||
|
# If fetching details fails, update with form data and log a warning
|
||||||
|
logger.warning(
|
||||||
|
f"Successfully updated Zoom meeting {zoom_meeting.meeting_id}, but failed to fetch updated details. "
|
||||||
|
f"Error: {details_result.get('message', 'Unknown error')}"
|
||||||
|
)
|
||||||
|
# Update with form data as a fallback
|
||||||
|
zoom_meeting.topic = new_topic
|
||||||
|
zoom_meeting.start_time = new_start_time
|
||||||
|
zoom_meeting.duration = new_duration
|
||||||
|
zoom_meeting.save()
|
||||||
|
scheduled_interview.interview_date = new_start_time.date()
|
||||||
|
scheduled_interview.interview_time = new_start_time.time()
|
||||||
|
scheduled_interview.save()
|
||||||
|
messages.success(request, f"Meeting for {candidate.name} rescheduled. (Note: Could not refresh all details from Zoom.)")
|
||||||
|
|
||||||
|
return redirect('candidate_interview_view', slug=job.slug)
|
||||||
|
else:
|
||||||
|
messages.error(request, f"Failed to update Zoom meeting: {zoom_update_result['message']}")
|
||||||
|
# Re-render form with error
|
||||||
|
return render(request, "recruitment/schedule_meeting_form.html", {
|
||||||
|
'form': form,
|
||||||
|
'job': job,
|
||||||
|
'candidate': candidate,
|
||||||
|
'scheduled_interview': scheduled_interview,
|
||||||
|
'initial_topic': new_topic,
|
||||||
|
'initial_start_time': new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else '',
|
||||||
|
'initial_duration': new_duration,
|
||||||
|
'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}),
|
||||||
|
'has_future_meeting': has_other_future_meetings
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Form validation errors
|
||||||
|
return render(request, "recruitment/schedule_meeting_form.html", {
|
||||||
|
'form': form,
|
||||||
|
'job': job,
|
||||||
|
'candidate': candidate,
|
||||||
|
'scheduled_interview': scheduled_interview,
|
||||||
|
'initial_topic': request.POST.get('topic', new_topic),
|
||||||
|
'initial_start_time': request.POST.get('start_time', new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else ''),
|
||||||
|
'initial_duration': request.POST.get('duration', new_duration),
|
||||||
|
'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}),
|
||||||
|
'has_future_meeting': has_other_future_meetings
|
||||||
|
})
|
||||||
|
else: # GET request
|
||||||
|
# Pre-populate form with existing meeting details
|
||||||
|
initial_data = {
|
||||||
|
'topic': zoom_meeting.topic,
|
||||||
|
'start_time': zoom_meeting.start_time.strftime('%Y-%m-%dT%H:%M'),
|
||||||
|
'duration': zoom_meeting.duration,
|
||||||
|
}
|
||||||
|
form = ZoomMeetingForm(initial=initial_data)
|
||||||
|
return render(request, "recruitment/schedule_meeting_form.html", {
|
||||||
|
'form': form,
|
||||||
|
'job': job,
|
||||||
|
'candidate': candidate,
|
||||||
|
'scheduled_interview': scheduled_interview, # Pass to template for title/differentiation
|
||||||
|
'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}),
|
||||||
|
'has_future_meeting': has_other_future_meetings # Pass status for template
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_meeting_for_candidate(request, job_slug, candidate_pk):
|
||||||
|
"""
|
||||||
|
Handles GET to display a simple form for scheduling a meeting for a candidate.
|
||||||
|
Handles POST to process the form, create the meeting, and redirect back.
|
||||||
|
"""
|
||||||
|
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||||
|
candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = ZoomMeetingForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
topic_val = form.cleaned_data.get('topic')
|
||||||
|
start_time_val = form.cleaned_data.get('start_time')
|
||||||
|
duration_val = form.cleaned_data.get('duration')
|
||||||
|
|
||||||
|
# Use a default topic if not provided
|
||||||
|
if not topic_val:
|
||||||
|
topic_val = f"Interview: {job.title} with {candidate.name}"
|
||||||
|
|
||||||
|
# Ensure start_time is in the future
|
||||||
|
if start_time_val <= timezone.now():
|
||||||
|
messages.error(request, "Start time must be in the future.")
|
||||||
|
# Re-render form with error and initial data
|
||||||
|
return render(request, "recruitment/schedule_meeting_form.html", {
|
||||||
|
'form': form,
|
||||||
|
'job': job,
|
||||||
|
'candidate': candidate,
|
||||||
|
'initial_topic': topic_val,
|
||||||
|
'initial_start_time': start_time_val.strftime('%Y-%m-%dT%H:%M') if start_time_val else '',
|
||||||
|
'initial_duration': duration_val
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create Zoom meeting using utility function
|
||||||
|
# The create_zoom_meeting expects start_time as a datetime object
|
||||||
|
# and handles its own conversion to UTC for the API call.
|
||||||
|
zoom_creation_result = create_zoom_meeting(
|
||||||
|
topic=topic_val,
|
||||||
|
start_time=start_time_val, # Pass the datetime object
|
||||||
|
duration=duration_val
|
||||||
|
)
|
||||||
|
|
||||||
|
if zoom_creation_result["status"] == "success":
|
||||||
|
zoom_details = zoom_creation_result["meeting_details"]
|
||||||
|
zoom_meeting_instance = ZoomMeeting.objects.create(
|
||||||
|
topic=topic_val,
|
||||||
|
start_time=start_time_val, # Store the original datetime
|
||||||
|
duration=duration_val,
|
||||||
|
meeting_id=zoom_details["meeting_id"],
|
||||||
|
join_url=zoom_details["join_url"],
|
||||||
|
password=zoom_details.get("password"), # password might be None
|
||||||
|
status=zoom_creation_result["zoom_gateway_response"].get("status", "waiting"),
|
||||||
|
zoom_gateway_response=zoom_creation_result["zoom_gateway_response"],
|
||||||
|
)
|
||||||
|
# Create a ScheduledInterview record
|
||||||
|
ScheduledInterview.objects.create(
|
||||||
|
candidate=candidate,
|
||||||
|
job=job,
|
||||||
|
zoom_meeting=zoom_meeting_instance,
|
||||||
|
interview_date=start_time_val.date(),
|
||||||
|
interview_time=start_time_val.time(),
|
||||||
|
status='scheduled'
|
||||||
|
)
|
||||||
|
messages.success(request, f"Meeting scheduled with {candidate.name}.")
|
||||||
|
return redirect('candidate_interview_view', slug=job.slug)
|
||||||
|
else:
|
||||||
|
messages.error(request, f"Failed to create Zoom meeting: {zoom_creation_result['message']}")
|
||||||
|
# Re-render form with error
|
||||||
|
return render(request, "recruitment/schedule_meeting_form.html", {
|
||||||
|
'form': form,
|
||||||
|
'job': job,
|
||||||
|
'candidate': candidate,
|
||||||
|
'initial_topic': topic_val,
|
||||||
|
'initial_start_time': start_time_val.strftime('%Y-%m-%dT%H:%M') if start_time_val else '',
|
||||||
|
'initial_duration': duration_val
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Form validation errors
|
||||||
|
return render(request, "recruitment/schedule_meeting_form.html", {
|
||||||
|
'form': form,
|
||||||
|
'job': job,
|
||||||
|
'candidate': candidate,
|
||||||
|
'initial_topic': request.POST.get('topic', f"Interview: {job.title} with {candidate.name}"),
|
||||||
|
'initial_start_time': request.POST.get('start_time', ''),
|
||||||
|
'initial_duration': request.POST.get('duration', 60)
|
||||||
|
})
|
||||||
|
else: # GET request
|
||||||
|
initial_data = {
|
||||||
|
'topic': f"Interview: {job.title} with {candidate.name}",
|
||||||
|
'start_time': (timezone.now() + timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M'), # Default to 1 hour from now
|
||||||
|
'duration': 60, # Default duration
|
||||||
|
}
|
||||||
|
form = ZoomMeetingForm(initial=initial_data)
|
||||||
|
return render(request, "recruitment/schedule_meeting_form.html", {
|
||||||
|
'form': form,
|
||||||
|
'job': job,
|
||||||
|
'candidate': candidate
|
||||||
|
})
|
||||||
|
|||||||
@ -588,7 +588,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
|
||||||
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.5/bundles/datastar.js"></script>
|
{% comment %} <script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.5/bundles/datastar.js"></script> {% endcomment %}
|
||||||
|
|
||||||
{% block customJS %}{% endblock %}
|
{% block customJS %}{% endblock %}
|
||||||
|
|
||||||
|
|||||||
150
templates/includes/meeting_form.html
Normal file
150
templates/includes/meeting_form.html
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
<!-- This snippet is loaded by HTMX into #meetingModalBody -->
|
||||||
|
<form id="meetingForm" method="post" action="{{ action_url }}?_target=modal" data-bs-theme="light">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="candidate_pk" value="{{ candidate.pk }}">
|
||||||
|
{% if scheduled_interview %}
|
||||||
|
<input type="hidden" name="interview_pk" value="{{ scheduled_interview.pk }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_topic" class="form-label">{% trans "Topic" %}</label>
|
||||||
|
<input type="text" class="form-control" id="id_topic" name="topic" value="{{ initial_data.topic|default:'' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_start_time" class="form-label">{% trans "Start Time and Date" %}</label>
|
||||||
|
<input type="datetime-local" class="form-control" id="id_start_time" name="start_time" value="{{ initial_data.start_time|default:'' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_duration" class="form-label">{% trans "Duration (minutes)" %}</label>
|
||||||
|
<input type="number" class="form-control" id="id_duration" name="duration" value="{{ initial_data.duration|default:60 }}" min="15" step="15" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="meetingDetails" class="alert alert-info" style="display: none;">
|
||||||
|
<strong>{% trans "Meeting Details (will appear after scheduling):" %}</strong>
|
||||||
|
<p><strong>{% trans "Join URL:" %}</strong> <a id="joinUrlDisplay" href="#" target="_blank"></a></p>
|
||||||
|
<p><strong>{% trans "Meeting ID:" %}</strong> <span id="meetingIdDisplay"></span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="successMessage" class="alert alert-success" style="display: none;">
|
||||||
|
<span id="successText"></span>
|
||||||
|
<small><a id="joinLinkSuccess" href="#" target="_blank" style="color: inherit; text-decoration: underline;">{% trans "Click here to join meeting" %}</a></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="errorMessage" class="alert alert-danger" style="display: none;">
|
||||||
|
<span id="errorText"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-dismiss="modal">{% trans "Cancel" %}</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="scheduleBtn">
|
||||||
|
{% if scheduled_interview %}{% trans "Reschedule Meeting" %}{% else %}{% trans "Schedule Meeting" %}{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% block customJS %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('meetingForm');
|
||||||
|
const scheduleBtn = document.getElementById('scheduleBtn');
|
||||||
|
const meetingDetailsDiv = document.getElementById('meetingDetails');
|
||||||
|
const joinUrlDisplay = document.getElementById('joinUrlDisplay');
|
||||||
|
const meetingIdDisplay = document.getElementById('meetingIdDisplay');
|
||||||
|
const successMessageDiv = document.getElementById('successMessage');
|
||||||
|
const successText = document.getElementById('successText');
|
||||||
|
const joinLinkSuccess = document.getElementById('joinLinkSuccess');
|
||||||
|
const errorMessageDiv = document.getElementById('errorMessage');
|
||||||
|
const errorText = document.getElementById('errorText');
|
||||||
|
const modalElement = document.getElementById('meetingModal'); // This should be on the parent page
|
||||||
|
const modalTitle = document.querySelector('#meetingModal .modal-title'); // Parent page element
|
||||||
|
|
||||||
|
// Update modal title based on data attributes from the triggering button (if available)
|
||||||
|
// This is a fallback, ideally the parent page JS updates the title before fetching.
|
||||||
|
// Or, the view context could set a variable for the title.
|
||||||
|
// For simplicity, we'll assume parent page JS or rely on initial context.
|
||||||
|
const modalTitleText = modalTitle.getAttribute('data-current-title') || "{% trans 'Schedule Interview' %}";
|
||||||
|
if (modalTitle) {
|
||||||
|
modalTitle.textContent = modalTitleText;
|
||||||
|
}
|
||||||
|
const submitBtnText = scheduleBtn.getAttribute('data-current-submit-text') || "{% trans 'Schedule Meeting' %}";
|
||||||
|
scheduleBtn.textContent = submitBtnText;
|
||||||
|
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
meetingDetailsDiv.style.display = 'none';
|
||||||
|
successMessageDiv.style.display = 'none';
|
||||||
|
errorMessageDiv.style.display = 'none';
|
||||||
|
|
||||||
|
scheduleBtn.disabled = true;
|
||||||
|
scheduleBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span> {% trans "Processing..." %}';
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
// Check if the target is modal, if so, we might want to close it on success
|
||||||
|
const isModalTarget = new URLSearchParams(window.location.search).get('_target') === 'modal';
|
||||||
|
const url = form.action.replace(/\?.*$/, ""); // Remove any existing query params like _target
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': formData.get('csrfmiddlewaretoken'),
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(response => response.json()) // Always expect JSON for HTMX success/error
|
||||||
|
.then(data => {
|
||||||
|
scheduleBtn.disabled = false;
|
||||||
|
scheduleBtn.innerHTML = submitBtnText; // Reset to original text
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
successText.textContent = data.message;
|
||||||
|
successMessageDiv.style.display = 'block';
|
||||||
|
if (data.join_url) {
|
||||||
|
joinUrlDisplay.textContent = data.join_url;
|
||||||
|
joinUrlDisplay.href = data.join_url;
|
||||||
|
joinLinkSuccess.href = data.join_url;
|
||||||
|
meetingDetailsDiv.style.display = 'block'; // Keep meeting details shown
|
||||||
|
}
|
||||||
|
if (data.meeting_id) {
|
||||||
|
meetingIdDisplay.textContent = data.meeting_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isModalTarget && modalElement) {
|
||||||
|
const bsModal = bootstrap.Modal.getInstance(modalElement);
|
||||||
|
if (bsModal) bsModal.hide();
|
||||||
|
}
|
||||||
|
// Optionally, trigger an event on the parent page to update its list
|
||||||
|
if (window.parent && window.parent.dispatchEvent) {
|
||||||
|
window.parent.dispatchEvent(new CustomEvent('meetingUpdated', { detail: data }));
|
||||||
|
} else {
|
||||||
|
// Fallback: reload the page if it's not in an iframe or parent dispatch is not available
|
||||||
|
// window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
errorText.textContent = data.error || '{% trans "An unknown error occurred." %}';
|
||||||
|
errorMessageDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
scheduleBtn.disabled = false;
|
||||||
|
scheduleBtn.innerHTML = submitBtnText;
|
||||||
|
errorText.textContent = '{% trans "An error occurred while processing your request." %}';
|
||||||
|
errorMessageDiv.style.display = 'block';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Repopulate if initial_data was passed (for rescheduling)
|
||||||
|
{% if initial_data %}
|
||||||
|
document.getElementById('id_topic').value = '{{ initial_data.topic }}';
|
||||||
|
document.getElementById('id_start_time').value = '{{ initial_data.start_time }}';
|
||||||
|
document.getElementById('id_duration').value = '{{ initial_data.duration }}';
|
||||||
|
{% endif %}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
148
templates/includes/schedule_interview_div.html
Normal file
148
templates/includes/schedule_interview_div.html
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
<div class="container mt-4">
|
||||||
|
<h1>Schedule Interviews for {{ job.title }}</h1>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" id="schedule-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5>Select Candidates</h5>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.candidates }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5>Schedule Details</h5>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="{{ form.start_date.id_for_label }}">Start Date</label>
|
||||||
|
{{ form.start_date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="{{ form.end_date.id_for_label }}">End Date</label>
|
||||||
|
{{ form.end_date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label>Working Days</label>
|
||||||
|
{{ form.working_days }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="{{ form.start_time.id_for_label }}">Start Time</label>
|
||||||
|
{{ form.start_time }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="{{ form.end_time.id_for_label }}">End Time</label>
|
||||||
|
{{ form.end_time }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="{{ form.interview_duration.id_for_label }}">Interview Duration (minutes)</label>
|
||||||
|
{{ form.interview_duration }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="{{ form.buffer_time.id_for_label }}">Buffer Time (minutes)</label>
|
||||||
|
{{ form.buffer_time }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h5>Break Times</h5>
|
||||||
|
<div id="break-times-container">
|
||||||
|
{{ break_formset.management_form }}
|
||||||
|
{% for form in break_formset %}
|
||||||
|
<div class="break-time-form row mb-2">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label>Start Time</label>
|
||||||
|
{{ form.start_time }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label>End Time</label>
|
||||||
|
{{ form.end_time }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label> </label><br>
|
||||||
|
{{ form.DELETE }}
|
||||||
|
<button type="button" class="btn btn-danger btn-sm remove-break">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="button" id="add-break" class="btn btn-secondary btn-sm mt-2">Add Break</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary">Preview Schedule</button>
|
||||||
|
<a href="{% url 'job_detail' slug=job.slug %}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const addBreakBtn = document.getElementById('add-break');
|
||||||
|
const breakTimesContainer = document.getElementById('break-times-container');
|
||||||
|
const totalFormsInput = document.getElementById('id_breaks-TOTAL_FORMS');
|
||||||
|
|
||||||
|
addBreakBtn.addEventListener('click', function() {
|
||||||
|
const formCount = parseInt(totalFormsInput.value);
|
||||||
|
const newFormHtml = `
|
||||||
|
<div class="break-time-form row mb-2">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label>Start Time</label>
|
||||||
|
<input type="time" name="breaks-${formCount}-start_time" class="form-control" id="id_breaks-${formCount}-start_time">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label>End Time</label>
|
||||||
|
<input type="time" name="breaks-${formCount}-end_time" class="form-control" id="id_breaks-${formCount}-end_time">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label> </label><br>
|
||||||
|
<input type="checkbox" name="breaks-${formCount}-DELETE" id="id_breaks-${formCount}-DELETE" style="display:none;">
|
||||||
|
<button type="button" class="btn btn-danger btn-sm remove-break">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = newFormHtml;
|
||||||
|
const newForm = tempDiv.firstChild;
|
||||||
|
|
||||||
|
breakTimesContainer.appendChild(newForm);
|
||||||
|
totalFormsInput.value = formCount + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle remove button clicks
|
||||||
|
breakTimesContainer.addEventListener('click', function(e) {
|
||||||
|
if (e.target.classList.contains('remove-break')) {
|
||||||
|
const form = e.target.closest('.break-time-form');
|
||||||
|
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||||||
|
deleteCheckbox.checked = true;
|
||||||
|
form.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -2,7 +2,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mt-4">
|
<div class="container mt-4 interview-schedule">
|
||||||
<h1>Schedule Interviews for {{ job.title }}</h1>
|
<h1>Schedule Interviews for {{ job.title }}</h1>
|
||||||
|
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
|
|||||||
@ -253,32 +253,23 @@
|
|||||||
|
|
||||||
<div class="kaauh-card shadow-sm p-3">
|
<div class="kaauh-card shadow-sm p-3">
|
||||||
<div class="candidate-table-responsive" data-signals__ifmissing="{_fetching: false, selections: Array({{ candidates|length }}).fill(false)}">
|
<div class="candidate-table-responsive" data-signals__ifmissing="{_fetching: false, selections: Array({{ candidates|length }}).fill(false)}">
|
||||||
{% url "bulk_update_candidate_exam_status" job.slug as bulk_update_candidate_exam_status_url %}
|
<div class="col-md-3 col-sm-6 mb-3 d-flex gap-2">
|
||||||
|
|
||||||
<div class="mb-3 d-flex gap-2">
|
|
||||||
{% if candidates %}
|
{% if candidates %}
|
||||||
<button class="btn btn-bulk-pass btn-sm"
|
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post">
|
||||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
<div class="d-flex align-items-center">
|
||||||
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
|
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="height: 3rem;">
|
||||||
contentType: 'form',
|
<option value="Applied">
|
||||||
selector: '#candidate-form',
|
<i class="fas fa-arrow-left me-1"></i> {% trans "Apply" %}
|
||||||
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'Passed'}
|
</option>
|
||||||
})"
|
<option value="Interview">
|
||||||
>
|
<i class="fas fa-arrow-right me-1"></i> {% trans "Interview" %}
|
||||||
<i class="fas fa-check-circle me-1"></i>
|
</option>
|
||||||
{% trans "Bulk Mark Passed (-> Interview)" %}
|
</select>
|
||||||
</button>
|
<button type="submit" class="btn btn-main-action btn-mds ms-2">
|
||||||
<button class="btn btn-bulk-fail btn-sm"
|
<i class="fas fa-arrow-right me-1"></i> {% trans "Update" %}
|
||||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
</button>
|
||||||
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
|
</div>
|
||||||
contentType: 'form',
|
</form>
|
||||||
selector: '#candidate-form',
|
|
||||||
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'Failed'}
|
|
||||||
})"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times-circle me-1"></i>
|
|
||||||
{% trans "Bulk Mark Failed" %}
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -291,11 +282,7 @@
|
|||||||
{% if candidates %}
|
{% if candidates %}
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input
|
<input
|
||||||
data-bind-_all
|
type="checkbox" class="form-check-input" id="selectAllCheckbox">
|
||||||
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
|
|
||||||
data-effect="$selections; $_all = $selections.every(Boolean)"
|
|
||||||
data-attr-disabled="$_fetching"
|
|
||||||
type="checkbox" class="form-check-input" id="checkAll">
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</th>
|
</th>
|
||||||
@ -312,13 +299,11 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input
|
<input
|
||||||
data-bind-selections
|
name="candidate_ids"
|
||||||
data-attr-disabled="$_fetching"
|
value="{{ candidate.id }}"
|
||||||
name="candidate_ids"
|
type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}">
|
||||||
value="{{ candidate.id }}"
|
</div>
|
||||||
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="candidate-name">
|
<div class="candidate-name">
|
||||||
@ -412,3 +397,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block customJS %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||||
|
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
|
||||||
|
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
|
||||||
|
// Function to safely update the header checkbox state
|
||||||
|
function updateSelectAllState() {
|
||||||
|
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
|
||||||
|
const totalCount = rowCheckboxes.length;
|
||||||
|
|
||||||
|
if (checkedCount === 0) {
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
} else if (checkedCount === totalCount) {
|
||||||
|
selectAllCheckbox.checked = true;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
} else {
|
||||||
|
// Set to indeterminate state (partially checked)
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: We do NOT fire a change event here to prevent the infinite loop.
|
||||||
|
// Your existing data-bind-_all logic should handle the bulk action status.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Logic for the 'Select All' checkbox (Clicking it updates all rows)
|
||||||
|
selectAllCheckbox.addEventListener('change', function () {
|
||||||
|
const isChecked = selectAllCheckbox.checked;
|
||||||
|
|
||||||
|
// Temporarily disable the change listener on rows to prevent cascading events
|
||||||
|
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
|
||||||
|
|
||||||
|
// Update all row checkboxes
|
||||||
|
rowCheckboxes.forEach(function (checkbox) {
|
||||||
|
checkbox.checked = isChecked;
|
||||||
|
|
||||||
|
// You must still dispatch the event here so your framework's data-bind-selections
|
||||||
|
// picks up the change on individual elements. This should NOT trigger the updateSelectAllState.
|
||||||
|
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-attach the change listeners to the rows
|
||||||
|
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
|
||||||
|
|
||||||
|
// Ensure the header state is correct after forcing all changes
|
||||||
|
updateSelectAllState();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Logic to update 'Select All' state based on row checkboxes
|
||||||
|
// Attach the function to be called whenever a row checkbox changes
|
||||||
|
rowCheckboxes.forEach(function (checkbox) {
|
||||||
|
checkbox.addEventListener('change', updateSelectAllState);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial check to set the correct state on load (in case items are pre-checked)
|
||||||
|
updateSelectAllState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -214,15 +214,16 @@
|
|||||||
<!-- Tier Display -->
|
<!-- Tier Display -->
|
||||||
<h2 class="h4 mb-3 mt-5">{% trans "Candidate Tiers" %}</h2>
|
<h2 class="h4 mb-3 mt-5">{% trans "Candidate Tiers" %}</h2>
|
||||||
<div class="candidate-table-responsive" data-signals__ifmissing="{_fetching: false, selections: Array({{ candidates|length }}).fill(false)}">
|
<div class="candidate-table-responsive" data-signals__ifmissing="{_fetching: false, selections: Array({{ candidates|length }}).fill(false)}">
|
||||||
{% url "candidate_interview_view" job.slug as bulk_update_candidate_exam_status_url %}
|
{% url "schedule_interviews" job.slug as bulk_update_candidate_exam_status_url %}
|
||||||
{% if candidates %}
|
{% if candidates %}
|
||||||
<button class="btn btn-primary"
|
<button class="btn btn-primary"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#candidateviewModal"
|
||||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
||||||
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
|
hx-get="{{bulk_update_candidate_exam_status_url}}"
|
||||||
contentType: 'form',
|
hx-target="#candidateviewModalBody"
|
||||||
selector: '#myform',
|
hx-include="#myform"
|
||||||
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'pass'}
|
hx-select=".interview-schedule"
|
||||||
})"
|
|
||||||
>Mark as Pass and move to Interview</button>
|
>Mark as Pass and move to Interview</button>
|
||||||
<button class="btn btn-danger"
|
<button class="btn btn-danger"
|
||||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
||||||
@ -233,7 +234,7 @@
|
|||||||
})"
|
})"
|
||||||
>Mark as Failed</button>
|
>Mark as Failed</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form id="myform" action="{{move_to_exam_url}}" method="post">
|
<form id="myform" action="{{bulk_update_candidate_exam_status_url}}" method="post">
|
||||||
<table class="candidate-table">
|
<table class="candidate-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -282,14 +283,9 @@
|
|||||||
<td>{{candidate.get_latest_meeting.start_time|date:"m-d-Y h:i A"}}</td>
|
<td>{{candidate.get_latest_meeting.start_time|date:"m-d-Y h:i A"}}</td>
|
||||||
<td><a href="{{candidate.get_latest_meeting.join_url}}">{% include "icons/link.html" %}</a></td>
|
<td><a href="{{candidate.get_latest_meeting.join_url}}">{% include "icons/link.html" %}</a></td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-primary btn-sm"
|
<a href="{% url 'schedule_meeting_for_candidate' job.slug candidate.pk %}" class="btn btn-primary btn-sm me-1" title="{% trans 'Schedule Interview' %}">
|
||||||
data-bs-toggle="modal"
|
<i class="fas fa-calendar-plus"></i>
|
||||||
data-bs-target="#candidateviewModal"
|
</a>
|
||||||
hx-get="{% url 'candidate_criteria_view_htmx' candidate.pk %}"
|
|
||||||
hx-target="#candidateviewModalBody"
|
|
||||||
>
|
|
||||||
{% include "icons/view.html" %}
|
|
||||||
{% trans "View" %}</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -299,7 +295,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Tab Content -->
|
<!-- Tab Content -->
|
||||||
|
|
||||||
<div class="modal fade modal-lg" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
|
<div class="modal fade modal-xl" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@ -315,4 +311,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Meeting Schedule/Reschedule Modal -->
|
||||||
|
<div class="modal fade" id="meetingModal" tabindex="-1" aria-labelledby="meetingModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="meetingModalLabel">
|
||||||
|
{% trans "Schedule Interview" %} <!-- Default title -->
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div id="meetingModalBody" class="modal-body">
|
||||||
|
<!-- HTMX will load the form here -->
|
||||||
|
<p class="text-center text-muted">{% trans "Loading form..." %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<!-- HTMX might update the button text or add specific actions here if needed -->
|
||||||
|
<!-- For now, the form itself has its own submit and cancel buttons -->
|
||||||
|
<!-- The cancel button inside the form will close the modal -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block customJS %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const meetingModalElement = document.getElementById('meetingModal');
|
||||||
|
const meetingModalTitle = meetingModalElement.querySelector('#meetingModalLabel');
|
||||||
|
// The submit button is now inside the form snippet loaded by HTMX.
|
||||||
|
// We will pass title info via data-* attributes or rely on the snippet's initial rendering.
|
||||||
|
|
||||||
|
// Add event listeners to buttons that trigger this modal
|
||||||
|
// Using event delegation for dynamically added buttons
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
const button = event.target.closest('button[data-bs-toggle="modal"][data-bs-target="#meetingModal"]');
|
||||||
|
if (button) {
|
||||||
|
event.preventDefault(); // Prevent default if button has an href or type="submit"
|
||||||
|
|
||||||
|
const modalTitle = button.getAttribute('data-modal-title');
|
||||||
|
const modalSubmitText = button.getAttribute('data-modal-submit-text');
|
||||||
|
|
||||||
|
if (meetingModalTitle) {
|
||||||
|
meetingModalTitle.textContent = modalTitle || "{% trans 'Schedule Interview' %}";
|
||||||
|
}
|
||||||
|
// The submit button text is now handled by the meeting_form.html snippet itself,
|
||||||
|
// based on whether 'scheduled_interview' context is passed.
|
||||||
|
|
||||||
|
// Show the modal first, then HTMX will load the content into #meetingModalBody
|
||||||
|
const modal = new bootstrap.Modal(meetingModalElement);
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
// HTMX attributes (hx-get, hx-target, hx-swap) on the button will trigger the fetch
|
||||||
|
// after the modal is shown. HTMX handles this automatically.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional: Clear HTMX target content if modal is hidden without submission
|
||||||
|
meetingModalElement.addEventListener('hidden.bs.modal', function () {
|
||||||
|
const modalBody = meetingModalElement.querySelector('#meetingModalBody');
|
||||||
|
if (modalBody) {
|
||||||
|
// Reset to a loading message or clear, so next open fetches fresh
|
||||||
|
modalBody.innerHTML = '<p class="text-center text-muted">{% trans "Loading form..." %}</p>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The old JS functions (loadScheduleMeetingForm, loadRescheduleMeetingForm) are no longer needed
|
||||||
|
// as HTMX handles fetching the form. They can be removed if not used elsewhere.
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -194,44 +194,40 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="filter-controls shadow-sm">
|
<div class="filter-controls shadow-sm">
|
||||||
<h4 class="h6 mb-3 fw-bold" style="color: var(--kaauh-primary-text);">
|
<h4 class="h6 mb-3 fw-bold" style="color: var(--kaauh-primary-text);">
|
||||||
<i class="fas fa-sort-numeric-up me-1"></i> {% trans "AI Scoring & Top Candidate Filter" %}
|
<i class="fas fa-sort-numeric-up me-1"></i> {% trans "AI Scoring & Top Candidate Filter" %}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<form method="GET" class="mb-0">
|
<form method="GET" class="mb-0 pb-3">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="row g-3 align-items-end">
|
<div class="d-flex flex-nowrap g-3 align-items-end" style="overflow-x: auto;">
|
||||||
|
|
||||||
<div class="col-md-2 col-sm-6">
|
<div class="p-2">
|
||||||
<label for="min_ai_score" class="form-label small text-muted mb-1">
|
<label for="min_ai_score" class="form-label small text-muted mb-1">
|
||||||
{% trans "Min AI Score" %}
|
{% trans "Min AI Score" %}
|
||||||
</label>
|
</label>
|
||||||
<input type="number" name="min_ai_score" id="min_ai_score" class="form-control form-control-sm"
|
<input type="number" name="min_ai_score" id="min_ai_score" class="form-control form-control-sm"
|
||||||
value="{{ min_ai_score}}" min="0" max="100" step="1"
|
value="{{ min_ai_score}}" min="0" max="100" step="1"
|
||||||
placeholder="e.g., 75">
|
placeholder="e.g., 75" style="min-width: 120px;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-2 col-sm-6">
|
<div class="p-2">
|
||||||
<label for="tier1_count" class="form-label small text-muted mb-1">
|
<label for="tier1_count" class="form-label small text-muted mb-1">
|
||||||
{% trans "Top N" %}
|
{% trans "Top N" %}
|
||||||
</label>
|
</label>
|
||||||
<input type="number" name="tier1_count" id="tier1_count" class="form-control form-control-sm"
|
<input type="number" name="tier1_count" id="tier1_count" class="form-control form-control-sm"
|
||||||
value="{{ tier1_count }}" min="1" max="{{ total_candidates }}">
|
value="{{ tier1_count }}" min="1" max="{{ total_candidates }}" style="min-width: 100px;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3 col-sm-6">
|
<div class="p-2">
|
||||||
<button type="submit" name="update_tiers" class="btn btn-main-action btn-sm w-100">
|
<label class="form-label small text-muted mb-1 d-block"> </label>
|
||||||
<i class="fas fa-sync-alt me-1"></i> {% trans "Update Filters" %}
|
<button type="submit" name="update_tiers" class="btn btn-main-action btn-sm w-100" style="min-width: 150px;">
|
||||||
</button>
|
<i class="fas fa-sync-alt me-1"></i> {% trans "Update Filters" %}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
{% comment %} Empty col for spacing (2 + 2 + 3 + 5 = 12) {% endcomment %}
|
</div>
|
||||||
<div class="col-md-5 d-none d-md-block"></div>
|
</form>
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="h4 mb-3" style="color: var(--kaauh-primary-text);">
|
<h2 class="h4 mb-3" style="color: var(--kaauh-primary-text);">
|
||||||
@ -240,22 +236,23 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="kaauh-card shadow-sm p-3">
|
<div class="kaauh-card shadow-sm p-3">
|
||||||
{% url "bulk_candidate_move_to_exam" as move_to_exam_url %}
|
|
||||||
|
|
||||||
{% if candidates %}
|
{% if candidates %}
|
||||||
<button class="btn btn-bulk-action btn-sm mb-3"
|
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post">
|
||||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
<div class="d-flex align-items-center">
|
||||||
data-on-click="@post('{{move_to_exam_url}}',{
|
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="height: 3rem;">
|
||||||
contentType: 'form',
|
<option value="Exam">
|
||||||
selector: '#candidate-form',
|
<i class="fas fa-arrow-right me-1"></i> {% trans "Exam" %}
|
||||||
headers: {'X-CSRFToken': '{{ csrf_token }}'}})"
|
</option>
|
||||||
>
|
</select>
|
||||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Bulk Move to Exam" %}
|
<button type="submit" class="btn btn-main-action btn-mds ms-2">
|
||||||
</button>
|
<i class="fas fa-arrow-right me-1"></i> {% trans "Update" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<form id="candidate-form" action="{{move_to_exam_url}}" method="post">
|
<form id="candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<table class="table candidate-table align-middle">
|
<table class="table candidate-table align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
@ -264,11 +261,7 @@
|
|||||||
{% if candidates %}
|
{% if candidates %}
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input
|
<input
|
||||||
data-bind-_all
|
type="checkbox" class="form-check-input" id="selectAllCheckbox">
|
||||||
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
|
|
||||||
data-effect="$selections; $_all = $selections.every(Boolean)"
|
|
||||||
data-attr-disabled="$_fetching"
|
|
||||||
type="checkbox" class="form-check-input" id="checkAll">
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</th>
|
</th>
|
||||||
@ -286,11 +279,9 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input
|
<input
|
||||||
data-bind-selections
|
|
||||||
data-attr-disabled="$_fetching"
|
|
||||||
name="candidate_ids"
|
name="candidate_ids"
|
||||||
value="{{ candidate.id }}"
|
value="{{ candidate.id }}"
|
||||||
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
|
type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}">
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -375,3 +366,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block customJS %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||||
|
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
|
||||||
|
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
|
||||||
|
// Function to safely update the header checkbox state
|
||||||
|
function updateSelectAllState() {
|
||||||
|
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
|
||||||
|
const totalCount = rowCheckboxes.length;
|
||||||
|
|
||||||
|
if (checkedCount === 0) {
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
} else if (checkedCount === totalCount) {
|
||||||
|
selectAllCheckbox.checked = true;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
} else {
|
||||||
|
// Set to indeterminate state (partially checked)
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: We do NOT fire a change event here to prevent the infinite loop.
|
||||||
|
// Your existing data-bind-_all logic should handle the bulk action status.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Logic for the 'Select All' checkbox (Clicking it updates all rows)
|
||||||
|
selectAllCheckbox.addEventListener('change', function () {
|
||||||
|
const isChecked = selectAllCheckbox.checked;
|
||||||
|
|
||||||
|
// Temporarily disable the change listener on rows to prevent cascading events
|
||||||
|
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
|
||||||
|
|
||||||
|
// Update all row checkboxes
|
||||||
|
rowCheckboxes.forEach(function (checkbox) {
|
||||||
|
checkbox.checked = isChecked;
|
||||||
|
|
||||||
|
// You must still dispatch the event here so your framework's data-bind-selections
|
||||||
|
// picks up the change on individual elements. This should NOT trigger the updateSelectAllState.
|
||||||
|
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-attach the change listeners to the rows
|
||||||
|
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
|
||||||
|
|
||||||
|
// Ensure the header state is correct after forcing all changes
|
||||||
|
updateSelectAllState();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Logic to update 'Select All' state based on row checkboxes
|
||||||
|
// Attach the function to be called whenever a row checkbox changes
|
||||||
|
rowCheckboxes.forEach(function (checkbox) {
|
||||||
|
checkbox.addEventListener('change', updateSelectAllState);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial check to set the correct state on load (in case items are pre-checked)
|
||||||
|
updateSelectAllState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
96
templates/recruitment/schedule_meeting_form.html
Normal file
96
templates/recruitment/schedule_meeting_form.html
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Schedule Meeting" %} - {{ job.title }} - ATS{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">
|
||||||
|
<i class="fas fa-calendar-plus me-2"></i>
|
||||||
|
{% if has_future_meeting %}
|
||||||
|
{% trans "Update Interview" %} for {{ candidate.name }}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Schedule Interview" %} for {{ candidate.name }}
|
||||||
|
{% endif %}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted mb-0">{% trans "Job" %}: {{ job.title }}</p>
|
||||||
|
{% if has_future_meeting %}
|
||||||
|
<div class="alert alert-info mt-2 mb-0" role="alert">
|
||||||
|
<i class="fas fa-info-circle me-1"></i>
|
||||||
|
{% trans "This candidate has upcoming interviews. You are updating an existing schedule." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'candidate_interview_view' job.slug %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Candidates" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.topic.id_for_label }}" class="form-label">
|
||||||
|
{% trans "Meeting Topic" %}
|
||||||
|
</label>
|
||||||
|
{{ form.topic }}
|
||||||
|
{% if form.topic.errors %}
|
||||||
|
<div class="text-danger">
|
||||||
|
{% for error in form.topic.errors %}
|
||||||
|
<small>{{ error }}</small>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">
|
||||||
|
{% trans "Default topic will be 'Interview: [Job Title] with [Candidate Name]' if left empty." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.start_time.id_for_label }}" class="form-label">
|
||||||
|
{% trans "Start Time" %}
|
||||||
|
</label>
|
||||||
|
{{ form.start_time }}
|
||||||
|
{% if form.start_time.errors %}
|
||||||
|
<div class="text-danger">
|
||||||
|
{% for error in form.start_time.errors %}
|
||||||
|
<small>{{ error }}</small>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">
|
||||||
|
{% trans "Please select a date and time for the interview." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="{{ form.duration.id_for_label }}" class="form-label">
|
||||||
|
{% trans "Duration (minutes)" %}
|
||||||
|
</label>
|
||||||
|
{{ form.duration }}
|
||||||
|
{% if form.duration.errors %}
|
||||||
|
<div class="text-danger">
|
||||||
|
{% for error in form.duration.errors %}
|
||||||
|
<small>{{ error }}</small>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save me-1"></i> {% trans "Schedule Meeting" %}
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'candidate_interview_view' job.slug %}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Loading…
x
Reference in New Issue
Block a user