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_USER=faheed
DB_PASSWORD=Faheed@215
DB_NAME=norahuniversity
DB_USER=norahuniversity
DB_PASSWORD=norahuniversity

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,146 +1,191 @@
annotated-types
appdirs
asgiref
asteval
astunparse
attrs
blinker
blis
boto3
botocore
bw-migrations
bw2parameters
bw_processing
cached-property
catalogue
certifi
channels
chardet
charset-normalizer
click
cloudpathlib
confection
constructive_geometries
country_converter
cymem
dataflows-tabulator
datapackage
deepdiff
Deprecated
Django
django-allauth
django-cors-headers
django-filter
django-unfold
djangorestframework
docopt
amqp==5.3.1
annotated-types==0.7.0
anthropic==0.63.0
anyio==4.11.0
appdirs==1.4.4
arrow==1.3.0
asgiref==3.9.2
asteval==1.0.6
astunparse==1.6.3
attrs==25.3.0
billiard==4.2.2
bleach==6.2.0
blessed==1.22.0
blinker==1.9.0
blis==1.3.0
boto3==1.40.37
botocore==1.40.37
bw-migrations==0.2
bw2data==4.5
bw2parameters==1.1.0
bw_processing==1.0
cached-property==2.0.1
catalogue==2.0.10
celery==5.5.3
certifi==2025.8.3
channels==4.3.1
chardet==5.2.0
charset-normalizer==3.4.3
click==8.3.0
click-didyoumean==0.3.1
click-plugins==1.1.1.2
click-repl==0.3.0
cloudpathlib==0.22.0
confection==0.1.5
constructive_geometries==1.0
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
et_xmlfile
Faker
flexcache
flexparser
fsspec
idna
ijson
isodate
Jinja2
jmespath
jsonlines
jsonpointer
jsonschema
jsonschema-specifications
langcodes
language_data
linear-tsv
llvmlite
loguru
lxml
marisa-trie
markdown-it-py
MarkupSafe
matrix_utils
mdurl
morefs
mrio-common-metadata
murmurhash
numba
numpy
openpyxl
ordered-set
packaging
pandas
peewee
Pint
platformdirs
preshed
prettytable
pydantic
pydantic-settings
pydantic_core
pyecospold
Pygments
PyJWT
PyMuPDF
pyparsing
PyPrind
python-dateutil
python-dotenv
python-json-logger
pytz
pyxlsb
PyYAML
randonneur
randonneur_data
RapidFuzz
rdflib
referencing
requests
rfc3986
rich
rpds-py
s3transfer
scipy
shellingham
six
smart-open
snowflake-id
spacy
spacy-legacy
spacy-loggers
SPARQLWrapper
sparse
SQLAlchemy
sqlparse
srsly
stats_arrays
structlog
tableschema
thinc
toolz
tqdm
typer
typing-inspection
typing_extensions
tzdata
unicodecsv
urllib3
voluptuous
wasabi
wcwidth
weasel
wrapt
wurst
xlrd
XlsxWriter
celery[redis]
redis
sentence-transformers
torch
pdfplumber
python-docx
PyMuPDF
pytesseract
Pillow
python-dotenv
django-countries
django-q2
et_xmlfile==2.0.0
Faker==37.8.0
flexcache==0.3
flexparser==0.4
fsspec==2025.9.0
gpt-po-translator==1.3.2
greenlet==3.2.4
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.10
ijson==3.4.0
iniconfig==2.1.0
isodate==0.7.2
isort==5.13.2
Jinja2==3.1.6
jiter==0.11.1
jmespath==1.0.1
jsonlines==4.0.0
jsonpointer==3.0.0
jsonschema==4.25.1
jsonschema-specifications==2025.9.1
kombu==5.5.4
langcodes==3.5.0
language_data==1.3.0
linear-tsv==1.1.0
llvmlite==0.45.0
loguru==0.7.3
lxml==6.0.2
marisa-trie==1.3.1
markdown-it-py==4.0.0
MarkupSafe==3.0.2
matrix_utils==0.6.2
mdurl==0.1.2
morefs==0.2.2
mrio-common-metadata==0.2.1
murmurhash==1.0.13
numba==0.62.0
numpy==2.3.3
openai==1.99.9
openpyxl==3.1.5
ordered-set==4.1.0
packaging==25.0
pandas==2.3.2
peewee==3.18.2
pillow==11.3.0
Pint==0.25
platformdirs==4.4.0
pluggy==1.6.0
polib==1.2.0
preshed==3.0.10
prettytable==3.16.0
prompt_toolkit==3.0.52
psycopg2-binary==2.9.11
pycountry==24.6.1
pydantic==2.11.9
pydantic-settings==2.10.1
pydantic_core==2.33.2
pyecospold==4.0.0
Pygments==2.19.2
PyJWT==2.10.1
PyMuPDF==1.26.4
pyparsing==3.2.5
PyPDF2==3.0.1
PyPrind==2.11.3
pytest==8.3.4
pytest-django==4.11.1
python-dateutil==2.9.0.post0
python-docx==1.2.0
python-dotenv==1.0.1
python-json-logger==3.3.0
pytz==2025.2
pyxlsb==1.0.10
PyYAML==6.0.2
randonneur==0.6.2
randonneur_data==0.6
RapidFuzz==3.14.1
rdflib==7.2.1
redis==3.5.3
referencing==0.36.2
requests==2.32.3
responses==0.25.8
rfc3986==2.0.0
rich==14.1.0
rpds-py==0.27.1
s3transfer==0.14.0
scipy==1.16.2
setuptools==80.9.0
setuptools-scm==8.1.0
shellingham==1.5.4
six==1.17.0
smart_open==7.3.1
sniffio==1.3.1
snowflake-id==1.0.2
spacy==3.8.7
spacy-legacy==3.0.12
spacy-loggers==1.0.5
SPARQLWrapper==2.0.0
sparse==0.17.0
SQLAlchemy==2.0.43
sqlparse==0.5.3
srsly==2.5.1
stats_arrays==0.7
structlog==25.4.0
tableschema==1.21.0
tenacity==9.0.0
thinc==8.3.6
tomli==2.2.1
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 name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<style>
/* -------------------------------------------------------------------------- */
/* CUSTOM TEAL THEME OVERRIDES FOR BOOTSTRAP */
@ -20,7 +20,7 @@
--bs-primary: #00636e; /* Dark Teal */
--bs-primary-rgb: 0, 99, 110;
--bs-primary-light: #007a88; /* Lighter Teal for hover */
/* Background and Text Colors */
--bs-body-bg: #f8f9fa; /* Light gray background */
--bs-body-color: #212529; /* Dark text */
@ -28,7 +28,7 @@
/* Utility colors */
--bs-border-color: #dee2e6; /* Bootstrap default border */
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bs-body-bg);
@ -77,8 +77,8 @@
background-color: var(--bs-primary);
border-color: var(--bs-primary);
font-weight: 600;
border-radius: 0.5rem;
box-shadow: 0 4px 8px rgba(0, 99, 110, 0.2);
border-radius: 0.5rem;
box-shadow: 0 4px 8px rgba(0, 99, 110, 0.2);
transition: all 0.2s ease;
}
.btn-primary:hover {
@ -101,7 +101,7 @@
color: var(--bs-primary-light) !important;
text-decoration: underline;
}
/* ADJUSTED: Custom size adjustment for right panel on desktop */
@media (min-width: 992px) {
/* 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;">
<span class="text-white">
<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">King Abdullah bin Abdulaziz University Hospital</div>
</div>
@ -145,24 +145,24 @@
</div>
<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">
<h2 id="form-title" class="h3 fw-bold mb-4 text-center">{% trans "Sign In" %}</h2>
<div class="form-fields">
<form id="login-form" class="space-y-4" method="post" action="{% url 'account_login' %}">
{% csrf_token %}
{% csrf_token %}
<div class="mb-3">
<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>
</div>
<div class="mb-3">
<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>
<div class="text-end mt-2">
<a href="{% url 'account_reset_password' %}" class="small text-accent fw-medium">{% trans 'Forgot Password?' %}</a>
</div>
@ -174,7 +174,7 @@
{% trans "Keep me signed in" %}
</label>
</div>
<button type="submit" class="btn btn-primary w-100 mt-4">{% trans "Sign In" %}</button>
</form>
</div>
@ -182,7 +182,7 @@
</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>
</body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,217 +1,478 @@
{% extends 'base.html' %}
{% load static i18n %}
{% extends "base.html" %}
{% 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 %}
<div class="container py-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">{{ title }}</h1>
<p class="text-muted mb-0">
<div class="container-fluid py-4">
<div class="form-container">
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<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 %}
{% 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 %}
{% 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 %}
</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>
<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 -->
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
{% 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 "Please correct the errors below:" %}
</h5>
{% for error in form.non_field_errors %}
<p class="mb-0">{{ error }}</p>
{% endfor %}
{% if agency %}
<!-- Current Agency 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">
<div class="current-image d-flex align-items-center justify-content-center bg-light">
<i class="fas fa-building text-muted"></i>
</div>
{% endif %}
<form method="post" novalidate>
{% 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 }}
{% if form.name.errors %}
{% for error in form.name.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
<div>
<h5 class="mb-1">{{ agency.name }}</h5>
{% if agency.contact_person %}
<p class="text-muted mb-0">{% trans "Contact" %}: {{ agency.contact_person }}</p>
{% endif %}
{% if form.name.help_text %}
<div class="form-text">{{ form.name.help_text }}</div>
{% if agency.email %}
<p class="text-muted mb-0">{{ agency.email }}</p>
{% 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>
<!-- 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>
{% 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>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add Bootstrap classes to form fields
const formFields = document.querySelectorAll('input[type="text"], input[type="email"], input[type="url"], input[type="tel"], textarea, select');
formFields.forEach(function(field) {
field.classList.add('form-control');
// Form Validation
const form = document.getElementById('agency-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 "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>
{% endblock %}
{% endblock %}

View File

@ -1,43 +1,51 @@
{% extends "base.html" %}
{% load static i18n crispy_forms_tags %}
{% block title %}Update Candidate - {{ block.super }}{% endblock %}
{% block title %}Update {{ object.name }} - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
/* ================================================= */
/* THEME VARIABLES AND GLOBAL STYLES (FROM JOB DETAIL) */
/* ================================================= */
/* UI Variables for the KAAT-S Theme */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-gray-light: #f8f9fa;
}
/* Primary Color Overrides */
.text-primary { color: var(--kaauh-teal) !important; }
/* 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, .btn-primary {
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
padding: 0.6rem 1.2rem;
transition: all 0.2s ease;
display: inline-flex;
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);
border-color: var(--kaauh-teal-dark);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Outlined Button Styles */
/* Secondary Button Style */
.btn-outline-secondary {
color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal);
@ -48,94 +56,308 @@
border-color: var(--kaauh-teal-dark);
}
/* Card enhancements */
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
background-color: white;
/* Form Field Styling */
.form-control:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
/* Colored Header Card */
.candidate-header-card {
background: linear-gradient(135deg, var(--kaauh-teal), #004d57);
color: white;
border-radius: 0.75rem 0.75rem 0 0;
padding: 1.5rem;
box-shadow: 0 4px 10px rgba(0,0,0,0.15);
.form-select:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
.candidate-header-card h1 {
font-weight: 700;
margin: 0;
font-size: 1.8rem;
/* Profile Image Upload Styling */
.profile-image-upload {
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;
height: 1.25rem;
vertical-align: text-bottom;
stroke: currentColor;
margin-right: 0.5rem;
.profile-image-upload:hover {
border-color: var(--kaauh-teal);
background-color: var(--kaauh-gray-light);
}
.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>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="card mb-4">
<div class="candidate-header-card">
<div class="d-flex justify-content-between align-items-start flex-wrap">
<div class="flex-grow-1">
<h1 class="h3 mb-1">
<i class="fas fa-user-edit"></i>
{% 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>
<div class="form-container">
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'application_list' %}" class="text-decoration-none text-secondary">
<i class="fas fa-users me-1"></i> {% trans "Applications" %}
</a>
{% if object.slug %}
<a href="{% url 'application_detail' object.slug %}" class="btn btn-outline-light btn-sm" title="{% trans 'View Candidate' %}">
<i class="fas fa-eye"></i>
<span class="d-none d-sm-inline">{% trans "View" %}</span>
</a>
{% endif %}
</li>
<li class="breadcrumb-item">
<a href="{% url 'application_detail' object.slug %}" class="text-decoration-none text-secondary">
{{ object.name }}
</a>
</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 class="card shadow-sm">
<div class="card-header bg-white border-bottom">
<h2 class="h5 mb-0 text-primary">
<i class="fas fa-file-alt me-1"></i>
{% trans "Candidate Form" %}
</h2>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{# Use Crispy Forms to render fields. The two-column layout is applied to the main form content #}
<div class="row g-4">
{% for field in form %}
<div class="col-md-6">
{{ field|as_crispy_field }}
<!-- 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 %}
</div>
<hr class="mt-4 mb-4">
<button class="btn btn-main-action" type="submit">
<i class="fas fa-save me-1"></i>
{% trans "Update Candidate" %}
</button>
</form>
{% endif %}
<form method="post" action="{% url 'candidate_update' object.slug %}" enctype="multipart/form-data" id="candidate-form">
{% csrf_token %}
{{form|crispy}}
</form>
<div class="d-flex gap-2">
<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>
{% 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 #}
<div class="vr" style="height: 28px;"></div>
<button type="button" class="btn btn-outline-info btn-sm"
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"
@ -418,7 +418,7 @@
</form>
</div>
</div>
<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" %}
</button>
<button type="button" class="btn btn-outline-info btn-sm"
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
hx-boost='true'
data-bs-target="#emailModal"

View File

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

View File

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

View File

@ -332,12 +332,12 @@
<a href="{% url 'application_update' candidate.slug %}" class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
<i class="fas fa-edit"></i>
</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-delete-url="{% url 'application_delete' candidate.slug %}"
data-item-name="{{ candidate.name }}">
<i class="fas fa-trash-alt"></i>
</button>
</button> {% endcomment %}
{% endif %}
</div>
</td>

View File

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

View File

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

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 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 %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">{{ title }}</h1>
<div class="container-fluid py-4">
<div class="form-container">
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb">
<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">
<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>
</div>
</div>
<div class="card">
<div class="card-body">
<form method="post" novalidate>
{% csrf_token %}
{% if source %}
<!-- Current Source 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">
<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 %}
<div class="alert alert-danger">
{% for error in form.non_field_errors %}
<!-- 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="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 }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{{ form.description.help_text }}</div>
</div>
<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>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<div class="form-check">
{{ 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>
{{ 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 }}
{% 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>
{% if form.is_active.errors %}
<div class="invalid-feedback d-block">
{% for error in form.is_active.errors %}
{{ error }}
{% endfor %}
</div>
{% if form.is_active.errors %}
<div class="invalid-feedback d-block">
{% for error in form.is_active.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{{ form.is_active.help_text }}</div>
</div>
{% endif %}
<div class="form-text">{{ form.is_active.help_text }}</div>
</div>
</div>
</div>
<!-- API Credentials Section -->
{% if source %}
<div class="card bg-light mb-4">
<div class="card-header">
<h6 class="mb-0">{% trans "API Credentials" %}</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">{% trans "API Key" %}</label>
<div class="input-group">
<input type="text" class="form-control" value="{{ source.api_key }}" readonly>
<button type="button" class="btn btn-outline-secondary"
hx-post="{% url 'copy_to_clipboard' %}"
hx-vals='{"text": "{{ source.api_key }}"}'
title="{% trans 'Copy to clipboard' %}">
<i class="fas fa-copy"></i>
</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>
<!-- API Credentials Section -->
{% if source %}
<div class="card bg-light mb-4">
<div class="card-header">
<h6 class="mb-0">{% trans "API Credentials" %}</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">{% trans "API Key" %}</label>
<div class="input-group">
<input type="text" class="form-control" value="{{ source.api_key }}" readonly>
<button type="button" class="btn btn-outline-secondary"
hx-post="{% url 'copy_to_clipboard' %}"
hx-vals='{"text": "{{ source.api_key }}"}'
title="{% trans 'Copy to clipboard' %}">
<i class="fas fa-copy"></i>
</button>
</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 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 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>
{% 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>
</form>
</div>
{% endif %}
<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>
@ -200,6 +413,67 @@
{% block extra_js %}
<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() {
const secretInput = document.getElementById('api-secret');
const toggleIcon = document.getElementById('secret-toggle-icon');