Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend

This commit is contained in:
Faheed 2025-11-27 16:28:42 +03:00
commit 1253b86b02
45 changed files with 7259 additions and 4508 deletions

6
.env
View File

@ -1,3 +1,3 @@
DB_NAME=haikal_db DB_NAME=norahuniversity
DB_USER=faheed DB_USER=norahuniversity
DB_PASSWORD=Faheed@215 DB_PASSWORD=norahuniversity

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,768 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-17 09:52
import django.contrib.auth.models
import django.contrib.auth.validators
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
import django_ckeditor_5.fields
import django_countries.fields
import django_extensions.db.fields
import recruitment.validators
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='BreakTime',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('start_time', models.TimeField(verbose_name='Start Time')),
('end_time', models.TimeField(verbose_name='End Time')),
],
),
migrations.CreateModel(
name='FormStage',
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')),
('name', models.CharField(help_text='Name of the stage', max_length=200)),
('order', models.PositiveIntegerField(default=0, help_text='Order of the stage in the form')),
('is_predefined', models.BooleanField(default=False, help_text='Whether this is a default resume stage')),
],
options={
'verbose_name': 'Form Stage',
'verbose_name_plural': 'Form Stages',
'ordering': ['order'],
},
),
migrations.CreateModel(
name='InterviewLocation',
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')),
('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')),
],
options={
'verbose_name': 'Interview Location',
'verbose_name_plural': 'Interview Locations',
},
),
migrations.CreateModel(
name='Participants',
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')),
('name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Participant Name')),
('email', models.EmailField(max_length=254, verbose_name='Email')),
('phone', models.CharField(blank=True, max_length=12, null=True, verbose_name='Phone Number')),
('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Source',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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')),
('name', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, unique=True, verbose_name='Source Name')),
('source_type', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, verbose_name='Source Type')),
('description', models.TextField(blank=True, help_text='A description of the source', verbose_name='Description')),
('ip_address', models.GenericIPAddressField(blank=True, help_text='The IP address of the source', null=True, verbose_name='IP Address')),
('created_at', models.DateTimeField(auto_now_add=True)),
('api_key', models.CharField(blank=True, help_text='API key for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Key')),
('api_secret', models.CharField(blank=True, help_text='API secret for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Secret')),
('trusted_ips', models.TextField(blank=True, help_text='Comma-separated list of trusted IP addresses', null=True, verbose_name='Trusted IP Addresses')),
('is_active', models.BooleanField(default=True, help_text='Whether this source is active for integration', verbose_name='Active')),
('integration_version', models.CharField(blank=True, help_text='Version of the integration protocol', max_length=50, verbose_name='Integration Version')),
('last_sync_at', models.DateTimeField(blank=True, help_text='Timestamp of the last successful synchronization', null=True, verbose_name='Last Sync At')),
('sync_status', models.CharField(blank=True, choices=[('IDLE', 'Idle'), ('SYNCING', 'Syncing'), ('ERROR', 'Error'), ('DISABLED', 'Disabled')], default='IDLE', max_length=20, verbose_name='Sync Status')),
('sync_endpoint', models.URLField(blank=True, help_text='Endpoint URL for sending candidate data (for outbound sync)', null=True, verbose_name='Sync Endpoint')),
('sync_method', models.CharField(blank=True, choices=[('POST', 'POST'), ('PUT', 'PUT')], default='POST', help_text='HTTP method for outbound sync requests', max_length=10, verbose_name='Sync Method')),
('test_method', models.CharField(blank=True, choices=[('GET', 'GET'), ('POST', 'POST')], default='GET', help_text='HTTP method for connection testing', max_length=10, verbose_name='Test Method')),
('custom_headers', models.TextField(blank=True, help_text='JSON object with custom HTTP headers for sync requests', null=True, verbose_name='Custom Headers')),
('supports_outbound_sync', models.BooleanField(default=False, help_text='Whether this source supports receiving candidate data from ATS', verbose_name='Supports Outbound Sync')),
],
options={
'verbose_name': 'Source',
'verbose_name_plural': 'Sources',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('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')),
('user_type', models.CharField(choices=[('staff', 'Staff'), ('agency', 'Agency'), ('candidate', 'Candidate')], default='staff', max_length=20, verbose_name='User Type')),
('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')),
('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')),
],
options={
'verbose_name': 'User',
'verbose_name_plural': 'Users',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='FormField',
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')),
('label', models.CharField(help_text='Label for the field', max_length=200)),
('field_type', models.CharField(choices=[('text', 'Text Input'), ('email', 'Email'), ('phone', 'Phone'), ('textarea', 'Text Area'), ('file', 'File Upload'), ('date', 'Date Picker'), ('select', 'Dropdown'), ('radio', 'Radio Buttons'), ('checkbox', 'Checkboxes')], help_text='Type of the field', max_length=20)),
('placeholder', models.CharField(blank=True, help_text='Placeholder text', max_length=200)),
('required', models.BooleanField(default=False, help_text='Whether the field is required')),
('order', models.PositiveIntegerField(default=0, help_text='Order of the field in the stage')),
('is_predefined', models.BooleanField(default=False, help_text='Whether this is a default field')),
('options', models.JSONField(blank=True, default=list, help_text='Options for selection fields (stored as JSON array)')),
('file_types', models.CharField(blank=True, help_text="Allowed file types (comma-separated, e.g., '.pdf,.doc,.docx')", max_length=200)),
('max_file_size', models.PositiveIntegerField(default=5, help_text='Maximum file size in MB (default: 5MB)')),
('multiple_files', models.BooleanField(default=False, help_text='Allow multiple files to be uploaded')),
('max_files', models.PositiveIntegerField(default=1, help_text='Maximum number of files allowed (when multiple_files is True)')),
('stage', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='recruitment.formstage')),
],
options={
'verbose_name': 'Form Field',
'verbose_name_plural': 'Form Fields',
'ordering': ['order'],
},
),
migrations.CreateModel(
name='FormTemplate',
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')),
('name', models.CharField(help_text='Name of the form template', max_length=200)),
('description', models.TextField(blank=True, help_text='Description of the form template')),
('is_active', models.BooleanField(default=False, help_text='Whether this template is active')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Form Template',
'verbose_name_plural': 'Form Templates',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FormSubmission',
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')),
('submitted_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('applicant_name', models.CharField(blank=True, help_text='Name of the applicant', max_length=200)),
('applicant_email', models.EmailField(blank=True, db_index=True, help_text='Email of the applicant', max_length=254)),
('submitted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='form_submissions', to=settings.AUTH_USER_MODEL)),
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='recruitment.formtemplate')),
],
options={
'verbose_name': 'Form Submission',
'verbose_name_plural': 'Form Submissions',
'ordering': ['-submitted_at'],
},
),
migrations.AddField(
model_name='formstage',
name='template',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='recruitment.formtemplate'),
),
migrations.CreateModel(
name='HiringAgency',
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')),
('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')),
('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')),
('email', models.EmailField(blank=True, max_length=254)),
('phone', models.CharField(blank=True, max_length=20)),
('website', models.URLField(blank=True)),
('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)),
('address', models.TextField(blank=True, null=True)),
('generated_password', models.CharField(blank=True, help_text='Generated password for agency user account', max_length=255, null=True)),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='agency_profile', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'Hiring Agency',
'verbose_name_plural': 'Hiring Agencies',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Application',
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')),
('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')),
('cover_letter', models.FileField(blank=True, null=True, upload_to='cover_letters/', verbose_name='Cover Letter')),
('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')),
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
('applied', models.BooleanField(default=False, verbose_name='Applied')),
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Document Review', 'Document Review'), ('Offer', 'Offer'), ('Hired', 'Hired'), ('Rejected', 'Rejected')], db_index=True, default='Applied', max_length=20, verbose_name='Stage')),
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=20, null=True, verbose_name='Applicant Status')),
('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Exam Status')),
('exam_score', models.FloatField(blank=True, null=True, verbose_name='Exam Score')),
('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Interview Status')),
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected'), ('Pending', 'Pending')], max_length=20, null=True, verbose_name='Offer Status')),
('hired_date', models.DateField(blank=True, null=True, verbose_name='Hired Date')),
('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
('ai_analysis_data', models.JSONField(blank=True, default=dict, help_text='Full JSON output from the resume scoring model.', null=True, verbose_name='AI Analysis Data')),
('retry', models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry')),
('hiring_source', models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source')),
('hiring_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applications', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
],
options={
'verbose_name': 'Application',
'verbose_name_plural': 'Applications',
},
),
migrations.CreateModel(
name='OnsiteLocationDetails',
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)),
],
options={
'verbose_name': 'Onsite Location Details',
'verbose_name_plural': 'Onsite Location Details',
},
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',
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')),
('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)),
('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)),
('description', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Description')),
('qualifications', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
('salary_range', models.CharField(blank=True, help_text='e.g., $60,000 - $80,000', max_length=200)),
('benefits', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
('application_url', models.URLField(blank=True, help_text='URL where candidates apply', null=True, validators=[django.core.validators.URLValidator()])),
('application_deadline', models.DateField(db_index=True)),
('application_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
('internal_job_id', models.CharField(editable=False, max_length=50)),
('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)),
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20)),
('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])),
('linkedin_post_id', models.CharField(blank=True, help_text='LinkedIn post ID after posting', max_length=200)),
('linkedin_post_url', models.URLField(blank=True, help_text='Direct URL to LinkedIn post')),
('posted_to_linkedin', models.BooleanField(default=False)),
('linkedin_post_status', models.CharField(blank=True, help_text='Status of LinkedIn posting', max_length=50)),
('linkedin_posted_at', models.DateTimeField(blank=True, null=True)),
('linkedin_post_formated_data', models.TextField(blank=True, null=True)),
('published_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)),
('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)),
('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')),
('max_applications', models.PositiveIntegerField(blank=True, default=1000, help_text='Maximum number of applications allowed', null=True)),
('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')),
('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')),
('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')),
],
options={
'verbose_name': 'Job Posting',
'verbose_name_plural': 'Job Postings',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='InterviewSchedule',
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')),
('start_time', models.TimeField(verbose_name='Start Time')),
('end_time', models.TimeField(verbose_name='End Time')),
('break_start_time', models.TimeField(blank=True, null=True, verbose_name='Break Start Time')),
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
('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)')),
('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',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.jobposting', verbose_name='Job'),
),
migrations.CreateModel(
name='AgencyJobAssignment',
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')),
('max_candidates', models.PositiveIntegerField(help_text='Maximum candidates agency can submit for this job', verbose_name='Maximum Candidates')),
('candidates_submitted', models.PositiveIntegerField(default=0, help_text='Number of candidates submitted so far', verbose_name='Candidates Submitted')),
('assigned_date', models.DateTimeField(auto_now_add=True, verbose_name='Assigned Date')),
('deadline_date', models.DateTimeField(help_text='Deadline for agency to submit candidates', verbose_name='Deadline Date')),
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
('status', models.CharField(choices=[('ACTIVE', 'Active'), ('COMPLETED', 'Completed'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='ACTIVE', max_length=20, verbose_name='Status')),
('deadline_extended', models.BooleanField(default=False, verbose_name='Deadline Extended')),
('original_deadline', models.DateTimeField(blank=True, help_text='Original deadline before extensions', null=True, verbose_name='Original Deadline')),
('admin_notes', models.TextField(blank=True, help_text='Internal notes about this assignment', verbose_name='Admin Notes')),
('agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_assignments', to='recruitment.hiringagency', verbose_name='Agency')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agency_assignments', to='recruitment.jobposting', verbose_name='Job')),
],
options={
'verbose_name': 'Agency Job Assignment',
'verbose_name_plural': 'Agency Job Assignments',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='JobPostingImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('post_image', models.ImageField(upload_to='post/', validators=[recruitment.validators.validate_image_size])),
('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')),
],
),
migrations.CreateModel(
name='Message',
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')),
('subject', models.CharField(max_length=200, verbose_name='Subject')),
('content', models.TextField(verbose_name='Message Content')),
('message_type', models.CharField(choices=[('direct', 'Direct Message'), ('job_related', 'Job Related'), ('system', 'System Notification')], default='direct', max_length=20, verbose_name='Message Type')),
('is_read', models.BooleanField(default=False, verbose_name='Is Read')),
('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.jobposting', verbose_name='Related Job')),
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')),
],
options={
'verbose_name': 'Message',
'verbose_name_plural': 'Messages',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField(verbose_name='Notification Message')),
('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')),
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')),
('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')),
('last_error', models.TextField(blank=True, verbose_name='Last Error Message')),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
],
options={
'verbose_name': 'Notification',
'verbose_name_plural': 'Notifications',
'ordering': ['-scheduled_for', '-created_at'],
},
),
migrations.CreateModel(
name='Person',
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')),
('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')),
('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')),
('gpa', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True, verbose_name='GPA')),
('nationality', django_countries.fields.CountryField(blank=True, max_length=2, null=True, verbose_name='Nationality')),
('address', models.TextField(blank=True, null=True, verbose_name='Address')),
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')),
('linkedin_profile', models.URLField(blank=True, null=True, verbose_name='LinkedIn Profile URL')),
('agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recruitment.hiringagency', verbose_name='Hiring Agency')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='person_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account')),
],
options={
'verbose_name': 'Person',
'verbose_name_plural': 'People',
},
),
migrations.AddField(
model_name='application',
name='person',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.person', verbose_name='Person'),
),
migrations.CreateModel(
name='ScheduledInterview',
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')),
('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
('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')),
('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')),
('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=[
('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')),
('is_public', models.BooleanField(default=False, help_text='Whether this template is publicly available')),
('shared_with', models.ManyToManyField(blank=True, related_name='shared_templates', to=settings.AUTH_USER_MODEL)),
('template', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='recruitment.formtemplate')),
],
options={
'verbose_name': 'Shared Form Template',
'verbose_name_plural': 'Shared Form Templates',
},
),
migrations.CreateModel(
name='IntegrationLog',
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')),
('action', models.CharField(choices=[('REQUEST', 'Request'), ('RESPONSE', 'Response'), ('ERROR', 'Error'), ('SYNC', 'Sync'), ('CREATE_JOB', 'Create Job'), ('UPDATE_JOB', 'Update Job')], max_length=20, verbose_name='Action')),
('endpoint', models.CharField(blank=True, max_length=255, verbose_name='Endpoint')),
('method', models.CharField(blank=True, max_length=50, verbose_name='HTTP Method')),
('request_data', models.JSONField(blank=True, null=True, verbose_name='Request Data')),
('response_data', models.JSONField(blank=True, null=True, verbose_name='Response Data')),
('status_code', models.CharField(blank=True, max_length=10, verbose_name='Status Code')),
('error_message', models.TextField(blank=True, verbose_name='Error Message')),
('ip_address', models.GenericIPAddressField(verbose_name='IP Address')),
('user_agent', models.CharField(blank=True, max_length=255, verbose_name='User Agent')),
('processing_time', models.FloatField(blank=True, null=True, verbose_name='Processing Time (seconds)')),
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integration_logs', to='recruitment.source', verbose_name='Source')),
],
options={
'verbose_name': 'Integration Log',
'verbose_name_plural': 'Integration Logs',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='TrainingMaterial',
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')),
('title', models.CharField(max_length=255, verbose_name='Title')),
('content', django_ckeditor_5.fields.CKEditor5Field(blank=True, verbose_name='Content')),
('video_link', models.URLField(blank=True, verbose_name='Video Link')),
('file', models.FileField(blank=True, upload_to='training_materials/', verbose_name='File')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Created by')),
],
options={
'verbose_name': 'Training Material',
'verbose_name_plural': 'Training Materials',
},
),
migrations.CreateModel(
name='AgencyAccessLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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')),
('unique_token', models.CharField(editable=False, max_length=64, unique=True, verbose_name='Unique Token')),
('access_password', models.CharField(help_text='Password for agency access', max_length=32, verbose_name='Access Password')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('expires_at', models.DateTimeField(help_text='When this access link expires', verbose_name='Expires At')),
('last_accessed', models.DateTimeField(blank=True, null=True, verbose_name='Last Accessed')),
('access_count', models.PositiveIntegerField(default=0, verbose_name='Access Count')),
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
('assignment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='access_link', to='recruitment.agencyjobassignment', verbose_name='Assignment')),
],
options={
'verbose_name': 'Agency Access Link',
'verbose_name_plural': 'Agency Access Links',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['unique_token'], name='recruitment_unique__f91e76_idx'), models.Index(fields=['expires_at'], name='recruitment_expires_954ed9_idx'), models.Index(fields=['is_active'], name='recruitment_is_acti_4b0804_idx')],
},
),
migrations.CreateModel(
name='Document',
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')),
('object_id', models.PositiveIntegerField(verbose_name='Object ID')),
('file', models.FileField(upload_to='documents/%Y/%m/', validators=[recruitment.validators.validate_image_size], verbose_name='Document File')),
('document_type', models.CharField(choices=[('resume', 'Resume'), ('cover_letter', 'Cover Letter'), ('certificate', 'Certificate'), ('id_document', 'ID Document'), ('passport', 'Passport'), ('education', 'Education Document'), ('experience', 'Experience Letter'), ('other', 'Other')], default='other', max_length=20, verbose_name='Document Type')),
('description', models.CharField(blank=True, max_length=200, verbose_name='Description')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Uploaded By')),
],
options={
'verbose_name': 'Document',
'verbose_name_plural': 'Documents',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['content_type', 'object_id', 'document_type', 'created_at'], name='recruitment_content_547650_idx')],
},
),
migrations.CreateModel(
name='FieldResponse',
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')),
('value', models.JSONField(blank=True, help_text='Response value (stored as JSON)', null=True)),
('uploaded_file', models.FileField(blank=True, null=True, upload_to='form_uploads/')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formfield')),
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formsubmission')),
],
options={
'verbose_name': 'Field Response',
'verbose_name_plural': 'Field Responses',
'indexes': [models.Index(fields=['submission'], name='recruitment_submiss_474130_idx'), models.Index(fields=['field'], name='recruitment_field_i_097e5b_idx')],
},
),
migrations.AddIndex(
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'),
),
migrations.AddIndex(
model_name='formtemplate',
index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['job', 'status'], name='recruitment_job_id_d798a8_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['deadline_date'], name='recruitment_deadlin_57d3b4_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['is_active'], name='recruitment_is_acti_93b919_idx'),
),
migrations.AlterUniqueTogether(
name='agencyjobassignment',
unique_together={('agency', 'job')},
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['sender', 'created_at'], name='recruitment_sender__49d984_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['recipient', 'is_read', 'created_at'], name='recruitment_recipie_af0e6d_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['job', 'created_at'], name='recruitment_job_id_18f813_idx'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'),
),
migrations.AddIndex(
model_name='person',
index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'),
),
migrations.AddIndex(
model_name='person',
index=models.Index(fields=['first_name', 'last_name'], name='recruitment_first_n_739de5_idx'),
),
migrations.AddIndex(
model_name='person',
index=models.Index(fields=['created_at'], name='recruitment_created_33495a_idx'),
),
migrations.AddIndex(
model_name='application',
index=models.Index(fields=['person', 'job'], name='recruitment_person__34355c_idx'),
),
migrations.AddIndex(
model_name='application',
index=models.Index(fields=['stage'], name='recruitment_stage_52c2d1_idx'),
),
migrations.AddIndex(
model_name='application',
index=models.Index(fields=['created_at'], name='recruitment_created_80633f_idx'),
),
migrations.AddIndex(
model_name='application',
index=models.Index(fields=['person', 'stage', 'created_at'], name='recruitment_person__8715ec_idx'),
),
migrations.AlterUniqueTogether(
name='application',
unique_together={('person', 'job')},
),
migrations.AddIndex(
model_name='scheduledinterview',
index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'),
),
migrations.AddIndex(
model_name='scheduledinterview',
index=models.Index(fields=['interview_date', 'interview_time'], name='recruitment_intervi_7f5877_idx'),
),
migrations.AddIndex(
model_name='scheduledinterview',
index=models.Index(fields=['application', 'job'], name='recruitment_applica_927561_idx'),
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
),
migrations.AddIndex(
model_name='notification',
index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'),
),
migrations.AddIndex(
model_name='notification',
index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-11-25 12:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0006_alter_customuser_email'),
]
operations = [
migrations.AlterField(
model_name='person',
name='email',
field=models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='Email'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,146 +1,191 @@
annotated-types amqp==5.3.1
appdirs annotated-types==0.7.0
asgiref anthropic==0.63.0
asteval anyio==4.11.0
astunparse appdirs==1.4.4
attrs arrow==1.3.0
blinker asgiref==3.9.2
blis asteval==1.0.6
boto3 astunparse==1.6.3
botocore attrs==25.3.0
bw-migrations billiard==4.2.2
bw2parameters bleach==6.2.0
bw_processing blessed==1.22.0
cached-property blinker==1.9.0
catalogue blis==1.3.0
certifi boto3==1.40.37
channels botocore==1.40.37
chardet bw-migrations==0.2
charset-normalizer bw2data==4.5
click bw2parameters==1.1.0
cloudpathlib bw_processing==1.0
confection cached-property==2.0.1
constructive_geometries catalogue==2.0.10
country_converter celery==5.5.3
cymem certifi==2025.8.3
dataflows-tabulator channels==4.3.1
datapackage chardet==5.2.0
deepdiff charset-normalizer==3.4.3
Deprecated click==8.3.0
Django click-didyoumean==0.3.1
django-allauth click-plugins==1.1.1.2
django-cors-headers click-repl==0.3.0
django-filter cloudpathlib==0.22.0
django-unfold confection==0.1.5
djangorestframework constructive_geometries==1.0
docopt country_converter==1.3.1
crispy-bootstrap5==2025.6
cymem==2.0.11
dataflows-tabulator==1.54.3
datapackage==1.15.4
datastar-py==0.6.5
deepdiff==7.0.1
Deprecated==1.2.18
distro==1.9.0
Django==5.2.6
django-allauth==65.11.2
django-ckeditor-5==0.2.18
django-cors-headers==4.9.0
django-countries==7.6.1
django-crispy-forms==2.4
django-easy-audit==1.3.7
django-extensions==4.1
django-filter==25.1
django-picklefield==3.3
django-q2==1.8.0
django-summernote==0.8.20.0
django-template-partials==25.2
django-unfold==0.66.0
django-widget-tweaks==1.5.0
django_celery_results==2.6.0
djangorestframework==3.16.1
docopt==0.6.2
en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl#sha256=1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85 en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl#sha256=1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85
et_xmlfile et_xmlfile==2.0.0
Faker Faker==37.8.0
flexcache flexcache==0.3
flexparser flexparser==0.4
fsspec fsspec==2025.9.0
idna gpt-po-translator==1.3.2
ijson greenlet==3.2.4
isodate h11==0.16.0
Jinja2 httpcore==1.0.9
jmespath httpx==0.28.1
jsonlines idna==3.10
jsonpointer ijson==3.4.0
jsonschema iniconfig==2.1.0
jsonschema-specifications isodate==0.7.2
langcodes isort==5.13.2
language_data Jinja2==3.1.6
linear-tsv jiter==0.11.1
llvmlite jmespath==1.0.1
loguru jsonlines==4.0.0
lxml jsonpointer==3.0.0
marisa-trie jsonschema==4.25.1
markdown-it-py jsonschema-specifications==2025.9.1
MarkupSafe kombu==5.5.4
matrix_utils langcodes==3.5.0
mdurl language_data==1.3.0
morefs linear-tsv==1.1.0
mrio-common-metadata llvmlite==0.45.0
murmurhash loguru==0.7.3
numba lxml==6.0.2
numpy marisa-trie==1.3.1
openpyxl markdown-it-py==4.0.0
ordered-set MarkupSafe==3.0.2
packaging matrix_utils==0.6.2
pandas mdurl==0.1.2
peewee morefs==0.2.2
Pint mrio-common-metadata==0.2.1
platformdirs murmurhash==1.0.13
preshed numba==0.62.0
prettytable numpy==2.3.3
pydantic openai==1.99.9
pydantic-settings openpyxl==3.1.5
pydantic_core ordered-set==4.1.0
pyecospold packaging==25.0
Pygments pandas==2.3.2
PyJWT peewee==3.18.2
PyMuPDF pillow==11.3.0
pyparsing Pint==0.25
PyPrind platformdirs==4.4.0
python-dateutil pluggy==1.6.0
python-dotenv polib==1.2.0
python-json-logger preshed==3.0.10
pytz prettytable==3.16.0
pyxlsb prompt_toolkit==3.0.52
PyYAML psycopg2-binary==2.9.11
randonneur pycountry==24.6.1
randonneur_data pydantic==2.11.9
RapidFuzz pydantic-settings==2.10.1
rdflib pydantic_core==2.33.2
referencing pyecospold==4.0.0
requests Pygments==2.19.2
rfc3986 PyJWT==2.10.1
rich PyMuPDF==1.26.4
rpds-py pyparsing==3.2.5
s3transfer PyPDF2==3.0.1
scipy PyPrind==2.11.3
shellingham pytest==8.3.4
six pytest-django==4.11.1
smart-open python-dateutil==2.9.0.post0
snowflake-id python-docx==1.2.0
spacy python-dotenv==1.0.1
spacy-legacy python-json-logger==3.3.0
spacy-loggers pytz==2025.2
SPARQLWrapper pyxlsb==1.0.10
sparse PyYAML==6.0.2
SQLAlchemy randonneur==0.6.2
sqlparse randonneur_data==0.6
srsly RapidFuzz==3.14.1
stats_arrays rdflib==7.2.1
structlog redis==3.5.3
tableschema referencing==0.36.2
thinc requests==2.32.3
toolz responses==0.25.8
tqdm rfc3986==2.0.0
typer rich==14.1.0
typing-inspection rpds-py==0.27.1
typing_extensions s3transfer==0.14.0
tzdata scipy==1.16.2
unicodecsv setuptools==80.9.0
urllib3 setuptools-scm==8.1.0
voluptuous shellingham==1.5.4
wasabi six==1.17.0
wcwidth smart_open==7.3.1
weasel sniffio==1.3.1
wrapt snowflake-id==1.0.2
wurst spacy==3.8.7
xlrd spacy-legacy==3.0.12
XlsxWriter spacy-loggers==1.0.5
celery[redis] SPARQLWrapper==2.0.0
redis sparse==0.17.0
sentence-transformers SQLAlchemy==2.0.43
torch sqlparse==0.5.3
pdfplumber srsly==2.5.1
python-docx stats_arrays==0.7
PyMuPDF structlog==25.4.0
pytesseract tableschema==1.21.0
Pillow tenacity==9.0.0
python-dotenv thinc==8.3.6
django-countries tomli==2.2.1
django-q2 toolz==1.0.0
tqdm==4.67.1
typer==0.19.2
types-python-dateutil==2.9.0.20251008
typing-inspection==0.4.1
typing_extensions==4.15.0
tzdata==2025.2
unicodecsv==0.14.1
urllib3==2.5.0
vine==5.1.0
voluptuous==0.15.2
wasabi==1.1.3
wcwidth==0.2.14
weasel==0.4.1
webencodings==0.5.1
wheel==0.45.1
wrapt==1.17.3
wurst==0.4
xlrd==2.0.2
xlsxwriter==3.2.9

View File

@ -7,10 +7,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KAAUH ATS - Sign In (Bootstrap)</title> <title>KAAUH ATS - Sign In (Bootstrap)</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<style> <style>
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* CUSTOM TEAL THEME OVERRIDES FOR BOOTSTRAP */ /* CUSTOM TEAL THEME OVERRIDES FOR BOOTSTRAP */
@ -20,7 +20,7 @@
--bs-primary: #00636e; /* Dark Teal */ --bs-primary: #00636e; /* Dark Teal */
--bs-primary-rgb: 0, 99, 110; --bs-primary-rgb: 0, 99, 110;
--bs-primary-light: #007a88; /* Lighter Teal for hover */ --bs-primary-light: #007a88; /* Lighter Teal for hover */
/* Background and Text Colors */ /* Background and Text Colors */
--bs-body-bg: #f8f9fa; /* Light gray background */ --bs-body-bg: #f8f9fa; /* Light gray background */
--bs-body-color: #212529; /* Dark text */ --bs-body-color: #212529; /* Dark text */
@ -28,7 +28,7 @@
/* Utility colors */ /* Utility colors */
--bs-border-color: #dee2e6; /* Bootstrap default border */ --bs-border-color: #dee2e6; /* Bootstrap default border */
} }
body { body {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
background-color: var(--bs-body-bg); background-color: var(--bs-body-bg);
@ -77,8 +77,8 @@
background-color: var(--bs-primary); background-color: var(--bs-primary);
border-color: var(--bs-primary); border-color: var(--bs-primary);
font-weight: 600; font-weight: 600;
border-radius: 0.5rem; border-radius: 0.5rem;
box-shadow: 0 4px 8px rgba(0, 99, 110, 0.2); box-shadow: 0 4px 8px rgba(0, 99, 110, 0.2);
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.btn-primary:hover { .btn-primary:hover {
@ -101,7 +101,7 @@
color: var(--bs-primary-light) !important; color: var(--bs-primary-light) !important;
text-decoration: underline; text-decoration: underline;
} }
/* ADJUSTED: Custom size adjustment for right panel on desktop */ /* ADJUSTED: Custom size adjustment for right panel on desktop */
@media (min-width: 992px) { @media (min-width: 992px) {
/* 1. Set a NARROWER fixed width for the right panel container */ /* 1. Set a NARROWER fixed width for the right panel container */
@ -133,8 +133,8 @@
<h1 class="text-4xl font-weight-bold mb-4" style="font-size: 1.5rem;"> <h1 class="text-4xl font-weight-bold mb-4" style="font-size: 1.5rem;">
<span class="text-white"> <span class="text-white">
<div class="hospital-text text-center text-md-start me-3"> <div class="hospital-text text-center text-md-start me-3">
<div class="ar small">جامعة الأميرة نورة بنت عبدالرحمن الأكاديمية</div> <div class="ar small">جامعة الأميرة نورة بنت عبدالرحمن</div>
<div class="ar small">ومستشفى الملك عبدالله بن عبدالعزيز التخصصي</div> <div class="ar small">ومستشفى الملك عبدالله بن عبدالعزيز الجامعي</div>
<div class="en small">Princess Nourah bint Abdulrahman University</div> <div class="en small">Princess Nourah bint Abdulrahman University</div>
<div class="en small">King Abdullah bin Abdulaziz University Hospital</div> <div class="en small">King Abdullah bin Abdulaziz University Hospital</div>
</div> </div>
@ -145,24 +145,24 @@
</div> </div>
<div class="d-flex flex-column right-panel right-panel-col flex-grow-1 align-items-center justify-content-center"> <div class="d-flex flex-column right-panel right-panel-col flex-grow-1 align-items-center justify-content-center">
<div class="right-panel-content-wrapper"> <div class="right-panel-content-wrapper">
<h2 id="form-title" class="h3 fw-bold mb-4 text-center">{% trans "Sign In" %}</h2> <h2 id="form-title" class="h3 fw-bold mb-4 text-center">{% trans "Sign In" %}</h2>
<div class="form-fields"> <div class="form-fields">
<form id="login-form" class="space-y-4" method="post" action="{% url 'account_login' %}"> <form id="login-form" class="space-y-4" method="post" action="{% url 'account_login' %}">
{% csrf_token %} {% csrf_token %}
<div class="mb-3"> <div class="mb-3">
<label for="id_login" class="form-label fw-semibold"> {% trans "Email *" %}</label> <label for="id_login" class="form-label fw-semibold"> {% trans "Email *" %}</label>
<input type="text" name="login" id="id_login" class="form-control" placeholder="{% trans 'Enter your email' %}" required autofocus> <input type="text" name="login" id="id_login" class="form-control" placeholder="{% trans 'Enter your email' %}" required autofocus>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="id_password" class="form-label fw-semibold">{% trans "Password *" %}</label> <label for="id_password" class="form-label fw-semibold">{% trans "Password *" %}</label>
<input type="password" name="password" id="id_password" class="form-control" placeholder="{% trans 'Password' %}" required> <input type="password" name="password" id="id_password" class="form-control" placeholder="{% trans 'Password' %}" required>
<div class="text-end mt-2"> <div class="text-end mt-2">
<a href="{% url 'account_reset_password' %}" class="small text-accent fw-medium">{% trans 'Forgot Password?' %}</a> <a href="{% url 'account_reset_password' %}" class="small text-accent fw-medium">{% trans 'Forgot Password?' %}</a>
</div> </div>
@ -174,7 +174,7 @@
{% trans "Keep me signed in" %} {% trans "Keep me signed in" %}
</label> </label>
</div> </div>
<button type="submit" class="btn btn-primary w-100 mt-4">{% trans "Sign In" %}</button> <button type="submit" class="btn btn-primary w-100 mt-4">{% trans "Sign In" %}</button>
</form> </form>
</div> </div>
@ -182,7 +182,7 @@
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body> </body>
</html> </html>

View File

@ -35,13 +35,10 @@
</div> </div>
<div class="logo-container d-flex gap-2 align-items-center"> <div class="logo-container d-flex gap-2 align-items-center">
<img src="{% static 'image/vision.svg' %}" alt="{% trans 'Saudi Vision 2030' %}" loading="lazy" style="height: 35px; object-fit: contain;"> <img src="{% static 'image/vision.svg' %}" alt="{% trans 'Saudi Vision 2030' %}" loading="lazy" style="height: 35px; object-fit: contain;">
<div class="kaauh-logo-container d-flex flex-column flex-md-row align-items-center gap-2 me-0">
<div class="kaauh-logo-container d-flex flex-column flex-md-row align-items-center gap-2 me-0">
<div class="hospital-text text-center text-md-start me-0"> <div class="hospital-text text-center text-md-start me-0">
<div class="ar text-xs">جامعة الأميرة نورة بنت عبدالرحمن الأكاديمية</div> <div class="en text-xs">{% trans "Princess Nourah bint Abdulrahman University"%}</div>
<div class="ar text-xs">ومستشفى الملك عبدالله بن عبدالرحمن التخصصي</div> <div class="en text-xs">{% trans "King Abdullah bin Abdulaziz University Hospital"%}</div>
<div class="en text-xs">Princess Nourah bint Abdulrahman University</div>
<div class="en text-xs">King Abdullah bin Abdulaziz University Hospital</div>
</div> </div>
</div> </div>
<img src="{% static 'image/kaauh.png' %}" alt="KAAUH Logo" style="max-height: 40px; max-width: 40px;"> <img src="{% static 'image/kaauh.png' %}" alt="KAAUH Logo" style="max-height: 40px; max-width: 40px;">
@ -277,7 +274,7 @@
</a> </a>
</li> </li>
<li class="nav-item me-lg-4"> <li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'list_meetings' %}"> <a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'interview_list' %}">
<span class="d-flex align-items-center gap-2"> <span class="d-flex align-items-center gap-2">
<i class="fas fa-calendar-check me-2"></i> <i class="fas fa-calendar-check me-2"></i>
{% trans "Meetings" %} {% trans "Meetings" %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,372 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{% trans "Delete Person" %} - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
/* KAAT-S UI Variables */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-danger: #dc3545;
--kaauh-warning: #ffc107;
}
/* 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;
}
/* Warning Section */
.warning-section {
background: linear-gradient(135deg, #fff3cd 0%, #ffeeba 100%);
border: 1px solid #ffeeba;
border-radius: 0.75rem;
padding: 2rem;
margin-bottom: 2rem;
text-align: center;
}
.warning-icon {
font-size: 4rem;
color: var(--kaauh-warning);
margin-bottom: 1rem;
}
.warning-title {
color: #856404;
font-weight: 700;
margin-bottom: 1rem;
}
.warning-text {
color: #856404;
margin-bottom: 0;
}
/* Person Info Card */
.person-info {
background-color: #f8f9fa;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 2rem;
border: 1px solid var(--kaauh-border);
}
.info-item {
display: flex;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e9ecef;
}
.info-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.info-icon {
width: 40px;
height: 40px;
background-color: var(--kaauh-teal);
color: white;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
margin-right: 1rem;
flex-shrink: 0;
}
.info-content {
flex: 1;
}
.info-label {
font-weight: 600;
color: var(--kaauh-primary-text);
margin-bottom: 0.25rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value {
color: #6c757d;
font-size: 1rem;
}
/* Button Styling */
.btn-danger {
background-color: var(--kaauh-danger);
border-color: var(--kaauh-danger);
color: white;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-danger:hover {
background-color: #c82333;
border-color: #bd2130;
box-shadow: 0 4px 8px rgba(220, 53, 69, 0.3);
}
.btn-secondary {
background-color: #6c757d;
border-color: #6c757d;
color: white;
font-weight: 600;
}
/* Consequence List */
.consequence-list {
list-style: none;
padding: 0;
margin: 0;
}
.consequence-list li {
padding: 0.5rem 0;
border-bottom: 1px solid #e9ecef;
color: #6c757d;
}
.consequence-list li:last-child {
border-bottom: none;
}
.consequence-list li i {
color: var(--kaauh-danger);
margin-right: 0.5rem;
}
/* Person Profile Image */
.person-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--kaauh-teal);
}
.avatar-placeholder {
width: 80px;
height: 80px;
border-radius: 50%;
background-color: #e9ecef;
display: flex;
align-items: center;
justify-content: center;
border: 3px solid var(--kaauh-teal);
}
</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-exclamation-triangle me-2"></i>
{% trans "Delete Person" %}
</h1>
<p class="text-muted mb-0">
{% trans "You are about to delete a person record. This action cannot be undone." %}
</p>
</div>
<a href="{% url 'person_detail' object.slug %}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Person" %}
</a>
</div>
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- Warning Section -->
<div class="warning-section">
<div class="warning-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<h3 class="warning-title">{% trans "Warning: This action cannot be undone!" %}</h3>
<p class="warning-text">
{% trans "Deleting this person will permanently remove all associated data. Please review the information below carefully before proceeding." %}
</p>
</div>
<!-- Person Information -->
<div class="card kaauh-card mb-4">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-user me-2"></i>
{% trans "Person to be Deleted" %}
</h5>
</div>
<div class="card-body">
<div class="person-info">
<div class="d-flex align-items-center mb-4">
{% if object.profile_image %}
<img src="{{ object.profile_image.url }}" alt="{{ object.get_full_name }}" class="person-avatar me-3">
{% else %}
<div class="avatar-placeholder me-3">
<i class="fas fa-user text-muted fa-2x"></i>
</div>
{% endif %}
<div>
<h4 class="mb-1">{{ object.get_full_name }}</h4>
{% if object.email %}
<p class="text-muted mb-0">{{ object.email }}</p>
{% endif %}
</div>
</div>
{% if object.phone %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-phone"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Phone" %}</div>
<div class="info-value">{{ object.phone }}</div>
</div>
</div>
{% endif %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-calendar"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Created On" %}</div>
<div class="info-value">{{ object.created_at|date:"F d, Y" }}</div>
</div>
</div>
{% if object.nationality %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-globe"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Nationality" %}</div>
<div class="info-value">{{ object.nationality }}</div>
</div>
</div>
{% endif %}
{% if object.gender %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-venus-mars"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Gender" %}</div>
<div class="info-value">{{ object.get_gender_display }}</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Consequences -->
<div class="card kaauh-card mb-4">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-list me-2"></i>
{% trans "What will happen when you delete this person?" %}
</h5>
</div>
<div class="card-body">
<ul class="consequence-list">
<li>
<i class="fas fa-times-circle"></i>
{% trans "The person profile and all personal information will be permanently deleted" %}
</li>
<li>
<i class="fas fa-times-circle"></i>
{% trans "All associated applications and documents will be removed" %}
</li>
<li>
<i class="fas fa-times-circle"></i>
{% trans "Any interview schedules and history will be deleted" %}
</li>
<li>
<i class="fas fa-times-circle"></i>
{% trans "All related data and records will be lost" %}
</li>
<li>
<i class="fas fa-times-circle"></i>
{% trans "This action cannot be undone under any circumstances" %}
</li>
</ul>
</div>
</div>
<!-- Confirmation Form -->
<div class="card kaauh-card">
<div class="card-body">
<form method="post" id="deleteForm">
{% csrf_token %}
<div class="mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="confirm_delete" name="confirm_delete" required>
<label class="form-check-label" for="confirm_delete">
<strong>{% trans "I understand that this action cannot be undone and I want to permanently delete this person." %}</strong>
</label>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{% url 'person_detail' object.slug %}" class="btn btn-secondary btn-lg">
<i class="fas fa-times me-2"></i>
{% trans "Cancel" %}
</a>
<button type="submit"
class="btn btn-danger btn-lg"
id="deleteButton"
disabled>
<i class="fas fa-trash me-2"></i>
{% trans "Delete Person Permanently" %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const confirmDeleteCheckbox = document.getElementById('confirm_delete');
const deleteButton = document.getElementById('deleteButton');
const deleteForm = document.getElementById('deleteForm');
function validateForm() {
const checkboxChecked = confirmDeleteCheckbox.checked;
deleteButton.disabled = !checkboxChecked;
if (checkboxChecked) {
deleteButton.classList.remove('btn-secondary');
deleteButton.classList.add('btn-danger');
} else {
deleteButton.classList.remove('btn-danger');
deleteButton.classList.add('btn-secondary');
}
}
confirmDeleteCheckbox.addEventListener('change', validateForm);
// Add confirmation before final submission
});
</script>
{% endblock %}

View File

@ -0,0 +1,60 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{% trans "Delete Person" %} - {{ block.super }}{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">
<i class="fas fa-exclamation-triangle me-2"></i>
{% trans "Confirm Deletion" %}
</h4>
</div>
<div class="card-body">
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>
{% trans "Warning: This action cannot be undone!" %}
</h5>
<p class="mb-0">
{% trans "You are about to permanently delete this person and all associated data." %}
</p>
</div>
<div class="text-center mb-4">
{% if person.profile_image %}
<img src="{{ person.profile_image.url }}" alt="{{ person.get_full_name }}"
class="rounded-circle mb-3" style="width: 100px; height: 100px; object-fit: cover;">
{% else %}
<div class="rounded-circle bg-light d-inline-flex align-items-center justify-content-center mb-3"
style="width: 100px; height: 100px;">
<i class="fas fa-user text-muted fa-2x"></i>
</div>
{% endif %}
<h5>{{ person.get_full_name }}</h5>
{% if person.email %}
<p class="text-muted mb-0">{{ person.email }}</p>
{% endif %}
</div>
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-between">
<a href="{% url 'person_update' person.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-1"></i> {% trans "Delete Person" %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -163,7 +163,7 @@
<div class="card mb-4 shadow-sm no-hover"> <div class="card mb-4 shadow-sm no-hover">
<div class="card-body"> <div class="card-body">
<div class="row g-4"> <div class="row g-4">
<div class="col-md-6"> <div class="col-md-6">
<label for="search" class="form-label small text-muted">{% trans "Search by Name or Email" %}</label> <label for="search" class="form-label small text-muted">{% trans "Search by Name or Email" %}</label>
<div class="input-group input-group-lg"> <div class="input-group input-group-lg">
@ -213,8 +213,8 @@
</div> </div>
</div> </div>
</div> </div>
{% if people_list %} {% if people_list %}
<div id="person-list"> <div id="person-list">
<!-- View Switcher --> <!-- View Switcher -->
@ -287,13 +287,13 @@
class="btn btn-outline-secondary" title="{% trans 'Edit' %}"> class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</a> </a>
<button type="button" class="btn btn-outline-danger" {% comment %} <button type="button" class="btn btn-outline-danger"
title="{% trans 'Delete' %}" title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-target="#deleteModal" data-bs-toggle="modal" data-bs-target="#deleteModal"
data-delete-url="{% url 'person_delete' person.slug %}" data-delete-url="{% url 'person_delete' person.slug %}"
data-item-name="{{ person.get_full_name }}"> data-item-name="{{ person.get_full_name }}">
<i class="fas fa-trash-alt"></i> <i class="fas fa-trash-alt"></i>
</button> </button> {% endcomment %}
{% endif %} {% endif %}
</div> </div>
</td> </td>

View File

@ -194,6 +194,9 @@
<a href="{% url 'person_detail' person.slug %}" class="btn btn-outline-secondary"> <a href="{% url 'person_detail' person.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-eye me-1"></i> {% trans "View Details" %} <i class="fas fa-eye me-1"></i> {% trans "View Details" %}
</a> </a>
<a href="{% url 'person_delete' person.slug %}" class="btn btn-danger">
<i class="fas fa-trash me-1"></i> {% trans "Delete" %}
</a>
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'person_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %} <i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %}
</a> </a>

View File

@ -1,217 +1,478 @@
{% extends 'base.html' %} {% extends "base.html" %}
{% load static i18n %} {% load static i18n widget_tweaks %}
{% block title %}{{ title }} - ATS{% endblock %} {% block title %}{{ title }} - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
/* UI Variables for the KAAT-S Theme */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-gray-light: #f8f9fa;
}
/* Form Container Styling */
.form-container {
max-width: 800px;
margin: 0 auto;
}
/* Card Styling */
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
/* Main Action Button Style */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1.5rem;
}
.btn-main-action:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Secondary Button Style */
.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);
}
/* Form Field Styling */
.form-control:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
.form-select:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
/* Breadcrumb Styling */
.breadcrumb {
background-color: transparent;
padding: 0;
margin-bottom: 1rem;
}
.breadcrumb-item + .breadcrumb-item::before {
content: ">";
color: var(--kaauh-teal);
}
/* Alert Styling */
.alert {
border-radius: 0.5rem;
border: none;
}
/* Loading State */
.btn.loading {
position: relative;
pointer-events: none;
opacity: 0.8;
}
.btn.loading::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
margin: auto;
border: 2px solid transparent;
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Current Profile Section */
.current-profile {
background-color: var(--kaauh-gray-light);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
}
.current-profile h6 {
color: var(--kaauh-teal-dark);
font-weight: 600;
margin-bottom: 0.75rem;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container py-4"> <div class="container-fluid py-4">
<!-- Header --> <div class="form-container">
<div class="d-flex justify-content-between align-items-center mb-4"> <!-- Breadcrumb Navigation -->
<div> <nav aria-label="breadcrumb">
<h1 class="h3 mb-1">{{ title }}</h1> <ol class="breadcrumb">
<p class="text-muted mb-0"> <li class="breadcrumb-item">
<a href="{% url 'agency_list' %}" class="text-decoration-none text-secondary">
<i class="fas fa-building me-1"></i> {% trans "Agencies" %}
</a>
</li>
{% if agency %} {% if agency %}
{% trans "Update the hiring agency information below." %} <li class="breadcrumb-item">
<a href="{% url 'agency_detail' agency.slug %}" class="text-decoration-none text-secondary">
{{ agency.name }}
</a>
</li>
<li class="breadcrumb-item active" aria-current="page"
style="
color: #F43B5E; /* Rosy Accent Color */
font-weight: 600;">{% trans "Update" %}</li>
{% else %} {% else %}
{% trans "Fill in the details to add a new hiring agency." %} <li class="breadcrumb-item active" aria-current="page"
style="
color: #F43B5E; /* Rosy Accent Color */
font-weight: 600;">{% trans "Create" %}</li>
{% endif %} {% endif %}
</p> </ol>
</nav>
<!-- Header -->
<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-building me-2"></i> {{ title }}
</h1>
<div class="d-flex gap-2">
{% if agency %}
<a href="{% url 'agency_detail' agency.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
</a>
<a href="{% url 'agency_delete' agency.slug %}" class="btn btn-danger">
<i class="fas fa-trash me-1"></i> {% trans "Delete" %}
</a>
{% endif %}
<a href="{% url 'agency_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %}
</a>
</div>
</div> </div>
<a href="{% url 'agency_list' %}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Agencies" %}
</a>
</div>
<!-- Form --> {% if agency %}
<div class="row"> <!-- Current Agency Info -->
<div class="col-lg-8"> <div class="card shadow-sm mb-4">
<div class="card"> <div class="card-body">
<div class="card-body"> <div class="current-profile">
{% if form.non_field_errors %} <h6><i class="fas fa-info-circle me-2"></i>{% trans "Currently Editing" %}</h6>
<div class="alert alert-danger" role="alert"> <div class="d-flex align-items-center">
<h5 class="alert-heading"> <div class="current-image d-flex align-items-center justify-content-center bg-light">
<i class="fas fa-exclamation-triangle me-2"></i> <i class="fas fa-building text-muted"></i>
{% trans "Please correct the errors below:" %}
</h5>
{% for error in form.non_field_errors %}
<p class="mb-0">{{ error }}</p>
{% endfor %}
</div> </div>
{% endif %} <div>
<h5 class="mb-1">{{ agency.name }}</h5>
<form method="post" novalidate> {% if agency.contact_person %}
{% csrf_token %} <p class="text-muted mb-0">{% trans "Contact" %}: {{ agency.contact_person }}</p>
<!-- Name -->
<div class="mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label">
{{ form.name.label }} <span class="text-danger">*</span>
</label>
{{ form.name }}
{% if form.name.errors %}
{% for error in form.name.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %} {% endif %}
{% if form.name.help_text %} {% if agency.email %}
<div class="form-text">{{ form.name.help_text }}</div> <p class="text-muted mb-0">{{ agency.email }}</p>
{% endif %} {% endif %}
<small class="text-muted">
{% trans "Created" %}: {{ agency.created_at|date:"d M Y" }} •
{% trans "Last Updated" %}: {{ agency.updated_at|date:"d M Y" }}
</small>
</div> </div>
</div>
<!-- Contact Person and Phone -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.contact_person.id_for_label }}" class="form-label">
{{ form.contact_person.label }}
</label>
{{ form.contact_person }}
{% if form.contact_person.errors %}
{% for error in form.contact_person.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.contact_person.help_text %}
<div class="form-text">{{ form.contact_person.help_text }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.phone.id_for_label }}" class="form-label">
{{ form.phone.label }}
</label>
{{ form.phone }}
{% if form.phone.errors %}
{% for error in form.phone.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.phone.help_text %}
<div class="form-text">{{ form.phone.help_text }}</div>
{% endif %}
</div>
</div>
<!-- Email and Website -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.email.id_for_label }}" class="form-label">
{{ form.email.label }}
</label>
{{ form.email }}
{% if form.email.errors %}
{% for error in form.email.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.email.help_text %}
<div class="form-text">{{ form.email.help_text }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.website.id_for_label }}" class="form-label">
{{ form.website.label }}
</label>
{{ form.website }}
{% if form.website.errors %}
{% for error in form.website.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.website.help_text %}
<div class="form-text">{{ form.website.help_text }}</div>
{% endif %}
</div>
</div>
<!-- Address -->
<div class="mb-3">
<label for="{{ form.address.id_for_label }}" class="form-label">
{{ form.address.label }}
</label>
{{ form.address }}
{% if form.address.errors %}
{% for error in form.address.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.address.help_text %}
<div class="form-text">{{ form.address.help_text }}</div>
{% endif %}
</div>
<!-- Country and City -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.country.id_for_label }}" class="form-label">
{{ form.country.label }}
</label>
{{ form.country }}
{% if form.country.errors %}
{% for error in form.country.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.country.help_text %}
<div class="form-text">{{ form.country.help_text }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.city.id_for_label }}" class="form-label">
{{ form.city.label }}
</label>
{{ form.city }}
{% if form.city.errors %}
{% for error in form.city.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.city.help_text %}
<div class="form-text">{{ form.city.help_text }}</div>
{% endif %}
</div>
</div>
<!-- Description -->
<div class="mb-4">
<label for="{{ form.description.id_for_label }}" class="form-label">
{{ form.description.label }}
</label>
{{ form.description }}
{% if form.description.errors %}
{% for error in form.description.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.description.help_text %}
<div class="form-text">{{ form.description.help_text }}</div>
{% endif %}
</div>
<!-- Form Actions -->
<div class="d-flex justify-content-between">
<a href="{% url 'agency_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-main-action">
<i class="fas fa-save me-1"></i> {{ button_text }}
</button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<!-- Form Card -->
<div class="card shadow-sm">
<div class="card-body p-4">
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>{% trans "Error" %}
</h5>
{% for error in form.non_field_errors %}
<p class="mb-0">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% 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" aria-label="{% trans 'Close' %}"></button>
</div>
{% endfor %}
{% endif %}
<form method="post" novalidate id="agency-form">
{% csrf_token %}
<!-- Name -->
<div class="mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label">
{{ form.name.label }} <span class="text-danger">*</span>
</label>
{{ form.name|add_class:"form-control" }}
{% if form.name.errors %}
{% for error in form.name.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.name.help_text %}
<div class="form-text">{{ form.name.help_text }}</div>
{% endif %}
</div>
<!-- Contact Person and Phone -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.contact_person.id_for_label }}" class="form-label">
{{ form.contact_person.label }}
</label>
{{ form.contact_person|add_class:"form-control" }}
{% if form.contact_person.errors %}
{% for error in form.contact_person.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.contact_person.help_text %}
<div class="form-text">{{ form.contact_person.help_text }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.phone.id_for_label }}" class="form-label">
{{ form.phone.label }}
</label>
{{ form.phone|add_class:"form-control" }}
{% if form.phone.errors %}
{% for error in form.phone.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.phone.help_text %}
<div class="form-text">{{ form.phone.help_text }}</div>
{% endif %}
</div>
</div>
<!-- Email and Website -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.email.id_for_label }}" class="form-label">
{{ form.email.label }}
</label>
{{ form.email|add_class:"form-control" }}
{% if form.email.errors %}
{% for error in form.email.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.email.help_text %}
<div class="form-text">{{ form.email.help_text }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.website.id_for_label }}" class="form-label">
{{ form.website.label }}
</label>
{{ form.website|add_class:"form-control" }}
{% if form.website.errors %}
{% for error in form.website.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.website.help_text %}
<div class="form-text">{{ form.website.help_text }}</div>
{% endif %}
</div>
</div>
<!-- Address -->
<div class="mb-3">
<label for="{{ form.address.id_for_label }}" class="form-label">
{{ form.address.label }}
</label>
{{ form.address|add_class:"form-control" }}
{% if form.address.errors %}
{% for error in form.address.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.address.help_text %}
<div class="form-text">{{ form.address.help_text }}</div>
{% endif %}
</div>
<!-- Country and City -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.country.id_for_label }}" class="form-label">
{{ form.country.label }}
</label>
{{ form.country|add_class:"form-control" }}
{% if form.country.errors %}
{% for error in form.country.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.country.help_text %}
<div class="form-text">{{ form.country.help_text }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.city.id_for_label }}" class="form-label">
{{ form.city.label }}
</label>
{{ form.city|add_class:"form-control" }}
{% if form.city.errors %}
{% for error in form.city.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.city.help_text %}
<div class="form-text">{{ form.city.help_text }}</div>
{% endif %}
</div>
</div>
<!-- Description -->
<div class="mb-4">
<label for="{{ form.description.id_for_label }}" class="form-label">
{{ form.description.label }}
</label>
{{ form.description|add_class:"form-control" }}
{% if form.description.errors %}
{% for error in form.description.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.description.help_text %}
<div class="form-text">{{ form.description.help_text }}</div>
{% endif %}
</div>
<div class="d-flex gap-2">
<button form="agency-form" type="submit" class="btn btn-main-action">
<i class="fas fa-save me-1"></i> {{ button_text }}
</button>
</div>
</form>
</div>
</div>
</div> </div>
</div> </div>
{% endblock %}
{% block customJS %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Add Bootstrap classes to form fields // Form Validation
const formFields = document.querySelectorAll('input[type="text"], input[type="email"], input[type="url"], input[type="tel"], textarea, select'); const form = document.getElementById('agency-form');
formFields.forEach(function(field) { if (form) {
field.classList.add('form-control'); form.addEventListener('submit', function(e) {
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.classList.add('loading');
submitBtn.disabled = true;
// Basic validation
const name = document.getElementById('id_name');
if (name && !name.value.trim()) {
e.preventDefault();
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
alert('{% trans "Agency name is required." %}');
return;
}
const email = document.getElementById('id_email');
if (email && email.value.trim() && !isValidEmail(email.value.trim())) {
e.preventDefault();
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
alert('{% trans "Please enter a valid email address." %}');
return;
}
const website = document.getElementById('id_website');
if (website && website.value.trim() && !isValidURL(website.value.trim())) {
e.preventDefault();
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
alert('{% trans "Please enter a valid website URL." %}');
return;
}
});
}
// Email validation helper
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// URL validation helper
function isValidURL(url) {
try {
new URL(url);
return true;
} catch (_) {
return false;
}
}
// Warn before leaving if changes are made
let formChanged = false;
const formInputs = form ? form.querySelectorAll('input, select, textarea') : [];
formInputs.forEach(input => {
input.addEventListener('change', function() {
formChanged = true;
});
}); });
window.addEventListener('beforeunload', function(e) {
if (formChanged) {
e.preventDefault();
e.returnValue = '{% trans "You have unsaved changes. Are you sure you want to leave?" %}';
return e.returnValue;
}
});
if (form) {
form.addEventListener('submit', function() {
formChanged = false;
});
}
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -1,43 +1,51 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static i18n crispy_forms_tags %} {% load static i18n crispy_forms_tags %}
{% block title %}Update Candidate - {{ block.super }}{% endblock %} {% block title %}Update {{ object.name }} - {{ block.super }}{% endblock %}
{% block customCSS %} {% block customCSS %}
<style> <style>
/* ================================================= */ /* UI Variables for the KAAT-S Theme */
/* THEME VARIABLES AND GLOBAL STYLES (FROM JOB DETAIL) */
/* ================================================= */
:root { :root {
--kaauh-teal: #00636e; --kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53; --kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3; --kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40; --kaauh-gray-light: #f8f9fa;
} }
/* Primary Color Overrides */ /* Form Container Styling */
.text-primary { color: var(--kaauh-teal) !important; } .form-container {
max-width: 800px;
margin: 0 auto;
}
/* Card Styling */
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
/* Main Action Button Style */ /* Main Action Button Style */
.btn-main-action, .btn-primary { .btn-main-action {
background-color: var(--kaauh-teal); background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal); border-color: var(--kaauh-teal);
color: white; color: white;
font-weight: 600; font-weight: 600;
padding: 0.6rem 1.2rem;
transition: all 0.2s ease; transition: all 0.2s ease;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.4rem;
padding: 0.5rem 1.5rem;
} }
.btn-main-action:hover, .btn-primary:hover {
.btn-main-action:hover {
background-color: var(--kaauh-teal-dark); background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark); border-color: var(--kaauh-teal-dark);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15); box-shadow: 0 4px 8px rgba(0,0,0,0.15);
} }
/* Outlined Button Styles */ /* Secondary Button Style */
.btn-outline-secondary { .btn-outline-secondary {
color: var(--kaauh-teal-dark); color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal); border-color: var(--kaauh-teal);
@ -48,94 +56,308 @@
border-color: var(--kaauh-teal-dark); border-color: var(--kaauh-teal-dark);
} }
/* Card enhancements */ /* Form Field Styling */
.card { .form-control:focus {
border: 1px solid var(--kaauh-border); border-color: var(--kaauh-teal);
border-radius: 0.75rem; box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
background-color: white;
} }
/* Colored Header Card */ .form-select:focus {
.candidate-header-card { border-color: var(--kaauh-teal);
background: linear-gradient(135deg, var(--kaauh-teal), #004d57); box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
color: white;
border-radius: 0.75rem 0.75rem 0 0;
padding: 1.5rem;
box-shadow: 0 4px 10px rgba(0,0,0,0.15);
} }
.candidate-header-card h1 {
font-weight: 700; /* Profile Image Upload Styling */
margin: 0; .profile-image-upload {
font-size: 1.8rem; border: 2px dashed var(--kaauh-border);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
transition: all 0.3s ease;
cursor: pointer;
} }
.heroicon {
width: 1.25rem; .profile-image-upload:hover {
height: 1.25rem; border-color: var(--kaauh-teal);
vertical-align: text-bottom; background-color: var(--kaauh-gray-light);
stroke: currentColor; }
margin-right: 0.5rem;
.profile-image-preview {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 50%;
border: 3px solid var(--kaauh-teal);
margin: 0 auto 1rem;
}
.current-image {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 50%;
border: 2px solid var(--kaauh-teal);
margin-right: 1rem;
}
/* Breadcrumb Styling */
.breadcrumb {
background-color: transparent;
padding: 0;
margin-bottom: 1rem;
}
.breadcrumb-item + .breadcrumb-item::before {
content: ">";
color: var(--kaauh-teal);
}
/* Alert Styling */
.alert {
border-radius: 0.5rem;
border: none;
}
/* Loading State */
.btn.loading {
position: relative;
pointer-events: none;
opacity: 0.8;
}
.btn.loading::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
margin: auto;
border: 2px solid transparent;
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Current Profile Section */
.current-profile {
background-color: var(--kaauh-gray-light);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
}
.current-profile h6 {
color: var(--kaauh-teal-dark);
font-weight: 600;
margin-bottom: 0.75rem;
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<div class="form-container">
<div class="card mb-4"> <!-- Breadcrumb Navigation -->
<div class="candidate-header-card"> <nav aria-label="breadcrumb">
<div class="d-flex justify-content-between align-items-start flex-wrap"> <ol class="breadcrumb">
<div class="flex-grow-1"> <li class="breadcrumb-item">
<h1 class="h3 mb-1"> <a href="{% url 'application_list' %}" class="text-decoration-none text-secondary">
<i class="fas fa-user-edit"></i> <i class="fas fa-users me-1"></i> {% trans "Applications" %}
{% trans "Update Candidate:" %} {{ object.name }}
</h1>
<p class="text-white opacity-75 mb-0">{% trans "Edit candidate information and details" %}</p>
</div>
<div class="d-flex gap-2 mt-1">
<a href="{% url 'application_list' %}" class="btn btn-outline-light btn-sm" title="{% trans 'Back to List' %}">
<i class="fas fa-arrow-left"></i>
<span class="d-none d-sm-inline">{% trans "Back to List" %}</span>
</a> </a>
{% if object.slug %} </li>
<a href="{% url 'application_detail' object.slug %}" class="btn btn-outline-light btn-sm" title="{% trans 'View Candidate' %}"> <li class="breadcrumb-item">
<i class="fas fa-eye"></i> <a href="{% url 'application_detail' object.slug %}" class="text-decoration-none text-secondary">
<span class="d-none d-sm-inline">{% trans "View" %}</span> {{ object.name }}
</a> </a>
{% endif %} </li>
<li class="breadcrumb-item active" aria-current="page"
style="
color: #F43B5E; /* Rosy Accent Color */
font-weight: 600;">{% trans "Update" %}</li>
</ol>
</nav>
<!-- Header -->
<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-user-edit me-2"></i> {% trans "Update Application" %}
</h1>
<div class="d-flex gap-2">
<a href="{% url 'application_detail' object.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
</a>
<a href="{% url 'application_delete' object.slug %}" class="btn btn-danger">
<i class="fas fa-trash me-1"></i> {% trans "Delete" %}
</a>
<a href="{% url 'application_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %}
</a>
</div>
</div>
<!-- Current Profile Info -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="current-profile">
<h6><i class="fas fa-info-circle me-2"></i>{% trans "Currently Editing" %}</h6>
<div class="d-flex align-items-center">
{% if object.profile_image %}
<img src="{{ object.profile_image.url }}" alt="{{ object.name }}"
class="current-image">
{% else %}
<div class="current-image d-flex align-items-center justify-content-center bg-light">
<i class="fas fa-user text-muted"></i>
</div>
{% endif %}
<div>
<h5 class="mb-1">{{ object.name }}</h5>
{% if object.email %}
<p class="text-muted mb-0">{{ object.email }}</p>
{% endif %}
<small class="text-muted">
{% trans "Created" %}: {{ object.created_at|date:"d M Y" }} •
{% trans "Last Updated" %}: {{ object.updated_at|date:"d M Y" }}
</small>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="card shadow-sm"> <!-- Form Card -->
<div class="card-header bg-white border-bottom"> <div class="card shadow-sm">
<h2 class="h5 mb-0 text-primary"> <div class="card-body p-4">
<i class="fas fa-file-alt me-1"></i> {% if form.non_field_errors %}
{% trans "Candidate Form" %} <div class="alert alert-danger" role="alert">
</h2> <h5 class="alert-heading">
</div> <i class="fas fa-exclamation-triangle me-2"></i>{% trans "Error" %}
<div class="card-body"> </h5>
<form method="post" enctype="multipart/form-data"> {% for error in form.non_field_errors %}
{% csrf_token %} <p class="mb-0">{{ error }}</p>
{% endfor %}
{# Use Crispy Forms to render fields. The two-column layout is applied to the main form content #} </div>
<div class="row g-4"> {% endif %}
{% for field in form %}
<div class="col-md-6"> {% if messages %}
{{ field|as_crispy_field }} {% 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" aria-label="{% trans 'Close' %}"></button>
</div> </div>
{% endfor %} {% endfor %}
</div> {% endif %}
<hr class="mt-4 mb-4"> <form method="post" action="{% url 'candidate_update' object.slug %}" enctype="multipart/form-data" id="candidate-form">
<button class="btn btn-main-action" type="submit"> {% csrf_token %}
<i class="fas fa-save me-1"></i> {{form|crispy}}
{% trans "Update Candidate" %} </form>
</button> <div class="d-flex gap-2">
</form> <button form="candidate-form" type="submit" class="btn btn-main-action">
<i class="fas fa-save me-1"></i> {% trans "Update" %}
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Profile Image Preview
const profileImageInput = document.getElementById('id_profile_image');
const imagePreviewContainer = document.getElementById('image-preview-container');
const originalImage = imagePreviewContainer ? imagePreviewContainer.innerHTML : '';
if (profileImageInput) {
profileImageInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = function(e) {
if (imagePreviewContainer) {
imagePreviewContainer.innerHTML = `
<img src="${e.target.result}" alt="Profile Preview" class="profile-image-preview">
<h5 class="text-muted mt-3">${file.name}</h5>
<p class="text-muted small">{% trans "New photo selected" %}</p>
`;
}
};
reader.readAsDataURL(file);
} else if (!file && imagePreviewContainer) {
// Reset to original if no file selected
imagePreviewContainer.innerHTML = originalImage;
}
});
}
// Form Validation
const form = document.getElementById('candidate-form');
if (form) {
form.addEventListener('submit', function(e) {
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.classList.add('loading');
submitBtn.disabled = true;
// Basic validation
const name = document.getElementById('id_name');
if (name && !name.value.trim()) {
e.preventDefault();
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
alert('{% trans "Name is required." %}');
return;
}
const email = document.getElementById('id_email');
if (email && email.value.trim() && !isValidEmail(email.value.trim())) {
e.preventDefault();
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
alert('{% trans "Please enter a valid email address." %}');
return;
}
});
}
// Email validation helper
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// Warn before leaving if changes are made
let formChanged = false;
const formInputs = form ? form.querySelectorAll('input, select, textarea') : [];
formInputs.forEach(input => {
input.addEventListener('change', function() {
formChanged = true;
});
});
window.addEventListener('beforeunload', function(e) {
if (formChanged) {
e.preventDefault();
e.returnValue = '{% trans "You have unsaved changes. Are you sure you want to leave?" %}';
return e.returnValue;
}
});
if (form) {
form.addEventListener('submit', function() {
formChanged = false;
});
}
});
</script>
{% endblock %}

