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_NAME=norahuniversity
|
||||||
DB_USER=faheed
|
DB_USER=norahuniversity
|
||||||
DB_PASSWORD=Faheed@215
|
DB_PASSWORD=norahuniversity
|
||||||
@ -354,7 +354,7 @@ class ScheduledInterview(Base):
|
|||||||
candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE, related_name="scheduled_interviews")
|
candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE, related_name="scheduled_interviews")
|
||||||
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="scheduled_interviews")
|
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="scheduled_interviews")
|
||||||
zoom_meeting = models.OneToOneField(ZoomMeeting, on_delete=models.CASCADE, related_name="interview")
|
zoom_meeting = models.OneToOneField(ZoomMeeting, on_delete=models.CASCADE, related_name="interview")
|
||||||
schedule = models.ForeignKey(InterviewSchedule, on_delete=models.CASCADE, related_name="interviews", null=True, blank=True)
|
schedule = models.ForeignKey(BulkInterviewTemplate, on_delete=models.CASCADE, related_name="interviews", null=True, blank=True)
|
||||||
interview_date = models.DateField()
|
interview_date = models.DateField()
|
||||||
interview_time = models.TimeField()
|
interview_time = models.TimeField()
|
||||||
status = models.CharField(max_length=20, choices=[
|
status = models.CharField(max_length=20, choices=[
|
||||||
@ -365,9 +365,9 @@ class ScheduledInterview(Base):
|
|||||||
], default="scheduled")
|
], default="scheduled")
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2.2.11 InterviewSchedule Model
|
#### 2.2.11 BulkInterviewTemplate Model
|
||||||
```python
|
```python
|
||||||
class InterviewSchedule(Base):
|
class BulkInterviewTemplate(Base):
|
||||||
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="interview_schedules")
|
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="interview_schedules")
|
||||||
candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True, null=True)
|
candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True, null=True)
|
||||||
start_date = models.DateField()
|
start_date = models.DateField()
|
||||||
@ -533,7 +533,7 @@ class CandidateService:
|
|||||||
|
|
||||||
### 5.2 Interview Scheduling Logic
|
### 5.2 Interview Scheduling Logic
|
||||||
```python
|
```python
|
||||||
class InterviewScheduler:
|
class BulkInterviewTemplater:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_available_slots(schedule, date):
|
def get_available_slots(schedule, date):
|
||||||
"""Get available interview slots for a specific date"""
|
"""Get available interview slots for a specific date"""
|
||||||
@ -915,7 +915,7 @@ class InterviewSchedulingTestCase(TestCase):
|
|||||||
phone="9876543210",
|
phone="9876543210",
|
||||||
job=self.job
|
job=self.job
|
||||||
)
|
)
|
||||||
self.schedule = InterviewSchedule.objects.create(
|
self.schedule = BulkInterviewTemplate.objects.create(
|
||||||
job=self.job,
|
job=self.job,
|
||||||
start_date=timezone.now().date(),
|
start_date=timezone.now().date(),
|
||||||
end_date=timezone.now().date() + timedelta(days=7),
|
end_date=timezone.now().date() + timedelta(days=7),
|
||||||
@ -930,7 +930,7 @@ class InterviewSchedulingTestCase(TestCase):
|
|||||||
def test_interview_scheduling(self):
|
def test_interview_scheduling(self):
|
||||||
"""Test interview scheduling process"""
|
"""Test interview scheduling process"""
|
||||||
# Test slot availability
|
# Test slot availability
|
||||||
available_slots = InterviewScheduler.get_available_slots(
|
available_slots = BulkInterviewTemplater.get_available_slots(
|
||||||
self.schedule,
|
self.schedule,
|
||||||
timezone.now().date()
|
timezone.now().date()
|
||||||
)
|
)
|
||||||
@ -942,7 +942,7 @@ class InterviewSchedulingTestCase(TestCase):
|
|||||||
'start_time': timezone.now().time(),
|
'start_time': timezone.now().time(),
|
||||||
'duration': 60
|
'duration': 60
|
||||||
}
|
}
|
||||||
interview = InterviewScheduler.schedule_interview(
|
interview = BulkInterviewTemplater.schedule_interview(
|
||||||
self.candidate,
|
self.candidate,
|
||||||
self.job,
|
self.job,
|
||||||
schedule_data
|
schedule_data
|
||||||
|
|||||||
@ -86,7 +86,7 @@ The test suite aims for 80% code coverage. Coverage reports are generated in:
|
|||||||
- **Candidate**: Stage transitions, relationships
|
- **Candidate**: Stage transitions, relationships
|
||||||
- **ZoomMeeting**: Time validation, status handling
|
- **ZoomMeeting**: Time validation, status handling
|
||||||
- **FormTemplate**: Template integrity, field ordering
|
- **FormTemplate**: Template integrity, field ordering
|
||||||
- **InterviewSchedule**: Scheduling logic, slot generation
|
- **BulkInterviewTemplate**: Scheduling logic, slot generation
|
||||||
|
|
||||||
### 2. View Testing
|
### 2. View Testing
|
||||||
- **Job Management**: CRUD operations, search, filtering
|
- **Job Management**: CRUD operations, search, filtering
|
||||||
@ -97,7 +97,7 @@ The test suite aims for 80% code coverage. Coverage reports are generated in:
|
|||||||
### 3. Form Testing
|
### 3. Form Testing
|
||||||
- **JobPostingForm**: Complex validation, field dependencies
|
- **JobPostingForm**: Complex validation, field dependencies
|
||||||
- **CandidateForm**: File upload, validation
|
- **CandidateForm**: File upload, validation
|
||||||
- **InterviewScheduleForm**: Dynamic fields, validation
|
- **BulkInterviewTemplateForm**: Dynamic fields, validation
|
||||||
- **MeetingCommentForm**: Comment creation/editing
|
- **MeetingCommentForm**: Comment creation/editing
|
||||||
|
|
||||||
### 4. Integration Testing
|
### 4. Integration Testing
|
||||||
|
|||||||
@ -28,13 +28,13 @@ from datetime import datetime, time, timedelta, date
|
|||||||
|
|
||||||
from recruitment.models import (
|
from recruitment.models import (
|
||||||
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField,
|
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
FormSubmission, FieldResponse, BulkInterviewTemplate, ScheduledInterview,
|
||||||
TrainingMaterial, Source, HiringAgency, Profile, MeetingComment, JobPostingImage,
|
TrainingMaterial, Source, HiringAgency, Profile, MeetingComment, JobPostingImage,
|
||||||
BreakTime
|
BreakTime
|
||||||
)
|
)
|
||||||
from recruitment.forms import (
|
from recruitment.forms import (
|
||||||
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
|
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
|
||||||
CandidateStageForm, InterviewScheduleForm, BreakTimeFormSet
|
CandidateStageForm, BulkInterviewTemplateForm, BreakTimeFormSet
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -185,7 +185,7 @@ def interview_schedule(staff_user, job):
|
|||||||
)
|
)
|
||||||
candidates.append(candidate)
|
candidates.append(candidate)
|
||||||
|
|
||||||
return InterviewSchedule.objects.create(
|
return BulkInterviewTemplate.objects.create(
|
||||||
job=job,
|
job=job,
|
||||||
created_by=staff_user,
|
created_by=staff_user,
|
||||||
start_date=date.today() + timedelta(days=1),
|
start_date=date.today() + timedelta(days=1),
|
||||||
|
|||||||
@ -3,10 +3,10 @@ from django.utils.html import format_html
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from .models import (
|
from .models import (
|
||||||
JobPosting, Application, TrainingMaterial, ZoomMeetingDetails,
|
JobPosting, Application, TrainingMaterial,
|
||||||
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
|
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
|
||||||
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,JobPostingImage,InterviewNote,
|
SharedFormTemplate, Source, HiringAgency, IntegrationLog,BulkInterviewTemplate,JobPostingImage,InterviewNote,
|
||||||
AgencyAccessLink, AgencyJobAssignment
|
AgencyAccessLink, AgencyJobAssignment,Interview,ScheduledInterview
|
||||||
)
|
)
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
@ -158,27 +158,27 @@ class TrainingMaterialAdmin(admin.ModelAdmin):
|
|||||||
save_on_top = True
|
save_on_top = True
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ZoomMeetingDetails)
|
# @admin.register(ZoomMeetingDetails)
|
||||||
class ZoomMeetingAdmin(admin.ModelAdmin):
|
# class ZoomMeetingAdmin(admin.ModelAdmin):
|
||||||
list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
|
# list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
|
||||||
list_filter = ['timezone', 'created_at']
|
# list_filter = ['timezone', 'created_at']
|
||||||
search_fields = ['topic', 'meeting_id']
|
# search_fields = ['topic', 'meeting_id']
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
# readonly_fields = ['created_at', 'updated_at']
|
||||||
fieldsets = (
|
# fieldsets = (
|
||||||
('Meeting Details', {
|
# ('Meeting Details', {
|
||||||
'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone','status')
|
# 'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone','status')
|
||||||
}),
|
# }),
|
||||||
('Meeting Settings', {
|
# ('Meeting Settings', {
|
||||||
'fields': ('participant_video', 'join_before_host', 'mute_upon_entry', 'waiting_room')
|
# 'fields': ('participant_video', 'join_before_host', 'mute_upon_entry', 'waiting_room')
|
||||||
}),
|
# }),
|
||||||
('Access', {
|
# ('Access', {
|
||||||
'fields': ('join_url',)
|
# 'fields': ('join_url',)
|
||||||
}),
|
# }),
|
||||||
('System Response', {
|
# ('System Response', {
|
||||||
'fields': ('zoom_gateway_response', 'created_at', 'updated_at')
|
# 'fields': ('zoom_gateway_response', 'created_at', 'updated_at')
|
||||||
}),
|
# }),
|
||||||
)
|
# )
|
||||||
save_on_top = True
|
# save_on_top = True
|
||||||
|
|
||||||
|
|
||||||
# @admin.register(InterviewNote)
|
# @admin.register(InterviewNote)
|
||||||
@ -241,9 +241,11 @@ admin.site.register(FormStage)
|
|||||||
admin.site.register(Application)
|
admin.site.register(Application)
|
||||||
admin.site.register(FormField)
|
admin.site.register(FormField)
|
||||||
admin.site.register(FieldResponse)
|
admin.site.register(FieldResponse)
|
||||||
admin.site.register(InterviewSchedule)
|
admin.site.register(BulkInterviewTemplate)
|
||||||
admin.site.register(AgencyAccessLink)
|
admin.site.register(AgencyAccessLink)
|
||||||
admin.site.register(AgencyJobAssignment)
|
admin.site.register(AgencyJobAssignment)
|
||||||
|
admin.site.register(Interview)
|
||||||
|
admin.site.register(ScheduledInterview)
|
||||||
# AgencyMessage admin removed - model has been deleted
|
# AgencyMessage admin removed - model has been deleted
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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
|
next_num = 1
|
||||||
|
|
||||||
self.internal_job_id = f"{prefix}-{year}-{next_num:06d}"
|
self.internal_job_id = f"{prefix}-{year}-{next_num:06d}"
|
||||||
|
|
||||||
if self.department:
|
if self.department:
|
||||||
self.department = self.department.title()
|
self.department = self.department.title()
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_location_display(self):
|
def get_location_display(self):
|
||||||
@ -995,36 +995,36 @@ class Application(Base):
|
|||||||
"""Legacy compatibility - get scheduled interviews for this application"""
|
"""Legacy compatibility - get scheduled interviews for this application"""
|
||||||
return self.scheduled_interviews.all()
|
return self.scheduled_interviews.all()
|
||||||
|
|
||||||
@property
|
# @property
|
||||||
def get_latest_meeting(self):
|
# def get_latest_meeting(self):
|
||||||
"""
|
# """
|
||||||
Retrieves the most specific location details (subclass instance)
|
# Retrieves the most specific location details (subclass instance)
|
||||||
of the latest ScheduledInterview for this application, or None.
|
# of the latest ScheduledInterview for this application, or None.
|
||||||
"""
|
# """
|
||||||
# 1. Get the latest ScheduledInterview
|
# # 1. Get the latest ScheduledInterview
|
||||||
schedule = self.scheduled_interviews.order_by("-created_at").first()
|
# schedule = self.scheduled_interviews.order_by("-created_at").first()
|
||||||
|
|
||||||
# Check if a schedule exists and if it has an interview location
|
# # Check if a schedule exists and if it has an interview location
|
||||||
if not schedule or not schedule.interview_location:
|
# if not schedule or not schedule.interview_location:
|
||||||
return None
|
# return None
|
||||||
|
|
||||||
# Get the base location instance
|
# # Get the base location instance
|
||||||
interview_location = schedule.interview_location
|
# interview_location = schedule.interview_location
|
||||||
|
|
||||||
# 2. Safely retrieve the specific subclass details
|
# # 2. Safely retrieve the specific subclass details
|
||||||
|
|
||||||
# Determine the expected subclass accessor name based on the location_type
|
# # Determine the expected subclass accessor name based on the location_type
|
||||||
if interview_location.location_type == 'Remote':
|
# if interview_location.location_type == 'Remote':
|
||||||
accessor_name = 'zoommeetingdetails'
|
# accessor_name = 'zoommeetingdetails'
|
||||||
else: # Assumes 'Onsite' or any other type defaults to Onsite
|
# else: # Assumes 'Onsite' or any other type defaults to Onsite
|
||||||
accessor_name = 'onsitelocationdetails'
|
# accessor_name = 'onsitelocationdetails'
|
||||||
|
|
||||||
# Use getattr to safely retrieve the specific meeting object (subclass instance).
|
# # Use getattr to safely retrieve the specific meeting object (subclass instance).
|
||||||
# If the accessor exists but points to None (because the subclass record was deleted),
|
# # If the accessor exists but points to None (because the subclass record was deleted),
|
||||||
# or if the accessor name is wrong for the object's true type, it will return None.
|
# # or if the accessor name is wrong for the object's true type, it will return None.
|
||||||
meeting_details = getattr(interview_location, accessor_name, None)
|
# meeting_details = getattr(interview_location, accessor_name, None)
|
||||||
|
|
||||||
return meeting_details
|
# return meeting_details
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -1094,9 +1094,6 @@ class Application(Base):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TrainingMaterial(Base):
|
class TrainingMaterial(Base):
|
||||||
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||||
content = CKEditor5Field(
|
content = CKEditor5Field(
|
||||||
@ -1118,17 +1115,155 @@ class TrainingMaterial(Base):
|
|||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
class InterviewLocation(Base):
|
# class InterviewLocation(Base):
|
||||||
"""
|
# """
|
||||||
Base model for all interview location/meeting details (remote or onsite)
|
# Base model for all interview location/meeting details (remote or onsite)
|
||||||
using Multi-Table Inheritance.
|
# using Multi-Table Inheritance.
|
||||||
"""
|
# """
|
||||||
|
# class LocationType(models.TextChoices):
|
||||||
|
# REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)')
|
||||||
|
# ONSITE = 'Onsite', _('In-Person (Physical Location)')
|
||||||
|
|
||||||
|
# class Status(models.TextChoices):
|
||||||
|
# """Defines the possible real-time statuses for any interview location/meeting."""
|
||||||
|
# WAITING = "waiting", _("Waiting")
|
||||||
|
# STARTED = "started", _("Started")
|
||||||
|
# ENDED = "ended", _("Ended")
|
||||||
|
# CANCELLED = "cancelled", _("Cancelled")
|
||||||
|
|
||||||
|
# location_type = models.CharField(
|
||||||
|
# max_length=10,
|
||||||
|
# choices=LocationType.choices,
|
||||||
|
# verbose_name=_("Location Type"),
|
||||||
|
# db_index=True
|
||||||
|
# )
|
||||||
|
|
||||||
|
# details_url = models.URLField(
|
||||||
|
# verbose_name=_("Meeting/Location URL"),
|
||||||
|
# max_length=2048,
|
||||||
|
# blank=True,
|
||||||
|
# null=True
|
||||||
|
# )
|
||||||
|
|
||||||
|
# topic = models.CharField( # Renamed from 'description' to 'topic' to match your input
|
||||||
|
# max_length=255,
|
||||||
|
# verbose_name=_("Location/Meeting Topic"),
|
||||||
|
# blank=True,
|
||||||
|
# help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'")
|
||||||
|
# )
|
||||||
|
|
||||||
|
# timezone = models.CharField(
|
||||||
|
# max_length=50,
|
||||||
|
# verbose_name=_("Timezone"),
|
||||||
|
# default='UTC'
|
||||||
|
# )
|
||||||
|
|
||||||
|
# def __str__(self):
|
||||||
|
# # Use 'topic' instead of 'description'
|
||||||
|
# return f"{self.get_location_type_display()} - {self.topic[:50]}"
|
||||||
|
|
||||||
|
# class Meta:
|
||||||
|
# verbose_name = _("Interview Location")
|
||||||
|
# verbose_name_plural = _("Interview Locations")
|
||||||
|
|
||||||
|
|
||||||
|
# class ZoomMeetingDetails(InterviewLocation):
|
||||||
|
# """Concrete model for remote interviews (Zoom specifics)."""
|
||||||
|
|
||||||
|
# status = models.CharField(
|
||||||
|
# db_index=True,
|
||||||
|
# max_length=20,
|
||||||
|
# choices=InterviewLocation.Status.choices,
|
||||||
|
# default=InterviewLocation.Status.WAITING,
|
||||||
|
# )
|
||||||
|
# start_time = models.DateTimeField(
|
||||||
|
# db_index=True, verbose_name=_("Start Time")
|
||||||
|
# )
|
||||||
|
# duration = models.PositiveIntegerField(
|
||||||
|
# verbose_name=_("Duration (minutes)")
|
||||||
|
# )
|
||||||
|
# meeting_id = models.CharField(
|
||||||
|
# db_index=True,
|
||||||
|
# max_length=50,
|
||||||
|
# unique=True,
|
||||||
|
# verbose_name=_("External Meeting ID")
|
||||||
|
# )
|
||||||
|
# password = models.CharField(
|
||||||
|
# max_length=20, blank=True, null=True, verbose_name=_("Password")
|
||||||
|
# )
|
||||||
|
# zoom_gateway_response = models.JSONField(
|
||||||
|
# blank=True, null=True, verbose_name=_("Zoom Gateway Response")
|
||||||
|
# )
|
||||||
|
# participant_video = models.BooleanField(
|
||||||
|
# default=True, verbose_name=_("Participant Video")
|
||||||
|
# )
|
||||||
|
# join_before_host = models.BooleanField(
|
||||||
|
# default=False, verbose_name=_("Join Before Host")
|
||||||
|
# )
|
||||||
|
|
||||||
|
# host_email=models.CharField(null=True,blank=True)
|
||||||
|
# mute_upon_entry = models.BooleanField(
|
||||||
|
# default=False, verbose_name=_("Mute Upon Entry")
|
||||||
|
# )
|
||||||
|
# waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room"))
|
||||||
|
|
||||||
|
# # *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
|
||||||
|
# # @classmethod
|
||||||
|
# # def create(cls, **kwargs):
|
||||||
|
# # """Factory method to ensure location_type is set to REMOTE."""
|
||||||
|
# # return cls(location_type=InterviewLocation.LocationType.REMOTE, **kwargs)
|
||||||
|
|
||||||
|
# class Meta:
|
||||||
|
# verbose_name = _("Zoom Meeting Details")
|
||||||
|
# verbose_name_plural = _("Zoom Meeting Details")
|
||||||
|
|
||||||
|
|
||||||
|
# class OnsiteLocationDetails(InterviewLocation):
|
||||||
|
# """Concrete model for onsite interviews (Room/Address specifics)."""
|
||||||
|
|
||||||
|
# physical_address = models.CharField(
|
||||||
|
# max_length=255,
|
||||||
|
# verbose_name=_("Physical Address"),
|
||||||
|
# blank=True,
|
||||||
|
# null=True
|
||||||
|
# )
|
||||||
|
# room_number = models.CharField(
|
||||||
|
# max_length=50,
|
||||||
|
# verbose_name=_("Room Number/Name"),
|
||||||
|
# blank=True,
|
||||||
|
# null=True
|
||||||
|
# )
|
||||||
|
# start_time = models.DateTimeField(
|
||||||
|
# db_index=True, verbose_name=_("Start Time")
|
||||||
|
# )
|
||||||
|
# duration = models.PositiveIntegerField(
|
||||||
|
# verbose_name=_("Duration (minutes)")
|
||||||
|
# )
|
||||||
|
# status = models.CharField(
|
||||||
|
# db_index=True,
|
||||||
|
# max_length=20,
|
||||||
|
# choices=InterviewLocation.Status.choices,
|
||||||
|
# default=InterviewLocation.Status.WAITING,
|
||||||
|
# )
|
||||||
|
|
||||||
|
# # *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
|
||||||
|
# # @classmethod
|
||||||
|
# # def create(cls, **kwargs):
|
||||||
|
# # """Factory method to ensure location_type is set to ONSITE."""
|
||||||
|
# # return cls(location_type=InterviewLocation.LocationType.ONSITE, **kwargs)
|
||||||
|
|
||||||
|
# class Meta:
|
||||||
|
# verbose_name = _("Onsite Location Details")
|
||||||
|
# verbose_name_plural = _("Onsite Location Details")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class Interview(Base):
|
||||||
class LocationType(models.TextChoices):
|
class LocationType(models.TextChoices):
|
||||||
REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)')
|
REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)')
|
||||||
ONSITE = 'Onsite', _('In-Person (Physical Location)')
|
ONSITE = 'Onsite', _('In-Person (Physical Location)')
|
||||||
|
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
"""Defines the possible real-time statuses for any interview location/meeting."""
|
|
||||||
WAITING = "waiting", _("Waiting")
|
WAITING = "waiting", _("Waiting")
|
||||||
STARTED = "started", _("Started")
|
STARTED = "started", _("Started")
|
||||||
ENDED = "ended", _("Ended")
|
ENDED = "ended", _("Ended")
|
||||||
@ -1141,137 +1276,73 @@ class InterviewLocation(Base):
|
|||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Common fields
|
||||||
|
topic = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_("Meeting/Location Topic"),
|
||||||
|
blank=True,
|
||||||
|
help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room'")
|
||||||
|
)
|
||||||
details_url = models.URLField(
|
details_url = models.URLField(
|
||||||
verbose_name=_("Meeting/Location URL"),
|
verbose_name=_("Meeting/Location URL"),
|
||||||
max_length=2048,
|
max_length=2048,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True
|
null=True
|
||||||
)
|
)
|
||||||
|
timezone = models.CharField(max_length=50, verbose_name=_("Timezone"), default='UTC')
|
||||||
topic = models.CharField( # Renamed from 'description' to 'topic' to match your input
|
start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time"))
|
||||||
max_length=255,
|
duration = models.PositiveIntegerField(verbose_name=_("Duration (minutes)"))
|
||||||
verbose_name=_("Location/Meeting Topic"),
|
status = models.CharField(
|
||||||
blank=True,
|
max_length=20,
|
||||||
help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'")
|
choices=Status.choices,
|
||||||
|
default=Status.WAITING,
|
||||||
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
timezone = models.CharField(
|
# Remote-specific (nullable)
|
||||||
max_length=50,
|
meeting_id = models.CharField(
|
||||||
verbose_name=_("Timezone"),
|
max_length=50, unique=True, null=True, blank=True, verbose_name=_("External Meeting ID")
|
||||||
default='UTC'
|
|
||||||
)
|
)
|
||||||
|
password = models.CharField(max_length=20, blank=True, null=True)
|
||||||
|
zoom_gateway_response = models.JSONField(blank=True, null=True)
|
||||||
|
participant_video = models.BooleanField(default=True)
|
||||||
|
join_before_host = models.BooleanField(default=False)
|
||||||
|
host_email = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
mute_upon_entry = models.BooleanField(default=False)
|
||||||
|
waiting_room = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Onsite-specific (nullable)
|
||||||
|
physical_address = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
room_number = models.CharField(max_length=50, blank=True, null=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
# Use 'topic' instead of 'description'
|
|
||||||
return f"{self.get_location_type_display()} - {self.topic[:50]}"
|
return f"{self.get_location_type_display()} - {self.topic[:50]}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Interview Location")
|
verbose_name = _("Interview Location")
|
||||||
verbose_name_plural = _("Interview Locations")
|
verbose_name_plural = _("Interview Locations")
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
class ZoomMeetingDetails(InterviewLocation):
|
# Optional: add validation
|
||||||
"""Concrete model for remote interviews (Zoom specifics)."""
|
if self.location_type == self.LocationType.REMOTE:
|
||||||
|
if not self.details_url:
|
||||||
status = models.CharField(
|
raise ValidationError(_("Remote interviews require a meeting URL."))
|
||||||
db_index=True,
|
if not self.meeting_id:
|
||||||
max_length=20,
|
raise ValidationError(_("Meeting ID is required for remote interviews."))
|
||||||
choices=InterviewLocation.Status.choices,
|
elif self.location_type == self.LocationType.ONSITE:
|
||||||
default=InterviewLocation.Status.WAITING,
|
if not (self.physical_address or self.room_number):
|
||||||
)
|
raise ValidationError(_("Onsite interviews require at least an address or room."))
|
||||||
start_time = models.DateTimeField(
|
|
||||||
db_index=True, verbose_name=_("Start Time")
|
|
||||||
)
|
|
||||||
duration = models.PositiveIntegerField(
|
|
||||||
verbose_name=_("Duration (minutes)")
|
|
||||||
)
|
|
||||||
meeting_id = models.CharField(
|
|
||||||
db_index=True,
|
|
||||||
max_length=50,
|
|
||||||
unique=True,
|
|
||||||
verbose_name=_("External Meeting ID")
|
|
||||||
)
|
|
||||||
password = models.CharField(
|
|
||||||
max_length=20, blank=True, null=True, verbose_name=_("Password")
|
|
||||||
)
|
|
||||||
zoom_gateway_response = models.JSONField(
|
|
||||||
blank=True, null=True, verbose_name=_("Zoom Gateway Response")
|
|
||||||
)
|
|
||||||
participant_video = models.BooleanField(
|
|
||||||
default=True, verbose_name=_("Participant Video")
|
|
||||||
)
|
|
||||||
join_before_host = models.BooleanField(
|
|
||||||
default=False, verbose_name=_("Join Before Host")
|
|
||||||
)
|
|
||||||
|
|
||||||
host_email=models.CharField(null=True,blank=True)
|
|
||||||
mute_upon_entry = models.BooleanField(
|
|
||||||
default=False, verbose_name=_("Mute Upon Entry")
|
|
||||||
)
|
|
||||||
waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room"))
|
|
||||||
|
|
||||||
# *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
|
|
||||||
# @classmethod
|
|
||||||
# def create(cls, **kwargs):
|
|
||||||
# """Factory method to ensure location_type is set to REMOTE."""
|
|
||||||
# return cls(location_type=InterviewLocation.LocationType.REMOTE, **kwargs)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("Zoom Meeting Details")
|
|
||||||
verbose_name_plural = _("Zoom Meeting Details")
|
|
||||||
|
|
||||||
|
|
||||||
class OnsiteLocationDetails(InterviewLocation):
|
|
||||||
"""Concrete model for onsite interviews (Room/Address specifics)."""
|
|
||||||
|
|
||||||
physical_address = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
verbose_name=_("Physical Address"),
|
|
||||||
blank=True,
|
|
||||||
null=True
|
|
||||||
)
|
|
||||||
room_number = models.CharField(
|
|
||||||
max_length=50,
|
|
||||||
verbose_name=_("Room Number/Name"),
|
|
||||||
blank=True,
|
|
||||||
null=True
|
|
||||||
)
|
|
||||||
start_time = models.DateTimeField(
|
|
||||||
db_index=True, verbose_name=_("Start Time")
|
|
||||||
)
|
|
||||||
duration = models.PositiveIntegerField(
|
|
||||||
verbose_name=_("Duration (minutes)")
|
|
||||||
)
|
|
||||||
status = models.CharField(
|
|
||||||
db_index=True,
|
|
||||||
max_length=20,
|
|
||||||
choices=InterviewLocation.Status.choices,
|
|
||||||
default=InterviewLocation.Status.WAITING,
|
|
||||||
)
|
|
||||||
|
|
||||||
# *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
|
|
||||||
# @classmethod
|
|
||||||
# def create(cls, **kwargs):
|
|
||||||
# """Factory method to ensure location_type is set to ONSITE."""
|
|
||||||
# return cls(location_type=InterviewLocation.LocationType.ONSITE, **kwargs)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("Onsite Location Details")
|
|
||||||
verbose_name_plural = _("Onsite Location Details")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# --- 2. Scheduling Models ---
|
# --- 2. Scheduling Models ---
|
||||||
|
|
||||||
class InterviewSchedule(Base):
|
class BulkInterviewTemplate(Base):
|
||||||
"""Stores the TEMPLATE criteria for BULK interview generation."""
|
"""Stores the TEMPLATE criteria for BULK interview generation."""
|
||||||
|
|
||||||
# We need a field to store the template location details linked to this bulk schedule.
|
# We need a field to store the template location details linked to this bulk schedule.
|
||||||
# This location object contains the generic Zoom/Onsite info to be cloned.
|
# This location object contains the generic Zoom/Onsite info to be cloned.
|
||||||
template_location = models.ForeignKey(
|
interview = models.ForeignKey(
|
||||||
InterviewLocation,
|
Interview,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
related_name="schedule_templates",
|
related_name="schedule_templates",
|
||||||
null=True,
|
null=True,
|
||||||
@ -1279,15 +1350,6 @@ class InterviewSchedule(Base):
|
|||||||
verbose_name=_("Location Template (Zoom/Onsite)")
|
verbose_name=_("Location Template (Zoom/Onsite)")
|
||||||
)
|
)
|
||||||
|
|
||||||
# NOTE: schedule_interview_type field is needed in the form,
|
|
||||||
# but not on the model itself if we use template_location.
|
|
||||||
# If you want to keep it:
|
|
||||||
schedule_interview_type = models.CharField(
|
|
||||||
max_length=10,
|
|
||||||
choices=InterviewLocation.LocationType.choices,
|
|
||||||
verbose_name=_("Interview Type"),
|
|
||||||
default=InterviewLocation.LocationType.REMOTE
|
|
||||||
)
|
|
||||||
|
|
||||||
job = models.ForeignKey(
|
job = models.ForeignKey(
|
||||||
JobPosting,
|
JobPosting,
|
||||||
@ -1332,6 +1394,9 @@ class InterviewSchedule(Base):
|
|||||||
|
|
||||||
class ScheduledInterview(Base):
|
class ScheduledInterview(Base):
|
||||||
"""Stores individual scheduled interviews (whether bulk or individually created)."""
|
"""Stores individual scheduled interviews (whether bulk or individually created)."""
|
||||||
|
class InterviewTypeChoice(models.TextChoices):
|
||||||
|
REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)')
|
||||||
|
ONSITE = 'Onsite', _('In-Person (Physical Location)')
|
||||||
|
|
||||||
class InterviewStatus(models.TextChoices):
|
class InterviewStatus(models.TextChoices):
|
||||||
SCHEDULED = "scheduled", _("Scheduled")
|
SCHEDULED = "scheduled", _("Scheduled")
|
||||||
@ -1353,19 +1418,19 @@ class ScheduledInterview(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Links to the specific, individual location/meeting details for THIS interview
|
# Links to the specific, individual location/meeting details for THIS interview
|
||||||
interview_location = models.OneToOneField(
|
interview = models.OneToOneField(
|
||||||
InterviewLocation,
|
Interview,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="scheduled_interview",
|
related_name="scheduled_interview",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
verbose_name=_("Meeting/Location Details")
|
verbose_name=_("Interview/Meeting")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Link back to the bulk schedule template (optional if individually created)
|
# Link back to the bulk schedule template (optional if individually created)
|
||||||
schedule = models.ForeignKey(
|
schedule = models.ForeignKey(
|
||||||
InterviewSchedule,
|
BulkInterviewTemplate,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
related_name="interviews",
|
related_name="interviews",
|
||||||
null=True,
|
null=True,
|
||||||
@ -1378,7 +1443,11 @@ class ScheduledInterview(Base):
|
|||||||
|
|
||||||
interview_date = models.DateField(db_index=True, verbose_name=_("Interview Date"))
|
interview_date = models.DateField(db_index=True, verbose_name=_("Interview Date"))
|
||||||
interview_time = models.TimeField(verbose_name=_("Interview Time"))
|
interview_time = models.TimeField(verbose_name=_("Interview Time"))
|
||||||
|
interview_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=InterviewTypeChoice.choices,
|
||||||
|
default=InterviewTypeChoice.REMOTE
|
||||||
|
)
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
db_index=True,
|
db_index=True,
|
||||||
max_length=20,
|
max_length=20,
|
||||||
@ -1420,7 +1489,7 @@ class InterviewNote(Base):
|
|||||||
|
|
||||||
1
|
1
|
||||||
interview = models.ForeignKey(
|
interview = models.ForeignKey(
|
||||||
ScheduledInterview,
|
Interview,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="notes",
|
related_name="notes",
|
||||||
verbose_name=_("Scheduled Interview"),
|
verbose_name=_("Scheduled Interview"),
|
||||||
@ -2301,14 +2370,14 @@ class Notification(models.Model):
|
|||||||
default=Status.PENDING,
|
default=Status.PENDING,
|
||||||
verbose_name=_("Status"),
|
verbose_name=_("Status"),
|
||||||
)
|
)
|
||||||
related_meeting = models.ForeignKey(
|
# related_meeting = models.ForeignKey(
|
||||||
ZoomMeetingDetails,
|
# ZoomMeetingDetails,
|
||||||
on_delete=models.CASCADE,
|
# on_delete=models.CASCADE,
|
||||||
related_name="notifications",
|
# related_name="notifications",
|
||||||
null=True,
|
# null=True,
|
||||||
blank=True,
|
# blank=True,
|
||||||
verbose_name=_("Related Meeting"),
|
# verbose_name=_("Related Meeting"),
|
||||||
)
|
# )
|
||||||
scheduled_for = models.DateTimeField(
|
scheduled_for = models.DateTimeField(
|
||||||
verbose_name=_("Scheduled Send Time"),
|
verbose_name=_("Scheduled Send Time"),
|
||||||
help_text=_("The date and time this notification is scheduled to be sent."),
|
help_text=_("The date and time this notification is scheduled to be sent."),
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from . linkedin_service import LinkedInService
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from . models import JobPosting
|
from . models import JobPosting
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from . models import InterviewSchedule,ScheduledInterview,ZoomMeetingDetails,Message
|
from . models import ScheduledInterview,Interview,Message
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
# Add python-docx import for Word document processing
|
# Add python-docx import for Word document processing
|
||||||
@ -679,20 +679,28 @@ def create_interview_and_meeting(
|
|||||||
Synchronous task for a single interview slot, dispatched by django-q.
|
Synchronous task for a single interview slot, dispatched by django-q.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
candidate = Application.objects.get(pk=candidate_id)
|
application = Application.objects.get(pk=candidate_id)
|
||||||
job = JobPosting.objects.get(pk=job_id)
|
job = JobPosting.objects.get(pk=job_id)
|
||||||
schedule = InterviewSchedule.objects.get(pk=schedule_id)
|
schedule = ScheduledInterview.objects.get(pk=schedule_id)
|
||||||
|
|
||||||
interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time))
|
interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time))
|
||||||
meeting_topic = f"Interview for {job.title} - {candidate.name}"
|
meeting_topic = f"Interview for {job.title} - {application.name}"
|
||||||
|
|
||||||
# 1. External API Call (Slow)
|
# 1. External API Call (Slow)
|
||||||
|
# "status": "success",
|
||||||
|
# "message": "Meeting created successfully.",
|
||||||
|
# "meeting_details": {
|
||||||
|
# "join_url": meeting_data['join_url'],
|
||||||
|
# "meeting_id": meeting_data['id'],
|
||||||
|
# "password": meeting_data['password'],
|
||||||
|
# "host_email": meeting_data['host_email']
|
||||||
|
# },
|
||||||
|
# "zoom_gateway_response": meeting_data
|
||||||
|
# }
|
||||||
result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
|
result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
|
||||||
|
|
||||||
if result["status"] == "success":
|
if result["status"] == "success":
|
||||||
# 2. Database Writes (Slow)
|
interview = Interview.objects.create(
|
||||||
zoom_meeting = ZoomMeetingDetails.objects.create(
|
|
||||||
topic=meeting_topic,
|
topic=meeting_topic,
|
||||||
start_time=interview_datetime,
|
start_time=interview_datetime,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
@ -703,14 +711,31 @@ def create_interview_and_meeting(
|
|||||||
password=result["meeting_details"]["password"],
|
password=result["meeting_details"]["password"],
|
||||||
location_type="Remote"
|
location_type="Remote"
|
||||||
)
|
)
|
||||||
ScheduledInterview.objects.create(
|
schedule.interviews = interview
|
||||||
application=candidate,
|
schedule.status = "Remote"
|
||||||
job=job,
|
|
||||||
interview_location=zoom_meeting,
|
schedule.save()
|
||||||
schedule=schedule,
|
|
||||||
interview_date=slot_date,
|
# 2. Database Writes (Slow)
|
||||||
interview_time=slot_time
|
# zoom_meeting = ZoomMeetingDetails.objects.create(
|
||||||
)
|
# topic=meeting_topic,
|
||||||
|
# start_time=interview_datetime,
|
||||||
|
# duration=duration,
|
||||||
|
# meeting_id=result["meeting_details"]["meeting_id"],
|
||||||
|
# details_url=result["meeting_details"]["join_url"],
|
||||||
|
# zoom_gateway_response=result["zoom_gateway_response"],
|
||||||
|
# host_email=result["meeting_details"]["host_email"],
|
||||||
|
# password=result["meeting_details"]["password"],
|
||||||
|
# location_type="Remote"
|
||||||
|
# )
|
||||||
|
# ScheduledInterview.objects.create(
|
||||||
|
# application=candidate,
|
||||||
|
# job=job,
|
||||||
|
# interview_location=zoom_meeting,
|
||||||
|
# schedule=schedule,
|
||||||
|
# interview_date=slot_date,
|
||||||
|
# interview_time=slot_time
|
||||||
|
# )
|
||||||
|
|
||||||
# Log success or use Django-Q result system for monitoring
|
# Log success or use Django-Q result system for monitoring
|
||||||
logger.info(f"Successfully scheduled interview for {Application.name}")
|
logger.info(f"Successfully scheduled interview for {Application.name}")
|
||||||
@ -745,7 +770,7 @@ def handle_zoom_webhook_event(payload):
|
|||||||
try:
|
try:
|
||||||
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
|
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
|
||||||
# and to simplify the logic flow.
|
# and to simplify the logic flow.
|
||||||
meeting_instance = ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first()
|
meeting_instance = ''#TODO:update #ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first()
|
||||||
print(meeting_instance)
|
print(meeting_instance)
|
||||||
# --- 1. Creation and Update Events ---
|
# --- 1. Creation and Update Events ---
|
||||||
if event_type == 'meeting.updated':
|
if event_type == 'meeting.updated':
|
||||||
|
|||||||
@ -11,12 +11,12 @@ User = get_user_model()
|
|||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
|
JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
FormSubmission, FieldResponse, BulkInterviewTemplate, ScheduledInterview,
|
||||||
TrainingMaterial, Source, HiringAgency, MeetingComment
|
TrainingMaterial, Source, HiringAgency, MeetingComment
|
||||||
)
|
)
|
||||||
from .forms import (
|
from .forms import (
|
||||||
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
|
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
|
||||||
CandidateStageForm, InterviewScheduleForm, CandidateSignupForm
|
CandidateStageForm, BulkInterviewTemplateForm, CandidateSignupForm
|
||||||
)
|
)
|
||||||
from .views import (
|
from .views import (
|
||||||
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, applications_screening_view,
|
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, applications_screening_view,
|
||||||
@ -304,7 +304,7 @@ class FormTests(BaseTestCase):
|
|||||||
self.assertTrue(form.is_valid())
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
def test_interview_schedule_form(self):
|
def test_interview_schedule_form(self):
|
||||||
"""Test InterviewScheduleForm"""
|
"""Test BulkInterviewTemplateForm"""
|
||||||
# Update candidate to Interview stage first
|
# Update candidate to Interview stage first
|
||||||
self.candidate.stage = 'Interview'
|
self.candidate.stage = 'Interview'
|
||||||
self.candidate.save()
|
self.candidate.save()
|
||||||
@ -315,7 +315,7 @@ class FormTests(BaseTestCase):
|
|||||||
'end_date': (timezone.now() + timedelta(days=7)).date(),
|
'end_date': (timezone.now() + timedelta(days=7)).date(),
|
||||||
'working_days': [0, 1, 2, 3, 4], # Monday to Friday
|
'working_days': [0, 1, 2, 3, 4], # Monday to Friday
|
||||||
}
|
}
|
||||||
form = InterviewScheduleForm(slug=self.job.slug, data=form_data)
|
form = BulkInterviewTemplateForm(slug=self.job.slug, data=form_data)
|
||||||
self.assertTrue(form.is_valid())
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
def test_candidate_signup_form_valid(self):
|
def test_candidate_signup_form_valid(self):
|
||||||
|
|||||||
@ -24,13 +24,13 @@ from PIL import Image
|
|||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
|
JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
FormSubmission, FieldResponse, BulkInterviewTemplate, ScheduledInterview,
|
||||||
TrainingMaterial, Source, HiringAgency, MeetingComment, JobPostingImage,
|
TrainingMaterial, Source, HiringAgency, MeetingComment, JobPostingImage,
|
||||||
BreakTime
|
BreakTime
|
||||||
)
|
)
|
||||||
from .forms import (
|
from .forms import (
|
||||||
JobPostingForm, ApplicationForm, ZoomMeetingForm, MeetingCommentForm,
|
JobPostingForm, ApplicationForm, ZoomMeetingForm, MeetingCommentForm,
|
||||||
ApplicationStageForm, InterviewScheduleForm, BreakTimeFormSet
|
ApplicationStageForm, BulkInterviewTemplateForm, BreakTimeFormSet
|
||||||
)
|
)
|
||||||
from .views import (
|
from .views import (
|
||||||
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, applications_screening_view,
|
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, applications_screening_view,
|
||||||
@ -228,7 +228,7 @@ class AdvancedModelTests(TestCase):
|
|||||||
'break_end_time': '13:00'
|
'break_end_time': '13:00'
|
||||||
}
|
}
|
||||||
|
|
||||||
form = InterviewScheduleForm(slug=self.job.slug, data=schedule_data)
|
form = BulkInterviewTemplateForm(slug=self.job.slug, data=schedule_data)
|
||||||
self.assertTrue(form.is_valid())
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
def test_field_response_data_types(self):
|
def test_field_response_data_types(self):
|
||||||
@ -625,7 +625,7 @@ class AdvancedFormTests(TestCase):
|
|||||||
|
|
||||||
def test_form_dependency_validation(self):
|
def test_form_dependency_validation(self):
|
||||||
"""Test validation for dependent form fields"""
|
"""Test validation for dependent form fields"""
|
||||||
# Test InterviewScheduleForm with dependent fields
|
# Test BulkInterviewTemplateForm with dependent fields
|
||||||
schedule_data = {
|
schedule_data = {
|
||||||
'candidates': [], # Empty for now
|
'candidates': [], # Empty for now
|
||||||
'start_date': '2025-01-15',
|
'start_date': '2025-01-15',
|
||||||
@ -637,7 +637,7 @@ class AdvancedFormTests(TestCase):
|
|||||||
'buffer_time': '15'
|
'buffer_time': '15'
|
||||||
}
|
}
|
||||||
|
|
||||||
form = InterviewScheduleForm(slug=self.job.slug, data=schedule_data)
|
form = BulkInterviewTemplateForm(slug=self.job.slug, data=schedule_data)
|
||||||
self.assertFalse(form.is_valid())
|
self.assertFalse(form.is_valid())
|
||||||
self.assertIn('end_date', form.errors)
|
self.assertIn('end_date', form.errors)
|
||||||
|
|
||||||
@ -667,7 +667,7 @@ class AdvancedFormTests(TestCase):
|
|||||||
|
|
||||||
def test_dynamic_form_fields(self):
|
def test_dynamic_form_fields(self):
|
||||||
"""Test forms with dynamically populated fields"""
|
"""Test forms with dynamically populated fields"""
|
||||||
# Test InterviewScheduleForm with dynamic candidate queryset
|
# Test BulkInterviewTemplateForm with dynamic candidate queryset
|
||||||
# Create applications in Interview stage
|
# Create applications in Interview stage
|
||||||
applications = []
|
applications = []
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
@ -684,7 +684,7 @@ class AdvancedFormTests(TestCase):
|
|||||||
applications.append(application)
|
applications.append(application)
|
||||||
|
|
||||||
# Form should only show Interview stage applications
|
# Form should only show Interview stage applications
|
||||||
form = InterviewScheduleForm(slug=self.job.slug)
|
form = BulkInterviewTemplateForm(slug=self.job.slug)
|
||||||
self.assertEqual(form.fields['candidates'].queryset.count(), 3)
|
self.assertEqual(form.fields['candidates'].queryset.count(), 3)
|
||||||
|
|
||||||
for application in applications:
|
for application in applications:
|
||||||
|
|||||||
@ -207,21 +207,21 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
|
|
||||||
|
|
||||||
path(
|
# path(
|
||||||
"jobs/<slug:slug>/<int:application_id>/reschedule_meeting_for_application/<int:meeting_id>/",
|
# "jobs/<slug:slug>/<int:application_id>/reschedule_meeting_for_application/<int:meeting_id>/",
|
||||||
views.reschedule_meeting_for_application,
|
# views.reschedule_meeting_for_application,
|
||||||
name="reschedule_meeting_for_application",
|
# name="reschedule_meeting_for_application",
|
||||||
),
|
# ),
|
||||||
path(
|
path(
|
||||||
"jobs/<slug:slug>/update_application_exam_status/",
|
"jobs/<slug:slug>/update_application_exam_status/",
|
||||||
views.update_application_exam_status,
|
views.update_application_exam_status,
|
||||||
name="update_application_exam_status",
|
name="update_application_exam_status",
|
||||||
),
|
),
|
||||||
path(
|
# path(
|
||||||
"jobs/<slug:slug>/bulk_update_application_exam_status/",
|
# "jobs/<slug:slug>/bulk_update_application_exam_status/",
|
||||||
views.bulk_update_application_exam_status,
|
# views.bulk_update_application_exam_status,
|
||||||
name="bulk_update_application_exam_status",
|
# name="bulk_update_application_exam_status",
|
||||||
),
|
# ),
|
||||||
path(
|
path(
|
||||||
"htmx/<int:pk>/application_criteria_view/",
|
"htmx/<int:pk>/application_criteria_view/",
|
||||||
views.application_criteria_view_htmx,
|
views.application_criteria_view_htmx,
|
||||||
@ -266,16 +266,16 @@ urlpatterns = [
|
|||||||
# path('api/templates/save/', views.save_form_template, name='save_form_template'),
|
# path('api/templates/save/', views.save_form_template, name='save_form_template'),
|
||||||
# path('api/templates/<slug:template_slug>/', views.load_form_template, name='load_form_template'),
|
# path('api/templates/<slug:template_slug>/', views.load_form_template, name='load_form_template'),
|
||||||
# path('api/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'),
|
# path('api/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'),
|
||||||
path(
|
# path(
|
||||||
"jobs/<slug:slug>/calendar/",
|
# "jobs/<slug:slug>/calendar/",
|
||||||
views.interview_calendar_view,
|
# views.interview_calendar_view,
|
||||||
name="interview_calendar",
|
# name="interview_calendar",
|
||||||
),
|
# ),
|
||||||
path(
|
# path(
|
||||||
"jobs/<slug:slug>/calendar/interview/<int:interview_id>/",
|
# "jobs/<slug:slug>/calendar/interview/<int:interview_id>/",
|
||||||
views.interview_detail_view,
|
# views.interview_detail_view,
|
||||||
name="interview_detail",
|
# name="interview_detail",
|
||||||
),
|
# ),
|
||||||
|
|
||||||
# users urls
|
# users urls
|
||||||
path("user/<int:pk>", views.user_detail, name="user_detail"),
|
path("user/<int:pk>", views.user_detail, name="user_detail"),
|
||||||
@ -333,26 +333,26 @@ urlpatterns = [
|
|||||||
name="copy_to_clipboard",
|
name="copy_to_clipboard",
|
||||||
),
|
),
|
||||||
# Meeting Comments URLs
|
# Meeting Comments URLs
|
||||||
path(
|
# path(
|
||||||
"meetings/<slug:slug>/comments/add/",
|
# "meetings/<slug:slug>/comments/add/",
|
||||||
views.add_meeting_comment,
|
# views.add_meeting_comment,
|
||||||
name="add_meeting_comment",
|
# name="add_meeting_comment",
|
||||||
),
|
# ),
|
||||||
path(
|
# path(
|
||||||
"meetings/<slug:slug>/comments/<int:comment_id>/edit/",
|
# "meetings/<slug:slug>/comments/<int:comment_id>/edit/",
|
||||||
views.edit_meeting_comment,
|
# views.edit_meeting_comment,
|
||||||
name="edit_meeting_comment",
|
# name="edit_meeting_comment",
|
||||||
),
|
# ),
|
||||||
path(
|
# path(
|
||||||
"meetings/<slug:slug>/comments/<int:comment_id>/delete/",
|
# "meetings/<slug:slug>/comments/<int:comment_id>/delete/",
|
||||||
views.delete_meeting_comment,
|
# views.delete_meeting_comment,
|
||||||
name="delete_meeting_comment",
|
# name="delete_meeting_comment",
|
||||||
),
|
# ),
|
||||||
path(
|
# path(
|
||||||
"meetings/<slug:slug>/set_meeting_application/",
|
# "meetings/<slug:slug>/set_meeting_application/",
|
||||||
views.set_meeting_application,
|
# views.set_meeting_application,
|
||||||
name="set_meeting_application",
|
# name="set_meeting_application",
|
||||||
),
|
# ),
|
||||||
# Hiring Agency URLs
|
# Hiring Agency URLs
|
||||||
path("agencies/", views.agency_list, name="agency_list"),
|
path("agencies/", views.agency_list, name="agency_list"),
|
||||||
path("agencies/create/", views.agency_create, name="agency_create"),
|
path("agencies/create/", views.agency_create, name="agency_create"),
|
||||||
@ -510,31 +510,31 @@ urlpatterns = [
|
|||||||
# path('notifications/mark-all-read/', views.notification_mark_all_read, name='notification_mark_all_read'),
|
# path('notifications/mark-all-read/', views.notification_mark_all_read, name='notification_mark_all_read'),
|
||||||
# path('api/notification-count/', views.api_notification_count, name='api_notification_count'),
|
# path('api/notification-count/', views.api_notification_count, name='api_notification_count'),
|
||||||
# participants urls
|
# participants urls
|
||||||
path(
|
# path(
|
||||||
"participants/",
|
# "participants/",
|
||||||
views_frontend.ParticipantsListView.as_view(),
|
# views_frontend.ParticipantsListView.as_view(),
|
||||||
name="participants_list",
|
# name="participants_list",
|
||||||
),
|
# ),
|
||||||
path(
|
# path(
|
||||||
"participants/create/",
|
# "participants/create/",
|
||||||
views_frontend.ParticipantsCreateView.as_view(),
|
# views_frontend.ParticipantsCreateView.as_view(),
|
||||||
name="participants_create",
|
# name="participants_create",
|
||||||
),
|
# ),
|
||||||
path(
|
# path(
|
||||||
"participants/<slug:slug>/",
|
# "participants/<slug:slug>/",
|
||||||
views_frontend.ParticipantsDetailView.as_view(),
|
# views_frontend.ParticipantsDetailView.as_view(),
|
||||||
name="participants_detail",
|
# name="participants_detail",
|
||||||
),
|
# ),
|
||||||
path(
|
# path(
|
||||||
"participants/<slug:slug>/update/",
|
# "participants/<slug:slug>/update/",
|
||||||
views_frontend.ParticipantsUpdateView.as_view(),
|
# views_frontend.ParticipantsUpdateView.as_view(),
|
||||||
name="participants_update",
|
# name="participants_update",
|
||||||
),
|
# ),
|
||||||
path(
|
# path(
|
||||||
"participants/<slug:slug>/delete/",
|
# "participants/<slug:slug>/delete/",
|
||||||
views_frontend.ParticipantsDeleteView.as_view(),
|
# views_frontend.ParticipantsDeleteView.as_view(),
|
||||||
name="participants_delete",
|
# name="participants_delete",
|
||||||
),
|
# ),
|
||||||
# Email composition URLs
|
# Email composition URLs
|
||||||
path(
|
path(
|
||||||
"jobs/<slug:job_slug>/applications/compose-email/",
|
"jobs/<slug:job_slug>/applications/compose-email/",
|
||||||
@ -563,13 +563,23 @@ urlpatterns = [
|
|||||||
path("application/documents/<int:document_id>/download/", views.document_download, name="application_document_download"),
|
path("application/documents/<int:document_id>/download/", views.document_download, name="application_document_download"),
|
||||||
path('jobs/<slug:job_slug>/applications/compose_email/', views.compose_application_email, name='compose_application_email'),
|
path('jobs/<slug:job_slug>/applications/compose_email/', views.compose_application_email, name='compose_application_email'),
|
||||||
|
|
||||||
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
|
# path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
|
||||||
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
|
# path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
|
||||||
# Candidate Signup
|
# Candidate Signup
|
||||||
path('application/signup/<slug:template_slug>/', views.application_signup, name='application_signup'),
|
path('application/signup/<slug:template_slug>/', views.application_signup, name='application_signup'),
|
||||||
# Password Reset
|
# Password Reset
|
||||||
path('user/<int:pk>/password-reset/', views.portal_password_reset, name='portal_password_reset'),
|
path('user/<int:pk>/password-reset/', views.portal_password_reset, name='portal_password_reset'),
|
||||||
|
|
||||||
|
# Interview URLs
|
||||||
|
path('interviews/', views.interview_list, name='interview_list'),
|
||||||
|
path('interviews/<slug:slug>/', views.interview_detail, name='interview_detail'),
|
||||||
|
|
||||||
|
# Interview Creation URLs
|
||||||
|
path('interviews/create/<slug:candidate_slug>/', views.interview_create_type_selection, name='interview_create_type_selection'),
|
||||||
|
path('interviews/create/<slug:candidate_slug>/remote/', views.interview_create_remote, name='interview_create_remote'),
|
||||||
|
path('interviews/create/<slug:candidate_slug>/onsite/', views.interview_create_onsite, name='interview_create_onsite'),
|
||||||
|
path('interviews/<slug:job_slug>/get_interview_list', views.get_interview_list, name='get_interview_list'),
|
||||||
|
|
||||||
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
|
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
|
||||||
# path('interview/list/', views.interview_list, name='interview_list'),
|
# path('interview/list/', views.interview_list, name='interview_list'),
|
||||||
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'),
|
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'),
|
||||||
@ -577,64 +587,64 @@ urlpatterns = [
|
|||||||
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
|
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
|
||||||
|
|
||||||
#interview and meeting related urls
|
#interview and meeting related urls
|
||||||
path(
|
# path(
|
||||||
"jobs/<slug:slug>/schedule-interviews/",
|
# "jobs/<slug:slug>/schedule-interviews/",
|
||||||
views.schedule_interviews_view,
|
# views.schedule_interviews_view,
|
||||||
name="schedule_interviews",
|
# name="schedule_interviews",
|
||||||
),
|
# ),
|
||||||
path(
|
# path(
|
||||||
"jobs/<slug:slug>/confirm-schedule-interviews/",
|
# "jobs/<slug:slug>/confirm-schedule-interviews/",
|
||||||
views.confirm_schedule_interviews_view,
|
# views.confirm_schedule_interviews_view,
|
||||||
name="confirm_schedule_interviews_view",
|
# name="confirm_schedule_interviews_view",
|
||||||
),
|
# ),
|
||||||
|
|
||||||
path(
|
# path(
|
||||||
"meetings/create-meeting/",
|
# "meetings/create-meeting/",
|
||||||
views.ZoomMeetingCreateView.as_view(),
|
# views.ZoomMeetingCreateView.as_view(),
|
||||||
name="create_meeting",
|
# name="create_meeting",
|
||||||
),
|
# ),
|
||||||
# path(
|
# path(
|
||||||
# "meetings/meeting-details/<slug:slug>/",
|
# "meetings/meeting-details/<slug:slug>/",
|
||||||
# views.ZoomMeetingDetailsView.as_view(),
|
# views.ZoomMeetingDetailsView.as_view(),
|
||||||
# name="meeting_details",
|
# name="meeting_details",
|
||||||
# ),
|
# ),
|
||||||
path(
|
# path(
|
||||||
"meetings/update-meeting/<slug:slug>/",
|
# "meetings/update-meeting/<slug:slug>/",
|
||||||
views.ZoomMeetingUpdateView.as_view(),
|
# views.ZoomMeetingUpdateView.as_view(),
|
||||||
name="update_meeting",
|
# name="update_meeting",
|
||||||
),
|
# ),
|
||||||
path(
|
# path(
|
||||||
"meetings/delete-meeting/<slug:slug>/",
|
# "meetings/delete-meeting/<slug:slug>/",
|
||||||
views.ZoomMeetingDeleteView,
|
# views.ZoomMeetingDeleteView,
|
||||||
name="delete_meeting",
|
# name="delete_meeting",
|
||||||
),
|
# ),
|
||||||
# Candidate Meeting Scheduling/Rescheduling URLs
|
# Candidate Meeting Scheduling/Rescheduling URLs
|
||||||
path(
|
# path(
|
||||||
"jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
|
# "jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
|
||||||
views.schedule_application_meeting,
|
# views.schedule_application_meeting,
|
||||||
name="schedule_application_meeting",
|
# name="schedule_application_meeting",
|
||||||
),
|
# ),
|
||||||
path(
|
# path(
|
||||||
"api/jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
|
# "api/jobs/<slug:job_slug>/applications/<int:application_pk>/schedule-meeting/",
|
||||||
views.api_schedule_application_meeting,
|
# views.api_schedule_application_meeting,
|
||||||
name="api_schedule_application_meeting",
|
# name="api_schedule_application_meeting",
|
||||||
),
|
# ),
|
||||||
path(
|
# path(
|
||||||
"jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
|
# "jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
|
||||||
views.reschedule_application_meeting,
|
# views.reschedule_application_meeting,
|
||||||
name="reschedule_application_meeting",
|
# name="reschedule_application_meeting",
|
||||||
),
|
# ),
|
||||||
path(
|
# path(
|
||||||
"api/jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
|
# "api/jobs/<slug:job_slug>/applications/<int:application_pk>/reschedule-meeting/<int:interview_pk>/",
|
||||||
views.api_reschedule_application_meeting,
|
# views.api_reschedule_application_meeting,
|
||||||
name="api_reschedule_application_meeting",
|
# name="api_reschedule_application_meeting",
|
||||||
),
|
# ),
|
||||||
# New URL for simple page-based meeting scheduling
|
# New URL for simple page-based meeting scheduling
|
||||||
path(
|
# path(
|
||||||
"jobs/<slug:slug>/applications/<int:application_pk>/schedule-meeting-page/",
|
# "jobs/<slug:slug>/applications/<int:application_pk>/schedule-meeting-page/",
|
||||||
views.schedule_meeting_for_application,
|
# views.schedule_meeting_for_application,
|
||||||
name="schedule_meeting_for_application",
|
# name="schedule_meeting_for_application",
|
||||||
),
|
# ),
|
||||||
# path(
|
# path(
|
||||||
# "jobs/<slug:slug>/applications/<int:application_pk>/delete_meeting_for_application/<int:meeting_id>/",
|
# "jobs/<slug:slug>/applications/<int:application_pk>/delete_meeting_for_application/<int:meeting_id>/",
|
||||||
# views.delete_meeting_for_candidate,
|
# views.delete_meeting_for_candidate,
|
||||||
@ -642,35 +652,35 @@ urlpatterns = [
|
|||||||
# ),
|
# ),
|
||||||
|
|
||||||
|
|
||||||
path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
|
# path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
|
||||||
|
|
||||||
# 1. Onsite Reschedule URL
|
# 1. Onsite Reschedule URL
|
||||||
path(
|
# path(
|
||||||
'<slug:slug>/application/<int:application_id>/onsite/reschedule/<int:meeting_id>/',
|
# '<slug:slug>/application/<int:application_id>/onsite/reschedule/<int:meeting_id>/',
|
||||||
views.reschedule_onsite_meeting,
|
# views.reschedule_onsite_meeting,
|
||||||
name='reschedule_onsite_meeting'
|
# name='reschedule_onsite_meeting'
|
||||||
),
|
# ),
|
||||||
|
|
||||||
# 2. Onsite Delete URL
|
# 2. Onsite Delete URL
|
||||||
|
|
||||||
path(
|
# path(
|
||||||
'job/<slug:slug>/applications/<int:application_pk>/delete-onsite-meeting/<int:meeting_id>/',
|
# 'job/<slug:slug>/applications/<int:application_pk>/delete-onsite-meeting/<int:meeting_id>/',
|
||||||
views.delete_onsite_meeting_for_application,
|
# views.delete_onsite_meeting_for_application,
|
||||||
name='delete_onsite_meeting_for_application'
|
# name='delete_onsite_meeting_for_application'
|
||||||
),
|
# ),
|
||||||
|
|
||||||
path(
|
# path(
|
||||||
'job/<slug:slug>/application/<int:application_pk>/schedule/onsite/',
|
# 'job/<slug:slug>/application/<int:application_pk>/schedule/onsite/',
|
||||||
views.schedule_onsite_meeting_for_application,
|
# views.schedule_onsite_meeting_for_application,
|
||||||
name='schedule_onsite_meeting_for_application' # This is the name used in the button
|
# name='schedule_onsite_meeting_for_application' # This is the name used in the button
|
||||||
),
|
# ),
|
||||||
|
|
||||||
|
|
||||||
# Detail View (assuming slug is on ScheduledInterview)
|
# Detail View (assuming slug is on ScheduledInterview)
|
||||||
path("interviews/meetings/<slug:slug>/", views.meeting_details, name="meeting_details"),
|
# path("interviews/meetings/<slug:slug>/", views.meeting_details, name="meeting_details"),
|
||||||
|
|
||||||
# Email invitation URLs
|
# Email invitation URLs
|
||||||
path("interviews/meetings/<slug:slug>/send-application-invitation/", views.send_application_invitation, name="send_application_invitation"),
|
# path("interviews/meetings/<slug:slug>/send-application-invitation/", views.send_application_invitation, name="send_application_invitation"),
|
||||||
path("interviews/meetings/<slug:slug>/send-participants-invitation/", views.send_participants_invitation, name="send_participants_invitation"),
|
# path("interviews/meetings/<slug:slug>/send-participants-invitation/", views.send_participants_invitation, name="send_participants_invitation"),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|||||||
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 django.db.models.fields.json import KeyTextTransform,KeyTransform
|
||||||
from recruitment.utils import json_to_markdown_table
|
from recruitment.utils import json_to_markdown_table
|
||||||
from django.db.models import Count, Avg, F, FloatField
|
from django.db.models import Count, Avg, F, FloatField
|
||||||
from django.db.models.functions import Cast
|
|
||||||
from django.db.models.functions import Coalesce, Cast, Replace, NullIf
|
from django.db.models.functions import Coalesce, Cast, Replace, NullIf
|
||||||
from . import models
|
from . import models
|
||||||
from django.utils.translation import get_language
|
from django.utils.translation import get_language
|
||||||
@ -1065,47 +1064,47 @@ def sync_history(request, job_slug=None):
|
|||||||
|
|
||||||
|
|
||||||
#participants views
|
#participants views
|
||||||
class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
# class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
||||||
model = models.Participants
|
# model = models.Participants
|
||||||
template_name = 'participants/participants_list.html'
|
# template_name = 'participants/participants_list.html'
|
||||||
context_object_name = 'participants'
|
# context_object_name = 'participants'
|
||||||
paginate_by = 10
|
# paginate_by = 10
|
||||||
|
|
||||||
def get_queryset(self):
|
# def get_queryset(self):
|
||||||
queryset = super().get_queryset()
|
# queryset = super().get_queryset()
|
||||||
|
|
||||||
# Handle search
|
# # Handle search
|
||||||
search_query = self.request.GET.get('search', '')
|
# search_query = self.request.GET.get('search', '')
|
||||||
if search_query:
|
# if search_query:
|
||||||
queryset = queryset.filter(
|
# queryset = queryset.filter(
|
||||||
Q(name__icontains=search_query) |
|
# Q(name__icontains=search_query) |
|
||||||
Q(email__icontains=search_query) |
|
# Q(email__icontains=search_query) |
|
||||||
Q(phone__icontains=search_query) |
|
# Q(phone__icontains=search_query) |
|
||||||
Q(designation__icontains=search_query)
|
# Q(designation__icontains=search_query)
|
||||||
)
|
# )
|
||||||
|
|
||||||
# Filter for non-staff users
|
# # Filter for non-staff users
|
||||||
if not self.request.user.is_staff:
|
# if not self.request.user.is_staff:
|
||||||
return models.Participants.objects.none() # Restrict for non-staff
|
# return models.Participants.objects.none() # Restrict for non-staff
|
||||||
|
|
||||||
return queryset.order_by('-created_at')
|
# return queryset.order_by('-created_at')
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
# def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
# context = super().get_context_data(**kwargs)
|
||||||
context['search_query'] = self.request.GET.get('search', '')
|
# context['search_query'] = self.request.GET.get('search', '')
|
||||||
return context
|
# return context
|
||||||
class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
|
# class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
|
||||||
model = models.Participants
|
# model = models.Participants
|
||||||
template_name = 'participants/participants_detail.html'
|
# template_name = 'participants/participants_detail.html'
|
||||||
context_object_name = 'participant'
|
# context_object_name = 'participant'
|
||||||
slug_url_kwarg = 'slug'
|
# slug_url_kwarg = 'slug'
|
||||||
|
|
||||||
class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
|
# class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
|
||||||
model = models.Participants
|
# model = models.Participants
|
||||||
form_class = forms.ParticipantsForm
|
# form_class = forms.ParticipantsForm
|
||||||
template_name = 'participants/participants_create.html'
|
# template_name = 'participants/participants_create.html'
|
||||||
success_url = reverse_lazy('job_list')
|
# success_url = reverse_lazy('job_list')
|
||||||
success_message = 'Participant created successfully.'
|
# success_message = 'Participant created successfully.'
|
||||||
|
|
||||||
# def get_initial(self):
|
# def get_initial(self):
|
||||||
# initial = super().get_initial()
|
# initial = super().get_initial()
|
||||||
@ -1116,17 +1115,17 @@ class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMess
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
|
# class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||||
model = models.Participants
|
# model = models.Participants
|
||||||
form_class = forms.ParticipantsForm
|
# form_class = forms.ParticipantsForm
|
||||||
template_name = 'participants/participants_create.html'
|
# template_name = 'participants/participants_create.html'
|
||||||
success_url = reverse_lazy('job_list')
|
# success_url = reverse_lazy('job_list')
|
||||||
success_message = 'Participant updated successfully.'
|
# success_message = 'Participant updated successfully.'
|
||||||
slug_url_kwarg = 'slug'
|
# slug_url_kwarg = 'slug'
|
||||||
|
|
||||||
class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
|
# class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||||
model = models.Participants
|
# model = models.Participants
|
||||||
|
|
||||||
success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
|
# success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
|
||||||
success_message = 'Participant deleted successfully.'
|
# success_message = 'Participant deleted successfully.'
|
||||||
slug_url_kwarg = 'slug'
|
# slug_url_kwarg = 'slug'
|
||||||
|
|||||||
335
requirements.txt
335
requirements.txt
@ -1,146 +1,191 @@
|
|||||||
annotated-types
|
amqp==5.3.1
|
||||||
appdirs
|
annotated-types==0.7.0
|
||||||
asgiref
|
anthropic==0.63.0
|
||||||
asteval
|
anyio==4.11.0
|
||||||
astunparse
|
appdirs==1.4.4
|
||||||
attrs
|
arrow==1.3.0
|
||||||
blinker
|
asgiref==3.9.2
|
||||||
blis
|
asteval==1.0.6
|
||||||
boto3
|
astunparse==1.6.3
|
||||||
botocore
|
attrs==25.3.0
|
||||||
bw-migrations
|
billiard==4.2.2
|
||||||
bw2parameters
|
bleach==6.2.0
|
||||||
bw_processing
|
blessed==1.22.0
|
||||||
cached-property
|
blinker==1.9.0
|
||||||
catalogue
|
blis==1.3.0
|
||||||
certifi
|
boto3==1.40.37
|
||||||
channels
|
botocore==1.40.37
|
||||||
chardet
|
bw-migrations==0.2
|
||||||
charset-normalizer
|
bw2data==4.5
|
||||||
click
|
bw2parameters==1.1.0
|
||||||
cloudpathlib
|
bw_processing==1.0
|
||||||
confection
|
cached-property==2.0.1
|
||||||
constructive_geometries
|
catalogue==2.0.10
|
||||||
country_converter
|
celery==5.5.3
|
||||||
cymem
|
certifi==2025.8.3
|
||||||
dataflows-tabulator
|
channels==4.3.1
|
||||||
datapackage
|
chardet==5.2.0
|
||||||
deepdiff
|
charset-normalizer==3.4.3
|
||||||
Deprecated
|
click==8.3.0
|
||||||
Django
|
click-didyoumean==0.3.1
|
||||||
django-allauth
|
click-plugins==1.1.1.2
|
||||||
django-cors-headers
|
click-repl==0.3.0
|
||||||
django-filter
|
cloudpathlib==0.22.0
|
||||||
django-unfold
|
confection==0.1.5
|
||||||
djangorestframework
|
constructive_geometries==1.0
|
||||||
docopt
|
country_converter==1.3.1
|
||||||
|
crispy-bootstrap5==2025.6
|
||||||
|
cymem==2.0.11
|
||||||
|
dataflows-tabulator==1.54.3
|
||||||
|
datapackage==1.15.4
|
||||||
|
datastar-py==0.6.5
|
||||||
|
deepdiff==7.0.1
|
||||||
|
Deprecated==1.2.18
|
||||||
|
distro==1.9.0
|
||||||
|
Django==5.2.6
|
||||||
|
django-allauth==65.11.2
|
||||||
|
django-ckeditor-5==0.2.18
|
||||||
|
django-cors-headers==4.9.0
|
||||||
|
django-countries==7.6.1
|
||||||
|
django-crispy-forms==2.4
|
||||||
|
django-easy-audit==1.3.7
|
||||||
|
django-extensions==4.1
|
||||||
|
django-filter==25.1
|
||||||
|
django-picklefield==3.3
|
||||||
|
django-q2==1.8.0
|
||||||
|
django-summernote==0.8.20.0
|
||||||
|
django-template-partials==25.2
|
||||||
|
django-unfold==0.66.0
|
||||||
|
django-widget-tweaks==1.5.0
|
||||||
|
django_celery_results==2.6.0
|
||||||
|
djangorestframework==3.16.1
|
||||||
|
docopt==0.6.2
|
||||||
en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl#sha256=1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85
|
en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl#sha256=1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85
|
||||||
et_xmlfile
|
et_xmlfile==2.0.0
|
||||||
Faker
|
Faker==37.8.0
|
||||||
flexcache
|
flexcache==0.3
|
||||||
flexparser
|
flexparser==0.4
|
||||||
fsspec
|
fsspec==2025.9.0
|
||||||
idna
|
gpt-po-translator==1.3.2
|
||||||
ijson
|
greenlet==3.2.4
|
||||||
isodate
|
h11==0.16.0
|
||||||
Jinja2
|
httpcore==1.0.9
|
||||||
jmespath
|
httpx==0.28.1
|
||||||
jsonlines
|
idna==3.10
|
||||||
jsonpointer
|
ijson==3.4.0
|
||||||
jsonschema
|
iniconfig==2.1.0
|
||||||
jsonschema-specifications
|
isodate==0.7.2
|
||||||
langcodes
|
isort==5.13.2
|
||||||
language_data
|
Jinja2==3.1.6
|
||||||
linear-tsv
|
jiter==0.11.1
|
||||||
llvmlite
|
jmespath==1.0.1
|
||||||
loguru
|
jsonlines==4.0.0
|
||||||
lxml
|
jsonpointer==3.0.0
|
||||||
marisa-trie
|
jsonschema==4.25.1
|
||||||
markdown-it-py
|
jsonschema-specifications==2025.9.1
|
||||||
MarkupSafe
|
kombu==5.5.4
|
||||||
matrix_utils
|
langcodes==3.5.0
|
||||||
mdurl
|
language_data==1.3.0
|
||||||
morefs
|
linear-tsv==1.1.0
|
||||||
mrio-common-metadata
|
llvmlite==0.45.0
|
||||||
murmurhash
|
loguru==0.7.3
|
||||||
numba
|
lxml==6.0.2
|
||||||
numpy
|
marisa-trie==1.3.1
|
||||||
openpyxl
|
markdown-it-py==4.0.0
|
||||||
ordered-set
|
MarkupSafe==3.0.2
|
||||||
packaging
|
matrix_utils==0.6.2
|
||||||
pandas
|
mdurl==0.1.2
|
||||||
peewee
|
morefs==0.2.2
|
||||||
Pint
|
mrio-common-metadata==0.2.1
|
||||||
platformdirs
|
murmurhash==1.0.13
|
||||||
preshed
|
numba==0.62.0
|
||||||
prettytable
|
numpy==2.3.3
|
||||||
pydantic
|
openai==1.99.9
|
||||||
pydantic-settings
|
openpyxl==3.1.5
|
||||||
pydantic_core
|
ordered-set==4.1.0
|
||||||
pyecospold
|
packaging==25.0
|
||||||
Pygments
|
pandas==2.3.2
|
||||||
PyJWT
|
peewee==3.18.2
|
||||||
PyMuPDF
|
pillow==11.3.0
|
||||||
pyparsing
|
Pint==0.25
|
||||||
PyPrind
|
platformdirs==4.4.0
|
||||||
python-dateutil
|
pluggy==1.6.0
|
||||||
python-dotenv
|
polib==1.2.0
|
||||||
python-json-logger
|
preshed==3.0.10
|
||||||
pytz
|
prettytable==3.16.0
|
||||||
pyxlsb
|
prompt_toolkit==3.0.52
|
||||||
PyYAML
|
psycopg2-binary==2.9.11
|
||||||
randonneur
|
pycountry==24.6.1
|
||||||
randonneur_data
|
pydantic==2.11.9
|
||||||
RapidFuzz
|
pydantic-settings==2.10.1
|
||||||
rdflib
|
pydantic_core==2.33.2
|
||||||
referencing
|
pyecospold==4.0.0
|
||||||
requests
|
Pygments==2.19.2
|
||||||
rfc3986
|
PyJWT==2.10.1
|
||||||
rich
|
PyMuPDF==1.26.4
|
||||||
rpds-py
|
pyparsing==3.2.5
|
||||||
s3transfer
|
PyPDF2==3.0.1
|
||||||
scipy
|
PyPrind==2.11.3
|
||||||
shellingham
|
pytest==8.3.4
|
||||||
six
|
pytest-django==4.11.1
|
||||||
smart-open
|
python-dateutil==2.9.0.post0
|
||||||
snowflake-id
|
python-docx==1.2.0
|
||||||
spacy
|
python-dotenv==1.0.1
|
||||||
spacy-legacy
|
python-json-logger==3.3.0
|
||||||
spacy-loggers
|
pytz==2025.2
|
||||||
SPARQLWrapper
|
pyxlsb==1.0.10
|
||||||
sparse
|
PyYAML==6.0.2
|
||||||
SQLAlchemy
|
randonneur==0.6.2
|
||||||
sqlparse
|
randonneur_data==0.6
|
||||||
srsly
|
RapidFuzz==3.14.1
|
||||||
stats_arrays
|
rdflib==7.2.1
|
||||||
structlog
|
redis==3.5.3
|
||||||
tableschema
|
referencing==0.36.2
|
||||||
thinc
|
requests==2.32.3
|
||||||
toolz
|
responses==0.25.8
|
||||||
tqdm
|
rfc3986==2.0.0
|
||||||
typer
|
rich==14.1.0
|
||||||
typing-inspection
|
rpds-py==0.27.1
|
||||||
typing_extensions
|
s3transfer==0.14.0
|
||||||
tzdata
|
scipy==1.16.2
|
||||||
unicodecsv
|
setuptools==80.9.0
|
||||||
urllib3
|
setuptools-scm==8.1.0
|
||||||
voluptuous
|
shellingham==1.5.4
|
||||||
wasabi
|
six==1.17.0
|
||||||
wcwidth
|
smart_open==7.3.1
|
||||||
weasel
|
sniffio==1.3.1
|
||||||
wrapt
|
snowflake-id==1.0.2
|
||||||
wurst
|
spacy==3.8.7
|
||||||
xlrd
|
spacy-legacy==3.0.12
|
||||||
XlsxWriter
|
spacy-loggers==1.0.5
|
||||||
celery[redis]
|
SPARQLWrapper==2.0.0
|
||||||
redis
|
sparse==0.17.0
|
||||||
sentence-transformers
|
SQLAlchemy==2.0.43
|
||||||
torch
|
sqlparse==0.5.3
|
||||||
pdfplumber
|
srsly==2.5.1
|
||||||
python-docx
|
stats_arrays==0.7
|
||||||
PyMuPDF
|
structlog==25.4.0
|
||||||
pytesseract
|
tableschema==1.21.0
|
||||||
Pillow
|
tenacity==9.0.0
|
||||||
python-dotenv
|
thinc==8.3.6
|
||||||
django-countries
|
tomli==2.2.1
|
||||||
django-q2
|
toolz==1.0.0
|
||||||
|
tqdm==4.67.1
|
||||||
|
typer==0.19.2
|
||||||
|
types-python-dateutil==2.9.0.20251008
|
||||||
|
typing-inspection==0.4.1
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
tzdata==2025.2
|
||||||
|
unicodecsv==0.14.1
|
||||||
|
urllib3==2.5.0
|
||||||
|
vine==5.1.0
|
||||||
|
voluptuous==0.15.2
|
||||||
|
wasabi==1.1.3
|
||||||
|
wcwidth==0.2.14
|
||||||
|
weasel==0.4.1
|
||||||
|
webencodings==0.5.1
|
||||||
|
wheel==0.45.1
|
||||||
|
wrapt==1.17.3
|
||||||
|
wurst==0.4
|
||||||
|
xlrd==2.0.2
|
||||||
|
xlsxwriter==3.2.9
|
||||||
|
|||||||
@ -7,10 +7,10 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>KAAUH ATS - Sign In (Bootstrap)</title>
|
<title>KAAUH ATS - Sign In (Bootstrap)</title>
|
||||||
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* CUSTOM TEAL THEME OVERRIDES FOR BOOTSTRAP */
|
/* CUSTOM TEAL THEME OVERRIDES FOR BOOTSTRAP */
|
||||||
@ -20,7 +20,7 @@
|
|||||||
--bs-primary: #00636e; /* Dark Teal */
|
--bs-primary: #00636e; /* Dark Teal */
|
||||||
--bs-primary-rgb: 0, 99, 110;
|
--bs-primary-rgb: 0, 99, 110;
|
||||||
--bs-primary-light: #007a88; /* Lighter Teal for hover */
|
--bs-primary-light: #007a88; /* Lighter Teal for hover */
|
||||||
|
|
||||||
/* Background and Text Colors */
|
/* Background and Text Colors */
|
||||||
--bs-body-bg: #f8f9fa; /* Light gray background */
|
--bs-body-bg: #f8f9fa; /* Light gray background */
|
||||||
--bs-body-color: #212529; /* Dark text */
|
--bs-body-color: #212529; /* Dark text */
|
||||||
@ -28,7 +28,7 @@
|
|||||||
/* Utility colors */
|
/* Utility colors */
|
||||||
--bs-border-color: #dee2e6; /* Bootstrap default border */
|
--bs-border-color: #dee2e6; /* Bootstrap default border */
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
background-color: var(--bs-body-bg);
|
background-color: var(--bs-body-bg);
|
||||||
@ -77,8 +77,8 @@
|
|||||||
background-color: var(--bs-primary);
|
background-color: var(--bs-primary);
|
||||||
border-color: var(--bs-primary);
|
border-color: var(--bs-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 4px 8px rgba(0, 99, 110, 0.2);
|
box-shadow: 0 4px 8px rgba(0, 99, 110, 0.2);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
@ -101,7 +101,7 @@
|
|||||||
color: var(--bs-primary-light) !important;
|
color: var(--bs-primary-light) !important;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ADJUSTED: Custom size adjustment for right panel on desktop */
|
/* ADJUSTED: Custom size adjustment for right panel on desktop */
|
||||||
@media (min-width: 992px) {
|
@media (min-width: 992px) {
|
||||||
/* 1. Set a NARROWER fixed width for the right panel container */
|
/* 1. Set a NARROWER fixed width for the right panel container */
|
||||||
@ -133,8 +133,8 @@
|
|||||||
<h1 class="text-4xl font-weight-bold mb-4" style="font-size: 1.5rem;">
|
<h1 class="text-4xl font-weight-bold mb-4" style="font-size: 1.5rem;">
|
||||||
<span class="text-white">
|
<span class="text-white">
|
||||||
<div class="hospital-text text-center text-md-start me-3">
|
<div class="hospital-text text-center text-md-start me-3">
|
||||||
<div class="ar small">جامعة الأميرة نورة بنت عبدالرحمن الأكاديمية</div>
|
<div class="ar small">جامعة الأميرة نورة بنت عبدالرحمن</div>
|
||||||
<div class="ar small">ومستشفى الملك عبدالله بن عبدالعزيز التخصصي</div>
|
<div class="ar small">ومستشفى الملك عبدالله بن عبدالعزيز الجامعي</div>
|
||||||
<div class="en small">Princess Nourah bint Abdulrahman University</div>
|
<div class="en small">Princess Nourah bint Abdulrahman University</div>
|
||||||
<div class="en small">King Abdullah bin Abdulaziz University Hospital</div>
|
<div class="en small">King Abdullah bin Abdulaziz University Hospital</div>
|
||||||
</div>
|
</div>
|
||||||
@ -145,24 +145,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex flex-column right-panel right-panel-col flex-grow-1 align-items-center justify-content-center">
|
<div class="d-flex flex-column right-panel right-panel-col flex-grow-1 align-items-center justify-content-center">
|
||||||
|
|
||||||
<div class="right-panel-content-wrapper">
|
<div class="right-panel-content-wrapper">
|
||||||
|
|
||||||
<h2 id="form-title" class="h3 fw-bold mb-4 text-center">{% trans "Sign In" %}</h2>
|
<h2 id="form-title" class="h3 fw-bold mb-4 text-center">{% trans "Sign In" %}</h2>
|
||||||
|
|
||||||
<div class="form-fields">
|
<div class="form-fields">
|
||||||
<form id="login-form" class="space-y-4" method="post" action="{% url 'account_login' %}">
|
<form id="login-form" class="space-y-4" method="post" action="{% url 'account_login' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="id_login" class="form-label fw-semibold"> {% trans "Email *" %}</label>
|
<label for="id_login" class="form-label fw-semibold"> {% trans "Email *" %}</label>
|
||||||
<input type="text" name="login" id="id_login" class="form-control" placeholder="{% trans 'Enter your email' %}" required autofocus>
|
<input type="text" name="login" id="id_login" class="form-control" placeholder="{% trans 'Enter your email' %}" required autofocus>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="id_password" class="form-label fw-semibold">{% trans "Password *" %}</label>
|
<label for="id_password" class="form-label fw-semibold">{% trans "Password *" %}</label>
|
||||||
<input type="password" name="password" id="id_password" class="form-control" placeholder="{% trans 'Password' %}" required>
|
<input type="password" name="password" id="id_password" class="form-control" placeholder="{% trans 'Password' %}" required>
|
||||||
|
|
||||||
<div class="text-end mt-2">
|
<div class="text-end mt-2">
|
||||||
<a href="{% url 'account_reset_password' %}" class="small text-accent fw-medium">{% trans 'Forgot Password?' %}</a>
|
<a href="{% url 'account_reset_password' %}" class="small text-accent fw-medium">{% trans 'Forgot Password?' %}</a>
|
||||||
</div>
|
</div>
|
||||||
@ -174,7 +174,7 @@
|
|||||||
{% trans "Keep me signed in" %}
|
{% trans "Keep me signed in" %}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary w-100 mt-4">{% trans "Sign In" %}</button>
|
<button type="submit" class="btn btn-primary w-100 mt-4">{% trans "Sign In" %}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -182,7 +182,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -35,13 +35,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="logo-container d-flex gap-2 align-items-center">
|
<div class="logo-container d-flex gap-2 align-items-center">
|
||||||
<img src="{% static 'image/vision.svg' %}" alt="{% trans 'Saudi Vision 2030' %}" loading="lazy" style="height: 35px; object-fit: contain;">
|
<img src="{% static 'image/vision.svg' %}" alt="{% trans 'Saudi Vision 2030' %}" loading="lazy" style="height: 35px; object-fit: contain;">
|
||||||
|
<div class="kaauh-logo-container d-flex flex-column flex-md-row align-items-center gap-2 me-0">
|
||||||
<div class="kaauh-logo-container d-flex flex-column flex-md-row align-items-center gap-2 me-0">
|
|
||||||
<div class="hospital-text text-center text-md-start me-0">
|
<div class="hospital-text text-center text-md-start me-0">
|
||||||
<div class="ar text-xs">جامعة الأميرة نورة بنت عبدالرحمن الأكاديمية</div>
|
<div class="en text-xs">{% trans "Princess Nourah bint Abdulrahman University"%}</div>
|
||||||
<div class="ar text-xs">ومستشفى الملك عبدالله بن عبدالرحمن التخصصي</div>
|
<div class="en text-xs">{% trans "King Abdullah bin Abdulaziz University Hospital"%}</div>
|
||||||
<div class="en text-xs">Princess Nourah bint Abdulrahman University</div>
|
|
||||||
<div class="en text-xs">King Abdullah bin Abdulaziz University Hospital</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<img src="{% static 'image/kaauh.png' %}" alt="KAAUH Logo" style="max-height: 40px; max-width: 40px;">
|
<img src="{% static 'image/kaauh.png' %}" alt="KAAUH Logo" style="max-height: 40px; max-width: 40px;">
|
||||||
@ -277,7 +274,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item me-lg-4">
|
<li class="nav-item me-lg-4">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'list_meetings' %}">
|
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'interview_list' %}">
|
||||||
<span class="d-flex align-items-center gap-2">
|
<span class="d-flex align-items-center gap-2">
|
||||||
<i class="fas fa-calendar-check me-2"></i>
|
<i class="fas fa-calendar-check me-2"></i>
|
||||||
{% trans "Meetings" %}
|
{% trans "Meetings" %}
|
||||||
|
|||||||
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 %}
|
{% load static i18n %}
|
||||||
|
|
||||||
{% block title %}{% trans "Scheduled Interviews List" %} - {{ block.super }}{% endblock %}
|
{% block title %}{% trans "Interview Management" %} - ATS{% endblock %}
|
||||||
|
|
||||||
{% block customCSS %}
|
{% block customCSS %}
|
||||||
{# (Your existing CSS is kept here, as it is perfect for the theme) #}
|
|
||||||
<style>
|
<style>
|
||||||
/* ... (Your CSS styles) ... */
|
/* KAAT-S UI Variables */
|
||||||
|
:root {
|
||||||
|
--kaauh-teal: #00636e;
|
||||||
|
--kaauh-teal-dark: #004a53;
|
||||||
|
--kaauh-border: #eaeff3;
|
||||||
|
--kaauh-primary-text: #343a40;
|
||||||
|
--kaauh-success: #28a745;
|
||||||
|
--kaauh-info: #17a2b8;
|
||||||
|
--kaauh-danger: #dc3545;
|
||||||
|
--kaauh-warning: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary Color Overrides */
|
||||||
|
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
||||||
|
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
|
||||||
|
|
||||||
|
/* Main Container & Card Styling */
|
||||||
|
.kaauh-card {
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Controls */
|
||||||
|
.filter-controls {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Styling */
|
||||||
|
.btn-main-action {
|
||||||
|
background-color: var(--kaauh-teal);
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.btn-main-action:hover {
|
||||||
|
background-color: var(--kaauh-teal-dark);
|
||||||
|
border-color: var(--kaauh-teal-dark);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.btn-outline-secondary {
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
.btn-outline-secondary:hover {
|
||||||
|
background-color: var(--kaauh-teal-dark);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--kaauh-teal-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Interview Table Styling */
|
||||||
|
.interview-table {
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.interview-table thead {
|
||||||
|
background-color: var(--kaauh-border);
|
||||||
|
}
|
||||||
|
.interview-table th {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
border-bottom: 2px solid var(--kaauh-teal);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.interview-table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--kaauh-border);
|
||||||
|
vertical-align: middle;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.interview-table tbody tr:hover {
|
||||||
|
background-color: #f1f3f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column Widths */
|
||||||
|
.interview-table thead th:nth-child(1) { width: 40px; }
|
||||||
|
.interview-table thead th:nth-child(2) { width: 15%; }
|
||||||
|
.interview-table thead th:nth-child(3) { width: 12%; }
|
||||||
|
.interview-table thead th:nth-child(4) { width: 12%; }
|
||||||
|
.interview-table thead th:nth-child(5) { width: 10%; }
|
||||||
|
.interview-table thead th:nth-child(6) { width: 8%; }
|
||||||
|
.interview-table thead th:nth-child(7) { width: 8%; }
|
||||||
|
.interview-table thead th:nth-child(8) { width: 15%; }
|
||||||
|
|
||||||
|
/* Candidate and Job Info */
|
||||||
|
.candidate-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--kaauh-primary-text);
|
||||||
|
}
|
||||||
|
.candidate-details {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
.job-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges and Statuses */
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.3em 0.7em;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.interview-type-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Colors */
|
||||||
|
.bg-scheduled { background-color: #6c757d !important; color: white; }
|
||||||
|
.bg-confirmed { background-color: var(--kaauh-info) !important; color: white; }
|
||||||
|
.bg-cancelled { background-color: var(--kaauh-danger) !important; color: white; }
|
||||||
|
.bg-completed { background-color: var(--kaauh-success) !important; color: white; }
|
||||||
|
.bg-remote { background-color: #007bff !important; color: white; }
|
||||||
|
.bg-onsite { background-color: #6f42c1 !important; color: white; }
|
||||||
|
|
||||||
|
/* Custom Height Optimization */
|
||||||
|
.form-control-sm,
|
||||||
|
.btn-sm {
|
||||||
|
padding-top: 0.2rem !important;
|
||||||
|
padding-bottom: 0.2rem !important;
|
||||||
|
height: 28px !important;
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination Styling */
|
||||||
|
.pagination .page-link {
|
||||||
|
color: var(--kaauh-teal);
|
||||||
|
border-color: var(--kaauh-border);
|
||||||
|
}
|
||||||
|
.pagination .page-item.active .page-link {
|
||||||
|
background-color: var(--kaauh-teal);
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
.pagination .page-link:hover {
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{interviews}}
|
|
||||||
<div class="container-fluid py-4">
|
<div class="container-fluid py-4">
|
||||||
|
<!-- Header Section -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
<div>
|
||||||
<i class="fas fa-calendar-alt me-2"></i> {% trans "Scheduled Interviews" %}
|
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||||
</h1>
|
<i class="fas fa-calendar-alt me-2"></i>
|
||||||
{# FIX: Using safe anchor href="#" to prevent the NoReverseMatch crash. #}
|
{% trans "Interview Management" %}
|
||||||
{# Replace '#' with {% url 'create_scheduled_interview' %} once the URL name is defined in urls.py #}
|
</h1>
|
||||||
<a href="#" class="btn btn-main-action">
|
<h2 class="h5 text-muted mb-0">
|
||||||
<i class="fas fa-plus me-1"></i> {% trans "Schedule Interview" %}
|
{% trans "Total Interviews:" %} <span class="fw-bold">{{ interviews|length }}</span>
|
||||||
</a>
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{% url 'dashboard' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Dashboard" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-4 shadow-sm no-hover">
|
<!-- Filter Controls -->
|
||||||
<div class="card-body">
|
<div class="filter-controls">
|
||||||
<form method="GET" class="row g-3 align-items-end">
|
<form method="get" class="row g-3">
|
||||||
{# Search field #}
|
<div class="col-md-3">
|
||||||
<div class="col-md-4">
|
<label for="job_filter" class="form-label form-label-sm">{% trans "Job" %}</label>
|
||||||
<label for="q" class="form-label small text-muted">{% trans "Search (Candidate/Job)" %}</label>
|
<select name="job" id="job_filter" class="form-select form-select-sm">
|
||||||
<div class="input-group">
|
<option value="">{% trans "All Jobs" %}</option>
|
||||||
<input type="text" class="form-control form-control-sm" id="q" name="q" placeholder="{% trans 'Search...' %}" value="{{ search_query }}">
|
{% for job in jobs %}
|
||||||
</div>
|
<option value="{{ job.id }}" {% if request.GET.job == job.id|stringformat:"s" %}selected{% endif %}>
|
||||||
</div>
|
{{ job.title }}
|
||||||
|
</option>
|
||||||
{# Filter by Status #}
|
{% endfor %}
|
||||||
<div class="col-md-3">
|
</select>
|
||||||
<label for="status" class="form-label small text-muted">{% trans "Filter by Status" %}</label>
|
</div>
|
||||||
<select name="status" id="status" class="form-select form-select-sm">
|
<div class="col-md-2">
|
||||||
<option value="">{% trans "All Statuses" %}</option>
|
<label for="status_filter" class="form-label form-label-sm">{% trans "Status" %}</label>
|
||||||
<option value="scheduled" {% if status_filter == 'scheduled' %}selected{% endif %}>{% trans "Scheduled" %}</option>
|
<select name="status" id="status_filter" class="form-select form-select-sm">
|
||||||
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>{% trans "Confirmed" %}</option>
|
<option value="">{% trans "All Status" %}</option>
|
||||||
<option value="completed" {% if status_filter == 'completed' %}selected{% endif %}>{% trans "Completed" %}</option>
|
<option value="scheduled" {% if request.GET.status == "scheduled" %}selected{% endif %}>{% trans "Scheduled" %}</option>
|
||||||
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>{% trans "Cancelled" %}</option>
|
<option value="confirmed" {% if request.GET.status == "confirmed" %}selected{% endif %}>{% trans "Confirmed" %}</option>
|
||||||
</select>
|
<option value="cancelled" {% if request.GET.status == "cancelled" %}selected{% endif %}>{% trans "Cancelled" %}</option>
|
||||||
</div>
|
<option value="completed" {% if request.GET.status == "completed" %}selected{% endif %}>{% trans "Completed" %}</option>
|
||||||
|
</select>
|
||||||
{# Filter by Interview Type (ONSITE/REMOTE) - This list now correctly populated #}
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-2">
|
||||||
<label for="interview_type" class="form-label small text-muted">{% trans "Interview Type" %}</label>
|
<label for="type_filter" class="form-label form-label-sm">{% trans "Type" %}</label>
|
||||||
<select name="interview_type" id="interview_type" class="form-select form-select-sm">
|
<select name="type" id="type_filter" class="form-select form-select-sm">
|
||||||
<option value="">{% trans "All Types" %}</option>
|
<option value="">{% trans "All Types" %}</option>
|
||||||
{% for type_value, type_label in interview_types %}
|
<option value="remote" {% if request.GET.type == "remote" %}selected{% endif %}>{% trans "Remote" %}</option>
|
||||||
<option value="{{ type_value }}" {% if type_filter == type_value %}selected{% endif %}>
|
<option value="onsite" {% if request.GET.type == "onsite" %}selected{% endif %}>{% trans "Onsite" %}</option>
|
||||||
{{ type_label }}
|
</select>
|
||||||
</option>
|
</div>
|
||||||
{% endfor %}
|
<div class="col-md-3">
|
||||||
</select>
|
<label for="search_filter" class="form-label form-label-sm">{% trans "Search Candidate" %}</label>
|
||||||
</div>
|
<input type="text" name="search" id="search_filter" class="form-control form-control-sm"
|
||||||
|
value="{{ request.GET.search }}" placeholder="{% trans 'Name or Email' %}">
|
||||||
<div class="col-md-2">
|
</div>
|
||||||
<div class="filter-buttons">
|
<div class="col-md-2 d-flex align-items-end">
|
||||||
<button type="submit" class="btn btn-main-action btn-sm">
|
<button type="submit" class="btn btn-main-action btn-sm me-2">
|
||||||
<i class="fas fa-filter me-1"></i> {% trans "Apply" %}
|
<i class="fas fa-filter me-1"></i> {% trans "Filter" %}
|
||||||
</button>
|
</button>
|
||||||
{% if status_filter or search_query or type_filter %}
|
<a href="{% url 'interview_list' %}" class="btn btn-outline-secondary btn-sm">
|
||||||
{# Assuming 'interview_list' is the URL name for this view #}
|
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
|
||||||
<a href="{% url 'interview_list' %}" class="btn btn-outline-secondary btn-sm">
|
</a>
|
||||||
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
|
</div>
|
||||||
</a>
|
</form>
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{{meetings}}
|
{{meetings}}
|
||||||
{# Using 'meetings' based on the context_object_name provided #}
|
{# Using 'meetings' based on the context_object_name provided #}
|
||||||
@ -99,11 +253,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="card-text text-muted small mb-3">
|
<p class="card-text text-muted small mb-3">
|
||||||
<i class="fas fa-briefcase"></i> {% trans "Job" %}:
|
<i class="fas fa-briefcase"></i> {% trans "Job" %}:
|
||||||
<a class="text-secondary text-decoration-none" href="{% url 'job_detail' interview.job.slug %}">{{ interview.job.title }}</a><br>
|
<a class="text-secondary text-decoration-none" href="{% url 'job_detail' interview.job.slug %}">{{ interview.job.title }}</a><br>
|
||||||
|
|
||||||
{# --- Remote/Onsite Logic - Handles both cases safely --- #}
|
{# --- Remote/Onsite Logic - Handles both cases safely --- #}
|
||||||
<i class="fas {% if interview.schedule.interview_type == 'Remote' %}fa-globe{% else %}fa-map-marker-alt{% endif %}"></i>
|
<i class="fas {% if interview.schedule.interview_type == 'Remote' %}fa-globe{% else %}fa-map-marker-alt{% endif %}"></i>
|
||||||
{% trans "Type" %}: {{ interview.schedule.get_interview_type_display }}
|
{% trans "Type" %}: {{ interview.schedule.get_interview_type_display }}
|
||||||
{% if interview.schedule.interview_type == 'Remote' %}<br>
|
{% if interview.schedule.interview_type == 'Remote' %}<br>
|
||||||
{# CRITICAL FIX: Safe access to zoom_meeting details #}
|
{# CRITICAL FIX: Safe access to zoom_meeting details #}
|
||||||
@ -111,7 +265,7 @@
|
|||||||
{% else %}<br>
|
{% else %}<br>
|
||||||
<i class="fas fa-building"></i> {% trans "Location" %}: {{ interview.schedule.location }}
|
<i class="fas fa-building"></i> {% trans "Location" %}: {{ interview.schedule.location }}
|
||||||
{% endif %}<br>
|
{% endif %}<br>
|
||||||
|
|
||||||
<i class="fas fa-clock"></i> {% trans "Date" %}: {{ interview.interview_date|date:"M d, Y" }}<br>
|
<i class="fas fa-clock"></i> {% trans "Date" %}: {{ interview.interview_date|date:"M d, Y" }}<br>
|
||||||
<i class="fas fa-clock"></i> {% trans "Time" %}: {{ interview.interview_time|time:"H:i" }}<br>
|
<i class="fas fa-clock"></i> {% trans "Time" %}: {{ interview.interview_time|time:"H:i" }}<br>
|
||||||
<i class="fas fa-stopwatch"></i> {% trans "Duration" %}: {{ interview.schedule.interview_duration }} minutes
|
<i class="fas fa-stopwatch"></i> {% trans "Duration" %}: {{ interview.schedule.interview_duration }} minutes
|
||||||
@ -149,70 +303,83 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Table View (Logic is identical, safe access applied) #}
|
<form id="interview-form">
|
||||||
<div class="table-view">
|
{% csrf_token %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover">
|
<table class="table interview-table align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">{% trans "Candidate" %}</th>
|
<th><i class="fas fa-user me-1"></i> {% trans "Candidate" %}</th>
|
||||||
<th scope="col">{% trans "Job" %}</th>
|
<th><i class="fas fa-briefcase me-1"></i> {% trans "Job" %}</th>
|
||||||
<th scope="col">{% trans "Type" %}</th>
|
<th><i class="fas fa-calendar me-1"></i> {% trans "Date & Time" %}</th>
|
||||||
<th scope="col">{% trans "Date/Time" %}</th>
|
<th><i class="fas fa-tag me-1"></i> {% trans "Type" %}</th>
|
||||||
<th scope="col">{% trans "Duration" %}</th>
|
<th><i class="fas fa-info-circle me-1"></i> {% trans "Status" %}</th>
|
||||||
<th scope="col">{% trans "Status" %}</th>
|
{% comment %} <th><i class="fas fa-users me-1"></i> {% trans "Participants" %}</th> {% endcomment %}
|
||||||
<th scope="col" class="text-end">{% trans "Actions" %}</th>
|
<th><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for interview in meetings %}
|
{% for interview in interviews %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<strong class="text-primary-theme">
|
<div class="candidate-name">{{ interview.application.name }}</div>
|
||||||
<a href="{% url 'application_detail' interview.candidate.slug %}" class="text-decoration-none text-primary-theme">{{ interview.candidate.name }}</a>
|
<div class="application-details">
|
||||||
</strong>
|
<i class="fas fa-envelope me-1"></i> {{ interview.application.email }}<br>
|
||||||
|
<i class="fas fa-phone me-1"></i> {{ interview.application.phone }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a class="text-secondary text-decoration-none" href="{% url 'job_detail' interview.job.slug %}">{{ interview.job.title }}</a>
|
<div class="job-title">{{ interview.job.title }}</div>
|
||||||
|
<div class="candidate-details">{{ interview.job.department }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ interview.schedule.get_interview_type_display }}
|
<div class="candidate-details">
|
||||||
|
<i class="fas fa-calendar-day me-1"></i> {{ interview.interview_date|date:"d-m-Y" }}<br>
|
||||||
|
<i class="fas fa-clock me-1"></i> {{ interview.interview_time|date:"h:i A" }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ interview.interview_date|date:"M d, Y" }} <br>({{ interview.interview_time|time:"H:i" }})</td>
|
|
||||||
<td>{{ interview.schedule.interview_duration }} min</td>
|
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-{{ interview.status }}">
|
{% if interview.interview.location_type == 'Remote' %}
|
||||||
{% if interview.status == 'confirmed' %}
|
<span class="badge interview-type-badge bg-remote">
|
||||||
<i class="fas fa-circle me-1 text-white"></i>
|
<i class="fas fa-video me-1"></i> {% trans "Remote" %}
|
||||||
{% endif %}
|
</span>
|
||||||
{{ interview.status|title }}
|
{% else %}
|
||||||
|
<span class="badge interview-type-badge bg-onsite">
|
||||||
|
<i class="fas fa-building me-1"></i> {% trans "Onsite" %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-primary-theme">
|
||||||
|
{{ interview.status|upper }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
|
||||||
|
|
||||||
{# CRITICAL FIX: Safe access to join URL #}
|
|
||||||
{% if interview.schedule.interview_type == 'Remote' and interview.zoom_meeting and interview.zoom_meeting.join_url %}
|
|
||||||
<a href="{{ interview.zoom_meeting.join_url }}" target="_blank" class="btn btn-main-action" title="{% trans 'Join' %}">
|
|
||||||
<i class="fas fa-sign-in-alt"></i>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<a href="{% url 'scheduled_interview_detail' interview.slug %}" class="btn btn-outline-primary" title="{% trans 'View' %}">
|
<td>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<a href="{% url 'interview_detail' interview.slug %}"
|
||||||
|
class="btn btn-outline-primary btn-sm"
|
||||||
|
title="{% trans 'View Details' %}">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'update_scheduled_interview' interview.slug %}" class="btn btn-outline-secondary" title="{% trans 'Update' %}">
|
{% comment %} {% if interview.status != 'CANCELLED' and interview.status != 'COMPLETED' %}
|
||||||
<i class="fas fa-edit"></i>
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
</a>
|
data-bs-toggle="modal"
|
||||||
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
|
data-bs-target="#actionModal"
|
||||||
data-bs-toggle="modal"
|
hx-get="#"
|
||||||
data-bs-target="#meetingModal"
|
hx-target="#actionModalBody"
|
||||||
hx-post="{% url 'delete_scheduled_interview' interview.slug %}"
|
title="{% trans 'Reschedule' %}">
|
||||||
hx-target="#meetingModalBody"
|
<i class="fas fa-redo-alt"></i>
|
||||||
hx-swap="outerHTML"
|
</button>
|
||||||
data-item-name="{{ interview.candidate.name }} Interview">
|
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||||
<i class="fas fa-trash-alt"></i>
|
data-bs-toggle="modal"
|
||||||
</button>
|
data-bs-target="#actionModal"
|
||||||
|
hx-get="#"
|
||||||
|
hx-target="#actionModalBody"
|
||||||
|
title="{% trans 'Cancel' %}">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %} {% endcomment %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -220,49 +387,138 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if is_paginated %}
|
||||||
|
<nav aria-label="Interview pagination" class="mt-4">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page=1{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "First" %}</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "Previous" %}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for num in page_obj.paginator.page_range %}
|
||||||
|
{% if page_obj.number == num %}
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">{{ num }}</span>
|
||||||
|
</li>
|
||||||
|
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ num }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{{ num }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "Next" %}</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "Last" %}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info text-center py-5" role="alert">
|
||||||
|
<i class="fas fa-info-circle fa-2x mb-3"></i>
|
||||||
|
<h5>{% trans "No interviews found" %}</h5>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
{% trans "There are no interviews matching your current filters." %}
|
||||||
|
<a href="{% url 'interview_list' %}" class="alert-link">{% trans "Clear filters" %}</a>
|
||||||
|
{% trans "to see all interviews." %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Pagination #}
|
|
||||||
{% if is_paginated %}
|
|
||||||
<nav aria-label="Page navigation" class="mt-4">
|
|
||||||
<ul class="pagination justify-content-center">
|
|
||||||
{% if page_obj.has_previous %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="?page=1{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">First</a>
|
|
||||||
</li>
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">Previous</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<li class="page-item active">
|
|
||||||
<span class="page-link">{{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">Next</a>
|
|
||||||
</li>
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&interview_type={{ type_filter }}{% endif %}">Last</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
</div>
|
||||||
<div class="text-center py-5 card shadow-sm">
|
</div>
|
||||||
<div class="card-body">
|
|
||||||
<i class="fas fa-calendar-alt fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
|
<!-- Action Modal -->
|
||||||
<h3>{% trans "No Interviews found" %}</h3>
|
<div class="modal fade" id="actionModal" tabindex="-1" aria-labelledby="actionModalLabel" aria-hidden="true">
|
||||||
<p class="text-muted">{% trans "Schedule your first interview or adjust your filters." %}</p>
|
<div class="modal-dialog">
|
||||||
{# FIX: Using safe anchor href="#" to prevent the NoReverseMatch crash. #}
|
<div class="modal-content kaauh-card">
|
||||||
<a href="#" class="btn btn-main-action mt-3">
|
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||||
<i class="fas fa-plus me-1"></i> {% trans "Schedule an Interview" %}
|
<h5 class="modal-title" id="actionModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||||
</a>
|
{% trans "Interview Action" %}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div id="actionModalBody" class="modal-body">
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||||
|
{% trans "Loading..." %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block customJS %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||||
|
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
|
||||||
|
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
function updateSelectAllState() {
|
||||||
|
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
|
||||||
|
const totalCount = rowCheckboxes.length;
|
||||||
|
|
||||||
|
if (checkedCount === 0) {
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
} else if (checkedCount === totalCount) {
|
||||||
|
selectAllCheckbox.checked = true;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
} else {
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAllCheckbox.addEventListener('change', function () {
|
||||||
|
const isChecked = selectAllCheckbox.checked;
|
||||||
|
|
||||||
|
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
|
||||||
|
|
||||||
|
rowCheckboxes.forEach(function (checkbox) {
|
||||||
|
checkbox.checked = isChecked;
|
||||||
|
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
|
||||||
|
updateSelectAllState();
|
||||||
|
});
|
||||||
|
|
||||||
|
rowCheckboxes.forEach(function (checkbox) {
|
||||||
|
checkbox.addEventListener('change', updateSelectAllState);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSelectAllState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear modal content when hidden
|
||||||
|
const actionModal = document.getElementById('actionModal');
|
||||||
|
actionModal.addEventListener('hidden.bs.modal', function () {
|
||||||
|
const modalBody = actionModal.querySelector('#actionModalBody');
|
||||||
|
if (modalBody) {
|
||||||
|
modalBody.innerHTML = `
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||||
|
{% trans "Loading..." %}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
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 mb-4 shadow-sm no-hover">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="search" class="form-label small text-muted">{% trans "Search by Name or Email" %}</label>
|
<label for="search" class="form-label small text-muted">{% trans "Search by Name or Email" %}</label>
|
||||||
<div class="input-group input-group-lg">
|
<div class="input-group input-group-lg">
|
||||||
@ -213,8 +213,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{% if people_list %}
|
{% if people_list %}
|
||||||
<div id="person-list">
|
<div id="person-list">
|
||||||
<!-- View Switcher -->
|
<!-- View Switcher -->
|
||||||
@ -287,13 +287,13 @@
|
|||||||
class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
|
class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="btn btn-outline-danger"
|
{% comment %} <button type="button" class="btn btn-outline-danger"
|
||||||
title="{% trans 'Delete' %}"
|
title="{% trans 'Delete' %}"
|
||||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||||
data-delete-url="{% url 'person_delete' person.slug %}"
|
data-delete-url="{% url 'person_delete' person.slug %}"
|
||||||
data-item-name="{{ person.get_full_name }}">
|
data-item-name="{{ person.get_full_name }}">
|
||||||
<i class="fas fa-trash-alt"></i>
|
<i class="fas fa-trash-alt"></i>
|
||||||
</button>
|
</button> {% endcomment %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -194,6 +194,9 @@
|
|||||||
<a href="{% url 'person_detail' person.slug %}" class="btn btn-outline-secondary">
|
<a href="{% url 'person_detail' person.slug %}" class="btn btn-outline-secondary">
|
||||||
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
|
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{% url 'person_delete' person.slug %}" class="btn btn-danger">
|
||||||
|
<i class="fas fa-trash me-1"></i> {% trans "Delete" %}
|
||||||
|
</a>
|
||||||
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary">
|
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary">
|
||||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %}
|
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -1,217 +1,478 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends "base.html" %}
|
||||||
{% load static i18n %}
|
{% load static i18n widget_tweaks %}
|
||||||
|
|
||||||
{% block title %}{{ title }} - ATS{% endblock %}
|
{% block title %}{{ title }} - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block customCSS %}
|
||||||
|
<style>
|
||||||
|
/* UI Variables for the KAAT-S Theme */
|
||||||
|
:root {
|
||||||
|
--kaauh-teal: #00636e;
|
||||||
|
--kaauh-teal-dark: #004a53;
|
||||||
|
--kaauh-border: #eaeff3;
|
||||||
|
--kaauh-gray-light: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Container Styling */
|
||||||
|
.form-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Styling */
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Action Button Style */
|
||||||
|
.btn-main-action {
|
||||||
|
background-color: var(--kaauh-teal);
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-main-action:hover {
|
||||||
|
background-color: var(--kaauh-teal-dark);
|
||||||
|
border-color: var(--kaauh-teal-dark);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary Button Style */
|
||||||
|
.btn-outline-secondary {
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
.btn-outline-secondary:hover {
|
||||||
|
background-color: var(--kaauh-teal-dark);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--kaauh-teal-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Field Styling */
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select:focus {
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breadcrumb Styling */
|
||||||
|
.breadcrumb {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item + .breadcrumb-item::before {
|
||||||
|
content: ">";
|
||||||
|
color: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert Styling */
|
||||||
|
.alert {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.btn.loading {
|
||||||
|
position: relative;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.loading::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: auto;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-top-color: #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Current Profile Section */
|
||||||
|
.current-profile {
|
||||||
|
background-color: var(--kaauh-gray-light);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-profile h6 {
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container py-4">
|
<div class="container-fluid py-4">
|
||||||
<!-- Header -->
|
<div class="form-container">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<!-- Breadcrumb Navigation -->
|
||||||
<div>
|
<nav aria-label="breadcrumb">
|
||||||
<h1 class="h3 mb-1">{{ title }}</h1>
|
<ol class="breadcrumb">
|
||||||
<p class="text-muted mb-0">
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{% url 'agency_list' %}" class="text-decoration-none text-secondary">
|
||||||
|
<i class="fas fa-building me-1"></i> {% trans "Agencies" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
{% if agency %}
|
{% if agency %}
|
||||||
{% trans "Update the hiring agency information below." %}
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{% url 'agency_detail' agency.slug %}" class="text-decoration-none text-secondary">
|
||||||
|
{{ agency.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page"
|
||||||
|
style="
|
||||||
|
color: #F43B5E; /* Rosy Accent Color */
|
||||||
|
font-weight: 600;">{% trans "Update" %}</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% trans "Fill in the details to add a new hiring agency." %}
|
<li class="breadcrumb-item active" aria-current="page"
|
||||||
|
style="
|
||||||
|
color: #F43B5E; /* Rosy Accent Color */
|
||||||
|
font-weight: 600;">{% trans "Create" %}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||||
|
<i class="fas fa-building me-2"></i> {{ title }}
|
||||||
|
</h1>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% if agency %}
|
||||||
|
<a href="{% url 'agency_detail' agency.slug %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'agency_delete' agency.slug %}" class="btn btn-danger">
|
||||||
|
<i class="fas fa-trash me-1"></i> {% trans "Delete" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url 'agency_list' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="{% url 'agency_list' %}" class="btn btn-secondary">
|
|
||||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Agencies" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form -->
|
{% if agency %}
|
||||||
<div class="row">
|
<!-- Current Agency Info -->
|
||||||
<div class="col-lg-8">
|
<div class="card shadow-sm mb-4">
|
||||||
<div class="card">
|
<div class="card-body">
|
||||||
<div class="card-body">
|
<div class="current-profile">
|
||||||
{% if form.non_field_errors %}
|
<h6><i class="fas fa-info-circle me-2"></i>{% trans "Currently Editing" %}</h6>
|
||||||
<div class="alert alert-danger" role="alert">
|
<div class="d-flex align-items-center">
|
||||||
<h5 class="alert-heading">
|
<div class="current-image d-flex align-items-center justify-content-center bg-light">
|
||||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
<i class="fas fa-building text-muted"></i>
|
||||||
{% trans "Please correct the errors below:" %}
|
|
||||||
</h5>
|
|
||||||
{% for error in form.non_field_errors %}
|
|
||||||
<p class="mb-0">{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
<div>
|
||||||
|
<h5 class="mb-1">{{ agency.name }}</h5>
|
||||||
<form method="post" novalidate>
|
{% if agency.contact_person %}
|
||||||
{% csrf_token %}
|
<p class="text-muted mb-0">{% trans "Contact" %}: {{ agency.contact_person }}</p>
|
||||||
|
|
||||||
<!-- Name -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="{{ form.name.id_for_label }}" class="form-label">
|
|
||||||
{{ form.name.label }} <span class="text-danger">*</span>
|
|
||||||
</label>
|
|
||||||
{{ form.name }}
|
|
||||||
{% if form.name.errors %}
|
|
||||||
{% for error in form.name.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if form.name.help_text %}
|
{% if agency.email %}
|
||||||
<div class="form-text">{{ form.name.help_text }}</div>
|
<p class="text-muted mb-0">{{ agency.email }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<small class="text-muted">
|
||||||
|
{% trans "Created" %}: {{ agency.created_at|date:"d M Y" }} •
|
||||||
|
{% trans "Last Updated" %}: {{ agency.updated_at|date:"d M Y" }}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Contact Person and Phone -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="{{ form.contact_person.id_for_label }}" class="form-label">
|
|
||||||
{{ form.contact_person.label }}
|
|
||||||
</label>
|
|
||||||
{{ form.contact_person }}
|
|
||||||
{% if form.contact_person.errors %}
|
|
||||||
{% for error in form.contact_person.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% if form.contact_person.help_text %}
|
|
||||||
<div class="form-text">{{ form.contact_person.help_text }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="{{ form.phone.id_for_label }}" class="form-label">
|
|
||||||
{{ form.phone.label }}
|
|
||||||
</label>
|
|
||||||
{{ form.phone }}
|
|
||||||
{% if form.phone.errors %}
|
|
||||||
{% for error in form.phone.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% if form.phone.help_text %}
|
|
||||||
<div class="form-text">{{ form.phone.help_text }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Email and Website -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="{{ form.email.id_for_label }}" class="form-label">
|
|
||||||
{{ form.email.label }}
|
|
||||||
</label>
|
|
||||||
{{ form.email }}
|
|
||||||
{% if form.email.errors %}
|
|
||||||
{% for error in form.email.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% if form.email.help_text %}
|
|
||||||
<div class="form-text">{{ form.email.help_text }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="{{ form.website.id_for_label }}" class="form-label">
|
|
||||||
{{ form.website.label }}
|
|
||||||
</label>
|
|
||||||
{{ form.website }}
|
|
||||||
{% if form.website.errors %}
|
|
||||||
{% for error in form.website.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% if form.website.help_text %}
|
|
||||||
<div class="form-text">{{ form.website.help_text }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Address -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="{{ form.address.id_for_label }}" class="form-label">
|
|
||||||
{{ form.address.label }}
|
|
||||||
</label>
|
|
||||||
{{ form.address }}
|
|
||||||
{% if form.address.errors %}
|
|
||||||
{% for error in form.address.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% if form.address.help_text %}
|
|
||||||
<div class="form-text">{{ form.address.help_text }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Country and City -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="{{ form.country.id_for_label }}" class="form-label">
|
|
||||||
{{ form.country.label }}
|
|
||||||
</label>
|
|
||||||
{{ form.country }}
|
|
||||||
{% if form.country.errors %}
|
|
||||||
{% for error in form.country.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% if form.country.help_text %}
|
|
||||||
<div class="form-text">{{ form.country.help_text }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="{{ form.city.id_for_label }}" class="form-label">
|
|
||||||
{{ form.city.label }}
|
|
||||||
</label>
|
|
||||||
{{ form.city }}
|
|
||||||
{% if form.city.errors %}
|
|
||||||
{% for error in form.city.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% if form.city.help_text %}
|
|
||||||
<div class="form-text">{{ form.city.help_text }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="{{ form.description.id_for_label }}" class="form-label">
|
|
||||||
{{ form.description.label }}
|
|
||||||
</label>
|
|
||||||
{{ form.description }}
|
|
||||||
{% if form.description.errors %}
|
|
||||||
{% for error in form.description.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% if form.description.help_text %}
|
|
||||||
<div class="form-text">{{ form.description.help_text }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form Actions -->
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<a href="{% url 'agency_list' %}" class="btn btn-outline-secondary">
|
|
||||||
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
|
|
||||||
</a>
|
|
||||||
<button type="submit" class="btn btn-main-action">
|
|
||||||
<i class="fas fa-save me-1"></i> {{ button_text }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Form Card -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
<h5 class="alert-heading">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2"></i>{% trans "Error" %}
|
||||||
|
</h5>
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<p class="mb-0">{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% trans 'Close' %}"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" novalidate id="agency-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.name.id_for_label }}" class="form-label">
|
||||||
|
{{ form.name.label }} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.name|add_class:"form-control" }}
|
||||||
|
{% if form.name.errors %}
|
||||||
|
{% for error in form.name.errors %}
|
||||||
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.name.help_text %}
|
||||||
|
<div class="form-text">{{ form.name.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Person and Phone -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="{{ form.contact_person.id_for_label }}" class="form-label">
|
||||||
|
{{ form.contact_person.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.contact_person|add_class:"form-control" }}
|
||||||
|
{% if form.contact_person.errors %}
|
||||||
|
{% for error in form.contact_person.errors %}
|
||||||
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.contact_person.help_text %}
|
||||||
|
<div class="form-text">{{ form.contact_person.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="{{ form.phone.id_for_label }}" class="form-label">
|
||||||
|
{{ form.phone.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.phone|add_class:"form-control" }}
|
||||||
|
{% if form.phone.errors %}
|
||||||
|
{% for error in form.phone.errors %}
|
||||||
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.phone.help_text %}
|
||||||
|
<div class="form-text">{{ form.phone.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email and Website -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="{{ form.email.id_for_label }}" class="form-label">
|
||||||
|
{{ form.email.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.email|add_class:"form-control" }}
|
||||||
|
{% if form.email.errors %}
|
||||||
|
{% for error in form.email.errors %}
|
||||||
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.email.help_text %}
|
||||||
|
<div class="form-text">{{ form.email.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="{{ form.website.id_for_label }}" class="form-label">
|
||||||
|
{{ form.website.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.website|add_class:"form-control" }}
|
||||||
|
{% if form.website.errors %}
|
||||||
|
{% for error in form.website.errors %}
|
||||||
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.website.help_text %}
|
||||||
|
<div class="form-text">{{ form.website.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.address.id_for_label }}" class="form-label">
|
||||||
|
{{ form.address.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.address|add_class:"form-control" }}
|
||||||
|
{% if form.address.errors %}
|
||||||
|
{% for error in form.address.errors %}
|
||||||
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.address.help_text %}
|
||||||
|
<div class="form-text">{{ form.address.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Country and City -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="{{ form.country.id_for_label }}" class="form-label">
|
||||||
|
{{ form.country.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.country|add_class:"form-control" }}
|
||||||
|
{% if form.country.errors %}
|
||||||
|
{% for error in form.country.errors %}
|
||||||
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.country.help_text %}
|
||||||
|
<div class="form-text">{{ form.country.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="{{ form.city.id_for_label }}" class="form-label">
|
||||||
|
{{ form.city.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.city|add_class:"form-control" }}
|
||||||
|
{% if form.city.errors %}
|
||||||
|
{% for error in form.city.errors %}
|
||||||
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.city.help_text %}
|
||||||
|
<div class="form-text">{{ form.city.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="{{ form.description.id_for_label }}" class="form-label">
|
||||||
|
{{ form.description.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.description|add_class:"form-control" }}
|
||||||
|
{% if form.description.errors %}
|
||||||
|
{% for error in form.description.errors %}
|
||||||
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.description.help_text %}
|
||||||
|
<div class="form-text">{{ form.description.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button form="agency-form" type="submit" class="btn btn-main-action">
|
||||||
|
<i class="fas fa-save me-1"></i> {{ button_text }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block customJS %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Add Bootstrap classes to form fields
|
// Form Validation
|
||||||
const formFields = document.querySelectorAll('input[type="text"], input[type="email"], input[type="url"], input[type="tel"], textarea, select');
|
const form = document.getElementById('agency-form');
|
||||||
formFields.forEach(function(field) {
|
if (form) {
|
||||||
field.classList.add('form-control');
|
form.addEventListener('submit', function(e) {
|
||||||
|
const submitBtn = form.querySelector('button[type="submit"]');
|
||||||
|
submitBtn.classList.add('loading');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
const name = document.getElementById('id_name');
|
||||||
|
if (name && !name.value.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitBtn.classList.remove('loading');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
alert('{% trans "Agency name is required." %}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = document.getElementById('id_email');
|
||||||
|
if (email && email.value.trim() && !isValidEmail(email.value.trim())) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitBtn.classList.remove('loading');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
alert('{% trans "Please enter a valid email address." %}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const website = document.getElementById('id_website');
|
||||||
|
if (website && website.value.trim() && !isValidURL(website.value.trim())) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitBtn.classList.remove('loading');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
alert('{% trans "Please enter a valid website URL." %}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email validation helper
|
||||||
|
function isValidEmail(email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL validation helper
|
||||||
|
function isValidURL(url) {
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn before leaving if changes are made
|
||||||
|
let formChanged = false;
|
||||||
|
const formInputs = form ? form.querySelectorAll('input, select, textarea') : [];
|
||||||
|
|
||||||
|
formInputs.forEach(input => {
|
||||||
|
input.addEventListener('change', function() {
|
||||||
|
formChanged = true;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function(e) {
|
||||||
|
if (formChanged) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '{% trans "You have unsaved changes. Are you sure you want to leave?" %}';
|
||||||
|
return e.returnValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function() {
|
||||||
|
formChanged = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -1,43 +1,51 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static i18n crispy_forms_tags %}
|
{% load static i18n crispy_forms_tags %}
|
||||||
|
|
||||||
{% block title %}Update Candidate - {{ block.super }}{% endblock %}
|
{% block title %}Update {{ object.name }} - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
{% block customCSS %}
|
{% block customCSS %}
|
||||||
<style>
|
<style>
|
||||||
/* ================================================= */
|
/* UI Variables for the KAAT-S Theme */
|
||||||
/* THEME VARIABLES AND GLOBAL STYLES (FROM JOB DETAIL) */
|
|
||||||
/* ================================================= */
|
|
||||||
:root {
|
:root {
|
||||||
--kaauh-teal: #00636e;
|
--kaauh-teal: #00636e;
|
||||||
--kaauh-teal-dark: #004a53;
|
--kaauh-teal-dark: #004a53;
|
||||||
--kaauh-border: #eaeff3;
|
--kaauh-border: #eaeff3;
|
||||||
--kaauh-primary-text: #343a40;
|
--kaauh-gray-light: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Primary Color Overrides */
|
/* Form Container Styling */
|
||||||
.text-primary { color: var(--kaauh-teal) !important; }
|
.form-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Styling */
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
/* Main Action Button Style */
|
/* Main Action Button Style */
|
||||||
.btn-main-action, .btn-primary {
|
.btn-main-action {
|
||||||
background-color: var(--kaauh-teal);
|
background-color: var(--kaauh-teal);
|
||||||
border-color: var(--kaauh-teal);
|
border-color: var(--kaauh-teal);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
}
|
}
|
||||||
.btn-main-action:hover, .btn-primary:hover {
|
|
||||||
|
.btn-main-action:hover {
|
||||||
background-color: var(--kaauh-teal-dark);
|
background-color: var(--kaauh-teal-dark);
|
||||||
border-color: var(--kaauh-teal-dark);
|
border-color: var(--kaauh-teal-dark);
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Outlined Button Styles */
|
/* Secondary Button Style */
|
||||||
.btn-outline-secondary {
|
.btn-outline-secondary {
|
||||||
color: var(--kaauh-teal-dark);
|
color: var(--kaauh-teal-dark);
|
||||||
border-color: var(--kaauh-teal);
|
border-color: var(--kaauh-teal);
|
||||||
@ -48,94 +56,308 @@
|
|||||||
border-color: var(--kaauh-teal-dark);
|
border-color: var(--kaauh-teal-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card enhancements */
|
/* Form Field Styling */
|
||||||
.card {
|
.form-control:focus {
|
||||||
border: 1px solid var(--kaauh-border);
|
border-color: var(--kaauh-teal);
|
||||||
border-radius: 0.75rem;
|
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
|
||||||
background-color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Colored Header Card */
|
.form-select:focus {
|
||||||
.candidate-header-card {
|
border-color: var(--kaauh-teal);
|
||||||
background: linear-gradient(135deg, var(--kaauh-teal), #004d57);
|
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
||||||
color: white;
|
|
||||||
border-radius: 0.75rem 0.75rem 0 0;
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: 0 4px 10px rgba(0,0,0,0.15);
|
|
||||||
}
|
}
|
||||||
.candidate-header-card h1 {
|
|
||||||
font-weight: 700;
|
/* Profile Image Upload Styling */
|
||||||
margin: 0;
|
.profile-image-upload {
|
||||||
font-size: 1.8rem;
|
border: 2px dashed var(--kaauh-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.heroicon {
|
|
||||||
width: 1.25rem;
|
.profile-image-upload:hover {
|
||||||
height: 1.25rem;
|
border-color: var(--kaauh-teal);
|
||||||
vertical-align: text-bottom;
|
background-color: var(--kaauh-gray-light);
|
||||||
stroke: currentColor;
|
}
|
||||||
margin-right: 0.5rem;
|
|
||||||
|
.profile-image-preview {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid var(--kaauh-teal);
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-image {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--kaauh-teal);
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breadcrumb Styling */
|
||||||
|
.breadcrumb {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item + .breadcrumb-item::before {
|
||||||
|
content: ">";
|
||||||
|
color: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert Styling */
|
||||||
|
.alert {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.btn.loading {
|
||||||
|
position: relative;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.loading::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: auto;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-top-color: #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Current Profile Section */
|
||||||
|
.current-profile {
|
||||||
|
background-color: var(--kaauh-gray-light);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-profile h6 {
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid py-4">
|
<div class="container-fluid py-4">
|
||||||
|
<div class="form-container">
|
||||||
<div class="card mb-4">
|
<!-- Breadcrumb Navigation -->
|
||||||
<div class="candidate-header-card">
|
<nav aria-label="breadcrumb">
|
||||||
<div class="d-flex justify-content-between align-items-start flex-wrap">
|
<ol class="breadcrumb">
|
||||||
<div class="flex-grow-1">
|
<li class="breadcrumb-item">
|
||||||
<h1 class="h3 mb-1">
|
<a href="{% url 'application_list' %}" class="text-decoration-none text-secondary">
|
||||||
<i class="fas fa-user-edit"></i>
|
<i class="fas fa-users me-1"></i> {% trans "Applications" %}
|
||||||
{% trans "Update Candidate:" %} {{ object.name }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-white opacity-75 mb-0">{% trans "Edit candidate information and details" %}</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2 mt-1">
|
|
||||||
<a href="{% url 'application_list' %}" class="btn btn-outline-light btn-sm" title="{% trans 'Back to List' %}">
|
|
||||||
<i class="fas fa-arrow-left"></i>
|
|
||||||
<span class="d-none d-sm-inline">{% trans "Back to List" %}</span>
|
|
||||||
</a>
|
</a>
|
||||||
{% if object.slug %}
|
</li>
|
||||||
<a href="{% url 'application_detail' object.slug %}" class="btn btn-outline-light btn-sm" title="{% trans 'View Candidate' %}">
|
<li class="breadcrumb-item">
|
||||||
<i class="fas fa-eye"></i>
|
<a href="{% url 'application_detail' object.slug %}" class="text-decoration-none text-secondary">
|
||||||
<span class="d-none d-sm-inline">{% trans "View" %}</span>
|
{{ object.name }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
</li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page"
|
||||||
|
style="
|
||||||
|
color: #F43B5E; /* Rosy Accent Color */
|
||||||
|
font-weight: 600;">{% trans "Update" %}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||||
|
<i class="fas fa-user-edit me-2"></i> {% trans "Update Application" %}
|
||||||
|
</h1>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{% url 'application_detail' object.slug %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'application_delete' object.slug %}" class="btn btn-danger">
|
||||||
|
<i class="fas fa-trash me-1"></i> {% trans "Delete" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'application_list' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Profile Info -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="current-profile">
|
||||||
|
<h6><i class="fas fa-info-circle me-2"></i>{% trans "Currently Editing" %}</h6>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
{% if object.profile_image %}
|
||||||
|
<img src="{{ object.profile_image.url }}" alt="{{ object.name }}"
|
||||||
|
class="current-image">
|
||||||
|
{% else %}
|
||||||
|
<div class="current-image d-flex align-items-center justify-content-center bg-light">
|
||||||
|
<i class="fas fa-user text-muted"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-1">{{ object.name }}</h5>
|
||||||
|
{% if object.email %}
|
||||||
|
<p class="text-muted mb-0">{{ object.email }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">
|
||||||
|
{% trans "Created" %}: {{ object.created_at|date:"d M Y" }} •
|
||||||
|
{% trans "Last Updated" %}: {{ object.updated_at|date:"d M Y" }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<!-- Form Card -->
|
||||||
<div class="card-header bg-white border-bottom">
|
<div class="card shadow-sm">
|
||||||
<h2 class="h5 mb-0 text-primary">
|
<div class="card-body p-4">
|
||||||
<i class="fas fa-file-alt me-1"></i>
|
{% if form.non_field_errors %}
|
||||||
{% trans "Candidate Form" %}
|
<div class="alert alert-danger" role="alert">
|
||||||
</h2>
|
<h5 class="alert-heading">
|
||||||
</div>
|
<i class="fas fa-exclamation-triangle me-2"></i>{% trans "Error" %}
|
||||||
<div class="card-body">
|
</h5>
|
||||||
<form method="post" enctype="multipart/form-data">
|
{% for error in form.non_field_errors %}
|
||||||
{% csrf_token %}
|
<p class="mb-0">{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
{# Use Crispy Forms to render fields. The two-column layout is applied to the main form content #}
|
</div>
|
||||||
<div class="row g-4">
|
{% endif %}
|
||||||
{% for field in form %}
|
|
||||||
<div class="col-md-6">
|
{% if messages %}
|
||||||
{{ field|as_crispy_field }}
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% trans 'Close' %}"></button>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
{% endif %}
|
||||||
|
|
||||||
<hr class="mt-4 mb-4">
|
<form method="post" action="{% url 'candidate_update' object.slug %}" enctype="multipart/form-data" id="candidate-form">
|
||||||
<button class="btn btn-main-action" type="submit">
|
{% csrf_token %}
|
||||||
<i class="fas fa-save me-1"></i>
|
{{form|crispy}}
|
||||||
{% trans "Update Candidate" %}
|
</form>
|
||||||
</button>
|
<div class="d-flex gap-2">
|
||||||
</form>
|
<button form="candidate-form" type="submit" class="btn btn-main-action">
|
||||||
|
<i class="fas fa-save me-1"></i> {% trans "Update" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block customJS %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Profile Image Preview
|
||||||
|
const profileImageInput = document.getElementById('id_profile_image');
|
||||||
|
const imagePreviewContainer = document.getElementById('image-preview-container');
|
||||||
|
const originalImage = imagePreviewContainer ? imagePreviewContainer.innerHTML : '';
|
||||||
|
|
||||||
|
if (profileImageInput) {
|
||||||
|
profileImageInput.addEventListener('change', function(e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file && file.type.startsWith('image/')) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
if (imagePreviewContainer) {
|
||||||
|
imagePreviewContainer.innerHTML = `
|
||||||
|
<img src="${e.target.result}" alt="Profile Preview" class="profile-image-preview">
|
||||||
|
<h5 class="text-muted mt-3">${file.name}</h5>
|
||||||
|
<p class="text-muted small">{% trans "New photo selected" %}</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
} else if (!file && imagePreviewContainer) {
|
||||||
|
// Reset to original if no file selected
|
||||||
|
imagePreviewContainer.innerHTML = originalImage;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form Validation
|
||||||
|
const form = document.getElementById('candidate-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
const submitBtn = form.querySelector('button[type="submit"]');
|
||||||
|
submitBtn.classList.add('loading');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
const name = document.getElementById('id_name');
|
||||||
|
if (name && !name.value.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitBtn.classList.remove('loading');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
alert('{% trans "Name is required." %}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = document.getElementById('id_email');
|
||||||
|
if (email && email.value.trim() && !isValidEmail(email.value.trim())) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitBtn.classList.remove('loading');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
alert('{% trans "Please enter a valid email address." %}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email validation helper
|
||||||
|
function isValidEmail(email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn before leaving if changes are made
|
||||||
|
let formChanged = false;
|
||||||
|
const formInputs = form ? form.querySelectorAll('input, select, textarea') : [];
|
||||||
|
|
||||||
|
formInputs.forEach(input => {
|
||||||
|
input.addEventListener('change', function() {
|
||||||
|
formChanged = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function(e) {
|
||||||
|
if (formChanged) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '{% trans "You have unsaved changes. Are you sure you want to leave?" %}';
|
||||||
|
return e.returnValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function() {
|
||||||
|
formChanged = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@ -286,7 +286,7 @@
|
|||||||
{# Separator (Vertical Rule) - Aligns automatically at the bottom with align-items-end #}
|
{# Separator (Vertical Rule) - Aligns automatically at the bottom with align-items-end #}
|
||||||
<div class="vr" style="height: 28px;"></div>
|
<div class="vr" style="height: 28px;"></div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-outline-info btn-sm"
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
hx-boost='true'
|
hx-boost='true'
|
||||||
data-bs-target="#emailModal"
|
data-bs-target="#emailModal"
|
||||||
@ -418,7 +418,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade modal-xl" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
|
<div class="modal fade modal-xl" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
|
||||||
|
|||||||
@ -229,7 +229,7 @@
|
|||||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" class="btn btn-outline-info btn-sm"
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
hx-boost='true'
|
hx-boost='true'
|
||||||
data-bs-target="#emailModal"
|
data-bs-target="#emailModal"
|
||||||
|
|||||||
@ -235,7 +235,7 @@
|
|||||||
|
|
||||||
{# Select Input Group #}
|
{# Select Input Group #}
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="min-width: 150px;">
|
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="min-width: 150px;">
|
||||||
<option selected>
|
<option selected>
|
||||||
----------
|
----------
|
||||||
@ -252,7 +252,7 @@
|
|||||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||||
</button>
|
</button>
|
||||||
{# email button#}
|
{# email button#}
|
||||||
<button type="button" class="btn btn-outline-info btn-sm"
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
hx-boost='true'
|
hx-boost='true'
|
||||||
data-bs-target="#emailModal"
|
data-bs-target="#emailModal"
|
||||||
@ -348,7 +348,7 @@
|
|||||||
<i class="fas fa-file-alt"></i>
|
<i class="fas fa-file-alt"></i>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -424,7 +424,7 @@
|
|||||||
<div class="text-center py-5 text-muted">
|
<div class="text-center py-5 text-muted">
|
||||||
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||||
{% trans "Loading email form..." %}
|
{% trans "Loading email form..." %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -293,7 +293,7 @@
|
|||||||
title="View Profile">
|
title="View Profile">
|
||||||
{{ application.name }}
|
{{ application.name }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="application-details">
|
<div class="application-details">
|
||||||
@ -422,27 +422,37 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<button type="button" class="btn btn-main-action btn-sm"
|
{% comment %} <a href="{% url 'interview_create_type_selection' candidate_slug=candidate.slug %}"
|
||||||
|
class="btn btn-main-action btn-sm"
|
||||||
|
title="Schedule Interview">
|
||||||
|
<i class="fas fa-calendar-plus me-1"></i>
|
||||||
|
Schedule
|
||||||
|
</a> {% endcomment %}
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#candidateviewModal"
|
data-bs-target="#candidateviewModal"
|
||||||
hx-get="{% url 'schedule_meeting_for_application' job.slug application.pk %}"
|
hx-get="{% url 'interview_create_type_selection' application_slug=application.slug %}"
|
||||||
hx-target="#candidateviewModalBody"
|
hx-select=".card-body"
|
||||||
data-modal-title="{% trans 'Schedule Interview' %}"
|
hx-swap="innerHTML"
|
||||||
title="Schedule Interview">
|
hx-target="#candidateviewModalBody">
|
||||||
<i class="fas fa-video"></i>
|
<i class="fas fa-calendar-plus me-1"></i>
|
||||||
</button>
|
Schedule
|
||||||
<button type="button" class="btn btn-main-action btn-sm"
|
|
||||||
data-bs-toggle="modal"
|
|
||||||
data-bs-target="#candidateviewModal"
|
|
||||||
{# UPDATED: Points to the specific Onsite scheduling URL #}
|
|
||||||
hx-get="{% url 'schedule_onsite_meeting_for_application' job.slug application.pk %}"
|
|
||||||
hx-target="#candidateviewModalBody"
|
|
||||||
data-modal-title="{% trans 'Schedule Onsite Interview' %}"
|
|
||||||
title="Schedule Onsite Interview">
|
|
||||||
<i class="fas fa-building"></i>
|
|
||||||
</button>
|
</button>
|
||||||
|
{% comment %} <a href="{% url 'interview_create_type_selection' candidate_slug=candidate.slug %}"
|
||||||
|
class="btn btn-main-action btn-sm"
|
||||||
|
title="Schedule Interview">
|
||||||
|
<i class="fas fa-calendar-plus me-1"></i>
|
||||||
|
Schedule
|
||||||
|
</a> {% endcomment %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#candidateviewModal"
|
||||||
|
hx-get="{% url 'get_interview_list' application.slug %}"
|
||||||
|
hx-target="#candidateviewModalBody">
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
</button>
|
||||||
|
{{candidate.get_interviews}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -463,7 +473,7 @@
|
|||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content kaauh-card"> <div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
<div class="modal-content kaauh-card"> <div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||||
<h5 class="modal-title" id="candidateviewModalLabel" style="color: var(--kaauh-teal-dark);">
|
<h5 class="modal-title" id="candidateviewModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||||
{% trans "Application Details / Bulk Action Form" %}
|
{% comment %} {% trans "Candidate Details / Bulk Action Form" %} {% endcomment %}
|
||||||
</h5>
|
</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
@ -476,11 +486,9 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Email Modal -->
|
<!-- Email Modal -->
|
||||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg" role="document">
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
|||||||
@ -332,12 +332,12 @@
|
|||||||
<a href="{% url 'application_update' candidate.slug %}" class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
|
<a href="{% url 'application_update' candidate.slug %}" class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
|
{% comment %} <button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
|
||||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||||
data-delete-url="{% url 'application_delete' candidate.slug %}"
|
data-delete-url="{% url 'application_delete' candidate.slug %}"
|
||||||
data-item-name="{{ candidate.name }}">
|
data-item-name="{{ candidate.name }}">
|
||||||
<i class="fas fa-trash-alt"></i>
|
<i class="fas fa-trash-alt"></i>
|
||||||
</button>
|
</button> {% endcomment %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -231,7 +231,7 @@
|
|||||||
|
|
||||||
{# Separator (Vertical Rule) #}
|
{# Separator (Vertical Rule) #}
|
||||||
<div class="vr" style="height: 28px;"></div>
|
<div class="vr" style="height: 28px;"></div>
|
||||||
<button type="button" class="btn btn-outline-info btn-sm"
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
hx-boost='true'
|
hx-boost='true'
|
||||||
data-bs-target="#emailModal"
|
data-bs-target="#emailModal"
|
||||||
|
|||||||
@ -344,7 +344,7 @@
|
|||||||
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||||
</button>
|
</button>
|
||||||
{# email button#}
|
{# email button#}
|
||||||
<button type="button" class="btn btn-outline-info btn-sm"
|
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
hx-boost='true'
|
hx-boost='true'
|
||||||
data-bs-target="#emailModal"
|
data-bs-target="#emailModal"
|
||||||
|
|||||||
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 static i18n %}
|
||||||
{% load widget_tweaks %}
|
{% load widget_tweaks %}
|
||||||
|
|
||||||
{% block title %}{{ title }}{% endblock %}
|
{% block title %}{{ title }} - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block customCSS %}
|
||||||
|
<style>
|
||||||
|
/* UI Variables for the KAAT-S Theme */
|
||||||
|
:root {
|
||||||
|
--kaauh-teal: #00636e;
|
||||||
|
--kaauh-teal-dark: #004a53;
|
||||||
|
--kaauh-border: #eaeff3;
|
||||||
|
--kaauh-gray-light: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Container Styling */
|
||||||
|
.form-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Styling */
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Action Button Style */
|
||||||
|
.btn-main-action {
|
||||||
|
background-color: var(--kaauh-teal);
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-main-action:hover {
|
||||||
|
background-color: var(--kaauh-teal-dark);
|
||||||
|
border-color: var(--kaauh-teal-dark);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary Button Style */
|
||||||
|
.btn-outline-secondary {
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
.btn-outline-secondary:hover {
|
||||||
|
background-color: var(--kaauh-teal-dark);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--kaauh-teal-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Field Styling */
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select:focus {
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breadcrumb Styling */
|
||||||
|
.breadcrumb {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item + .breadcrumb-item::before {
|
||||||
|
content: ">";
|
||||||
|
color: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert Styling */
|
||||||
|
.alert {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.btn.loading {
|
||||||
|
position: relative;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.loading::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: auto;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-top-color: #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Current Profile Section */
|
||||||
|
.current-profile {
|
||||||
|
background-color: var(--kaauh-gray-light);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-profile h6 {
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-image {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--kaauh-teal);
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid">
|
<div class="container-fluid py-4">
|
||||||
<div class="row">
|
<div class="form-container">
|
||||||
<div class="col-12">
|
<!-- Breadcrumb Navigation -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<nav aria-label="breadcrumb">
|
||||||
<h1 class="h3 mb-0">{{ title }}</h1>
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{% url 'source_list' %}" class="text-decoration-none text-secondary">
|
||||||
|
<i class="fas fa-plug me-1"></i> {% trans "Sources" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% if source %}
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{% url 'source_detail' source.pk %}" class="text-decoration-none text-secondary">
|
||||||
|
{{ source.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page"
|
||||||
|
style="
|
||||||
|
color: #F43B5E; /* Rosy Accent Color */
|
||||||
|
font-weight: 600;">{% trans "Update" %}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item active" aria-current="page"
|
||||||
|
style="
|
||||||
|
color: #F43B5E; /* Rosy Accent Color */
|
||||||
|
font-weight: 600;">{% trans "Create" %}</li>
|
||||||
|
{% endif %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||||
|
<i class="fas fa-plug me-2"></i> {{ title }}
|
||||||
|
</h1>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% if source %}
|
||||||
|
<a href="{% url 'source_detail' source.pk %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'source_delete' source.pk %}" class="btn btn-danger">
|
||||||
|
<i class="fas fa-trash me-1"></i> {% trans "Delete" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<a href="{% url 'source_list' %}" class="btn btn-outline-secondary">
|
<a href="{% url 'source_list' %}" class="btn btn-outline-secondary">
|
||||||
<i class="fas fa-arrow-left"></i> {% trans "Back to Sources" %}
|
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
{% if source %}
|
||||||
<div class="card-body">
|
<!-- Current Source Info -->
|
||||||
<form method="post" novalidate>
|
<div class="card shadow-sm mb-4">
|
||||||
{% csrf_token %}
|
<div class="card-body">
|
||||||
|
<div class="current-profile">
|
||||||
|
<h6><i class="fas fa-info-circle me-2"></i>{% trans "Currently Editing" %}</h6>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="current-image d-flex align-items-center justify-content-center bg-light">
|
||||||
|
<i class="fas fa-plug text-muted"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-1">{{ source.name }}</h5>
|
||||||
|
{% if source.source_type %}
|
||||||
|
<p class="text-muted mb-0">{% trans "Type" %}: {{ source.get_source_type_display }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if source.ip_address %}
|
||||||
|
<p class="text-muted mb-0">{% trans "IP Address" %}: {{ source.ip_address }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">
|
||||||
|
{% trans "Created" %}: {{ source.created_at|date:"d M Y" }} •
|
||||||
|
{% trans "Last Updated" %}: {{ source.updated_at|date:"d M Y" }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if form.non_field_errors %}
|
<!-- Form Card -->
|
||||||
<div class="alert alert-danger">
|
<div class="card shadow-sm">
|
||||||
{% for error in form.non_field_errors %}
|
<div class="card-body p-4">
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
<h5 class="alert-heading">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2"></i>{% trans "Error" %}
|
||||||
|
</h5>
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<p class="mb-0">{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% trans 'Close' %}"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" novalidate id="source-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.name.id_for_label }}" class="form-label">
|
||||||
|
{{ form.name.label }} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.name|add_class:"form-control" }}
|
||||||
|
{% if form.name.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.name.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">{{ form.name.help_text }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.source_type.id_for_label }}" class="form-label">
|
||||||
|
{{ form.source_type.label }} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.source_type|add_class:"form-select" }}
|
||||||
|
{% if form.source_type.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.source_type.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">{{ form.source_type.help_text }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.ip_address.id_for_label }}" class="form-label">
|
||||||
|
{{ form.ip_address.label }} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.ip_address|add_class:"form-control" }}
|
||||||
|
{% if form.ip_address.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.ip_address.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">{{ form.ip_address.help_text }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.trusted_ips.id_for_label }}" class="form-label">
|
||||||
|
{{ form.trusted_ips.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.trusted_ips|add_class:"form-control" }}
|
||||||
|
{% if form.trusted_ips.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.trusted_ips.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">{{ form.trusted_ips.help_text }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.description.id_for_label }}" class="form-label">
|
||||||
|
{{ form.description.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.description|add_class:"form-control" }}
|
||||||
|
{% if form.description.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.description.errors %}
|
||||||
{{ error }}
|
{{ error }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="form-text">{{ form.description.help_text }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="{{ form.name.id_for_label }}" class="form-label">
|
<div class="form-check">
|
||||||
{{ form.name.label }} <span class="text-danger">*</span>
|
{{ form.is_active|add_class:"form-check-input" }}
|
||||||
|
<label for="{{ form.is_active.id_for_label }}" class="form-check-label">
|
||||||
|
{{ form.is_active.label }}
|
||||||
</label>
|
</label>
|
||||||
{{ form.name|add_class:"form-control" }}
|
|
||||||
{% if form.name.errors %}
|
|
||||||
<div class="invalid-feedback d-block">
|
|
||||||
{% for error in form.name.errors %}
|
|
||||||
{{ error }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="form-text">{{ form.name.help_text }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% if form.is_active.errors %}
|
||||||
<div class="col-md-6">
|
<div class="invalid-feedback d-block">
|
||||||
<div class="mb-3">
|
{% for error in form.is_active.errors %}
|
||||||
<label for="{{ form.source_type.id_for_label }}" class="form-label">
|
{{ error }}
|
||||||
{{ form.source_type.label }} <span class="text-danger">*</span>
|
{% endfor %}
|
||||||
</label>
|
|
||||||
{{ form.source_type }}
|
|
||||||
{% if form.source_type.errors %}
|
|
||||||
<div class="invalid-feedback d-block">
|
|
||||||
{% for error in form.source_type.errors %}
|
|
||||||
{{ error }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="form-text">{{ form.source_type.help_text }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="{{ form.ip_address.id_for_label }}" class="form-label">
|
|
||||||
{{ form.ip_address.label }} <span class="text-danger">*</span>
|
|
||||||
</label>
|
|
||||||
{{ form.ip_address|add_class:"form-control" }}
|
|
||||||
{% if form.ip_address.errors %}
|
|
||||||
<div class="invalid-feedback d-block">
|
|
||||||
{% for error in form.ip_address.errors %}
|
|
||||||
{{ error }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="form-text">{{ form.ip_address.help_text }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="{{ form.ip_address.id_for_label }}" class="form-label">
|
|
||||||
{{ form.trusted_ips.label }} <span class="text-danger">*</span>
|
|
||||||
</label>
|
|
||||||
{{ form.trusted_ips|add_class:"form-control" }}
|
|
||||||
{% if form.trusted_ips.errors %}
|
|
||||||
<div class="invalid-feedback d-block">
|
|
||||||
{% for error in form.trusted_ips.errors %}
|
|
||||||
{{ error }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="form-text">{{ form.trusted_ips.help_text }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="{{ form.description.id_for_label }}" class="form-label">
|
|
||||||
{{ form.description.label }}
|
|
||||||
</label>
|
|
||||||
{{ form.description|add_class:"form-control" }}
|
|
||||||
{% if form.description.errors %}
|
|
||||||
<div class="invalid-feedback d-block">
|
|
||||||
{% for error in form.description.errors %}
|
|
||||||
{{ error }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="form-text">{{ form.description.help_text }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check">
|
|
||||||
{{ form.is_active|add_class:"form-check-input bg-primary-theme" }}
|
|
||||||
<label for="{{ form.is_active.id_for_label }}" class="form-check-label">
|
|
||||||
{{ form.is_active.label }}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
{% if form.is_active.errors %}
|
{% endif %}
|
||||||
<div class="invalid-feedback d-block">
|
<div class="form-text">{{ form.is_active.help_text }}</div>
|
||||||
{% for error in form.is_active.errors %}
|
|
||||||
{{ error }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="form-text">{{ form.is_active.help_text }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Credentials Section -->
|
||||||
<!-- API Credentials Section -->
|
{% if source %}
|
||||||
{% if source %}
|
<div class="card bg-light mb-4">
|
||||||
<div class="card bg-light mb-4">
|
<div class="card-header">
|
||||||
<div class="card-header">
|
<h6 class="mb-0">{% trans "API Credentials" %}</h6>
|
||||||
<h6 class="mb-0">{% trans "API Credentials" %}</h6>
|
</div>
|
||||||
</div>
|
<div class="card-body">
|
||||||
<div class="card-body">
|
<div class="row">
|
||||||
<div class="row">
|
<div class="col-md-6">
|
||||||
<div class="col-md-6">
|
<div class="mb-3">
|
||||||
<div class="mb-3">
|
<label class="form-label">{% trans "API Key" %}</label>
|
||||||
<label class="form-label">{% trans "API Key" %}</label>
|
<div class="input-group">
|
||||||
<div class="input-group">
|
<input type="text" class="form-control" value="{{ source.api_key }}" readonly>
|
||||||
<input type="text" class="form-control" value="{{ source.api_key }}" readonly>
|
<button type="button" class="btn btn-outline-secondary"
|
||||||
<button type="button" class="btn btn-outline-secondary"
|
hx-post="{% url 'copy_to_clipboard' %}"
|
||||||
hx-post="{% url 'copy_to_clipboard' %}"
|
hx-vals='{"text": "{{ source.api_key }}"}'
|
||||||
hx-vals='{"text": "{{ source.api_key }}"}'
|
title="{% trans 'Copy to clipboard' %}">
|
||||||
title="{% trans 'Copy to clipboard' %}">
|
<i class="fas fa-copy"></i>
|
||||||
<i class="fas fa-copy"></i>
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">{% trans "API Secret" %}</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="password" class="form-control" value="{{ source.api_secret }}" readonly id="api-secret">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" onclick="toggleSecretVisibility()">
|
|
||||||
<i class="fas fa-eye" id="secret-toggle-icon"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary"
|
|
||||||
hx-post="{% url 'copy_to_clipboard' %}"
|
|
||||||
hx-vals='{"text": "{{ source.api_secret }}"}'
|
|
||||||
title="{% trans 'Copy to clipboard' %}">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end">
|
<div class="col-md-6">
|
||||||
<a href="{% url 'generate_api_keys' source.pk %}" class="btn btn-warning">
|
<div class="mb-3">
|
||||||
<i class="fas fa-key"></i> {% trans "Generate New Keys" %}
|
<label class="form-label">{% trans "API Secret" %}</label>
|
||||||
</a>
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" value="{{ source.api_secret }}" readonly id="api-secret">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="toggleSecretVisibility()">
|
||||||
|
<i class="fas fa-eye" id="secret-toggle-icon"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary"
|
||||||
|
hx-post="{% url 'copy_to_clipboard' %}"
|
||||||
|
hx-vals='{"text": "{{ source.api_secret }}"}'
|
||||||
|
title="{% trans 'Copy to clipboard' %}">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<a href="{% url 'generate_api_keys' source.pk %}" class="btn btn-warning">
|
||||||
|
<i class="fas fa-key"></i> {% trans "Generate New Keys" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<a href="{% url 'source_list' %}" class="btn btn-outline-secondary">
|
|
||||||
<i class="fas fa-times me-1"></i>{% trans "Cancel" %}
|
|
||||||
</a>
|
|
||||||
<button type="submit" class="btn btn-main-action">
|
|
||||||
<i class="fas fa-save me-1"></i> {% trans "Save" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{% endif %}
|
||||||
</div>
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button form="source-form" type="submit" class="btn btn-main-action">
|
||||||
|
<i class="fas fa-save me-1"></i> {% trans "Save" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -200,6 +413,67 @@
|
|||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Form Validation
|
||||||
|
const form = document.getElementById('source-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
const submitBtn = form.querySelector('button[type="submit"]');
|
||||||
|
submitBtn.classList.add('loading');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
const name = document.getElementById('id_name');
|
||||||
|
if (name && !name.value.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitBtn.classList.remove('loading');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
alert('{% trans "Source name is required." %}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipAddress = document.getElementById('id_ip_address');
|
||||||
|
if (ipAddress && ipAddress.value.trim() && !isValidIP(ipAddress.value.trim())) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitBtn.classList.remove('loading');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
alert('{% trans "Please enter a valid IP address." %}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP validation helper
|
||||||
|
function isValidIP(ip) {
|
||||||
|
const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||||
|
return ipRegex.test(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn before leaving if changes are made
|
||||||
|
let formChanged = false;
|
||||||
|
const formInputs = form ? form.querySelectorAll('input, select, textarea') : [];
|
||||||
|
|
||||||
|
formInputs.forEach(input => {
|
||||||
|
input.addEventListener('change', function() {
|
||||||
|
formChanged = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function(e) {
|
||||||
|
if (formChanged) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '{% trans "You have unsaved changes. Are you sure you want to leave?" %}';
|
||||||
|
return e.returnValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function() {
|
||||||
|
formChanged = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function toggleSecretVisibility() {
|
function toggleSecretVisibility() {
|
||||||
const secretInput = document.getElementById('api-secret');
|
const secretInput = document.getElementById('api-secret');
|
||||||
const toggleIcon = document.getElementById('secret-toggle-icon');
|
const toggleIcon = document.getElementById('secret-toggle-icon');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user