refactore interview
This commit is contained in:
parent
01fd4d4495
commit
1c0e0b0825
@ -354,7 +354,7 @@ class ScheduledInterview(Base):
|
||||
candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE, related_name="scheduled_interviews")
|
||||
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="scheduled_interviews")
|
||||
zoom_meeting = models.OneToOneField(ZoomMeeting, on_delete=models.CASCADE, related_name="interview")
|
||||
schedule = models.ForeignKey(InterviewSchedule, on_delete=models.CASCADE, related_name="interviews", null=True, blank=True)
|
||||
schedule = models.ForeignKey(BulkInterviewTemplate, on_delete=models.CASCADE, related_name="interviews", null=True, blank=True)
|
||||
interview_date = models.DateField()
|
||||
interview_time = models.TimeField()
|
||||
status = models.CharField(max_length=20, choices=[
|
||||
@ -365,9 +365,9 @@ class ScheduledInterview(Base):
|
||||
], default="scheduled")
|
||||
```
|
||||
|
||||
#### 2.2.11 InterviewSchedule Model
|
||||
#### 2.2.11 BulkInterviewTemplate Model
|
||||
```python
|
||||
class InterviewSchedule(Base):
|
||||
class BulkInterviewTemplate(Base):
|
||||
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="interview_schedules")
|
||||
candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True, null=True)
|
||||
start_date = models.DateField()
|
||||
@ -533,7 +533,7 @@ class CandidateService:
|
||||
|
||||
### 5.2 Interview Scheduling Logic
|
||||
```python
|
||||
class InterviewScheduler:
|
||||
class BulkInterviewTemplater:
|
||||
@staticmethod
|
||||
def get_available_slots(schedule, date):
|
||||
"""Get available interview slots for a specific date"""
|
||||
@ -915,7 +915,7 @@ class InterviewSchedulingTestCase(TestCase):
|
||||
phone="9876543210",
|
||||
job=self.job
|
||||
)
|
||||
self.schedule = InterviewSchedule.objects.create(
|
||||
self.schedule = BulkInterviewTemplate.objects.create(
|
||||
job=self.job,
|
||||
start_date=timezone.now().date(),
|
||||
end_date=timezone.now().date() + timedelta(days=7),
|
||||
@ -930,7 +930,7 @@ class InterviewSchedulingTestCase(TestCase):
|
||||
def test_interview_scheduling(self):
|
||||
"""Test interview scheduling process"""
|
||||
# Test slot availability
|
||||
available_slots = InterviewScheduler.get_available_slots(
|
||||
available_slots = BulkInterviewTemplater.get_available_slots(
|
||||
self.schedule,
|
||||
timezone.now().date()
|
||||
)
|
||||
@ -942,7 +942,7 @@ class InterviewSchedulingTestCase(TestCase):
|
||||
'start_time': timezone.now().time(),
|
||||
'duration': 60
|
||||
}
|
||||
interview = InterviewScheduler.schedule_interview(
|
||||
interview = BulkInterviewTemplater.schedule_interview(
|
||||
self.candidate,
|
||||
self.job,
|
||||
schedule_data
|
||||
|
||||
@ -86,7 +86,7 @@ The test suite aims for 80% code coverage. Coverage reports are generated in:
|
||||
- **Candidate**: Stage transitions, relationships
|
||||
- **ZoomMeeting**: Time validation, status handling
|
||||
- **FormTemplate**: Template integrity, field ordering
|
||||
- **InterviewSchedule**: Scheduling logic, slot generation
|
||||
- **BulkInterviewTemplate**: Scheduling logic, slot generation
|
||||
|
||||
### 2. View Testing
|
||||
- **Job Management**: CRUD operations, search, filtering
|
||||
@ -97,7 +97,7 @@ The test suite aims for 80% code coverage. Coverage reports are generated in:
|
||||
### 3. Form Testing
|
||||
- **JobPostingForm**: Complex validation, field dependencies
|
||||
- **CandidateForm**: File upload, validation
|
||||
- **InterviewScheduleForm**: Dynamic fields, validation
|
||||
- **BulkInterviewTemplateForm**: Dynamic fields, validation
|
||||
- **MeetingCommentForm**: Comment creation/editing
|
||||
|
||||
### 4. Integration Testing
|
||||
|
||||
@ -28,13 +28,13 @@ from datetime import datetime, time, timedelta, date
|
||||
|
||||
from recruitment.models import (
|
||||
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
||||
FormSubmission, FieldResponse, BulkInterviewTemplate, ScheduledInterview,
|
||||
TrainingMaterial, Source, HiringAgency, Profile, MeetingComment, JobPostingImage,
|
||||
BreakTime
|
||||
)
|
||||
from recruitment.forms import (
|
||||
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
|
||||
CandidateStageForm, InterviewScheduleForm, BreakTimeFormSet
|
||||
CandidateStageForm, BulkInterviewTemplateForm, BreakTimeFormSet
|
||||
)
|
||||
|
||||
|
||||
@ -185,7 +185,7 @@ def interview_schedule(staff_user, job):
|
||||
)
|
||||
candidates.append(candidate)
|
||||
|
||||
return InterviewSchedule.objects.create(
|
||||
return BulkInterviewTemplate.objects.create(
|
||||
job=job,
|
||||
created_by=staff_user,
|
||||
start_date=date.today() + timedelta(days=1),
|
||||
|
||||
@ -3,10 +3,10 @@ from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from .models import (
|
||||
JobPosting, Application, TrainingMaterial, ZoomMeetingDetails,
|
||||
JobPosting, Application, TrainingMaterial,
|
||||
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
|
||||
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,JobPostingImage,InterviewNote,
|
||||
AgencyAccessLink, AgencyJobAssignment
|
||||
SharedFormTemplate, Source, HiringAgency, IntegrationLog,BulkInterviewTemplate,JobPostingImage,InterviewNote,
|
||||
AgencyAccessLink, AgencyJobAssignment,Interview,ScheduledInterview
|
||||
)
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
@ -158,27 +158,27 @@ class TrainingMaterialAdmin(admin.ModelAdmin):
|
||||
save_on_top = True
|
||||
|
||||
|
||||
@admin.register(ZoomMeetingDetails)
|
||||
class ZoomMeetingAdmin(admin.ModelAdmin):
|
||||
list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
|
||||
list_filter = ['timezone', 'created_at']
|
||||
search_fields = ['topic', 'meeting_id']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
fieldsets = (
|
||||
('Meeting Details', {
|
||||
'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone','status')
|
||||
}),
|
||||
('Meeting Settings', {
|
||||
'fields': ('participant_video', 'join_before_host', 'mute_upon_entry', 'waiting_room')
|
||||
}),
|
||||
('Access', {
|
||||
'fields': ('join_url',)
|
||||
}),
|
||||
('System Response', {
|
||||
'fields': ('zoom_gateway_response', 'created_at', 'updated_at')
|
||||
}),
|
||||
)
|
||||
save_on_top = True
|
||||
# @admin.register(ZoomMeetingDetails)
|
||||
# class ZoomMeetingAdmin(admin.ModelAdmin):
|
||||
# list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
|
||||
# list_filter = ['timezone', 'created_at']
|
||||
# search_fields = ['topic', 'meeting_id']
|
||||
# readonly_fields = ['created_at', 'updated_at']
|
||||
# fieldsets = (
|
||||
# ('Meeting Details', {
|
||||
# 'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone','status')
|
||||
# }),
|
||||
# ('Meeting Settings', {
|
||||
# 'fields': ('participant_video', 'join_before_host', 'mute_upon_entry', 'waiting_room')
|
||||
# }),
|
||||
# ('Access', {
|
||||
# 'fields': ('join_url',)
|
||||
# }),
|
||||
# ('System Response', {
|
||||
# 'fields': ('zoom_gateway_response', 'created_at', 'updated_at')
|
||||
# }),
|
||||
# )
|
||||
# save_on_top = True
|
||||
|
||||
|
||||
# @admin.register(InterviewNote)
|
||||
@ -241,9 +241,11 @@ admin.site.register(FormStage)
|
||||
admin.site.register(Application)
|
||||
admin.site.register(FormField)
|
||||
admin.site.register(FieldResponse)
|
||||
admin.site.register(InterviewSchedule)
|
||||
admin.site.register(BulkInterviewTemplate)
|
||||
admin.site.register(AgencyAccessLink)
|
||||
admin.site.register(AgencyJobAssignment)
|
||||
admin.site.register(Interview)
|
||||
admin.site.register(ScheduledInterview)
|
||||
# AgencyMessage admin removed - model has been deleted
|
||||
|
||||
|
||||
|
||||
1279
recruitment/forms.py
1279
recruitment/forms.py
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-17 09:52
|
||||
# Generated by Django 5.2.6 on 2025-11-26 11:13
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
@ -49,16 +49,29 @@ class Migration(migrations.Migration):
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InterviewLocation',
|
||||
name='Interview',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('location_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], db_index=True, max_length=10, verbose_name='Location Type')),
|
||||
('topic', models.CharField(blank=True, help_text="e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room'", max_length=255, verbose_name='Meeting/Location Topic')),
|
||||
('details_url', models.URLField(blank=True, max_length=2048, null=True, verbose_name='Meeting/Location URL')),
|
||||
('topic', models.CharField(blank=True, help_text="e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'", max_length=255, verbose_name='Location/Meeting Topic')),
|
||||
('timezone', models.CharField(default='UTC', max_length=50, verbose_name='Timezone')),
|
||||
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
|
||||
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
|
||||
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
|
||||
('meeting_id', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='External Meeting ID')),
|
||||
('password', models.CharField(blank=True, max_length=20, null=True)),
|
||||
('zoom_gateway_response', models.JSONField(blank=True, null=True)),
|
||||
('participant_video', models.BooleanField(default=True)),
|
||||
('join_before_host', models.BooleanField(default=False)),
|
||||
('host_email', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('mute_upon_entry', models.BooleanField(default=False)),
|
||||
('waiting_room', models.BooleanField(default=False)),
|
||||
('physical_address', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('room_number', models.CharField(blank=True, max_length=50, null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Interview Location',
|
||||
@ -121,7 +134,6 @@ class Migration(migrations.Migration):
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
@ -129,6 +141,7 @@ class Migration(migrations.Migration):
|
||||
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
|
||||
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')),
|
||||
('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')),
|
||||
('email', models.EmailField(error_messages={'unique': 'A user with this email already exists.'}, max_length=254, unique=True)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
@ -266,42 +279,22 @@ class Migration(migrations.Migration):
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OnsiteLocationDetails',
|
||||
name='InterviewNote',
|
||||
fields=[
|
||||
('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')),
|
||||
('physical_address', models.CharField(blank=True, max_length=255, null=True, verbose_name='Physical Address')),
|
||||
('room_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='Room Number/Name')),
|
||||
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
|
||||
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
|
||||
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')),
|
||||
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content/Feedback')),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_notes', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
|
||||
('interview', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.interview', verbose_name='Scheduled Interview')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Onsite Location Details',
|
||||
'verbose_name_plural': 'Onsite Location Details',
|
||||
'verbose_name': 'Interview Note',
|
||||
'verbose_name_plural': 'Interview Notes',
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
bases=('recruitment.interviewlocation',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ZoomMeetingDetails',
|
||||
fields=[
|
||||
('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')),
|
||||
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
|
||||
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
|
||||
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
|
||||
('meeting_id', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='External Meeting ID')),
|
||||
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
|
||||
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
|
||||
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
|
||||
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
|
||||
('host_email', models.CharField(blank=True, null=True)),
|
||||
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
|
||||
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Zoom Meeting Details',
|
||||
'verbose_name_plural': 'Zoom Meeting Details',
|
||||
},
|
||||
bases=('recruitment.interviewlocation',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='JobPosting',
|
||||
@ -312,8 +305,8 @@ class Migration(migrations.Migration):
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('department', models.CharField(blank=True, max_length=100)),
|
||||
('job_type', models.CharField(choices=[('Full-time', 'Full-time'), ('Part-time', 'Part-time'), ('Contract', 'Contract'), ('Internship', 'Internship'), ('Faculty', 'Faculty'), ('Temporary', 'Temporary')], default='FULL_TIME', max_length=20)),
|
||||
('workplace_type', models.CharField(choices=[('On-site', 'On-site'), ('Remote', 'Remote'), ('Hybrid', 'Hybrid')], default='ON_SITE', max_length=20)),
|
||||
('job_type', models.CharField(choices=[('Full-time', 'Full-time'), ('Part-time', 'Part-time'), ('Contract', 'Contract'), ('Internship', 'Internship'), ('Faculty', 'Faculty'), ('Temporary', 'Temporary')], default='Full-time', max_length=20)),
|
||||
('workplace_type', models.CharField(choices=[('On-site', 'On-site'), ('Remote', 'Remote'), ('Hybrid', 'Hybrid')], default='On-site', max_length=20)),
|
||||
('location_city', models.CharField(blank=True, max_length=100)),
|
||||
('location_state', models.CharField(blank=True, max_length=100)),
|
||||
('location_country', models.CharField(default='Saudia Arabia', max_length=100)),
|
||||
@ -343,6 +336,8 @@ class Migration(migrations.Migration):
|
||||
('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
|
||||
('cancelled_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ai_parsed', models.BooleanField(default=False, help_text='Whether the job posting has been parsed by AI', verbose_name='AI Parsed')),
|
||||
('cv_zip_file', models.FileField(blank=True, null=True, upload_to='job_zips/')),
|
||||
('zip_created', models.BooleanField(default=False)),
|
||||
('assigned_to', models.ForeignKey(blank=True, help_text='The user who has been assigned to this job', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_jobs', to=settings.AUTH_USER_MODEL, verbose_name='Assigned To')),
|
||||
('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
|
||||
('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')),
|
||||
@ -353,14 +348,18 @@ class Migration(migrations.Migration):
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='formtemplate',
|
||||
name='job',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InterviewSchedule',
|
||||
name='BulkInterviewTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('schedule_interview_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], default='Remote', max_length=10, verbose_name='Interview Type')),
|
||||
('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
|
||||
('end_date', models.DateField(db_index=True, verbose_name='End Date')),
|
||||
('working_days', models.JSONField(verbose_name='Working Days')),
|
||||
@ -372,18 +371,13 @@ class Migration(migrations.Migration):
|
||||
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
|
||||
('applications', models.ManyToManyField(blank=True, related_name='interview_schedules', to='recruitment.application')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('template_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schedule_templates', to='recruitment.interviewlocation', verbose_name='Location Template (Zoom/Onsite)')),
|
||||
('interview', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schedule_templates', to='recruitment.interview', verbose_name='Location Template (Zoom/Onsite)')),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='formtemplate',
|
||||
name='job',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='application',
|
||||
name='job',
|
||||
@ -474,7 +468,7 @@ class Migration(migrations.Migration):
|
||||
('first_name', models.CharField(max_length=255, verbose_name='First Name')),
|
||||
('last_name', models.CharField(max_length=255, verbose_name='Last Name')),
|
||||
('middle_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Middle Name')),
|
||||
('email', models.EmailField(db_index=True, help_text='Unique email address for the person', max_length=254, unique=True, verbose_name='Email')),
|
||||
('email', models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='Email')),
|
||||
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
|
||||
('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')),
|
||||
('gender', models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female')], max_length=1, null=True, verbose_name='Gender')),
|
||||
@ -507,31 +501,13 @@ class Migration(migrations.Migration):
|
||||
('interview_time', models.TimeField(verbose_name='Interview Time')),
|
||||
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
|
||||
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')),
|
||||
('interview_location', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='scheduled_interview', to='recruitment.interviewlocation', verbose_name='Meeting/Location Details')),
|
||||
('interview', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interview', to='recruitment.interview', verbose_name='Meeting/Location Details')),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
|
||||
('participants', models.ManyToManyField(blank=True, to='recruitment.participants')),
|
||||
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interviews', to='recruitment.interviewschedule')),
|
||||
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interviews', to='recruitment.bulkinterviewtemplate')),
|
||||
('system_users', models.ManyToManyField(blank=True, related_name='attended_interviews', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InterviewNote',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')),
|
||||
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content/Feedback')),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_notes', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
|
||||
('interview', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.scheduledinterview', verbose_name='Scheduled Interview')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Interview Note',
|
||||
'verbose_name_plural': 'Interview Notes',
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SharedFormTemplate',
|
||||
fields=[
|
||||
@ -656,11 +632,6 @@ class Migration(migrations.Migration):
|
||||
model_name='formsubmission',
|
||||
index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='related_meeting',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.zoommeetingdetails', verbose_name='Related Meeting'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='formtemplate',
|
||||
index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'),
|
||||
@ -705,6 +676,14 @@ class Migration(migrations.Migration):
|
||||
model_name='message',
|
||||
index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='person',
|
||||
index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'),
|
||||
@ -757,12 +736,4 @@ class Migration(migrations.Migration):
|
||||
model_name='jobposting',
|
||||
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'),
|
||||
),
|
||||
]
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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)'),
|
||||
),
|
||||
]
|
||||
@ -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)'),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -995,36 +995,36 @@ class Application(Base):
|
||||
"""Legacy compatibility - get scheduled interviews for this application"""
|
||||
return self.scheduled_interviews.all()
|
||||
|
||||
@property
|
||||
def get_latest_meeting(self):
|
||||
"""
|
||||
Retrieves the most specific location details (subclass instance)
|
||||
of the latest ScheduledInterview for this application, or None.
|
||||
"""
|
||||
# 1. Get the latest ScheduledInterview
|
||||
schedule = self.scheduled_interviews.order_by("-created_at").first()
|
||||
# @property
|
||||
# def get_latest_meeting(self):
|
||||
# """
|
||||
# Retrieves the most specific location details (subclass instance)
|
||||
# of the latest ScheduledInterview for this application, or None.
|
||||
# """
|
||||
# # 1. Get the latest ScheduledInterview
|
||||
# schedule = self.scheduled_interviews.order_by("-created_at").first()
|
||||
|
||||
# Check if a schedule exists and if it has an interview location
|
||||
if not schedule or not schedule.interview_location:
|
||||
return None
|
||||
# # Check if a schedule exists and if it has an interview location
|
||||
# if not schedule or not schedule.interview_location:
|
||||
# return None
|
||||
|
||||
# Get the base location instance
|
||||
interview_location = schedule.interview_location
|
||||
# # Get the base location instance
|
||||
# interview_location = schedule.interview_location
|
||||
|
||||
# 2. Safely retrieve the specific subclass details
|
||||
# # 2. Safely retrieve the specific subclass details
|
||||
|
||||
# Determine the expected subclass accessor name based on the location_type
|
||||
if interview_location.location_type == 'Remote':
|
||||
accessor_name = 'zoommeetingdetails'
|
||||
else: # Assumes 'Onsite' or any other type defaults to Onsite
|
||||
accessor_name = 'onsitelocationdetails'
|
||||
# # Determine the expected subclass accessor name based on the location_type
|
||||
# if interview_location.location_type == 'Remote':
|
||||
# accessor_name = 'zoommeetingdetails'
|
||||
# else: # Assumes 'Onsite' or any other type defaults to Onsite
|
||||
# accessor_name = 'onsitelocationdetails'
|
||||
|
||||
# Use getattr to safely retrieve the specific meeting object (subclass instance).
|
||||
# If the accessor exists but points to None (because the subclass record was deleted),
|
||||
# or if the accessor name is wrong for the object's true type, it will return None.
|
||||
meeting_details = getattr(interview_location, accessor_name, None)
|
||||
# # Use getattr to safely retrieve the specific meeting object (subclass instance).
|
||||
# # If the accessor exists but points to None (because the subclass record was deleted),
|
||||
# # or if the accessor name is wrong for the object's true type, it will return None.
|
||||
# meeting_details = getattr(interview_location, accessor_name, None)
|
||||
|
||||
return meeting_details
|
||||
# return meeting_details
|
||||
|
||||
|
||||
@property
|
||||
@ -1094,9 +1094,6 @@ class Application(Base):
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TrainingMaterial(Base):
|
||||
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||
content = CKEditor5Field(
|
||||
@ -1118,17 +1115,155 @@ class TrainingMaterial(Base):
|
||||
return self.title
|
||||
|
||||
|
||||
class InterviewLocation(Base):
|
||||
"""
|
||||
Base model for all interview location/meeting details (remote or onsite)
|
||||
using Multi-Table Inheritance.
|
||||
"""
|
||||
# class InterviewLocation(Base):
|
||||
# """
|
||||
# Base model for all interview location/meeting details (remote or onsite)
|
||||
# using Multi-Table Inheritance.
|
||||
# """
|
||||
# class LocationType(models.TextChoices):
|
||||
# REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)')
|
||||
# ONSITE = 'Onsite', _('In-Person (Physical Location)')
|
||||
|
||||
# class Status(models.TextChoices):
|
||||
# """Defines the possible real-time statuses for any interview location/meeting."""
|
||||
# WAITING = "waiting", _("Waiting")
|
||||
# STARTED = "started", _("Started")
|
||||
# ENDED = "ended", _("Ended")
|
||||
# CANCELLED = "cancelled", _("Cancelled")
|
||||
|
||||
# location_type = models.CharField(
|
||||
# max_length=10,
|
||||
# choices=LocationType.choices,
|
||||
# verbose_name=_("Location Type"),
|
||||
# db_index=True
|
||||
# )
|
||||
|
||||
# details_url = models.URLField(
|
||||
# verbose_name=_("Meeting/Location URL"),
|
||||
# max_length=2048,
|
||||
# blank=True,
|
||||
# null=True
|
||||
# )
|
||||
|
||||
# topic = models.CharField( # Renamed from 'description' to 'topic' to match your input
|
||||
# max_length=255,
|
||||
# verbose_name=_("Location/Meeting Topic"),
|
||||
# blank=True,
|
||||
# help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'")
|
||||
# )
|
||||
|
||||
# timezone = models.CharField(
|
||||
# max_length=50,
|
||||
# verbose_name=_("Timezone"),
|
||||
# default='UTC'
|
||||
# )
|
||||
|
||||
# def __str__(self):
|
||||
# # Use 'topic' instead of 'description'
|
||||
# return f"{self.get_location_type_display()} - {self.topic[:50]}"
|
||||
|
||||
# class Meta:
|
||||
# verbose_name = _("Interview Location")
|
||||
# verbose_name_plural = _("Interview Locations")
|
||||
|
||||
|
||||
# class ZoomMeetingDetails(InterviewLocation):
|
||||
# """Concrete model for remote interviews (Zoom specifics)."""
|
||||
|
||||
# status = models.CharField(
|
||||
# db_index=True,
|
||||
# max_length=20,
|
||||
# choices=InterviewLocation.Status.choices,
|
||||
# default=InterviewLocation.Status.WAITING,
|
||||
# )
|
||||
# start_time = models.DateTimeField(
|
||||
# db_index=True, verbose_name=_("Start Time")
|
||||
# )
|
||||
# duration = models.PositiveIntegerField(
|
||||
# verbose_name=_("Duration (minutes)")
|
||||
# )
|
||||
# meeting_id = models.CharField(
|
||||
# db_index=True,
|
||||
# max_length=50,
|
||||
# unique=True,
|
||||
# verbose_name=_("External Meeting ID")
|
||||
# )
|
||||
# password = models.CharField(
|
||||
# max_length=20, blank=True, null=True, verbose_name=_("Password")
|
||||
# )
|
||||
# zoom_gateway_response = models.JSONField(
|
||||
# blank=True, null=True, verbose_name=_("Zoom Gateway Response")
|
||||
# )
|
||||
# participant_video = models.BooleanField(
|
||||
# default=True, verbose_name=_("Participant Video")
|
||||
# )
|
||||
# join_before_host = models.BooleanField(
|
||||
# default=False, verbose_name=_("Join Before Host")
|
||||
# )
|
||||
|
||||
# host_email=models.CharField(null=True,blank=True)
|
||||
# mute_upon_entry = models.BooleanField(
|
||||
# default=False, verbose_name=_("Mute Upon Entry")
|
||||
# )
|
||||
# waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room"))
|
||||
|
||||
# # *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
|
||||
# # @classmethod
|
||||
# # def create(cls, **kwargs):
|
||||
# # """Factory method to ensure location_type is set to REMOTE."""
|
||||
# # return cls(location_type=InterviewLocation.LocationType.REMOTE, **kwargs)
|
||||
|
||||
# class Meta:
|
||||
# verbose_name = _("Zoom Meeting Details")
|
||||
# verbose_name_plural = _("Zoom Meeting Details")
|
||||
|
||||
|
||||
# class OnsiteLocationDetails(InterviewLocation):
|
||||
# """Concrete model for onsite interviews (Room/Address specifics)."""
|
||||
|
||||
# physical_address = models.CharField(
|
||||
# max_length=255,
|
||||
# verbose_name=_("Physical Address"),
|
||||
# blank=True,
|
||||
# null=True
|
||||
# )
|
||||
# room_number = models.CharField(
|
||||
# max_length=50,
|
||||
# verbose_name=_("Room Number/Name"),
|
||||
# blank=True,
|
||||
# null=True
|
||||
# )
|
||||
# start_time = models.DateTimeField(
|
||||
# db_index=True, verbose_name=_("Start Time")
|
||||
# )
|
||||
# duration = models.PositiveIntegerField(
|
||||
# verbose_name=_("Duration (minutes)")
|
||||
# )
|
||||
# status = models.CharField(
|
||||
# db_index=True,
|
||||
# max_length=20,
|
||||
# choices=InterviewLocation.Status.choices,
|
||||
# default=InterviewLocation.Status.WAITING,
|
||||
# )
|
||||
|
||||
# # *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
|
||||
# # @classmethod
|
||||
# # def create(cls, **kwargs):
|
||||
# # """Factory method to ensure location_type is set to ONSITE."""
|
||||
# # return cls(location_type=InterviewLocation.LocationType.ONSITE, **kwargs)
|
||||
|
||||
# class Meta:
|
||||
# verbose_name = _("Onsite Location Details")
|
||||
# verbose_name_plural = _("Onsite Location Details")
|
||||
|
||||
|
||||
|
||||
class Interview(Base):
|
||||
class LocationType(models.TextChoices):
|
||||
REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)')
|
||||
ONSITE = 'Onsite', _('In-Person (Physical Location)')
|
||||
|
||||
class Status(models.TextChoices):
|
||||
"""Defines the possible real-time statuses for any interview location/meeting."""
|
||||
WAITING = "waiting", _("Waiting")
|
||||
STARTED = "started", _("Started")
|
||||
ENDED = "ended", _("Ended")
|
||||
@ -1141,137 +1276,73 @@ class InterviewLocation(Base):
|
||||
db_index=True
|
||||
)
|
||||
|
||||
# Common fields
|
||||
topic = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Meeting/Location Topic"),
|
||||
blank=True,
|
||||
help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room'")
|
||||
)
|
||||
details_url = models.URLField(
|
||||
verbose_name=_("Meeting/Location URL"),
|
||||
max_length=2048,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
topic = models.CharField( # Renamed from 'description' to 'topic' to match your input
|
||||
max_length=255,
|
||||
verbose_name=_("Location/Meeting Topic"),
|
||||
blank=True,
|
||||
help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'")
|
||||
timezone = models.CharField(max_length=50, verbose_name=_("Timezone"), default='UTC')
|
||||
start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time"))
|
||||
duration = models.PositiveIntegerField(verbose_name=_("Duration (minutes)"))
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.WAITING,
|
||||
db_index=True
|
||||
)
|
||||
|
||||
timezone = models.CharField(
|
||||
max_length=50,
|
||||
verbose_name=_("Timezone"),
|
||||
default='UTC'
|
||||
# Remote-specific (nullable)
|
||||
meeting_id = models.CharField(
|
||||
max_length=50, unique=True, null=True, blank=True, verbose_name=_("External Meeting ID")
|
||||
)
|
||||
password = models.CharField(max_length=20, blank=True, null=True)
|
||||
zoom_gateway_response = models.JSONField(blank=True, null=True)
|
||||
participant_video = models.BooleanField(default=True)
|
||||
join_before_host = models.BooleanField(default=False)
|
||||
host_email = models.CharField(max_length=255, blank=True, null=True)
|
||||
mute_upon_entry = models.BooleanField(default=False)
|
||||
waiting_room = models.BooleanField(default=False)
|
||||
|
||||
# Onsite-specific (nullable)
|
||||
physical_address = models.CharField(max_length=255, blank=True, null=True)
|
||||
room_number = models.CharField(max_length=50, blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
# Use 'topic' instead of 'description'
|
||||
return f"{self.get_location_type_display()} - {self.topic[:50]}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Interview Location")
|
||||
verbose_name_plural = _("Interview Locations")
|
||||
|
||||
|
||||
class ZoomMeetingDetails(InterviewLocation):
|
||||
"""Concrete model for remote interviews (Zoom specifics)."""
|
||||
|
||||
status = models.CharField(
|
||||
db_index=True,
|
||||
max_length=20,
|
||||
choices=InterviewLocation.Status.choices,
|
||||
default=InterviewLocation.Status.WAITING,
|
||||
)
|
||||
start_time = models.DateTimeField(
|
||||
db_index=True, verbose_name=_("Start Time")
|
||||
)
|
||||
duration = models.PositiveIntegerField(
|
||||
verbose_name=_("Duration (minutes)")
|
||||
)
|
||||
meeting_id = models.CharField(
|
||||
db_index=True,
|
||||
max_length=50,
|
||||
unique=True,
|
||||
verbose_name=_("External Meeting ID")
|
||||
)
|
||||
password = models.CharField(
|
||||
max_length=20, blank=True, null=True, verbose_name=_("Password")
|
||||
)
|
||||
zoom_gateway_response = models.JSONField(
|
||||
blank=True, null=True, verbose_name=_("Zoom Gateway Response")
|
||||
)
|
||||
participant_video = models.BooleanField(
|
||||
default=True, verbose_name=_("Participant Video")
|
||||
)
|
||||
join_before_host = models.BooleanField(
|
||||
default=False, verbose_name=_("Join Before Host")
|
||||
)
|
||||
|
||||
host_email=models.CharField(null=True,blank=True)
|
||||
mute_upon_entry = models.BooleanField(
|
||||
default=False, verbose_name=_("Mute Upon Entry")
|
||||
)
|
||||
waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room"))
|
||||
|
||||
# *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
|
||||
# @classmethod
|
||||
# def create(cls, **kwargs):
|
||||
# """Factory method to ensure location_type is set to REMOTE."""
|
||||
# return cls(location_type=InterviewLocation.LocationType.REMOTE, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Zoom Meeting Details")
|
||||
verbose_name_plural = _("Zoom Meeting Details")
|
||||
|
||||
|
||||
class OnsiteLocationDetails(InterviewLocation):
|
||||
"""Concrete model for onsite interviews (Room/Address specifics)."""
|
||||
|
||||
physical_address = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Physical Address"),
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
room_number = models.CharField(
|
||||
max_length=50,
|
||||
verbose_name=_("Room Number/Name"),
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
start_time = models.DateTimeField(
|
||||
db_index=True, verbose_name=_("Start Time")
|
||||
)
|
||||
duration = models.PositiveIntegerField(
|
||||
verbose_name=_("Duration (minutes)")
|
||||
)
|
||||
status = models.CharField(
|
||||
db_index=True,
|
||||
max_length=20,
|
||||
choices=InterviewLocation.Status.choices,
|
||||
default=InterviewLocation.Status.WAITING,
|
||||
)
|
||||
|
||||
# *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
|
||||
# @classmethod
|
||||
# def create(cls, **kwargs):
|
||||
# """Factory method to ensure location_type is set to ONSITE."""
|
||||
# return cls(location_type=InterviewLocation.LocationType.ONSITE, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Onsite Location Details")
|
||||
verbose_name_plural = _("Onsite Location Details")
|
||||
|
||||
|
||||
|
||||
def clean(self):
|
||||
# Optional: add validation
|
||||
if self.location_type == self.LocationType.REMOTE:
|
||||
if not self.details_url:
|
||||
raise ValidationError(_("Remote interviews require a meeting URL."))
|
||||
if not self.meeting_id:
|
||||
raise ValidationError(_("Meeting ID is required for remote interviews."))
|
||||
elif self.location_type == self.LocationType.ONSITE:
|
||||
if not (self.physical_address or self.room_number):
|
||||
raise ValidationError(_("Onsite interviews require at least an address or room."))
|
||||
|
||||
|
||||
# --- 2. Scheduling Models ---
|
||||
|
||||
class InterviewSchedule(Base):
|
||||
class BulkInterviewTemplate(Base):
|
||||
"""Stores the TEMPLATE criteria for BULK interview generation."""
|
||||
|
||||
# We need a field to store the template location details linked to this bulk schedule.
|
||||
# This location object contains the generic Zoom/Onsite info to be cloned.
|
||||
template_location = models.ForeignKey(
|
||||
InterviewLocation,
|
||||
interview = models.ForeignKey(
|
||||
Interview,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="schedule_templates",
|
||||
null=True,
|
||||
@ -1279,15 +1350,6 @@ class InterviewSchedule(Base):
|
||||
verbose_name=_("Location Template (Zoom/Onsite)")
|
||||
)
|
||||
|
||||
# NOTE: schedule_interview_type field is needed in the form,
|
||||
# but not on the model itself if we use template_location.
|
||||
# If you want to keep it:
|
||||
schedule_interview_type = models.CharField(
|
||||
max_length=10,
|
||||
choices=InterviewLocation.LocationType.choices,
|
||||
verbose_name=_("Interview Type"),
|
||||
default=InterviewLocation.LocationType.REMOTE
|
||||
)
|
||||
|
||||
job = models.ForeignKey(
|
||||
JobPosting,
|
||||
@ -1332,6 +1394,9 @@ class InterviewSchedule(Base):
|
||||
|
||||
class ScheduledInterview(Base):
|
||||
"""Stores individual scheduled interviews (whether bulk or individually created)."""
|
||||
class InterviewTypeChoice(models.TextChoices):
|
||||
REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)')
|
||||
ONSITE = 'Onsite', _('In-Person (Physical Location)')
|
||||
|
||||
class InterviewStatus(models.TextChoices):
|
||||
SCHEDULED = "scheduled", _("Scheduled")
|
||||
@ -1353,19 +1418,19 @@ class ScheduledInterview(Base):
|
||||
)
|
||||
|
||||
# Links to the specific, individual location/meeting details for THIS interview
|
||||
interview_location = models.OneToOneField(
|
||||
InterviewLocation,
|
||||
interview = models.OneToOneField(
|
||||
Interview,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="scheduled_interview",
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
verbose_name=_("Meeting/Location Details")
|
||||
verbose_name=_("Interview/Meeting")
|
||||
)
|
||||
|
||||
# Link back to the bulk schedule template (optional if individually created)
|
||||
schedule = models.ForeignKey(
|
||||
InterviewSchedule,
|
||||
BulkInterviewTemplate,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="interviews",
|
||||
null=True,
|
||||
@ -1378,7 +1443,11 @@ class ScheduledInterview(Base):
|
||||
|
||||
interview_date = models.DateField(db_index=True, verbose_name=_("Interview Date"))
|
||||
interview_time = models.TimeField(verbose_name=_("Interview Time"))
|
||||
|
||||
interview_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=InterviewTypeChoice.choices,
|
||||
default=InterviewTypeChoice.REMOTE
|
||||
)
|
||||
status = models.CharField(
|
||||
db_index=True,
|
||||
max_length=20,
|
||||
@ -1420,7 +1489,7 @@ class InterviewNote(Base):
|
||||
|
||||
1
|
||||
interview = models.ForeignKey(
|
||||
ScheduledInterview,
|
||||
Interview,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notes",
|
||||
verbose_name=_("Scheduled Interview"),
|
||||
@ -2301,14 +2370,14 @@ class Notification(models.Model):
|
||||
default=Status.PENDING,
|
||||
verbose_name=_("Status"),
|
||||
)
|
||||
related_meeting = models.ForeignKey(
|
||||
ZoomMeetingDetails,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notifications",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Related Meeting"),
|
||||
)
|
||||
# related_meeting = models.ForeignKey(
|
||||
# ZoomMeetingDetails,
|
||||
# on_delete=models.CASCADE,
|
||||
# related_name="notifications",
|
||||
# null=True,
|
||||
# blank=True,
|
||||
# verbose_name=_("Related Meeting"),
|
||||
# )
|
||||
scheduled_for = models.DateTimeField(
|
||||
verbose_name=_("Scheduled Send Time"),
|
||||
help_text=_("The date and time this notification is scheduled to be sent."),
|
||||
|
||||
@ -12,7 +12,7 @@ from . linkedin_service import LinkedInService
|
||||
from django.shortcuts import get_object_or_404
|
||||
from . models import JobPosting
|
||||
from django.utils import timezone
|
||||
from . models import InterviewSchedule,ScheduledInterview,ZoomMeetingDetails,Message
|
||||
from . models import ScheduledInterview,Interview,Message
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
# Add python-docx import for Word document processing
|
||||
@ -679,20 +679,28 @@ def create_interview_and_meeting(
|
||||
Synchronous task for a single interview slot, dispatched by django-q.
|
||||
"""
|
||||
try:
|
||||
candidate = Application.objects.get(pk=candidate_id)
|
||||
application = Application.objects.get(pk=candidate_id)
|
||||
job = JobPosting.objects.get(pk=job_id)
|
||||
schedule = InterviewSchedule.objects.get(pk=schedule_id)
|
||||
schedule = ScheduledInterview.objects.get(pk=schedule_id)
|
||||
|
||||
interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time))
|
||||
meeting_topic = f"Interview for {job.title} - {candidate.name}"
|
||||
meeting_topic = f"Interview for {job.title} - {application.name}"
|
||||
|
||||
# 1. External API Call (Slow)
|
||||
|
||||
# "status": "success",
|
||||
# "message": "Meeting created successfully.",
|
||||
# "meeting_details": {
|
||||
# "join_url": meeting_data['join_url'],
|
||||
# "meeting_id": meeting_data['id'],
|
||||
# "password": meeting_data['password'],
|
||||
# "host_email": meeting_data['host_email']
|
||||
# },
|
||||
# "zoom_gateway_response": meeting_data
|
||||
# }
|
||||
result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
|
||||
|
||||
if result["status"] == "success":
|
||||
# 2. Database Writes (Slow)
|
||||
zoom_meeting = ZoomMeetingDetails.objects.create(
|
||||
interview = Interview.objects.create(
|
||||
topic=meeting_topic,
|
||||
start_time=interview_datetime,
|
||||
duration=duration,
|
||||
@ -703,14 +711,31 @@ def create_interview_and_meeting(
|
||||
password=result["meeting_details"]["password"],
|
||||
location_type="Remote"
|
||||
)
|
||||
ScheduledInterview.objects.create(
|
||||
application=candidate,
|
||||
job=job,
|
||||
interview_location=zoom_meeting,
|
||||
schedule=schedule,
|
||||
interview_date=slot_date,
|
||||
interview_time=slot_time
|
||||
)
|
||||
schedule.interviews = interview
|
||||
schedule.status = "Remote"
|
||||
|
||||
schedule.save()
|
||||
|
||||
# 2. Database Writes (Slow)
|
||||
# zoom_meeting = ZoomMeetingDetails.objects.create(
|
||||
# topic=meeting_topic,
|
||||
# start_time=interview_datetime,
|
||||
# duration=duration,
|
||||
# meeting_id=result["meeting_details"]["meeting_id"],
|
||||
# details_url=result["meeting_details"]["join_url"],
|
||||
# zoom_gateway_response=result["zoom_gateway_response"],
|
||||
# host_email=result["meeting_details"]["host_email"],
|
||||
# password=result["meeting_details"]["password"],
|
||||
# location_type="Remote"
|
||||
# )
|
||||
# ScheduledInterview.objects.create(
|
||||
# application=candidate,
|
||||
# job=job,
|
||||
# interview_location=zoom_meeting,
|
||||
# schedule=schedule,
|
||||
# interview_date=slot_date,
|
||||
# interview_time=slot_time
|
||||
# )
|
||||
|
||||
# Log success or use Django-Q result system for monitoring
|
||||
logger.info(f"Successfully scheduled interview for {Application.name}")
|
||||
@ -745,7 +770,7 @@ def handle_zoom_webhook_event(payload):
|
||||
try:
|
||||
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
|
||||
# and to simplify the logic flow.
|
||||
meeting_instance = ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first()
|
||||
meeting_instance = ''#TODO:update #ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first()
|
||||
print(meeting_instance)
|
||||
# --- 1. Creation and Update Events ---
|
||||
if event_type == 'meeting.updated':
|
||||
|
||||
@ -11,12 +11,12 @@ User = get_user_model()
|
||||
|
||||
from .models import (
|
||||
JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
||||
FormSubmission, FieldResponse, BulkInterviewTemplate, ScheduledInterview,
|
||||
TrainingMaterial, Source, HiringAgency, MeetingComment
|
||||
)
|
||||
from .forms import (
|
||||
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
|
||||
CandidateStageForm, InterviewScheduleForm, CandidateSignupForm
|
||||
CandidateStageForm, BulkInterviewTemplateForm, CandidateSignupForm
|
||||
)
|
||||
from .views import (
|
||||
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, applications_screening_view,
|
||||
@ -304,7 +304,7 @@ class FormTests(BaseTestCase):
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_interview_schedule_form(self):
|
||||
"""Test InterviewScheduleForm"""
|
||||
"""Test BulkInterviewTemplateForm"""
|
||||
# Update candidate to Interview stage first
|
||||
self.candidate.stage = 'Interview'
|
||||
self.candidate.save()
|
||||
@ -315,7 +315,7 @@ class FormTests(BaseTestCase):
|
||||
'end_date': (timezone.now() + timedelta(days=7)).date(),
|
||||
'working_days': [0, 1, 2, 3, 4], # Monday to Friday
|
||||
}
|
||||
form = InterviewScheduleForm(slug=self.job.slug, data=form_data)
|
||||
form = BulkInterviewTemplateForm(slug=self.job.slug, data=form_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_candidate_signup_form_valid(self):
|
||||
|
||||
@ -24,13 +24,13 @@ from PIL import Image
|
||||
|
||||
from .models import (
|
||||
JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
||||
FormSubmission, FieldResponse, BulkInterviewTemplate, ScheduledInterview,
|
||||
TrainingMaterial, Source, HiringAgency, MeetingComment, JobPostingImage,
|
||||
BreakTime
|
||||
)
|
||||
from .forms import (
|
||||
JobPostingForm, ApplicationForm, ZoomMeetingForm, MeetingCommentForm,
|
||||
ApplicationStageForm, InterviewScheduleForm, BreakTimeFormSet
|
||||
ApplicationStageForm, BulkInterviewTemplateForm, BreakTimeFormSet
|
||||
)
|
||||
from .views import (
|
||||
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, applications_screening_view,
|
||||
@ -228,7 +228,7 @@ class AdvancedModelTests(TestCase):
|
||||
'break_end_time': '13:00'
|
||||
}
|
||||
|
||||
form = InterviewScheduleForm(slug=self.job.slug, data=schedule_data)
|
||||
form = BulkInterviewTemplateForm(slug=self.job.slug, data=schedule_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_field_response_data_types(self):
|
||||
@ -625,7 +625,7 @@ class AdvancedFormTests(TestCase):
|
||||
|
||||
def test_form_dependency_validation(self):
|
||||
"""Test validation for dependent form fields"""
|
||||
# Test InterviewScheduleForm with dependent fields
|
||||
# Test BulkInterviewTemplateForm with dependent fields
|
||||
schedule_data = {
|
||||
'candidates': [], # Empty for now
|
||||
'start_date': '2025-01-15',
|
||||
@ -637,7 +637,7 @@ class AdvancedFormTests(TestCase):
|
||||
'buffer_time': '15'
|
||||
}
|
||||
|
||||
form = InterviewScheduleForm(slug=self.job.slug, data=schedule_data)
|
||||
form = BulkInterviewTemplateForm(slug=self.job.slug, data=schedule_data)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('end_date', form.errors)
|
||||
|
||||
@ -667,7 +667,7 @@ class AdvancedFormTests(TestCase):
|
||||
|
||||
def test_dynamic_form_fields(self):
|
||||
"""Test forms with dynamically populated fields"""
|
||||
# Test InterviewScheduleForm with dynamic candidate queryset
|
||||
# Test BulkInterviewTemplateForm with dynamic candidate queryset
|
||||
# Create applications in Interview stage
|
||||
applications = []
|
||||
for i in range(3):
|
||||
@ -684,7 +684,7 @@ class AdvancedFormTests(TestCase):
|
||||
applications.append(application)
|
||||
|
||||
# Form should only show Interview stage applications
|
||||
form = InterviewScheduleForm(slug=self.job.slug)
|
||||
form = BulkInterviewTemplateForm(slug=self.job.slug)
|
||||
self.assertEqual(form.fields['candidates'].queryset.count(), 3)
|
||||
|
||||
for application in applications:
|
||||
|
||||
@ -207,21 +207,21 @@ urlpatterns = [
|
||||
),
|
||||
|
||||
|
||||
path(
|
||||
"jobs/<slug:slug>/<int:application_id>/reschedule_meeting_for_application/<int:meeting_id>/",
|
||||
views.reschedule_meeting_for_application,
|
||||
name="reschedule_meeting_for_application",
|
||||
),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/<int:application_id>/reschedule_meeting_for_application/<int:meeting_id>/",
|
||||
# views.reschedule_meeting_for_application,
|
||||
# name="reschedule_meeting_for_application",
|
||||
# ),
|
||||
path(
|
||||
"jobs/<slug:slug>/update_application_exam_status/",
|
||||
views.update_application_exam_status,
|
||||
name="update_application_exam_status",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:slug>/bulk_update_application_exam_status/",
|
||||
views.bulk_update_application_exam_status,
|
||||
name="bulk_update_application_exam_status",
|
||||
),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/bulk_update_application_exam_status/",
|
||||
# views.bulk_update_application_exam_status,
|
||||
# name="bulk_update_application_exam_status",
|
||||
# ),
|
||||
path(
|
||||
"htmx/<int:pk>/application_criteria_view/",
|
||||
views.application_criteria_view_htmx,
|
||||
@ -266,16 +266,16 @@ urlpatterns = [
|
||||
# path('api/templates/save/', views.save_form_template, name='save_form_template'),
|
||||
# path('api/templates/<slug:template_slug>/', views.load_form_template, name='load_form_template'),
|
||||
# path('api/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'),
|
||||
path(
|
||||
"jobs/<slug:slug>/calendar/",
|
||||
views.interview_calendar_view,
|
||||
name="interview_calendar",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:slug>/calendar/interview/<int:interview_id>/",
|
||||
views.interview_detail_view,
|
||||
name="interview_detail",
|
||||
),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/calendar/",
|
||||
# views.interview_calendar_view,
|
||||
# name="interview_calendar",
|
||||
# ),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/calendar/interview/<int:interview_id>/",
|
||||
# views.interview_detail_view,
|
||||
# name="interview_detail",
|
||||
# ),
|
||||
|
||||
# users urls
|
||||
path("user/<int:pk>", views.user_detail, name="user_detail"),
|
||||
@ -333,26 +333,26 @@ urlpatterns = [
|
||||
name="copy_to_clipboard",
|
||||
),
|
||||
# Meeting Comments URLs
|
||||
path(
|
||||
"meetings/<slug:slug>/comments/add/",
|
||||
views.add_meeting_comment,
|
||||
name="add_meeting_comment",
|
||||
),
|
||||
path(
|
||||
"meetings/<slug:slug>/comments/<int:comment_id>/edit/",
|
||||
views.edit_meeting_comment,
|
||||
name="edit_meeting_comment",
|
||||
),
|
||||
path(
|
||||
"meetings/<slug:slug>/comments/<int:comment_id>/delete/",
|
||||
views.delete_meeting_comment,
|
||||
name="delete_meeting_comment",
|
||||
),
|
||||
path(
|
||||
"meetings/<slug:slug>/set_meeting_application/",
|
||||
views.set_meeting_application,
|
||||
name="set_meeting_application",
|
||||
),
|
||||
# path(
|
||||
# "meetings/<slug:slug>/comments/add/",
|
||||
# views.add_meeting_comment,
|
||||
# name="add_meeting_comment",
|
||||
# ),
|
||||
# path(
|
||||
# "meetings/<slug:slug>/comments/<int:comment_id>/edit/",
|
||||
# views.edit_meeting_comment,
|
||||
# name="edit_meeting_comment",
|
||||
# ),
|
||||
# path(
|
||||
# "meetings/<slug:slug>/comments/<int:comment_id>/delete/",
|
||||
# views.delete_meeting_comment,
|
||||
# name="delete_meeting_comment",
|
||||
# ),
|
||||
# path(
|
||||
# "meetings/<slug:slug>/set_meeting_application/",
|
||||
# views.set_meeting_application,
|
||||
# name="set_meeting_application",
|
||||
# ),
|
||||
# Hiring Agency URLs
|
||||
path("agencies/", views.agency_list, name="agency_list"),
|
||||
path("agencies/create/", views.agency_create, name="agency_create"),
|
||||
@ -510,31 +510,31 @@ urlpatterns = [
|
||||
# path('notifications/mark-all-read/', views.notification_mark_all_read, name='notification_mark_all_read'),
|
||||
# path('api/notification-count/', views.api_notification_count, name='api_notification_count'),
|
||||
# participants urls
|
||||
path(
|
||||
"participants/",
|
||||
views_frontend.ParticipantsListView.as_view(),
|
||||
name="participants_list",
|
||||
),
|
||||
path(
|
||||
"participants/create/",
|
||||
views_frontend.ParticipantsCreateView.as_view(),
|
||||
name="participants_create",
|
||||
),
|
||||
path(
|
||||
"participants/<slug:slug>/",
|
||||
views_frontend.ParticipantsDetailView.as_view(),
|
||||
name="participants_detail",
|
||||
),
|
||||
path(
|
||||
"participants/<slug:slug>/update/",
|
||||
views_frontend.ParticipantsUpdateView.as_view(),
|
||||
name="participants_update",
|
||||
),
|
||||
path(
|
||||
"participants/<slug:slug>/delete/",
|
||||
views_frontend.ParticipantsDeleteView.as_view(),
|
||||
name="participants_delete",
|
||||
),
|
||||
# path(
|
||||
# "participants/",
|
||||
# views_frontend.ParticipantsListView.as_view(),
|
||||
# name="participants_list",
|
||||
# ),
|
||||
# path(
|
||||
# "participants/create/",
|
||||
# views_frontend.ParticipantsCreateView.as_view(),
|
||||
# name="participants_create",
|
||||
# ),
|
||||
# path(
|
||||
# "participants/<slug:slug>/",
|
||||
# views_frontend.ParticipantsDetailView.as_view(),
|
||||
# name="participants_detail",
|
||||
# ),
|
||||
# path(
|
||||
# "participants/<slug:slug>/update/",
|
||||
# views_frontend.ParticipantsUpdateView.as_view(),
|
||||
# name="participants_update",
|
||||
# ),
|
||||
# path(
|
||||
# "participants/<slug:slug>/delete/",
|
||||
# views_frontend.ParticipantsDeleteView.as_view(),
|
||||
# name="participants_delete",
|
||||
# ),
|
||||
# Email composition URLs
|
||||
path(
|
||||
"jobs/<slug:job_slug>/applications/compose-email/",
|
||||
@ -563,13 +563,23 @@ urlpatterns = [
|
||||
path("application/documents/<int:document_id>/download/", views.document_download, name="application_document_download"),
|
||||
path('jobs/<slug:job_slug>/applications/compose_email/', views.compose_application_email, name='compose_application_email'),
|
||||
|
||||
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
|
||||
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
|
||||
# path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
|
||||
# path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
|
||||
# Candidate Signup
|
||||
path('application/signup/<slug:template_slug>/', views.application_signup, name='application_signup'),
|
||||
# Password Reset
|
||||
path('user/<int:pk>/password-reset/', views.portal_password_reset, name='portal_password_reset'),
|
||||
|
||||
# Interview URLs
|
||||
path('interviews/', views.interview_list, name='interview_list'),
|
||||
path('interviews/<slug:slug>/', views.interview_detail, name='interview_detail'),
|
||||
|
||||
# Interview Creation URLs
|
||||
path('interviews/create/<slug:candidate_slug>/', views.interview_create_type_selection, name='interview_create_type_selection'),
|
||||
path('interviews/create/<slug:candidate_slug>/remote/', views.interview_create_remote, name='interview_create_remote'),
|
||||
path('interviews/create/<slug:candidate_slug>/onsite/', views.interview_create_onsite, name='interview_create_onsite'),
|
||||
path('interviews/<slug:job_slug>/get_interview_list', views.get_interview_list, name='get_interview_list'),
|
||||
|
||||
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
|
||||
# path('interview/list/', views.interview_list, name='interview_list'),
|
||||
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'),
|
||||
@ -577,64 +587,64 @@ urlpatterns = [
|
||||
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
|
||||
|
||||
#interview and meeting related urls
|
||||
path(
|
||||
"jobs/<slug:slug>/schedule-interviews/",
|
||||
views.schedule_interviews_view,
|
||||
name="schedule_interviews",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:slug>/confirm-schedule-interviews/",
|
||||
views.confirm_schedule_interviews_view,
|
||||
name="confirm_schedule_interviews_view",
|
||||
),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/schedule-interviews/",
|
||||
# views.schedule_interviews_view,
|
||||
# name="schedule_interviews",
|
||||
# ),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/confirm-schedule-interviews/",
|
||||
# views.confirm_schedule_interviews_view,
|
||||
# name="confirm_schedule_interviews_view",
|
||||
# ),
|
||||
|
||||
path(
|
||||
"meetings/create-meeting/",
|
||||
views.ZoomMeetingCreateView.as_view(),
|
||||
name="create_meeting",
|
||||
),
|
||||
# path(
|
||||
# "meetings/create-meeting/",
|
||||
# views.ZoomMeetingCreateView.as_view(),
|
||||
# name="create_meeting",
|
||||
# ),
|
||||
# path(
|
||||
# "meetings/meeting-details/<slug:slug>/",
|
||||
# views.ZoomMeetingDetailsView.as_view(),
|
||||
# name="meeting_details",
|
||||
# ),
|
||||
path(
|
||||
"meetings/update-meeting/<slug:slug>/",
|
||||
views.ZoomMeetingUpdateView.as_view(),
|
||||
name="update_meeting",
|
||||
),
|
||||
path(
|
||||
"meetings/delete-meeting/<slug:slug>/",
|
||||
views.ZoomMeetingDeleteView,
|
||||
name="delete_meeting",
|
||||
),
|
||||
# path(
|
||||
# "meetings/update-meeting/<slug:slug>/",
|
||||
# views.ZoomMeetingUpdateView.as_view(),
|
||||
# name="update_meeting",
|
||||
# ),
|
||||
# path(
|
||||
# "meetings/delete-meeting/<slug:slug>/",
|
||||
# views.ZoomMeetingDeleteView,
|
||||
# name="delete_meeting",
|
||||
# ),
|
||||
# Candidate Meeting Scheduling/Rescheduling URLs
|
||||
path(
|
||||
"jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
|
||||
views.schedule_application_meeting,
|
||||
name="schedule_application_meeting",
|
||||
),
|
||||
path(
|
||||
"api/jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
|
||||
views.api_schedule_application_meeting,
|
||||
name="api_schedule_application_meeting",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
|
||||
views.reschedule_application_meeting,
|
||||
name="reschedule_application_meeting",
|
||||
),
|
||||
path(
|
||||
"api/jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
|
||||
views.api_reschedule_application_meeting,
|
||||
name="api_reschedule_application_meeting",
|
||||
),
|
||||
# path(
|
||||
# "jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
|
||||
# views.schedule_application_meeting,
|
||||
# name="schedule_application_meeting",
|
||||
# ),
|
||||
# path(
|
||||
# "api/jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
|
||||
# views.api_schedule_application_meeting,
|
||||
# name="api_schedule_application_meeting",
|
||||
# ),
|
||||
# path(
|
||||
# "jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
|
||||
# views.reschedule_application_meeting,
|
||||
# name="reschedule_application_meeting",
|
||||
# ),
|
||||
# path(
|
||||
# "api/jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
|
||||
# views.api_reschedule_application_meeting,
|
||||
# name="api_reschedule_application_meeting",
|
||||
# ),
|
||||
# New URL for simple page-based meeting scheduling
|
||||
path(
|
||||
"jobs/<slug:slug>/applications/<int:application_pk>/schedule-meeting-page/",
|
||||
views.schedule_meeting_for_application,
|
||||
name="schedule_meeting_for_application",
|
||||
),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/applications/<int:application_pk>/schedule-meeting-page/",
|
||||
# views.schedule_meeting_for_application,
|
||||
# name="schedule_meeting_for_application",
|
||||
# ),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/applications/<int:application_pk>/delete_meeting_for_application/<int:meeting_id>/",
|
||||
# views.delete_meeting_for_candidate,
|
||||
@ -642,35 +652,35 @@ urlpatterns = [
|
||||
# ),
|
||||
|
||||
|
||||
path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
|
||||
# path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
|
||||
|
||||
# 1. Onsite Reschedule URL
|
||||
path(
|
||||
'<slug:slug>/application/<int:application_id>/onsite/reschedule/<int:meeting_id>/',
|
||||
views.reschedule_onsite_meeting,
|
||||
name='reschedule_onsite_meeting'
|
||||
),
|
||||
# path(
|
||||
# '<slug:slug>/application/<int:application_id>/onsite/reschedule/<int:meeting_id>/',
|
||||
# views.reschedule_onsite_meeting,
|
||||
# name='reschedule_onsite_meeting'
|
||||
# ),
|
||||
|
||||
# 2. Onsite Delete URL
|
||||
|
||||
path(
|
||||
'job/<slug:slug>/applications/<int:application_pk>/delete-onsite-meeting/<int:meeting_id>/',
|
||||
views.delete_onsite_meeting_for_application,
|
||||
name='delete_onsite_meeting_for_application'
|
||||
),
|
||||
# path(
|
||||
# 'job/<slug:slug>/applications/<int:application_pk>/delete-onsite-meeting/<int:meeting_id>/',
|
||||
# views.delete_onsite_meeting_for_application,
|
||||
# name='delete_onsite_meeting_for_application'
|
||||
# ),
|
||||
|
||||
path(
|
||||
'job/<slug:slug>/application/<int:application_pk>/schedule/onsite/',
|
||||
views.schedule_onsite_meeting_for_application,
|
||||
name='schedule_onsite_meeting_for_application' # This is the name used in the button
|
||||
),
|
||||
# path(
|
||||
# 'job/<slug:slug>/application/<int:application_pk>/schedule/onsite/',
|
||||
# views.schedule_onsite_meeting_for_application,
|
||||
# name='schedule_onsite_meeting_for_application' # This is the name used in the button
|
||||
# ),
|
||||
|
||||
|
||||
# Detail View (assuming slug is on ScheduledInterview)
|
||||
path("interviews/meetings/<slug:slug>/", views.meeting_details, name="meeting_details"),
|
||||
# path("interviews/meetings/<slug:slug>/", views.meeting_details, name="meeting_details"),
|
||||
|
||||
# Email invitation URLs
|
||||
path("interviews/meetings/<slug:slug>/send-application-invitation/", views.send_application_invitation, name="send_application_invitation"),
|
||||
path("interviews/meetings/<slug:slug>/send-participants-invitation/", views.send_participants_invitation, name="send_participants_invitation"),
|
||||
# path("interviews/meetings/<slug:slug>/send-application-invitation/", views.send_application_invitation, name="send_application_invitation"),
|
||||
# path("interviews/meetings/<slug:slug>/send-participants-invitation/", views.send_participants_invitation, name="send_participants_invitation"),
|
||||
|
||||
]
|
||||
|
||||
3970
recruitment/views.py
3970
recruitment/views.py
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,6 @@ from django.http import JsonResponse, HttpResponse
|
||||
from django.db.models.fields.json import KeyTextTransform,KeyTransform
|
||||
from recruitment.utils import json_to_markdown_table
|
||||
from django.db.models import Count, Avg, F, FloatField
|
||||
from django.db.models.functions import Cast
|
||||
from django.db.models.functions import Coalesce, Cast, Replace, NullIf
|
||||
from . import models
|
||||
from django.utils.translation import get_language
|
||||
@ -1065,47 +1064,47 @@ def sync_history(request, job_slug=None):
|
||||
|
||||
|
||||
#participants views
|
||||
class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
||||
model = models.Participants
|
||||
template_name = 'participants/participants_list.html'
|
||||
context_object_name = 'participants'
|
||||
paginate_by = 10
|
||||
# class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
||||
# model = models.Participants
|
||||
# template_name = 'participants/participants_list.html'
|
||||
# context_object_name = 'participants'
|
||||
# paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
# def get_queryset(self):
|
||||
# queryset = super().get_queryset()
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get('search', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=search_query) |
|
||||
Q(email__icontains=search_query) |
|
||||
Q(phone__icontains=search_query) |
|
||||
Q(designation__icontains=search_query)
|
||||
)
|
||||
# # Handle search
|
||||
# search_query = self.request.GET.get('search', '')
|
||||
# if search_query:
|
||||
# queryset = queryset.filter(
|
||||
# Q(name__icontains=search_query) |
|
||||
# Q(email__icontains=search_query) |
|
||||
# Q(phone__icontains=search_query) |
|
||||
# Q(designation__icontains=search_query)
|
||||
# )
|
||||
|
||||
# Filter for non-staff users
|
||||
if not self.request.user.is_staff:
|
||||
return models.Participants.objects.none() # Restrict for non-staff
|
||||
# # Filter for non-staff users
|
||||
# if not self.request.user.is_staff:
|
||||
# return models.Participants.objects.none() # Restrict for non-staff
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
# return queryset.order_by('-created_at')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
return context
|
||||
class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
|
||||
model = models.Participants
|
||||
template_name = 'participants/participants_detail.html'
|
||||
context_object_name = 'participant'
|
||||
slug_url_kwarg = 'slug'
|
||||
# def get_context_data(self, **kwargs):
|
||||
# context = super().get_context_data(**kwargs)
|
||||
# context['search_query'] = self.request.GET.get('search', '')
|
||||
# return context
|
||||
# class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
|
||||
# model = models.Participants
|
||||
# template_name = 'participants/participants_detail.html'
|
||||
# context_object_name = 'participant'
|
||||
# slug_url_kwarg = 'slug'
|
||||
|
||||
class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
model = models.Participants
|
||||
form_class = forms.ParticipantsForm
|
||||
template_name = 'participants/participants_create.html'
|
||||
success_url = reverse_lazy('job_list')
|
||||
success_message = 'Participant created successfully.'
|
||||
# class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
# model = models.Participants
|
||||
# form_class = forms.ParticipantsForm
|
||||
# template_name = 'participants/participants_create.html'
|
||||
# success_url = reverse_lazy('job_list')
|
||||
# success_message = 'Participant created successfully.'
|
||||
|
||||
# def get_initial(self):
|
||||
# initial = super().get_initial()
|
||||
@ -1116,17 +1115,17 @@ class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMess
|
||||
|
||||
|
||||
|
||||
class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
model = models.Participants
|
||||
form_class = forms.ParticipantsForm
|
||||
template_name = 'participants/participants_create.html'
|
||||
success_url = reverse_lazy('job_list')
|
||||
success_message = 'Participant updated successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
# class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
# model = models.Participants
|
||||
# form_class = forms.ParticipantsForm
|
||||
# template_name = 'participants/participants_create.html'
|
||||
# success_url = reverse_lazy('job_list')
|
||||
# success_message = 'Participant updated successfully.'
|
||||
# slug_url_kwarg = 'slug'
|
||||
|
||||
class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
model = models.Participants
|
||||
# class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
# model = models.Participants
|
||||
|
||||
success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
|
||||
success_message = 'Participant deleted successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
# success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
|
||||
# success_message = 'Participant deleted successfully.'
|
||||
# slug_url_kwarg = 'slug'
|
||||
|
||||
@ -274,7 +274,7 @@
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item me-lg-4">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'list_meetings' %}">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'interview_list' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<i class="fas fa-calendar-check me-2"></i>
|
||||
{% trans "Meetings" %}
|
||||
|
||||
237
templates/interviews/interview_create_onsite.html
Normal file
237
templates/interviews/interview_create_onsite.html
Normal 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 %}
|
||||
74
templates/interviews/interview_create_remote.html
Normal file
74
templates/interviews/interview_create_remote.html
Normal 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 %}
|
||||
54
templates/interviews/interview_create_type_selection.html
Normal file
54
templates/interviews/interview_create_type_selection.html
Normal 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 %}
|
||||
768
templates/interviews/interview_detail.html
Normal file
768
templates/interviews/interview_detail.html
Normal 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 %}
|
||||
@ -1,81 +1,235 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends 'base.html' %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{% trans "Scheduled Interviews List" %} - {{ block.super }}{% endblock %}
|
||||
{% block title %}{% trans "Interview Management" %} - ATS{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
{# (Your existing CSS is kept here, as it is perfect for the theme) #}
|
||||
<style>
|
||||
/* ... (Your CSS styles) ... */
|
||||
/* KAAT-S UI Variables */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
--kaauh-success: #28a745;
|
||||
--kaauh-info: #17a2b8;
|
||||
--kaauh-danger: #dc3545;
|
||||
--kaauh-warning: #ffc107;
|
||||
}
|
||||
|
||||
/* Primary Color Overrides */
|
||||
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
||||
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
|
||||
|
||||
/* Main Container & Card Styling */
|
||||
.kaauh-card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* Filter Controls */
|
||||
.filter-controls {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--kaauh-border);
|
||||
}
|
||||
|
||||
/* Button Styling */
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-main-action:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
.btn-outline-secondary {
|
||||
color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal);
|
||||
}
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
color: white;
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
}
|
||||
|
||||
/* Interview Table Styling */
|
||||
.interview-table {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.interview-table thead {
|
||||
background-color: var(--kaauh-border);
|
||||
}
|
||||
.interview-table th {
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--kaauh-teal-dark);
|
||||
border-bottom: 2px solid var(--kaauh-teal);
|
||||
font-size: 0.9rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.interview-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
vertical-align: middle;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.interview-table tbody tr:hover {
|
||||
background-color: #f1f3f4;
|
||||
}
|
||||
|
||||
/* Column Widths */
|
||||
.interview-table thead th:nth-child(1) { width: 40px; }
|
||||
.interview-table thead th:nth-child(2) { width: 15%; }
|
||||
.interview-table thead th:nth-child(3) { width: 12%; }
|
||||
.interview-table thead th:nth-child(4) { width: 12%; }
|
||||
.interview-table thead th:nth-child(5) { width: 10%; }
|
||||
.interview-table thead th:nth-child(6) { width: 8%; }
|
||||
.interview-table thead th:nth-child(7) { width: 8%; }
|
||||
.interview-table thead th:nth-child(8) { width: 15%; }
|
||||
|
||||
/* Candidate and Job Info */
|
||||
.candidate-name {
|
||||
font-weight: 600;
|
||||
color: var(--kaauh-primary-text);
|
||||
}
|
||||
.candidate-details {
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
.job-title {
|
||||
font-weight: 500;
|
||||
color: var(--kaauh-teal-dark);
|
||||
}
|
||||
|
||||
/* Badges and Statuses */
|
||||
.status-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.3em 0.7em;
|
||||
border-radius: 0.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.interview-type-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Status Colors */
|
||||
.bg-scheduled { background-color: #6c757d !important; color: white; }
|
||||
.bg-confirmed { background-color: var(--kaauh-info) !important; color: white; }
|
||||
.bg-cancelled { background-color: var(--kaauh-danger) !important; color: white; }
|
||||
.bg-completed { background-color: var(--kaauh-success) !important; color: white; }
|
||||
.bg-remote { background-color: #007bff !important; color: white; }
|
||||
.bg-onsite { background-color: #6f42c1 !important; color: white; }
|
||||
|
||||
/* Custom Height Optimization */
|
||||
.form-control-sm,
|
||||
.btn-sm {
|
||||
padding-top: 0.2rem !important;
|
||||
padding-bottom: 0.2rem !important;
|
||||
height: 28px !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
/* Pagination Styling */
|
||||
.pagination .page-link {
|
||||
color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-border);
|
||||
}
|
||||
.pagination .page-item.active .page-link {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
}
|
||||
.pagination .page-link:hover {
|
||||
color: var(--kaauh-teal-dark);
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{interviews}}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Header Section -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||
<i class="fas fa-calendar-alt me-2"></i> {% trans "Scheduled Interviews" %}
|
||||
<div>
|
||||
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||
<i class="fas fa-calendar-alt me-2"></i>
|
||||
{% trans "Interview Management" %}
|
||||
</h1>
|
||||
{# FIX: Using safe anchor href="#" to prevent the NoReverseMatch crash. #}
|
||||
{# Replace '#' with {% url 'create_scheduled_interview' %} once the URL name is defined in urls.py #}
|
||||
<a href="#" class="btn btn-main-action">
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Schedule Interview" %}
|
||||
<h2 class="h5 text-muted mb-0">
|
||||
{% trans "Total Interviews:" %} <span class="fw-bold">{{ interviews|length }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'dashboard' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Dashboard" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4 shadow-sm no-hover">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3 align-items-end">
|
||||
{# Search field #}
|
||||
<div class="col-md-4">
|
||||
<label for="q" class="form-label small text-muted">{% trans "Search (Candidate/Job)" %}</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control form-control-sm" id="q" name="q" placeholder="{% trans 'Search...' %}" value="{{ search_query }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Filter by Status #}
|
||||
<!-- Filter Controls -->
|
||||
<div class="filter-controls">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="status" class="form-label small text-muted">{% trans "Filter by Status" %}</label>
|
||||
<select name="status" id="status" class="form-select form-select-sm">
|
||||
<option value="">{% trans "All Statuses" %}</option>
|
||||
<option value="scheduled" {% if status_filter == 'scheduled' %}selected{% endif %}>{% trans "Scheduled" %}</option>
|
||||
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>{% trans "Confirmed" %}</option>
|
||||
<option value="completed" {% if status_filter == 'completed' %}selected{% endif %}>{% trans "Completed" %}</option>
|
||||
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>{% trans "Cancelled" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Filter by Interview Type (ONSITE/REMOTE) - This list now correctly populated #}
|
||||
<div class="col-md-3">
|
||||
<label for="interview_type" class="form-label small text-muted">{% trans "Interview Type" %}</label>
|
||||
<select name="interview_type" id="interview_type" class="form-select form-select-sm">
|
||||
<option value="">{% trans "All Types" %}</option>
|
||||
{% for type_value, type_label in interview_types %}
|
||||
<option value="{{ type_value }}" {% if type_filter == type_value %}selected{% endif %}>
|
||||
{{ type_label }}
|
||||
<label for="job_filter" class="form-label form-label-sm">{% trans "Job" %}</label>
|
||||
<select name="job" id="job_filter" class="form-select form-select-sm">
|
||||
<option value="">{% trans "All Jobs" %}</option>
|
||||
{% for job in jobs %}
|
||||
<option value="{{ job.id }}" {% if request.GET.job == job.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ job.title }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<div class="filter-buttons">
|
||||
<button type="submit" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-filter me-1"></i> {% trans "Apply" %}
|
||||
<label for="status_filter" class="form-label form-label-sm">{% trans "Status" %}</label>
|
||||
<select name="status" id="status_filter" class="form-select form-select-sm">
|
||||
<option value="">{% trans "All Status" %}</option>
|
||||
<option value="scheduled" {% if request.GET.status == "scheduled" %}selected{% endif %}>{% trans "Scheduled" %}</option>
|
||||
<option value="confirmed" {% if request.GET.status == "confirmed" %}selected{% endif %}>{% trans "Confirmed" %}</option>
|
||||
<option value="cancelled" {% if request.GET.status == "cancelled" %}selected{% endif %}>{% trans "Cancelled" %}</option>
|
||||
<option value="completed" {% if request.GET.status == "completed" %}selected{% endif %}>{% trans "Completed" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="type_filter" class="form-label form-label-sm">{% trans "Type" %}</label>
|
||||
<select name="type" id="type_filter" class="form-select form-select-sm">
|
||||
<option value="">{% trans "All Types" %}</option>
|
||||
<option value="remote" {% if request.GET.type == "remote" %}selected{% endif %}>{% trans "Remote" %}</option>
|
||||
<option value="onsite" {% if request.GET.type == "onsite" %}selected{% endif %}>{% trans "Onsite" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="search_filter" class="form-label form-label-sm">{% trans "Search Candidate" %}</label>
|
||||
<input type="text" name="search" id="search_filter" class="form-control form-control-sm"
|
||||
value="{{ request.GET.search }}" placeholder="{% trans 'Name or Email' %}">
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-main-action btn-sm me-2">
|
||||
<i class="fas fa-filter me-1"></i> {% trans "Filter" %}
|
||||
</button>
|
||||
{% if status_filter or search_query or type_filter %}
|
||||
{# Assuming 'interview_list' is the URL name for this view #}
|
||||
<a href="{% url 'interview_list' %}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{meetings}}
|
||||
{# Using 'meetings' based on the context_object_name provided #}
|
||||
{% if meetings %}
|
||||
@ -149,70 +303,83 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Table View (Logic is identical, safe access applied) #}
|
||||
<div class="table-view">
|
||||
<form id="interview-form">
|
||||
{% csrf_token %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<table class="table interview-table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Candidate" %}</th>
|
||||
<th scope="col">{% trans "Job" %}</th>
|
||||
<th scope="col">{% trans "Type" %}</th>
|
||||
<th scope="col">{% trans "Date/Time" %}</th>
|
||||
<th scope="col">{% trans "Duration" %}</th>
|
||||
<th scope="col">{% trans "Status" %}</th>
|
||||
<th scope="col" class="text-end">{% trans "Actions" %}</th>
|
||||
<th><i class="fas fa-user me-1"></i> {% trans "Candidate" %}</th>
|
||||
<th><i class="fas fa-briefcase me-1"></i> {% trans "Job" %}</th>
|
||||
<th><i class="fas fa-calendar me-1"></i> {% trans "Date & Time" %}</th>
|
||||
<th><i class="fas fa-tag me-1"></i> {% trans "Type" %}</th>
|
||||
<th><i class="fas fa-info-circle me-1"></i> {% trans "Status" %}</th>
|
||||
{% comment %} <th><i class="fas fa-users me-1"></i> {% trans "Participants" %}</th> {% endcomment %}
|
||||
<th><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for interview in meetings %}
|
||||
{% for interview in interviews %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong class="text-primary-theme">
|
||||
<a href="{% url 'application_detail' interview.candidate.slug %}" class="text-decoration-none text-primary-theme">{{ interview.candidate.name }}</a>
|
||||
</strong>
|
||||
<div class="candidate-name">{{ interview.application.name }}</div>
|
||||
<div class="application-details">
|
||||
<i class="fas fa-envelope me-1"></i> {{ interview.application.email }}<br>
|
||||
<i class="fas fa-phone me-1"></i> {{ interview.application.phone }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a class="text-secondary text-decoration-none" href="{% url 'job_detail' interview.job.slug %}">{{ interview.job.title }}</a>
|
||||
<div class="job-title">{{ interview.job.title }}</div>
|
||||
<div class="candidate-details">{{ interview.job.department }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ interview.schedule.get_interview_type_display }}
|
||||
<div class="candidate-details">
|
||||
<i class="fas fa-calendar-day me-1"></i> {{ interview.interview_date|date:"d-m-Y" }}<br>
|
||||
<i class="fas fa-clock me-1"></i> {{ interview.interview_time|date:"h:i A" }}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ interview.interview_date|date:"M d, Y" }} <br>({{ interview.interview_time|time:"H:i" }})</td>
|
||||
<td>{{ interview.schedule.interview_duration }} min</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ interview.status }}">
|
||||
{% if interview.status == 'confirmed' %}
|
||||
<i class="fas fa-circle me-1 text-white"></i>
|
||||
{% if interview.interview.location_type == 'Remote' %}
|
||||
<span class="badge interview-type-badge bg-remote">
|
||||
<i class="fas fa-video me-1"></i> {% trans "Remote" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge interview-type-badge bg-onsite">
|
||||
<i class="fas fa-building me-1"></i> {% trans "Onsite" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{{ interview.status|title }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary-theme">
|
||||
{{ interview.status|upper }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
|
||||
{# CRITICAL FIX: Safe access to join URL #}
|
||||
{% if interview.schedule.interview_type == 'Remote' and interview.zoom_meeting and interview.zoom_meeting.join_url %}
|
||||
<a href="{{ interview.zoom_meeting.join_url }}" target="_blank" class="btn btn-main-action" title="{% trans 'Join' %}">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="{% url 'scheduled_interview_detail' interview.slug %}" class="btn btn-outline-primary" title="{% trans 'View' %}">
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{% url 'interview_detail' interview.slug %}"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
title="{% trans 'View Details' %}">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<a href="{% url 'update_scheduled_interview' interview.slug %}" class="btn btn-outline-secondary" title="{% trans 'Update' %}">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
|
||||
{% comment %} {% if interview.status != 'CANCELLED' and interview.status != 'COMPLETED' %}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#meetingModal"
|
||||
hx-post="{% url 'delete_scheduled_interview' interview.slug %}"
|
||||
hx-target="#meetingModalBody"
|
||||
hx-swap="outerHTML"
|
||||
data-item-name="{{ interview.candidate.name }} Interview">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
data-bs-target="#actionModal"
|
||||
hx-get="#"
|
||||
hx-target="#actionModalBody"
|
||||
title="{% trans 'Reschedule' %}">
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#actionModal"
|
||||
hx-get="#"
|
||||
hx-target="#actionModalBody"
|
||||
title="{% trans 'Cancel' %}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
{% endif %} {% endcomment %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -220,49 +387,138 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# Pagination #}
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<nav aria-label="Page navigation" class="mt-4">
|
||||
<nav aria-label="Interview pagination" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">First</a>
|
||||
<a class="page-link" href="?page=1{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "First" %}</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">Previous</a>
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "Previous" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||
<span class="page-link">{{ num }}</span>
|
||||
</li>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ num }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{{ num }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">Next</a>
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "Next" %}</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">Last</a>
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "Last" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5 card shadow-sm">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-calendar-alt fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
|
||||
<h3>{% trans "No Interviews found" %}</h3>
|
||||
<p class="text-muted">{% trans "Schedule your first interview or adjust your filters." %}</p>
|
||||
{# FIX: Using safe anchor href="#" to prevent the NoReverseMatch crash. #}
|
||||
<a href="#" class="btn btn-main-action mt-3">
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Schedule an Interview" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="alert alert-info text-center py-5" role="alert">
|
||||
<i class="fas fa-info-circle fa-2x mb-3"></i>
|
||||
<h5>{% trans "No interviews found" %}</h5>
|
||||
<p class="text-muted mb-0">
|
||||
{% trans "There are no interviews matching your current filters." %}
|
||||
<a href="{% url 'interview_list' %}" class="alert-link">{% trans "Clear filters" %}</a>
|
||||
{% trans "to see all interviews." %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Modal -->
|
||||
<div class="modal fade" id="actionModal" tabindex="-1" aria-labelledby="actionModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content kaauh-card">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||
<h5 class="modal-title" id="actionModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||
{% trans "Interview Action" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="actionModalBody" class="modal-body">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||
{% trans "Loading..." %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
|
||||
|
||||
if (selectAllCheckbox) {
|
||||
function updateSelectAllState() {
|
||||
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
|
||||
const totalCount = rowCheckboxes.length;
|
||||
|
||||
if (checkedCount === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else if (checkedCount === totalCount) {
|
||||
selectAllCheckbox.checked = true;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
selectAllCheckbox.addEventListener('change', function () {
|
||||
const isChecked = selectAllCheckbox.checked;
|
||||
|
||||
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
|
||||
|
||||
rowCheckboxes.forEach(function (checkbox) {
|
||||
checkbox.checked = isChecked;
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
|
||||
updateSelectAllState();
|
||||
});
|
||||
|
||||
rowCheckboxes.forEach(function (checkbox) {
|
||||
checkbox.addEventListener('change', updateSelectAllState);
|
||||
});
|
||||
|
||||
updateSelectAllState();
|
||||
}
|
||||
|
||||
// Clear modal content when hidden
|
||||
const actionModal = document.getElementById('actionModal');
|
||||
actionModal.addEventListener('hidden.bs.modal', function () {
|
||||
const modalBody = actionModal.querySelector('#actionModalBody');
|
||||
if (modalBody) {
|
||||
modalBody.innerHTML = `
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||
{% trans "Loading..." %}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
42
templates/interviews/partials/interview_list.html
Normal file
42
templates/interviews/partials/interview_list.html
Normal 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>
|
||||
@ -286,7 +286,7 @@
|
||||
{# Separator (Vertical Rule) - Aligns automatically at the bottom with align-items-end #}
|
||||
<div class="vr" style="height: 28px;"></div>
|
||||
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
hx-boost='true'
|
||||
data-bs-target="#emailModal"
|
||||
|
||||
@ -229,7 +229,7 @@
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
hx-boost='true'
|
||||
data-bs-target="#emailModal"
|
||||
|
||||
@ -252,7 +252,7 @@
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||
</button>
|
||||
{# email button#}
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
hx-boost='true'
|
||||
data-bs-target="#emailModal"
|
||||
|
||||
@ -422,27 +422,37 @@
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-main-action btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateviewModal"
|
||||
hx-get="{% url 'schedule_meeting_for_application' job.slug application.pk %}"
|
||||
hx-target="#candidateviewModalBody"
|
||||
data-modal-title="{% trans 'Schedule Interview' %}"
|
||||
{% comment %} <a href="{% url 'interview_create_type_selection' candidate_slug=candidate.slug %}"
|
||||
class="btn btn-main-action btn-sm"
|
||||
title="Schedule Interview">
|
||||
<i class="fas fa-video"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-main-action btn-sm"
|
||||
<i class="fas fa-calendar-plus me-1"></i>
|
||||
Schedule
|
||||
</a> {% endcomment %}
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateviewModal"
|
||||
{# UPDATED: Points to the specific Onsite scheduling URL #}
|
||||
hx-get="{% url 'schedule_onsite_meeting_for_application' job.slug application.pk %}"
|
||||
hx-target="#candidateviewModalBody"
|
||||
data-modal-title="{% trans 'Schedule Onsite Interview' %}"
|
||||
title="Schedule Onsite Interview">
|
||||
<i class="fas fa-building"></i>
|
||||
hx-get="{% url 'interview_create_type_selection' application_slug=application.slug %}"
|
||||
hx-select=".card-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#candidateviewModalBody">
|
||||
<i class="fas fa-calendar-plus me-1"></i>
|
||||
Schedule
|
||||
</button>
|
||||
{% comment %} <a href="{% url 'interview_create_type_selection' candidate_slug=candidate.slug %}"
|
||||
class="btn btn-main-action btn-sm"
|
||||
title="Schedule Interview">
|
||||
<i class="fas fa-calendar-plus me-1"></i>
|
||||
Schedule
|
||||
</a> {% endcomment %}
|
||||
{% endif %}
|
||||
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateviewModal"
|
||||
hx-get="{% url 'get_interview_list' application.slug %}"
|
||||
hx-target="#candidateviewModalBody">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
{{candidate.get_interviews}}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -463,7 +473,7 @@
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content kaauh-card"> <div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||
<h5 class="modal-title" id="candidateviewModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||
{% trans "Application Details / Bulk Action Form" %}
|
||||
{% comment %} {% trans "Candidate Details / Bulk Action Form" %} {% endcomment %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
@ -476,11 +486,9 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Email Modal -->
|
||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
|
||||
@ -231,7 +231,7 @@
|
||||
|
||||
{# Separator (Vertical Rule) #}
|
||||
<div class="vr" style="height: 28px;"></div>
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
hx-boost='true'
|
||||
data-bs-target="#emailModal"
|
||||
|
||||
@ -344,7 +344,7 @@
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||
</button>
|
||||
{# email button#}
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
hx-boost='true'
|
||||
data-bs-target="#emailModal"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user