View File

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

View File

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

View File

@ -235,7 +235,7 @@
{# Select Input Group #} {# Select Input Group #}
<div> <div>
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="min-width: 150px;"> <select name="mark_as" id="update_status" class="form-select form-select-sm" style="min-width: 150px;">
<option selected> <option selected>
---------- ----------
@ -252,7 +252,7 @@
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %} <i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
</button> </button>
{# email button#} {# email button#}
<button type="button" class="btn btn-outline-info btn-sm" <button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal" data-bs-toggle="modal"
hx-boost='true' hx-boost='true'
data-bs-target="#emailModal" data-bs-target="#emailModal"
@ -348,7 +348,7 @@
<i class="fas fa-file-alt"></i> <i class="fas fa-file-alt"></i>
</a> </a>
</div> </div>
</td> </td>
</tr> </tr>
@ -424,7 +424,7 @@
<div class="text-center py-5 text-muted"> <div class="text-center py-5 text-muted">
<i class="fas fa-spinner fa-spin fa-2x"></i><br> <i class="fas fa-spinner fa-spin fa-2x"></i><br>
{% trans "Loading email form..." %} {% trans "Loading email form..." %}
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@ -332,12 +332,12 @@
<a href="{% url 'application_update' candidate.slug %}" class="btn btn-outline-secondary" title="{% trans 'Edit' %}"> <a href="{% url 'application_update' candidate.slug %}" class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</a> </a>
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}" {% comment %} <button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-target="#deleteModal" data-bs-toggle="modal" data-bs-target="#deleteModal"
data-delete-url="{% url 'application_delete' candidate.slug %}" data-delete-url="{% url 'application_delete' candidate.slug %}"
data-item-name="{{ candidate.name }}"> data-item-name="{{ candidate.name }}">
<i class="fas fa-trash-alt"></i> <i class="fas fa-trash-alt"></i>
</button> </button> {% endcomment %}
{% endif %} {% endif %}
</div> </div>
</td> </td>

