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") candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE, related_name="scheduled_interviews")
job = models.ForeignKey(JobPosting, 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") 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_date = models.DateField()
interview_time = models.TimeField() interview_time = models.TimeField()
status = models.CharField(max_length=20, choices=[ status = models.CharField(max_length=20, choices=[
@ -365,9 +365,9 @@ class ScheduledInterview(Base):
], default="scheduled") ], default="scheduled")
``` ```
#### 2.2.11 InterviewSchedule Model #### 2.2.11 BulkInterviewTemplate Model
```python ```python
class InterviewSchedule(Base): class BulkInterviewTemplate(Base):
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="interview_schedules") job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="interview_schedules")
candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True, null=True) candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True, null=True)
start_date = models.DateField() start_date = models.DateField()
@ -533,7 +533,7 @@ class CandidateService:
### 5.2 Interview Scheduling Logic ### 5.2 Interview Scheduling Logic
```python ```python
class InterviewScheduler: class BulkInterviewTemplater:
@staticmethod @staticmethod
def get_available_slots(schedule, date): def get_available_slots(schedule, date):
"""Get available interview slots for a specific date""" """Get available interview slots for a specific date"""
@ -915,7 +915,7 @@ class InterviewSchedulingTestCase(TestCase):
phone="9876543210", phone="9876543210",
job=self.job job=self.job
) )
self.schedule = InterviewSchedule.objects.create( self.schedule = BulkInterviewTemplate.objects.create(
job=self.job, job=self.job,
start_date=timezone.now().date(), start_date=timezone.now().date(),
end_date=timezone.now().date() + timedelta(days=7), end_date=timezone.now().date() + timedelta(days=7),
@ -930,7 +930,7 @@ class InterviewSchedulingTestCase(TestCase):
def test_interview_scheduling(self): def test_interview_scheduling(self):
"""Test interview scheduling process""" """Test interview scheduling process"""
# Test slot availability # Test slot availability
available_slots = InterviewScheduler.get_available_slots( available_slots = BulkInterviewTemplater.get_available_slots(
self.schedule, self.schedule,
timezone.now().date() timezone.now().date()
) )
@ -942,7 +942,7 @@ class InterviewSchedulingTestCase(TestCase):
'start_time': timezone.now().time(), 'start_time': timezone.now().time(),
'duration': 60 'duration': 60
} }
interview = InterviewScheduler.schedule_interview( interview = BulkInterviewTemplater.schedule_interview(
self.candidate, self.candidate,
self.job, self.job,
schedule_data 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 - **Candidate**: Stage transitions, relationships
- **ZoomMeeting**: Time validation, status handling - **ZoomMeeting**: Time validation, status handling
- **FormTemplate**: Template integrity, field ordering - **FormTemplate**: Template integrity, field ordering
- **InterviewSchedule**: Scheduling logic, slot generation - **BulkInterviewTemplate**: Scheduling logic, slot generation
### 2. View Testing ### 2. View Testing
- **Job Management**: CRUD operations, search, filtering - **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 ### 3. Form Testing
- **JobPostingForm**: Complex validation, field dependencies - **JobPostingForm**: Complex validation, field dependencies
- **CandidateForm**: File upload, validation - **CandidateForm**: File upload, validation
- **InterviewScheduleForm**: Dynamic fields, validation - **BulkInterviewTemplateForm**: Dynamic fields, validation
- **MeetingCommentForm**: Comment creation/editing - **MeetingCommentForm**: Comment creation/editing
### 4. Integration Testing ### 4. Integration Testing

View File

@ -28,13 +28,13 @@ from datetime import datetime, time, timedelta, date
from recruitment.models import ( from recruitment.models import (
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField, JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField,
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview, FormSubmission, FieldResponse, BulkInterviewTemplate, ScheduledInterview,
TrainingMaterial, Source, HiringAgency, Profile, MeetingComment, JobPostingImage, TrainingMaterial, Source, HiringAgency, Profile, MeetingComment, JobPostingImage,
BreakTime BreakTime
) )
from recruitment.forms import ( from recruitment.forms import (
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm, JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
CandidateStageForm, InterviewScheduleForm, BreakTimeFormSet CandidateStageForm, BulkInterviewTemplateForm, BreakTimeFormSet
) )
@ -185,7 +185,7 @@ def interview_schedule(staff_user, job):
) )
candidates.append(candidate) candidates.append(candidate)
return InterviewSchedule.objects.create( return BulkInterviewTemplate.objects.create(
job=job, job=job,
created_by=staff_user, created_by=staff_user,
start_date=date.today() + timedelta(days=1), 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.urls import reverse
from django.utils import timezone from django.utils import timezone
from .models import ( from .models import (
JobPosting, Application, TrainingMaterial, ZoomMeetingDetails, JobPosting, Application, TrainingMaterial,
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,JobPostingImage,InterviewNote, SharedFormTemplate, Source, HiringAgency, IntegrationLog,BulkInterviewTemplate,JobPostingImage,InterviewNote,
AgencyAccessLink, AgencyJobAssignment AgencyAccessLink, AgencyJobAssignment,Interview,ScheduledInterview
) )
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -158,27 +158,27 @@ class TrainingMaterialAdmin(admin.ModelAdmin):
save_on_top = True save_on_top = True
@admin.register(ZoomMeetingDetails) # @admin.register(ZoomMeetingDetails)
class ZoomMeetingAdmin(admin.ModelAdmin): # class ZoomMeetingAdmin(admin.ModelAdmin):
list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at'] # list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
list_filter = ['timezone', 'created_at'] # list_filter = ['timezone', 'created_at']
search_fields = ['topic', 'meeting_id'] # search_fields = ['topic', 'meeting_id']
readonly_fields = ['created_at', 'updated_at'] # readonly_fields = ['created_at', 'updated_at']
fieldsets = ( # fieldsets = (
('Meeting Details', { # ('Meeting Details', {
'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone','status') # 'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone','status')
}), # }),
('Meeting Settings', { # ('Meeting Settings', {
'fields': ('participant_video', 'join_before_host', 'mute_upon_entry', 'waiting_room') # 'fields': ('participant_video', 'join_before_host', 'mute_upon_entry', 'waiting_room')
}), # }),
('Access', { # ('Access', {
'fields': ('join_url',) # 'fields': ('join_url',)
}), # }),
('System Response', { # ('System Response', {
'fields': ('zoom_gateway_response', 'created_at', 'updated_at') # 'fields': ('zoom_gateway_response', 'created_at', 'updated_at')
}), # }),
) # )
save_on_top = True # save_on_top = True
# @admin.register(InterviewNote) # @admin.register(InterviewNote)
@ -241,9 +241,11 @@ admin.site.register(FormStage)
admin.site.register(Application) admin.site.register(Application)
admin.site.register(FormField) admin.site.register(FormField)
admin.site.register(FieldResponse) admin.site.register(FieldResponse)
admin.site.register(InterviewSchedule) admin.site.register(BulkInterviewTemplate)
admin.site.register(AgencyAccessLink) admin.site.register(AgencyAccessLink)
admin.site.register(AgencyJobAssignment) admin.site.register(AgencyJobAssignment)
admin.site.register(Interview)
admin.site.register(ScheduledInterview)
# AgencyMessage admin removed - model has been deleted # 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.models
import django.contrib.auth.validators import django.contrib.auth.validators
@ -49,16 +49,29 @@ class Migration(migrations.Migration):
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='InterviewLocation', name='Interview',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated 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')), ('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')), ('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')), ('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')), ('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={ options={
'verbose_name': 'Interview Location', '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')), ('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')), ('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')), ('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_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')), ('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')), ('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')), ('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')), ('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')), ('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')), ('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')), ('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( migrations.CreateModel(
name='OnsiteLocationDetails', name='InterviewNote',
fields=[ 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')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('physical_address', models.CharField(blank=True, max_length=255, null=True, verbose_name='Physical Address')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('room_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='Room Number/Name')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')), ('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')),
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)), ('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={ options={
'verbose_name': 'Onsite Location Details', 'verbose_name': 'Interview Note',
'verbose_name_plural': 'Onsite Location Details', '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( migrations.CreateModel(
name='JobPosting', 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')), ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('title', models.CharField(max_length=200)), ('title', models.CharField(max_length=200)),
('department', models.CharField(blank=True, max_length=100)), ('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)), ('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)), ('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_city', models.CharField(blank=True, max_length=100)),
('location_state', 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)), ('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_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)), ('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')), ('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')), ('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')), ('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')), ('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'], '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( migrations.CreateModel(
name='InterviewSchedule', name='BulkInterviewTemplate',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated 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')), ('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')), ('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
('end_date', models.DateField(db_index=True, verbose_name='End Date')), ('end_date', models.DateField(db_index=True, verbose_name='End Date')),
('working_days', models.JSONField(verbose_name='Working Days')), ('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)')), ('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
('applications', models.ManyToManyField(blank=True, related_name='interview_schedules', to='recruitment.application')), ('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)), ('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')), ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
], ],
options={ options={
'abstract': False, '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( migrations.AddField(
model_name='application', model_name='application',
name='job', name='job',
@ -474,7 +468,7 @@ class Migration(migrations.Migration):
('first_name', models.CharField(max_length=255, verbose_name='First Name')), ('first_name', models.CharField(max_length=255, verbose_name='First Name')),
('last_name', models.CharField(max_length=255, verbose_name='Last 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')), ('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')), ('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')), ('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')), ('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')), ('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)), ('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')), ('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')), ('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')), ('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)), ('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( migrations.CreateModel(
name='SharedFormTemplate', name='SharedFormTemplate',
fields=[ fields=[
@ -656,11 +632,6 @@ class Migration(migrations.Migration):
model_name='formsubmission', model_name='formsubmission',
index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'), 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( migrations.AddIndex(
model_name='formtemplate', model_name='formtemplate',
index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'), index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'),
@ -705,6 +676,14 @@ class Migration(migrations.Migration):
model_name='message', model_name='message',
index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'), 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( migrations.AddIndex(
model_name='person', model_name='person',
index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'), index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'),
@ -757,12 +736,4 @@ class Migration(migrations.Migration):
model_name='jobposting', model_name='jobposting',
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'), 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""" """Legacy compatibility - get scheduled interviews for this application"""
return self.scheduled_interviews.all() return self.scheduled_interviews.all()
@property # @property
def get_latest_meeting(self): # def get_latest_meeting(self):
""" # """
Retrieves the most specific location details (subclass instance) # Retrieves the most specific location details (subclass instance)
of the latest ScheduledInterview for this application, or None. # of the latest ScheduledInterview for this application, or None.
""" # """
# 1. Get the latest ScheduledInterview # # 1. Get the latest ScheduledInterview
schedule = self.scheduled_interviews.order_by("-created_at").first() # schedule = self.scheduled_interviews.order_by("-created_at").first()
# Check if a schedule exists and if it has an interview location # # Check if a schedule exists and if it has an interview location
if not schedule or not schedule.interview_location: # if not schedule or not schedule.interview_location:
return None # return None
# Get the base location instance # # Get the base location instance
interview_location = schedule.interview_location # 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 # # Determine the expected subclass accessor name based on the location_type
if interview_location.location_type == 'Remote': # if interview_location.location_type == 'Remote':
accessor_name = 'zoommeetingdetails' # accessor_name = 'zoommeetingdetails'
else: # Assumes 'Onsite' or any other type defaults to Onsite # else: # Assumes 'Onsite' or any other type defaults to Onsite
accessor_name = 'onsitelocationdetails' # accessor_name = 'onsitelocationdetails'
# Use getattr to safely retrieve the specific meeting object (subclass instance). # # 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), # # 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. # # 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) # meeting_details = getattr(interview_location, accessor_name, None)
return meeting_details # return meeting_details
@property @property
@ -1094,9 +1094,6 @@ class Application(Base):
class TrainingMaterial(Base): class TrainingMaterial(Base):
title = models.CharField(max_length=255, verbose_name=_("Title")) title = models.CharField(max_length=255, verbose_name=_("Title"))
content = CKEditor5Field( content = CKEditor5Field(
@ -1118,17 +1115,155 @@ class TrainingMaterial(Base):
return self.title return self.title
class InterviewLocation(Base): # class InterviewLocation(Base):
""" # """
Base model for all interview location/meeting details (remote or onsite) # Base model for all interview location/meeting details (remote or onsite)
using Multi-Table Inheritance. # 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): class LocationType(models.TextChoices):
REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)') REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)')
ONSITE = 'Onsite', _('In-Person (Physical Location)') ONSITE = 'Onsite', _('In-Person (Physical Location)')
class Status(models.TextChoices): class Status(models.TextChoices):
"""Defines the possible real-time statuses for any interview location/meeting."""
WAITING = "waiting", _("Waiting") WAITING = "waiting", _("Waiting")
STARTED = "started", _("Started") STARTED = "started", _("Started")
ENDED = "ended", _("Ended") ENDED = "ended", _("Ended")
@ -1141,137 +1276,73 @@ class InterviewLocation(Base):
db_index=True 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( details_url = models.URLField(
verbose_name=_("Meeting/Location URL"), verbose_name=_("Meeting/Location URL"),
max_length=2048, max_length=2048,
blank=True, blank=True,
null=True null=True
) )
timezone = models.CharField(max_length=50, verbose_name=_("Timezone"), default='UTC')
topic = models.CharField( # Renamed from 'description' to 'topic' to match your input start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time"))
max_length=255, duration = models.PositiveIntegerField(verbose_name=_("Duration (minutes)"))
verbose_name=_("Location/Meeting Topic"), status = models.CharField(
blank=True, max_length=20,
help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'") choices=Status.choices,
default=Status.WAITING,
db_index=True
) )
timezone = models.CharField( # Remote-specific (nullable)
max_length=50, meeting_id = models.CharField(
verbose_name=_("Timezone"), max_length=50, unique=True, null=True, blank=True, verbose_name=_("External Meeting ID")
default='UTC'
) )
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): def __str__(self):
# Use 'topic' instead of 'description'
return f"{self.get_location_type_display()} - {self.topic[:50]}" return f"{self.get_location_type_display()} - {self.topic[:50]}"
class Meta: class Meta:
verbose_name = _("Interview Location") verbose_name = _("Interview Location")
verbose_name_plural = _("Interview Locations") verbose_name_plural = _("Interview Locations")
def clean(self):
class ZoomMeetingDetails(InterviewLocation): # Optional: add validation
"""Concrete model for remote interviews (Zoom specifics).""" if self.location_type == self.LocationType.REMOTE:
if not self.details_url:
status = models.CharField( raise ValidationError(_("Remote interviews require a meeting URL."))
db_index=True, if not self.meeting_id:
max_length=20, raise ValidationError(_("Meeting ID is required for remote interviews."))
choices=InterviewLocation.Status.choices, elif self.location_type == self.LocationType.ONSITE:
default=InterviewLocation.Status.WAITING, if not (self.physical_address or self.room_number):
) raise ValidationError(_("Onsite interviews require at least an address or room."))
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")
# --- 2. Scheduling Models --- # --- 2. Scheduling Models ---
class InterviewSchedule(Base): class BulkInterviewTemplate(Base):
"""Stores the TEMPLATE criteria for BULK interview generation.""" """Stores the TEMPLATE criteria for BULK interview generation."""
# We need a field to store the template location details linked to this bulk schedule. # 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. # This location object contains the generic Zoom/Onsite info to be cloned.
template_location = models.ForeignKey( interview = models.ForeignKey(
InterviewLocation, Interview,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="schedule_templates", related_name="schedule_templates",
null=True, null=True,
@ -1279,15 +1350,6 @@ class InterviewSchedule(Base):
verbose_name=_("Location Template (Zoom/Onsite)") 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( job = models.ForeignKey(
JobPosting, JobPosting,
@ -1332,6 +1394,9 @@ class InterviewSchedule(Base):
class ScheduledInterview(Base): class ScheduledInterview(Base):
"""Stores individual scheduled interviews (whether bulk or individually created).""" """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): class InterviewStatus(models.TextChoices):
SCHEDULED = "scheduled", _("Scheduled") SCHEDULED = "scheduled", _("Scheduled")
@ -1353,19 +1418,19 @@ class ScheduledInterview(Base):
) )
# Links to the specific, individual location/meeting details for THIS interview # Links to the specific, individual location/meeting details for THIS interview
interview_location = models.OneToOneField( interview = models.OneToOneField(
InterviewLocation, Interview,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="scheduled_interview", related_name="scheduled_interview",
null=True, null=True,
blank=True, blank=True,
db_index=True, db_index=True,
verbose_name=_("Meeting/Location Details") verbose_name=_("Interview/Meeting")
) )
# Link back to the bulk schedule template (optional if individually created) # Link back to the bulk schedule template (optional if individually created)
schedule = models.ForeignKey( schedule = models.ForeignKey(
InterviewSchedule, BulkInterviewTemplate,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="interviews", related_name="interviews",
null=True, null=True,
@ -1378,7 +1443,11 @@ class ScheduledInterview(Base):
interview_date = models.DateField(db_index=True, verbose_name=_("Interview Date")) interview_date = models.DateField(db_index=True, verbose_name=_("Interview Date"))
interview_time = models.TimeField(verbose_name=_("Interview Time")) interview_time = models.TimeField(verbose_name=_("Interview Time"))
interview_type = models.CharField(
max_length=20,
choices=InterviewTypeChoice.choices,
default=InterviewTypeChoice.REMOTE
)
status = models.CharField( status = models.CharField(
db_index=True, db_index=True,
max_length=20, max_length=20,
@ -1420,7 +1489,7 @@ class InterviewNote(Base):
1 1
interview = models.ForeignKey( interview = models.ForeignKey(
ScheduledInterview, Interview,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="notes", related_name="notes",
verbose_name=_("Scheduled Interview"), verbose_name=_("Scheduled Interview"),
@ -2301,14 +2370,14 @@ class Notification(models.Model):
default=Status.PENDING, default=Status.PENDING,
verbose_name=_("Status"), verbose_name=_("Status"),
) )
related_meeting = models.ForeignKey( # related_meeting = models.ForeignKey(
ZoomMeetingDetails, # ZoomMeetingDetails,
on_delete=models.CASCADE, # on_delete=models.CASCADE,
related_name="notifications", # related_name="notifications",
null=True, # null=True,
blank=True, # blank=True,
verbose_name=_("Related Meeting"), # verbose_name=_("Related Meeting"),
) # )
scheduled_for = models.DateTimeField( scheduled_for = models.DateTimeField(
verbose_name=_("Scheduled Send Time"), verbose_name=_("Scheduled Send Time"),
help_text=_("The date and time this notification is scheduled to be sent."), 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 django.shortcuts import get_object_or_404
from . models import JobPosting from . models import JobPosting
from django.utils import timezone 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 from django.contrib.auth import get_user_model
User = get_user_model() User = get_user_model()
# Add python-docx import for Word document processing # 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. Synchronous task for a single interview slot, dispatched by django-q.
""" """
try: try:
candidate = Application.objects.get(pk=candidate_id) application = Application.objects.get(pk=candidate_id)
job = JobPosting.objects.get(pk=job_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)) 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) # 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) result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
if result["status"] == "success": if result["status"] == "success":
# 2. Database Writes (Slow) interview = Interview.objects.create(
zoom_meeting = ZoomMeetingDetails.objects.create(
topic=meeting_topic, topic=meeting_topic,
start_time=interview_datetime, start_time=interview_datetime,
duration=duration, duration=duration,
@ -703,14 +711,31 @@ def create_interview_and_meeting(
password=result["meeting_details"]["password"], password=result["meeting_details"]["password"],
location_type="Remote" location_type="Remote"
) )
ScheduledInterview.objects.create( schedule.interviews = interview
application=candidate, schedule.status = "Remote"
job=job,
interview_location=zoom_meeting, schedule.save()
schedule=schedule,
interview_date=slot_date, # 2. Database Writes (Slow)
interview_time=slot_time # 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 # Log success or use Django-Q result system for monitoring
logger.info(f"Successfully scheduled interview for {Application.name}") logger.info(f"Successfully scheduled interview for {Application.name}")
@ -745,7 +770,7 @@ def handle_zoom_webhook_event(payload):
try: try:
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet, # Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
# and to simplify the logic flow. # 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) print(meeting_instance)
# --- 1. Creation and Update Events --- # --- 1. Creation and Update Events ---
if event_type == 'meeting.updated': if event_type == 'meeting.updated':

View File

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

View File

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

View File

@ -207,21 +207,21 @@ urlpatterns = [
), ),
path( # path(
"jobs/<slug:slug>/<int:application_id>/reschedule_meeting_for_application/<int:meeting_id>/", # "jobs/<slug:slug>/<int:application_id>/reschedule_meeting_for_application/<int:meeting_id>/",
views.reschedule_meeting_for_application, # views.reschedule_meeting_for_application,
name="reschedule_meeting_for_application", # name="reschedule_meeting_for_application",
), # ),
path( path(
"jobs/<slug:slug>/update_application_exam_status/", "jobs/<slug:slug>/update_application_exam_status/",
views.update_application_exam_status, views.update_application_exam_status,
name="update_application_exam_status", name="update_application_exam_status",
), ),
path( # path(
"jobs/<slug:slug>/bulk_update_application_exam_status/", # "jobs/<slug:slug>/bulk_update_application_exam_status/",
views.bulk_update_application_exam_status, # views.bulk_update_application_exam_status,
name="bulk_update_application_exam_status", # name="bulk_update_application_exam_status",
), # ),
path( path(
"htmx/<int:pk>/application_criteria_view/", "htmx/<int:pk>/application_criteria_view/",
views.application_criteria_view_htmx, 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/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>/', views.load_form_template, name='load_form_template'),
# path('api/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'), # path('api/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'),
path( # path(
"jobs/<slug:slug>/calendar/", # "jobs/<slug:slug>/calendar/",
views.interview_calendar_view, # views.interview_calendar_view,
name="interview_calendar", # name="interview_calendar",
), # ),
path( # path(
"jobs/<slug:slug>/calendar/interview/<int:interview_id>/", # "jobs/<slug:slug>/calendar/interview/<int:interview_id>/",
views.interview_detail_view, # views.interview_detail_view,
name="interview_detail", # name="interview_detail",
), # ),
# users urls # users urls
path("user/<int:pk>", views.user_detail, name="user_detail"), path("user/<int:pk>", views.user_detail, name="user_detail"),
@ -333,26 +333,26 @@ urlpatterns = [
name="copy_to_clipboard", name="copy_to_clipboard",
), ),
# Meeting Comments URLs # Meeting Comments URLs
path( # path(
"meetings/<slug:slug>/comments/add/", # "meetings/<slug:slug>/comments/add/",
views.add_meeting_comment, # views.add_meeting_comment,
name="add_meeting_comment", # name="add_meeting_comment",
), # ),
path( # path(
"meetings/<slug:slug>/comments/<int:comment_id>/edit/", # "meetings/<slug:slug>/comments/<int:comment_id>/edit/",
views.edit_meeting_comment, # views.edit_meeting_comment,
name="edit_meeting_comment", # name="edit_meeting_comment",
), # ),
path( # path(
"meetings/<slug:slug>/comments/<int:comment_id>/delete/", # "meetings/<slug:slug>/comments/<int:comment_id>/delete/",
views.delete_meeting_comment, # views.delete_meeting_comment,
name="delete_meeting_comment", # name="delete_meeting_comment",
), # ),
path( # path(
"meetings/<slug:slug>/set_meeting_application/", # "meetings/<slug:slug>/set_meeting_application/",
views.set_meeting_application, # views.set_meeting_application,
name="set_meeting_application", # name="set_meeting_application",
), # ),
# Hiring Agency URLs # Hiring Agency URLs
path("agencies/", views.agency_list, name="agency_list"), path("agencies/", views.agency_list, name="agency_list"),
path("agencies/create/", views.agency_create, name="agency_create"), 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('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'), # path('api/notification-count/', views.api_notification_count, name='api_notification_count'),
# participants urls # participants urls
path( # path(
"participants/", # "participants/",
views_frontend.ParticipantsListView.as_view(), # views_frontend.ParticipantsListView.as_view(),
name="participants_list", # name="participants_list",
), # ),
path( # path(
"participants/create/", # "participants/create/",
views_frontend.ParticipantsCreateView.as_view(), # views_frontend.ParticipantsCreateView.as_view(),
name="participants_create", # name="participants_create",
), # ),
path( # path(
"participants/<slug:slug>/", # "participants/<slug:slug>/",
views_frontend.ParticipantsDetailView.as_view(), # views_frontend.ParticipantsDetailView.as_view(),
name="participants_detail", # name="participants_detail",
), # ),
path( # path(
"participants/<slug:slug>/update/", # "participants/<slug:slug>/update/",
views_frontend.ParticipantsUpdateView.as_view(), # views_frontend.ParticipantsUpdateView.as_view(),
name="participants_update", # name="participants_update",
), # ),
path( # path(
"participants/<slug:slug>/delete/", # "participants/<slug:slug>/delete/",
views_frontend.ParticipantsDeleteView.as_view(), # views_frontend.ParticipantsDeleteView.as_view(),
name="participants_delete", # name="participants_delete",
), # ),
# Email composition URLs # Email composition URLs
path( path(
"jobs/<slug:job_slug>/applications/compose-email/", "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("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('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/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'), # path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
# Candidate Signup # Candidate Signup
path('application/signup/<slug:template_slug>/', views.application_signup, name='application_signup'), path('application/signup/<slug:template_slug>/', views.application_signup, name='application_signup'),
# Password Reset # Password Reset
path('user/<int:pk>/password-reset/', views.portal_password_reset, name='portal_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) --- # # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
# path('interview/list/', views.interview_list, name='interview_list'), # path('interview/list/', views.interview_list, name='interview_list'),
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'), # 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'), # path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
#interview and meeting related urls #interview and meeting related urls
path( # path(
"jobs/<slug:slug>/schedule-interviews/", # "jobs/<slug:slug>/schedule-interviews/",
views.schedule_interviews_view, # views.schedule_interviews_view,
name="schedule_interviews", # name="schedule_interviews",
), # ),
path( # path(
"jobs/<slug:slug>/confirm-schedule-interviews/", # "jobs/<slug:slug>/confirm-schedule-interviews/",
views.confirm_schedule_interviews_view, # views.confirm_schedule_interviews_view,
name="confirm_schedule_interviews_view", # name="confirm_schedule_interviews_view",
), # ),
path( # path(
"meetings/create-meeting/", # "meetings/create-meeting/",
views.ZoomMeetingCreateView.as_view(), # views.ZoomMeetingCreateView.as_view(),
name="create_meeting", # name="create_meeting",
), # ),
# path( # path(
# "meetings/meeting-details/<slug:slug>/", # "meetings/meeting-details/<slug:slug>/",
# views.ZoomMeetingDetailsView.as_view(), # views.ZoomMeetingDetailsView.as_view(),
# name="meeting_details", # name="meeting_details",
# ), # ),
path( # path(
"meetings/update-meeting/<slug:slug>/", # "meetings/update-meeting/<slug:slug>/",
views.ZoomMeetingUpdateView.as_view(), # views.ZoomMeetingUpdateView.as_view(),
name="update_meeting", # name="update_meeting",
), # ),
path( # path(
"meetings/delete-meeting/<slug:slug>/", # "meetings/delete-meeting/<slug:slug>/",
views.ZoomMeetingDeleteView, # views.ZoomMeetingDeleteView,
name="delete_meeting", # name="delete_meeting",
), # ),
# Candidate Meeting Scheduling/Rescheduling URLs # Candidate Meeting Scheduling/Rescheduling URLs
path( # path(
"jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/", # "jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
views.schedule_application_meeting, # views.schedule_application_meeting,
name="schedule_application_meeting", # name="schedule_application_meeting",
), # ),
path( # path(
"api/jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/", # "api/jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
views.api_schedule_application_meeting, # views.api_schedule_application_meeting,
name="api_schedule_application_meeting", # name="api_schedule_application_meeting",
), # ),
path( # path(
"jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/", # "jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
views.reschedule_application_meeting, # views.reschedule_application_meeting,
name="reschedule_application_meeting", # name="reschedule_application_meeting",
), # ),
path( # path(
"api/jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/", # "api/jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
views.api_reschedule_application_meeting, # views.api_reschedule_application_meeting,
name="api_reschedule_application_meeting", # name="api_reschedule_application_meeting",
), # ),
# New URL for simple page-based meeting scheduling # New URL for simple page-based meeting scheduling
path( # path(
"jobs/<slug:slug>/applications/<int:application_pk>/schedule-meeting-page/", # "jobs/<slug:slug>/applications/<int:application_pk>/schedule-meeting-page/",
views.schedule_meeting_for_application, # views.schedule_meeting_for_application,
name="schedule_meeting_for_application", # name="schedule_meeting_for_application",
), # ),
# path( # path(
# "jobs/<slug:slug>/applications/<int:application_pk>/delete_meeting_for_application/<int:meeting_id>/", # "jobs/<slug:slug>/applications/<int:application_pk>/delete_meeting_for_application/<int:meeting_id>/",
# views.delete_meeting_for_candidate, # 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 # 1. Onsite Reschedule URL
path( # path(
'<slug:slug>/application/<int:application_id>/onsite/reschedule/<int:meeting_id>/', # '<slug:slug>/application/<int:application_id>/onsite/reschedule/<int:meeting_id>/',
views.reschedule_onsite_meeting, # views.reschedule_onsite_meeting,
name='reschedule_onsite_meeting' # name='reschedule_onsite_meeting'
), # ),
# 2. Onsite Delete URL # 2. Onsite Delete URL
path( # path(
'job/<slug:slug>/applications/<int:application_pk>/delete-onsite-meeting/<int:meeting_id>/', # 'job/<slug:slug>/applications/<int:application_pk>/delete-onsite-meeting/<int:meeting_id>/',
views.delete_onsite_meeting_for_application, # views.delete_onsite_meeting_for_application,
name='delete_onsite_meeting_for_application' # name='delete_onsite_meeting_for_application'
), # ),
path( # path(
'job/<slug:slug>/application/<int:application_pk>/schedule/onsite/', # 'job/<slug:slug>/application/<int:application_pk>/schedule/onsite/',
views.schedule_onsite_meeting_for_application, # views.schedule_onsite_meeting_for_application,
name='schedule_onsite_meeting_for_application' # This is the name used in the button # name='schedule_onsite_meeting_for_application' # This is the name used in the button
), # ),
# Detail View (assuming slug is on ScheduledInterview) # 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 # 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-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-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 django.db.models.fields.json import KeyTextTransform,KeyTransform
from recruitment.utils import json_to_markdown_table from recruitment.utils import json_to_markdown_table
from django.db.models import Count, Avg, F, FloatField 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 django.db.models.functions import Coalesce, Cast, Replace, NullIf
from . import models from . import models
from django.utils.translation import get_language from django.utils.translation import get_language
@ -1065,47 +1064,47 @@ def sync_history(request, job_slug=None):
#participants views #participants views
class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView): # class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
model = models.Participants # model = models.Participants
template_name = 'participants/participants_list.html' # template_name = 'participants/participants_list.html'
context_object_name = 'participants' # context_object_name = 'participants'
paginate_by = 10 # paginate_by = 10
def get_queryset(self): # def get_queryset(self):
queryset = super().get_queryset() # queryset = super().get_queryset()
# Handle search # # Handle search
search_query = self.request.GET.get('search', '') # search_query = self.request.GET.get('search', '')
if search_query: # if search_query:
queryset = queryset.filter( # queryset = queryset.filter(
Q(name__icontains=search_query) | # Q(name__icontains=search_query) |
Q(email__icontains=search_query) | # Q(email__icontains=search_query) |
Q(phone__icontains=search_query) | # Q(phone__icontains=search_query) |
Q(designation__icontains=search_query) # Q(designation__icontains=search_query)
) # )
# Filter for non-staff users # # Filter for non-staff users
if not self.request.user.is_staff: # if not self.request.user.is_staff:
return models.Participants.objects.none() # Restrict for non-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): # def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) # context = super().get_context_data(**kwargs)
context['search_query'] = self.request.GET.get('search', '') # context['search_query'] = self.request.GET.get('search', '')
return context # return context
class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView): # class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
model = models.Participants # model = models.Participants
template_name = 'participants/participants_detail.html' # template_name = 'participants/participants_detail.html'
context_object_name = 'participant' # context_object_name = 'participant'
slug_url_kwarg = 'slug' # slug_url_kwarg = 'slug'
class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView): # class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
model = models.Participants # model = models.Participants
form_class = forms.ParticipantsForm # form_class = forms.ParticipantsForm
template_name = 'participants/participants_create.html' # template_name = 'participants/participants_create.html'
success_url = reverse_lazy('job_list') # success_url = reverse_lazy('job_list')
success_message = 'Participant created successfully.' # success_message = 'Participant created successfully.'
# def get_initial(self): # def get_initial(self):
# initial = super().get_initial() # initial = super().get_initial()
@ -1116,17 +1115,17 @@ class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMess
class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView): # class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
model = models.Participants # model = models.Participants
form_class = forms.ParticipantsForm # form_class = forms.ParticipantsForm
template_name = 'participants/participants_create.html' # template_name = 'participants/participants_create.html'
success_url = reverse_lazy('job_list') # success_url = reverse_lazy('job_list')
success_message = 'Participant updated successfully.' # success_message = 'Participant updated successfully.'
slug_url_kwarg = 'slug' # slug_url_kwarg = 'slug'
class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView): # class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
model = models.Participants # model = models.Participants
success_url = reverse_lazy('participants_list') # Redirect to the participants list after success # success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
success_message = 'Participant deleted successfully.' # success_message = 'Participant deleted successfully.'
slug_url_kwarg = 'slug' # slug_url_kwarg = 'slug'

View File

@ -274,7 +274,7 @@
</a> </a>
</li> </li>
<li class="nav-item me-lg-4"> <li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == '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"> <span class="d-flex align-items-center gap-2">
<i class="fas fa-calendar-check me-2"></i> <i class="fas fa-calendar-check me-2"></i>
{% trans "Meetings" %} {% 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 %} {% load static i18n %}
{% block title %}{% trans "Scheduled Interviews List" %} - {{ block.super }}{% endblock %} {% block title %}{% trans "Interview Management" %} - ATS{% endblock %}
{% block customCSS %} {% block customCSS %}
{# (Your existing CSS is kept here, as it is perfect for the theme) #}
<style> <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> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{{interviews}}
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<!-- Header Section -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;"> <div>
<i class="fas fa-calendar-alt me-2"></i> {% trans "Scheduled Interviews" %} <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> </h1>
{# FIX: Using safe anchor href="#" to prevent the NoReverseMatch crash. #} <h2 class="h5 text-muted mb-0">
{# Replace '#' with {% url 'create_scheduled_interview' %} once the URL name is defined in urls.py #} {% trans "Total Interviews:" %} <span class="fw-bold">{{ interviews|length }}</span>
<a href="#" class="btn btn-main-action"> </h2>
<i class="fas fa-plus me-1"></i> {% trans "Schedule Interview" %} </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> </a>
</div> </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> </div>
{# Filter by Status #} <!-- Filter Controls -->
<div class="filter-controls">
<form method="get" class="row g-3">
<div class="col-md-3"> <div class="col-md-3">
<label for="status" class="form-label small text-muted">{% trans "Filter by Status" %}</label> <label for="job_filter" class="form-label form-label-sm">{% trans "Job" %}</label>
<select name="status" id="status" class="form-select form-select-sm"> <select name="job" id="job_filter" class="form-select form-select-sm">
<option value="">{% trans "All Statuses" %}</option> <option value="">{% trans "All Jobs" %}</option>
<option value="scheduled" {% if status_filter == 'scheduled' %}selected{% endif %}>{% trans "Scheduled" %}</option> {% for job in jobs %}
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>{% trans "Confirmed" %}</option> <option value="{{ job.id }}" {% if request.GET.job == job.id|stringformat:"s" %}selected{% endif %}>
<option value="completed" {% if status_filter == 'completed' %}selected{% endif %}>{% trans "Completed" %}</option> {{ job.title }}
<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 }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<div class="filter-buttons"> <label for="status_filter" class="form-label form-label-sm">{% trans "Status" %}</label>
<button type="submit" class="btn btn-main-action btn-sm"> <select name="status" id="status_filter" class="form-select form-select-sm">
<i class="fas fa-filter me-1"></i> {% trans "Apply" %} <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> </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"> <a href="{% url 'interview_list' %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-times me-1"></i> {% trans "Clear" %} <i class="fas fa-times me-1"></i> {% trans "Clear" %}
</a> </a>
{% endif %}
</div>
</div> </div>
</form> </form>
</div> </div>
</div>
{{meetings}} {{meetings}}
{# Using 'meetings' based on the context_object_name provided #} {# Using 'meetings' based on the context_object_name provided #}
{% if meetings %} {% if meetings %}
@ -149,70 +303,83 @@
{% endfor %} {% endfor %}
</div> </div>
{# Table View (Logic is identical, safe access applied) #} <form id="interview-form">
<div class="table-view"> {% csrf_token %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover"> <table class="table interview-table align-middle">
<thead> <thead>
<tr> <tr>
<th scope="col">{% trans "Candidate" %}</th> <th><i class="fas fa-user me-1"></i> {% trans "Candidate" %}</th>
<th scope="col">{% trans "Job" %}</th> <th><i class="fas fa-briefcase me-1"></i> {% trans "Job" %}</th>
<th scope="col">{% trans "Type" %}</th> <th><i class="fas fa-calendar me-1"></i> {% trans "Date & Time" %}</th>
<th scope="col">{% trans "Date/Time" %}</th> <th><i class="fas fa-tag me-1"></i> {% trans "Type" %}</th>
<th scope="col">{% trans "Duration" %}</th> <th><i class="fas fa-info-circle me-1"></i> {% trans "Status" %}</th>
<th scope="col">{% trans "Status" %}</th> {% comment %} <th><i class="fas fa-users me-1"></i> {% trans "Participants" %}</th> {% endcomment %}
<th scope="col" class="text-end">{% trans "Actions" %}</th> <th><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for interview in meetings %} {% for interview in interviews %}
<tr> <tr>
<td> <td>
<strong class="text-primary-theme"> <div class="candidate-name">{{ interview.application.name }}</div>
<a href="{% url 'application_detail' interview.candidate.slug %}" class="text-decoration-none text-primary-theme">{{ interview.candidate.name }}</a> <div class="application-details">
</strong> <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>
<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>
<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>
<td>{{ interview.interview_date|date:"M d, Y" }} <br>({{ interview.interview_time|time:"H:i" }})</td>
<td>{{ interview.schedule.interview_duration }} min</td>
<td> <td>
<span class="badge bg-{{ interview.status }}"> {% if interview.interview.location_type == 'Remote' %}
{% if interview.status == 'confirmed' %} <span class="badge interview-type-badge bg-remote">
<i class="fas fa-circle me-1 text-white"></i> <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 %} {% endif %}
{{ interview.status|title }} </td>
<td>
<span class="badge bg-primary-theme">
{{ interview.status|upper }}
</span> </span>
</td> </td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
{# CRITICAL FIX: Safe access to join URL #} <td>
{% if interview.schedule.interview_type == 'Remote' and interview.zoom_meeting and interview.zoom_meeting.join_url %} <div class="btn-group" role="group">
<a href="{{ interview.zoom_meeting.join_url }}" target="_blank" class="btn btn-main-action" title="{% trans 'Join' %}"> <a href="{% url 'interview_detail' interview.slug %}"
<i class="fas fa-sign-in-alt"></i> class="btn btn-outline-primary btn-sm"
</a> title="{% trans 'View Details' %}">
{% endif %}
<a href="{% url 'scheduled_interview_detail' interview.slug %}" class="btn btn-outline-primary" title="{% trans 'View' %}">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</a> </a>
<a href="{% url 'update_scheduled_interview' interview.slug %}" class="btn btn-outline-secondary" title="{% trans 'Update' %}"> {% comment %} {% if interview.status != 'CANCELLED' and interview.status != 'COMPLETED' %}
<i class="fas fa-edit"></i> <button type="button" class="btn btn-outline-secondary btn-sm"
</a>
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#meetingModal" data-bs-target="#actionModal"
hx-post="{% url 'delete_scheduled_interview' interview.slug %}" hx-get="#"
hx-target="#meetingModalBody" hx-target="#actionModalBody"
hx-swap="outerHTML" title="{% trans 'Reschedule' %}">
data-item-name="{{ interview.candidate.name }} Interview"> <i class="fas fa-redo-alt"></i>
<i class="fas fa-trash-alt"></i>
</button> </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> </div>
</td> </td>
</tr> </tr>
@ -220,49 +387,138 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </form>
</div>
{# Pagination #} <!-- Pagination -->
{% if is_paginated %} {% 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"> <ul class="pagination justify-content-center">
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
<li class="page-item"> <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>
<li class="page-item"> <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> </li>
{% endif %} {% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active"> <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> </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 %} {% if page_obj.has_next %}
<li class="page-item"> <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>
<li class="page-item"> <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> </li>
{% endif %} {% endif %}
</ul> </ul>
</nav> </nav>
{% endif %} {% endif %}
{% else %} {% else %}
<div class="text-center py-5 card shadow-sm"> <div class="alert alert-info text-center py-5" role="alert">
<div class="card-body"> <i class="fas fa-info-circle fa-2x mb-3"></i>
<i class="fas fa-calendar-alt fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i> <h5>{% trans "No interviews found" %}</h5>
<h3>{% trans "No Interviews found" %}</h3> <p class="text-muted mb-0">
<p class="text-muted">{% trans "Schedule your first interview or adjust your filters." %}</p> {% trans "There are no interviews matching your current filters." %}
{# FIX: Using safe anchor href="#" to prevent the NoReverseMatch crash. #} <a href="{% url 'interview_list' %}" class="alert-link">{% trans "Clear filters" %}</a>
<a href="#" class="btn btn-main-action mt-3"> {% trans "to see all interviews." %}
<i class="fas fa-plus me-1"></i> {% trans "Schedule an Interview" %} </p>
</a>
</div>
</div> </div>
{% endif %} {% endif %}
</div>
</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 %} {% 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 #} {# Separator (Vertical Rule) - Aligns automatically at the bottom with align-items-end #}
<div class="vr" style="height: 28px;"></div> <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" data-bs-toggle="modal"
hx-boost='true' hx-boost='true'
data-bs-target="#emailModal" data-bs-target="#emailModal"

View File

@ -229,7 +229,7 @@
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %} <i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button> </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" data-bs-toggle="modal"
hx-boost='true' hx-boost='true'
data-bs-target="#emailModal" data-bs-target="#emailModal"

View File

@ -252,7 +252,7 @@
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %} <i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button> </button>
{# email 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" data-bs-toggle="modal"
hx-boost='true' hx-boost='true'
data-bs-target="#emailModal" data-bs-target="#emailModal"

View File

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

View File

@ -231,7 +231,7 @@
{# Separator (Vertical Rule) #} {# Separator (Vertical Rule) #}
<div class="vr" style="height: 28px;"></div> <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" data-bs-toggle="modal"
hx-boost='true' hx-boost='true'
data-bs-target="#emailModal" data-bs-target="#emailModal"

View File

@ -344,7 +344,7 @@
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %} <i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button> </button>
{# email 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" data-bs-toggle="modal"
hx-boost='true' hx-boost='true'
data-bs-target="#emailModal" data-bs-target="#emailModal"