finish interview and validation

This commit is contained in:
ismail 2025-12-02 12:59:34 +03:00
parent c4115efb52
commit 670ff55883
20 changed files with 877 additions and 559 deletions

View File

@ -199,7 +199,7 @@ ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_EMAIL_VERIFICATION = 'none'
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
ACCOUNT_EMAIL_VERIFICATION = "optional"
ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True

View File

@ -5,7 +5,7 @@ from django.utils import timezone
from .models import (
JobPosting, Application, TrainingMaterial,
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
SharedFormTemplate, Source, HiringAgency, IntegrationLog,BulkInterviewTemplate,JobPostingImage,InterviewNote,
SharedFormTemplate, Source, HiringAgency, IntegrationLog,BulkInterviewTemplate,JobPostingImage,Note,
AgencyAccessLink, AgencyJobAssignment,Interview,ScheduledInterview
)
from django.contrib.auth import get_user_model
@ -250,4 +250,4 @@ admin.site.register(ScheduledInterview)
admin.site.register(JobPostingImage)
admin.site.register(User)
# admin.site.register(User)

View File

@ -18,7 +18,7 @@ from .models import (
BulkInterviewTemplate,
BreakTime,
JobPostingImage,
InterviewNote,
Note,
ScheduledInterview,
Source,
HiringAgency,
@ -720,94 +720,100 @@ class FormTemplateForm(forms.ModelForm):
# BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
# class BulkInterviewTemplateForm(forms.ModelForm):
# applications = forms.ModelMultipleChoiceField(
# queryset=Application.objects.none(),
# widget=forms.CheckboxSelectMultiple,
# required=True,
# )
# working_days = forms.MultipleChoiceField(
# choices=[
# (0, "Monday"),
# (1, "Tuesday"),
# (2, "Wednesday"),
# (3, "Thursday"),
# (4, "Friday"),
# (5, "Saturday"),
# (6, "Sunday"),
# ],
# widget=forms.CheckboxSelectMultiple,
# required=True,
# )
class BulkInterviewTemplateForm(forms.ModelForm):
applications = forms.ModelMultipleChoiceField(
queryset=Application.objects.none(),
widget=forms.CheckboxSelectMultiple,
required=True,
)
working_days = forms.MultipleChoiceField(
choices=[
(0, "Monday"),
(1, "Tuesday"),
(2, "Wednesday"),
(3, "Thursday"),
(4, "Friday"),
(5, "Saturday"),
(6, "Sunday"),
],
widget=forms.CheckboxSelectMultiple,
required=True,
)
# class Meta:
# model = BulkInterviewTemplate
# fields = [
# 'schedule_interview_type',
# "applications",
# "start_date",
# "end_date",
# "working_days",
# "start_time",
# "end_time",
# "interview_duration",
# "buffer_time",
# "break_start_time",
# "break_end_time",
# ]
# widgets = {
# "start_date": forms.DateInput(
# attrs={"type": "date", "class": "form-control"}
# ),
# "end_date": forms.DateInput(
# attrs={"type": "date", "class": "form-control"}
# ),
# "start_time": forms.TimeInput(
# attrs={"type": "time", "class": "form-control"}
# ),
# "end_time": forms.TimeInput(
# attrs={"type": "time", "class": "form-control"}
# ),
# "interview_duration": forms.NumberInput(attrs={"class": "form-control"}),
# "buffer_time": forms.NumberInput(attrs={"class": "form-control"}),
# "break_start_time": forms.TimeInput(
# attrs={"type": "time", "class": "form-control"}
# ),
# "break_end_time": forms.TimeInput(
# attrs={"type": "time", "class": "form-control"}
# ),
# "schedule_interview_type":forms.RadioSelect()
# }
class Meta:
model = BulkInterviewTemplate
fields = [
'schedule_interview_type',
'topic',
'physical_address',
"applications",
"start_date",
"end_date",
"working_days",
"start_time",
"end_time",
"interview_duration",
"buffer_time",
"break_start_time",
"break_end_time",
]
widgets = {
"topic": forms.TextInput(attrs={"class": "form-control"}),
"start_date": forms.DateInput(
attrs={"type": "date", "class": "form-control"}
),
"end_date": forms.DateInput(
attrs={"type": "date", "class": "form-control"}
),
"start_time": forms.TimeInput(
attrs={"type": "time", "class": "form-control"}
),
"end_time": forms.TimeInput(
attrs={"type": "time", "class": "form-control"}
),
"interview_duration": forms.NumberInput(attrs={"class": "form-control"}),
"buffer_time": forms.NumberInput(attrs={"class": "form-control"}),
"break_start_time": forms.TimeInput(
attrs={"type": "time", "class": "form-control"}
),
"break_end_time": forms.TimeInput(
attrs={"type": "time", "class": "form-control"}
),
"schedule_interview_type":forms.RadioSelect(),
"physical_address": forms.Textarea(
attrs={"class": "form-control", "rows": 3, "placeholder": "Enter physical address if 'In-Person' is selected"}
),
}
# def __init__(self, slug, *args, **kwargs):
# super().__init__(*args, **kwargs)
# self.fields["applications"].queryset = Application.objects.filter(
# job__slug=slug, stage="Interview"
# )
def __init__(self, slug, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["applications"].queryset = Application.objects.filter(
job__slug=slug, stage="Interview"
)
# def clean_working_days(self):
# working_days = self.cleaned_data.get("working_days")
# return [int(day) for day in working_days]
def clean_working_days(self):
working_days = self.cleaned_data.get("working_days")
return [int(day) for day in working_days]
# class InterviewNoteForm(forms.ModelForm):
# """Form for creating and editing meeting comments"""
class NoteForm(forms.ModelForm):
"""Form for creating and editing meeting comments"""
# class Meta:
# model = InterviewNote
# fields = ["content"]
# widgets = {
# "content": CKEditor5Widget(
# attrs={
# "class": "form-control",
# "placeholder": _("Enter your comment or note"),
# },
# config_name="extends",
# ),
# }
# labels = {
# "content": _("Comment"),
# }
class Meta:
model = Note
fields = "__all__"
widgets = {
"content": CKEditor5Widget(
attrs={
"class": "form-control",
"placeholder": _("Enter your comment or note"),
},
config_name="extends",
),
}
labels = {
"content": _("Comment"),
}
# def __init__(self, *args, **kwargs):
# super().__init__(*args, **kwargs)

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-12-01 12:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='bulkinterviewtemplate',
name='schedule_interview_type',
field=models.CharField(choices=[('Remote', 'Remote (e.g., Zoom)'), ('Onsite', 'In-Person (Physical Location)')], default='Onsite', max_length=10, verbose_name='Interview Type'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-12-01 13:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0002_bulkinterviewtemplate_schedule_interview_type'),
]
operations = [
migrations.AddField(
model_name='bulkinterviewtemplate',
name='physical_address',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.2.6 on 2025-12-01 14:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0003_bulkinterviewtemplate_physical_address'),
]
operations = [
migrations.AddField(
model_name='bulkinterviewtemplate',
name='topic',
field=models.CharField(default='', max_length=255, verbose_name='Interview Topic'),
preserve_default=False,
),
]

View File

@ -1397,6 +1397,7 @@ class BulkInterviewTemplate(Base):
working_days = models.JSONField(
verbose_name=_("Working Days")
)
topic = models.CharField(max_length=255, verbose_name=_("Interview Topic"))
start_time = models.TimeField(verbose_name=_("Start Time"))
end_time = models.TimeField(verbose_name=_("End Time"))
@ -1414,6 +1415,14 @@ class BulkInterviewTemplate(Base):
buffer_time = models.PositiveIntegerField(
verbose_name=_("Buffer Time (minutes)"), default=0
)
schedule_interview_type = models.CharField(
max_length=10,
choices=[('Remote', 'Remote (e.g., Zoom)'), ('Onsite', 'In-Person (Physical Location)')],
default='Onsite',
verbose_name=_("Interview Type"),
)
physical_address = models.CharField(max_length=255, blank=True, null=True)
created_by = models.ForeignKey(
User, on_delete=models.CASCADE, db_index=True
)
@ -1509,7 +1518,7 @@ class ScheduledInterview(Base):
return self.interview_location
# --- 3. Interview Notes Model (Fixed) ---
class InterviewNote(Base):
class Note(Base):
"""Model for storing notes, feedback, or comments related to a specific ScheduledInterview."""
class NoteType(models.TextChoices):
@ -1517,13 +1526,24 @@ class InterviewNote(Base):
LOGISTICS = 'Logistics', _('Logistical Note')
GENERAL = 'General', _('General Comment')
1
application = models.ForeignKey(
Application,
on_delete=models.CASCADE,
related_name="notes",
verbose_name=_("Application"),
db_index=True,
null=True,
blank=True
)
interview = models.ForeignKey(
Interview,
on_delete=models.CASCADE,
related_name="notes",
verbose_name=_("Scheduled Interview"),
db_index=True
db_index=True,
null=True,
blank=True
)
author = models.ForeignKey(
@ -2692,3 +2712,5 @@ class Document(Base):
if self.file:
return self.file.name.split(".")[-1].upper()
return ""

View File

@ -12,7 +12,7 @@ from . linkedin_service import LinkedInService
from django.shortcuts import get_object_or_404
from . models import JobPosting
from django.utils import timezone
from . models import ScheduledInterview,Interview,Message
from . models import BulkInterviewTemplate,Interview,Message,ScheduledInterview
from django.contrib.auth import get_user_model
User = get_user_model()
# Add python-docx import for Word document processing
@ -668,7 +668,7 @@ def handle_resume_parsing_and_scoring(pk: int):
from django.utils import timezone
def create_interview_and_meeting(
candidate_id,
application_id,
job_id,
schedule_id,
slot_date,
@ -679,24 +679,13 @@ def create_interview_and_meeting(
Synchronous task for a single interview slot, dispatched by django-q.
"""
try:
application = Application.objects.get(pk=candidate_id)
application = Application.objects.get(pk=application_id)
job = JobPosting.objects.get(pk=job_id)
schedule = ScheduledInterview.objects.get(pk=schedule_id)
schedule = BulkInterviewTemplate.objects.get(pk=schedule_id)
interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time))
meeting_topic = f"Interview for {job.title} - {application.name}"
meeting_topic = schedule.topic
# 1. External API Call (Slow)
# "status": "success",
# "message": "Meeting created successfully.",
# "meeting_details": {
# "join_url": meeting_data['join_url'],
# "meeting_id": meeting_data['id'],
# "password": meeting_data['password'],
# "host_email": meeting_data['host_email']
# },
# "zoom_gateway_response": meeting_data
# }
result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
if result["status"] == "success":
@ -711,33 +700,19 @@ def create_interview_and_meeting(
password=result["meeting_details"]["password"],
location_type="Remote"
)
schedule = ScheduledInterview.objects.create(
application=application,
job=job,
schedule=schedule,
interview_date=slot_date,
interview_time=slot_time,
interview=interview
)
schedule.interview = interview
schedule.status = "scheduled"
schedule.save()
# 2. Database Writes (Slow)
# zoom_meeting = ZoomMeetingDetails.objects.create(
# topic=meeting_topic,
# start_time=interview_datetime,
# duration=duration,
# meeting_id=result["meeting_details"]["meeting_id"],
# details_url=result["meeting_details"]["join_url"],
# zoom_gateway_response=result["zoom_gateway_response"],
# host_email=result["meeting_details"]["host_email"],
# password=result["meeting_details"]["password"],
# location_type="Remote"
# )
# ScheduledInterview.objects.create(
# application=candidate,
# job=job,
# interview_location=zoom_meeting,
# schedule=schedule,
# interview_date=slot_date,
# interview_time=slot_time
# )
# Log success or use Django-Q result system for monitoring
logger.info(f"Successfully scheduled interview for {Application.name}")
return True # Task succeeded
else:

View File

@ -587,16 +587,16 @@ urlpatterns = [
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
#interview and meeting related urls
# path(
# "jobs/<slug:slug>/schedule-interviews/",
# views.schedule_interviews_view,
# name="schedule_interviews",
# ),
# path(
# "jobs/<slug:slug>/confirm-schedule-interviews/",
# views.confirm_schedule_interviews_view,
# name="confirm_schedule_interviews_view",
# ),
path(
"jobs/<slug:slug>/schedule-interviews/",
views.schedule_interviews_view,
name="schedule_interviews",
),
path(
"jobs/<slug:slug>/confirm-schedule-interviews/",
views.confirm_schedule_interviews_view,
name="confirm_schedule_interviews_view",
),
# path(
# "meetings/create-meeting/",
@ -682,5 +682,6 @@ urlpatterns = [
# Email invitation URLs
# path("interviews/meetings/<slug:slug>/send-application-invitation/", views.send_application_invitation, name="send_application_invitation"),
# path("interviews/meetings/<slug:slug>/send-participants-invitation/", views.send_participants_invitation, name="send_participants_invitation"),
path("note/<slug:slug>/application_add_note/", views.application_add_note, name="application_add_note"),
path("note/<slug:slug>/interview_add_note/", views.interview_add_note, name="interview_add_note"),
]

View File

@ -33,7 +33,8 @@ from .forms import (
PasswordResetForm,
StaffAssignmentForm,
RemoteInterviewForm,
OnsiteInterviewForm
OnsiteInterviewForm,
BulkInterviewTemplateForm
)
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
@ -132,7 +133,8 @@ from .models import (
Source,
Message,
Document,
Interview
Interview,
BulkInterviewTemplate
)
@ -1463,323 +1465,293 @@ def form_submission_details(request, template_id, slug):
)
# def _handle_get_request(request, slug, job):
# """
# Handles GET requests, setting up forms and restoring candidate selections
# from the session for persistence.
# """
# SESSION_KEY = f"schedule_candidate_ids_{slug}"
def _handle_get_request(request, slug, job):
"""
Handles GET requests, setting up forms and restoring candidate selections
from the session for persistence.
"""
SESSION_KEY = f"schedule_candidate_ids_{slug}"
# form = BulkInterviewTemplateForm(slug=slug)
# # break_formset = BreakTimeFormSet(prefix='breaktime')
form = BulkInterviewTemplateForm(slug=slug)
# break_formset = BreakTimeFormSet(prefix='breaktime')
# selected_ids = []
selected_ids = []
# # 1. Capture IDs from HTMX request and store in session (when first clicked)
# if "HX-Request" in request.headers:
# candidate_ids = request.GET.getlist("candidate_ids")
# 1. Capture IDs from HTMX request and store in session (when first clicked)
if "HX-Request" in request.headers:
candidate_ids = request.GET.getlist("candidate_ids")
# if candidate_ids:
# request.session[SESSION_KEY] = candidate_ids
# selected_ids = candidate_ids
if candidate_ids:
request.session[SESSION_KEY] = candidate_ids
selected_ids = candidate_ids
# # 2. Restore IDs from session (on refresh or navigation)
# if not selected_ids:
# selected_ids = request.session.get(SESSION_KEY, [])
# 2. Restore IDs from session (on refresh or navigation)
if not selected_ids:
selected_ids = request.session.get(SESSION_KEY, [])
# # 3. Use the list of IDs to initialize the form
# if selected_ids:
# candidates_to_load = Application.objects.filter(pk__in=selected_ids)
# print(candidates_to_load)
# form.initial["applications"] = candidates_to_load
# 3. Use the list of IDs to initialize the form
if selected_ids:
candidates_to_load = Application.objects.filter(pk__in=selected_ids)
form.initial["applications"] = candidates_to_load
# return render(
# request,
# "interviews/schedule_interviews.html",
# {"form": form, "job": job},
# )
#TODO:MAIN FUNCTIONS
# def _handle_preview_submission(request, slug, job):
# """
# Handles the initial POST request (Preview Schedule).
# Validates forms, calculates slots, saves data to session, and renders preview.
# """
# SESSION_DATA_KEY = "interview_schedule_data"
# form = BulkInterviewTemplateForm(slug, request.POST)
# # break_formset = BreakTimeFormSet(request.POST,prefix='breaktime')
# if form.is_valid():
# # Get the form data
# applications = form.cleaned_data["applications"]
# start_date = form.cleaned_data["start_date"]
# end_date = form.cleaned_data["end_date"]
# working_days = form.cleaned_data["working_days"]
# start_time = form.cleaned_data["start_time"]
# end_time = form.cleaned_data["end_time"]
# interview_duration = form.cleaned_data["interview_duration"]
# buffer_time = form.cleaned_data["buffer_time"]
# break_start_time = form.cleaned_data["break_start_time"]
# break_end_time = form.cleaned_data["break_end_time"]
# schedule_interview_type=form.cleaned_data["schedule_interview_type"]
# # Process break times
# # breaks = []
# # for break_form in break_formset:
# # print(break_form.cleaned_data)
# # if break_form.cleaned_data and not break_form.cleaned_data.get("DELETE"):
# # breaks.append(
# # {
# # "start_time": break_form.cleaned_data["start_time"].strftime("%H:%M:%S"),
# # "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"),
# # }
# # )
# # Create a temporary schedule object (not saved to DB)
# temp_schedule = BulkInterviewTemplate(
# job=job,
# start_date=start_date,
# end_date=end_date,
# working_days=working_days,
# start_time=start_time,
# end_time=end_time,
# interview_duration=interview_duration,
# buffer_time=buffer_time or 5,
# break_start_time=break_start_time or None,
# break_end_time=break_end_time or None,
# )
# # Get available slots (temp_breaks logic moved into get_available_time_slots if needed)
# available_slots = get_available_time_slots(temp_schedule)
# if len(available_slots) < len(applications):
# messages.error(
# request,
# f"Not enough available slots. Required: {len(applications)}, Available: {len(available_slots)}",
# )
# return render(
# request,
# "interviews/schedule_interviews.html",
# {"form": form, "job": job},
# )
# # Create a preview schedule
# preview_schedule = []
# for i, application in enumerate(applications):
# slot = available_slots[i]
# preview_schedule.append(
# {"application": application, "date": slot["date"], "time": slot["time"]}
# )
# # Save the form data to session for later use
# schedule_data = {
# "start_date": start_date.isoformat(),
# "end_date": end_date.isoformat(),
# "working_days": working_days,
# "start_time": start_time.isoformat(),
# "end_time": end_time.isoformat(),
# "interview_duration": interview_duration,
# "buffer_time": buffer_time,
# "break_start_time": break_start_time.isoformat() if break_start_time else None,
# "break_end_time": break_end_time.isoformat() if break_end_time else None,
# "candidate_ids": [c.id for c in applications],
# "schedule_interview_type":schedule_interview_type
# }
# request.session[SESSION_DATA_KEY] = schedule_data
# # Render the preview page
# return render(
# request,
# "interviews/preview_schedule.html",
# {
# "job": job,
# "schedule": preview_schedule,
# "start_date": start_date,
# "end_date": end_date,
# "working_days": working_days,
# "start_time": start_time,
# "end_time": end_time,
# "break_start_time": break_start_time,
# "break_end_time": break_end_time,
# "interview_duration": interview_duration,
# "buffer_time": buffer_time,
# "schedule_interview_type":schedule_interview_type,
# "form":OnsiteLocationForm()
# },
# )
# else:
# # Re-render the form if validation fails
# return render(
# request,
# "interviews/schedule_interviews.html",
# {"form": form, "job": job},
# )
# def _handle_confirm_schedule(request, slug, job):
# """
# Handles the final POST request (Confirm Schedule).
# Creates the main schedule record and queues individual interviews asynchronously.
# """
# SESSION_DATA_KEY = "interview_schedule_data"
# SESSION_ID_KEY = f"schedule_candidate_ids_{slug}"
# # 1. Get schedule data from session
# schedule_data = request.session.get(SESSION_DATA_KEY)
# if not schedule_data:
# messages.error(request, "Session expired. Please try again.")
# return redirect("schedule_interviews", slug=slug)
# # 2. Create the Interview Schedule (Parent Record)
# try:
# # Handle break times: If they exist, convert them; otherwise, pass None.
# break_start = schedule_data.get("break_start_time")
# break_end = schedule_data.get("break_end_time")
# schedule = BulkInterviewTemplate.objects.create(
# job=job,
# created_by=request.user,
# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
# working_days=schedule_data["working_days"],
# start_time=time.fromisoformat(schedule_data["start_time"]),
# end_time=time.fromisoformat(schedule_data["end_time"]),
# interview_duration=schedule_data["interview_duration"],
# buffer_time=schedule_data["buffer_time"],
# # Convert time strings to time objects only if they exist and handle None gracefully
# break_start_time=time.fromisoformat(break_start) if break_start else None,
# break_end_time=time.fromisoformat(break_end) if break_end else None,
# schedule_interview_type=schedule_data.get("schedule_interview_type")
# )
# except Exception as e:
# # Clear data on failure to prevent stale data causing repeated errors
# messages.error(request, f"Error creating schedule: {e}")
# if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
# if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
# return redirect("schedule_interviews", slug=slug)
# # 3. Setup candidates and get slots
# candidates = Application.objects.filter(id__in=schedule_data["candidate_ids"])
# schedule.applications.set(candidates)
# available_slots = get_available_time_slots(schedule)
# # 4. Handle Remote/Onsite logic
# if schedule_data.get("schedule_interview_type") == 'Remote':
# # ... (Remote logic remains unchanged)
# queued_count = 0
# for i, candidate in enumerate(candidates):
# if i < len(available_slots):
# slot = available_slots[i]
# async_task(
# "recruitment.tasks.create_interview_and_meeting",
# candidate.pk, job.pk, schedule.pk, slot["date"], slot["time"], schedule.interview_duration,
# )
# queued_count += 1
# messages.success(
# request,
# f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!",
# )
# if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
# if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
# return redirect("job_detail", slug=slug)
# elif schedule_data.get("schedule_interview_type") == 'Onsite':
# print("inside...")
# if request.method == 'POST':
# form = OnsiteLocationForm(request.POST)
# if form.is_valid():
# if not available_slots:
# messages.error(request, "No available slots found for the selected schedule range.")
# return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
# # Extract common location data from the form
# physical_address = form.cleaned_data['physical_address']
# room_number = form.cleaned_data['room_number']
# topic=form.cleaned_data['topic']
# try:
# # 1. Iterate over candidates and create a NEW Location object for EACH
# for i, candidate in enumerate(candidates):
# if i < len(available_slots):
# slot = available_slots[i]
# location_start_dt = datetime.combine(slot['date'], schedule.start_time)
# # --- CORE FIX: Create a NEW Location object inside the loop ---
# onsite_location = OnsiteLocationDetails.objects.create(
# start_time=location_start_dt,
# duration=schedule.interview_duration,
# physical_address=physical_address,
# room_number=room_number,
# location_type="Onsite",
# topic=topic
# )
# # 2. Create the ScheduledInterview, linking the unique location
# ScheduledInterview.objects.create(
# application=candidate,
# job=job,
# schedule=schedule,
# interview_date=slot['date'],
# interview_time=slot['time'],
# interview_location=onsite_location,
# )
# messages.success(
# request,
# f"Onsite schedule interviews created successfully for {len(candidates)} candidates."
# )
# # Clear session data keys upon successful completion
# if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
# if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
# return redirect('job_detail', slug=job.slug)
# except Exception as e:
# messages.error(request, f"Error creating onsite location/interviews: {e}")
# # On failure, re-render the form with the error and ensure 'job' is present
# return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
# else:
# # Form is invalid, re-render with errors
# # Ensure 'job' is passed to prevent NoReverseMatch
# return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
# else:
# # For a GET request
# form = OnsiteLocationForm()
# return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
return render(
request,
"interviews/schedule_interviews.html",
{"form": form, "job": job},
)
# def schedule_interviews_view(request, slug):
# job = get_object_or_404(JobPosting, slug=slug)
# if request.method == "POST":
# # return _handle_confirm_schedule(request, slug, job)
# return _handle_preview_submission(request, slug, job)
# else:
# return _handle_get_request(request, slug, job)
def _handle_preview_submission(request, slug, job):
"""
Handles the initial POST request (Preview Schedule).
Validates forms, calculates slots, saves data to session, and renders preview.
"""
SESSION_DATA_KEY = "interview_schedule_data"
form = BulkInterviewTemplateForm(slug, request.POST)
# break_formset = BreakTimeFormSet(request.POST,prefix='breaktime')
if form.is_valid():
# Get the form data
applications = form.cleaned_data["applications"]
start_date = form.cleaned_data["start_date"]
end_date = form.cleaned_data["end_date"]
working_days = form.cleaned_data["working_days"]
start_time = form.cleaned_data["start_time"]
end_time = form.cleaned_data["end_time"]
interview_duration = form.cleaned_data["interview_duration"]
buffer_time = form.cleaned_data["buffer_time"]
break_start_time = form.cleaned_data["break_start_time"]
break_end_time = form.cleaned_data["break_end_time"]
schedule_interview_type=form.cleaned_data["schedule_interview_type"]
physical_address=form.cleaned_data["physical_address"]
# Process break times
# breaks = []
# for break_form in break_formset:
# print(break_form.cleaned_data)
# if break_form.cleaned_data and not break_form.cleaned_data.get("DELETE"):
# breaks.append(
# {
# "start_time": break_form.cleaned_data["start_time"].strftime("%H:%M:%S"),
# "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"),
# }
# )
# Create a temporary schedule object (not saved to DB)
temp_schedule = BulkInterviewTemplate(
job=job,
start_date=start_date,
end_date=end_date,
working_days=working_days,
start_time=start_time,
end_time=end_time,
interview_duration=interview_duration,
buffer_time=buffer_time or 5,
break_start_time=break_start_time or None,
break_end_time=break_end_time or None,
schedule_interview_type=schedule_interview_type,
physical_address=physical_address
)
# Get available slots (temp_breaks logic moved into get_available_time_slots if needed)
available_slots = get_available_time_slots(temp_schedule)
if len(available_slots) < len(applications):
messages.error(
request,
f"Not enough available slots. Required: {len(applications)}, Available: {len(available_slots)}",
)
return render(
request,
"interviews/schedule_interviews.html",
{"form": form, "job": job},
)
# Create a preview schedule
preview_schedule = []
for i, application in enumerate(applications):
slot = available_slots[i]
preview_schedule.append(
{"application": application, "date": slot["date"], "time": slot["time"]}
)
# Save the form data to session for later use
schedule_data = {
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"working_days": working_days,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"interview_duration": interview_duration,
"buffer_time": buffer_time,
"break_start_time": break_start_time.isoformat() if break_start_time else None,
"break_end_time": break_end_time.isoformat() if break_end_time else None,
"candidate_ids": [c.id for c in applications],
"schedule_interview_type":schedule_interview_type,
"physical_address":physical_address,
"topic":form.cleaned_data.get("topic"),
}
request.session[SESSION_DATA_KEY] = schedule_data
# Render the preview page
return render(
request,
"interviews/preview_schedule.html",
{
"job": job,
"schedule": preview_schedule,
"start_date": start_date,
"end_date": end_date,
"working_days": working_days,
"start_time": start_time,
"end_time": end_time,
"break_start_time": break_start_time,
"break_end_time": break_end_time,
"interview_duration": interview_duration,
"buffer_time": buffer_time,
# "schedule_interview_type":schedule_interview_type,
# "form":OnsiteLocationForm()
},
)
else:
# Re-render the form if validation fails
return render(
request,
"interviews/schedule_interviews.html",
{"form": form, "job": job},
)
# def confirm_schedule_interviews_view(request, slug):
# job = get_object_or_404(JobPosting, slug=slug)
# if request.method == "POST":
# return _handle_confirm_schedule(request, slug, job)
def _handle_confirm_schedule(request, slug, job):
"""
Handles the final POST request (Confirm Schedule).
Creates the main schedule record and queues individual interviews asynchronously.
"""
SESSION_DATA_KEY = "interview_schedule_data"
SESSION_ID_KEY = f"schedule_candidate_ids_{slug}"
# 1. Get schedule data from session
schedule_data = request.session.get(SESSION_DATA_KEY)
if not schedule_data:
messages.error(request, "Session expired. Please try again.")
return redirect("schedule_interviews", slug=slug)
# 2. Create the Interview Schedule (Parent Record)
try:
# Handle break times: If they exist, convert them; otherwise, pass None.
break_start = schedule_data.get("break_start_time")
break_end = schedule_data.get("break_end_time")
schedule = BulkInterviewTemplate.objects.create(
job=job,
created_by=request.user,
start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
working_days=schedule_data["working_days"],
start_time=time.fromisoformat(schedule_data["start_time"]),
end_time=time.fromisoformat(schedule_data["end_time"]),
interview_duration=schedule_data["interview_duration"],
buffer_time=schedule_data["buffer_time"],
break_start_time=time.fromisoformat(break_start) if break_start else None,
break_end_time=time.fromisoformat(break_end) if break_end else None,
schedule_interview_type=schedule_data.get("schedule_interview_type"),
physical_address=schedule_data.get("physical_address"),
topic=schedule_data.get("topic"),
)
except Exception as e:
# Clear data on failure to prevent stale data causing repeated errors
messages.error(request, f"Error creating schedule: {e}")
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
return redirect("schedule_interviews", slug=slug)
applications = Application.objects.filter(id__in=schedule_data["candidate_ids"])
schedule.applications.set(applications)
available_slots = get_available_time_slots(schedule)
if schedule_data.get("schedule_interview_type") == 'Remote':
queued_count = 0
for i, application in enumerate(applications):
if i < len(available_slots):
slot = available_slots[i]
async_task(
"recruitment.tasks.create_interview_and_meeting",
application.pk, job.pk, schedule.pk, slot["date"], slot["time"], schedule.interview_duration,
)
queued_count += 1
messages.success(
request,
f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!",
)
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
return redirect("job_detail", slug=slug)
elif schedule_data.get("schedule_interview_type") == 'Onsite':
try:
for i, application in enumerate(applications):
if i < len(available_slots):
slot = available_slots[i]
start_dt = datetime.combine(slot['date'], schedule.start_time)
interview = Interview.objects.create(
topic=schedule.topic,
start_time=start_dt,
duration=schedule.interview_duration,
location_type="Onsite",
physical_address=schedule.physical_address,
)
# 2. Create the ScheduledInterview, linking the unique location
ScheduledInterview.objects.create(
application=application,
job=job,
schedule=schedule,
interview_date=slot['date'],
interview_time=slot['time'],
interview=interview
)
messages.success(
request,
f"created successfully for {len(applications)} application."
)
# Clear session data keys upon successful completion
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
return redirect('job_detail', slug=job.slug)
except Exception as e:
messages.error(request, f"Error creating onsite interviews: {e}")
return redirect("schedule_interviews", slug=slug)
def schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
if request.method == "POST":
# return _handle_confirm_schedule(request, slug, job)
return _handle_preview_submission(request, slug, job)
else:
# if request.session.get("interview_schedule_data"):
print(request.session.get("interview_schedule_data"))
return _handle_get_request(request, slug, job)
# return redirect("applications_interview_view", slug=slug)
def confirm_schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
if request.method == "POST":
# print(request.session['interview_schedule_data'])
return _handle_confirm_schedule(request, slug, job)
@staff_user_required
@ -6583,3 +6555,57 @@ def interview_detail(request, slug):
# messages.error(request, f"Failed to send invitation emails: {str(e)}")
# return redirect('meeting_details', slug=slug)
def application_add_note(request, slug):
from .models import Note
from .forms import NoteForm
application = get_object_or_404(Application, slug=slug)
notes = Note.objects.filter(application=application).order_by('-created_at')
if request.method == 'POST':
form = NoteForm(request.POST)
if form.is_valid():
form.save()
# messages.success(request, "Note added successfully.")
else:
messages.error(request, "Note content cannot be empty.")
return render(request, 'recruitment/partials/note_form.html', {'notes':notes})
else:
form = NoteForm()
form.initial['application'] = application
form.fields['application'].widget = HiddenInput()
form.fields['interview'].widget = HiddenInput()
form.initial['author'] = request.user
form.fields['author'].widget = HiddenInput()
url = reverse('application_add_note', kwargs={'slug':slug})
notes = Note.objects.filter(application=application).order_by('-created_at')
return render(request, 'recruitment/partials/note_form.html', {'form': form,'instance':application,'notes':notes,'url':url})
def interview_add_note(request, slug):
from .models import Note
from .forms import NoteForm
interview = get_object_or_404(Interview, slug=slug)
if request.method == 'POST':
form = NoteForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, "Note added successfully.")
else:
messages.error(request, "Note content cannot be empty.")
return redirect('interview_detail', slug=slug)
else:
form = NoteForm()
form.initial['interview'] = interview
form.fields['interview'].widget = HiddenInput()
form.fields['application'].widget = HiddenInput()
form.initial['author'] = request.user
form.fields['author'].widget = HiddenInput()
return render(request, 'recruitment/partials/note_form.html', {'form': form,'instance':interview,'notes':interview.notes.all()})

View File

@ -444,11 +444,25 @@
});
});
}
function remove_form_loader(){
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('htmx:afterRequest', function(evt) {
const submitButton = form.querySelector('button[type="submit"], input[type="submit"]');
if (submitButton) {
submitButton.disabled = false;
submitButton.classList.remove('loading');
}
});
});
}
//form_loader();
try{
document.addEventListener('htmx:afterSwap', form_loader);
document.body.addEventListener('htmx:afterRequest', function(evt) {
remove_form_loader();
});
}catch(e){
console.error(e)
}