View File

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

View File

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

View File

@ -0,0 +1,386 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{% trans "Delete Candidate" %} - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
/* KAAT-S UI Variables */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-danger: #dc3545;
--kaauh-warning: #ffc107;
}
/* 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;
}
/* Warning Section */
.warning-section {
background: linear-gradient(135deg, #fff3cd 0%, #ffeeba 100%);
border: 1px solid #ffeeba;
border-radius: 0.75rem;
padding: 2rem;
margin-bottom: 2rem;
text-align: center;
}
.warning-icon {
font-size: 4rem;
color: var(--kaauh-warning);
margin-bottom: 1rem;
}
.warning-title {
color: #856404;
font-weight: 700;
margin-bottom: 1rem;
}
.warning-text {
color: #856404;
margin-bottom: 0;
}
/* Candidate Info Card */
.candidate-info {
background-color: #f8f9fa;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 2rem;
border: 1px solid var(--kaauh-border);
}
.info-item {
display: flex;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e9ecef;
}
.info-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.info-icon {
width: 40px;
height: 40px;
background-color: var(--kaauh-teal);
color: white;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
margin-right: 1rem;
flex-shrink: 0;
}
.info-content {
flex: 1;
}
.info-label {
font-weight: 600;
color: var(--kaauh-primary-text);
margin-bottom: 0.25rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value {
color: #6c757d;
font-size: 1rem;
}
/* Button Styling */
.btn-danger {
background-color: var(--kaauh-danger);
border-color: var(--kaauh-danger);
color: white;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-danger:hover {
background-color: #c82333;
border-color: #bd2130;
box-shadow: 0 4px 8px rgba(220, 53, 69, 0.3);
}
.btn-secondary {
background-color: #6c757d;
border-color: #6c757d;
color: white;
font-weight: 600;
}
/* Consequence List */
.consequence-list {
list-style: none;
padding: 0;
margin: 0;
}
.consequence-list li {
padding: 0.5rem 0;
border-bottom: 1px solid #e9ecef;
color: #6c757d;
}
.consequence-list li:last-child {
border-bottom: none;
}
.consequence-list li i {
color: var(--kaauh-danger);
margin-right: 0.5rem;
}
/* Candidate Profile Image */
.candidate-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--kaauh-teal);
}
.avatar-placeholder {
width: 80px;
height: 80px;
border-radius: 50%;
background-color: #e9ecef;
display: flex;
align-items: center;
justify-content: center;
border: 3px solid var(--kaauh-teal);
}
</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-exclamation-triangle me-2"></i>
{% trans "Delete Candidate" %}
</h1>
<p class="text-muted mb-0">
{% trans "You are about to delete a candidate application. This action cannot be undone." %}
</p>
</div>
<a href="{% url 'candidate_detail' object.slug %}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Candidate" %}
</a>
</div>
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- Warning Section -->
<div class="warning-section">
<div class="warning-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<h3 class="warning-title">{% trans "Warning: This action cannot be undone!" %}</h3>
<p class="warning-text">
{% trans "Deleting this candidate will permanently remove all associated data. Please review the information below carefully before proceeding." %}
</p>
</div>
<!-- Candidate Information -->
<div class="card kaauh-card mb-4">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-user me-2"></i>
{% trans "Candidate to be Deleted" %}
</h5>
</div>
<div class="card-body">
<div class="candidate-info">
<div class="d-flex align-items-center mb-4">
{% if object.profile_image %}
<img src="{{ object.profile_image.url }}" alt="{{ object.name }}" class="candidate-avatar me-3">
{% else %}
<div class="avatar-placeholder me-3">
<i class="fas fa-user text-muted fa-2x"></i>
</div>
{% endif %}
<div>
<h4 class="mb-1">{{ object.name }}</h4>
{% if object.email %}
<p class="text-muted mb-0">{{ object.email }}</p>
{% endif %}
</div>
</div>
<div class="info-item">
<div class="info-icon">
<i class="fas fa-briefcase"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Position Applied" %}</div>
<div class="info-value">
{% if object.job_posting %}
{{ object.job_posting.title }}
{% else %}
{% trans "Not specified" %}
{% endif %}
</div>
</div>
</div>
{% if object.phone %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-phone"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Phone" %}</div>
<div class="info-value">{{ object.phone }}</div>
</div>
</div>
{% endif %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-calendar"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Applied On" %}</div>
<div class="info-value">{{ object.created_at|date:"F d, Y" }}</div>
</div>
</div>
<div class="info-item">
<div class="info-icon">
<i class="fas fa-info-circle"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Status" %}</div>
<div class="info-value">
{% if object.status %}
<span class="badge bg-info">{{ object.get_status_display }}</span>
{% else %}
{% trans "Not specified" %}
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Consequences -->
<div class="card kaauh-card mb-4">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-list me-2"></i>
{% trans "What will happen when you delete this candidate?" %}
</h5>
</div>
<div class="card-body">
<ul class="consequence-list">
<li>
<i class="fas fa-times-circle"></i>
{% trans "The candidate profile and all personal information will be permanently deleted" %}
</li>
<li>
<i class="fas fa-times-circle"></i>
{% trans "All application data and documents will be removed" %}
</li>
<li>
<i class="fas fa-times-circle"></i>
{% trans "Interview schedules and history will be deleted" %}
</li>
<li>
<i class="fas fa-times-circle"></i>
{% trans "Any associated notes and communications will be lost" %}
</li>
<li>
<i class="fas fa-times-circle"></i>
{% trans "This action cannot be undone under any circumstances" %}
</li>
</ul>
</div>
</div>
<!-- Confirmation Form -->
<div class="card kaauh-card">
<div class="card-body">
<form method="post" id="deleteForm">
{% csrf_token %}
<div class="mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="confirm_delete" name="confirm_delete" required>
<label class="form-check-label" for="confirm_delete">
<strong>{% trans "I understand that this action cannot be undone and I want to permanently delete this candidate." %}</strong>
</label>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{% url 'candidate_detail' object.slug %}" class="btn btn-secondary btn-lg">
<i class="fas fa-times me-2"></i>
{% trans "Cancel" %}
</a>
<button type="submit"
class="btn btn-danger btn-lg"
id="deleteButton"
disabled>
<i class="fas fa-trash me-2"></i>
{% trans "Delete Candidate Permanently" %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const confirmDeleteCheckbox = document.getElementById('confirm_delete');
const deleteButton = document.getElementById('deleteButton');
const deleteForm = document.getElementById('deleteForm');
function validateForm() {
const checkboxChecked = confirmDeleteCheckbox.checked;
deleteButton.disabled = !checkboxChecked;
if (checkboxChecked) {
deleteButton.classList.remove('btn-secondary');
deleteButton.classList.add('btn-danger');
} else {
deleteButton.classList.remove('btn-danger');
deleteButton.classList.add('btn-secondary');
}
}
confirmDeleteCheckbox.addEventListener('change', validateForm);
// Add confirmation before final submission
/*deleteForm.addEventListener('submit', function(e) {
const confirmMessage = "{% trans 'Are you absolutely sure you want to delete this candidate? This action cannot be undone.' %}";
if (!confirm(confirmMessage)) {
e.preventDefault();
}
});
*/
});
</script>
{% endblock %}

