meetings list

This commit is contained in:
Faheed 2025-11-09 19:13:08 +03:00
parent 517ee1d54d
commit 4148c7eb16
21 changed files with 771 additions and 34 deletions

View File

@ -584,11 +584,12 @@ class InterviewScheduleForm(forms.ModelForm):
class Meta: class Meta:
model = InterviewSchedule model = InterviewSchedule
fields = [ fields = [
'candidates', 'start_date', 'end_date', 'working_days', 'candidates', 'interview_type', 'start_date', 'end_date', 'working_days',
'start_time', 'end_time', 'interview_duration', 'buffer_time', 'start_time', 'end_time', 'interview_duration', 'buffer_time',
'break_start_time', 'break_end_time' 'break_start_time', 'break_end_time'
] ]
widgets = { widgets = {
'interview_type': forms.Select(attrs={'class': 'form-control'}),
'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), 'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'end_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'}), 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
@ -1326,7 +1327,8 @@ class CandidateEmailForm(forms.Form):
"""Generate initial message with candidate and meeting information""" """Generate initial message with candidate and meeting information"""
candidate=self.candidates.first() candidate=self.candidates.first()
message_parts=[] message_parts=[]
if candidate.stage == 'Applied':
if candidate and candidate.stage == 'Applied':
message_parts = [ message_parts = [
f"Than you, for your interest in the {self.job.title} role.", f"Than you, for your interest in the {self.job.title} role.",
f"We regret to inform you that you were not selected to move forward to the exam round at this time.", f"We regret to inform you that you were not selected to move forward to the exam round at this time.",
@ -1335,7 +1337,7 @@ class CandidateEmailForm(forms.Form):
f"Wishing you the best in your job search,", f"Wishing you the best in your job search,",
f"The KAAUH Hiring team" f"The KAAUH Hiring team"
] ]
elif candidate.stage == 'Exam': elif candidate and candidate.stage == 'Exam':
message_parts = [ message_parts = [
f"Than you,for your interest in the {self.job.title} role.", f"Than you,for your interest in the {self.job.title} role.",
f"We're pleased to inform you that your initial screening was successful!", f"We're pleased to inform you that your initial screening was successful!",
@ -1346,7 +1348,7 @@ class CandidateEmailForm(forms.Form):
f"Best regards, The KAAUH Hiring team" f"Best regards, The KAAUH Hiring team"
] ]
elif candidate.stage == 'Interview': elif candidate and candidate.stage == 'Interview':
message_parts = [ message_parts = [
f"Than you, for your interest in the {self.job.title} role.", f"Than you, for your interest in the {self.job.title} role.",
f"We're pleased to inform you that your initial screening was successful!", f"We're pleased to inform you that your initial screening was successful!",
@ -1357,7 +1359,7 @@ class CandidateEmailForm(forms.Form):
f"Best regards, The KAAUH Hiring team" f"Best regards, The KAAUH Hiring team"
] ]
elif candidate.stage == 'Offer': elif candidate and candidate.stage == 'Offer':
message_parts = [ message_parts = [
f"Congratulations, ! We are delighted to inform you that we are extending a formal offer of employment for the {self.job.title} role.", f"Congratulations, ! We are delighted to inform you that we are extending a formal offer of employment for the {self.job.title} role.",
f"This is an exciting moment, and we look forward to having you join the KAAUH team.", f"This is an exciting moment, and we look forward to having you join the KAAUH team.",
@ -1366,7 +1368,7 @@ class CandidateEmailForm(forms.Form):
f"Welcome to the team!", f"Welcome to the team!",
f"Best regards, The KAAUH Hiring team" f"Best regards, The KAAUH Hiring team"
] ]
elif candidate.stage == 'Hired': elif candidate and candidate.stage == 'Hired':
message_parts = [ message_parts = [
f"Welcome aboard,!", f"Welcome aboard,!",
f"We are thrilled to officially confirm your employment as our new {self.job.title}.", f"We are thrilled to officially confirm your employment as our new {self.job.title}.",
@ -1589,6 +1591,17 @@ KAAUH HIRING TEAM
self.initial['message_for_agency'] = agency_message.strip() self.initial['message_for_agency'] = agency_message.strip()
self.initial['message_for_participants'] = participants_message.strip() self.initial['message_for_participants'] = participants_message.strip()
class InterviewScheduleLocationForm(forms.ModelForm):
class Meta:
model=InterviewSchedule
fields=['location']
widgets={
'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}),
}

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-09 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0004_remove_jobposting_participants_and_more'),
]
operations = [
migrations.AddField(
model_name='scheduledinterview',
name='meeting_type',
field=models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type'),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2025-11-09 11:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0005_scheduledinterview_meeting_type'),
]
operations = [
migrations.RemoveField(
model_name='scheduledinterview',
name='meeting_type',
),
migrations.AddField(
model_name='interviewschedule',
name='meeting_type',
field=models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-09 11:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0006_remove_scheduledinterview_meeting_type_and_more'),
]
operations = [
migrations.RenameField(
model_name='interviewschedule',
old_name='meeting_type',
new_name='interview_type',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-09 12:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0007_rename_meeting_type_interviewschedule_interview_type'),
]
operations = [
migrations.AddField(
model_name='interviewschedule',
name='location',
field=models.CharField(blank=True, default='Remote', null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-09 13:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0008_interviewschedule_location'),
]
operations = [
migrations.AlterField(
model_name='zoommeeting',
name='meeting_id',
field=models.CharField(blank=True, db_index=True, max_length=20, null=True, unique=True, verbose_name='Meeting ID'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.2.7 on 2025-11-09 13:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0009_alter_zoommeeting_meeting_id'),
]
operations = [
migrations.AlterField(
model_name='zoommeeting',
name='meeting_id',
field=models.CharField(db_index=True, default=1, max_length=20, unique=True, verbose_name='Meeting ID'),
preserve_default=False,
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.2.7 on 2025-11-09 13:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0010_alter_zoommeeting_meeting_id'),
]
operations = [
migrations.AlterField(
model_name='scheduledinterview',
name='zoom_meeting',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting'),
),
]

View File

@ -742,6 +742,7 @@ class ZoomMeeting(Base):
topic = models.CharField(max_length=255, verbose_name=_("Topic")) topic = models.CharField(max_length=255, verbose_name=_("Topic"))
meeting_id = models.CharField( meeting_id = models.CharField(
db_index=True, max_length=20, unique=True, verbose_name=_("Meeting ID") # Added index db_index=True, max_length=20, unique=True, verbose_name=_("Meeting ID") # Added index
) # Unique identifier for the meeting ) # Unique identifier for the meeting
start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time")) # Added index start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time")) # Added index
duration = models.PositiveIntegerField( duration = models.PositiveIntegerField(
@ -1599,6 +1600,20 @@ class BreakTime(models.Model):
class InterviewSchedule(Base): class InterviewSchedule(Base):
"""Stores the scheduling criteria for interviews""" """Stores the scheduling criteria for interviews"""
"""Stores individual scheduled interviews"""
class InterviewType(models.TextChoices):
REMOTE = 'Remote', 'Remote Interview'
ONSITE = 'Onsite', 'In-Person Interview'
interview_type = models.CharField(
max_length=10,
choices=InterviewType.choices,
default=InterviewType.REMOTE,
verbose_name="Interview Meeting Type"
)
location=models.CharField(null=True,blank=True,default='Remote')
job = models.ForeignKey( job = models.ForeignKey(
JobPosting, on_delete=models.CASCADE, related_name="interview_schedules", db_index=True JobPosting, on_delete=models.CASCADE, related_name="interview_schedules", db_index=True
@ -1636,14 +1651,16 @@ class InterviewSchedule(Base):
class ScheduledInterview(Base): class ScheduledInterview(Base):
"""Stores individual scheduled interviews"""
#for one candidate
candidate = models.ForeignKey( candidate = models.ForeignKey(
Candidate, Candidate,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="scheduled_interviews", related_name="scheduled_interviews",
db_index=True db_index=True
) )
participants = models.ManyToManyField('Participants', blank=True) participants = models.ManyToManyField('Participants', blank=True)
system_users=models.ManyToManyField(User,blank=True) system_users=models.ManyToManyField(User,blank=True)
@ -1653,7 +1670,8 @@ class ScheduledInterview(Base):
"JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True "JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True
) )
zoom_meeting = models.OneToOneField( zoom_meeting = models.OneToOneField(
ZoomMeeting, on_delete=models.CASCADE, related_name="interview", db_index=True ZoomMeeting, on_delete=models.CASCADE, related_name="interview", db_index=True,
null=True, blank=True
) )
schedule = models.ForeignKey( schedule = models.ForeignKey(
InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True, db_index=True InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True, db_index=True

View File

@ -461,6 +461,7 @@ def create_interview_and_meeting(
meeting_topic = f"Interview for {job.title} - {candidate.name}" meeting_topic = f"Interview for {job.title} - {candidate.name}"
# 1. External API Call (Slow) # 1. External API Call (Slow)
result = create_zoom_meeting(meeting_topic, interview_datetime, duration) result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
if result["status"] == "success": if result["status"] == "success":

View File

@ -234,5 +234,8 @@ urlpatterns = [
path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'), path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'),
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'), path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'), path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
path('interview/schedule/location/<slug:slug>/',views.schedule_interview_location_form,name='schedule_interview_location_form'),
path('interview/list',views.InterviewListView,name='interview_list')
] ]

View File

@ -43,7 +43,8 @@ from .forms import (
LinkedPostContentForm, LinkedPostContentForm,
CandidateEmailForm, CandidateEmailForm,
SourceForm, SourceForm,
InterviewEmailForm InterviewEmailForm,
InterviewScheduleLocationForm
) )
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
from rest_framework import viewsets from rest_framework import viewsets
@ -192,6 +193,43 @@ class ZoomMeetingListView(LoginRequiredMixin, ListView):
context["status_filter"] = self.request.GET.get("status", "") context["status_filter"] = self.request.GET.get("status", "")
context["candidate_name_filter"] = self.request.GET.get("candidate_name", "") context["candidate_name_filter"] = self.request.GET.get("candidate_name", "")
return context return context
@login_required
def InterviewListView(request):
interview_type=request.GET.get('interview_type','Remote')
print(interview_type)
if interview_type=='Onsite':
meetings=ScheduledInterview.objects.filter(schedule__interview_type=interview_type)
else:
meetings=ZoomMeeting.objects.all()
print(meetings)
return render(request, "meetings/list_meetings.html",{
'meetings':meetings,
'current_interview_type':interview_type
})
# search_query = request.GET.get("q", "") # Renamed from 'search' to 'q' for consistency
# if search_query:
# interviews = interviews.filter(
# Q(topic__icontains=search_query) | Q(meeting_id__icontains=search_query)
# )
# # Handle filter by status
# status_filter = request.GET.get("status", "")
# if status_filter:
# queryset = queryset.filter(status=status_filter)
# # Handle search by candidate name
# candidate_name = request.GET.get("candidate_name", "")
# if candidate_name:
# # Filter based on the name of the candidate associated with the meeting's interview
# queryset = queryset.filter(
# Q(interview__candidate__first_name__icontains=candidate_name) |
# Q(interview__candidate__last_name__icontains=candidate_name)
# )
class ZoomMeetingDetailsView(LoginRequiredMixin, DetailView): class ZoomMeetingDetailsView(LoginRequiredMixin, DetailView):
@ -1126,6 +1164,7 @@ def _handle_get_request(request, slug, job):
def _handle_preview_submission(request, slug, job): def _handle_preview_submission(request, slug, job):
""" """
Handles the initial POST request (Preview Schedule). Handles the initial POST request (Preview Schedule).
Validates forms, calculates slots, saves data to session, and renders preview. Validates forms, calculates slots, saves data to session, and renders preview.
@ -1137,6 +1176,7 @@ def _handle_preview_submission(request, slug, job):
if form.is_valid(): if form.is_valid():
# Get the form data # Get the form data
candidates = form.cleaned_data["candidates"] candidates = form.cleaned_data["candidates"]
interview_type=form.cleaned_data["interview_type"]
start_date = form.cleaned_data["start_date"] start_date = form.cleaned_data["start_date"]
end_date = form.cleaned_data["end_date"] end_date = form.cleaned_data["end_date"]
working_days = form.cleaned_data["working_days"] working_days = form.cleaned_data["working_days"]
@ -1197,6 +1237,7 @@ def _handle_preview_submission(request, slug, job):
# Save the form data to session for later use # Save the form data to session for later use
schedule_data = { schedule_data = {
"interview_type":interview_type,
"start_date": start_date.isoformat(), "start_date": start_date.isoformat(),
"end_date": end_date.isoformat(), "end_date": end_date.isoformat(),
"working_days": working_days, "working_days": working_days,
@ -1217,6 +1258,7 @@ def _handle_preview_submission(request, slug, job):
{ {
"job": job, "job": job,
"schedule": preview_schedule, "schedule": preview_schedule,
"interview_type":interview_type,
"start_date": start_date, "start_date": start_date,
"end_date": end_date, "end_date": end_date,
"working_days": working_days, "working_days": working_days,
@ -1260,6 +1302,7 @@ def _handle_confirm_schedule(request, slug, job):
schedule = InterviewSchedule.objects.create( schedule = InterviewSchedule.objects.create(
job=job, job=job,
created_by=request.user, created_by=request.user,
interview_type=schedule_data["interview_type"],
start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
working_days=schedule_data["working_days"], working_days=schedule_data["working_days"],
@ -1286,34 +1329,59 @@ def _handle_confirm_schedule(request, slug, job):
available_slots = get_available_time_slots(schedule) # This should still be synchronous and fast available_slots = get_available_time_slots(schedule) # This should still be synchronous and fast
# 4. Queue scheduled interviews asynchronously (FAST RESPONSE) # 4. Queue scheduled interviews asynchronously (FAST RESPONSE)
queued_count = 0 if schedule.interview_type=='Remote':
for i, candidate in enumerate(candidates): queued_count = 0
if i < len(available_slots): for i, candidate in enumerate(candidates):
slot = available_slots[i] if i < len(available_slots):
slot = available_slots[i]
# Dispatch the individual creation task to the background queue # Dispatch the individual creation task to the background queue
async_task(
"recruitment.tasks.create_interview_and_meeting", async_task(
candidate.pk, "recruitment.tasks.create_interview_and_meeting",
job.pk, candidate.pk,
schedule.pk, job.pk,
slot['date'], schedule.pk,
slot['time'], slot['date'],
schedule.interview_duration, slot['time'],
) schedule.interview_duration,
queued_count += 1 )
queued_count += 1
# 5. Success and Cleanup (IMMEDIATE RESPONSE) # 5. Success and Cleanup (IMMEDIATE RESPONSE)
messages.success( messages.success(
request, request,
f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!" f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!"
) )
# Clear both session data keys upon successful completion # Clear both session data keys upon successful completion
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY] 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] if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
return redirect("job_detail", slug=slug)
else:
for i, candidate in enumerate(candidates):
if i < len(available_slots):
slot = available_slots[i]
ScheduledInterview.objects.create(
candidate=candidate,
job=job,
# zoom_meeting=None,
schedule=schedule,
interview_date=slot['date'],
interview_time= slot['time']
)
messages.success(
request,
f"Onsite schedule Interview Create succesfully"
)
# Clear both session data keys upon successful completion
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
return redirect('schedule_interview_location_form',slug=schedule.slug)
return redirect("job_detail", slug=slug)
def schedule_interviews_view(request, slug): def schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug) job = get_object_or_404(JobPosting, slug=slug)
@ -1322,6 +1390,7 @@ def schedule_interviews_view(request, slug):
return _handle_preview_submission(request, slug, job) return _handle_preview_submission(request, slug, job)
else: else:
return _handle_get_request(request, slug, job) return _handle_get_request(request, slug, job)
def confirm_schedule_interviews_view(request, slug): def confirm_schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug) job = get_object_or_404(JobPosting, slug=slug)
if request.method == "POST": if request.method == "POST":
@ -4062,4 +4131,18 @@ def send_interview_email(request, slug):
def schedule_interview_location_form(request,slug):
schedule=get_object_or_404(InterviewSchedule,slug=slug)
if request.method=='POST':
form=InterviewScheduleLocationForm(request.POST,instance=schedule)
form.save()
return redirect('list_meetings')
else:
form=InterviewScheduleLocationForm(instance=schedule)
return render(request,'interviews/schedule_interview_location_form.html',{'form':form,'schedule':schedule})
def onsite_interview_list_view(request):
onsite_interviews=ScheduledInterview.objects.filter(schedule__interview_type='Onsite')
return render(request,'interviews/onsite_interview_list.html',{'onsite_interviews':onsite_interviews})

View File

@ -255,6 +255,16 @@
</span> </span>
</a> </a>
</li> </li>
<li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'interview_list' %}active{% endif %}" href="{% url 'interview_list' %}">
<span class="d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
{% trans "Onsite Interviews" %}
</span>
</a>
</li>
<li class="nav-item me-lg-4"> <li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'participants_list' %}active{% endif %}" href="{% url 'participants_list' %}"> <a class="nav-link {% if request.resolver_match.url_name == 'participants_list' %}active{% endif %}" href="{% url 'participants_list' %}">
<span class="d-flex align-items-center gap-2"> <span class="d-flex align-items-center gap-2">

View File

@ -0,0 +1,432 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{% trans "Zoom Meetings" %} - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
/* UI Variables for the KAAT-S Theme (Consistent with Reference) */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-gray-light: #f8f9fa;
}
/* Enhanced Card Styling (Consistent) */
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
transition: transform 0.2s, box-shadow 0.2s;
background-color: white;
}
.card:not(.no-hover):hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
}
.card.no-hover:hover {
transform: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
/* Main Action Button Style (Teal Theme) */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
}
.btn-main-action:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Secondary Button Style (For Edit/Outline - Consistent) */
.btn-outline-secondary {
color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal);
}
.btn-outline-secondary:hover {
background-color: var(--kaauh-teal-dark);
color: white;
border-color: var(--kaauh-teal-dark);
}
/* Primary Outline for View/Join */
.btn-outline-primary {
color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
}
.btn-outline-primary:hover {
background-color: var(--kaauh-teal);
color: white;
}
/* Meeting Card Specifics (Adapted to Standard Card View) */
.meeting-card .card-title {
color: var(--kaauh-teal-dark);
font-weight: 600;
font-size: 1.15rem;
}
.meeting-card .card-text i {
color: var(--kaauh-teal);
width: 1.25rem;
}
/* Primary Color Overrides */
.text-primary-theme { color: var(--kaauh-teal) !important; }
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
.text-success { color: var(--kaauh-success) !important; }
.text-danger { color: var(--kaauh-danger) !important; }
.text-info { color: #17a2b8 !important; }
/* Status Badges (Standardized) */
.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;
}
/* Status Badge Mapping */
.bg-waiting { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;}
.bg-scheduled { 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;}
/* Table Styling (Consistent with Reference) */
.table-view .table thead th {
background-color: var(--kaauh-teal-dark);
color: white;
font-weight: 600;
border-color: var(--kaauh-border);
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.5px;
padding: 1rem;
}
.table-view .table tbody td {
vertical-align: middle;
padding: 1rem;
border-color: var(--kaauh-border);
}
.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);
border-color: var(--kaauh-border);
}
.pagination .page-item.active .page-link {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
}
.pagination .page-item:hover .page-link:not(.active) {
background-color: #e9ecef;
}
/* Filter & Search Layout Adjustments */
.filter-buttons {
display: flex;
gap: 0.5rem;
}
/* Icon color for empty state */
.text-muted.fa-3x {
color: var(--kaauh-teal-dark) !important;
}
@keyframes svg-pulse {
0% {
transform: scale(0.9);
opacity: 0.8;
}
50% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(0.9);
opacity: 0.8;
}
}
/* Apply the animation to the custom class */
.svg-pulse {
animation: svg-pulse 2s infinite ease-in-out;
transform-origin: center; /* Ensure scaling is centered */
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-video me-2"></i> {% trans "Zoom Meetings" %}
</h1>
<a href="{% url 'create_meeting' %}" class="btn btn-main-action">
<i class="fas fa-plus me-1"></i> {% trans "Create Meeting" %}
</a>
</div>
<div class="card mb-4 shadow-sm no-hover">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<label for="search" class="form-label small text-muted">{% trans "Search by Topic" %}</label>
<div class="input-group input-group-lg mb-3">
<form method="get" action="" class="w-100">
{% include "includes/search_form.html" with search_query=search_query %}
</form>
</div>
</div>
<div class="col-md-6">
<form method="GET" class="row g-3 align-items-end" >
{% if search_query %}<input class="form-control form-control-sm" type="hidden" name="q" value="{{ search_query }}">{% endif %}
{% if status_filter %}<input class="form-control form-control-sm" type="hidden" name="status" value="{{ status_filter }}">{% endif %}
<div class="col-md-4">
<label for="status" class="form-label small text-muted">{% trans "Filter by Status" %}</label>
<select name="status" id="status" class="form-select form-select-sm">
<option value="">{% trans "All Statuses" %}</option>
<option value="waiting" {% if status_filter == 'waiting' %}selected{% endif %}>{% trans "Waiting" %}</option>
<option value="started" {% if status_filter == 'started' %}selected{% endif %}>{% trans "Started" %}</option>
<option value="ended" {% if status_filter == 'ended' %}selected{% endif %}>{% trans "Ended" %}</option>
</select>
</div>
<div class="col-md-4">
<label for="candidate_name" class="form-label small text-muted">{% trans "Candidate Name" %}</label>
<input type="text" class="form-control form-control-sm" id="candidate_name" name="candidate_name" placeholder="{% trans 'Search by candidate...' %}" value="{{ candidate_name_filter }}">
</div>
<div class="col-md-5">
<div class="filter-buttons">
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-filter me-1"></i> {% trans "Apply Filters" %}
</button>
{% if status_filter or search_query or candidate_name_filter %}
<a href="{% url 'list_meetings' %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
</a>
{% endif %}
</div>
</div>
</form>
</div>
</div>
</div>
</div>
{% if meetings %}
<div id="meetings-list">
{# View Switcher #}
{% include "includes/_list_view_switcher.html" with list_id="meetings-list" %}
{# Card View #}
<div class="card-view active row">
{% for meeting in meetings %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="card meeting-card h-100 shadow-sm">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title flex-grow-1 me-3"><a href="{% url 'meeting_details' meeting.slug %}" class="text-decoration-none text-primary-theme">{{ meeting.topic }}</a></h5>
<span class="status-badge bg-{{ meeting.status }}">
{{ meeting.status|title }}
</span>
</div>
<p class="card-text text-muted small mb-3">
<i class="fas fa-user"></i> {% trans "Candidate" %}: {% if meeting.interview %}{{ meeting.interview.candidate.name }}{% else %}
<button data-bs-toggle="modal"
data-bs-target="#meetingModal"
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
hx-target="#meetingModalBody"
hx-swap="outerHTML"
class="btn text-primary-theme btn-link btn-sm">Set Candidate</button>
{% endif %}<br>
<i class="fas fa-briefcase"></i> {% trans "Job" %}: {% if meeting.interview %}{{ meeting.interview.job.title }}{% else %}
<button data-bs-toggle="modal"
data-bs-target="#meetingModal"
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
hx-target="#meetingModalBody"
hx-swap="outerHTML"
class="btn text-primary-theme btn-link btn-sm">Set Job</button>
{% endif %}<br>
<i class="fas fa-hashtag"></i> {% trans "ID" %}: {{ meeting.meeting_id|default:meeting.id }}<br>
<i class="fas fa-clock"></i> {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }}<br>
<i class="fas fa-stopwatch"></i> {% trans "Duration" %}: {{ meeting.duration }} minutes{% if meeting.password %}<br><i class="fas fa-lock"></i> {% trans "Password" %}: Yes{% endif %}
</p>
<div class="mt-auto pt-2 border-top">
<div class="d-flex gap-2">
<a href="{% url 'meeting_details' meeting.slug %}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i> {% trans "View" %}
</a>
{% if meeting.join_url %}
<a href="{{ meeting.join_url }}" target="_blank" class="btn btn-sm btn-main-action">
<i class="fas fa-link"></i> {% trans "Join" %}
</a>
{% endif %}
<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"
hx-post="{% url 'delete_meeting' meeting.slug %}"
hx-target="#deleteModalBody"
hx-swap="outerHTML"
data-item-name="{{ meeting.topic }}">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{# Table View #}
<div class="table-view">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th scope="col">{% trans "Topic" %}</th>
<th scope="col">{% trans "Candidate" %}</th>
<th scope="col">{% trans "Job" %}</th>
<th scope="col">{% trans "ID" %}</th>
<th scope="col">{% trans "Start Time" %}</th>
<th scope="col">{% trans "Duration" %}</th>
<th scope="col">{% trans "Status" %}</th>
<th scope="col" class="text-end">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for meeting in meetings %}
<tr>
<td><strong class="text-primary"><a href="{% url 'meeting_details' meeting.slug %}" class="text-decoration-none text-secondary">{{ meeting.topic }}<a></strong></td>
<td>
{% if meeting.interview %}
<a class="text-primary text-decoration-none" href="{% url 'candidate_detail' meeting.interview.candidate.slug %}">{{ meeting.interview.candidate.name }} <i class="fas fa-link"></i></a>
{% else %}
<button data-bs-toggle="modal"
data-bs-target="#meetingModal"
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
hx-target="#meetingModalBody"
hx-swap="outerHTML"
class="btn btn-outline-primary btn-sm">Set Candidate</button>
{% endif %}
</td>
<td>
{% if meeting.interview %}
<a class="text-primary text-decoration-none" href="{% url 'job_detail' meeting.interview.job.slug %}">{{ meeting.interview.job.title }} <i class="fas fa-link"></i></a>
{% else %}
<button data-bs-toggle="modal"
data-bs-target="#meetingModal"
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
hx-target="#meetingModalBody"
hx-swap="outerHTML"
class="btn btn-outline-primary btn-sm">Set Job</button>
{% endif %}
</td>
<td>{{ meeting.meeting_id|default:meeting.id }}</td>
<td>{{ meeting.start_time|date:"M d, Y H:i" }}</td>
<td>{{ meeting.duration }} min</td>
<td>
{% if meeting %}
<span class="badge {% if meeting.status == 'waiting' %}bg-warning{% elif meeting.status == 'started' %}bg-success{% elif meeting.status == 'ended' %}bg-danger{% endif %}">
{% if meeting.status == 'started' %}
<i class="fas fa-circle me-1 text-success"></i>
{% endif %}
{{ meeting.status|title }}
</span>
{% else %}
<span class="text-muted">--</span>
{% endif %}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
{% if meeting.join_url %}
<a href="{{ meeting.join_url }}" target="_blank" class="btn btn-main-action" title="{% trans 'Join' %}">
<i class="fas fa-sign-in-alt"></i>
</a>
{% endif %}
<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.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="#meetingModal"
hx-post="{% url 'delete_meeting' meeting.slug %}"
hx-target="#meetingModalBody"
hx-swap="outerHTML"
data-item-name="{{ meeting.topic }}">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# Pagination (Standardized) #}
{% if is_paginated %}
<nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">First</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">Previous</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">Next</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">Last</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5 card shadow-sm">
<div class="card-body">
<i class="fas fa-video fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
<h3>{% trans "No Zoom meetings found" %}</h3>
<p class="text-muted">{% trans "Create your first meeting or adjust your filters." %}</p>
<a href="{% url 'create_meeting' %}" class="btn btn-main-action mt-3">
<i class="fas fa-plus me-1"></i> {% trans "Create Your First Meeting" %}
</a>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -119,6 +119,7 @@
{% if not forloop.last %}, {% endif %} {% if not forloop.last %}, {% endif %}
{% endfor %} {% endfor %}
</p> </p>
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Type:</strong> {{interview_type}}</p>
</div> </div>
</div> </div>

View File

@ -0,0 +1,34 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="card shadow-lg border-0 rounded-lg mt-5">
<div class="card-header bg-main-action text-white">
<h3 class="text-center font-weight-light my-4">Set Interview Location</h3>
</div>
<div class="card-body">
<form method="post" action="{% url 'schedule_interview_location_form' schedule.slug %}" enctype="multipart/form-data">
{% csrf_token %}
{# Renders the single 'location' field using the crispy filter #}
{{ form|crispy }}
<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">
<i class="fas fa-times me-1"></i> Close
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i> Save Location
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock content %}

View File

@ -139,7 +139,17 @@
<div class="col-md-8"> <div class="col-md-8">
<h5 class="section-header">{% trans "Schedule Details" %}</h5> <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.start_date.id_for_label }}">{% trans "Interview Type" %}</label>
{{ form.interview_type }}
</div>
</div>
</div>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group mb-3"> <div class="form-group mb-3">