View File

@ -170,11 +170,7 @@
</tbody>
</table>
</div>
{% if schedule_interview_type == "Onsite" %}
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4" data-bs-toggle="modal" data-bs-target="#interviewDetailsModal" data-placement="top">
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
</button>
{% else %}
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3">
{% csrf_token %}
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary px-4">
@ -184,7 +180,6 @@
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
</button>
</form>
{% endif %}
</div>
</div>
</div>
@ -196,20 +191,20 @@
<h5 class="modal-title" id="interviewDetailsModalLabel">{% trans "Interview Details" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body"> <form method="post" action="{% url 'confirm_schedule_interviews_view' job.slug %}" enctype="multipart/form-data" id="onsite-form">
{% comment %} <div class="modal-body"> <form method="post" action="{% url 'confirm_schedule_interviews_view' job.slug %}" enctype="multipart/form-data" id="onsite-form">
{% csrf_token %}
{# Renders the single 'location' field using the crispy filter #}
{{ form|crispy }}
</form>
{{ form|crispy }}
</form> {% endcomment %}
</div>
<div class="modal-footer">
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
<a href="{% url 'list_meetings' %}" class="btn btn-secondary me-2">
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
<a href="#" class="btn btn-secondary me-2">
<i class="fas fa-times me-1"></i> Close
</a>
<button type="submit" class="btn btn-primary" form="onsite-form">
</a>
<button type="submit" class="btn btn-primary" form="onsite-form">
<i class="fas fa-save me-1"></i> Save Location
</button>
</div>

View File

@ -1,5 +1,6 @@
{% extends 'base.html' %}
{% load static i18n %}
{% load widget_tweaks %}
{% block title %}Bulk Interview Scheduling - {{ job.title }} - ATS{% endblock %}
@ -125,7 +126,6 @@
<div class="row">
<div class="col-md-4">
<h5 class="section-header">{% trans "Select Candidates" %}</h5>
<div class="form-group">
<label for="{{ form.candidates.id_for_label }}">
{% trans "Candidates to Schedule (Hold Ctrl/Cmd to select multiple)" %}
@ -141,14 +141,19 @@
<h5 class="section-header">{% trans "Schedule Details" %}</h5>
<div class="row">
<div class="col-md-12">
<div class="form-group mb-3">
<label for="{{ form.topic.id_for_label }}">{% trans "Topic" %}</label>
{{ form.topic }}
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="form-group mb-3">
<label for="{{ form.schedule_interview_type.id_for_label }}">{% trans "Interview Type" %}</label>
{{ form.schedule_interview_type }}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
@ -217,8 +222,17 @@
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="form-group mb-3">
<label for="{{ form.physical_address.id_for_label }}">{% trans "Physical Address" %}</label>
{{ form.physical_address }}
{% if form.physical_address.errors %}
<div class="text-danger small mt-1">{{ form.physical_address.errors }}</div>
{% endif %}
</div>
</div>
</div>
</div>

View File

@ -278,7 +278,7 @@
{% trans "To Offer" %}
</option>
</select>
<button type="submit" class="btn btn-main-action btn-sm">
<button id="changeStage" type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button>
</form>
@ -286,7 +286,7 @@
{# Separator (Vertical Rule) - Aligns automatically at the bottom with align-items-end #}
<div class="vr" style="height: 28px;"></div>
<button type="button" class="btn btn-outline-primary btn-sm"
<button id="emailBotton" type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
@ -323,6 +323,9 @@
<th scope="col" style="width: 28%;">
<i class="fas fa-file-alt me-1"></i> {% trans "Documents" %}
</th>
<th scope="col" style="width: 10%;">
<i class="fas fa-file-alt me-1"></i> {% trans "Notes" %}
</th>
<th scope="col" style="width: 10%;">
<i class="fas fa-cog me-1"></i> {% trans "Actions" %}
</th>
@ -394,6 +397,15 @@
{% endif %}
{% endwith %}
</td>
<td><button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#noteModal"
hx-get="{% url 'application_add_note' application.slug %}"
hx-swap="outerHTML"
hx-target=".notemodal">
<i class="fas fa-calendar-plus me-1"></i>
Add note
</button></td>
<td class="text-center">
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
@ -462,51 +474,78 @@
</div>
</div>
</div>
{% include "recruitment/partials/note_modal.html" %}
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
const changeStageButton = document.getElementById('changeStage');
const emailButton = document.getElementById('emailBotton');
const updateStatus = document.getElementById('update_status');
if (selectAllCheckbox) {
// Function to safely update header checkbox state
function updateSelectAllState() {
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
const totalCount = rowCheckboxes.length;
if (selectAllCheckbox) {
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
// 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;
changeStageButton.disabled = true;
emailButton.disabled = true;
updateStatus.disabled = true;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
} else {
// Set to indeterminate state (partially checked)
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
}
}
}
// 1. Logic for 'Select All' checkbox (Clicking it updates all rows)
selectAllCheckbox.addEventListener('change', function () {
const isChecked = selectAllCheckbox.checked;
// 1. Logic for the 'Select All' checkbox (Clicking it updates all rows)
selectAllCheckbox.addEventListener('change', function () {
const isChecked = selectAllCheckbox.checked;
rowCheckboxes.forEach(checkbox => {
checkbox.checked = isChecked;
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
// 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;
// Dispatch event for the framework (data-bind-selections)
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();
});
// 2. Logic to update 'Select All' state based on row checkboxes
rowCheckboxes.forEach(function (checkbox) {
checkbox.addEventListener('change', updateSelectAllState);
});
// Initial check to set correct state on load
updateSelectAllState();
}
});
}
});
</script>
{% endblock %}

View File

@ -225,11 +225,11 @@
</div>
{# Button #}
<button type="submit" class="btn btn-main-action btn-sm">
<button id="changeStage" type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button>
<button type="button" class="btn btn-outline-primary btn-sm"
<button id="emailBotton" type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
@ -261,9 +261,10 @@
<th style="width: 15%;">{% trans "Name" %}</th>
<th style="width: 15%;">{% trans "Contact Info" %}</th>
<th style="width: 10%;" class="text-center">{% trans "AI Score" %}</th>
<th style="width: 15%;">{% trans "Exam Date" %}</th>
<th style="width: 15%;">{% trans "Exam Score" %}</th>
<th style="width: 10%;">{% trans "Exam Date" %}</th>
<th style="width: 10%;">{% trans "Exam Score" %}</th>
<th style="width: 10%;" class="text-center">{% trans "Exam Results" %}</th>
<th style="width: 10%"> {% trans "Notes"%}</th>
<th style="width: 15%;">{% trans "Actions" %}</th>
</tr>
</thead>
@ -324,6 +325,15 @@
{% endif %}
{% endif %}
</td>
<td><button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#noteModal"
hx-get="{% url 'application_add_note' application.slug %}"
hx-swap="outerHTML"
hx-target=".notemodal">
<i class="fas fa-calendar-plus me-1"></i>
Add note
</button></td>
<td >
<button type="button" class="btn btn-outline-secondary btn-sm"
@ -395,14 +405,18 @@
</div>
</div>
</div>
{% include "recruitment/partials/note_modal.html" %}
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
const changeStageButton = document.getElementById('changeStage');
const emailButton = document.getElementById('emailBotton');
const updateStatus = document.getElementById('update_status');
if (selectAllCheckbox) {
@ -414,13 +428,22 @@
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = true;
emailButton.disabled = true;
updateStatus.disabled = true;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
} else {
// Set to indeterminate state (partially checked)
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
}
}

View File

@ -218,7 +218,7 @@
{% trans "To Exam" %}
</option>
</select>
<button type="submit" class="btn btn-main-action btn-sm">
<button id="changeStage" type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button>
</form>
@ -227,23 +227,23 @@
<div class="vr" style="height: 28px;"></div>
{# Form 2: Schedule Interviews #}
<form hx-boost="true" hx-include="#application-form" action="#" method="get" class="action-group">
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-calendar-plus me-1"></i> {% trans "Schedule Interviews" %}
<form hx-boost="true" hx-include="#application-form" action="{% url 'schedule_interviews' job.slug %}" method="get" class="action-group">
<button id="scheduleInterview" type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-calendar-plus me-1"></i> {% trans "Bulk Schedule Interviews" %}
</button>
</form>
<div class="vr" style="height: 28px;"></div>
<button type="button" class="btn btn-outline-info btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
hx-get="{% url 'compose_application_email' job.slug %}"
hx-target="#emailModalBody"
hx-include="#application-form"
title="Email Participants">
<i class="fas fa-envelope"></i>
<button id="emailBotton" type="button" class="btn btn-outline-info btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
hx-get="{% url 'compose_application_email' job.slug %}"
hx-target="#emailModalBody"
hx-include="#application-form"
title="Email Participants">
<i class="fas fa-envelope"></i>
</button>
</div>
</div>
@ -271,8 +271,9 @@
<th style="width: 10%"><i class="fas fa-calendar me-1"></i> {% trans "Meeting Date" %}</th>
<th style="width: 7%"><i class="fas fa-video me-1"></i> {% trans "Link" %}</th>
<th style="width: 8%"><i class="fas fa-check-circle me-1"></i> {% trans "Meeting Status" %}</th> {% endcomment %}
<th style="width: 15%"><i class="fas fa-check-circle me-1"></i> {% trans "Interview Result"%}</th>
<th style="width: 15%"><i class="fas fa-check-circle me-1"></i> {% trans "Interview List"%}</th>
<th style="width: 10%"><i class="fas fa-video me-1"></i> {% trans "Interviews"%}</th>
<th style="width: 10%"><i class="fas fa-sticky-note me-1"></i> {% trans "Notes"%}</th>
<th style="width: 5%"><i class="fas fa-check-circle me-1"></i> {% trans "Result"%}</th>
<th style="width: 10%"><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
</tr>
</thead>
@ -353,6 +354,26 @@
{% endif %}
{% endwith %}
</td> {% endcomment %}
<td>
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'get_interview_list' application.slug %}"
hx-target="#candidateviewModalBody">
View
<i class="fas fa-list"></i>
{{candidate.get_interviews}}
</button>
</td>
<td><button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#noteModal"
hx-get="{% url 'application_add_note' application.slug %}"
hx-swap="outerHTML"
hx-target=".notemodal">
<i class="fas fa-calendar-plus me-1"></i>
Add note
</button></td>
<td class="text-center" id="interview-result-{{ application.pk }}">
{% if not application.interview_status %}
<button type="button" class="btn btn-warning btn-sm"
@ -379,18 +400,6 @@
{% endif %}
</td>
<td>
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'get_interview_list' application.slug %}"
hx-target="#candidateviewModalBody">
Interview List
<i class="fas fa-list"></i>
{{candidate.get_interviews}}
</button>
</td>
<td>
{% if application.get_latest_meeting %}
{% if application.get_latest_meeting.location_type == 'Remote'%}
@ -445,7 +454,6 @@
<i class="fas fa-calendar-plus me-1"></i>
Schedule Interview
</button>
{% endif %}
</td>
</tr>
@ -504,6 +512,9 @@
</div>
</div>
{% include "recruitment/partials/note_modal.html" %}
{% endblock %}
{% block customJS %}
@ -511,6 +522,10 @@
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
const changeStageButton = document.getElementById('changeStage');
const emailButton = document.getElementById('emailBotton');
const updateStatus = document.getElementById('update_status');
const scheduleInterviewButton = document.getElementById('scheduleInterview');
if (selectAllCheckbox) {
@ -522,13 +537,25 @@
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = true;
emailButton.disabled = true;
updateStatus.disabled = true;
scheduleInterviewButton.disabled = true;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
scheduleInterviewButton.disabled = false;
} else {
// Set to indeterminate state (partially checked)
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
scheduleInterviewButton.disabled = false;
}
}

