update the zoom meeting

This commit is contained in:
ismail 2025-10-13 17:06:26 +03:00
parent ce15603802
commit ffae8b2e64
26 changed files with 1546 additions and 163 deletions

1
ZoomMeetingAPISpec.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,22 @@
# Generated by Django 5.2.6 on 2025-10-12 21:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0004_alter_candidate_interview_date'),
]
operations = [
migrations.RemoveField(
model_name='interviewschedule',
name='breaks',
),
migrations.AddField(
model_name='interviewschedule',
name='breaks',
field=models.JSONField(blank=True, default=list, verbose_name='Break Times'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-10-13 12:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0005_remove_interviewschedule_breaks_and_more'),
]
operations = [
migrations.AddField(
model_name='zoommeeting',
name='meeting_status',
field=models.CharField(choices=[('scheduled', 'Scheduled'), ('started', 'Started'), ('ended', 'Ended')], default='scheduled', max_length=20, verbose_name='Meeting Status'),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 5.2.6 on 2025-10-13 12:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0006_zoommeeting_meeting_status'),
]
operations = [
migrations.RemoveField(
model_name='zoommeeting',
name='meeting_status',
),
migrations.AddField(
model_name='zoommeeting',
name='status',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Status'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-10-13 12:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0007_remove_zoommeeting_meeting_status_zoommeeting_status'),
]
operations = [
migrations.AddField(
model_name='zoommeeting',
name='password',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Password'),
),
]

View File

@ -421,7 +421,6 @@ class Candidate(Base):
return self.STAGE_SEQUENCE.get(old_stage, [])
@property
def submission(self):
return FormSubmission.objects.filter(template__job=self.job).first()
@property
@ -432,6 +431,16 @@ class Candidate(Base):
def __str__(self):
return self.full_name
@property
def get_meetings(self):
return self.scheduled_interviews.all()
@property
def get_latest_meeting(self):
schedule = self.scheduled_interviews.order_by('-created_at').first()
if schedule:
return schedule.zoom_meeting
return None
class TrainingMaterial(Base):
title = models.CharField(max_length=255, verbose_name=_("Title"))
@ -453,6 +462,10 @@ class TrainingMaterial(Base):
class ZoomMeeting(Base):
class MeetingStatus(models.TextChoices):
SCHEDULED = "scheduled", _("Scheduled")
STARTED = "started", _("Started")
ENDED = "ended", _("Ended")
# Basic meeting details
topic = models.CharField(max_length=255, verbose_name=_("Topic"))
meeting_id = models.CharField(
@ -469,6 +482,9 @@ class ZoomMeeting(Base):
participant_video = models.BooleanField(
default=True, verbose_name=_("Participant Video")
)
password = models.CharField(
max_length=20, blank=True, null=True, verbose_name=_("Password")
)
join_before_host = models.BooleanField(
default=False, verbose_name=_("Join Before Host")
)
@ -480,6 +496,12 @@ class ZoomMeeting(Base):
zoom_gateway_response = models.JSONField(
blank=True, null=True, verbose_name=_("Zoom Gateway Response")
)
status = models.CharField(
max_length=20,
null=True,
blank=True,
verbose_name=_("Status"),
)
# Timestamps
def __str__(self):
@ -952,7 +974,9 @@ class InterviewSchedule(Base):
) # Store days of week as [0,1,2,3,4] for Mon-Fri
start_time = models.TimeField(verbose_name=_("Start Time"))
end_time = models.TimeField(verbose_name=_("End Time"))
breaks = models.ManyToManyField(BreakTime, blank=True, related_name="schedules")
breaks = models.JSONField(default=list, blank=True, verbose_name=_('Break Times'))
interview_duration = models.PositiveIntegerField(
verbose_name=_("Interview Duration (minutes)")
)

View File

@ -66,8 +66,10 @@ urlpatterns = [
path('forms/', views.form_templates_list, name='form_templates_list'),
path('forms/create-template/', views.create_form_template, name='create_form_template'),
path('jobs/<slug:slug>/candidate-tiers/', views.candidate_tier_management_view, name='candidate_tier_management'),
path('jobs/<slug:slug>/candidate_screening_view/', views.candidate_screening_view, name='candidate_screening_view'),
path('jobs/<slug:slug>/candidate_exam_view/', views.candidate_exam_view, name='candidate_exam_view'),
path('jobs/<slug:slug>/candidate_interview_view/', views.candidate_interview_view, name='candidate_interview_view'),
path('jobs/<slug:slug>/update_candidate_exam_status/', views.update_candidate_exam_status, name='update_candidate_exam_status'),
path('jobs/<slug:slug>/bulk_update_candidate_exam_status/', views.bulk_update_candidate_exam_status, name='bulk_update_candidate_exam_status'),
@ -89,5 +91,6 @@ urlpatterns = [
# path('api/forms/save/', views.save_form_builder, name='save_form_builder'),
# path('api/forms/<int:form_id>/load/', views.load_form, name='load_form'),
# path('api/forms/<int:form_id>/update/', views.update_form_builder, name='update_form_builder'),
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'),
]

View File

@ -161,7 +161,7 @@ def create_zoom_meeting(topic, start_time, duration):
meeting_details = {
"topic": topic,
"type": 2,
"start_time": start_time,
"start_time": start_time.isoformat() + "Z",
"duration": duration,
"timezone": "UTC",
"settings": {
@ -273,6 +273,7 @@ def get_zoom_meeting_details(meeting_id):
Returns:
dict: A dictionary containing the meeting details or an error message.
The 'start_time' in 'meeting_details' will be a Python datetime object.
"""
try:
access_token = get_access_token()
@ -288,6 +289,19 @@ def get_zoom_meeting_details(meeting_id):
if response.status_code == 200:
meeting_data = response.json()
if 'start_time' in meeting_data and meeting_data['start_time']:
try:
# Convert ISO 8601 string (with 'Z' for UTC) to datetime object
meeting_data['start_time'] = str(datetime.fromisoformat(
meeting_data['start_time'].replace('Z', '+00:00')
))
except (ValueError, TypeError) as e:
logger.error(
f"Failed to parse start_time '{meeting_data['start_time']}' for meeting {meeting_id}: {e}"
)
meeting_data['start_time'] = None # Ensure it's None on failure
else:
meeting_data['start_time'] = None # Explicitly set to None if not present
return {
"status": "success",
"message": "Meeting details retrieved successfully.",
@ -325,11 +339,13 @@ def update_zoom_meeting(meeting_id, updated_data):
"Content-Type": "application/json"
}
response = requests.patch(
f"https://api.zoom.us/v2/meetings/{meeting_id}",
f"https://api.zoom.us/v2/meetings/{meeting_id}/",
headers=headers,
json=updated_data
)
print(response.status_code)
if response.status_code == 204:
return {
"status": "success",
@ -465,7 +481,7 @@ def send_interview_email(scheduled_interview):
fail_silently=False,
)
def get_available_time_slots(schedule, breaks=None):
def get_available_time_slots(schedule):
"""
Generate a list of available time slots based on the schedule criteria.
Returns a list of dictionaries with 'date' and 'time' keys.
@ -475,7 +491,6 @@ def get_available_time_slots(schedule, breaks=None):
end_date = schedule.end_date
# Convert working days to a set for quick lookup
# working_days should be a list of integers where 0=Monday, 1=Tuesday, etc.
working_days_set = set(int(day) for day in schedule.working_days)
# Parse times
@ -485,17 +500,12 @@ def get_available_time_slots(schedule, breaks=None):
# Calculate slot duration (interview duration + buffer time)
slot_duration = timedelta(minutes=schedule.interview_duration + schedule.buffer_time)
# Debug output - remove in production
print(f"Working days: {working_days_set}")
print(f"Date range: {current_date} to {end_date}")
print(f"Time range: {start_time} to {end_time}")
print(f"Slot duration: {slot_duration}")
print(f"Breaks: {breaks}")
# Get breaks from the schedule
breaks = schedule.breaks if hasattr(schedule, 'breaks') and schedule.breaks else []
while current_date <= end_date:
# Check if current day is a working day
weekday = current_date.weekday() # Monday is 0, Sunday is 6
print(f"Checking {current_date}, weekday: {weekday}, in working days: {weekday in working_days_set}")
if weekday in working_days_set:
# Generate slots for this day
@ -511,13 +521,18 @@ def get_available_time_slots(schedule, breaks=None):
# Check if slot conflicts with any break time
conflict_with_break = False
if breaks:
for break_time in breaks:
for break_data in breaks:
# Parse break times
try:
break_start = datetime.strptime(break_data['start_time'], '%H:%M:%S').time()
break_end = datetime.strptime(break_data['end_time'], '%H:%M:%S').time()
# Check if the slot overlaps with this break time
if not (current_time >= break_time.end_time or slot_end_time <= break_time.start_time):
if not (current_time >= break_end or slot_end_time <= break_start):
conflict_with_break = True
print(f"Slot {current_time}-{slot_end_time} conflicts with break {break_time.start_time}-{break_time.end_time}")
break
except (ValueError, KeyError) as e:
continue
if not conflict_with_break:
# Add this slot to available slots
@ -525,7 +540,6 @@ def get_available_time_slots(schedule, breaks=None):
'date': current_date,
'time': current_time
})
print(f"Added slot: {current_date} {current_time}")
# Move to next slot
current_datetime = datetime.combine(current_date, current_time) + slot_duration
@ -534,11 +548,9 @@ def get_available_time_slots(schedule, breaks=None):
# Move to next day
current_date += timedelta(days=1)
print(f"Total slots generated: {len(slots)}")
return slots
def json_to_markdown_table(data_list):
if not data_list:
return ""
@ -550,4 +562,4 @@ def json_to_markdown_table(data_list):
for row in data_list:
values = [str(row.get(header, "")) for header in headers]
markdown += "| " + " | ".join(values) + " |\n"
return markdown
return markdown

View File

@ -1,11 +1,12 @@
import json
import requests
from rich import print
from django.template.loader import render_to_string
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.http import JsonResponse
from datetime import datetime
from datetime import datetime,time,timedelta
from django.views import View
from django.db.models import Q
from django.urls import reverse
@ -31,6 +32,7 @@ from .utils import (
create_zoom_meeting,
delete_zoom_meeting,
update_zoom_meeting,
get_zoom_meeting_details,
schedule_interviews,
get_available_time_slots,
)
@ -47,6 +49,7 @@ from .models import (
ZoomMeeting,
Candidate,
JobPosting,
ScheduledInterview
)
import logging
from datastar_py.django import (
@ -81,15 +84,18 @@ class ZoomMeetingCreateView(CreateView):
if instance.start_time < timezone.now():
messages.error(self.request, "Start time must be in the future.")
return redirect("/create-meeting/", status=400)
start_time = instance.start_time.isoformat() + "Z"
start_time = instance.start_time
# start_time = instance.start_time.isoformat() + "Z"
duration = instance.duration
result = create_zoom_meeting(topic, start_time, duration)
print(result)
if result["status"] == "success":
instance.meeting_id = result["meeting_details"]["meeting_id"]
instance.join_url = result["meeting_details"]["join_url"]
instance.host_email = result["meeting_details"]["host_email"]
instance.password = result["meeting_details"]["password"]
instance.status = result["zoom_gateway_response"]["status"]
instance.zoom_gateway_response = result["zoom_gateway_response"]
instance.save()
messages.success(self.request, result["message"])
@ -139,6 +145,21 @@ class ZoomMeetingUpdateView(UpdateView):
template_name = "meetings/update_meeting.html"
success_url = "/"
# def get_form_kwargs(self):
# kwargs = super().get_form_kwargs()
# # Ensure the form is initialized with the instance's current values
# if self.object:
# kwargs['initial'] = getattr(kwargs, 'initial', {})
# initial_start_time = ""
# if self.object.start_time:
# try:
# initial_start_time = self.object.start_time.strftime('%m-%d-%Y,T%H:%M')
# except AttributeError:
# print(f"Warning: start_time {self.object.start_time} is not a datetime object.")
# initial_start_time = ""
# kwargs['initial']['start_time'] = initial_start_time
# return kwargs
def form_valid(self, form):
instance = form.save(commit=False)
updated_data = {
@ -149,15 +170,39 @@ class ZoomMeetingUpdateView(UpdateView):
if instance.start_time < timezone.now():
messages.error(self.request, "Start time must be in the future.")
return redirect(f"/update-meeting/{instance.pk}/", status=400)
result = update_zoom_meeting(instance.meeting_id, updated_data)
if result["status"] == "success":
instance.save()
messages.success(self.request, result["message"])
return redirect(reverse("meeting_details", kwargs={"pk": instance.pk}))
# Fetch the latest details from Zoom after successful update
details_result = get_zoom_meeting_details(instance.meeting_id)
if details_result["status"] == "success":
zoom_details = details_result["meeting_details"]
# Update instance with fetched details
instance.topic = zoom_details.get("topic", instance.topic)
instance.duration = zoom_details.get("duration", instance.duration)
instance.join_url = zoom_details.get("join_url", instance.join_url)
instance.password = zoom_details.get("password", instance.password)
# Corrected status assignment: instance.status, not instance.password
instance.status = zoom_details.get("status")
instance.zoom_gateway_response = details_result.get("meeting_details") # Store full response
instance.save()
messages.success(self.request, result["message"] + " Local data updated from Zoom.")
else:
# If fetching details fails, save with form data and log a warning
logger.warning(
f"Successfully updated Zoom meeting {instance.meeting_id}, but failed to fetch updated details. "
f"Error: {details_result.get('message', 'Unknown error')}"
)
instance.save() # Save with data from the form
messages.success(self.request, result["message"] + " (Note: Could not refresh local data from Zoom.)")
return redirect(reverse("meeting_details", kwargs={"slug": instance.slug}))
else:
messages.error(self.request, result["message"])
return redirect(reverse("meeting_details", kwargs={"pk": instance.pk}))
return redirect(reverse("meeting_details", kwargs={"slug": instance.slug}))
def ZoomMeetingDeleteView(request, pk):
@ -1065,12 +1110,11 @@ def form_submission_details(request, template_id, slug):
},
)
def schedule_interviews_view(request, job_id):
job = get_object_or_404(JobPosting, id=job_id)
def schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
if request.method == "POST":
form = InterviewScheduleForm(job_id, request.POST)
form = InterviewScheduleForm(slug, request.POST)
break_formset = BreakTimeFormSet(request.POST)
# Check if this is a confirmation request
@ -1079,21 +1123,31 @@ def schedule_interviews_view(request, job_id):
schedule_data = request.session.get("interview_schedule_data")
if not schedule_data:
messages.error(request, "Session expired. Please try again.")
return redirect("schedule_interviews", job_id=job_id)
return redirect("schedule_interviews", slug=slug)
# Create the interview schedule
schedule = InterviewSchedule.objects.create(
job=job, created_by=request.user, **schedule_data
job=job,
created_by=request.user,
start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
working_days=schedule_data["working_days"],
start_time=time.fromisoformat(schedule_data["start_time"]),
end_time=time.fromisoformat(schedule_data["end_time"]),
interview_duration=schedule_data["interview_duration"],
buffer_time=schedule_data["buffer_time"],
breaks=schedule_data["breaks"],
)
# Add candidates to the schedule
candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"])
schedule.candidates.set(candidates)
# Add break times to the schedule
if "breaks" in schedule_data and schedule_data["breaks"]:
for break_data in schedule_data["breaks"]:
break_time = BreakTime.objects.create(
# Create temporary break time objects for slot calculation
temp_breaks = []
for break_data in schedule_data["breaks"]:
temp_breaks.append(
BreakTime(
start_time=datetime.strptime(
break_data["start_time"], "%H:%M:%S"
).time(),
@ -1101,21 +1155,71 @@ def schedule_interviews_view(request, job_id):
break_data["end_time"], "%H:%M:%S"
).time(),
)
schedule.breaks.add(break_time)
# Schedule the interviews
try:
scheduled_count = schedule_interviews(schedule)
messages.success(
request, f"Successfully scheduled {scheduled_count} interviews."
)
# Clear the session data
if "interview_schedule_data" in request.session:
del request.session["interview_schedule_data"]
return redirect("job_detail", pk=job_id)
except Exception as e:
messages.error(request, f"Error scheduling interviews: {str(e)}")
return redirect("schedule_interviews", job_id=job_id)
# Get available slots
available_slots = get_available_time_slots(schedule)
# Create scheduled interviews
scheduled_count = 0
for i, candidate in enumerate(candidates):
if i < len(available_slots):
slot = available_slots[i]
interview_datetime = datetime.combine(slot['date'], slot['time'])
# Create Zoom meeting
meeting_topic = f"Interview for {job.title} - {candidate.name}"
start_time = interview_datetime.isoformat() + "Z"
zoom_meeting = create_zoom_meeting(
topic=meeting_topic,
start_time=start_time,
duration=schedule.interview_duration
)
result = create_zoom_meeting(meeting_topic, start_time, schedule.interview_duration)
if result["status"] == "success":
zoom_meeting = ZoomMeeting.objects.create(
topic=meeting_topic,
start_time=interview_datetime,
duration=schedule.interview_duration,
meeting_id=result["meeting_details"]["meeting_id"],
join_url=result["meeting_details"]["join_url"],
zoom_gateway_response=result["zoom_gateway_response"],
)
# Create scheduled interview record
scheduled_interview = ScheduledInterview.objects.create(
candidate=candidate,
job=job,
zoom_meeting=zoom_meeting,
schedule=schedule,
interview_date=slot['date'],
interview_time=slot['time']
)
# Send email to candidate
# try:
# send_interview_email(scheduled_interview)
# except Exception as e:
# messages.warning(
# request,
# f"Interview scheduled for {candidate.name}, but failed to send email: {str(e)}"
# )
scheduled_count += 1
messages.success(
request, f"Successfully scheduled {scheduled_count} interviews."
)
# Clear the session data
if "interview_schedule_data" in request.session:
del request.session["interview_schedule_data"]
return redirect("job_detail", slug=slug)
# This is the initial form submission
if form.is_valid() and break_formset.is_valid():
@ -1139,8 +1243,8 @@ def schedule_interviews_view(request, job_id):
{
"start_time": break_form.cleaned_data[
"start_time"
].isoformat(),
"end_time": break_form.cleaned_data["end_time"].isoformat(),
].strftime("%H:%M:%S"),
"end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"),
}
)
@ -1154,6 +1258,7 @@ def schedule_interviews_view(request, job_id):
end_time=end_time,
interview_duration=interview_duration,
buffer_time=buffer_time,
breaks=breaks,
)
# Create temporary break time objects
@ -1171,7 +1276,7 @@ def schedule_interviews_view(request, job_id):
)
# Get available slots
available_slots = get_available_time_slots(temp_schedule, temp_breaks)
available_slots = get_available_time_slots(temp_schedule)
if len(available_slots) < len(candidates):
messages.error(
@ -1224,7 +1329,7 @@ def schedule_interviews_view(request, job_id):
},
)
else:
form = InterviewScheduleForm(job_id=job_id)
form = InterviewScheduleForm(slug=slug)
break_formset = BreakTimeFormSet()
return render(
@ -1232,9 +1337,157 @@ def schedule_interviews_view(request, job_id):
"interviews/schedule_interviews.html",
{"form": form, "break_formset": break_formset, "job": job},
)
# def schedule_interviews_view(request, slug):
# job = get_object_or_404(JobPosting, slug=slug)
# if request.method == "POST":
# form = InterviewScheduleForm(slug, request.POST)
# break_formset = BreakTimeFormSet(request.POST)
def candidate_tier_management_view(request, slug):
# # Check if this is a confirmation request
# if "confirm_schedule" in request.POST:
# # Get the schedule data from session
# schedule_data = request.session.get("interview_schedule_data")
# if not schedule_data:
# messages.error(request, "Session expired. Please try again.")
# return redirect("schedule_interviews", slug=slug)
# # Create the interview schedule
# schedule = InterviewSchedule.objects.create(
# job=job,
# created_by=request.user,
# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
# working_days=schedule_data["working_days"],
# start_time=time.fromisoformat(schedule_data["start_time"]),
# end_time=time.fromisoformat(schedule_data["end_time"]),
# interview_duration=schedule_data["interview_duration"],
# buffer_time=schedule_data["buffer_time"],
# breaks=schedule_data["breaks"], # Direct assignment for JSON field
# )
# # Add candidates to the schedule
# candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"])
# schedule.candidates.set(candidates)
# # Schedule the interviews
# try:
# scheduled_count = schedule_interviews(schedule)
# messages.success(
# request, f"Successfully scheduled {scheduled_count} interviews."
# )
# # Clear the session data
# if "interview_schedule_data" in request.session:
# del request.session["interview_schedule_data"]
# return redirect("job_detail", slug=slug)
# except Exception as e:
# messages.error(request, f"Error scheduling interviews: {str(e)}")
# return redirect("schedule_interviews", slug=slug)
# # This is the initial form submission
# if form.is_valid() and break_formset.is_valid():
# # Get the form data
# candidates = form.cleaned_data["candidates"]
# start_date = form.cleaned_data["start_date"]
# end_date = form.cleaned_data["end_date"]
# working_days = form.cleaned_data["working_days"]
# start_time = form.cleaned_data["start_time"]
# end_time = form.cleaned_data["end_time"]
# interview_duration = form.cleaned_data["interview_duration"]
# buffer_time = form.cleaned_data["buffer_time"]
# # Process break times
# breaks = []
# for break_form in break_formset:
# if break_form.cleaned_data and not break_form.cleaned_data.get(
# "DELETE"
# ):
# breaks.append(
# {
# "start_time": break_form.cleaned_data[
# "start_time"
# ].strftime("%H:%M:%S"),
# "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"),
# }
# )
# # Create a temporary schedule object (not saved to DB)
# temp_schedule = InterviewSchedule(
# job=job,
# start_date=start_date,
# end_date=end_date,
# working_days=working_days,
# start_time=start_time,
# end_time=end_time,
# interview_duration=interview_duration,
# buffer_time=buffer_time,
# breaks=breaks, # Direct assignment for JSON field
# )
# # Get available slots
# available_slots = get_available_time_slots(temp_schedule)
# if len(available_slots) < len(candidates):
# messages.error(
# request,
# f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}",
# )
# return render(
# request,
# "interviews/schedule_interviews.html",
# {"form": form, "break_formset": break_formset, "job": job},
# )
# # Create a preview schedule
# preview_schedule = []
# for i, candidate in enumerate(candidates):
# slot = available_slots[i]
# preview_schedule.append(
# {"candidate": candidate, "date": slot["date"], "time": slot["time"]}
# )
# # Save the form data to session for later use
# schedule_data = {
# "start_date": start_date.isoformat(),
# "end_date": end_date.isoformat(),
# "working_days": working_days,
# "start_time": start_time.isoformat(),
# "end_time": end_time.isoformat(),
# "interview_duration": interview_duration,
# "buffer_time": buffer_time,
# "candidate_ids": [c.id for c in candidates],
# "breaks": breaks,
# }
# request.session["interview_schedule_data"] = schedule_data
# # Render the preview page
# return render(
# request,
# "interviews/preview_schedule.html",
# {
# "job": job,
# "schedule": preview_schedule,
# "start_date": start_date,
# "end_date": end_date,
# "working_days": working_days,
# "start_time": start_time,
# "end_time": end_time,
# "breaks": breaks,
# "interview_duration": interview_duration,
# "buffer_time": buffer_time,
# },
# )
# else:
# form = InterviewScheduleForm(slug=slug)
# break_formset = BreakTimeFormSet()
# return render(
# request,
# "interviews/schedule_interviews.html",
# {"form": form, "break_formset": break_formset, "job": job},
# )
def candidate_screening_view(request, slug):
"""
Manage candidate tiers and stage transitions
"""
@ -1265,7 +1518,7 @@ def candidate_tier_management_view(request, slug):
# if "update_tiers" in request.POST:
# tier1_count = int(request.POST.get("tier1_count", 100))
# messages.success(request, f"Tier categorization updated. Tier 1: {tier1_count} candidates")
# return redirect("candidate_tier_management", slug=slug)
# return redirect("candidate_screening_view", slug=slug)
# # Update individual candidate stages
# elif "update_stage" in request.POST:
@ -1354,9 +1607,16 @@ def candidate_tier_management_view(request, slug):
# "total_candidates": candidates.count(),
}
return render(request, "recruitment/candidate_tier_management.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):
"""
Manage candidate tiers and stage transitions
@ -1378,15 +1638,16 @@ def update_candidate_exam_status(request, slug):
return render(request, "includes/candidate_exam_status_form.html", {"candidate": candidate,"form": form})
def bulk_update_candidate_exam_status(request,slug):
job = get_object_or_404(JobPosting, slug=slug)
print(request.headers)
status = request.headers.get('status')
print(status)
if status:
for c in request.POST.items():
for candidate in get_candidates_from_request(request):
try:
candidate = Candidate.objects.get(pk=c[0])
candidate.exam_status = "Passed" if status == "pass" else "Failed"
candidate.stage = "Interview"
if status == "pass":
candidate.exam_status = "Passed"
candidate.stage = "Interview"
else:
candidate.exam_status = "Failed"
candidate.save()
except Exception as e:
print(e)
@ -1403,20 +1664,97 @@ def candidate_set_exam_date(request, slug):
candidate.exam_date = timezone.now()
candidate.save()
messages.success(request, f"Set exam date for {candidate.name} to {candidate.exam_date}")
return redirect("candidate_tier_management", slug=candidate.job.slug)
return redirect("candidate_screening_view", slug=candidate.job.slug)
def bulk_candidate_move_to_exam(request):
for c in request.POST.items():
try:
candidate = Candidate.objects.get(pk=c[0])
candidate.stage = "Exam"
candidate.applicant_status = "Candidate"
candidate.exam_date = timezone.now()
candidate.save()
except Exception as e:
print(e)
messages.success(request, f"Moved {candidate.name} to Exam stage")
return redirect("candidate_tier_management", slug=candidate.job.slug)
for candidate in get_candidates_from_request(request):
candidate.stage = "Exam"
candidate.applicant_status = "Candidate"
candidate.exam_date = timezone.now()
candidate.save()
messages.success(request, f"Candidates Moved to Exam stage")
return redirect("candidate_screening_view", slug=candidate.job.slug)
# def response():
# yield SSE.patch_elements("","")
# yield SSE.execute_script("console.log('hello world');")
# return DatastarResponse(response())
# return DatastarResponse(response())
def candidate_interview_view(request,slug):
job = get_object_or_404(JobPosting,slug=slug)
if "Datastar-Request" in request.headers:
for candidate in get_candidates_from_request(request):
print(candidate)
context = {"job":job,"candidates":job.candidates.all()}
return render(request,"recruitment/candidate_interview_view.html",context)
def interview_calendar_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
# Get all scheduled interviews for this job
scheduled_interviews = ScheduledInterview.objects.filter(
job=job
).select_related('candidate', 'zoom_meeting')
print(scheduled_interviews)
# Convert interviews to calendar events
events = []
for interview in scheduled_interviews:
# Create start datetime
start_datetime = datetime.combine(
interview.interview_date,
interview.interview_time
)
# Calculate end datetime based on interview duration
duration = interview.zoom_meeting.duration if interview.zoom_meeting else 60
end_datetime = start_datetime + timedelta(minutes=duration)
# Determine event color based on status
color = '#00636e' # Default color
if interview.status == 'confirmed':
color = '#00a86b' # Green for confirmed
elif interview.status == 'cancelled':
color = '#e74c3c' # Red for cancelled
elif interview.status == 'completed':
color = '#95a5a6' # Gray for completed
events.append({
'title': f"Interview: {interview.candidate.name}",
'start': start_datetime.isoformat(),
'end': end_datetime.isoformat(),
'url': f"{request.path}interview/{interview.id}/",
'color': color,
'extendedProps': {
'candidate': interview.candidate.name,
'email': interview.candidate.email,
'status': interview.status,
'meeting_id': interview.zoom_meeting.meeting_id if interview.zoom_meeting else None,
'join_url': interview.zoom_meeting.join_url if interview.zoom_meeting else None,
}
})
context = {
'job': job,
'events': events,
'calendar_color': '#00636e',
}
return render(request, 'recruitment/interview_calendar.html', context)
def interview_detail_view(request, slug, interview_id):
job = get_object_or_404(JobPosting, slug=slug)
interview = get_object_or_404(
ScheduledInterview,
id=interview_id,
job=job
)
context = {
'job': job,
'interview': interview,
}
return render(request, 'recruitment/interview_detail.html', context)

View File

@ -204,7 +204,7 @@ class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
# def job_detail(request, slug):
# job = get_object_or_404(models.JobPosting, slug=slug, status='Published')
# form = forms.CandidateForm()
# return render(request, 'jobs/job_detail.html', {'job': job, 'form': form})
@ -278,6 +278,8 @@ def candidate_update_stage(request, slug):
'new_stage_display': candidate.get_stage_display(),
'candidate': candidate
}
messages.success(request,"Candidate Stage Updated")
return redirect("candidate_detail",slug=candidate.slug)
def response():
stage_form = forms.CandidateStageForm(candidate=candidate)
context['stage_form'] = stage_form

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 heroicon">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>

After

Width:  |  Height:  |  Size: 340 B

View File

@ -80,7 +80,7 @@
<button type="submit" name="confirm_schedule" class="btn btn-success">
<i class="fas fa-check"></i> Confirm Schedule
</button>
<a href="{% url 'schedule_interviews' job_id=job.id %}" class="btn btn-secondary">
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Edit
</a>
</form>

View File

@ -99,7 +99,7 @@
<div class="mt-4">
<button type="submit" class="btn btn-primary">Preview Schedule</button>
<a href="{% url 'job_detail' pk=job.id %}" class="btn btn-secondary">Cancel</a>
<a href="{% url 'job_detail' slug=job.slug %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>

View File

@ -29,7 +29,7 @@
.text-info { color: var(--stage-exam) !important; }
.text-success { color: var(--stage-offer) !important; }
.text-secondary { color: var(--stage-inactive) !important; }
.bg-success { background-color: var(--kaauh-teal) !important; }
.bg-success { background-color: var(--kaauh-teal) !important; }
.bg-warning { background-color: #ffc107 !important; }
.bg-secondary { background-color: #6c757d !important; }
.bg-danger { background-color: #dc3545 !important; }
@ -117,7 +117,7 @@
border-right-color: transparent;
margin-bottom: -1px;
}
/* Main Action Button Style */
.btn-main-action {
background-color: var(--kaauh-teal);
@ -238,7 +238,7 @@
/* Active State (Applies glow/scale to current stage) */
.stage-item.active .stage-icon {
box-shadow: 0 0 0 4px rgba(0, 99, 110, 0.4);
box-shadow: 0 0 0 4px rgba(0, 99, 110, 0.4);
transform: scale(1.1);
}
.stage-item.active .stage-count {
@ -257,7 +257,7 @@
background-color: #e9ecef;
margin: 0 0.5rem;
position: relative;
top: -18px;
top: -18px;
z-index: 1;
transition: background-color 0.3s ease;
}
@ -283,7 +283,7 @@
</style>
{% endblock %}
{% block content %}
@ -296,7 +296,7 @@
<li class="breadcrumb-item"><a href="{% url 'job_list' %}" class="text-secondary">Jobs</a></li>
<li class="breadcrumb-item active" aria-current="page" class="text-secondary">Job Detail</li>
</ol>
</nav>
</nav>
<div class="row g-4">
{# LEFT COLUMN: JOB DETAILS WITH TABS #}
@ -387,12 +387,12 @@
</div>
<div class="col-md-6">
<button
type="button"
class="btn btn-outline-secondary"
id="copyJobLinkButton"
<button
type="button"
class="btn btn-outline-secondary"
id="copyJobLinkButton"
data-url="{{ job.application_url }}">
<i class="fas fa-link me-1"></i>
<i class="fas fa-link me-1"></i>
{% trans "Copy and Share Public Link" %}
</button>
@ -699,10 +699,12 @@
<i class="fas fa-list-alt me-1"></i> {% trans "View All Existing Forms" %}
</a> {% endcomment %}
{% comment %} <a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-main-action">
<i class="fas fa-user-plus"></i> {% trans "Create Applicant" %}
</a> {% endcomment %}
<a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-main-action">
<i class="fas fa-user-plus"></i> {% trans "Create Candidate" %}
</a>
<a href="{% url 'candidate_screening_view' job.slug %}" class="btn btn-main-action">
<i class="fas fa-layer-group"></i> {% trans "Manage Tiers" %}
</a>
</div>
</div>
@ -775,19 +777,19 @@
document.getElementById('copyJobLinkButton').addEventListener('click', function() {
// 1. Get the URL from the data attribute
const urlToCopy = this.getAttribute('data-url');
// 2. Use the modern Clipboard API
navigator.clipboard.writeText(urlToCopy).then(() => {
// 3. Show feedback message
const feedback = document.getElementById('copyFeedback');
feedback.style.display = 'inline';
// 4. Hide feedback after 2 seconds
setTimeout(() => {
feedback.style.display = 'none';
}, 2000);
}).catch(err => {
// Fallback for older browsers or security issues
console.error('Could not copy text: ', err);

View File

@ -11,7 +11,7 @@
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-gray-light: #f8f9fa;
--kaauh-gray-light: #f8f9fa;
}
/* Enhanced Card Styling (Consistent) */
@ -114,7 +114,7 @@
.table-view .table tbody tr:hover {
background-color: var(--kaauh-gray-light);
}
/* Pagination Link Styling (Consistent) */
.pagination .page-item .page-link {
color: var(--kaauh-teal-dark);
@ -134,7 +134,7 @@
display: flex;
gap: 0.5rem;
}
/* Icon color for empty state */
.text-muted.fa-3x {
color: var(--kaauh-teal-dark) !important;
@ -177,7 +177,7 @@
<option value="ended" {% if status_filter == 'ended' %}selected{% endif %}>{% trans "Ended" %}</option>
</select>
</div>
<div class="col-md-5">
<div class="filter-buttons">
<button type="submit" class="btn btn-main-action btn-lg">
@ -221,7 +221,7 @@
<div class="mt-auto pt-2 border-top">
<div class="d-flex gap-2">
<a href="{% url 'meeting_details' meeting.pk %}" class="btn btn-sm btn-outline-primary">
<a href="{% url 'meeting_details' meeting.slug %}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i> {% trans "View" %}
</a>
@ -231,12 +231,12 @@
</a>
{% endif %}
<a href="{% url 'update_meeting' meeting.pk %}" class="btn btn-sm btn-outline-secondary">
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-edit"></i>
</a>
<button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-delete-url="{% url 'delete_meeting' meeting.pk %}"
data-delete-url="{% url 'delete_meeting' meeting.slug %}"
data-item-name="{{ meeting.topic }}">
<i class="fas fa-trash-alt"></i>
</button>
@ -281,15 +281,15 @@
<i class="fas fa-sign-in-alt"></i>
</a>
{% endif %}
<a href="{% url 'meeting_details' meeting.pk %}" class="btn btn-outline-primary" title="{% trans 'View' %}">
<a href="{% url 'meeting_details' meeting.slug %}" class="btn btn-outline-primary" title="{% trans 'View' %}">
<i class="fas fa-eye"></i>
</a>
<a href="{% url 'update_meeting' meeting.pk %}" class="btn btn-outline-secondary" title="{% trans 'Update' %}">
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-outline-secondary" title="{% trans 'Update' %}">
<i class="fas fa-edit"></i>
</a>
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-delete-url="{% url 'delete_meeting' meeting.pk %}"
data-delete-url="{% url 'delete_meeting' meeting.slug %}"
data-item-name="{{ meeting.topic }}">
<i class="fas fa-trash-alt"></i>
</button>

View File

@ -3,61 +3,256 @@
{% block title %}{% trans "Meeting Details" %} - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
/* UI Variables for the KAAT-S Theme (Consistent with list_meetings.html) */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-gray-light: #f8f9fa;
}
/* Main Container and Card Styling */
.container {
padding: 2rem 1rem;
}
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
background-color: white;
margin-bottom: 2rem;
}
.card:not(.no-hover):hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.card.no-hover:hover {
transform: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
/* Header Card Styling */
.card-header {
background-color: var(--kaauh-gray-light);
border-bottom: 1px solid var(--kaauh-border);
padding: 1.5rem;
border-radius: 0.75rem 0.75rem 0 0; /* Match top border radius of card */
}
.card-header h1 {
color: var(--kaauh-teal-dark);
font-weight: 700;
margin: 0 0 0.5rem 0; /* Space below title */
display: flex;
align-items: center;
gap: 0.75rem;
}
.card-header .heroicon {
width: 1.75rem;
height: 1.75rem;
color: var(--kaauh-teal);
}
.card-header .btn-secondary {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
}
.card-header .btn-secondary:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
color: white;
}
/* Status Badge Styling (from list_meetings.html) */
.status-badge {
font-size: 0.8rem;
padding: 0.4em 0.8em;
border-radius: 0.4rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.7px;
}
.bg-waiting { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;}
.bg-started { background-color: var(--kaauh-teal) !important; color: white !important;}
.bg-ended { background-color: #dc3545 !important; color: white !important;}
/* Detail Row Styling */
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--kaauh-border);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
font-weight: 600;
color: var(--kaauh-teal-dark);
min-width: 40%; /* Ensure labels align */
}
.detail-value {
text-align: right;
color: var(--kaauh-primary-text);
word-wrap: break-word; /* Long URLs or text wrap */
max-width: 60%; /* Ensure values align */
}
/* Card Title Styling within content cards */
.card h2 {
color: var(--kaauh-teal-dark);
font-weight: 600;
padding: 1.5rem 1.5rem 0.75rem;
margin: 0;
border-bottom: 1px solid var(--kaauh-border);
}
/* Join URL Styling */
.join-url-display {
background-color: #f8f9fa;
border: 1px solid var(--kaauh-border);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
margin-top: 0.75rem;
word-break: break-all; /* Force long URLs to break */
}
/* Actions Styling */
.actions {
padding: 1.5rem;
display: flex;
flex-wrap: wrap;
gap: 1rem; /* Space between buttons */
justify-content: flex-start; /* Align buttons to the left */
}
.btn-primary {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-primary:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
color: white;
}
.btn-danger {
background-color: #dc3545;
border-color: #dc3545;
color: white;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-danger:hover {
background-color: #c82333;
border-color: #bd2130;
color: white;
}
.btn-secondary {
background-color: #6c757d;
border-color: #6c757d;
color: white;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background-color: #5a6268;
border-color: #545b62;
color: white;
}
/* API Response Styling */
#gateway-response {
margin-top: 2rem;
}
#gateway-response .card {
border-left: 4px solid var(--kaauh-teal); /* Indicate it's different content */
}
#gateway-response pre {
background-color: #f8f9fa;
border: 1px solid var(--kaauh-border);
border-radius: 0.5rem;
padding: 1rem;
margin-top: 0.75rem;
overflow-x: auto; /* For long JSON lines */
font-size: 0.9rem;
color: var(--kaauh-primary-text);
}
</style>
{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h1>
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0 8.268-2.943-9.542-7z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
{{ meeting.topic }}
</h1>
<span class="status-badge status-{{ meeting.status }}">
{{ meeting.status|title }}
</span>
<a href="{% url 'list_meetings' %}" class="btn btn-secondary">{% trans "Back to Meetings" %}</a>
<div class="container py-4">
<div class="card no-hover">
<div class="card-header">
<h1>
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0 8.268-2.943-9.542-7z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
{{ meeting.topic }}
</h1>
<span class="status-badge bg-{{ meeting.status }}">
{{ meeting.status|title }}
</span>
<a href="{% url 'list_meetings' %}" class="btn btn-secondary mt-3">{% trans "Back to Meetings" %}</a>
</div>
</div>
<div class="card">
<div class="card no-hover">
<h2>{% trans "Meeting Information" %}</h2>
<div class="detail-row">
<div class="detail-label">{% trans "Meeting ID" %}:</div>
<div class="detail-value">{{ meeting.meeting_id }}</div>
</div>
<div class="detail-row">
<div class="detail-label">{% trans "Topic" %}:</div>
<div class="detail-value">{{ meeting.topic }}</div>
</div>
<div class="detail-row">
<div class="detail-label">{% trans "Start Time" %}:</div>
<div class="detail-value">{{ meeting.start_time|date:"M d, Y H:i" }}</div>
</div>
<div class="detail-row">
<div class="detail-label">{% trans "Duration" %}:</div>
<div class="detail-value">{{ meeting.duration }} minutes</div>
<div class="detail-value">{{ meeting.duration }} {% trans "minutes" %}</div>
</div>
<div class="detail-row">
<div class="detail-label">{% trans "Timezone" %}:</div>
<div class="detail-value">{{ meeting.timezone|default:"UTC" }}</div>
</div>
<div class="detail-row">
<div class="detail-label">{% trans "Host Email" %}:</div>
<div class="detail-value">{{ meeting.host_email }}</div>
<div class="detail-value">{{ meeting.host_email|default:"N/A" }}</div>
</div>
</div>
{% if meeting.join_url %}
<div class="card">
<div class="card no-hover">
<h2>{% trans "Join Information" %}</h2>
<a href="{{ meeting.join_url }}" class="btn btn-primary" target="_blank">{% trans "Join Meeting" %}</a>
{% comment %} <div class="join-url-display">
<strong>{% trans "Join URL:" %}</strong> {{ meeting.join_url }}
</div> {% endcomment %}
{% if meeting.password %}
<div class="detail-row">
<div class="detail-label">{% trans "Password" %}:</div>
<div class="detail-value">{{ meeting.password }}</div>
<div class="detail-label">{% trans "Password" %}: {{ meeting.password }}</div>
<div class="detail-value"></div>
</div>
{% endif %}
</div>
{% endif %}
<div class="card">
<div class="card no-hover">
<h2>{% trans "Settings" %}</h2>
<div class="detail-row">
<div class="detail-label">{% trans "Host Video" %}:</div>
<div class="detail-value">{{ meeting.host_video|yesno:"Yes,No" }}</div>
</div>
<div class="detail-row">
<div class="detail-label">{% trans "Participant Video" %}:</div>
<div class="detail-value">{{ meeting.participant_video|yesno:"Yes,No" }}</div>
@ -76,27 +271,36 @@
</div>
</div>
<div class="actions">
{% if meeting.zoom_gateway_response %}
<a href="#" class="btn btn-secondary" onclick="toggleGateway()">{% trans "View API Response" %}</a>
{% endif %}
<a href="{% url 'update_meeting' meeting.pk %}" class="btn btn-primary">{% trans "Update Meeting" %}</a>
<form method="post" action="{% url 'delete_meeting' meeting.pk %}" style="display: inline;">
{% csrf_token %}
<button type="submit" class="btn btn-danger" onclick="return confirm('{% trans "Are you sure?" %}')">{% trans "Delete Meeting" %}</button>
</form>
<div class="card no-hover">
<div class="actions">
{% if meeting.zoom_gateway_response %}
<a href="#" class="btn btn-secondary" onclick="toggleGateway()">{% trans "View API Response" %}</a>
{% endif %}
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-primary">{% trans "Update Meeting" %}</a>
<form method="post" action="{% url 'delete_meeting' meeting.pk %}" style="display: inline;">
{% csrf_token %}
<button type="submit" class="btn btn-danger" onclick="return confirm('{% trans "Are you sure?" %}')">{% trans "Delete Meeting" %}</button>
</form>
</div>
</div>
{% if meeting.zoom_gateway_response %}
<div id="gateway-response" style="display: none; margin-top: 2rem;">
{% comment %} <div id="gateway-response">
<div class="card">
<h3>{% trans "Zoom API Response" %}</h3>
<pre>{{ meeting.zoom_gateway_response|safe }}</pre>
</div>
</div>
</div> {% endcomment %}
<script>
function toggleGateway() {
document.getElementById('gateway-response').style.display = document.getElementById('gateway-response').style.display === 'none' ? 'block' : 'none';
const element = document.getElementById('gateway-response');
if (element.style.display === 'none' || !element.style.display) {
element.style.display = 'block';
} else {
element.style.display = 'none';
}
}
</script>
{% endif %}
</div>
{% endblock %}

View File

@ -46,7 +46,7 @@
color: var(--kaauh-gray);
font-size: 1rem;
}
/* CARD TITLE STYLING */
.card-title {
font-size: 1.25rem;
@ -140,9 +140,9 @@
transition: all 0.2s ease;
text-decoration: none;
}
/* Primary Action Button (Update) */
.btn-main-action {
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
@ -190,24 +190,20 @@
<div class="card">
<h2 class="card-title">{% trans "Meeting Information" %}</h2>
<form method="post" action="{% url 'update_meeting' meeting.pk %}">
<form method="post" action="{% url 'update_meeting' meeting.slug %}">
{% csrf_token %}
<div class="form-row">
<label for="topic" class="form-label">{% trans "Topic:" %}</label>
<input type="text" id="topic" name="topic" class="form-input" value="{{ meeting.topic }}" required>
<label class="form-label" for="{{ form.topic.id_for_label }}">{% trans "Topic" %}</label>
{{ form.topic }}
</div>
<div class="form-row">
<label for="start_time" class="form-label">{% trans "Start Time (ISO 8601):" %}</label>
{# Note: Django template filter for datetime-local needs to be precise Y-m-d\TH:i #}
<input type="datetime-local" id="start_time" name="start_time" class="form-input"
value="{{ meeting.start_time|slice:'0:16' }}" required>
<label class="form-label" for="{{ form.start_time.id_for_label }}">{% trans "Start Time" %}</label>
{{ form.start_time }}
</div>
<div class="form-row">
<label for="duration" class="form-label">{% trans "Duration (minutes):" %}</label>
<input type="number" id="duration" name="duration" class="form-input" value="{{ meeting.duration }}" required>
<label class="form-label" for="{{ form.duration.id_for_label }}">{% trans "Duration (minutes)" %}</label>
{{ form.duration }}
</div>
<div class="actions">

View File

@ -0,0 +1,318 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}Candidate Tier Management - {{ job.title }} - ATS{% endblock %}
{% block customCSS %}
<style>
/* Minimal Tier Management Styles */
.tier-controls {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1.5rem;
}
.tier-controls .form-row {
display: flex;
align-items: end;
gap: 0.75rem;
}
.tier-controls .form-group {
flex: 1;
margin-bottom: 0;
}
.bulk-update-controls {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1.5rem;
}
.stage-groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stage-group {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
overflow: hidden;
}
.stage-group .stage-header {
background-color: #495057;
color: white;
padding: 0.5rem 0.75rem;
font-weight: 500;
font-size: 0.95rem;
}
.stage-group .stage-body {
padding: 0.75rem;
min-height: 80px;
}
.stage-candidate {
padding: 0.375rem;
border-bottom: 1px solid #f1f3f4;
}
.stage-candidate:last-child {
border-bottom: none;
}
.match-score {
font-weight: 600;
color: #0056b3;
}
.btn-sm {
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
}
/* Tab Styles for Tiers */
.nav-tabs {
border-bottom: 1px solid #dee2e6;
margin-bottom: 1rem;
}
.nav-tabs .nav-link {
border: none;
color: #495057;
font-weight: 500;
padding: 0.5rem 1rem;
transition: all 0.2s;
}
.nav-tabs .nav-link:hover {
border: none;
background-color: #f8f9fa;
}
.nav-tabs .nav-link.active {
color: #495057;
background-color: #fff;
border: none;
border-bottom: 2px solid #007bff;
font-weight: 600;
}
.tier-1 .nav-link {
color: #155724;
}
.tier-1 .nav-link.active {
border-bottom-color: #28a745;
}
.tier-2 .nav-link {
color: #856404;
}
.tier-2 .nav-link.active {
border-bottom-color: #ffc107;
}
.tier-3 .nav-link {
color: #721c24;
}
.tier-3 .nav-link.active {
border-bottom-color: #dc3545;
}
/* Candidate Table Styles */
.candidate-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
background-color: white;
border-radius: 0.375rem;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.candidate-table thead {
background-color: #f8f9fa;
}
.candidate-table th {
padding: 0.75rem;
text-align: left;
font-weight: 600;
font-size: 0.875rem;
color: #495057;
border-bottom: 1px solid #dee2e6;
}
.candidate-table td {
padding: 0.75rem;
border-bottom: 1px solid #f1f3f4;
vertical-align: middle;
}
.candidate-table tbody tr:hover {
background-color: #f8f9fa;
}
.candidate-table tbody tr:last-child td {
border-bottom: none;
}
.candidate-name {
font-weight: 600;
font-size: 0.95rem;
}
.candidate-details {
font-size: 0.8rem;
color: #6c757d;
}
.candidate-table-responsive {
overflow-x: auto;
margin-bottom: 1rem;
}
.stage-badge {
padding: 0.125rem 0.5rem;
border-radius: 0.5rem;
font-size: 0.7rem;
font-weight: 600;
margin-left: 0.375rem;
}
.stage-Applied {
background-color: #e9ecef;
color: #495057;
}
.stage-Exam {
background-color: #cce5ff;
color: #004085;
}
.stage-Interview {
background-color: #d1ecf1;
color: #0c5460;
}
.stage-Offer {
background-color: #d4edda;
color: #155724;
}
.exam-controls {
display: flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.375rem;
}
.exam-controls select,
.exam-controls input {
font-size: 0.75rem;
padding: 0.125rem 0.25rem;
}
.tier-badge {
font-size: 0.7rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background-color: rgba(0,0,0,0.1);
color: #495057;
margin-left: 0.375rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="fas fa-layer-group me-2"></i>
{% trans "Interview" %} - {{ job.title }}
</h1>
</div>
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
</a>
</div>
<!-- Tier Display -->
<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)}">
{% url "candidate_interview_view" job.slug as bulk_update_candidate_exam_status_url %}
{% if candidates %}
<button class="btn btn-primary"
data-attr="{disabled: !$selections.filter(Boolean).length}"
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
contentType: 'form',
selector: '#myform',
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'pass'}
})"
>Mark as Pass and move to Interview</button>
<button class="btn btn-danger"
data-attr="{disabled: !$selections.filter(Boolean).length}"
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
contentType: 'form',
selector: '#myform',
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'fail'}
})"
>Mark as Failed</button>
{% endif %}
<form id="myform" action="{{move_to_exam_url}}" method="post">
<table class="candidate-table">
<thead>
<tr>
<th>
{% if candidates %}
<div class="form-check">
<input
data-bind-_all
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="candidate-{{ candidate.id }}">
</div>
{% endif %}
</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Contact" %}</th>
<th>{% trans "Interview Date" %}</th>
<th>{% trans "Interview Link" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for candidate in candidates %}
<tr>
<td>
<div class="form-check">
<input
data-bind-selections
data-attr-disabled="$_fetching"
name="{{ candidate.id }}"
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
</div>
</td>
<td>
<div class="candidate-name">{{ candidate.name }}</div>
</td>
<td>
<div class="candidate-details">
Email: {{ candidate.email }}<br>
Phone: {{ candidate.phone }}<br>
</div>
</td>
<td>{{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>
<button class="btn btn-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'candidate_criteria_view_htmx' candidate.pk %}"
hx-target="#candidateviewModalBody"
>
{% include "icons/view.html" %}
{% trans "View" %}</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
<!-- Tab Content -->
<div class="modal fade modal-lg" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="candidateviewModalLabel">Form Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div id="candidateviewModalBody" class="modal-body">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,251 @@
<!-- templates/recruitment/interview_calendar.html -->
{% extends "base.html" %}
{% load static %}
{% block customCSS %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.css" rel="stylesheet">
<style>
:root {
--calendar-color: #00636e;
--calendar-light: rgba(0, 99, 110, 0.1);
--calendar-hover: rgba(0, 99, 110, 0.2);
}
.calendar-header {
background-color: var(--calendar-color);
color: white;
padding: 1rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
}
.calendar-container {
background-color: white;
border-radius: 0.25rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
padding: 1rem;
}
.fc-toolbar-title {
color: var(--calendar-color) !important;
}
.fc-button-primary {
background-color: var(--calendar-color) !important;
border-color: var(--calendar-color) !important;
}
.fc-button-primary:hover {
background-color: #004d56 !important;
border-color: #004d56 !important;
}
.fc-button-primary:not(:disabled):active, .fc-button-primary:not(:disabled).fc-button-active {
background-color: #003a40 !important;
border-color: #003a40 !important;
}
.fc-daygrid-day.fc-day-today {
background-color: var(--calendar-light) !important;
}
.fc-event-title {
font-weight: 500;
}
.status-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 1rem;
}
.status-scheduled {
background-color: #e3f2fd;
color: #0d47a1;
}
.status-confirmed {
background-color: #e8f5e9;
color: #1b5e20;
}
.status-cancelled {
background-color: #ffebee;
color: #b71c1c;
}
.status-completed {
background-color: #f5f5f5;
color: #424242;
}
.interview-details {
margin-top: 1rem;
}
.interview-details .card {
border-left: 4px solid var(--calendar-color);
}
.calendar-legend {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1rem;
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 0.25rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.legend-color {
width: 1rem;
height: 1rem;
border-radius: 0.25rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="calendar-header">
<div class="d-flex justify-content-between align-items-center">
<h1 class="h3 mb-0">Interview Calendar</h1>
<div>
<span class="h5">{{ job.title }}</span>
</div>
</div>
</div>
<div class="calendar-container">
<div id="calendar"></div>
<div class="calendar-legend">
<div class="legend-item">
<div class="legend-color" style="background-color: #00636e;"></div>
<span>Scheduled</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #00a86b;"></div>
<span>Confirmed</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #e74c3c;"></div>
<span>Cancelled</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #95a5a6;"></div>
<span>Completed</span>
</div>
</div>
</div>
<div class="interview-details" id="interview-details" style="display: none;">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Interview Details</h5>
<button type="button" class="btn-close" id="close-details"></button>
</div>
<div class="card-body" id="interview-info">
<!-- Interview details will be loaded here -->
</div>
</div>
</div>
</div>
<!-- Include FullCalendar JS -->
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var events = {{ events|safe }};
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
},
events: events,
eventClick: function(info) {
// Prevent default browser behavior
info.jsEvent.preventDefault();
// Show interview details
showInterviewDetails(info.event);
},
eventMouseEnter: function(info) {
// Change cursor to pointer on hover
document.body.style.cursor = 'pointer';
},
eventMouseLeave: function() {
// Reset cursor
document.body.style.cursor = 'default';
},
height: 'auto',
aspectRatio: 2,
eventDisplay: 'block',
displayEventTime: true,
displayEventEnd: true,
});
calendar.render();
// Function to show interview details
function showInterviewDetails(event) {
const detailsContainer = document.getElementById('interview-details');
const infoContainer = document.getElementById('interview-info');
const statusClass = `status-${event.extendedProps.status}`;
const statusText = event.extendedProps.status.charAt(0).toUpperCase() + event.extendedProps.status.slice(1);
let meetingInfo = '';
if (event.extendedProps.meeting_id) {
meetingInfo = `
<div class="mb-3">
<h6>Meeting Information</h6>
<p><strong>Meeting ID:</strong> ${event.extendedProps.meeting_id}</p>
<p><strong>Join URL:</strong> <a href="${event.extendedProps.join_url}" target="_blank">${event.extendedProps.join_url}</a></p>
</div>
`;
}
infoContainer.innerHTML = `
<div class="row">
<div class="col-md-6">
<h6>Candidate Information</h6>
<p><strong>Name:</strong> ${event.extendedProps.candidate}</p>
<p><strong>Email:</strong> ${event.extendedProps.email}</p>
</div>
<div class="col-md-6">
<h6>Interview Details</h6>
<p><strong>Date:</strong> ${event.start.toLocaleDateString()}</p>
<p><strong>Time:</strong> ${event.start.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} - ${event.end.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</p>
<p><strong>Status:</strong> <span class="status-badge ${statusClass}">${statusText}</span></p>
</div>
</div>
${meetingInfo}
<div class="mt-3">
<a href="${event.url}" class="btn btn-primary btn-sm">View Full Details</a>
</div>
`;
detailsContainer.style.display = 'block';
// Scroll to details
detailsContainer.scrollIntoView({ behavior: 'smooth' });
}
// Close details button
document.getElementById('close-details').addEventListener('click', function() {
document.getElementById('interview-details').style.display = 'none';
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,149 @@
<!-- templates/recruitment/interview_detail.html -->
{% extends "base.html" %}
{% load static %}
{% block extra_css %}
<style>
:root {
--calendar-color: #00636e;
}
.detail-header {
background-color: var(--calendar-color);
color: white;
padding: 1rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
}
.detail-card {
border-left: 4px solid var(--calendar-color);
}
.status-badge {
font-size: 0.875rem;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
}
.status-scheduled {
background-color: #e3f2fd;
color: #0d47a1;
}
.status-confirmed {
background-color: #e8f5e9;
color: #1b5e20;
}
.status-cancelled {
background-color: #ffebee;
color: #b71c1c;
}
.status-completed {
background-color: #f5f5f5;
color: #424242;
}
</style>
{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="detail-header">
<div class="d-flex justify-content-between align-items-center">
<h1 class="h3 mb-0">Interview Details</h1>
<div>
<span class="h5">{{ job.title }}</span>
</div>
</div>
</div>
<div class="card detail-card mb-4">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h5>Candidate Information</h5>
<table class="table table-borderless">
<tr>
<td><strong>Name:</strong></td>
<td>{{ interview.candidate.name }}</td>
</tr>
<tr>
<td><strong>Email:</strong></td>
<td>{{ interview.candidate.email }}</td>
</tr>
<tr>
<td><strong>Phone:</strong></td>
<td>{{ interview.candidate.phone|default:"Not provided" }}</td>
</tr>
</table>
</div>
<div class="col-md-6">
<h5>Interview Details</h5>
<table class="table table-borderless">
<tr>
<td><strong>Date:</strong></td>
<td>{{ interview.interview_date|date:"l, F j, Y" }}</td>
</tr>
<tr>
<td><strong>Time:</strong></td>
<td>{{ interview.interview_time|time:"g:i A" }}</td>
</tr>
<tr>
<td><strong>Status:</strong></td>
<td>
<span class="status-badge status-{{ interview.status }}">
{{ interview.status|title }}
</span>
</td>
</tr>
</table>
</div>
</div>
{% if interview.zoom_meeting %}
<div class="mt-4">
<h5>Meeting Information</h5>
<table class="table table-borderless">
<tr>
<td><strong>Meeting ID:</strong></td>
<td>{{ interview.zoom_meeting.meeting_id }}</td>
</tr>
<tr>
<td><strong>Topic:</strong></td>
<td>{{ interview.zoom_meeting.topic }}</td>
</tr>
<tr>
<td><strong>Duration:</strong></td>
<td>{{ interview.zoom_meeting.duration }} minutes</td>
</tr>
<tr>
<td><strong>Join URL:</strong></td>
<td><a href="{{ interview.zoom_meeting.join_url }}" target="_blank">{{ interview.zoom_meeting.join_url }}</a></td>
</tr>
</table>
</div>
{% endif %}
<div class="mt-4">
<div class="d-flex gap-2">
<a href="{% url 'interview_calendar' slug=job.slug %}" class="btn btn-secondary">
<i class="fas fa-calendar"></i> Back to Calendar
</a>
{% if interview.status == 'scheduled' %}
<button class="btn btn-success">
<i class="fas fa-check"></i> Confirm Interview
</button>
{% endif %}
{% if interview.status != 'cancelled' and interview.status != 'completed' %}
<button class="btn btn-danger">
<i class="fas fa-times"></i> Cancel Interview
</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}