View File

@ -2,196 +2,409 @@
{% load static i18n %} {% load static i18n %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% block title %}{{ title }}{% endblock %} {% block title %}{{ title }} - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
/* UI Variables for the KAAT-S Theme */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-gray-light: #f8f9fa;
}
/* Form Container Styling */
.form-container {
max-width: 800px;
margin: 0 auto;
}
/* Card Styling */
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
/* Main Action Button Style */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1.5rem;
}
.btn-main-action:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Secondary Button Style */
.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);
}
/* Form Field Styling */
.form-control:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
.form-select:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
/* Breadcrumb Styling */
.breadcrumb {
background-color: transparent;
padding: 0;
margin-bottom: 1rem;
}
.breadcrumb-item + .breadcrumb-item::before {
content: ">";
color: var(--kaauh-teal);
}
/* Alert Styling */
.alert {
border-radius: 0.5rem;
border: none;
}
/* Loading State */
.btn.loading {
position: relative;
pointer-events: none;
opacity: 0.8;
}
.btn.loading::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
margin: auto;
border: 2px solid transparent;
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Current Profile Section */
.current-profile {
background-color: var(--kaauh-gray-light);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
}
.current-profile h6 {
color: var(--kaauh-teal-dark);
font-weight: 600;
margin-bottom: 0.75rem;
}
.current-image {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 50%;
border: 2px solid var(--kaauh-teal);
margin-right: 1rem;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid py-4">
<div class="row"> <div class="form-container">
<div class="col-12"> <!-- Breadcrumb Navigation -->
<div class="d-flex justify-content-between align-items-center mb-4"> <nav aria-label="breadcrumb">
<h1 class="h3 mb-0">{{ title }}</h1> <ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'source_list' %}" class="text-decoration-none text-secondary">
<i class="fas fa-plug me-1"></i> {% trans "Sources" %}
</a>
</li>
{% if source %}
<li class="breadcrumb-item">
<a href="{% url 'source_detail' source.pk %}" class="text-decoration-none text-secondary">
{{ source.name }}
</a>
</li>
<li class="breadcrumb-item active" aria-current="page"
style="
color: #F43B5E; /* Rosy Accent Color */
font-weight: 600;">{% trans "Update" %}</li>
{% else %}
<li class="breadcrumb-item active" aria-current="page"
style="
color: #F43B5E; /* Rosy Accent Color */
font-weight: 600;">{% trans "Create" %}</li>
{% endif %}
</ol>
</nav>
<!-- Header -->
<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-plug me-2"></i> {{ title }}
</h1>
<div class="d-flex gap-2">
{% if source %}
<a href="{% url 'source_detail' source.pk %}" class="btn btn-outline-secondary">
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
</a>
<a href="{% url 'source_delete' source.pk %}" class="btn btn-danger">
<i class="fas fa-trash me-1"></i> {% trans "Delete" %}
</a>
{% endif %}
<a href="{% url 'source_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'source_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> {% trans "Back to Sources" %} <i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %}
</a> </a>
</div> </div>
</div>
<div class="card"> {% if source %}
<div class="card-body"> <!-- Current Source Info -->
<form method="post" novalidate> <div class="card shadow-sm mb-4">
{% csrf_token %} <div class="card-body">
<div class="current-profile">
<h6><i class="fas fa-info-circle me-2"></i>{% trans "Currently Editing" %}</h6>
<div class="d-flex align-items-center">
<div class="current-image d-flex align-items-center justify-content-center bg-light">
<i class="fas fa-plug text-muted"></i>
</div>
<div>
<h5 class="mb-1">{{ source.name }}</h5>
{% if source.source_type %}
<p class="text-muted mb-0">{% trans "Type" %}: {{ source.get_source_type_display }}</p>
{% endif %}
{% if source.ip_address %}
<p class="text-muted mb-0">{% trans "IP Address" %}: {{ source.ip_address }}</p>
{% endif %}
<small class="text-muted">
{% trans "Created" %}: {{ source.created_at|date:"d M Y" }} •
{% trans "Last Updated" %}: {{ source.updated_at|date:"d M Y" }}
</small>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if form.non_field_errors %} <!-- Form Card -->
<div class="alert alert-danger"> <div class="card shadow-sm">
{% for error in form.non_field_errors %} <div class="card-body p-4">
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>{% trans "Error" %}
</h5>
{% for error in form.non_field_errors %}
<p class="mb-0">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% 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" aria-label="{% trans 'Close' %}"></button>
</div>
{% endfor %}
{% endif %}
<form method="post" novalidate id="source-form">
{% csrf_token %}
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label">
{{ form.name.label }} <span class="text-danger">*</span>
</label>
{{ form.name|add_class:"form-control" }}
{% if form.name.errors %}
<div class="invalid-feedback d-block">
{% for error in form.name.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{{ form.name.help_text }}</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.source_type.id_for_label }}" class="form-label">
{{ form.source_type.label }} <span class="text-danger">*</span>
</label>
{{ form.source_type|add_class:"form-select" }}
{% if form.source_type.errors %}
<div class="invalid-feedback d-block">
{% for error in form.source_type.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{{ form.source_type.help_text }}</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.ip_address.id_for_label }}" class="form-label">
{{ form.ip_address.label }} <span class="text-danger">*</span>
</label>
{{ form.ip_address|add_class:"form-control" }}
{% if form.ip_address.errors %}
<div class="invalid-feedback d-block">
{% for error in form.ip_address.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{{ form.ip_address.help_text }}</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.trusted_ips.id_for_label }}" class="form-label">
{{ form.trusted_ips.label }}
</label>
{{ form.trusted_ips|add_class:"form-control" }}
{% if form.trusted_ips.errors %}
<div class="invalid-feedback d-block">
{% for error in form.trusted_ips.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{{ form.trusted_ips.help_text }}</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="{{ form.description.id_for_label }}" class="form-label">
{{ form.description.label }}
</label>
{{ form.description|add_class:"form-control" }}
{% if form.description.errors %}
<div class="invalid-feedback d-block">
{% for error in form.description.errors %}
{{ error }} {{ error }}
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<div class="form-text">{{ form.description.help_text }}</div>
</div>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label"> <div class="form-check">
{{ form.name.label }} <span class="text-danger">*</span> {{ form.is_active|add_class:"form-check-input" }}
<label for="{{ form.is_active.id_for_label }}" class="form-check-label">
{{ form.is_active.label }}
</label> </label>
{{ form.name|add_class:"form-control" }}
{% if form.name.errors %}
<div class="invalid-feedback d-block">
{% for error in form.name.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{{ form.name.help_text }}</div>
</div> </div>
</div> {% if form.is_active.errors %}
<div class="col-md-6"> <div class="invalid-feedback d-block">
<div class="mb-3"> {% for error in form.is_active.errors %}
<label for="{{ form.source_type.id_for_label }}" class="form-label"> {{ error }}
{{ form.source_type.label }} <span class="text-danger">*</span> {% endfor %}
</label>
{{ form.source_type }}
{% if form.source_type.errors %}
<div class="invalid-feedback d-block">
{% for error in form.source_type.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{{ form.source_type.help_text }}</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.ip_address.id_for_label }}" class="form-label">
{{ form.ip_address.label }} <span class="text-danger">*</span>
</label>
{{ form.ip_address|add_class:"form-control" }}
{% if form.ip_address.errors %}
<div class="invalid-feedback d-block">
{% for error in form.ip_address.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{{ form.ip_address.help_text }}</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.ip_address.id_for_label }}" class="form-label">
{{ form.trusted_ips.label }} <span class="text-danger">*</span>
</label>
{{ form.trusted_ips|add_class:"form-control" }}
{% if form.trusted_ips.errors %}
<div class="invalid-feedback d-block">
{% for error in form.trusted_ips.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{{ form.trusted_ips.help_text }}</div>
</div>
</div>
<div class="mb-3">
<label for="{{ form.description.id_for_label }}" class="form-label">
{{ form.description.label }}
</label>
{{ form.description|add_class:"form-control" }}
{% if form.description.errors %}
<div class="invalid-feedback d-block">
{% for error in form.description.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{{ form.description.help_text }}</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<div class="form-check">
{{ form.is_active|add_class:"form-check-input bg-primary-theme" }}
<label for="{{ form.is_active.id_for_label }}" class="form-check-label">
{{ form.is_active.label }}
</label>
</div> </div>
{% if form.is_active.errors %} {% endif %}
<div class="invalid-feedback d-block"> <div class="form-text">{{ form.is_active.help_text }}</div>
{% for error in form.is_active.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{{ form.is_active.help_text }}</div>
</div>
</div> </div>
</div> </div>
</div>
<!-- API Credentials Section -->
<!-- API Credentials Section --> {% if source %}
{% if source %} <div class="card bg-light mb-4">
<div class="card bg-light mb-4"> <div class="card-header">
<div class="card-header"> <h6 class="mb-0">{% trans "API Credentials" %}</h6>
<h6 class="mb-0">{% trans "API Credentials" %}</h6> </div>
</div> <div class="card-body">
<div class="card-body"> <div class="row">
<div class="row"> <div class="col-md-6">
<div class="col-md-6"> <div class="mb-3">
<div class="mb-3"> <label class="form-label">{% trans "API Key" %}</label>
<label class="form-label">{% trans "API Key" %}</label> <div class="input-group">
<div class="input-group"> <input type="text" class="form-control" value="{{ source.api_key }}" readonly>
<input type="text" class="form-control" value="{{ source.api_key }}" readonly> <button type="button" class="btn btn-outline-secondary"
<button type="button" class="btn btn-outline-secondary" hx-post="{% url 'copy_to_clipboard' %}"
hx-post="{% url 'copy_to_clipboard' %}" hx-vals='{"text": "{{ source.api_key }}"}'
hx-vals='{"text": "{{ source.api_key }}"}' title="{% trans 'Copy to clipboard' %}">
title="{% trans 'Copy to clipboard' %}"> <i class="fas fa-copy"></i>
<i class="fas fa-copy"></i> </button>
</button>
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">{% trans "API Secret" %}</label>
<div class="input-group">
<input type="password" class="form-control" value="{{ source.api_secret }}" readonly id="api-secret">
<button type="button" class="btn btn-outline-secondary" onclick="toggleSecretVisibility()">
<i class="fas fa-eye" id="secret-toggle-icon"></i>
</button>
<button type="button" class="btn btn-outline-secondary"
hx-post="{% url 'copy_to_clipboard' %}"
hx-vals='{"text": "{{ source.api_secret }}"}'
title="{% trans 'Copy to clipboard' %}">
<i class="fas fa-copy"></i>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="text-end"> <div class="col-md-6">
<a href="{% url 'generate_api_keys' source.pk %}" class="btn btn-warning"> <div class="mb-3">
<i class="fas fa-key"></i> {% trans "Generate New Keys" %} <label class="form-label">{% trans "API Secret" %}</label>
</a> <div class="input-group">
<input type="password" class="form-control" value="{{ source.api_secret }}" readonly id="api-secret">
<button type="button" class="btn btn-outline-secondary" onclick="toggleSecretVisibility()">
<i class="fas fa-eye" id="secret-toggle-icon"></i>
</button>
<button type="button" class="btn btn-outline-secondary"
hx-post="{% url 'copy_to_clipboard' %}"
hx-vals='{"text": "{{ source.api_secret }}"}'
title="{% trans 'Copy to clipboard' %}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div> </div>
</div> </div>
<div class="text-end">
<a href="{% url 'generate_api_keys' source.pk %}" class="btn btn-warning">
<i class="fas fa-key"></i> {% trans "Generate New Keys" %}
</a>
</div>
</div> </div>
{% endif %}
<div class="d-flex justify-content-between">
<a href="{% url 'source_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i>{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-main-action">
<i class="fas fa-save me-1"></i> {% trans "Save" %}
</button>
</div> </div>
</form> {% endif %}
</div>
<div class="d-flex gap-2">
<button form="source-form" type="submit" class="btn btn-main-action">
<i class="fas fa-save me-1"></i> {% trans "Save" %}
</button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
@ -200,6 +413,67 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() {
// Form Validation
const form = document.getElementById('source-form');
if (form) {
form.addEventListener('submit', function(e) {
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.classList.add('loading');
submitBtn.disabled = true;
// Basic validation
const name = document.getElementById('id_name');
if (name && !name.value.trim()) {
e.preventDefault();
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
alert('{% trans "Source name is required." %}');
return;
}
const ipAddress = document.getElementById('id_ip_address');
if (ipAddress && ipAddress.value.trim() && !isValidIP(ipAddress.value.trim())) {
e.preventDefault();
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
alert('{% trans "Please enter a valid IP address." %}');
return;
}
});
}
// IP validation helper
function isValidIP(ip) {
const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return ipRegex.test(ip);
}
// Warn before leaving if changes are made
let formChanged = false;
const formInputs = form ? form.querySelectorAll('input, select, textarea') : [];
formInputs.forEach(input => {
input.addEventListener('change', function() {
formChanged = true;
});
});
window.addEventListener('beforeunload', function(e) {
if (formChanged) {
e.preventDefault();
e.returnValue = '{% trans "You have unsaved changes. Are you sure you want to leave?" %}';
return e.returnValue;
}
});
if (form) {
form.addEventListener('submit', function() {
formChanged = false;
});
}
});
function toggleSecretVisibility() { function toggleSecretVisibility() {
const secretInput = document.getElementById('api-secret'); const secretInput = document.getElementById('api-secret');
const toggleIcon = document.getElementById('secret-toggle-icon'); const toggleIcon = document.getElementById('secret-toggle-icon');