View File

@ -220,26 +220,23 @@
</select>
{# Button #}
<button type="submit" class="btn btn-main-action btn-sm">
<button id="changeStage" type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button>
</form>
{# Separator (Vertical Rule) #}
<div class="vr" style="height: 28px;"></div>
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
hx-get="{% url 'compose_application_email' job.slug %}"
hx-target="#emailModalBody"
hx-include="#application-form"
title="Email Participants">
<i class="fas fa-envelope"></i>
<button id="emailBotton" type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
hx-get="{% url 'compose_application_email' job.slug %}"
hx-target="#emailModalBody"
hx-include="#application-form"
title="Email Participants">
<i class="fas fa-envelope"></i>
</button>
</div>
@ -263,8 +260,9 @@
<th style="width: 15%"><i class="fas fa-phone me-1"></i> {% trans "Contact" %}</th>
<th class="text-center" style="width: 10%"><i class="fas fa-check-circle me-1"></i> {% trans "Offer" %}</th>
<th scope="col" style="width: 30%;">
<i class="fas fa-file-alt me-1"></i> {% trans "Documents" %}
</th>
<i class="fas fa-file-alt me-1"></i> {% trans "Documents" %}
</th>
<th style="width: 10%"><i class="fas fa-phone me-1"></i> {% trans "Notes" %}</th>
<th style="width: 5%"><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
</tr>
</thead>
@ -356,6 +354,15 @@
{% endif %}
{% endwith %}
</td>
<td><button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#noteModal"
hx-get="{% url 'application_add_note' application.slug %}"
hx-swap="outerHTML"
hx-target=".notemodal">
<i class="fas fa-calendar-plus me-1"></i>
Add note
</button></td>
<td>
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
@ -423,6 +430,8 @@
</div>
</div>
</div>
{% include "recruitment/partials/note_modal.html" %}
{% endblock %}
{% block customJS %}
@ -430,6 +439,9 @@
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
const changeStageButton = document.getElementById('changeStage');
const emailButton = document.getElementById('emailBotton');
const updateStatus = document.getElementById('update_status');
if (selectAllCheckbox) {
@ -441,13 +453,22 @@
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = true;
emailButton.disabled = true;
updateStatus.disabled = true;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
} else {
// Set to indeterminate state (partially checked)
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
}
}

View File

@ -340,11 +340,11 @@
</div>
{# Button #}
<button type="submit" class="btn btn-main-action btn-sm">
<button id="changeStage" type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button>
{# email button#}
<button type="button" class="btn btn-outline-primary btn-sm"
<button id="emailBotton" type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
@ -374,28 +374,31 @@
</div>
{% endif %}
</th>
<th scope="col" style="width: 8%;">
<th scope="col" style="width: 13%;">
<i class="fas fa-user me-1"></i> {% trans "Name" %}
</th>
<th scope="col" style="width: 10%;">
<th scope="col" style="width: 15%;">
<i class="fas fa-phone me-1"></i> {% trans "Contact Info" %}
</th>
<th scope="col" style="width: 5%;">
<i class="fas fa-graduation-cap me-1"></i> {% trans "GPA" %}
</th>
<th scope="col" style="width: 6%;" class="text-center">
<th scope="col" style="width: 5%;" class="text-center">
<i class="fas fa-robot me-1"></i> {% trans "AI Score" %}
</th>
<th scope="col" style="width: 15%;" class="text-center">
<th scope="col" style="width: 10%;" class="text-center">
<i class="fas fa-robot me-1"></i> {% trans "Is Qualified?" %}
</th>
<th scope="col" style="width: 10%;">
<th scope="col" style="width: 20%;">
<i class="fas fa-graduation-cap me-1"></i> {% trans "Professional Category" %}
</th>
<th scope="col" style="width: 15%;">
<i class="fas fa-graduation-cap me-1"></i> {% trans "Top 3 Skills" %}
</th>
<th scope="col" style="width: 10%;" class="text-center">
<i class="fas fa-cog me-1"></i> {% trans "Note" %}
</th>
<th scope="col" style="width: 5%;" class="text-center">
<i class="fas fa-cog me-1"></i> {% trans "Actions" %}
</th>
</tr>
@ -466,7 +469,15 @@
</div>
{% endif %}
</td>
<td><button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#noteModal"
hx-get="{% url 'application_add_note' application.slug %}"
hx-swap="outerHTML"
hx-target=".notemodal">
<i class="fas fa-calendar-plus me-1"></i>
Add note
</button></td>
<td class="text-center">
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
@ -536,6 +547,8 @@
</div>
</div>
</div>
{% include "recruitment/partials/note_modal.html" %}
{% endblock %}
@ -544,8 +557,12 @@
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
const changeStageButton = document.getElementById('changeStage');
const emailButton = document.getElementById('emailBotton');
const updateStatus = document.getElementById('update_status');
if (selectAllCheckbox) {
// Function to safely update the header checkbox state
function updateSelectAllState() {
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
@ -554,12 +571,22 @@
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = true;
emailButton.disabled = true;
updateStatus.disabled = true;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
} else {
// Set to indeterminate state (partially checked)
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
changeStageButton.disabled = false;
emailButton.disabled = false;
updateStatus.disabled = false;
}
}
@ -567,18 +594,26 @@
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;
// Dispatch event for the framework (data-bind-selections)
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);
});

