Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend
This commit is contained in:
commit
1253b86b02
6
.env
6
.env
@ -1,3 +1,3 @@
|
||||
DB_NAME=haikal_db
|
||||
DB_USER=faheed
|
||||
DB_PASSWORD=Faheed@215
|
||||
DB_NAME=norahuniversity
|
||||
DB_USER=norahuniversity
|
||||
DB_PASSWORD=norahuniversity
|
||||
@ -354,7 +354,7 @@ class ScheduledInterview(Base):
|
||||
candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE, related_name="scheduled_interviews")
|
||||
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="scheduled_interviews")
|
||||
zoom_meeting = models.OneToOneField(ZoomMeeting, on_delete=models.CASCADE, related_name="interview")
|
||||
schedule = models.ForeignKey(InterviewSchedule, on_delete=models.CASCADE, related_name="interviews", null=True, blank=True)
|
||||
schedule = models.ForeignKey(BulkInterviewTemplate, on_delete=models.CASCADE, related_name="interviews", null=True, blank=True)
|
||||
interview_date = models.DateField()
|
||||
interview_time = models.TimeField()
|
||||
status = models.CharField(max_length=20, choices=[
|
||||
@ -365,9 +365,9 @@ class ScheduledInterview(Base):
|
||||
], default="scheduled")
|
||||
```
|
||||
|
||||
#### 2.2.11 InterviewSchedule Model
|
||||
#### 2.2.11 BulkInterviewTemplate Model
|
||||
```python
|
||||
class InterviewSchedule(Base):
|
||||
class BulkInterviewTemplate(Base):
|
||||
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="interview_schedules")
|
||||
candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True, null=True)
|
||||
start_date = models.DateField()
|
||||
@ -533,7 +533,7 @@ class CandidateService:
|
||||
|
||||
### 5.2 Interview Scheduling Logic
|
||||
```python
|
||||
class InterviewScheduler:
|
||||
class BulkInterviewTemplater:
|
||||
@staticmethod
|
||||
def get_available_slots(schedule, date):
|
||||
"""Get available interview slots for a specific date"""
|
||||
@ -915,7 +915,7 @@ class InterviewSchedulingTestCase(TestCase):
|
||||
phone="9876543210",
|
||||
job=self.job
|
||||
)
|
||||
self.schedule = InterviewSchedule.objects.create(
|
||||
self.schedule = BulkInterviewTemplate.objects.create(
|
||||
job=self.job,
|
||||
start_date=timezone.now().date(),
|
||||
end_date=timezone.now().date() + timedelta(days=7),
|
||||
@ -930,7 +930,7 @@ class InterviewSchedulingTestCase(TestCase):
|
||||
def test_interview_scheduling(self):
|
||||
"""Test interview scheduling process"""
|
||||
# Test slot availability
|
||||
available_slots = InterviewScheduler.get_available_slots(
|
||||
available_slots = BulkInterviewTemplater.get_available_slots(
|
||||
self.schedule,
|
||||
timezone.now().date()
|
||||
)
|
||||
@ -942,7 +942,7 @@ class InterviewSchedulingTestCase(TestCase):
|
||||
'start_time': timezone.now().time(),
|
||||
'duration': 60
|
||||
}
|
||||
interview = InterviewScheduler.schedule_interview(
|
||||
interview = BulkInterviewTemplater.schedule_interview(
|
||||
self.candidate,
|
||||
self.job,
|
||||
schedule_data
|
||||
|
||||
@ -86,7 +86,7 @@ The test suite aims for 80% code coverage. Coverage reports are generated in:
|
||||
- **Candidate**: Stage transitions, relationships
|
||||
- **ZoomMeeting**: Time validation, status handling
|
||||
- **FormTemplate**: Template integrity, field ordering
|
||||
- **InterviewSchedule**: Scheduling logic, slot generation
|
||||
- **BulkInterviewTemplate**: Scheduling logic, slot generation
|
||||
|
||||
### 2. View Testing
|
||||
- **Job Management**: CRUD operations, search, filtering
|
||||
@ -97,7 +97,7 @@ The test suite aims for 80% code coverage. Coverage reports are generated in:
|
||||
### 3. Form Testing
|
||||
- **JobPostingForm**: Complex validation, field dependencies
|
||||
- **CandidateForm**: File upload, validation
|
||||
- **InterviewScheduleForm**: Dynamic fields, validation
|
||||
- **BulkInterviewTemplateForm**: Dynamic fields, validation
|
||||
- **MeetingCommentForm**: Comment creation/editing
|
||||
|
||||
### 4. Integration Testing
|
||||
|
||||
@ -28,13 +28,13 @@ from datetime import datetime, time, timedelta, date
|
||||
|
||||
from recruitment.models import (
|
||||
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
||||
FormSubmission, FieldResponse, BulkInterviewTemplate, ScheduledInterview,
|
||||
TrainingMaterial, Source, HiringAgency, Profile, MeetingComment, JobPostingImage,
|
||||
BreakTime
|
||||
)
|
||||
from recruitment.forms import (
|
||||
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
|
||||
CandidateStageForm, InterviewScheduleForm, BreakTimeFormSet
|
||||
CandidateStageForm, BulkInterviewTemplateForm, BreakTimeFormSet
|
||||
)
|
||||
|
||||
|
||||
@ -185,7 +185,7 @@ def interview_schedule(staff_user, job):
|
||||
)
|
||||
candidates.append(candidate)
|
||||
|
||||
return InterviewSchedule.objects.create(
|
||||
return BulkInterviewTemplate.objects.create(
|
||||
job=job,
|
||||
created_by=staff_user,
|
||||
start_date=date.today() + timedelta(days=1),
|
||||
|
||||
@ -3,10 +3,10 @@ from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from .models import (
|
||||
JobPosting, Application, TrainingMaterial, ZoomMeetingDetails,
|
||||
JobPosting, Application, TrainingMaterial,
|
||||
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
|
||||
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,JobPostingImage,InterviewNote,
|
||||
AgencyAccessLink, AgencyJobAssignment
|
||||
SharedFormTemplate, Source, HiringAgency, IntegrationLog,BulkInterviewTemplate,JobPostingImage,InterviewNote,
|
||||
AgencyAccessLink, AgencyJobAssignment,Interview,ScheduledInterview
|
||||
)
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
@ -158,27 +158,27 @@ class TrainingMaterialAdmin(admin.ModelAdmin):
|
||||
save_on_top = True
|
||||
|
||||
|
||||
@admin.register(ZoomMeetingDetails)
|
||||
class ZoomMeetingAdmin(admin.ModelAdmin):
|
||||
list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
|
||||
list_filter = ['timezone', 'created_at']
|
||||
search_fields = ['topic', 'meeting_id']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
fieldsets = (
|
||||
('Meeting Details', {
|
||||
'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone','status')
|
||||
}),
|
||||
('Meeting Settings', {
|
||||
'fields': ('participant_video', 'join_before_host', 'mute_upon_entry', 'waiting_room')
|
||||
}),
|
||||
('Access', {
|
||||
'fields': ('join_url',)
|
||||
}),
|
||||
('System Response', {
|
||||
'fields': ('zoom_gateway_response', 'created_at', 'updated_at')
|
||||
}),
|
||||
)
|
||||
save_on_top = True
|
||||
# @admin.register(ZoomMeetingDetails)
|
||||
# class ZoomMeetingAdmin(admin.ModelAdmin):
|
||||
# list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
|
||||
# list_filter = ['timezone', 'created_at']
|
||||
# search_fields = ['topic', 'meeting_id']
|
||||
# readonly_fields = ['created_at', 'updated_at']
|
||||
# fieldsets = (
|
||||
# ('Meeting Details', {
|
||||
# 'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone','status')
|
||||
# }),
|
||||
# ('Meeting Settings', {
|
||||
# 'fields': ('participant_video', 'join_before_host', 'mute_upon_entry', 'waiting_room')
|
||||
# }),
|
||||
# ('Access', {
|
||||
# 'fields': ('join_url',)
|
||||
# }),
|
||||
# ('System Response', {
|
||||
# 'fields': ('zoom_gateway_response', 'created_at', 'updated_at')
|
||||
# }),
|
||||
# )
|
||||
# save_on_top = True
|
||||
|
||||
|
||||
# @admin.register(InterviewNote)
|
||||
@ -241,9 +241,11 @@ admin.site.register(FormStage)
|
||||
admin.site.register(Application)
|
||||
admin.site.register(FormField)
|
||||
admin.site.register(FieldResponse)
|
||||
admin.site.register(InterviewSchedule)
|
||||
admin.site.register(BulkInterviewTemplate)
|
||||
admin.site.register(AgencyAccessLink)
|
||||
admin.site.register(AgencyJobAssignment)
|
||||
admin.site.register(Interview)
|
||||
admin.site.register(ScheduledInterview)
|
||||
# AgencyMessage admin removed - model has been deleted
|
||||
|
||||
|
||||
|
||||
1281
recruitment/forms.py
1281
recruitment/forms.py
File diff suppressed because it is too large
Load Diff
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-19 14:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0002_alter_jobposting_job_type_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='jobposting',
|
||||
name='cv_zip_file',
|
||||
field=models.FileField(blank=True, null=True, upload_to='job_zips/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='jobposting',
|
||||
name='zip_created',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@ -1,19 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-23 09:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0003_jobposting_cv_zip_file_jobposting_zip_created'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='interviewschedule',
|
||||
name='template_location',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='schedule_templates', to='recruitment.interviewlocation', verbose_name='Location Template (Zoom/Onsite)'),
|
||||
),
|
||||
]
|
||||
@ -1,19 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-23 09:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0004_alter_interviewschedule_template_location'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='interviewschedule',
|
||||
name='template_location',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schedule_templates', to='recruitment.interviewlocation', verbose_name='Location Template (Zoom/Onsite)'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-23 12:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0005_alter_interviewschedule_template_location'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='customuser',
|
||||
name='email',
|
||||
field=models.EmailField(error_messages={'unique': 'A user with this email already exists.'}, max_length=254, unique=True),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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."),
|
||||
|
||||
@ -12,7 +12,7 @@ from . linkedin_service import LinkedInService
|
||||
from django.shortcuts import get_object_or_404
|
||||
from . models import JobPosting
|
||||
from django.utils import timezone
|
||||
from . models import InterviewSchedule,ScheduledInterview,ZoomMeetingDetails,Message
|
||||
from . models import ScheduledInterview,Interview,Message
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
# Add python-docx import for Word document processing
|
||||
@ -679,20 +679,28 @@ def create_interview_and_meeting(
|
||||
Synchronous task for a single interview slot, dispatched by django-q.
|
||||
"""
|
||||
try:
|
||||
candidate = Application.objects.get(pk=candidate_id)
|
||||
application = Application.objects.get(pk=candidate_id)
|
||||
job = JobPosting.objects.get(pk=job_id)
|
||||
schedule = InterviewSchedule.objects.get(pk=schedule_id)
|
||||
schedule = ScheduledInterview.objects.get(pk=schedule_id)
|
||||
|
||||
interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time))
|
||||
meeting_topic = f"Interview for {job.title} - {candidate.name}"
|
||||
meeting_topic = f"Interview for {job.title} - {application.name}"
|
||||
|
||||
# 1. External API Call (Slow)
|
||||
|
||||
# "status": "success",
|
||||
# "message": "Meeting created successfully.",
|
||||
# "meeting_details": {
|
||||
# "join_url": meeting_data['join_url'],
|
||||
# "meeting_id": meeting_data['id'],
|
||||
# "password": meeting_data['password'],
|
||||
# "host_email": meeting_data['host_email']
|
||||
# },
|
||||
# "zoom_gateway_response": meeting_data
|
||||
# }
|
||||
result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
|
||||
|
||||
if result["status"] == "success":
|
||||
# 2. Database Writes (Slow)
|
||||
zoom_meeting = ZoomMeetingDetails.objects.create(
|
||||
interview = Interview.objects.create(
|
||||
topic=meeting_topic,
|
||||
start_time=interview_datetime,
|
||||
duration=duration,
|
||||
@ -703,14 +711,31 @@ def create_interview_and_meeting(
|
||||
password=result["meeting_details"]["password"],
|
||||
location_type="Remote"
|
||||
)
|
||||
ScheduledInterview.objects.create(
|
||||
application=candidate,
|
||||
job=job,
|
||||
interview_location=zoom_meeting,
|
||||
schedule=schedule,
|
||||
interview_date=slot_date,
|
||||
interview_time=slot_time
|
||||
)
|
||||
schedule.interviews = interview
|
||||
schedule.status = "Remote"
|
||||
|
||||
schedule.save()
|
||||
|
||||
# 2. Database Writes (Slow)
|
||||
# zoom_meeting = ZoomMeetingDetails.objects.create(
|
||||
# topic=meeting_topic,
|
||||
# start_time=interview_datetime,
|
||||
# duration=duration,
|
||||
# meeting_id=result["meeting_details"]["meeting_id"],
|
||||
# details_url=result["meeting_details"]["join_url"],
|
||||
# zoom_gateway_response=result["zoom_gateway_response"],
|
||||
# host_email=result["meeting_details"]["host_email"],
|
||||
# password=result["meeting_details"]["password"],
|
||||
# location_type="Remote"
|
||||
# )
|
||||
# ScheduledInterview.objects.create(
|
||||
# application=candidate,
|
||||
# job=job,
|
||||
# interview_location=zoom_meeting,
|
||||
# schedule=schedule,
|
||||
# interview_date=slot_date,
|
||||
# interview_time=slot_time
|
||||
# )
|
||||
|
||||
# Log success or use Django-Q result system for monitoring
|
||||
logger.info(f"Successfully scheduled interview for {Application.name}")
|
||||
@ -745,7 +770,7 @@ def handle_zoom_webhook_event(payload):
|
||||
try:
|
||||
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
|
||||
# and to simplify the logic flow.
|
||||
meeting_instance = ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first()
|
||||
meeting_instance = ''#TODO:update #ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first()
|
||||
print(meeting_instance)
|
||||
# --- 1. Creation and Update Events ---
|
||||
if event_type == 'meeting.updated':
|
||||
|
||||
@ -11,12 +11,12 @@ User = get_user_model()
|
||||
|
||||
from .models import (
|
||||
JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
||||
FormSubmission, FieldResponse, BulkInterviewTemplate, ScheduledInterview,
|
||||
TrainingMaterial, Source, HiringAgency, MeetingComment
|
||||
)
|
||||
from .forms import (
|
||||
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
|
||||
CandidateStageForm, InterviewScheduleForm, CandidateSignupForm
|
||||
CandidateStageForm, BulkInterviewTemplateForm, CandidateSignupForm
|
||||
)
|
||||
from .views import (
|
||||
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, applications_screening_view,
|
||||
@ -304,7 +304,7 @@ class FormTests(BaseTestCase):
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_interview_schedule_form(self):
|
||||
"""Test InterviewScheduleForm"""
|
||||
"""Test BulkInterviewTemplateForm"""
|
||||
# Update candidate to Interview stage first
|
||||
self.candidate.stage = 'Interview'
|
||||
self.candidate.save()
|
||||
@ -315,7 +315,7 @@ class FormTests(BaseTestCase):
|
||||
'end_date': (timezone.now() + timedelta(days=7)).date(),
|
||||
'working_days': [0, 1, 2, 3, 4], # Monday to Friday
|
||||
}
|
||||
form = InterviewScheduleForm(slug=self.job.slug, data=form_data)
|
||||
form = BulkInterviewTemplateForm(slug=self.job.slug, data=form_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_candidate_signup_form_valid(self):
|
||||
|
||||
@ -24,13 +24,13 @@ from PIL import Image
|
||||
|
||||
from .models import (
|
||||
JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
||||
FormSubmission, FieldResponse, BulkInterviewTemplate, ScheduledInterview,
|
||||
TrainingMaterial, Source, HiringAgency, MeetingComment, JobPostingImage,
|
||||
BreakTime
|
||||
)
|
||||
from .forms import (
|
||||
JobPostingForm, ApplicationForm, ZoomMeetingForm, MeetingCommentForm,
|
||||
ApplicationStageForm, InterviewScheduleForm, BreakTimeFormSet
|
||||
ApplicationStageForm, BulkInterviewTemplateForm, BreakTimeFormSet
|
||||
)
|
||||
from .views import (
|
||||
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, applications_screening_view,
|
||||
@ -228,7 +228,7 @@ class AdvancedModelTests(TestCase):
|
||||
'break_end_time': '13:00'
|
||||
}
|
||||
|
||||
form = InterviewScheduleForm(slug=self.job.slug, data=schedule_data)
|
||||
form = BulkInterviewTemplateForm(slug=self.job.slug, data=schedule_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_field_response_data_types(self):
|
||||
@ -625,7 +625,7 @@ class AdvancedFormTests(TestCase):
|
||||
|
||||
def test_form_dependency_validation(self):
|
||||
"""Test validation for dependent form fields"""
|
||||
# Test InterviewScheduleForm with dependent fields
|
||||
# Test BulkInterviewTemplateForm with dependent fields
|
||||
schedule_data = {
|
||||
'candidates': [], # Empty for now
|
||||
'start_date': '2025-01-15',
|
||||
@ -637,7 +637,7 @@ class AdvancedFormTests(TestCase):
|
||||
'buffer_time': '15'
|
||||
}
|
||||
|
||||
form = InterviewScheduleForm(slug=self.job.slug, data=schedule_data)
|
||||
form = BulkInterviewTemplateForm(slug=self.job.slug, data=schedule_data)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('end_date', form.errors)
|
||||
|
||||
@ -667,7 +667,7 @@ class AdvancedFormTests(TestCase):
|
||||
|
||||
def test_dynamic_form_fields(self):
|
||||
"""Test forms with dynamically populated fields"""
|
||||
# Test InterviewScheduleForm with dynamic candidate queryset
|
||||
# Test BulkInterviewTemplateForm with dynamic candidate queryset
|
||||
# Create applications in Interview stage
|
||||
applications = []
|
||||
for i in range(3):
|
||||
@ -684,7 +684,7 @@ class AdvancedFormTests(TestCase):
|
||||
applications.append(application)
|
||||
|
||||
# Form should only show Interview stage applications
|
||||
form = InterviewScheduleForm(slug=self.job.slug)
|
||||
form = BulkInterviewTemplateForm(slug=self.job.slug)
|
||||
self.assertEqual(form.fields['candidates'].queryset.count(), 3)
|
||||
|
||||
for application in applications:
|
||||
|
||||
@ -207,21 +207,21 @@ urlpatterns = [
|
||||
),
|
||||
|
||||
|
||||
path(
|
||||
"jobs/<slug:slug>/<int:application_id>/reschedule_meeting_for_application/<int:meeting_id>/",
|
||||
views.reschedule_meeting_for_application,
|
||||
name="reschedule_meeting_for_application",
|
||||
),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/<int:application_id>/reschedule_meeting_for_application/<int:meeting_id>/",
|
||||
# views.reschedule_meeting_for_application,
|
||||
# name="reschedule_meeting_for_application",
|
||||
# ),
|
||||
path(
|
||||
"jobs/<slug:slug>/update_application_exam_status/",
|
||||
views.update_application_exam_status,
|
||||
name="update_application_exam_status",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:slug>/bulk_update_application_exam_status/",
|
||||
views.bulk_update_application_exam_status,
|
||||
name="bulk_update_application_exam_status",
|
||||
),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/bulk_update_application_exam_status/",
|
||||
# views.bulk_update_application_exam_status,
|
||||
# name="bulk_update_application_exam_status",
|
||||
# ),
|
||||
path(
|
||||
"htmx/<int:pk>/application_criteria_view/",
|
||||
views.application_criteria_view_htmx,
|
||||
@ -266,16 +266,16 @@ urlpatterns = [
|
||||
# path('api/templates/save/', views.save_form_template, name='save_form_template'),
|
||||
# path('api/templates/<slug:template_slug>/', views.load_form_template, name='load_form_template'),
|
||||
# path('api/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'),
|
||||
path(
|
||||
"jobs/<slug:slug>/calendar/",
|
||||
views.interview_calendar_view,
|
||||
name="interview_calendar",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:slug>/calendar/interview/<int:interview_id>/",
|
||||
views.interview_detail_view,
|
||||
name="interview_detail",
|
||||
),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/calendar/",
|
||||
# views.interview_calendar_view,
|
||||
# name="interview_calendar",
|
||||
# ),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/calendar/interview/<int:interview_id>/",
|
||||
# views.interview_detail_view,
|
||||
# name="interview_detail",
|
||||
# ),
|
||||
|
||||
# users urls
|
||||
path("user/<int:pk>", views.user_detail, name="user_detail"),
|
||||
@ -333,26 +333,26 @@ urlpatterns = [
|
||||
name="copy_to_clipboard",
|
||||
),
|
||||
# Meeting Comments URLs
|
||||
path(
|
||||
"meetings/<slug:slug>/comments/add/",
|
||||
views.add_meeting_comment,
|
||||
name="add_meeting_comment",
|
||||
),
|
||||
path(
|
||||
"meetings/<slug:slug>/comments/<int:comment_id>/edit/",
|
||||
views.edit_meeting_comment,
|
||||
name="edit_meeting_comment",
|
||||
),
|
||||
path(
|
||||
"meetings/<slug:slug>/comments/<int:comment_id>/delete/",
|
||||
views.delete_meeting_comment,
|
||||
name="delete_meeting_comment",
|
||||
),
|
||||
path(
|
||||
"meetings/<slug:slug>/set_meeting_application/",
|
||||
views.set_meeting_application,
|
||||
name="set_meeting_application",
|
||||
),
|
||||
# path(
|
||||
# "meetings/<slug:slug>/comments/add/",
|
||||
# views.add_meeting_comment,
|
||||
# name="add_meeting_comment",
|
||||
# ),
|
||||
# path(
|
||||
# "meetings/<slug:slug>/comments/<int:comment_id>/edit/",
|
||||
# views.edit_meeting_comment,
|
||||
# name="edit_meeting_comment",
|
||||
# ),
|
||||
# path(
|
||||
# "meetings/<slug:slug>/comments/<int:comment_id>/delete/",
|
||||
# views.delete_meeting_comment,
|
||||
# name="delete_meeting_comment",
|
||||
# ),
|
||||
# path(
|
||||
# "meetings/<slug:slug>/set_meeting_application/",
|
||||
# views.set_meeting_application,
|
||||
# name="set_meeting_application",
|
||||
# ),
|
||||
# Hiring Agency URLs
|
||||
path("agencies/", views.agency_list, name="agency_list"),
|
||||
path("agencies/create/", views.agency_create, name="agency_create"),
|
||||
@ -510,31 +510,31 @@ urlpatterns = [
|
||||
# path('notifications/mark-all-read/', views.notification_mark_all_read, name='notification_mark_all_read'),
|
||||
# path('api/notification-count/', views.api_notification_count, name='api_notification_count'),
|
||||
# participants urls
|
||||
path(
|
||||
"participants/",
|
||||
views_frontend.ParticipantsListView.as_view(),
|
||||
name="participants_list",
|
||||
),
|
||||
path(
|
||||
"participants/create/",
|
||||
views_frontend.ParticipantsCreateView.as_view(),
|
||||
name="participants_create",
|
||||
),
|
||||
path(
|
||||
"participants/<slug:slug>/",
|
||||
views_frontend.ParticipantsDetailView.as_view(),
|
||||
name="participants_detail",
|
||||
),
|
||||
path(
|
||||
"participants/<slug:slug>/update/",
|
||||
views_frontend.ParticipantsUpdateView.as_view(),
|
||||
name="participants_update",
|
||||
),
|
||||
path(
|
||||
"participants/<slug:slug>/delete/",
|
||||
views_frontend.ParticipantsDeleteView.as_view(),
|
||||
name="participants_delete",
|
||||
),
|
||||
# path(
|
||||
# "participants/",
|
||||
# views_frontend.ParticipantsListView.as_view(),
|
||||
# name="participants_list",
|
||||
# ),
|
||||
# path(
|
||||
# "participants/create/",
|
||||
# views_frontend.ParticipantsCreateView.as_view(),
|
||||
# name="participants_create",
|
||||
# ),
|
||||
# path(
|
||||
# "participants/<slug:slug>/",
|
||||
# views_frontend.ParticipantsDetailView.as_view(),
|
||||
# name="participants_detail",
|
||||
# ),
|
||||
# path(
|
||||
# "participants/<slug:slug>/update/",
|
||||
# views_frontend.ParticipantsUpdateView.as_view(),
|
||||
# name="participants_update",
|
||||
# ),
|
||||
# path(
|
||||
# "participants/<slug:slug>/delete/",
|
||||
# views_frontend.ParticipantsDeleteView.as_view(),
|
||||
# name="participants_delete",
|
||||
# ),
|
||||
# Email composition URLs
|
||||
path(
|
||||
"jobs/<slug:job_slug>/applications/compose-email/",
|
||||
@ -563,13 +563,23 @@ urlpatterns = [
|
||||
path("application/documents/<int:document_id>/download/", views.document_download, name="application_document_download"),
|
||||
path('jobs/<slug:job_slug>/applications/compose_email/', views.compose_application_email, name='compose_application_email'),
|
||||
|
||||
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
|
||||
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
|
||||
# path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
|
||||
# path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
|
||||
# Candidate Signup
|
||||
path('application/signup/<slug:template_slug>/', views.application_signup, name='application_signup'),
|
||||
# Password Reset
|
||||
path('user/<int:pk>/password-reset/', views.portal_password_reset, name='portal_password_reset'),
|
||||
|
||||
# Interview URLs
|
||||
path('interviews/', views.interview_list, name='interview_list'),
|
||||
path('interviews/<slug:slug>/', views.interview_detail, name='interview_detail'),
|
||||
|
||||
# Interview Creation URLs
|
||||
path('interviews/create/<slug:candidate_slug>/', views.interview_create_type_selection, name='interview_create_type_selection'),
|
||||
path('interviews/create/<slug:candidate_slug>/remote/', views.interview_create_remote, name='interview_create_remote'),
|
||||
path('interviews/create/<slug:candidate_slug>/onsite/', views.interview_create_onsite, name='interview_create_onsite'),
|
||||
path('interviews/<slug:job_slug>/get_interview_list', views.get_interview_list, name='get_interview_list'),
|
||||
|
||||
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
|
||||
# path('interview/list/', views.interview_list, name='interview_list'),
|
||||
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'),
|
||||
@ -577,64 +587,64 @@ urlpatterns = [
|
||||
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
|
||||
|
||||
#interview and meeting related urls
|
||||
path(
|
||||
"jobs/<slug:slug>/schedule-interviews/",
|
||||
views.schedule_interviews_view,
|
||||
name="schedule_interviews",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:slug>/confirm-schedule-interviews/",
|
||||
views.confirm_schedule_interviews_view,
|
||||
name="confirm_schedule_interviews_view",
|
||||
),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/schedule-interviews/",
|
||||
# views.schedule_interviews_view,
|
||||
# name="schedule_interviews",
|
||||
# ),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/confirm-schedule-interviews/",
|
||||
# views.confirm_schedule_interviews_view,
|
||||
# name="confirm_schedule_interviews_view",
|
||||
# ),
|
||||
|
||||
path(
|
||||
"meetings/create-meeting/",
|
||||
views.ZoomMeetingCreateView.as_view(),
|
||||
name="create_meeting",
|
||||
),
|
||||
# path(
|
||||
# "meetings/create-meeting/",
|
||||
# views.ZoomMeetingCreateView.as_view(),
|
||||
# name="create_meeting",
|
||||
# ),
|
||||
# path(
|
||||
# "meetings/meeting-details/<slug:slug>/",
|
||||
# views.ZoomMeetingDetailsView.as_view(),
|
||||
# name="meeting_details",
|
||||
# ),
|
||||
path(
|
||||
"meetings/update-meeting/<slug:slug>/",
|
||||
views.ZoomMeetingUpdateView.as_view(),
|
||||
name="update_meeting",
|
||||
),
|
||||
path(
|
||||
"meetings/delete-meeting/<slug:slug>/",
|
||||
views.ZoomMeetingDeleteView,
|
||||
name="delete_meeting",
|
||||
),
|
||||
# path(
|
||||
# "meetings/update-meeting/<slug:slug>/",
|
||||
# views.ZoomMeetingUpdateView.as_view(),
|
||||
# name="update_meeting",
|
||||
# ),
|
||||
# path(
|
||||
# "meetings/delete-meeting/<slug:slug>/",
|
||||
# views.ZoomMeetingDeleteView,
|
||||
# name="delete_meeting",
|
||||
# ),
|
||||
# Candidate Meeting Scheduling/Rescheduling URLs
|
||||
path(
|
||||
"jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
|
||||
views.schedule_application_meeting,
|
||||
name="schedule_application_meeting",
|
||||
),
|
||||
path(
|
||||
"api/jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
|
||||
views.api_schedule_application_meeting,
|
||||
name="api_schedule_application_meeting",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
|
||||
views.reschedule_application_meeting,
|
||||
name="reschedule_application_meeting",
|
||||
),
|
||||
path(
|
||||
"api/jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
|
||||
views.api_reschedule_application_meeting,
|
||||
name="api_reschedule_application_meeting",
|
||||
),
|
||||
# path(
|
||||
# "jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
|
||||
# views.schedule_application_meeting,
|
||||
# name="schedule_application_meeting",
|
||||
# ),
|
||||
# path(
|
||||
# "api/jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
|
||||
# views.api_schedule_application_meeting,
|
||||
# name="api_schedule_application_meeting",
|
||||
# ),
|
||||
# path(
|
||||
# "jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
|
||||
# views.reschedule_application_meeting,
|
||||
# name="reschedule_application_meeting",
|
||||
# ),
|
||||
# path(
|
||||
# "api/jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
|
||||
# views.api_reschedule_application_meeting,
|
||||
# name="api_reschedule_application_meeting",
|
||||
# ),
|
||||
# New URL for simple page-based meeting scheduling
|
||||
path(
|
||||
"jobs/<slug:slug>/applications/<int:application_pk>/schedule-meeting-page/",
|
||||
views.schedule_meeting_for_application,
|
||||
name="schedule_meeting_for_application",
|
||||
),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/applications/<int:application_pk>/schedule-meeting-page/",
|
||||
# views.schedule_meeting_for_application,
|
||||
# name="schedule_meeting_for_application",
|
||||
# ),
|
||||
# path(
|
||||
# "jobs/<slug:slug>/applications/<int:application_pk>/delete_meeting_for_application/<int:meeting_id>/",
|
||||
# views.delete_meeting_for_candidate,
|
||||
@ -642,35 +652,35 @@ urlpatterns = [
|
||||
# ),
|
||||
|
||||
|
||||
path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
|
||||
# path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
|
||||
|
||||
# 1. Onsite Reschedule URL
|
||||
path(
|
||||
'<slug:slug>/application/<int:application_id>/onsite/reschedule/<int:meeting_id>/',
|
||||
views.reschedule_onsite_meeting,
|
||||
name='reschedule_onsite_meeting'
|
||||
),
|
||||
# path(
|
||||
# '<slug:slug>/application/<int:application_id>/onsite/reschedule/<int:meeting_id>/',
|
||||
# views.reschedule_onsite_meeting,
|
||||
# name='reschedule_onsite_meeting'
|
||||
# ),
|
||||
|
||||
# 2. Onsite Delete URL
|
||||
|
||||
path(
|
||||
'job/<slug:slug>/applications/<int:application_pk>/delete-onsite-meeting/<int:meeting_id>/',
|
||||
views.delete_onsite_meeting_for_application,
|
||||
name='delete_onsite_meeting_for_application'
|
||||
),
|
||||
# path(
|
||||
# 'job/<slug:slug>/applications/<int:application_pk>/delete-onsite-meeting/<int:meeting_id>/',
|
||||
# views.delete_onsite_meeting_for_application,
|
||||
# name='delete_onsite_meeting_for_application'
|
||||
# ),
|
||||
|
||||
path(
|
||||
'job/<slug:slug>/application/<int:application_pk>/schedule/onsite/',
|
||||
views.schedule_onsite_meeting_for_application,
|
||||
name='schedule_onsite_meeting_for_application' # This is the name used in the button
|
||||
),
|
||||
# path(
|
||||
# 'job/<slug:slug>/application/<int:application_pk>/schedule/onsite/',
|
||||
# views.schedule_onsite_meeting_for_application,
|
||||
# name='schedule_onsite_meeting_for_application' # This is the name used in the button
|
||||
# ),
|
||||
|
||||
|
||||
# Detail View (assuming slug is on ScheduledInterview)
|
||||
path("interviews/meetings/<slug:slug>/", views.meeting_details, name="meeting_details"),
|
||||
# path("interviews/meetings/<slug:slug>/", views.meeting_details, name="meeting_details"),
|
||||
|
||||
# Email invitation URLs
|
||||
path("interviews/meetings/<slug:slug>/send-application-invitation/", views.send_application_invitation, name="send_application_invitation"),
|
||||
path("interviews/meetings/<slug:slug>/send-participants-invitation/", views.send_participants_invitation, name="send_participants_invitation"),
|
||||
# path("interviews/meetings/<slug:slug>/send-application-invitation/", views.send_application_invitation, name="send_application_invitation"),
|
||||
# path("interviews/meetings/<slug:slug>/send-participants-invitation/", views.send_participants_invitation, name="send_participants_invitation"),
|
||||
|
||||
]
|
||||
|
||||
3984
recruitment/views.py
3984
recruitment/views.py
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,6 @@ from django.http import JsonResponse, HttpResponse
|
||||
from django.db.models.fields.json import KeyTextTransform,KeyTransform
|
||||
from recruitment.utils import json_to_markdown_table
|
||||
from django.db.models import Count, Avg, F, FloatField
|
||||
from django.db.models.functions import Cast
|
||||
from django.db.models.functions import Coalesce, Cast, Replace, NullIf
|
||||
from . import models
|
||||
from django.utils.translation import get_language
|
||||
@ -1065,47 +1064,47 @@ def sync_history(request, job_slug=None):
|
||||
|
||||
|
||||
#participants views
|
||||
class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
||||
model = models.Participants
|
||||
template_name = 'participants/participants_list.html'
|
||||
context_object_name = 'participants'
|
||||
paginate_by = 10
|
||||
# class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
||||
# model = models.Participants
|
||||
# template_name = 'participants/participants_list.html'
|
||||
# context_object_name = 'participants'
|
||||
# paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
# def get_queryset(self):
|
||||
# queryset = super().get_queryset()
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get('search', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=search_query) |
|
||||
Q(email__icontains=search_query) |
|
||||
Q(phone__icontains=search_query) |
|
||||
Q(designation__icontains=search_query)
|
||||
)
|
||||
# # Handle search
|
||||
# search_query = self.request.GET.get('search', '')
|
||||
# if search_query:
|
||||
# queryset = queryset.filter(
|
||||
# Q(name__icontains=search_query) |
|
||||
# Q(email__icontains=search_query) |
|
||||
# Q(phone__icontains=search_query) |
|
||||
# Q(designation__icontains=search_query)
|
||||
# )
|
||||
|
||||
# Filter for non-staff users
|
||||
if not self.request.user.is_staff:
|
||||
return models.Participants.objects.none() # Restrict for non-staff
|
||||
# # Filter for non-staff users
|
||||
# if not self.request.user.is_staff:
|
||||
# return models.Participants.objects.none() # Restrict for non-staff
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
# return queryset.order_by('-created_at')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
return context
|
||||
class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
|
||||
model = models.Participants
|
||||
template_name = 'participants/participants_detail.html'
|
||||
context_object_name = 'participant'
|
||||
slug_url_kwarg = 'slug'
|
||||
# def get_context_data(self, **kwargs):
|
||||
# context = super().get_context_data(**kwargs)
|
||||
# context['search_query'] = self.request.GET.get('search', '')
|
||||
# return context
|
||||
# class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
|
||||
# model = models.Participants
|
||||
# template_name = 'participants/participants_detail.html'
|
||||
# context_object_name = 'participant'
|
||||
# slug_url_kwarg = 'slug'
|
||||
|
||||
class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
model = models.Participants
|
||||
form_class = forms.ParticipantsForm
|
||||
template_name = 'participants/participants_create.html'
|
||||
success_url = reverse_lazy('job_list')
|
||||
success_message = 'Participant created successfully.'
|
||||
# class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
# model = models.Participants
|
||||
# form_class = forms.ParticipantsForm
|
||||
# template_name = 'participants/participants_create.html'
|
||||
# success_url = reverse_lazy('job_list')
|
||||
# success_message = 'Participant created successfully.'
|
||||
|
||||
# def get_initial(self):
|
||||
# initial = super().get_initial()
|
||||
@ -1116,17 +1115,17 @@ class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMess
|
||||
|
||||
|
||||
|
||||
class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
model = models.Participants
|
||||
form_class = forms.ParticipantsForm
|
||||
template_name = 'participants/participants_create.html'
|
||||
success_url = reverse_lazy('job_list')
|
||||
success_message = 'Participant updated successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
# class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
# model = models.Participants
|
||||
# form_class = forms.ParticipantsForm
|
||||
# template_name = 'participants/participants_create.html'
|
||||
# success_url = reverse_lazy('job_list')
|
||||
# success_message = 'Participant updated successfully.'
|
||||
# slug_url_kwarg = 'slug'
|
||||
|
||||
class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
model = models.Participants
|
||||
# class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
# model = models.Participants
|
||||
|
||||
success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
|
||||
success_message = 'Participant deleted successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
# success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
|
||||
# success_message = 'Participant deleted successfully.'
|
||||
# slug_url_kwarg = 'slug'
|
||||
|
||||
335
requirements.txt
335
requirements.txt
@ -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
|
||||
|
||||
@ -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>
|
||||
@ -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" %}
|
||||
|
||||
237
templates/interviews/interview_create_onsite.html
Normal file
237
templates/interviews/interview_create_onsite.html
Normal file
@ -0,0 +1,237 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Onsite Interview{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0">
|
||||
<i class="fas fa-building me-2"></i>
|
||||
Create Onsite Interview for {{ candidate.name }}
|
||||
</h4>
|
||||
<a href="{% url 'interview_create_type_selection' candidate.slug %}"
|
||||
class="btn btn-outline-primary">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Back to Candidate List
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-3">
|
||||
Schedule an onsite interview for <strong>{{ candidate.name }}</strong>
|
||||
for the position of <strong>{{ job.title }}</strong>.
|
||||
</p>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'interview_create_onsite' candidate_slug=candidate.slug %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.interview_date.id_for_label }}" class="form-label">
|
||||
<i class="fas fa-calendar me-1"></i>
|
||||
Topic
|
||||
</label>
|
||||
{{ form.topic }}
|
||||
{% if form.topic.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.topic.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.interview_date.id_for_label }}" class="form-label">
|
||||
<i class="fas fa-calendar me-1"></i>
|
||||
Interview Date
|
||||
</label>
|
||||
{{ form.interview_date }}
|
||||
{% if form.interview_date.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.interview_date.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.interview_time.id_for_label }}" class="form-label">
|
||||
<i class="fas fa-clock me-1"></i>
|
||||
Interview Time
|
||||
</label>
|
||||
{{ form.interview_time }}
|
||||
{% if form.interview_time.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.interview_time.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.duration.id_for_label }}" class="form-label">
|
||||
<i class="fas fa-hourglass-half me-1"></i>
|
||||
Duration (minutes)
|
||||
</label>
|
||||
{{ form.duration }}
|
||||
{% if form.duration.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.duration.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% comment %} <div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.interviewer.id_for_label }}" class="form-label">
|
||||
<i class="fas fa-user me-1"></i>
|
||||
Interviewer
|
||||
</label>
|
||||
{{ form.interviewer }}
|
||||
{% if form.interviewer.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.interviewer.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
</div>
|
||||
|
||||
{% comment %} <div class="mb-3">
|
||||
<label for="{{ form.topic.id_for_label }}" class="form-label">
|
||||
<i class="fas fa-comment me-1"></i>
|
||||
Meeting Topic
|
||||
</label>
|
||||
{{ form.topic }}
|
||||
{% if form.topic.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.topic.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div> {% endcomment %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.physical_address.id_for_label }}" class="form-label">
|
||||
<i class="fas fa-map-marker-alt me-1"></i>
|
||||
Physical Address
|
||||
</label>
|
||||
{{ form.physical_address }}
|
||||
{% if form.physical_address.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.physical_address.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.room_number.id_for_label }}" class="form-label">
|
||||
<i class="fas fa-door-open me-1"></i>
|
||||
Room Number
|
||||
</label>
|
||||
{{ form.room_number }}
|
||||
{% if form.room_number.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.room_number.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% comment %} <div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.floor_number.id_for_label }}" class="form-label">
|
||||
<i class="fas fa-layer-group me-1"></i>
|
||||
Floor Number
|
||||
</label>
|
||||
{{ form.floor_number }}
|
||||
{% if form.floor_number.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.floor_number.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
{% comment %} </div> {% endcomment %}
|
||||
|
||||
{% comment %} <div class="mb-3">
|
||||
<label for="{{ form.parking_info.id_for_label }}" class="form-label">
|
||||
<i class="fas fa-parking me-1"></i>
|
||||
Parking Information
|
||||
</label>
|
||||
{{ form.parking_info }}
|
||||
{% if form.parking_info.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.parking_info.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div> {% endcomment %}
|
||||
|
||||
{% comment %} <div class="mb-3">
|
||||
<label for="{{ form.notes.id_for_label }}" class="form-label">
|
||||
<i class="fas fa-sticky-note me-1"></i>
|
||||
Notes
|
||||
</label>
|
||||
{{ form.notes }}
|
||||
{% if form.notes.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.notes.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div> {% endcomment %}
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="submit" class="btn btn-main-action">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Schedule Onsite Interview
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add form validation for future dates
|
||||
const dateInput = document.querySelector('input[type="date"]');
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
if (dateInput) {
|
||||
dateInput.min = today;
|
||||
|
||||
dateInput.addEventListener('change', function() {
|
||||
if (this.value < today) {
|
||||
this.setCustomValidity('Interview date must be in the future');
|
||||
} else {
|
||||
this.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
74
templates/interviews/interview_create_remote.html
Normal file
74
templates/interviews/interview_create_remote.html
Normal file
@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n crispy_forms_tags %}
|
||||
|
||||
{% block title %}Create Remote Interview{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0">
|
||||
<i class="fas fa-video me-2"></i>
|
||||
Create Remote Interview for {{ candidate.name }}
|
||||
</h4>
|
||||
<a href="{% url 'interview_create_type_selection' candidate.slug %}"
|
||||
class="btn btn-outline-primary">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Back to Candidate List
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-3">
|
||||
Schedule a remote interview for <strong>{{ candidate.name }}</strong>
|
||||
for the position of <strong>{{ job.title }}</strong>.
|
||||
</p>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'interview_create_remote' candidate_slug=candidate.slug %}">
|
||||
{% csrf_token %}
|
||||
{{form|crispy}}
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="submit" class="btn btn-main-action">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
Schedule Remote Interview
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add form validation for future dates
|
||||
const dateInput = document.querySelector('input[type="date"]');
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
if (dateInput) {
|
||||
dateInput.min = today;
|
||||
|
||||
dateInput.addEventListener('change', function() {
|
||||
if (this.value < today) {
|
||||
this.setCustomValidity('Interview date must be in the future');
|
||||
} else {
|
||||
this.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
54
templates/interviews/interview_create_type_selection.html
Normal file
54
templates/interviews/interview_create_type_selection.html
Normal file
@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Interview - Select Type{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0">
|
||||
<i class="fas fa-calendar-plus me-2"></i>
|
||||
Create Interview for {{ candidate.name }}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body" hx-boost="true" hx-push-url="false" hx-select=".card-body" hx-swap="innerHTML" hx-target="#candidateviewModalBody">
|
||||
<p class="text-muted mb-3">
|
||||
Select the type of interview you want to schedule for <strong>{{ candidate.name }}</strong>
|
||||
for the position of <strong>{{ job.title }}</strong>.
|
||||
</p>
|
||||
|
||||
<div class="d-grid gap-3" style="grid-template-columns: 1fr 1fr;">
|
||||
<a href="{% url 'interview_create_remote' candidate_slug=candidate.slug %}"
|
||||
class="btn btn-outline-primary btn-lg h-100 p-3 text-decoration-none">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-video me-2"></i>
|
||||
<div class="mt-2">Remote Interview</div>
|
||||
<small class="d-block">Via Zoom/Video Conference</small>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'interview_create_onsite' candidate_slug=candidate.slug %}"
|
||||
class="btn btn-outline-primary btn-lg h-100 p-3 text-decoration-none">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-building me-2"></i>
|
||||
<div class="mt-2">Onsite Interview</div>
|
||||
<small class="d-block">In-person at our facility</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<a href="{% url 'candidate_interview_view' slug=job.slug %}"
|
||||
class="btn btn-outline-primary">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
Back to Candidate List
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
768
templates/interviews/interview_detail.html
Normal file
768
templates/interviews/interview_detail.html
Normal file
@ -0,0 +1,768 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{{ interview.application.name }} - {% trans "Interview Details" %} - ATS{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* KAAT-S UI Variables */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
--kaauh-success: #28a745;
|
||||
--kaauh-info: #17a2b8;
|
||||
--kaauh-danger: #dc3545;
|
||||
--kaauh-warning: #ffc107;
|
||||
}
|
||||
|
||||
/* Primary Color Overrides */
|
||||
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
||||
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
|
||||
|
||||
/* Main Container & Card Styling */
|
||||
.kaauh-card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* Button Styling */
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-main-action:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
.btn-outline-secondary {
|
||||
color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal);
|
||||
}
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
color: white;
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
}
|
||||
|
||||
/* Info Panel Styling */
|
||||
.info-panel {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
||||
border-left: 4px solid var(--kaauh-teal);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.info-panel h5 {
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Status Badge Styling */
|
||||
.status-badge {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4em 0.8em;
|
||||
border-radius: 0.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.interview-type-badge {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 0.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Status Colors */
|
||||
.bg-scheduled { background-color: #6c757d !important; color: white; }
|
||||
.bg-confirmed { background-color: var(--kaauh-info) !important; color: white; }
|
||||
.bg-cancelled { background-color: var(--kaauh-danger) !important; color: white; }
|
||||
.bg-completed { background-color: var(--kaauh-success) !important; color: white; }
|
||||
.bg-remote { background-color: #007bff !important; color: white; }
|
||||
.bg-onsite { background-color: #6f42c1 !important; color: white; }
|
||||
|
||||
/* Timeline Styling */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: var(--kaauh-border);
|
||||
}
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1.5rem;
|
||||
top: 0.5rem;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--kaauh-teal);
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 0 0 2px var(--kaauh-border);
|
||||
}
|
||||
.timeline-content {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--kaauh-border);
|
||||
}
|
||||
|
||||
/* Participant List */
|
||||
.participant-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.participant-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.participant-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.participant-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
/* Meeting Details */
|
||||
.meeting-details {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--kaauh-border);
|
||||
}
|
||||
.meeting-details .detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
.meeting-details .detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.meeting-details .detail-label {
|
||||
font-weight: 600;
|
||||
color: var(--kaauh-primary-text);
|
||||
}
|
||||
.meeting-details .detail-value {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Custom Height Optimization */
|
||||
.form-control-sm,
|
||||
.btn-sm {
|
||||
padding-top: 0.2rem !important;
|
||||
padding-bottom: 0.2rem !important;
|
||||
height: 28px !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
.action-buttons .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Header Section -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||
<i class="fas fa-calendar-alt me-2"></i>
|
||||
{% trans "Interview Details" %}
|
||||
</h1>
|
||||
<h2 class="h5 text-muted mb-0">
|
||||
{{ interview.application.name }} - {{ interview.job.title }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'interview_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Interviews" %}
|
||||
</a>
|
||||
<a href="{% url 'job_detail' interview.job.slug %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-briefcase me-1"></i> {% trans "View Job" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Left Column - Candidate & Interview Info -->
|
||||
<div class="col-lg-8">
|
||||
<!-- Candidate Information Panel -->
|
||||
<div class="kaauh-card shadow-sm p-4 mb-4">
|
||||
<div class="d-flex align-items-start justify-content-between mb-3">
|
||||
<h5 class="mb-0" style="color: var(--kaauh-teal-dark); font-weight: 600;">
|
||||
<i class="fas fa-user me-2"></i> {% trans "Candidate Information" %}
|
||||
</h5>
|
||||
<div class="action-buttons">
|
||||
{% if interview.application.resume %}
|
||||
<a href="{{ interview.application.resume.url }}" class="btn btn-outline-primary btn-sm" target="_blank">
|
||||
<i class="fas fa-download me-1"></i> {% trans "Download Resume" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateModal"
|
||||
hx-get="{% url 'candidate_criteria_view_htmx' interview.application.pk %}"
|
||||
hx-target="#candidateModalBody">
|
||||
<i class="fas fa-eye me-1"></i> {% trans "AI Scoring" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="info-panel">
|
||||
<h6>{% trans "Personal Details" %}</h6>
|
||||
<p class="mb-2"><strong>{% trans "Name:" %}</strong> {{ interview.application.name }}</p>
|
||||
<p class="mb-2"><strong>{% trans "Email:" %}</strong> {{ interview.application.email }}</p>
|
||||
<p class="mb-2"><strong>{% trans "Phone:" %}</strong> {{ interview.application.phone }}</p>
|
||||
{% if interview.application.location %}
|
||||
<p class="mb-0"><strong>{% trans "Location:" %}</strong> {{ interview.application.location }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="info-panel">
|
||||
<h6>{% trans "Application Details" %}</h6>
|
||||
<p class="mb-2"><strong>{% trans "Job:" %}</strong> {{ interview.job.title }}</p>
|
||||
<p class="mb-2"><strong>{% trans "Department:" %}</strong> {{ interview.job.department }}</p>
|
||||
<p class="mb-2"><strong>{% trans "Applied Date:" %}</strong> {{ interview.application.created_at|date:"d-m-Y" }}</p>
|
||||
<p class="mb-0"><strong>{% trans "Current Stage:" %}</strong>
|
||||
<span class="badge stage-badge stage-{{ interview.application.stage|lower }}">
|
||||
{{ interview.application.stage }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interview Details Panel -->
|
||||
<div class="kaauh-card shadow-sm p-4 mb-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h5 class="mb-0" style="color: var(--kaauh-teal-dark); font-weight: 600;">
|
||||
<i class="fas fa-calendar-check me-2"></i> {% trans "Interview Details" %}
|
||||
</h5>
|
||||
<div class="d-flex gap-2">
|
||||
<span class="badge interview-type-badge
|
||||
{% if interview.interview.location_type == 'Remote' %}bg-remote
|
||||
{% else %}bg-onsite
|
||||
{% endif %}">
|
||||
{% if interview.interview.location_type == 'Remote' %}
|
||||
<i class="fas fa-video me-1"></i> {% trans "Remote" %}
|
||||
{% else %}
|
||||
<i class="fas fa-building me-1"></i> {% trans "Onsite" %}
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="badge status-badge
|
||||
{% if interview.status == 'SCHEDULED' %}bg-scheduled
|
||||
{% elif interview.status == 'CONFIRMED' %}bg-confirmed
|
||||
{% elif interview.status == 'CANCELLED' %}bg-cancelled
|
||||
{% elif interview.status == 'COMPLETED' %}bg-completed
|
||||
{% endif %}">
|
||||
{{ interview.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="meeting-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{% trans "Date:" %}</span>
|
||||
<span class="detail-value">{{ interview.interview_date|date:"d-m-Y" }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{% trans "Time:" %}</span>
|
||||
<span class="detail-value">{{ interview.interview_time|date:"h:i A" }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{% trans "Duration:" %}</span>
|
||||
<span class="detail-value">{{ interview.interview.duration }} {% trans "minutes" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% if interview.interview.location_type == 'Remote' %}
|
||||
<div class="meeting-details">
|
||||
<h6 class="mb-3">{% trans "Remote Meeting Details" %}</h6>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{% trans "Platform:" %}</span>
|
||||
<span class="detail-value">Zoom</span>
|
||||
</div>
|
||||
{% if interview.interview %}
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{% trans "Meeting ID:" %}</span>
|
||||
<span class="detail-value">{{ interview.interview.meeting_id }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{% trans "Password:" %}</span>
|
||||
<span class="detail-value">{{ interview.interview.password }}</span>
|
||||
</div>
|
||||
{% if interview.interview.details_url %}
|
||||
<div class="mt-3">
|
||||
<a href="{{ interview.interview.zoommeetingdetails.details_url }}"
|
||||
target="_blank"
|
||||
class="btn btn-main-action btn-sm w-100">
|
||||
<i class="fas fa-video me-1"></i> {% trans "Join Meeting" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="meeting-details">
|
||||
<h6 class="mb-3">{% trans "Onsite Location Details" %}</h6>
|
||||
{% if interview.interview %}
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{% trans "Address:" %}</span>
|
||||
<span class="detail-value">{{ interview.interview.physical_address }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{% trans "Room:" %}</span>
|
||||
<span class="detail-value">{{ interview.interview.room_number }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline/History Section -->
|
||||
<div class="kaauh-card shadow-sm p-4">
|
||||
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;">
|
||||
<i class="fas fa-history me-2"></i> {% trans "Interview Timeline" %}
|
||||
</h5>
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-content">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="mb-1">{% trans "Interview Scheduled" %}</h6>
|
||||
<p class="mb-0 text-muted">{% trans "Interview was scheduled for" %} {{ interview.interview_date|date:"d-m-Y" }} {{ interview.interview_time|date:"h:i A" }}</p>
|
||||
</div>
|
||||
<small class="text-muted">{{ interview.interview.created_at|date:"d-m-Y h:i A" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if interview.interview.status == 'CONFIRMED' %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-content">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="mb-1">{% trans "Interview Confirmed" %}</h6>
|
||||
<p class="mb-0 text-muted">{% trans "Candidate has confirmed attendance" %}</p>
|
||||
</div>
|
||||
<small class="text-muted">{% trans "Recently" %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if interview.interview.status == 'COMPLETED' %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-content">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="mb-1">{% trans "Interview Completed" %}</h6>
|
||||
<p class="mb-0 text-muted">{% trans "Interview has been completed" %}</p>
|
||||
</div>
|
||||
<small class="text-muted">{% trans "Recently" %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if interview.interview.status == 'CANCELLED' %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-content">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="mb-1">{% trans "Interview Cancelled" %}</h6>
|
||||
<p class="mb-0 text-muted">{% trans "Interview was cancelled" %}</p>
|
||||
</div>
|
||||
<small class="text-muted">{% trans "Recently" %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Participants & Actions -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Participants Panel -->
|
||||
<div class="kaauh-card shadow-sm p-4 mb-4">
|
||||
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;">
|
||||
<i class="fas fa-users me-2"></i> {% trans "Participants" %}
|
||||
</h5>
|
||||
|
||||
<!-- Internal Participants -->
|
||||
{% if interview.participants.exists %}
|
||||
<h6 class="mb-2 text-muted">{% trans "Internal Participants" %}</h6>
|
||||
{% for participant in interview.participants.all %}
|
||||
<div class="participant-item">
|
||||
<div class="participant-avatar">
|
||||
{{ participant.first_name.0 }}{{ participant.last_name.0 }}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-semibold">{{ participant.get_full_name }}</div>
|
||||
<div class="text-muted small">{{ participant.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<!-- External Participants -->
|
||||
{% if interview.system_users.exists %}
|
||||
<h6 class="mb-2 mt-3 text-muted">{% trans "External Participants" %}</h6>
|
||||
{% for user in interview.system_users.all %}
|
||||
<div class="participant-item">
|
||||
<div class="participant-avatar">
|
||||
{{ user.first_name.0 }}{{ user.last_name.0 }}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-semibold">{{ user.get_full_name }}</div>
|
||||
<div class="text-muted small">{{ user.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if not interview.participants.exists and not interview.system_users.exists %}
|
||||
<div class="text-center py-3 text-muted">
|
||||
<i class="fas fa-users fa-2x mb-2"></i>
|
||||
<p class="mb-0">{% trans "No participants added yet" %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-3"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#participantModal">
|
||||
<i class="fas fa-user-plus me-1"></i> {% trans "Add Participants" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions Panel -->
|
||||
<div class="kaauh-card shadow-sm p-4">
|
||||
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;">
|
||||
<i class="fas fa-cog me-2"></i> {% trans "Actions" %}
|
||||
</h5>
|
||||
|
||||
<div class="action-buttons">
|
||||
{% if interview.status != 'CANCELLED' and interview.status != 'COMPLETED' %}
|
||||
<button type="button" class="btn btn-main-action btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#rescheduleModal">
|
||||
<i class="fas fa-redo-alt me-1"></i> {% trans "Reschedule" %}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-outline-warning btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#cancelModal">
|
||||
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#emailModal">
|
||||
<i class="fas fa-envelope me-1"></i> {% trans "Send Email" %}
|
||||
</button>
|
||||
|
||||
{% if interview.status == 'COMPLETED' %}
|
||||
<button type="button" class="btn btn-outline-success btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#resultModal">
|
||||
<i class="fas fa-check-circle me-1"></i> {% trans "Update Result" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Candidate Profile Modal -->
|
||||
<div class="modal fade modal-xl" id="candidateModal" tabindex="-1" aria-labelledby="candidateModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content kaauh-card">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||
<h5 class="modal-title" id="candidateModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||
{% trans "Candidate Profile" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="candidateModalBody" class="modal-body">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||
{% trans "Loading profile..." %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participant Modal -->
|
||||
<div class="modal fade" id="participantModal" tabindex="-1" aria-labelledby="participantModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content kaauh-card">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||
<h5 class="modal-title" id="participantModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||
{% trans "Add Participants" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="post" action="#">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="internal_participants" class="form-label">{% trans "Internal Participants" %}</label>
|
||||
<select multiple class="form-select" id="internal_participants" name="participants">
|
||||
<!-- Options will be populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="external_participants" class="form-label">{% trans "External Participants" %}</label>
|
||||
<select multiple class="form-select" id="external_participants" name="system_users">
|
||||
<!-- Options will be populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Add Participants" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Modal -->
|
||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content kaauh-card">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||
<h5 class="modal-title" id="emailModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||
<i class="fas fa-envelope me-2"></i> {% trans "Compose Email" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="post" action="#">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="email_to" class="form-label">{% trans "To" %}</label>
|
||||
<input type="email" class="form-control" id="email_to" value="{{ interview.application.email }}" readonly>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email_subject" class="form-label">{% trans "Subject" %}</label>
|
||||
<input type="text" class="form-control" id="email_subject" name="subject"
|
||||
value="{% trans 'Interview Details' %} - {{ interview.job.title }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email_message" class="form-label">{% trans "Message" %}</label>
|
||||
<textarea class="form-control" id="email_message" name="message" rows="6">
|
||||
{% trans "Dear" %} {{ interview.application.name }},
|
||||
|
||||
{% trans "Your interview details are as follows:" %}
|
||||
|
||||
{% trans "Date:" %} {{ interview.interview_date|date:"d-m-Y" }}
|
||||
{% trans "Time:" %} {{ interview.interview_time|date:"h:i A" }}
|
||||
{% trans "Job:" %} {{ interview.job.title }}
|
||||
|
||||
{% if interview.interview.location_type == 'Remote' %}
|
||||
{% trans "This is a remote interview. You will receive the meeting link separately." %}
|
||||
{% else %}
|
||||
{% trans "This is an onsite interview. Please arrive 10 minutes early." %}
|
||||
{% endif %}
|
||||
|
||||
{% trans "Best regards," %}
|
||||
{% trans "HR Team" %}
|
||||
</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-paper-plane me-1"></i> {% trans "Send Email" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reschedule Modal -->
|
||||
<div class="modal fade" id="rescheduleModal" tabindex="-1" aria-labelledby="rescheduleModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content kaauh-card">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||
<h5 class="modal-title" id="rescheduleModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||
{% trans "Reschedule Interview" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="post" action="#">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="new_date" class="form-label">{% trans "New Date" %}</label>
|
||||
<input type="date" class="form-control" id="new_date" name="new_date" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_time" class="form-label">{% trans "New Time" %}</label>
|
||||
<input type="time" class="form-control" id="new_time" name="new_time" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="reschedule_reason" class="form-label">{% trans "Reason for Rescheduling" %}</label>
|
||||
<textarea class="form-control" id="reschedule_reason" name="reason" rows="3"
|
||||
placeholder="{% trans 'Optional: Provide reason for rescheduling' %}"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-redo-alt me-1"></i> {% trans "Reschedule" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel Modal -->
|
||||
<div class="modal fade" id="cancelModal" tabindex="-1" aria-labelledby="cancelModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content kaauh-card">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||
<h5 class="modal-title" id="cancelModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||
{% trans "Cancel Interview" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="post" action="#">
|
||||
{% csrf_token %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
{% trans "Are you sure you want to cancel this interview? This action cannot be undone." %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="cancel_reason" class="form-label">{% trans "Reason for Cancellation" %}</label>
|
||||
<textarea class="form-control" id="cancel_reason" name="reason" rows="3" required
|
||||
placeholder="{% trans 'Please provide a reason for cancellation' %}"></textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
<i class="fas fa-times me-1"></i> {% trans "Cancel Interview" %}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">
|
||||
{% trans "Close" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Modal -->
|
||||
<div class="modal fade" id="resultModal" tabindex="-1" aria-labelledby="resultModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content kaauh-card">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||
<h5 class="modal-title" id="resultModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||
{% trans "Update Interview Result" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="post" action="#">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="interview_result" class="form-label">{% trans "Interview Result" %}</label>
|
||||
<select class="form-select" id="interview_result" name="result" required>
|
||||
<option value="">{% trans "Select Result" %}</option>
|
||||
<option value="passed">{% trans "Passed" %}</option>
|
||||
<option value="failed">{% trans "Failed" %}</option>
|
||||
<option value="on_hold">{% trans "On Hold" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="result_notes" class="form-label">{% trans "Notes" %}</label>
|
||||
<textarea class="form-control" id="result_notes" name="notes" rows="4"
|
||||
placeholder="{% trans 'Add interview feedback and notes' %}"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-check me-1"></i> {% trans "Update Result" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Clear modal content when hidden
|
||||
const modals = ['candidateModal', 'participantModal', 'emailModal', 'rescheduleModal', 'cancelModal', 'resultModal'];
|
||||
|
||||
modals.forEach(modalId => {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.addEventListener('hidden.bs.modal', function () {
|
||||
const modalBody = modal.querySelector('.modal-body');
|
||||
if (modalBody && modalId === 'candidateModal') {
|
||||
modalBody.innerHTML = `
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||
{% trans "Loading profile..." %}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,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 %}
|
||||
|
||||
42
templates/interviews/partials/interview_list.html
Normal file
42
templates/interviews/partials/interview_list.html
Normal file
@ -0,0 +1,42 @@
|
||||
{% load i18n %}
|
||||
<table class="table candidate-table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40%;"><i class="fas fa-user me-1"></i> {% trans "Topic" %}</th>
|
||||
<th style="width: 15%;"><i class="fas fa-calendar-alt me-1"></i> {% trans "Date" %}</th>
|
||||
<th style="width: 5%;"><i class="fas fa-map-marker-alt me-1"></i> {% trans "Duration" %}</th>
|
||||
<th style="width: 10%;"><i class="fas fa-map-marker-alt me-1"></i> {% trans "Location" %}</th>
|
||||
<th style="width: 10%;"><i class="fas fa-info-circle me-1"></i> {% trans "Status" %}</th>
|
||||
<th style="width: 10%;"><i class="fas fa-ellipsis-h me-1"></i> {% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for interview in interviews %}
|
||||
<tr>
|
||||
<td>{{ interview.interview.topic }}</td>
|
||||
<td>{{ interview.interview_date }} {{interview.interview_time}}</td>
|
||||
<td>{{ interview.interview.duration }}</td>
|
||||
|
||||
<td>
|
||||
<span class="badge bg-primary-theme">
|
||||
{{ interview.interview.location_type }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary-theme">
|
||||
{{ interview.get_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td><a class="btn btn-outline-primary btn-sm" href="{% url 'interview_detail' interview.slug %}" target="_blank">View</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-muted">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
{% trans "No interviews scheduled yet." %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
372
templates/people/delete_person.html
Normal file
372
templates/people/delete_person.html
Normal 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 %}
|
||||
60
templates/people/person_confirm_delete.html
Normal file
60
templates/people/person_confirm_delete.html
Normal 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 %}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -231,7 +231,7 @@
|
||||
|
||||
{# Separator (Vertical Rule) #}
|
||||
<div class="vr" style="height: 28px;"></div>
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
hx-boost='true'
|
||||
data-bs-target="#emailModal"
|
||||
|
||||
@ -344,7 +344,7 @@
|
||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||
</button>
|
||||
{# email button#}
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
hx-boost='true'
|
||||
data-bs-target="#emailModal"
|
||||
|
||||
386
templates/recruitment/candidate_delete.html
Normal file
386
templates/recruitment/candidate_delete.html
Normal 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 %}
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user