refactore interview

This commit is contained in:
ismail 2025-11-27 15:47:03 +03:00
parent 01fd4d4495
commit 1c0e0b0825
32 changed files with 5081 additions and 3175 deletions

View File

@ -354,7 +354,7 @@ class ScheduledInterview(Base):
candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE, related_name="scheduled_interviews")
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="scheduled_interviews")
zoom_meeting = models.OneToOneField(ZoomMeeting, on_delete=models.CASCADE, related_name="interview")
schedule = models.ForeignKey(InterviewSchedule, on_delete=models.CASCADE, related_name="interviews", null=True, blank=True)
schedule = models.ForeignKey(BulkInterviewTemplate, on_delete=models.CASCADE, related_name="interviews", null=True, blank=True)
interview_date = models.DateField()
interview_time = models.TimeField()
status = models.CharField(max_length=20, choices=[
@ -365,9 +365,9 @@ class ScheduledInterview(Base):
], default="scheduled")
```
#### 2.2.11 InterviewSchedule Model
#### 2.2.11 BulkInterviewTemplate Model
```python
class InterviewSchedule(Base):
class BulkInterviewTemplate(Base):
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="interview_schedules")
candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True, null=True)
start_date = models.DateField()
@ -533,7 +533,7 @@ class CandidateService:
### 5.2 Interview Scheduling Logic
```python
class InterviewScheduler:
class BulkInterviewTemplater:
@staticmethod
def get_available_slots(schedule, date):
"""Get available interview slots for a specific date"""
@ -915,7 +915,7 @@ class InterviewSchedulingTestCase(TestCase):
phone="9876543210",
job=self.job
)
self.schedule = InterviewSchedule.objects.create(
self.schedule = BulkInterviewTemplate.objects.create(
job=self.job,
start_date=timezone.now().date(),
end_date=timezone.now().date() + timedelta(days=7),
@ -930,7 +930,7 @@ class InterviewSchedulingTestCase(TestCase):
def test_interview_scheduling(self):
"""Test interview scheduling process"""
# Test slot availability
available_slots = InterviewScheduler.get_available_slots(
available_slots = BulkInterviewTemplater.get_available_slots(
self.schedule,
timezone.now().date()
)
@ -942,7 +942,7 @@ class InterviewSchedulingTestCase(TestCase):
'start_time': timezone.now().time(),
'duration': 60
}
interview = InterviewScheduler.schedule_interview(
interview = BulkInterviewTemplater.schedule_interview(
self.candidate,
self.job,
schedule_data

View File

@ -86,7 +86,7 @@ The test suite aims for 80% code coverage. Coverage reports are generated in:
- **Candidate**: Stage transitions, relationships
- **ZoomMeeting**: Time validation, status handling
- **FormTemplate**: Template integrity, field ordering
- **InterviewSchedule**: Scheduling logic, slot generation
- **BulkInterviewTemplate**: Scheduling logic, slot generation
### 2. View Testing
- **Job Management**: CRUD operations, search, filtering
@ -97,7 +97,7 @@ The test suite aims for 80% code coverage. Coverage reports are generated in:
### 3. Form Testing
- **JobPostingForm**: Complex validation, field dependencies
- **CandidateForm**: File upload, validation
- **InterviewScheduleForm**: Dynamic fields, validation
- **BulkInterviewTemplateForm**: Dynamic fields, validation
- **MeetingCommentForm**: Comment creation/editing
### 4. Integration Testing

View File

@ -28,13 +28,13 @@ from datetime import datetime, time, timedelta, date
from recruitment.models import (
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField,
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
FormSubmission, FieldResponse, BulkInterviewTemplate, ScheduledInterview,
TrainingMaterial, Source, HiringAgency, Profile, MeetingComment, JobPostingImage,
BreakTime
)
from recruitment.forms import (
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
CandidateStageForm, InterviewScheduleForm, BreakTimeFormSet
CandidateStageForm, BulkInterviewTemplateForm, BreakTimeFormSet
)
@ -185,7 +185,7 @@ def interview_schedule(staff_user, job):
)
candidates.append(candidate)
return InterviewSchedule.objects.create(
return BulkInterviewTemplate.objects.create(
job=job,
created_by=staff_user,
start_date=date.today() + timedelta(days=1),

View File

@ -3,10 +3,10 @@ from django.utils.html import format_html
from django.urls import reverse
from django.utils import timezone
from .models import (
JobPosting, Application, TrainingMaterial, ZoomMeetingDetails,
JobPosting, Application, TrainingMaterial,
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,JobPostingImage,InterviewNote,
AgencyAccessLink, AgencyJobAssignment
SharedFormTemplate, Source, HiringAgency, IntegrationLog,BulkInterviewTemplate,JobPostingImage,InterviewNote,
AgencyAccessLink, AgencyJobAssignment,Interview,ScheduledInterview
)
from django.contrib.auth import get_user_model
@ -158,27 +158,27 @@ class TrainingMaterialAdmin(admin.ModelAdmin):
save_on_top = True
@admin.register(ZoomMeetingDetails)
class ZoomMeetingAdmin(admin.ModelAdmin):
list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
list_filter = ['timezone', 'created_at']
search_fields = ['topic', 'meeting_id']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Meeting Details', {
'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone','status')
}),
('Meeting Settings', {
'fields': ('participant_video', 'join_before_host', 'mute_upon_entry', 'waiting_room')
}),
('Access', {
'fields': ('join_url',)
}),
('System Response', {
'fields': ('zoom_gateway_response', 'created_at', 'updated_at')
}),
)
save_on_top = True
# @admin.register(ZoomMeetingDetails)
# class ZoomMeetingAdmin(admin.ModelAdmin):
# list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
# list_filter = ['timezone', 'created_at']
# search_fields = ['topic', 'meeting_id']
# readonly_fields = ['created_at', 'updated_at']
# fieldsets = (
# ('Meeting Details', {
# 'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone','status')
# }),
# ('Meeting Settings', {
# 'fields': ('participant_video', 'join_before_host', 'mute_upon_entry', 'waiting_room')
# }),
# ('Access', {
# 'fields': ('join_url',)
# }),
# ('System Response', {
# 'fields': ('zoom_gateway_response', 'created_at', 'updated_at')
# }),
# )
# save_on_top = True
# @admin.register(InterviewNote)
@ -241,9 +241,11 @@ admin.site.register(FormStage)
admin.site.register(Application)
admin.site.register(FormField)
admin.site.register(FieldResponse)
admin.site.register(InterviewSchedule)
admin.site.register(BulkInterviewTemplate)
admin.site.register(AgencyAccessLink)
admin.site.register(AgencyJobAssignment)
admin.site.register(Interview)
admin.site.register(ScheduledInterview)
# AgencyMessage admin removed - model has been deleted

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
# Generated by Django 5.2.7 on 2025-11-17 09:52
# Generated by Django 5.2.6 on 2025-11-26 11:13
import django.contrib.auth.models
import django.contrib.auth.validators
@ -49,16 +49,29 @@ class Migration(migrations.Migration):
},
),
migrations.CreateModel(
name='InterviewLocation',
name='Interview',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('location_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], db_index=True, max_length=10, verbose_name='Location Type')),
('topic', models.CharField(blank=True, help_text="e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room'", max_length=255, verbose_name='Meeting/Location Topic')),
('details_url', models.URLField(blank=True, max_length=2048, null=True, verbose_name='Meeting/Location URL')),
('topic', models.CharField(blank=True, help_text="e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'", max_length=255, verbose_name='Location/Meeting Topic')),
('timezone', models.CharField(default='UTC', max_length=50, verbose_name='Timezone')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
('meeting_id', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='External Meeting ID')),
('password', models.CharField(blank=True, max_length=20, null=True)),
('zoom_gateway_response', models.JSONField(blank=True, null=True)),
('participant_video', models.BooleanField(default=True)),
('join_before_host', models.BooleanField(default=False)),
('host_email', models.CharField(blank=True, max_length=255, null=True)),
('mute_upon_entry', models.BooleanField(default=False)),
('waiting_room', models.BooleanField(default=False)),
('physical_address', models.CharField(blank=True, max_length=255, null=True)),
('room_number', models.CharField(blank=True, max_length=50, null=True)),
],
options={
'verbose_name': 'Interview Location',
@ -121,7 +134,6 @@ class Migration(migrations.Migration):
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
@ -129,6 +141,7 @@ class Migration(migrations.Migration):
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')),
('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')),
('email', models.EmailField(error_messages={'unique': 'A user with this email already exists.'}, max_length=254, unique=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
@ -266,42 +279,22 @@ class Migration(migrations.Migration):
},
),
migrations.CreateModel(
name='OnsiteLocationDetails',
name='InterviewNote',
fields=[
('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')),
('physical_address', models.CharField(blank=True, max_length=255, null=True, verbose_name='Physical Address')),
('room_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='Room Number/Name')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')),
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content/Feedback')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_notes', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('interview', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.interview', verbose_name='Scheduled Interview')),
],
options={
'verbose_name': 'Onsite Location Details',
'verbose_name_plural': 'Onsite Location Details',
'verbose_name': 'Interview Note',
'verbose_name_plural': 'Interview Notes',
'ordering': ['created_at'],
},
bases=('recruitment.interviewlocation',),
),
migrations.CreateModel(
name='ZoomMeetingDetails',
fields=[
('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')),
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
('meeting_id', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='External Meeting ID')),
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
('host_email', models.CharField(blank=True, null=True)),
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
],
options={
'verbose_name': 'Zoom Meeting Details',
'verbose_name_plural': 'Zoom Meeting Details',
},
bases=('recruitment.interviewlocation',),
),
migrations.CreateModel(
name='JobPosting',
@ -312,8 +305,8 @@ class Migration(migrations.Migration):
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('title', models.CharField(max_length=200)),
('department', models.CharField(blank=True, max_length=100)),
('job_type', models.CharField(choices=[('Full-time', 'Full-time'), ('Part-time', 'Part-time'), ('Contract', 'Contract'), ('Internship', 'Internship'), ('Faculty', 'Faculty'), ('Temporary', 'Temporary')], default='FULL_TIME', max_length=20)),
('workplace_type', models.CharField(choices=[('On-site', 'On-site'), ('Remote', 'Remote'), ('Hybrid', 'Hybrid')], default='ON_SITE', max_length=20)),
('job_type', models.CharField(choices=[('Full-time', 'Full-time'), ('Part-time', 'Part-time'), ('Contract', 'Contract'), ('Internship', 'Internship'), ('Faculty', 'Faculty'), ('Temporary', 'Temporary')], default='Full-time', max_length=20)),
('workplace_type', models.CharField(choices=[('On-site', 'On-site'), ('Remote', 'Remote'), ('Hybrid', 'Hybrid')], default='On-site', max_length=20)),
('location_city', models.CharField(blank=True, max_length=100)),
('location_state', models.CharField(blank=True, max_length=100)),
('location_country', models.CharField(default='Saudia Arabia', max_length=100)),
@ -343,6 +336,8 @@ class Migration(migrations.Migration):
('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
('cancelled_at', models.DateTimeField(blank=True, null=True)),
('ai_parsed', models.BooleanField(default=False, help_text='Whether the job posting has been parsed by AI', verbose_name='AI Parsed')),
('cv_zip_file', models.FileField(blank=True, null=True, upload_to='job_zips/')),
('zip_created', models.BooleanField(default=False)),
('assigned_to', models.ForeignKey(blank=True, help_text='The user who has been assigned to this job', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_jobs', to=settings.AUTH_USER_MODEL, verbose_name='Assigned To')),
('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')),
@ -353,14 +348,18 @@ class Migration(migrations.Migration):
'ordering': ['-created_at'],
},
),
migrations.AddField(
model_name='formtemplate',
name='job',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'),
),
migrations.CreateModel(
name='InterviewSchedule',
name='BulkInterviewTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('schedule_interview_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], default='Remote', max_length=10, verbose_name='Interview Type')),
('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
('end_date', models.DateField(db_index=True, verbose_name='End Date')),
('working_days', models.JSONField(verbose_name='Working Days')),
@ -372,18 +371,13 @@ class Migration(migrations.Migration):
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
('applications', models.ManyToManyField(blank=True, related_name='interview_schedules', to='recruitment.application')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('template_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schedule_templates', to='recruitment.interviewlocation', verbose_name='Location Template (Zoom/Onsite)')),
('interview', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schedule_templates', to='recruitment.interview', verbose_name='Location Template (Zoom/Onsite)')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='formtemplate',
name='job',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'),
),
migrations.AddField(
model_name='application',
name='job',
@ -474,7 +468,7 @@ class Migration(migrations.Migration):
('first_name', models.CharField(max_length=255, verbose_name='First Name')),
('last_name', models.CharField(max_length=255, verbose_name='Last Name')),
('middle_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Middle Name')),
('email', models.EmailField(db_index=True, help_text='Unique email address for the person', max_length=254, unique=True, verbose_name='Email')),
('email', models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='Email')),
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')),
('gender', models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female')], max_length=1, null=True, verbose_name='Gender')),
@ -507,31 +501,13 @@ class Migration(migrations.Migration):
('interview_time', models.TimeField(verbose_name='Interview Time')),
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')),
('interview_location', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='scheduled_interview', to='recruitment.interviewlocation', verbose_name='Meeting/Location Details')),
('interview', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interview', to='recruitment.interview', verbose_name='Meeting/Location Details')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
('participants', models.ManyToManyField(blank=True, to='recruitment.participants')),
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interviews', to='recruitment.interviewschedule')),
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interviews', to='recruitment.bulkinterviewtemplate')),
('system_users', models.ManyToManyField(blank=True, related_name='attended_interviews', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='InterviewNote',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')),
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content/Feedback')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_notes', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('interview', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.scheduledinterview', verbose_name='Scheduled Interview')),
],
options={
'verbose_name': 'Interview Note',
'verbose_name_plural': 'Interview Notes',
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='SharedFormTemplate',
fields=[
@ -656,11 +632,6 @@ class Migration(migrations.Migration):
model_name='formsubmission',
index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'),
),
migrations.AddField(
model_name='notification',
name='related_meeting',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.zoommeetingdetails', verbose_name='Related Meeting'),
),
migrations.AddIndex(
model_name='formtemplate',
index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'),
@ -705,6 +676,14 @@ class Migration(migrations.Migration):
model_name='message',
index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'),
),
migrations.AddIndex(
model_name='notification',
index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'),
),
migrations.AddIndex(
model_name='notification',
index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'),
),
migrations.AddIndex(
model_name='person',
index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'),
@ -757,12 +736,4 @@ class Migration(migrations.Migration):
model_name='jobposting',
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
),
migrations.AddIndex(
model_name='notification',
index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'),
),
migrations.AddIndex(
model_name='notification',
index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'),
),
]

View File

@ -1,29 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-18 10:24
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='jobposting',
name='job_type',
field=models.CharField(choices=[('Full-time', 'Full-time'), ('Part-time', 'Part-time'), ('Contract', 'Contract'), ('Internship', 'Internship'), ('Faculty', 'Faculty'), ('Temporary', 'Temporary')], default='Full-time', max_length=20),
),
migrations.AlterField(
model_name='jobposting',
name='workplace_type',
field=models.CharField(choices=[('On-site', 'On-site'), ('Remote', 'Remote'), ('Hybrid', 'Hybrid')], default='On-site', max_length=20),
),
migrations.AlterField(
model_name='scheduledinterview',
name='interview_location',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interview', to='recruitment.interviewlocation', verbose_name='Meeting/Location Details'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-11-26 12:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='scheduledinterview',
name='interview_type',
field=models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], default='Remote', max_length=20),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-19 14:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0002_alter_jobposting_job_type_and_more'),
]
operations = [
migrations.AddField(
model_name='jobposting',
name='cv_zip_file',
field=models.FileField(blank=True, null=True, upload_to='job_zips/'),
),
migrations.AddField(
model_name='jobposting',
name='zip_created',
field=models.BooleanField(default=False),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-23 09:22
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0003_jobposting_cv_zip_file_jobposting_zip_created'),
]
operations = [
migrations.AlterField(
model_name='interviewschedule',
name='template_location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='schedule_templates', to='recruitment.interviewlocation', verbose_name='Location Template (Zoom/Onsite)'),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-23 09:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0004_alter_interviewschedule_template_location'),
]
operations = [
migrations.AlterField(
model_name='interviewschedule',
name='template_location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schedule_templates', to='recruitment.interviewlocation', verbose_name='Location Template (Zoom/Onsite)'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-23 12:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0005_alter_interviewschedule_template_location'),
]
operations = [
migrations.AlterField(
model_name='customuser',
name='email',
field=models.EmailField(error_messages={'unique': 'A user with this email already exists.'}, max_length=254, unique=True),
),
]

View File

@ -995,36 +995,36 @@ class Application(Base):
"""Legacy compatibility - get scheduled interviews for this application"""
return self.scheduled_interviews.all()
@property
def get_latest_meeting(self):
"""
Retrieves the most specific location details (subclass instance)
of the latest ScheduledInterview for this application, or None.
"""
# 1. Get the latest ScheduledInterview
schedule = self.scheduled_interviews.order_by("-created_at").first()
# @property
# def get_latest_meeting(self):
# """
# Retrieves the most specific location details (subclass instance)
# of the latest ScheduledInterview for this application, or None.
# """
# # 1. Get the latest ScheduledInterview
# schedule = self.scheduled_interviews.order_by("-created_at").first()
# Check if a schedule exists and if it has an interview location
if not schedule or not schedule.interview_location:
return None
# # Check if a schedule exists and if it has an interview location
# if not schedule or not schedule.interview_location:
# return None
# Get the base location instance
interview_location = schedule.interview_location
# # Get the base location instance
# interview_location = schedule.interview_location
# 2. Safely retrieve the specific subclass details
# # 2. Safely retrieve the specific subclass details
# Determine the expected subclass accessor name based on the location_type
if interview_location.location_type == 'Remote':
accessor_name = 'zoommeetingdetails'
else: # Assumes 'Onsite' or any other type defaults to Onsite
accessor_name = 'onsitelocationdetails'
# # Determine the expected subclass accessor name based on the location_type
# if interview_location.location_type == 'Remote':
# accessor_name = 'zoommeetingdetails'
# else: # Assumes 'Onsite' or any other type defaults to Onsite
# accessor_name = 'onsitelocationdetails'
# Use getattr to safely retrieve the specific meeting object (subclass instance).
# If the accessor exists but points to None (because the subclass record was deleted),
# or if the accessor name is wrong for the object's true type, it will return None.
meeting_details = getattr(interview_location, accessor_name, None)
# # Use getattr to safely retrieve the specific meeting object (subclass instance).
# # If the accessor exists but points to None (because the subclass record was deleted),
# # or if the accessor name is wrong for the object's true type, it will return None.
# meeting_details = getattr(interview_location, accessor_name, None)
return meeting_details
# return meeting_details
@property
@ -1094,9 +1094,6 @@ class Application(Base):
class TrainingMaterial(Base):
title = models.CharField(max_length=255, verbose_name=_("Title"))
content = CKEditor5Field(
@ -1118,17 +1115,155 @@ class TrainingMaterial(Base):
return self.title
class InterviewLocation(Base):
"""
Base model for all interview location/meeting details (remote or onsite)
using Multi-Table Inheritance.
"""
# class InterviewLocation(Base):
# """
# Base model for all interview location/meeting details (remote or onsite)
# using Multi-Table Inheritance.
# """
# class LocationType(models.TextChoices):
# REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)')
# ONSITE = 'Onsite', _('In-Person (Physical Location)')
# class Status(models.TextChoices):
# """Defines the possible real-time statuses for any interview location/meeting."""
# WAITING = "waiting", _("Waiting")
# STARTED = "started", _("Started")
# ENDED = "ended", _("Ended")
# CANCELLED = "cancelled", _("Cancelled")
# location_type = models.CharField(
# max_length=10,
# choices=LocationType.choices,
# verbose_name=_("Location Type"),
# db_index=True
# )
# details_url = models.URLField(
# verbose_name=_("Meeting/Location URL"),
# max_length=2048,
# blank=True,
# null=True
# )
# topic = models.CharField( # Renamed from 'description' to 'topic' to match your input
# max_length=255,
# verbose_name=_("Location/Meeting Topic"),
# blank=True,
# help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'")
# )
# timezone = models.CharField(
# max_length=50,
# verbose_name=_("Timezone"),
# default='UTC'
# )
# def __str__(self):
# # Use 'topic' instead of 'description'
# return f"{self.get_location_type_display()} - {self.topic[:50]}"
# class Meta:
# verbose_name = _("Interview Location")
# verbose_name_plural = _("Interview Locations")
# class ZoomMeetingDetails(InterviewLocation):
# """Concrete model for remote interviews (Zoom specifics)."""
# status = models.CharField(
# db_index=True,
# max_length=20,
# choices=InterviewLocation.Status.choices,
# default=InterviewLocation.Status.WAITING,
# )
# start_time = models.DateTimeField(
# db_index=True, verbose_name=_("Start Time")
# )
# duration = models.PositiveIntegerField(
# verbose_name=_("Duration (minutes)")
# )
# meeting_id = models.CharField(
# db_index=True,
# max_length=50,
# unique=True,
# verbose_name=_("External Meeting ID")
# )
# password = models.CharField(
# max_length=20, blank=True, null=True, verbose_name=_("Password")
# )
# zoom_gateway_response = models.JSONField(
# blank=True, null=True, verbose_name=_("Zoom Gateway Response")
# )
# participant_video = models.BooleanField(
# default=True, verbose_name=_("Participant Video")
# )
# join_before_host = models.BooleanField(
# default=False, verbose_name=_("Join Before Host")
# )
# host_email=models.CharField(null=True,blank=True)
# mute_upon_entry = models.BooleanField(
# default=False, verbose_name=_("Mute Upon Entry")
# )
# waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room"))
# # *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
# # @classmethod
# # def create(cls, **kwargs):
# # """Factory method to ensure location_type is set to REMOTE."""
# # return cls(location_type=InterviewLocation.LocationType.REMOTE, **kwargs)
# class Meta:
# verbose_name = _("Zoom Meeting Details")
# verbose_name_plural = _("Zoom Meeting Details")
# class OnsiteLocationDetails(InterviewLocation):
# """Concrete model for onsite interviews (Room/Address specifics)."""
# physical_address = models.CharField(
# max_length=255,
# verbose_name=_("Physical Address"),
# blank=True,
# null=True
# )
# room_number = models.CharField(
# max_length=50,
# verbose_name=_("Room Number/Name"),
# blank=True,
# null=True
# )
# start_time = models.DateTimeField(
# db_index=True, verbose_name=_("Start Time")
# )
# duration = models.PositiveIntegerField(
# verbose_name=_("Duration (minutes)")
# )
# status = models.CharField(
# db_index=True,
# max_length=20,
# choices=InterviewLocation.Status.choices,
# default=InterviewLocation.Status.WAITING,
# )
# # *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
# # @classmethod
# # def create(cls, **kwargs):
# # """Factory method to ensure location_type is set to ONSITE."""
# # return cls(location_type=InterviewLocation.LocationType.ONSITE, **kwargs)
# class Meta:
# verbose_name = _("Onsite Location Details")
# verbose_name_plural = _("Onsite Location Details")
class Interview(Base):
class LocationType(models.TextChoices):
REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)')
ONSITE = 'Onsite', _('In-Person (Physical Location)')
class Status(models.TextChoices):
"""Defines the possible real-time statuses for any interview location/meeting."""
WAITING = "waiting", _("Waiting")
STARTED = "started", _("Started")
ENDED = "ended", _("Ended")
@ -1141,137 +1276,73 @@ class InterviewLocation(Base):
db_index=True
)
# Common fields
topic = models.CharField(
max_length=255,
verbose_name=_("Meeting/Location Topic"),
blank=True,
help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room'")
)
details_url = models.URLField(
verbose_name=_("Meeting/Location URL"),
max_length=2048,
blank=True,
null=True
)
topic = models.CharField( # Renamed from 'description' to 'topic' to match your input
max_length=255,
verbose_name=_("Location/Meeting Topic"),
blank=True,
help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'")
timezone = models.CharField(max_length=50, verbose_name=_("Timezone"), default='UTC')
start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time"))
duration = models.PositiveIntegerField(verbose_name=_("Duration (minutes)"))
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.WAITING,
db_index=True
)
timezone = models.CharField(
max_length=50,
verbose_name=_("Timezone"),
default='UTC'
# Remote-specific (nullable)
meeting_id = models.CharField(
max_length=50, unique=True, null=True, blank=True, verbose_name=_("External Meeting ID")
)
password = models.CharField(max_length=20, blank=True, null=True)
zoom_gateway_response = models.JSONField(blank=True, null=True)
participant_video = models.BooleanField(default=True)
join_before_host = models.BooleanField(default=False)
host_email = models.CharField(max_length=255, blank=True, null=True)
mute_upon_entry = models.BooleanField(default=False)
waiting_room = models.BooleanField(default=False)
# Onsite-specific (nullable)
physical_address = models.CharField(max_length=255, blank=True, null=True)
room_number = models.CharField(max_length=50, blank=True, null=True)
def __str__(self):
# Use 'topic' instead of 'description'
return f"{self.get_location_type_display()} - {self.topic[:50]}"
class Meta:
verbose_name = _("Interview Location")
verbose_name_plural = _("Interview Locations")
class ZoomMeetingDetails(InterviewLocation):
"""Concrete model for remote interviews (Zoom specifics)."""
status = models.CharField(
db_index=True,
max_length=20,
choices=InterviewLocation.Status.choices,
default=InterviewLocation.Status.WAITING,
)
start_time = models.DateTimeField(
db_index=True, verbose_name=_("Start Time")
)
duration = models.PositiveIntegerField(
verbose_name=_("Duration (minutes)")
)
meeting_id = models.CharField(
db_index=True,
max_length=50,
unique=True,
verbose_name=_("External Meeting ID")
)
password = models.CharField(
max_length=20, blank=True, null=True, verbose_name=_("Password")
)
zoom_gateway_response = models.JSONField(
blank=True, null=True, verbose_name=_("Zoom Gateway Response")
)
participant_video = models.BooleanField(
default=True, verbose_name=_("Participant Video")
)
join_before_host = models.BooleanField(
default=False, verbose_name=_("Join Before Host")
)
host_email=models.CharField(null=True,blank=True)
mute_upon_entry = models.BooleanField(
default=False, verbose_name=_("Mute Upon Entry")
)
waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room"))
# *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
# @classmethod
# def create(cls, **kwargs):
# """Factory method to ensure location_type is set to REMOTE."""
# return cls(location_type=InterviewLocation.LocationType.REMOTE, **kwargs)
class Meta:
verbose_name = _("Zoom Meeting Details")
verbose_name_plural = _("Zoom Meeting Details")
class OnsiteLocationDetails(InterviewLocation):
"""Concrete model for onsite interviews (Room/Address specifics)."""
physical_address = models.CharField(
max_length=255,
verbose_name=_("Physical Address"),
blank=True,
null=True
)
room_number = models.CharField(
max_length=50,
verbose_name=_("Room Number/Name"),
blank=True,
null=True
)
start_time = models.DateTimeField(
db_index=True, verbose_name=_("Start Time")
)
duration = models.PositiveIntegerField(
verbose_name=_("Duration (minutes)")
)
status = models.CharField(
db_index=True,
max_length=20,
choices=InterviewLocation.Status.choices,
default=InterviewLocation.Status.WAITING,
)
# *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
# @classmethod
# def create(cls, **kwargs):
# """Factory method to ensure location_type is set to ONSITE."""
# return cls(location_type=InterviewLocation.LocationType.ONSITE, **kwargs)
class Meta:
verbose_name = _("Onsite Location Details")
verbose_name_plural = _("Onsite Location Details")
def clean(self):
# Optional: add validation
if self.location_type == self.LocationType.REMOTE:
if not self.details_url:
raise ValidationError(_("Remote interviews require a meeting URL."))
if not self.meeting_id:
raise ValidationError(_("Meeting ID is required for remote interviews."))
elif self.location_type == self.LocationType.ONSITE:
if not (self.physical_address or self.room_number):
raise ValidationError(_("Onsite interviews require at least an address or room."))
# --- 2. Scheduling Models ---
class InterviewSchedule(Base):
class BulkInterviewTemplate(Base):
"""Stores the TEMPLATE criteria for BULK interview generation."""
# We need a field to store the template location details linked to this bulk schedule.
# This location object contains the generic Zoom/Onsite info to be cloned.
template_location = models.ForeignKey(
InterviewLocation,
interview = models.ForeignKey(
Interview,
on_delete=models.SET_NULL,
related_name="schedule_templates",
null=True,
@ -1279,15 +1350,6 @@ class InterviewSchedule(Base):
verbose_name=_("Location Template (Zoom/Onsite)")
)
# NOTE: schedule_interview_type field is needed in the form,
# but not on the model itself if we use template_location.
# If you want to keep it:
schedule_interview_type = models.CharField(
max_length=10,
choices=InterviewLocation.LocationType.choices,
verbose_name=_("Interview Type"),
default=InterviewLocation.LocationType.REMOTE
)
job = models.ForeignKey(
JobPosting,
@ -1332,6 +1394,9 @@ class InterviewSchedule(Base):
class ScheduledInterview(Base):
"""Stores individual scheduled interviews (whether bulk or individually created)."""
class InterviewTypeChoice(models.TextChoices):
REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)')
ONSITE = 'Onsite', _('In-Person (Physical Location)')
class InterviewStatus(models.TextChoices):
SCHEDULED = "scheduled", _("Scheduled")
@ -1353,19 +1418,19 @@ class ScheduledInterview(Base):
)
# Links to the specific, individual location/meeting details for THIS interview
interview_location = models.OneToOneField(
InterviewLocation,
interview = models.OneToOneField(
Interview,
on_delete=models.CASCADE,
related_name="scheduled_interview",
null=True,
blank=True,
db_index=True,
verbose_name=_("Meeting/Location Details")
verbose_name=_("Interview/Meeting")
)
# Link back to the bulk schedule template (optional if individually created)
schedule = models.ForeignKey(
InterviewSchedule,
BulkInterviewTemplate,
on_delete=models.SET_NULL,
related_name="interviews",
null=True,
@ -1378,7 +1443,11 @@ class ScheduledInterview(Base):
interview_date = models.DateField(db_index=True, verbose_name=_("Interview Date"))
interview_time = models.TimeField(verbose_name=_("Interview Time"))
interview_type = models.CharField(
max_length=20,
choices=InterviewTypeChoice.choices,
default=InterviewTypeChoice.REMOTE
)
status = models.CharField(
db_index=True,
max_length=20,
@ -1420,7 +1489,7 @@ class InterviewNote(Base):
1
interview = models.ForeignKey(
ScheduledInterview,
Interview,
on_delete=models.CASCADE,
related_name="notes",
verbose_name=_("Scheduled Interview"),
@ -2301,14 +2370,14 @@ class Notification(models.Model):
default=Status.PENDING,
verbose_name=_("Status"),
)
related_meeting = models.ForeignKey(
ZoomMeetingDetails,
on_delete=models.CASCADE,
related_name="notifications",
null=True,
blank=True,
verbose_name=_("Related Meeting"),
)
# related_meeting = models.ForeignKey(
# ZoomMeetingDetails,
# on_delete=models.CASCADE,
# related_name="notifications",
# null=True,
# blank=True,
# verbose_name=_("Related Meeting"),
# )
scheduled_for = models.DateTimeField(
verbose_name=_("Scheduled Send Time"),
help_text=_("The date and time this notification is scheduled to be sent."),

View File

@ -12,7 +12,7 @@ from . linkedin_service import LinkedInService
from django.shortcuts import get_object_or_404
from . models import JobPosting
from django.utils import timezone
from . models import InterviewSchedule,ScheduledInterview,ZoomMeetingDetails,Message
from . models import ScheduledInterview,Interview,Message
from django.contrib.auth import get_user_model
User = get_user_model()
# Add python-docx import for Word document processing
@ -679,20 +679,28 @@ def create_interview_and_meeting(
Synchronous task for a single interview slot, dispatched by django-q.
"""
try:
candidate = Application.objects.get(pk=candidate_id)
application = Application.objects.get(pk=candidate_id)
job = JobPosting.objects.get(pk=job_id)
schedule = InterviewSchedule.objects.get(pk=schedule_id)
schedule = ScheduledInterview.objects.get(pk=schedule_id)
interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time))
meeting_topic = f"Interview for {job.title} - {candidate.name}"
meeting_topic = f"Interview for {job.title} - {application.name}"
# 1. External API Call (Slow)
# "status": "success",
# "message": "Meeting created successfully.",
# "meeting_details": {
# "join_url": meeting_data['join_url'],
# "meeting_id": meeting_data['id'],
# "password": meeting_data['password'],
# "host_email": meeting_data['host_email']
# },
# "zoom_gateway_response": meeting_data
# }
result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
if result["status"] == "success":
# 2. Database Writes (Slow)
zoom_meeting = ZoomMeetingDetails.objects.create(
interview = Interview.objects.create(
topic=meeting_topic,
start_time=interview_datetime,
duration=duration,
@ -703,14 +711,31 @@ def create_interview_and_meeting(
password=result["meeting_details"]["password"],
location_type="Remote"
)
ScheduledInterview.objects.create(
application=candidate,
job=job,
interview_location=zoom_meeting,
schedule=schedule,
interview_date=slot_date,
interview_time=slot_time
)
schedule.interviews = interview
schedule.status = "Remote"
schedule.save()
# 2. Database Writes (Slow)
# zoom_meeting = ZoomMeetingDetails.objects.create(
# topic=meeting_topic,
# start_time=interview_datetime,
# duration=duration,
# meeting_id=result["meeting_details"]["meeting_id"],
# details_url=result["meeting_details"]["join_url"],
# zoom_gateway_response=result["zoom_gateway_response"],
# host_email=result["meeting_details"]["host_email"],
# password=result["meeting_details"]["password"],
# location_type="Remote"
# )
# ScheduledInterview.objects.create(
# application=candidate,
# job=job,
# interview_location=zoom_meeting,
# schedule=schedule,
# interview_date=slot_date,
# interview_time=slot_time
# )
# Log success or use Django-Q result system for monitoring
logger.info(f"Successfully scheduled interview for {Application.name}")
@ -745,7 +770,7 @@ def handle_zoom_webhook_event(payload):
try:
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
# and to simplify the logic flow.
meeting_instance = ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first()
meeting_instance = ''#TODO:update #ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first()
print(meeting_instance)
# --- 1. Creation and Update Events ---
if event_type == 'meeting.updated':

View File

@ -11,12 +11,12 @@ User = get_user_model()
from .models import (
JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
FormSubmission, FieldResponse, BulkInterviewTemplate, ScheduledInterview,
TrainingMaterial, Source, HiringAgency, MeetingComment
)
from .forms import (
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
CandidateStageForm, InterviewScheduleForm, CandidateSignupForm
CandidateStageForm, BulkInterviewTemplateForm, CandidateSignupForm
)
from .views import (
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, applications_screening_view,
@ -304,7 +304,7 @@ class FormTests(BaseTestCase):
self.assertTrue(form.is_valid())
def test_interview_schedule_form(self):
"""Test InterviewScheduleForm"""
"""Test BulkInterviewTemplateForm"""
# Update candidate to Interview stage first
self.candidate.stage = 'Interview'
self.candidate.save()
@ -315,7 +315,7 @@ class FormTests(BaseTestCase):
'end_date': (timezone.now() + timedelta(days=7)).date(),
'working_days': [0, 1, 2, 3, 4], # Monday to Friday
}
form = InterviewScheduleForm(slug=self.job.slug, data=form_data)
form = BulkInterviewTemplateForm(slug=self.job.slug, data=form_data)
self.assertTrue(form.is_valid())
def test_candidate_signup_form_valid(self):

View File

@ -24,13 +24,13 @@ from PIL import Image
from .models import (
JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
FormSubmission, FieldResponse, BulkInterviewTemplate, ScheduledInterview,
TrainingMaterial, Source, HiringAgency, MeetingComment, JobPostingImage,
BreakTime
)
from .forms import (
JobPostingForm, ApplicationForm, ZoomMeetingForm, MeetingCommentForm,
ApplicationStageForm, InterviewScheduleForm, BreakTimeFormSet
ApplicationStageForm, BulkInterviewTemplateForm, BreakTimeFormSet
)
from .views import (
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, applications_screening_view,
@ -228,7 +228,7 @@ class AdvancedModelTests(TestCase):
'break_end_time': '13:00'
}
form = InterviewScheduleForm(slug=self.job.slug, data=schedule_data)
form = BulkInterviewTemplateForm(slug=self.job.slug, data=schedule_data)
self.assertTrue(form.is_valid())
def test_field_response_data_types(self):
@ -625,7 +625,7 @@ class AdvancedFormTests(TestCase):
def test_form_dependency_validation(self):
"""Test validation for dependent form fields"""
# Test InterviewScheduleForm with dependent fields
# Test BulkInterviewTemplateForm with dependent fields
schedule_data = {
'candidates': [], # Empty for now
'start_date': '2025-01-15',
@ -637,7 +637,7 @@ class AdvancedFormTests(TestCase):
'buffer_time': '15'
}
form = InterviewScheduleForm(slug=self.job.slug, data=schedule_data)
form = BulkInterviewTemplateForm(slug=self.job.slug, data=schedule_data)
self.assertFalse(form.is_valid())
self.assertIn('end_date', form.errors)
@ -667,7 +667,7 @@ class AdvancedFormTests(TestCase):
def test_dynamic_form_fields(self):
"""Test forms with dynamically populated fields"""
# Test InterviewScheduleForm with dynamic candidate queryset
# Test BulkInterviewTemplateForm with dynamic candidate queryset
# Create applications in Interview stage
applications = []
for i in range(3):
@ -684,7 +684,7 @@ class AdvancedFormTests(TestCase):
applications.append(application)
# Form should only show Interview stage applications
form = InterviewScheduleForm(slug=self.job.slug)
form = BulkInterviewTemplateForm(slug=self.job.slug)
self.assertEqual(form.fields['candidates'].queryset.count(), 3)
for application in applications:

View File

@ -207,21 +207,21 @@ urlpatterns = [
),
path(
"jobs/<slug:slug>/<int:application_id>/reschedule_meeting_for_application/<int:meeting_id>/",
views.reschedule_meeting_for_application,
name="reschedule_meeting_for_application",
),
# path(
# "jobs/<slug:slug>/<int:application_id>/reschedule_meeting_for_application/<int:meeting_id>/",
# views.reschedule_meeting_for_application,
# name="reschedule_meeting_for_application",
# ),
path(
"jobs/<slug:slug>/update_application_exam_status/",
views.update_application_exam_status,
name="update_application_exam_status",
),
path(
"jobs/<slug:slug>/bulk_update_application_exam_status/",
views.bulk_update_application_exam_status,
name="bulk_update_application_exam_status",
),
# path(
# "jobs/<slug:slug>/bulk_update_application_exam_status/",
# views.bulk_update_application_exam_status,
# name="bulk_update_application_exam_status",
# ),
path(
"htmx/<int:pk>/application_criteria_view/",
views.application_criteria_view_htmx,
@ -266,16 +266,16 @@ urlpatterns = [
# path('api/templates/save/', views.save_form_template, name='save_form_template'),
# path('api/templates/<slug:template_slug>/', views.load_form_template, name='load_form_template'),
# path('api/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'),
path(
"jobs/<slug:slug>/calendar/",
views.interview_calendar_view,
name="interview_calendar",
),
path(
"jobs/<slug:slug>/calendar/interview/<int:interview_id>/",
views.interview_detail_view,
name="interview_detail",
),
# path(
# "jobs/<slug:slug>/calendar/",
# views.interview_calendar_view,
# name="interview_calendar",
# ),
# path(
# "jobs/<slug:slug>/calendar/interview/<int:interview_id>/",
# views.interview_detail_view,
# name="interview_detail",
# ),
# users urls
path("user/<int:pk>", views.user_detail, name="user_detail"),
@ -333,26 +333,26 @@ urlpatterns = [
name="copy_to_clipboard",
),
# Meeting Comments URLs
path(
"meetings/<slug:slug>/comments/add/",
views.add_meeting_comment,
name="add_meeting_comment",
),
path(
"meetings/<slug:slug>/comments/<int:comment_id>/edit/",
views.edit_meeting_comment,
name="edit_meeting_comment",
),
path(
"meetings/<slug:slug>/comments/<int:comment_id>/delete/",
views.delete_meeting_comment,
name="delete_meeting_comment",
),
path(
"meetings/<slug:slug>/set_meeting_application/",
views.set_meeting_application,
name="set_meeting_application",
),
# path(
# "meetings/<slug:slug>/comments/add/",
# views.add_meeting_comment,
# name="add_meeting_comment",
# ),
# path(
# "meetings/<slug:slug>/comments/<int:comment_id>/edit/",
# views.edit_meeting_comment,
# name="edit_meeting_comment",
# ),
# path(
# "meetings/<slug:slug>/comments/<int:comment_id>/delete/",
# views.delete_meeting_comment,
# name="delete_meeting_comment",
# ),
# path(
# "meetings/<slug:slug>/set_meeting_application/",
# views.set_meeting_application,
# name="set_meeting_application",
# ),
# Hiring Agency URLs
path("agencies/", views.agency_list, name="agency_list"),
path("agencies/create/", views.agency_create, name="agency_create"),
@ -510,31 +510,31 @@ urlpatterns = [
# path('notifications/mark-all-read/', views.notification_mark_all_read, name='notification_mark_all_read'),
# path('api/notification-count/', views.api_notification_count, name='api_notification_count'),
# participants urls
path(
"participants/",
views_frontend.ParticipantsListView.as_view(),
name="participants_list",
),
path(
"participants/create/",
views_frontend.ParticipantsCreateView.as_view(),
name="participants_create",
),
path(
"participants/<slug:slug>/",
views_frontend.ParticipantsDetailView.as_view(),
name="participants_detail",
),
path(
"participants/<slug:slug>/update/",
views_frontend.ParticipantsUpdateView.as_view(),
name="participants_update",
),
path(
"participants/<slug:slug>/delete/",
views_frontend.ParticipantsDeleteView.as_view(),
name="participants_delete",
),
# path(
# "participants/",
# views_frontend.ParticipantsListView.as_view(),
# name="participants_list",
# ),
# path(
# "participants/create/",
# views_frontend.ParticipantsCreateView.as_view(),
# name="participants_create",
# ),
# path(
# "participants/<slug:slug>/",
# views_frontend.ParticipantsDetailView.as_view(),
# name="participants_detail",
# ),
# path(
# "participants/<slug:slug>/update/",
# views_frontend.ParticipantsUpdateView.as_view(),
# name="participants_update",
# ),
# path(
# "participants/<slug:slug>/delete/",
# views_frontend.ParticipantsDeleteView.as_view(),
# name="participants_delete",
# ),
# Email composition URLs
path(
"jobs/<slug:job_slug>/applications/compose-email/",
@ -563,13 +563,23 @@ urlpatterns = [
path("application/documents/<int:document_id>/download/", views.document_download, name="application_document_download"),
path('jobs/<slug:job_slug>/applications/compose_email/', views.compose_application_email, name='compose_application_email'),
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/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
# path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
# Candidate Signup
path('application/signup/<slug:template_slug>/', views.application_signup, name='application_signup'),
# Password Reset
path('user/<int:pk>/password-reset/', views.portal_password_reset, name='portal_password_reset'),
# Interview URLs
path('interviews/', views.interview_list, name='interview_list'),
path('interviews/<slug:slug>/', views.interview_detail, name='interview_detail'),
# Interview Creation URLs
path('interviews/create/<slug:candidate_slug>/', views.interview_create_type_selection, name='interview_create_type_selection'),
path('interviews/create/<slug:candidate_slug>/remote/', views.interview_create_remote, name='interview_create_remote'),
path('interviews/create/<slug:candidate_slug>/onsite/', views.interview_create_onsite, name='interview_create_onsite'),
path('interviews/<slug:job_slug>/get_interview_list', views.get_interview_list, name='get_interview_list'),
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
# path('interview/list/', views.interview_list, name='interview_list'),
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'),
@ -577,64 +587,64 @@ urlpatterns = [
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
#interview and meeting related urls
path(
"jobs/<slug:slug>/schedule-interviews/",
views.schedule_interviews_view,
name="schedule_interviews",
),
path(
"jobs/<slug:slug>/confirm-schedule-interviews/",
views.confirm_schedule_interviews_view,
name="confirm_schedule_interviews_view",
),
# path(
# "jobs/<slug:slug>/schedule-interviews/",
# views.schedule_interviews_view,
# name="schedule_interviews",
# ),
# path(
# "jobs/<slug:slug>/confirm-schedule-interviews/",
# views.confirm_schedule_interviews_view,
# name="confirm_schedule_interviews_view",
# ),
path(
"meetings/create-meeting/",
views.ZoomMeetingCreateView.as_view(),
name="create_meeting",
),
# path(
# "meetings/create-meeting/",
# views.ZoomMeetingCreateView.as_view(),
# name="create_meeting",
# ),
# path(
# "meetings/meeting-details/<slug:slug>/",
# views.ZoomMeetingDetailsView.as_view(),
# name="meeting_details",
# ),
path(
"meetings/update-meeting/<slug:slug>/",
views.ZoomMeetingUpdateView.as_view(),
name="update_meeting",
),
path(
"meetings/delete-meeting/<slug:slug>/",
views.ZoomMeetingDeleteView,
name="delete_meeting",
),
# path(
# "meetings/update-meeting/<slug:slug>/",
# views.ZoomMeetingUpdateView.as_view(),
# name="update_meeting",
# ),
# path(
# "meetings/delete-meeting/<slug:slug>/",
# views.ZoomMeetingDeleteView,
# name="delete_meeting",
# ),
# Candidate Meeting Scheduling/Rescheduling URLs
path(
"jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
views.schedule_application_meeting,
name="schedule_application_meeting",
),
path(
"api/jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
views.api_schedule_application_meeting,
name="api_schedule_application_meeting",
),
path(
"jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
views.reschedule_application_meeting,
name="reschedule_application_meeting",
),
path(
"api/jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
views.api_reschedule_application_meeting,
name="api_reschedule_application_meeting",
),
# path(
# "jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
# views.schedule_application_meeting,
# name="schedule_application_meeting",
# ),
# path(
# "api/jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
# views.api_schedule_application_meeting,
# name="api_schedule_application_meeting",
# ),
# path(
# "jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
# views.reschedule_application_meeting,
# name="reschedule_application_meeting",
# ),
# path(
# "api/jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
# views.api_reschedule_application_meeting,
# name="api_reschedule_application_meeting",
# ),
# New URL for simple page-based meeting scheduling
path(
"jobs/<slug:slug>/applications/<int:application_pk>/schedule-meeting-page/",
views.schedule_meeting_for_application,
name="schedule_meeting_for_application",
),
# path(
# "jobs/<slug:slug>/applications/<int:application_pk>/schedule-meeting-page/",
# views.schedule_meeting_for_application,
# name="schedule_meeting_for_application",
# ),
# path(
# "jobs/<slug:slug>/applications/<int:application_pk>/delete_meeting_for_application/<int:meeting_id>/",
# views.delete_meeting_for_candidate,
@ -642,35 +652,35 @@ urlpatterns = [
# ),
path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
# path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
# 1. Onsite Reschedule URL
path(
'<slug:slug>/application/<int:application_id>/onsite/reschedule/<int:meeting_id>/',
views.reschedule_onsite_meeting,
name='reschedule_onsite_meeting'
),
# path(
# '<slug:slug>/application/<int:application_id>/onsite/reschedule/<int:meeting_id>/',
# views.reschedule_onsite_meeting,
# name='reschedule_onsite_meeting'
# ),
# 2. Onsite Delete URL
path(
'job/<slug:slug>/applications/<int:application_pk>/delete-onsite-meeting/<int:meeting_id>/',
views.delete_onsite_meeting_for_application,
name='delete_onsite_meeting_for_application'
),
# path(
# 'job/<slug:slug>/applications/<int:application_pk>/delete-onsite-meeting/<int:meeting_id>/',
# views.delete_onsite_meeting_for_application,
# name='delete_onsite_meeting_for_application'
# ),
path(
'job/<slug:slug>/application/<int:application_pk>/schedule/onsite/',
views.schedule_onsite_meeting_for_application,
name='schedule_onsite_meeting_for_application' # This is the name used in the button
),
# path(
# 'job/<slug:slug>/application/<int:application_pk>/schedule/onsite/',
# views.schedule_onsite_meeting_for_application,
# name='schedule_onsite_meeting_for_application' # This is the name used in the button
# ),
# Detail View (assuming slug is on ScheduledInterview)
path("interviews/meetings/<slug:slug>/", views.meeting_details, name="meeting_details"),
# path("interviews/meetings/<slug:slug>/", views.meeting_details, name="meeting_details"),
# Email invitation URLs
path("interviews/meetings/<slug:slug>/send-application-invitation/", views.send_application_invitation, name="send_application_invitation"),
path("interviews/meetings/<slug:slug>/send-participants-invitation/", views.send_participants_invitation, name="send_participants_invitation"),
# path("interviews/meetings/<slug:slug>/send-application-invitation/", views.send_application_invitation, name="send_application_invitation"),
# path("interviews/meetings/<slug:slug>/send-participants-invitation/", views.send_participants_invitation, name="send_participants_invitation"),
]

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,6 @@ from django.http import JsonResponse, HttpResponse
from django.db.models.fields.json import KeyTextTransform,KeyTransform
from recruitment.utils import json_to_markdown_table
from django.db.models import Count, Avg, F, FloatField
from django.db.models.functions import Cast
from django.db.models.functions import Coalesce, Cast, Replace, NullIf
from . import models
from django.utils.translation import get_language
@ -1065,47 +1064,47 @@ def sync_history(request, job_slug=None):
#participants views
class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
model = models.Participants
template_name = 'participants/participants_list.html'
context_object_name = 'participants'
paginate_by = 10
# class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
# model = models.Participants
# template_name = 'participants/participants_list.html'
# context_object_name = 'participants'
# paginate_by = 10
def get_queryset(self):
queryset = super().get_queryset()
# def get_queryset(self):
# queryset = super().get_queryset()
# Handle search
search_query = self.request.GET.get('search', '')
if search_query:
queryset = queryset.filter(
Q(name__icontains=search_query) |
Q(email__icontains=search_query) |
Q(phone__icontains=search_query) |
Q(designation__icontains=search_query)
)
# # Handle search
# search_query = self.request.GET.get('search', '')
# if search_query:
# queryset = queryset.filter(
# Q(name__icontains=search_query) |
# Q(email__icontains=search_query) |
# Q(phone__icontains=search_query) |
# Q(designation__icontains=search_query)
# )
# Filter for non-staff users
if not self.request.user.is_staff:
return models.Participants.objects.none() # Restrict for non-staff
# # Filter for non-staff users
# if not self.request.user.is_staff:
# return models.Participants.objects.none() # Restrict for non-staff
return queryset.order_by('-created_at')
# return queryset.order_by('-created_at')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '')
return context
class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
model = models.Participants
template_name = 'participants/participants_detail.html'
context_object_name = 'participant'
slug_url_kwarg = 'slug'
# def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['search_query'] = self.request.GET.get('search', '')
# return context
# class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
# model = models.Participants
# template_name = 'participants/participants_detail.html'
# context_object_name = 'participant'
# slug_url_kwarg = 'slug'
class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
model = models.Participants
form_class = forms.ParticipantsForm
template_name = 'participants/participants_create.html'
success_url = reverse_lazy('job_list')
success_message = 'Participant created successfully.'
# class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
# model = models.Participants
# form_class = forms.ParticipantsForm
# template_name = 'participants/participants_create.html'
# success_url = reverse_lazy('job_list')
# success_message = 'Participant created successfully.'
# def get_initial(self):
# initial = super().get_initial()
@ -1116,17 +1115,17 @@ class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMess
class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.Participants
form_class = forms.ParticipantsForm
template_name = 'participants/participants_create.html'
success_url = reverse_lazy('job_list')
success_message = 'Participant updated successfully.'
slug_url_kwarg = 'slug'
# class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
# model = models.Participants
# form_class = forms.ParticipantsForm
# template_name = 'participants/participants_create.html'
# success_url = reverse_lazy('job_list')
# success_message = 'Participant updated successfully.'
# slug_url_kwarg = 'slug'
class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
model = models.Participants
# class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
# model = models.Participants
success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
success_message = 'Participant deleted successfully.'
slug_url_kwarg = 'slug'
# success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
# success_message = 'Participant deleted successfully.'
# slug_url_kwarg = 'slug'

View File

@ -274,7 +274,7 @@
</a>
</li>
<li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'list_meetings' %}">
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'interview_list' %}">
<span class="d-flex align-items-center gap-2">
<i class="fas fa-calendar-check me-2"></i>
{% trans "Meetings" %}

View File

@ -0,0 +1,237 @@
{% extends "base.html" %}
{% block title %}Create Onsite Interview{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="fas fa-building me-2"></i>
Create Onsite Interview for {{ candidate.name }}
</h4>
<a href="{% url 'interview_create_type_selection' candidate.slug %}"
class="btn btn-outline-primary">
<i class="fas fa-arrow-left me-2"></i>
Back to Candidate List
</a>
</div>
<div class="card-body">
<p class="text-muted mb-3">
Schedule an onsite interview for <strong>{{ candidate.name }}</strong>
for the position of <strong>{{ job.title }}</strong>.
</p>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<form method="post" action="{% url 'interview_create_onsite' candidate_slug=candidate.slug %}">
{% csrf_token %}
<div class="row">
<div class="col-md-12">
<div class="mb-3">
<label for="{{ form.interview_date.id_for_label }}" class="form-label">
<i class="fas fa-calendar me-1"></i>
Topic
</label>
{{ form.topic }}
{% if form.topic.errors %}
<div class="text-danger small">
{{ form.topic.errors }}
</div>
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.interview_date.id_for_label }}" class="form-label">
<i class="fas fa-calendar me-1"></i>
Interview Date
</label>
{{ form.interview_date }}
{% if form.interview_date.errors %}
<div class="text-danger small">
{{ form.interview_date.errors }}
</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.interview_time.id_for_label }}" class="form-label">
<i class="fas fa-clock me-1"></i>
Interview Time
</label>
{{ form.interview_time }}
{% if form.interview_time.errors %}
<div class="text-danger small">
{{ form.interview_time.errors }}
</div>
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.duration.id_for_label }}" class="form-label">
<i class="fas fa-hourglass-half me-1"></i>
Duration (minutes)
</label>
{{ form.duration }}
{% if form.duration.errors %}
<div class="text-danger small">
{{ form.duration.errors }}
</div>
{% endif %}
</div>
</div>
{% comment %} <div class="col-md-6">
<div class="mb-3">
<label for="{{ form.interviewer.id_for_label }}" class="form-label">
<i class="fas fa-user me-1"></i>
Interviewer
</label>
{{ form.interviewer }}
{% if form.interviewer.errors %}
<div class="text-danger small">
{{ form.interviewer.errors }}
</div>
{% endif %}
</div>
</div> {% endcomment %}
</div>
{% comment %} <div class="mb-3">
<label for="{{ form.topic.id_for_label }}" class="form-label">
<i class="fas fa-comment me-1"></i>
Meeting Topic
</label>
{{ form.topic }}
{% if form.topic.errors %}
<div class="text-danger small">
{{ form.topic.errors }}
</div>
{% endif %}
</div> {% endcomment %}
<div class="mb-3">
<label for="{{ form.physical_address.id_for_label }}" class="form-label">
<i class="fas fa-map-marker-alt me-1"></i>
Physical Address
</label>
{{ form.physical_address }}
{% if form.physical_address.errors %}
<div class="text-danger small">
{{ form.physical_address.errors }}
</div>
{% endif %}
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.room_number.id_for_label }}" class="form-label">
<i class="fas fa-door-open me-1"></i>
Room Number
</label>
{{ form.room_number }}
{% if form.room_number.errors %}
<div class="text-danger small">
{{ form.room_number.errors }}
</div>
{% endif %}
</div>
</div>
{% comment %} <div class="col-md-6">
<div class="mb-3">
<label for="{{ form.floor_number.id_for_label }}" class="form-label">
<i class="fas fa-layer-group me-1"></i>
Floor Number
</label>
{{ form.floor_number }}
{% if form.floor_number.errors %}
<div class="text-danger small">
{{ form.floor_number.errors }}
</div>
{% endif %}
</div>
</div> {% endcomment %}
{% comment %} </div> {% endcomment %}
{% comment %} <div class="mb-3">
<label for="{{ form.parking_info.id_for_label }}" class="form-label">
<i class="fas fa-parking me-1"></i>
Parking Information
</label>
{{ form.parking_info }}
{% if form.parking_info.errors %}
<div class="text-danger small">
{{ form.parking_info.errors }}
</div>
{% endif %}
</div> {% endcomment %}
{% comment %} <div class="mb-3">
<label for="{{ form.notes.id_for_label }}" class="form-label">
<i class="fas fa-sticky-note me-1"></i>
Notes
</label>
{{ form.notes }}
{% if form.notes.errors %}
<div class="text-danger small">
{{ form.notes.errors }}
</div>
{% endif %}
</div> {% endcomment %}
<div class="d-flex justify-content-between">
<button type="submit" class="btn btn-main-action">
<i class="fas fa-save me-2"></i>
Schedule Onsite Interview
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add form validation for future dates
const dateInput = document.querySelector('input[type="date"]');
const today = new Date().toISOString().split('T')[0];
if (dateInput) {
dateInput.min = today;
dateInput.addEventListener('change', function() {
if (this.value < today) {
this.setCustomValidity('Interview date must be in the future');
} else {
this.setCustomValidity('');
}
});
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% load i18n crispy_forms_tags %}
{% block title %}Create Remote Interview{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="fas fa-video me-2"></i>
Create Remote Interview for {{ candidate.name }}
</h4>
<a href="{% url 'interview_create_type_selection' candidate.slug %}"
class="btn btn-outline-primary">
<i class="fas fa-arrow-left me-2"></i>
Back to Candidate List
</a>
</div>
<div class="card-body">
<p class="text-muted mb-3">
Schedule a remote interview for <strong>{{ candidate.name }}</strong>
for the position of <strong>{{ job.title }}</strong>.
</p>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<form method="post" action="{% url 'interview_create_remote' candidate_slug=candidate.slug %}">
{% csrf_token %}
{{form|crispy}}
<div class="d-flex justify-content-between">
<button type="submit" class="btn btn-main-action">
<i class="fas fa-save me-2"></i>
Schedule Remote Interview
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add form validation for future dates
const dateInput = document.querySelector('input[type="date"]');
const today = new Date().toISOString().split('T')[0];
if (dateInput) {
dateInput.min = today;
dateInput.addEventListener('change', function() {
if (this.value < today) {
this.setCustomValidity('Interview date must be in the future');
} else {
this.setCustomValidity('');
}
});
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Create Interview - Select Type{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="fas fa-calendar-plus me-2"></i>
Create Interview for {{ candidate.name }}
</h4>
</div>
<div class="card-body" hx-boost="true" hx-push-url="false" hx-select=".card-body" hx-swap="innerHTML" hx-target="#candidateviewModalBody">
<p class="text-muted mb-3">
Select the type of interview you want to schedule for <strong>{{ candidate.name }}</strong>
for the position of <strong>{{ job.title }}</strong>.
</p>
<div class="d-grid gap-3" style="grid-template-columns: 1fr 1fr;">
<a href="{% url 'interview_create_remote' candidate_slug=candidate.slug %}"
class="btn btn-outline-primary btn-lg h-100 p-3 text-decoration-none">
<div class="text-center">
<i class="fas fa-video me-2"></i>
<div class="mt-2">Remote Interview</div>
<small class="d-block">Via Zoom/Video Conference</small>
</div>
</a>
<a href="{% url 'interview_create_onsite' candidate_slug=candidate.slug %}"
class="btn btn-outline-primary btn-lg h-100 p-3 text-decoration-none">
<div class="text-center">
<i class="fas fa-building me-2"></i>
<div class="mt-2">Onsite Interview</div>
<small class="d-block">In-person at our facility</small>
</div>
</a>
</div>
</div>
<div class="mt-4">
<a href="{% url 'candidate_interview_view' slug=job.slug %}"
class="btn btn-outline-primary">
<i class="fas fa-arrow-left me-2"></i>
Back to Candidate List
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,768 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{{ interview.application.name }} - {% trans "Interview Details" %} - ATS{% endblock %}
{% block customCSS %}
<style>
/* KAAT-S UI Variables */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-success: #28a745;
--kaauh-info: #17a2b8;
--kaauh-danger: #dc3545;
--kaauh-warning: #ffc107;
}
/* Primary Color Overrides */
.text-primary-theme { color: var(--kaauh-teal) !important; }
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
/* Main Container & Card Styling */
.kaauh-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;
}
/* Button Styling */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
}
.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);
}
.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);
}
/* Info Panel Styling */
.info-panel {
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
border-left: 4px solid var(--kaauh-teal);
border-radius: 0.5rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.info-panel h5 {
color: var(--kaauh-teal-dark);
font-weight: 600;
margin-bottom: 1rem;
}
/* Status Badge Styling */
.status-badge {
font-size: 0.9rem;
padding: 0.4em 0.8em;
border-radius: 0.4rem;
font-weight: 700;
}
.interview-type-badge {
font-size: 0.8rem;
padding: 0.3rem 0.6rem;
border-radius: 0.3rem;
font-weight: 600;
}
/* Status Colors */
.bg-scheduled { background-color: #6c757d !important; color: white; }
.bg-confirmed { background-color: var(--kaauh-info) !important; color: white; }
.bg-cancelled { background-color: var(--kaauh-danger) !important; color: white; }
.bg-completed { background-color: var(--kaauh-success) !important; color: white; }
.bg-remote { background-color: #007bff !important; color: white; }
.bg-onsite { background-color: #6f42c1 !important; color: white; }
/* Timeline Styling */
.timeline {
position: relative;
padding-left: 2rem;
}
.timeline::before {
content: '';
position: absolute;
left: 0.5rem;
top: 0;
bottom: 0;
width: 2px;
background-color: var(--kaauh-border);
}
.timeline-item {
position: relative;
margin-bottom: 1.5rem;
}
.timeline-item::before {
content: '';
position: absolute;
left: -1.5rem;
top: 0.5rem;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: var(--kaauh-teal);
border: 2px solid white;
box-shadow: 0 0 0 2px var(--kaauh-border);
}
.timeline-content {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid var(--kaauh-border);
}
/* Participant List */
.participant-item {
display: flex;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid var(--kaauh-border);
transition: background-color 0.2s ease;
}
.participant-item:hover {
background-color: #f8f9fa;
}
.participant-item:last-child {
border-bottom: none;
}
.participant-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--kaauh-teal);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
margin-right: 1rem;
}
/* Meeting Details */
.meeting-details {
background-color: #f8f9fa;
border-radius: 0.5rem;
padding: 1rem;
border: 1px solid var(--kaauh-border);
}
.meeting-details .detail-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid #e9ecef;
}
.meeting-details .detail-item:last-child {
border-bottom: none;
}
.meeting-details .detail-label {
font-weight: 600;
color: var(--kaauh-primary-text);
}
.meeting-details .detail-value {
color: #6c757d;
}
/* Custom Height Optimization */
.form-control-sm,
.btn-sm {
padding-top: 0.2rem !important;
padding-bottom: 0.2rem !important;
height: 28px !important;
font-size: 0.8rem !important;
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.action-buttons {
flex-direction: column;
}
.action-buttons .btn {
width: 100%;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header Section -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-calendar-alt me-2"></i>
{% trans "Interview Details" %}
</h1>
<h2 class="h5 text-muted mb-0">
{{ interview.application.name }} - {{ interview.job.title }}
</h2>
</div>
<div class="d-flex gap-2">
<a href="{% url 'interview_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Interviews" %}
</a>
<a href="{% url 'job_detail' interview.job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-briefcase me-1"></i> {% trans "View Job" %}
</a>
</div>
</div>
<div class="row">
<!-- Left Column - Candidate & Interview Info -->
<div class="col-lg-8">
<!-- Candidate Information Panel -->
<div class="kaauh-card shadow-sm p-4 mb-4">
<div class="d-flex align-items-start justify-content-between mb-3">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark); font-weight: 600;">
<i class="fas fa-user me-2"></i> {% trans "Candidate Information" %}
</h5>
<div class="action-buttons">
{% if interview.application.resume %}
<a href="{{ interview.application.resume.url }}" class="btn btn-outline-primary btn-sm" target="_blank">
<i class="fas fa-download me-1"></i> {% trans "Download Resume" %}
</a>
{% endif %}
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateModal"
hx-get="{% url 'candidate_criteria_view_htmx' interview.application.pk %}"
hx-target="#candidateModalBody">
<i class="fas fa-eye me-1"></i> {% trans "AI Scoring" %}
</button>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="info-panel">
<h6>{% trans "Personal Details" %}</h6>
<p class="mb-2"><strong>{% trans "Name:" %}</strong> {{ interview.application.name }}</p>
<p class="mb-2"><strong>{% trans "Email:" %}</strong> {{ interview.application.email }}</p>
<p class="mb-2"><strong>{% trans "Phone:" %}</strong> {{ interview.application.phone }}</p>
{% if interview.application.location %}
<p class="mb-0"><strong>{% trans "Location:" %}</strong> {{ interview.application.location }}</p>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="info-panel">
<h6>{% trans "Application Details" %}</h6>
<p class="mb-2"><strong>{% trans "Job:" %}</strong> {{ interview.job.title }}</p>
<p class="mb-2"><strong>{% trans "Department:" %}</strong> {{ interview.job.department }}</p>
<p class="mb-2"><strong>{% trans "Applied Date:" %}</strong> {{ interview.application.created_at|date:"d-m-Y" }}</p>
<p class="mb-0"><strong>{% trans "Current Stage:" %}</strong>
<span class="badge stage-badge stage-{{ interview.application.stage|lower }}">
{{ interview.application.stage }}
</span>
</p>
</div>
</div>
</div>
</div>
<!-- Interview Details Panel -->
<div class="kaauh-card shadow-sm p-4 mb-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark); font-weight: 600;">
<i class="fas fa-calendar-check me-2"></i> {% trans "Interview Details" %}
</h5>
<div class="d-flex gap-2">
<span class="badge interview-type-badge
{% if interview.interview.location_type == 'Remote' %}bg-remote
{% else %}bg-onsite
{% endif %}">
{% if interview.interview.location_type == 'Remote' %}
<i class="fas fa-video me-1"></i> {% trans "Remote" %}
{% else %}
<i class="fas fa-building me-1"></i> {% trans "Onsite" %}
{% endif %}
</span>
<span class="badge status-badge
{% if interview.status == 'SCHEDULED' %}bg-scheduled
{% elif interview.status == 'CONFIRMED' %}bg-confirmed
{% elif interview.status == 'CANCELLED' %}bg-cancelled
{% elif interview.status == 'COMPLETED' %}bg-completed
{% endif %}">
{{ interview.status }}
</span>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="meeting-details">
<div class="detail-item">
<span class="detail-label">{% trans "Date:" %}</span>
<span class="detail-value">{{ interview.interview_date|date:"d-m-Y" }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{% trans "Time:" %}</span>
<span class="detail-value">{{ interview.interview_time|date:"h:i A" }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{% trans "Duration:" %}</span>
<span class="detail-value">{{ interview.interview.duration }} {% trans "minutes" %}</span>
</div>
</div>
</div>
<div class="col-md-6">
{% if interview.interview.location_type == 'Remote' %}
<div class="meeting-details">
<h6 class="mb-3">{% trans "Remote Meeting Details" %}</h6>
<div class="detail-item">
<span class="detail-label">{% trans "Platform:" %}</span>
<span class="detail-value">Zoom</span>
</div>
{% if interview.interview %}
<div class="detail-item">
<span class="detail-label">{% trans "Meeting ID:" %}</span>
<span class="detail-value">{{ interview.interview.meeting_id }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{% trans "Password:" %}</span>
<span class="detail-value">{{ interview.interview.password }}</span>
</div>
{% if interview.interview.details_url %}
<div class="mt-3">
<a href="{{ interview.interview.zoommeetingdetails.details_url }}"
target="_blank"
class="btn btn-main-action btn-sm w-100">
<i class="fas fa-video me-1"></i> {% trans "Join Meeting" %}
</a>
</div>
{% endif %}
{% endif %}
</div>
{% else %}
<div class="meeting-details">
<h6 class="mb-3">{% trans "Onsite Location Details" %}</h6>
{% if interview.interview %}
<div class="detail-item">
<span class="detail-label">{% trans "Address:" %}</span>
<span class="detail-value">{{ interview.interview.physical_address }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{% trans "Room:" %}</span>
<span class="detail-value">{{ interview.interview.room_number }}</span>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Timeline/History Section -->
<div class="kaauh-card shadow-sm p-4">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;">
<i class="fas fa-history me-2"></i> {% trans "Interview Timeline" %}
</h5>
<div class="timeline">
<div class="timeline-item">
<div class="timeline-content">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1">{% trans "Interview Scheduled" %}</h6>
<p class="mb-0 text-muted">{% trans "Interview was scheduled for" %} {{ interview.interview_date|date:"d-m-Y" }} {{ interview.interview_time|date:"h:i A" }}</p>
</div>
<small class="text-muted">{{ interview.interview.created_at|date:"d-m-Y h:i A" }}</small>
</div>
</div>
</div>
{% if interview.interview.status == 'CONFIRMED' %}
<div class="timeline-item">
<div class="timeline-content">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1">{% trans "Interview Confirmed" %}</h6>
<p class="mb-0 text-muted">{% trans "Candidate has confirmed attendance" %}</p>
</div>
<small class="text-muted">{% trans "Recently" %}</small>
</div>
</div>
</div>
{% endif %}
{% if interview.interview.status == 'COMPLETED' %}
<div class="timeline-item">
<div class="timeline-content">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1">{% trans "Interview Completed" %}</h6>
<p class="mb-0 text-muted">{% trans "Interview has been completed" %}</p>
</div>
<small class="text-muted">{% trans "Recently" %}</small>
</div>
</div>
</div>
{% endif %}
{% if interview.interview.status == 'CANCELLED' %}
<div class="timeline-item">
<div class="timeline-content">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1">{% trans "Interview Cancelled" %}</h6>
<p class="mb-0 text-muted">{% trans "Interview was cancelled" %}</p>
</div>
<small class="text-muted">{% trans "Recently" %}</small>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Right Column - Participants & Actions -->
<div class="col-lg-4">
<!-- Participants Panel -->
<div class="kaauh-card shadow-sm p-4 mb-4">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;">
<i class="fas fa-users me-2"></i> {% trans "Participants" %}
</h5>
<!-- Internal Participants -->
{% if interview.participants.exists %}
<h6 class="mb-2 text-muted">{% trans "Internal Participants" %}</h6>
{% for participant in interview.participants.all %}
<div class="participant-item">
<div class="participant-avatar">
{{ participant.first_name.0 }}{{ participant.last_name.0 }}
</div>
<div class="flex-grow-1">
<div class="fw-semibold">{{ participant.get_full_name }}</div>
<div class="text-muted small">{{ participant.email }}</div>
</div>
</div>
{% endfor %}
{% endif %}
<!-- External Participants -->
{% if interview.system_users.exists %}
<h6 class="mb-2 mt-3 text-muted">{% trans "External Participants" %}</h6>
{% for user in interview.system_users.all %}
<div class="participant-item">
<div class="participant-avatar">
{{ user.first_name.0 }}{{ user.last_name.0 }}
</div>
<div class="flex-grow-1">
<div class="fw-semibold">{{ user.get_full_name }}</div>
<div class="text-muted small">{{ user.email }}</div>
</div>
</div>
{% endfor %}
{% endif %}
{% if not interview.participants.exists and not interview.system_users.exists %}
<div class="text-center py-3 text-muted">
<i class="fas fa-users fa-2x mb-2"></i>
<p class="mb-0">{% trans "No participants added yet" %}</p>
</div>
{% endif %}
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-3"
data-bs-toggle="modal"
data-bs-target="#participantModal">
<i class="fas fa-user-plus me-1"></i> {% trans "Add Participants" %}
</button>
</div>
<!-- Actions Panel -->
<div class="kaauh-card shadow-sm p-4">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;">
<i class="fas fa-cog me-2"></i> {% trans "Actions" %}
</h5>
<div class="action-buttons">
{% if interview.status != 'CANCELLED' and interview.status != 'COMPLETED' %}
<button type="button" class="btn btn-main-action btn-sm"
data-bs-toggle="modal"
data-bs-target="#rescheduleModal">
<i class="fas fa-redo-alt me-1"></i> {% trans "Reschedule" %}
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
data-bs-toggle="modal"
data-bs-target="#cancelModal">
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
</button>
{% endif %}
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#emailModal">
<i class="fas fa-envelope me-1"></i> {% trans "Send Email" %}
</button>
{% if interview.status == 'COMPLETED' %}
<button type="button" class="btn btn-outline-success btn-sm"
data-bs-toggle="modal"
data-bs-target="#resultModal">
<i class="fas fa-check-circle me-1"></i> {% trans "Update Result" %}
</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Candidate Profile Modal -->
<div class="modal fade modal-xl" id="candidateModal" tabindex="-1" aria-labelledby="candidateModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content kaauh-card">
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
<h5 class="modal-title" id="candidateModalLabel" style="color: var(--kaauh-teal-dark);">
{% trans "Candidate Profile" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div id="candidateModalBody" class="modal-body">
<div class="text-center py-5 text-muted">
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
{% trans "Loading profile..." %}
</div>
</div>
</div>
</div>
</div>
<!-- Participant Modal -->
<div class="modal fade" id="participantModal" tabindex="-1" aria-labelledby="participantModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content kaauh-card">
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
<h5 class="modal-title" id="participantModalLabel" style="color: var(--kaauh-teal-dark);">
{% trans "Add Participants" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="post" action="#">
{% csrf_token %}
<div class="mb-3">
<label for="internal_participants" class="form-label">{% trans "Internal Participants" %}</label>
<select multiple class="form-select" id="internal_participants" name="participants">
<!-- Options will be populated dynamically -->
</select>
</div>
<div class="mb-3">
<label for="external_participants" class="form-label">{% trans "External Participants" %}</label>
<select multiple class="form-select" id="external_participants" name="system_users">
<!-- Options will be populated dynamically -->
</select>
</div>
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-plus me-1"></i> {% trans "Add Participants" %}
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Email Modal -->
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content kaauh-card">
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
<h5 class="modal-title" id="emailModalLabel" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-envelope me-2"></i> {% trans "Compose Email" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="post" action="#">
{% csrf_token %}
<div class="mb-3">
<label for="email_to" class="form-label">{% trans "To" %}</label>
<input type="email" class="form-control" id="email_to" value="{{ interview.application.email }}" readonly>
</div>
<div class="mb-3">
<label for="email_subject" class="form-label">{% trans "Subject" %}</label>
<input type="text" class="form-control" id="email_subject" name="subject"
value="{% trans 'Interview Details' %} - {{ interview.job.title }}">
</div>
<div class="mb-3">
<label for="email_message" class="form-label">{% trans "Message" %}</label>
<textarea class="form-control" id="email_message" name="message" rows="6">
{% trans "Dear" %} {{ interview.application.name }},
{% trans "Your interview details are as follows:" %}
{% trans "Date:" %} {{ interview.interview_date|date:"d-m-Y" }}
{% trans "Time:" %} {{ interview.interview_time|date:"h:i A" }}
{% trans "Job:" %} {{ interview.job.title }}
{% if interview.interview.location_type == 'Remote' %}
{% trans "This is a remote interview. You will receive the meeting link separately." %}
{% else %}
{% trans "This is an onsite interview. Please arrive 10 minutes early." %}
{% endif %}
{% trans "Best regards," %}
{% trans "HR Team" %}
</textarea>
</div>
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-paper-plane me-1"></i> {% trans "Send Email" %}
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Reschedule Modal -->
<div class="modal fade" id="rescheduleModal" tabindex="-1" aria-labelledby="rescheduleModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content kaauh-card">
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
<h5 class="modal-title" id="rescheduleModalLabel" style="color: var(--kaauh-teal-dark);">
{% trans "Reschedule Interview" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="post" action="#">
{% csrf_token %}
<div class="mb-3">
<label for="new_date" class="form-label">{% trans "New Date" %}</label>
<input type="date" class="form-control" id="new_date" name="new_date" required>
</div>
<div class="mb-3">
<label for="new_time" class="form-label">{% trans "New Time" %}</label>
<input type="time" class="form-control" id="new_time" name="new_time" required>
</div>
<div class="mb-3">
<label for="reschedule_reason" class="form-label">{% trans "Reason for Rescheduling" %}</label>
<textarea class="form-control" id="reschedule_reason" name="reason" rows="3"
placeholder="{% trans 'Optional: Provide reason for rescheduling' %}"></textarea>
</div>
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-redo-alt me-1"></i> {% trans "Reschedule" %}
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Cancel Modal -->
<div class="modal fade" id="cancelModal" tabindex="-1" aria-labelledby="cancelModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content kaauh-card">
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
<h5 class="modal-title" id="cancelModalLabel" style="color: var(--kaauh-teal-dark);">
{% trans "Cancel Interview" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="post" action="#">
{% csrf_token %}
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
{% trans "Are you sure you want to cancel this interview? This action cannot be undone." %}
</div>
<div class="mb-3">
<label for="cancel_reason" class="form-label">{% trans "Reason for Cancellation" %}</label>
<textarea class="form-control" id="cancel_reason" name="reason" rows="3" required
placeholder="{% trans 'Please provide a reason for cancellation' %}"></textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-times me-1"></i> {% trans "Cancel Interview" %}
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">
{% trans "Close" %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Result Modal -->
<div class="modal fade" id="resultModal" tabindex="-1" aria-labelledby="resultModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content kaauh-card">
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
<h5 class="modal-title" id="resultModalLabel" style="color: var(--kaauh-teal-dark);">
{% trans "Update Interview Result" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="post" action="#">
{% csrf_token %}
<div class="mb-3">
<label for="interview_result" class="form-label">{% trans "Interview Result" %}</label>
<select class="form-select" id="interview_result" name="result" required>
<option value="">{% trans "Select Result" %}</option>
<option value="passed">{% trans "Passed" %}</option>
<option value="failed">{% trans "Failed" %}</option>
<option value="on_hold">{% trans "On Hold" %}</option>
</select>
</div>
<div class="mb-3">
<label for="result_notes" class="form-label">{% trans "Notes" %}</label>
<textarea class="form-control" id="result_notes" name="notes" rows="4"
placeholder="{% trans 'Add interview feedback and notes' %}"></textarea>
</div>
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-check me-1"></i> {% trans "Update Result" %}
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// Clear modal content when hidden
const modals = ['candidateModal', 'participantModal', 'emailModal', 'rescheduleModal', 'cancelModal', 'resultModal'];
modals.forEach(modalId => {
const modal = document.getElementById(modalId);
if (modal) {
modal.addEventListener('hidden.bs.modal', function () {
const modalBody = modal.querySelector('.modal-body');
if (modalBody && modalId === 'candidateModal') {
modalBody.innerHTML = `
<div class="text-center py-5 text-muted">
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
{% trans "Loading profile..." %}
</div>
`;
}
});
}
});
});
</script>
{% endblock %}

View File

@ -1,81 +1,235 @@
{% extends "base.html" %}
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{% trans "Scheduled Interviews List" %} - {{ block.super }}{% endblock %}
{% block title %}{% trans "Interview Management" %} - ATS{% endblock %}
{% block customCSS %}
{# (Your existing CSS is kept here, as it is perfect for the theme) #}
<style>
/* ... (Your CSS styles) ... */
/* KAAT-S UI Variables */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-success: #28a745;
--kaauh-info: #17a2b8;
--kaauh-danger: #dc3545;
--kaauh-warning: #ffc107;
}
/* Primary Color Overrides */
.text-primary-theme { color: var(--kaauh-teal) !important; }
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
/* Main Container & Card Styling */
.kaauh-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;
}
/* Filter Controls */
.filter-controls {
background-color: #f8f9fa;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 2rem;
border: 1px solid var(--kaauh-border);
}
/* Button Styling */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
}
.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);
}
.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);
}
/* Interview Table Styling */
.interview-table {
table-layout: fixed;
width: 100%;
border-collapse: separate;
border-spacing: 0;
background-color: white;
border-radius: 0.5rem;
overflow: hidden;
}
.interview-table thead {
background-color: var(--kaauh-border);
}
.interview-table th {
padding: 0.75rem 1rem;
font-weight: 600;
color: var(--kaauh-teal-dark);
border-bottom: 2px solid var(--kaauh-teal);
font-size: 0.9rem;
vertical-align: middle;
}
.interview-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--kaauh-border);
vertical-align: middle;
font-size: 0.9rem;
}
.interview-table tbody tr:hover {
background-color: #f1f3f4;
}
/* Column Widths */
.interview-table thead th:nth-child(1) { width: 40px; }
.interview-table thead th:nth-child(2) { width: 15%; }
.interview-table thead th:nth-child(3) { width: 12%; }
.interview-table thead th:nth-child(4) { width: 12%; }
.interview-table thead th:nth-child(5) { width: 10%; }
.interview-table thead th:nth-child(6) { width: 8%; }
.interview-table thead th:nth-child(7) { width: 8%; }
.interview-table thead th:nth-child(8) { width: 15%; }
/* Candidate and Job Info */
.candidate-name {
font-weight: 600;
color: var(--kaauh-primary-text);
}
.candidate-details {
font-size: 0.8rem;
color: #6c757d;
}
.job-title {
font-weight: 500;
color: var(--kaauh-teal-dark);
}
/* Badges and Statuses */
.status-badge {
font-size: 0.75rem;
padding: 0.3em 0.7em;
border-radius: 0.35rem;
font-weight: 700;
}
.interview-type-badge {
font-size: 0.7rem;
padding: 0.25rem 0.5rem;
border-radius: 0.3rem;
font-weight: 600;
}
/* Status Colors */
.bg-scheduled { background-color: #6c757d !important; color: white; }
.bg-confirmed { background-color: var(--kaauh-info) !important; color: white; }
.bg-cancelled { background-color: var(--kaauh-danger) !important; color: white; }
.bg-completed { background-color: var(--kaauh-success) !important; color: white; }
.bg-remote { background-color: #007bff !important; color: white; }
.bg-onsite { background-color: #6f42c1 !important; color: white; }
/* Custom Height Optimization */
.form-control-sm,
.btn-sm {
padding-top: 0.2rem !important;
padding-bottom: 0.2rem !important;
height: 28px !important;
font-size: 0.8rem !important;
}
/* Pagination Styling */
.pagination .page-link {
color: var(--kaauh-teal);
border-color: var(--kaauh-border);
}
.pagination .page-item.active .page-link {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
}
.pagination .page-link:hover {
color: var(--kaauh-teal-dark);
background-color: #f8f9fa;
}
</style>
{% endblock %}
{% block content %}
{{interviews}}
<div class="container-fluid py-4">
<!-- Header Section -->
<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-calendar-alt me-2"></i> {% trans "Scheduled Interviews" %}
<div>
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-calendar-alt me-2"></i>
{% trans "Interview Management" %}
</h1>
{# FIX: Using safe anchor href="#" to prevent the NoReverseMatch crash. #}
{# Replace '#' with {% url 'create_scheduled_interview' %} once the URL name is defined in urls.py #}
<a href="#" class="btn btn-main-action">
<i class="fas fa-plus me-1"></i> {% trans "Schedule Interview" %}
<h2 class="h5 text-muted mb-0">
{% trans "Total Interviews:" %} <span class="fw-bold">{{ interviews|length }}</span>
</h2>
</div>
<div class="d-flex gap-2">
<a href="{% url 'dashboard' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Dashboard" %}
</a>
</div>
<div class="card mb-4 shadow-sm no-hover">
<div class="card-body">
<form method="GET" class="row g-3 align-items-end">
{# Search field #}
<div class="col-md-4">
<label for="q" class="form-label small text-muted">{% trans "Search (Candidate/Job)" %}</label>
<div class="input-group">
<input type="text" class="form-control form-control-sm" id="q" name="q" placeholder="{% trans 'Search...' %}" value="{{ search_query }}">
</div>
</div>
{# Filter by Status #}
<!-- Filter Controls -->
<div class="filter-controls">
<form method="get" class="row g-3">
<div class="col-md-3">
<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="scheduled" {% if status_filter == 'scheduled' %}selected{% endif %}>{% trans "Scheduled" %}</option>
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>{% trans "Confirmed" %}</option>
<option value="completed" {% if status_filter == 'completed' %}selected{% endif %}>{% trans "Completed" %}</option>
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>{% trans "Cancelled" %}</option>
</select>
</div>
{# Filter by Interview Type (ONSITE/REMOTE) - This list now correctly populated #}
<div class="col-md-3">
<label for="interview_type" class="form-label small text-muted">{% trans "Interview Type" %}</label>
<select name="interview_type" id="interview_type" class="form-select form-select-sm">
<option value="">{% trans "All Types" %}</option>
{% for type_value, type_label in interview_types %}
<option value="{{ type_value }}" {% if type_filter == type_value %}selected{% endif %}>
{{ type_label }}
<label for="job_filter" class="form-label form-label-sm">{% trans "Job" %}</label>
<select name="job" id="job_filter" class="form-select form-select-sm">
<option value="">{% trans "All Jobs" %}</option>
{% for job in jobs %}
<option value="{{ job.id }}" {% if request.GET.job == job.id|stringformat:"s" %}selected{% endif %}>
{{ job.title }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<div class="filter-buttons">
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-filter me-1"></i> {% trans "Apply" %}
<label for="status_filter" class="form-label form-label-sm">{% trans "Status" %}</label>
<select name="status" id="status_filter" class="form-select form-select-sm">
<option value="">{% trans "All Status" %}</option>
<option value="scheduled" {% if request.GET.status == "scheduled" %}selected{% endif %}>{% trans "Scheduled" %}</option>
<option value="confirmed" {% if request.GET.status == "confirmed" %}selected{% endif %}>{% trans "Confirmed" %}</option>
<option value="cancelled" {% if request.GET.status == "cancelled" %}selected{% endif %}>{% trans "Cancelled" %}</option>
<option value="completed" {% if request.GET.status == "completed" %}selected{% endif %}>{% trans "Completed" %}</option>
</select>
</div>
<div class="col-md-2">
<label for="type_filter" class="form-label form-label-sm">{% trans "Type" %}</label>
<select name="type" id="type_filter" class="form-select form-select-sm">
<option value="">{% trans "All Types" %}</option>
<option value="remote" {% if request.GET.type == "remote" %}selected{% endif %}>{% trans "Remote" %}</option>
<option value="onsite" {% if request.GET.type == "onsite" %}selected{% endif %}>{% trans "Onsite" %}</option>
</select>
</div>
<div class="col-md-3">
<label for="search_filter" class="form-label form-label-sm">{% trans "Search Candidate" %}</label>
<input type="text" name="search" id="search_filter" class="form-control form-control-sm"
value="{{ request.GET.search }}" placeholder="{% trans 'Name or Email' %}">
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-main-action btn-sm me-2">
<i class="fas fa-filter me-1"></i> {% trans "Filter" %}
</button>
{% if status_filter or search_query or type_filter %}
{# Assuming 'interview_list' is the URL name for this view #}
<a href="{% url 'interview_list' %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
</a>
{% endif %}
</div>
</div>
</form>
</div>
</div>
{{meetings}}
{# Using 'meetings' based on the context_object_name provided #}
{% if meetings %}
@ -149,70 +303,83 @@
{% endfor %}
</div>
{# Table View (Logic is identical, safe access applied) #}
<div class="table-view">
<form id="interview-form">
{% csrf_token %}
<div class="table-responsive">
<table class="table table-hover">
<table class="table interview-table align-middle">
<thead>
<tr>
<th scope="col">{% trans "Candidate" %}</th>
<th scope="col">{% trans "Job" %}</th>
<th scope="col">{% trans "Type" %}</th>
<th scope="col">{% trans "Date/Time" %}</th>
<th scope="col">{% trans "Duration" %}</th>
<th scope="col">{% trans "Status" %}</th>
<th scope="col" class="text-end">{% trans "Actions" %}</th>
<th><i class="fas fa-user me-1"></i> {% trans "Candidate" %}</th>
<th><i class="fas fa-briefcase me-1"></i> {% trans "Job" %}</th>
<th><i class="fas fa-calendar me-1"></i> {% trans "Date & Time" %}</th>
<th><i class="fas fa-tag me-1"></i> {% trans "Type" %}</th>
<th><i class="fas fa-info-circle me-1"></i> {% trans "Status" %}</th>
{% comment %} <th><i class="fas fa-users me-1"></i> {% trans "Participants" %}</th> {% endcomment %}
<th><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for interview in meetings %}
{% for interview in interviews %}
<tr>
<td>
<strong class="text-primary-theme">
<a href="{% url 'application_detail' interview.candidate.slug %}" class="text-decoration-none text-primary-theme">{{ interview.candidate.name }}</a>
</strong>
<div class="candidate-name">{{ interview.application.name }}</div>
<div class="application-details">
<i class="fas fa-envelope me-1"></i> {{ interview.application.email }}<br>
<i class="fas fa-phone me-1"></i> {{ interview.application.phone }}
</div>
</td>
<td>
<a class="text-secondary text-decoration-none" href="{% url 'job_detail' interview.job.slug %}">{{ interview.job.title }}</a>
<div class="job-title">{{ interview.job.title }}</div>
<div class="candidate-details">{{ interview.job.department }}</div>
</td>
<td>
{{ interview.schedule.get_interview_type_display }}
<div class="candidate-details">
<i class="fas fa-calendar-day me-1"></i> {{ interview.interview_date|date:"d-m-Y" }}<br>
<i class="fas fa-clock me-1"></i> {{ interview.interview_time|date:"h:i A" }}
</div>
</td>
<td>{{ interview.interview_date|date:"M d, Y" }} <br>({{ interview.interview_time|time:"H:i" }})</td>
<td>{{ interview.schedule.interview_duration }} min</td>
<td>
<span class="badge bg-{{ interview.status }}">
{% if interview.status == 'confirmed' %}
<i class="fas fa-circle me-1 text-white"></i>
{% if interview.interview.location_type == 'Remote' %}
<span class="badge interview-type-badge bg-remote">
<i class="fas fa-video me-1"></i> {% trans "Remote" %}
</span>
{% else %}
<span class="badge interview-type-badge bg-onsite">
<i class="fas fa-building me-1"></i> {% trans "Onsite" %}
</span>
{% endif %}
{{ interview.status|title }}
</td>
<td>
<span class="badge bg-primary-theme">
{{ interview.status|upper }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
{# CRITICAL FIX: Safe access to join URL #}
{% if interview.schedule.interview_type == 'Remote' and interview.zoom_meeting and interview.zoom_meeting.join_url %}
<a href="{{ interview.zoom_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 'scheduled_interview_detail' interview.slug %}" class="btn btn-outline-primary" title="{% trans 'View' %}">
<td>
<div class="btn-group" role="group">
<a href="{% url 'interview_detail' interview.slug %}"
class="btn btn-outline-primary btn-sm"
title="{% trans 'View Details' %}">
<i class="fas fa-eye"></i>
</a>
<a href="{% url 'update_scheduled_interview' interview.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' %}"
{% comment %} {% if interview.status != 'CANCELLED' and interview.status != 'COMPLETED' %}
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
data-bs-target="#meetingModal"
hx-post="{% url 'delete_scheduled_interview' interview.slug %}"
hx-target="#meetingModalBody"
hx-swap="outerHTML"
data-item-name="{{ interview.candidate.name }} Interview">
<i class="fas fa-trash-alt"></i>
data-bs-target="#actionModal"
hx-get="#"
hx-target="#actionModalBody"
title="{% trans 'Reschedule' %}">
<i class="fas fa-redo-alt"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-sm"
data-bs-toggle="modal"
data-bs-target="#actionModal"
hx-get="#"
hx-target="#actionModalBody"
title="{% trans 'Cancel' %}">
<i class="fas fa-times"></i>
</button>
{% endif %} {% endcomment %}
</div>
</td>
</tr>
@ -220,49 +387,138 @@
</tbody>
</table>
</div>
</div>
</div>
</form>
{# Pagination #}
<!-- Pagination -->
{% if is_paginated %}
<nav aria-label="Page navigation" class="mt-4">
<nav aria-label="Interview pagination" 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 %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">First</a>
<a class="page-link" href="?page=1{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "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 %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">Previous</a>
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "Previous" %}</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% 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 %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">Next</a>
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "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 %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">Last</a>
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "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-calendar-alt fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
<h3>{% trans "No Interviews found" %}</h3>
<p class="text-muted">{% trans "Schedule your first interview or adjust your filters." %}</p>
{# FIX: Using safe anchor href="#" to prevent the NoReverseMatch crash. #}
<a href="#" class="btn btn-main-action mt-3">
<i class="fas fa-plus me-1"></i> {% trans "Schedule an Interview" %}
</a>
</div>
<div class="alert alert-info text-center py-5" role="alert">
<i class="fas fa-info-circle fa-2x mb-3"></i>
<h5>{% trans "No interviews found" %}</h5>
<p class="text-muted mb-0">
{% trans "There are no interviews matching your current filters." %}
<a href="{% url 'interview_list' %}" class="alert-link">{% trans "Clear filters" %}</a>
{% trans "to see all interviews." %}
</p>
</div>
{% endif %}
</div>
</div>
<!-- Action Modal -->
<div class="modal fade" id="actionModal" tabindex="-1" aria-labelledby="actionModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content kaauh-card">
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
<h5 class="modal-title" id="actionModalLabel" style="color: var(--kaauh-teal-dark);">
{% trans "Interview Action" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div id="actionModalBody" class="modal-body">
<div class="text-center py-5 text-muted">
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
{% trans "Loading..." %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
if (selectAllCheckbox) {
function updateSelectAllState() {
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
const totalCount = rowCheckboxes.length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
selectAllCheckbox.addEventListener('change', function () {
const isChecked = selectAllCheckbox.checked;
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
rowCheckboxes.forEach(function (checkbox) {
checkbox.checked = isChecked;
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
});
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
updateSelectAllState();
});
rowCheckboxes.forEach(function (checkbox) {
checkbox.addEventListener('change', updateSelectAllState);
});
updateSelectAllState();
}
// Clear modal content when hidden
const actionModal = document.getElementById('actionModal');
actionModal.addEventListener('hidden.bs.modal', function () {
const modalBody = actionModal.querySelector('#actionModalBody');
if (modalBody) {
modalBody.innerHTML = `
<div class="text-center py-5 text-muted">
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
{% trans "Loading..." %}
</div>
`;
}
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,42 @@
{% load i18n %}
<table class="table candidate-table align-middle">
<thead>
<tr>
<th style="width: 40%;"><i class="fas fa-user me-1"></i> {% trans "Topic" %}</th>
<th style="width: 15%;"><i class="fas fa-calendar-alt me-1"></i> {% trans "Date" %}</th>
<th style="width: 5%;"><i class="fas fa-map-marker-alt me-1"></i> {% trans "Duration" %}</th>
<th style="width: 10%;"><i class="fas fa-map-marker-alt me-1"></i> {% trans "Location" %}</th>
<th style="width: 10%;"><i class="fas fa-info-circle me-1"></i> {% trans "Status" %}</th>
<th style="width: 10%;"><i class="fas fa-ellipsis-h me-1"></i> {% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for interview in interviews %}
<tr>
<td>{{ interview.interview.topic }}</td>
<td>{{ interview.interview_date }} {{interview.interview_time}}</td>
<td>{{ interview.interview.duration }}</td>
<td>
<span class="badge bg-primary-theme">
{{ interview.interview.location_type }}
</span>
</td>
<td>
<span class="badge bg-primary-theme">
{{ interview.get_status_display }}
</span>
</td>
<td><a class="btn btn-outline-primary btn-sm" href="{% url 'interview_detail' interview.slug %}" target="_blank">View</a></td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center text-muted">
<i class="fas fa-info-circle me-2"></i>
{% trans "No interviews scheduled yet." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@ -286,7 +286,7 @@
{# Separator (Vertical Rule) - Aligns automatically at the bottom with align-items-end #}
<div class="vr" style="height: 28px;"></div>
<button type="button" class="btn btn-outline-info btn-sm"
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"

View File

@ -229,7 +229,7 @@
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button>
<button type="button" class="btn btn-outline-info btn-sm"
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"

View File

@ -252,7 +252,7 @@
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button>
{# email button#}
<button type="button" class="btn btn-outline-info btn-sm"
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"

View File

@ -422,27 +422,37 @@
{% endif %}
{% else %}
<button type="button" class="btn btn-main-action btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'schedule_meeting_for_application' job.slug application.pk %}"
hx-target="#candidateviewModalBody"
data-modal-title="{% trans 'Schedule Interview' %}"
{% comment %} <a href="{% url 'interview_create_type_selection' candidate_slug=candidate.slug %}"
class="btn btn-main-action btn-sm"
title="Schedule Interview">
<i class="fas fa-video"></i>
</button>
<button type="button" class="btn btn-main-action btn-sm"
<i class="fas fa-calendar-plus me-1"></i>
Schedule
</a> {% endcomment %}
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
{# UPDATED: Points to the specific Onsite scheduling URL #}
hx-get="{% url 'schedule_onsite_meeting_for_application' job.slug application.pk %}"
hx-target="#candidateviewModalBody"
data-modal-title="{% trans 'Schedule Onsite Interview' %}"
title="Schedule Onsite Interview">
<i class="fas fa-building"></i>
hx-get="{% url 'interview_create_type_selection' application_slug=application.slug %}"
hx-select=".card-body"
hx-swap="innerHTML"
hx-target="#candidateviewModalBody">
<i class="fas fa-calendar-plus me-1"></i>
Schedule
</button>
{% comment %} <a href="{% url 'interview_create_type_selection' candidate_slug=candidate.slug %}"
class="btn btn-main-action btn-sm"
title="Schedule Interview">
<i class="fas fa-calendar-plus me-1"></i>
Schedule
</a> {% endcomment %}
{% endif %}
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'get_interview_list' application.slug %}"
hx-target="#candidateviewModalBody">
<i class="fas fa-list"></i>
</button>
{{candidate.get_interviews}}
</td>
</tr>
{% endfor %}
@ -463,7 +473,7 @@
<div class="modal-dialog">
<div class="modal-content kaauh-card"> <div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
<h5 class="modal-title" id="candidateviewModalLabel" style="color: var(--kaauh-teal-dark);">
{% trans "Application Details / Bulk Action Form" %}
{% comment %} {% trans "Candidate Details / Bulk Action Form" %} {% endcomment %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
@ -476,11 +486,9 @@
</div>
</div>
</div>
<!-- Email Modal -->
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">

View File

@ -231,7 +231,7 @@
{# Separator (Vertical Rule) #}
<div class="vr" style="height: 28px;"></div>
<button type="button" class="btn btn-outline-info btn-sm"
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"

View File

@ -344,7 +344,7 @@
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button>
{# email button#}
<button type="button" class="btn btn-outline-info btn-sm"
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"