View File

@ -0,0 +1,52 @@
{% load i18n crispy_forms_tags %}
<div class="p-3">
<form hx-boost="true" id="noteform" action="{{url}}" method="post" hx-select=".note-table-body" hx-target=".note-table-body" hx-swap="outerHTML" hx-push-url="false">
{% csrf_token %}
{{form|crispy}}
<div class="modal-footer">
<button type="button" id="notesubmit" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
<button type="submit" class="btn btn-main-action" id="saveNoteBtn">{% trans "Save Note" %}</button>
</div>
</form>
<div class="table-responsive mt-3">
<table class="table table-sm" id="notesTable">
<thead>
<tr>
<th scope="col">{% trans "Author" %}</th>
<th scope="col" style="width: 60%;">{% trans "Note" %}</th>
<th scope="col">{% trans "Created" %}</th>
<th scope="col" class="text-end">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody class="note-table-body">
{% if notes %}
{% for note in notes %}
<tr id="note-{{ note.id }}">
<td class="align-middle">
{{ note.author.get_full_name|default:note.author.username }}
</td>
<td class="align-middle">
{{ note.content|linebreaksbr }}
</td>
<td class="align-middle text-nowrap">
<span class="text-muted">
{{ note.created_at|date:"SHORT_DATETIME_FORMAT" }}
</span>
</td>
<td class="align-middle text-end">
<button type="button" class="btn btn-sm btn-outline-danger delete-note-btn"
data-note-id="{{ note.id }}">
{% trans "Delete" %}
</button>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-3">{% trans "No notes yet." %}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>

View File

@ -0,0 +1,13 @@
<div class="modal fade" id="noteModal" tabindex="-1" aria-labelledby="noteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content kaauh-card">
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body notemodal">
</div>
</div>
</div>
</div>
</div>