Compare commits

...

7 Commits

28 changed files with 1015 additions and 593 deletions

View File

@ -33,12 +33,12 @@ urlpatterns = [
path('api/v1/templates/save/', views.save_form_template, name='save_form_template'), path('api/v1/templates/save/', views.save_form_template, name='save_form_template'),
path('api/v1/templates/<slug:template_slug>/', views.load_form_template, name='load_form_template'), path('api/v1/templates/<slug:template_slug>/', views.load_form_template, name='load_form_template'),
path('api/v1/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'), path('api/v1/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'),
path('api/v1/webhooks/zoom/', views.zoom_webhook_view, name='zoom_webhook_view'),
path('api/v1/sync/task/<str:task_id>/status/', views.sync_task_status, name='sync_task_status'), path('api/v1/sync/task/<str:task_id>/status/', views.sync_task_status, name='sync_task_status'),
path('api/v1/sync/history/', views.sync_history, name='sync_history'), path('api/v1/sync/history/', views.sync_history, name='sync_history'),
path('api/v1/sync/history/<slug:job_slug>/', views.sync_history, name='sync_history_job'), path('api/v1/sync/history/<slug:job_slug>/', views.sync_history, name='sync_history_job'),
path('api/v1/webhooks/zoom/', views.zoom_webhook_view, name='zoom_webhook_view'),
] ]
urlpatterns += i18n_patterns( urlpatterns += i18n_patterns(

View File

@ -27,4 +27,5 @@ admin.site.register(IntegrationLog)
admin.site.register(HiringAgency) admin.site.register(HiringAgency)
admin.site.register(JobPosting) admin.site.register(JobPosting)
admin.site.register(Settings) admin.site.register(Settings)
admin.site.register(FormSubmission) admin.site.register(FormSubmission)
# admin.site.register(InterviewQuestion)

View File

@ -359,6 +359,10 @@ class ApplicationForm(forms.ModelForm):
self.fields['job'].initial = current_job self.fields['job'].initial = current_job
self.fields['job'].widget.attrs['readonly'] = True self.fields['job'].widget.attrs['readonly'] = True
else:
self.fields['job'].queryset = self.fields['job'].queryset.filter(
status="ACTIVE"
)
# Make job field read-only if it's being pre-populated # Make job field read-only if it's being pre-populated
job_value = self.initial.get("job") job_value = self.initial.get("job")
@ -690,20 +694,40 @@ class BulkInterviewTemplateForm(forms.ModelForm):
self.fields["applications"].queryset.first().job.title self.fields["applications"].queryset.first().job.title
) )
self.fields["start_date"].initial = timezone.now().date() self.fields["start_date"].initial = timezone.now().date()
working_days_initial = [0, 1, 2, 3, 6] # Monday to Friday working_days_initial = [0, 1, 2, 3, 6]
self.fields["working_days"].initial = working_days_initial self.fields["working_days"].initial = working_days_initial
self.fields["start_time"].initial = "08:00" self.fields["start_time"].initial = "08:00"
self.fields["end_time"].initial = "14:00" self.fields["end_time"].initial = "14:00"
self.fields["interview_duration"].initial = 30 self.fields["interview_duration"].initial = 30
self.fields["buffer_time"].initial = 10 self.fields["buffer_time"].initial = 10
self.fields["break_start_time"].initial = "11:30" self.fields["break_start_time"].initial = "11:30"
self.fields["break_end_time"].initial = "12:00" self.fields["break_end_time"].initial = "12:30"
self.fields["physical_address"].initial = "Airport Road, King Khalid International Airport, Riyadh 11564, Saudi Arabia" self.fields["physical_address"].initial = "Airport Road, King Khalid International Airport, Riyadh 11564, Saudi Arabia"
def clean_working_days(self): def clean_working_days(self):
working_days = self.cleaned_data.get("working_days") working_days = self.cleaned_data.get("working_days")
return [int(day) for day in working_days] return [int(day) for day in working_days]
def clean_start_date(self):
start_date = self.cleaned_data.get("start_date")
if start_date and start_date < timezone.now().date():
raise forms.ValidationError(_("Start date must be in the future"))
return start_date
def clean_end_date(self):
start_date = self.cleaned_data.get("start_date")
end_date = self.cleaned_data.get("end_date")
if end_date and start_date and end_date < start_date:
raise forms.ValidationError(_("End date must be after start date"))
return end_date
def clean_end_time(self):
start_time = self.cleaned_data.get("start_time")
end_time = self.cleaned_data.get("end_time")
if end_time and start_time and end_time < start_time:
raise forms.ValidationError(_("End time must be after start time"))
return end_time
class InterviewCancelForm(forms.ModelForm): class InterviewCancelForm(forms.ModelForm):
class Meta: class Meta:
model = ScheduledInterview model = ScheduledInterview
@ -1369,43 +1393,70 @@ class CandidateEmailForm(forms.Form):
if candidate and candidate.stage == 'Applied': if candidate and candidate.stage == 'Applied':
message_parts = [ message_parts = [
f"Than you, for your interest in the {self.job.title} role.", f"Thank you for your interest in the {self.job.title} position at KAAUH and for taking the time to submit your application.",
f"We regret to inform you that you were not selected to move forward to the exam round at this time.", f"We have carefully reviewed your qualifications; however, we regret to inform you that your application was not selected to proceed to the examination round at this time.",
f"We encourage you to check our career page for further updates and future opportunities:", f"The selection process was highly competitive, and we had a large number of highly qualified applicants.",
f"https://kaauh/careers", f"We encourage you to review other opportunities and apply for roles that align with your skills on our career portal:",
f"Wishing you the best in your job search,", f"[settings.CAREER_PAGE_URL]", # Use a Django setting for the URL for flexibility
f"The KAAUH Hiring team" f"We wish you the best of luck in your current job search and future career endeavors.",
f"Sincerely,",
f"The KAAUH Recruitment Team",
] ]
elif candidate and candidate.stage == 'Exam': elif candidate and candidate.stage == 'Exam':
message_parts = [ message_parts = [
f"Than you,for your interest in the {self.job.title} role.", f"Dear Candidate,",
f"We're pleased to inform you that your initial screening was successful!", f"Thank you once again for your continued interest in the **{self.job.title}** position.",
f"The next step is the mandatory online assessment exam.", f"We are pleased to inform you that, following a careful review of your application, you have been **selected to proceed to the next phase** of our recruitment process.",
f"Please complete the assessment by using the following link:", f"The next mandatory step is the **Online Assessment Examination** designed to evaluate essential skills for this role.",
f"https://kaauh/hire/exam", f"\n**Action Required:**",
f"We look forward to reviewing your results.", f"Please click on the link below to access and complete the assessment:",
f"Best regards, The KAAUH Hiring team" f"[settings.EXAM_LINK_URL]", # Using a settings variable is a professional best practice
f"\n**Important Details:**",
f"* **Deadline:** The exam must be completed within **72 hours** of receiving this notification.",
f"* **Duration:** The assessment is timed and will take approximately [Insert Time e.g., 60 minutes] to complete.",
f"* **Technical Note:** Please ensure you have a stable internet connection before beginning.",
f"We appreciate your dedication to this process and look forward to reviewing your results.",
f"Sincerely,",
f"The KAAUH Recruitment Team",
] ]
elif candidate and candidate.stage == 'Interview': elif candidate and candidate.stage == 'Interview':
message_parts = [ message_parts = [
f"Than you, for your interest in the {self.job.title} role.", f"Dear Candidate,",
f"We're pleased to inform you that you have cleared your exam!", f"Thank you for your performance in the recent assessment for the **{self.job.title}** role.",
f"The next step is the mandatory interview.", f"We are pleased to inform you that you have **successfully cleared the examination phase** and have been selected to proceed to an interview.",
f"Please complete the assessment by using the following link:", f"The interview is a mandatory step that allows us to learn more about your experience and fit for the role.",
f"https://kaauh/hire/exam", f"\n**Next Steps:**",
f"We look forward to reviewing your results.", f"Our recruitment coordinator will contact you directly within the next 1-2 business days to schedule your interview time and provide the necessary details (such as the interview panel, format, and location/virtual meeting link).",
f"Best regards, The KAAUH Hiring team" f"\n**Please ensure your phone number and email address are current.**",
f"We look forward to speaking with you and discussing this exciting opportunity further.",
f"Sincerely,",
f"The KAAUH Recruitment Team",
] ]
elif candidate and candidate.stage == 'Offer': elif candidate and candidate.stage == 'Offer':
message_parts = [
f"Dear Candidate,",
f"We are delighted to extend to you a **formal offer of employment** for the position of **{self.job.title}** at KAAUH.",
f"Congratulations! This is an exciting moment, and we are very enthusiastic about the prospect of you joining our team.",
f"\n**Next Steps & Documentation:**",
f"A comprehensive offer package, detailing your compensation, benefits, and the full terms of employment, will be transmitted to your email address within the next **24 hours**.",
f"Please review this document carefully.",
f"\n**Questions and Support:**",
f"Should you have any immediate questions regarding the offer or the next steps, please do not hesitate to contact our Human Resources department directly at [HR Contact Email/Phone].",
f"\nWe eagerly anticipate your favorable response and officially welcoming you to the KAAUH team!",
f"Sincerely,",
f"The KAAUH Recruitment Team",
]
elif candidate and candidate.stage == 'Document Review':
message_parts = [ message_parts = [
f"Congratulations, ! We are delighted to inform you that we are extending a formal offer of employment for the {self.job.title} role.", f"Congratulations on progressing to the final stage for the {self.job.title} role!",
f"This is an exciting moment, and we look forward to having you join the KAAUH team.", f"The next critical step is to complete your application by uploading the required employment verification documents.",
f"A detailed offer letter and compensation package will be sent to you via email within 24 hours.", f"**Please log into the Candidate Portal immediately** to access the 'Document Upload' section.",
f"In the meantime, please contact our HR department at [HR Contact] if you have immediate questions.", f"Required documents typically include: National ID/Iqama, Academic Transcripts, and Professional Certifications.",
f"Welcome to the team!", f"You have **7 days** to upload all documents. Failure to do so may delay or invalidate your candidacy.",
f"Best regards, The KAAUH Hiring team" f"If you encounter any technical issues, please contact our support team at [Support Email/Phone] immediately.",
f"We appreciate your cooperation as we finalize your employment process.",
] ]
elif candidate and candidate.stage == 'Hired': elif candidate and candidate.stage == 'Hired':
message_parts = [ message_parts = [
@ -1414,7 +1465,7 @@ class CandidateEmailForm(forms.Form):
f"You will receive a separate email shortly with details regarding your start date, first-day instructions, and onboarding documents.", f"You will receive a separate email shortly with details regarding your start date, first-day instructions, and onboarding documents.",
f"We look forward to seeing you at KAAUH.", f"We look forward to seeing you at KAAUH.",
f"If you have any questions before your start date, please contact [Onboarding Contact].", f"If you have any questions before your start date, please contact [Onboarding Contact].",
f"Best regards, The KAAUH Hiring team"
] ]
elif candidate: elif candidate:
message_parts="" message_parts=""
@ -1536,7 +1587,7 @@ class MessageForm(forms.ModelForm):
print(person) print(person)
applications=person.applications.all() applications=person.applications.all()
print(applications) print(applications)
self.fields["job"].queryset = JobPosting.objects.filter( self.fields["job"].queryset = JobPosting.objects.filter(
applications__in=applications, applications__in=applications,
).distinct().order_by("-created_at") ).distinct().order_by("-created_at")
@ -2151,14 +2202,11 @@ Job: {job.title}
""" """
if interview.location_type == 'Remote': if interview.location_type == 'Remote':
initial_message += f"Pease join using meeting link {interview.details_url} .\n\n" initial_message += f"Pease join using meeting link {interview.join_url} \n\n"
else: else:
initial_message += "This is an onsite schedule. Please arrive 10 minutes early.\n\n" initial_message += "This is an onsite schedule. Please arrive 10 minutes early.\n\n"
initial_message += """
Best regards,
KAAUH Hiring Team
"""
self.fields['message'].initial = initial_message self.fields['message'].initial = initial_message
@ -2167,7 +2215,7 @@ KAAUH Hiring Team
class InterviewResultForm(forms.ModelForm): class InterviewResultForm(forms.ModelForm):
class Meta: class Meta:
model = Interview model = Interview
fields = ['interview_result', 'result_comments'] fields = ['interview_result', 'result_comments']
widgets = { widgets = {
'interview_result': forms.Select(attrs={ 'interview_result': forms.Select(attrs={

View File

@ -1,4 +1,4 @@
# Generated by Django 6.0 on 2025-12-12 11:17 # Generated by Django 5.2.7 on 2025-12-16 14:20
import django.contrib.auth.models import django.contrib.auth.models
import django.contrib.auth.validators import django.contrib.auth.validators
@ -92,11 +92,14 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated 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')), ('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')), ('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')),
('interview_result', models.CharField(blank=True, choices=[('passed', 'Passed'), ('failed', 'Failed'), ('on_hold', 'ON Hold')], default='on_hold', max_length=10, null=True, verbose_name='Interview Result')),
('result_comments', models.TextField(blank=True, null=True)),
('topic', models.CharField(blank=True, help_text="e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room'", max_length=255, verbose_name='Meeting/Location Topic')), ('topic', models.CharField(blank=True, help_text="e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room'", max_length=255, verbose_name='Meeting/Location Topic')),
('join_url', models.URLField(blank=True, max_length=2048, null=True, verbose_name='Meeting/Location URL')),
('timezone', models.CharField(default='UTC', max_length=50, verbose_name='Timezone')), ('timezone', models.CharField(default='UTC', max_length=50, verbose_name='Timezone')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')), ('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)), ('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('updated', 'Updated'), ('deleted', 'Deleted'), ('ended', 'Ended')], db_index=True, default='waiting', max_length=20)),
('cancelled_at', models.DateTimeField(blank=True, null=True, verbose_name='Cancelled At')), ('cancelled_at', models.DateTimeField(blank=True, null=True, verbose_name='Cancelled At')),
('cancelled_reason', models.TextField(blank=True, null=True, verbose_name='Cancellation Reason')), ('cancelled_reason', models.TextField(blank=True, null=True, verbose_name='Cancellation Reason')),
('meeting_id', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='External Meeting ID')), ('meeting_id', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='External Meeting ID')),
@ -125,7 +128,7 @@ class Migration(migrations.Migration):
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), ('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')), ('name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Participant Name')),
('email', models.EmailField(max_length=254, verbose_name='Email')), ('email', models.EmailField(max_length=254, verbose_name='Email')),
('phone', secured_fields.fields.EncryptedCharField(blank=True, max_length=12, null=True, verbose_name='Phone Number')), ('phone', secured_fields.fields.EncryptedCharField(blank=True, max_length=12, null=True, searchable=True, verbose_name='Phone Number')),
('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')), ('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')),
], ],
options={ options={
@ -139,8 +142,9 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated 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')), ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('name', models.CharField(blank=True, help_text="A human-readable name (e.g., 'Zoom')", max_length=100, null=True, verbose_name='Friendly Name')),
('key', models.CharField(help_text='Unique key for the setting', max_length=100, unique=True, verbose_name='Setting Key')), ('key', models.CharField(help_text='Unique key for the setting', max_length=100, unique=True, verbose_name='Setting Key')),
('value', models.TextField(help_text='Value for the setting', verbose_name='Setting Value')), ('value', secured_fields.fields.EncryptedTextField(help_text='Value for the setting', verbose_name='Setting Value')),
], ],
options={ options={
'verbose_name': 'Setting', 'verbose_name': 'Setting',
@ -154,8 +158,8 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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')), ('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')), ('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')), ('name', models.CharField(help_text='Name of the source', 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')), ('source_type', models.CharField(help_text='Type of the source', max_length=100, verbose_name='Source Type')),
('description', models.TextField(blank=True, help_text='A description of the source', verbose_name='Description')), ('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')), ('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)), ('created_at', models.DateTimeField(auto_now_add=True)),
@ -190,9 +194,9 @@ class Migration(migrations.Migration):
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), ('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')), ('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')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('first_name', secured_fields.fields.EncryptedCharField(blank=True, max_length=150, verbose_name='first name')), ('first_name', secured_fields.fields.EncryptedCharField(blank=True, max_length=150, searchable=True, verbose_name='first name')),
('user_type', models.CharField(choices=[('staff', 'Staff'), ('agency', 'Agency'), ('candidate', 'Candidate')], db_index=True, default='staff', max_length=20, verbose_name='User Type')), ('user_type', models.CharField(choices=[('staff', 'Staff'), ('agency', 'Agency'), ('candidate', 'Candidate')], db_index=True, default='staff', max_length=20, verbose_name='User Type')),
('phone', secured_fields.fields.EncryptedCharField(blank=True, null=True, verbose_name='Phone')), ('phone', secured_fields.fields.EncryptedCharField(blank=True, null=True, searchable=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')), ('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')), ('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')),
('email', models.EmailField(db_index=True, error_messages={'unique': 'A user with this email already exists.'}, max_length=254, unique=True)), ('email', models.EmailField(db_index=True, error_messages={'unique': 'A user with this email already exists.'}, max_length=254, unique=True)),
@ -358,7 +362,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')), ('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')), ('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')),
('email', models.EmailField(max_length=254, unique=True)), ('email', models.EmailField(max_length=254, unique=True)),
('phone', secured_fields.fields.EncryptedCharField(blank=True, max_length=20, null=True)), ('phone', secured_fields.fields.EncryptedCharField(blank=True, max_length=20, null=True, searchable=True)),
('website', models.URLField(blank=True)), ('website', models.URLField(blank=True)),
('notes', models.TextField(blank=True, help_text='Internal notes about the agency')), ('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)), ('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)),
@ -581,11 +585,11 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated 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')), ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('first_name', secured_fields.fields.EncryptedCharField(max_length=255, verbose_name='First Name')), ('first_name', secured_fields.fields.EncryptedCharField(max_length=255, searchable=True, verbose_name='First Name')),
('last_name', models.CharField(max_length=255, verbose_name='Last 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')), ('middle_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Middle Name')),
('email', models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='Email')), ('email', models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='Email')),
('phone', secured_fields.fields.EncryptedCharField(blank=True, null=True, verbose_name='Phone')), ('phone', secured_fields.fields.EncryptedCharField(blank=True, null=True, searchable=True, verbose_name='Phone')),
('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')), ('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')), ('gender', models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female')], max_length=1, null=True, verbose_name='Gender')),
('gpa', models.DecimalField(decimal_places=2, help_text='GPA must be between 0 and 4.', max_digits=3, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(4)], verbose_name='GPA')), ('gpa', models.DecimalField(decimal_places=2, help_text='GPA must be between 0 and 4.', max_digits=3, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(4)], verbose_name='GPA')),
@ -620,6 +624,7 @@ class Migration(migrations.Migration):
('interview_time', models.TimeField(verbose_name='Interview Time')), ('interview_time', models.TimeField(verbose_name='Interview Time')),
('interview_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], default='Remote', max_length=20)), ('interview_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], default='Remote', max_length=20)),
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)), ('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
('interview_questions', models.JSONField(blank=True, null=True, verbose_name='Question Data')),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')), ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')),
('interview', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interview', to='recruitment.interview', verbose_name='Interview/Meeting')), ('interview', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interview', to='recruitment.interview', verbose_name='Interview/Meeting')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')), ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),

View File

@ -1,23 +0,0 @@
# Generated by Django 6.0 on 2025-12-12 11:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='source',
name='name',
field=models.CharField(help_text='Name of the source', max_length=100, unique=True, verbose_name='Source Name'),
),
migrations.AlterField(
model_name='source',
name='source_type',
field=models.CharField(help_text='Type of the source', max_length=100, verbose_name='Source Type'),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 5.2.7 on 2025-12-15 12:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0002_alter_source_name_alter_source_source_type'),
]
operations = [
migrations.AddField(
model_name='interview',
name='interview_result',
field=models.CharField(blank=True, choices=[('passed', 'Passed'), ('failed', 'Failed'), ('on_hold', 'ON Hold')], default='on_hold', max_length=10, null=True, verbose_name='Interview Result'),
),
migrations.AddField(
model_name='interview',
name='result_comments',
field=models.TextField(blank=True, null=True),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2025-12-15 14:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0003_interview_interview_result_interview_result_comments'),
]
operations = [
migrations.AddField(
model_name='settings',
name='name',
field=models.CharField(blank=True, help_text="A human-readable name (e.g., 'Zoom')", max_length=100, null=True, verbose_name='Friendly Name'),
),
]

View File

@ -17,7 +17,7 @@ from django_countries.fields import CountryField
from django_ckeditor_5.fields import CKEditor5Field from django_ckeditor_5.fields import CKEditor5Field
from django_extensions.db.fields import RandomCharField from django_extensions.db.fields import RandomCharField
from django.contrib.postgres.validators import MinValueValidator, MaxValueValidator from django.contrib.postgres.validators import MinValueValidator, MaxValueValidator
from secured_fields import EncryptedCharField from secured_fields import EncryptedCharField,EncryptedTextField
from typing import List, Dict, Any from typing import List, Dict, Any
@ -45,7 +45,7 @@ class CustomUser(AbstractUser):
("candidate", _("Candidate")), ("candidate", _("Candidate")),
] ]
first_name=EncryptedCharField(_("first name"), max_length=150, blank=True) first_name=EncryptedCharField(_("first name"), max_length=150, blank=True,searchable=True)
user_type = models.CharField( user_type = models.CharField(
max_length=20, max_length=20,
@ -55,7 +55,7 @@ class CustomUser(AbstractUser):
db_index=True, # Added index for user_type filtering db_index=True, # Added index for user_type filtering
) )
phone = EncryptedCharField( phone = EncryptedCharField(
blank=True, null=True, verbose_name=_("Phone") blank=True, null=True, verbose_name=_("Phone"),searchable=True
) )
profile_image = models.ImageField( profile_image = models.ImageField(
null=True, null=True,
@ -535,7 +535,7 @@ class Person(Base):
] ]
# Personal Information # Personal Information
first_name = EncryptedCharField(max_length=255, verbose_name=_("First Name")) first_name = EncryptedCharField(max_length=255, verbose_name=_("First Name"),searchable=True)
last_name = models.CharField(max_length=255, verbose_name=_("Last Name")) last_name = models.CharField(max_length=255, verbose_name=_("Last Name"))
middle_name = models.CharField( middle_name = models.CharField(
max_length=255, blank=True, null=True, verbose_name=_("Middle Name") max_length=255, blank=True, null=True, verbose_name=_("Middle Name")
@ -546,7 +546,7 @@ class Person(Base):
verbose_name=_("Email"), verbose_name=_("Email"),
) )
phone = EncryptedCharField( phone = EncryptedCharField(
blank=True, null=True, verbose_name=_("Phone") blank=True, null=True, verbose_name=_("Phone"),searchable=True
) )
date_of_birth = models.DateField( date_of_birth = models.DateField(
null=True, blank=True, verbose_name=_("Date of Birth") null=True, blank=True, verbose_name=_("Date of Birth")
@ -1120,9 +1120,10 @@ class Interview(Base):
class Status(models.TextChoices): class Status(models.TextChoices):
WAITING = "waiting", _("Waiting") WAITING = "waiting", _("Waiting")
STARTED = "started", _("Started") STARTED = "started", _("Started")
UPDATED = "updated", _("Updated")
DELETED = "deleted", _("Deleted")
ENDED = "ended", _("Ended") ENDED = "ended", _("Ended")
CANCELLED = "cancelled", _("Cancelled")
class InterviewResult(models.TextChoices): class InterviewResult(models.TextChoices):
PASSED="passed",_("Passed") PASSED="passed",_("Passed")
FAILED="failed",_("Failed") FAILED="failed",_("Failed")
@ -1154,7 +1155,7 @@ class Interview(Base):
blank=True, blank=True,
help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room'"), help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room'"),
) )
details_url = models.URLField( join_url = models.URLField(
verbose_name=_("Meeting/Location URL"), max_length=2048, blank=True, null=True verbose_name=_("Meeting/Location URL"), max_length=2048, blank=True, null=True
) )
timezone = models.CharField( timezone = models.CharField(
@ -1351,6 +1352,10 @@ class ScheduledInterview(Base):
choices=InterviewStatus.choices, choices=InterviewStatus.choices,
default=InterviewStatus.SCHEDULED, default=InterviewStatus.SCHEDULED,
) )
interview_questions = models.JSONField(
verbose_name=_("Question Data"),
blank=True,null=True
)
def __str__(self): def __str__(self):
return ( return (
@ -1963,7 +1968,7 @@ class HiringAgency(Base):
max_length=150, blank=True, verbose_name=_("Contact Person") max_length=150, blank=True, verbose_name=_("Contact Person")
) )
email = models.EmailField(unique=True) email = models.EmailField(unique=True)
phone = EncryptedCharField(max_length=20, blank=True,null=True) phone = EncryptedCharField(max_length=20, blank=True,null=True,searchable=True)
website = models.URLField(blank=True) website = models.URLField(blank=True)
notes = models.TextField(blank=True, help_text=_("Internal notes about the agency")) notes = models.TextField(blank=True, help_text=_("Internal notes about the agency"))
country = CountryField(blank=True, null=True, blank_label=_("Select country")) country = CountryField(blank=True, null=True, blank_label=_("Select country"))
@ -2357,7 +2362,7 @@ class Participants(Base):
) )
email =models.EmailField(verbose_name=_("Email")) email =models.EmailField(verbose_name=_("Email"))
phone = EncryptedCharField( phone = EncryptedCharField(
max_length=12, verbose_name=_("Phone Number"), null=True, blank=True max_length=12, verbose_name=_("Phone Number"), null=True, blank=True,searchable=True
) )
designation = models.CharField( designation = models.CharField(
max_length=100, blank=True, verbose_name=_("Designation"), null=True max_length=100, blank=True, verbose_name=_("Designation"), null=True
@ -2606,11 +2611,11 @@ class Settings(Base):
verbose_name=_("Setting Key"), verbose_name=_("Setting Key"),
help_text=_("Unique key for the setting"), help_text=_("Unique key for the setting"),
) )
value = models.TextField( value = EncryptedTextField(
verbose_name=_("Setting Value"), verbose_name=_("Setting Value"),
help_text=_("Value for the setting"), help_text=_("Value for the setting"),
) )
class Meta: class Meta:
verbose_name = _("Setting") verbose_name = _("Setting")

View File

@ -142,22 +142,14 @@ def create_default_stages(sender, instance, created, **kwargs):
if created: if created:
with transaction.atomic(): with transaction.atomic():
# Stage 1: Contact Information # Stage 1: Contact Information
contact_stage = FormStage.objects.create( resume_upload = FormStage.objects.create(
template=instance, template=instance,
name="Contact Information", name="Resume Upload",
order=0, order=0,
is_predefined=True, is_predefined=True,
) )
FormField.objects.create( FormField.objects.create(
stage=contact_stage, stage=resume_upload,
label="GPA",
field_type="text",
required=False,
order=1,
is_predefined=True,
)
FormField.objects.create(
stage=contact_stage,
label="Resume Upload", label="Resume Upload",
field_type="file", field_type="file",
required=True, required=True,

View File

@ -27,6 +27,8 @@ from django.template.loader import render_to_string
from .models import BulkInterviewTemplate, Interview, Message, ScheduledInterview from .models import BulkInterviewTemplate, Interview, Message, ScheduledInterview
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .utils import get_setting from .utils import get_setting
from pypdf import PdfReader
User = get_user_model() User = get_user_model()
# Add python-docx import for Word document processing # Add python-docx import for Word document processing
@ -227,6 +229,10 @@ def format_job_description(pk):
def ai_handler(prompt): def ai_handler(prompt):
print("model call") print("model call")
OPENROUTER_API_URL = get_setting("OPENROUTER_API_URL")
OPENROUTER_API_KEY = get_setting("OPENROUTER_API_KEY")
OPENROUTER_MODEL = get_setting("OPENROUTER_MODEL")
print(OPENROUTER_MODEL)
response = requests.post( response = requests.post(
url=OPENROUTER_API_URL, url=OPENROUTER_API_URL,
headers={ headers={
@ -722,22 +728,26 @@ def create_interview_and_meeting(schedule_id):
try: try:
schedule = ScheduledInterview.objects.get(pk=schedule_id) schedule = ScheduledInterview.objects.get(pk=schedule_id)
interview = schedule.interview interview = schedule.interview
result = create_zoom_meeting(
interview.topic, interview.start_time, interview.duration logger.info(f"Processing schedule {schedule_id} with interview {interview.id}")
) logger.info(f"Interview topic: {interview.topic}")
logger.info(f"Interview start_time: {interview.start_time}")
logger.info(f"Interview duration: {interview.duration}")
result = create_zoom_meeting(interview.topic, interview.start_time, interview.duration)
if result["status"] == "success": if result["status"] == "success":
interview.meeting_id = result["meeting_details"]["meeting_id"] interview.meeting_id = result["meeting_details"]["meeting_id"]
interview.details_url = result["meeting_details"]["join_url"] interview.join_url = result["meeting_details"]["join_url"]
interview.zoom_gateway_response = result["zoom_gateway_response"]
interview.host_email = result["meeting_details"]["host_email"] interview.host_email = result["meeting_details"]["host_email"]
interview.password = result["meeting_details"]["password"] interview.password = result["meeting_details"]["password"]
interview.zoom_gateway_response = result["zoom_gateway_response"]
interview.save() interview.save()
logger.info(f"Successfully scheduled interview for {Application.name}") logger.info(f"Successfully scheduled interview for {schedule.application.name}")
return True return True
else: else:
# Handle Zoom API failure (e.g., log it or notify administrator) # Handle Zoom API failure (e.g., log it or notify administrator)
logger.error(f"Zoom API failed for {Application.name}: {result['message']}") logger.error(f"Zoom API failed for {schedule.application.name}: {result['message']}")
return False # Task failed return False # Task failed
except Exception as e: except Exception as e:
@ -745,7 +755,6 @@ def create_interview_and_meeting(schedule_id):
logger.error(f"Critical error scheduling interview: {e}") logger.error(f"Critical error scheduling interview: {e}")
return False # Task failed return False # Task failed
def handle_zoom_webhook_event(payload): def handle_zoom_webhook_event(payload):
""" """
Background task to process a Zoom webhook event and update the local ZoomMeeting status. Background task to process a Zoom webhook event and update the local ZoomMeeting status.
@ -754,32 +763,20 @@ def handle_zoom_webhook_event(payload):
event_type = payload.get("event") event_type = payload.get("event")
object_data = payload["payload"]["object"] object_data = payload["payload"]["object"]
# Zoom often uses a long 'id' for the scheduled meeting and sometimes a 'uuid'. meeting_id = str(object_data.get("id"))
# We rely on the unique 'id' that maps to your ZoomMeeting.meeting_id field. if not meeting_id:
meeting_id_zoom = str(object_data.get("id"))
if not meeting_id_zoom:
logger.warning(f"Webhook received without a valid Meeting ID: {event_type}") logger.warning(f"Webhook received without a valid Meeting ID: {event_type}")
return False return False
try: try:
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet, meeting_instance = Interview.objects.filter(meeting_id=meeting_id).first()
# and to simplify the logic flow.
meeting_instance = "" # TODO:update #ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first()
print(meeting_instance)
# --- 1. Creation and Update Events ---
if event_type == "meeting.updated": if event_type == "meeting.updated":
logger.info(f"Zoom meeting updated: {meeting_id}")
if meeting_instance: if meeting_instance:
# Update key fields from the webhook payload # Update key fields from the webhook payload
meeting_instance.topic = object_data.get( meeting_instance.topic = object_data.get(
"topic", meeting_instance.topic "topic", meeting_instance.topic
) )
# Check for and update status and time details
# if event_type == 'meeting.created':
# meeting_instance.status = 'scheduled'
# elif event_type == 'meeting.updated':
# Only update time fields if they are in the payload
print(object_data)
meeting_instance.start_time = object_data.get( meeting_instance.start_time = object_data.get(
"start_time", meeting_instance.start_time "start_time", meeting_instance.start_time
) )
@ -789,7 +786,6 @@ def handle_zoom_webhook_event(payload):
meeting_instance.timezone = object_data.get( meeting_instance.timezone = object_data.get(
"timezone", meeting_instance.timezone "timezone", meeting_instance.timezone
) )
meeting_instance.status = object_data.get( meeting_instance.status = object_data.get(
"status", meeting_instance.status "status", meeting_instance.status
) )
@ -804,31 +800,19 @@ def handle_zoom_webhook_event(payload):
] ]
) )
# --- 2. Status Change Events (Start/End) ---
elif event_type == "meeting.started":
if meeting_instance:
meeting_instance.status = "started"
meeting_instance.save(update_fields=["status"])
elif event_type == "meeting.ended":
if meeting_instance:
meeting_instance.status = "ended"
meeting_instance.save(update_fields=["status"])
# --- 3. Deletion Event (User Action) --- # --- 3. Deletion Event (User Action) ---
elif event_type == "meeting.deleted": elif event_type in ["meeting.started","meeting.ended","meeting.deleted"]:
if meeting_instance: if meeting_instance:
try: try:
meeting_instance.status = "cancelled" meeting_instance.status = event_type.split(".")[-1]
meeting_instance.save(update_fields=["status"]) meeting_instance.save(update_fields=["status"])
except Exception as e: except Exception as e:
logger.error(f"Failed to mark Zoom meeting as cancelled: {e}") logger.error(f"Failed to mark Zoom meeting as cancelled: {e}")
return True return True
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Failed to process Zoom webhook for {event_type} (ID: {meeting_id_zoom}): {e}", f"Failed to process Zoom webhook for {event_type} (ID: {meeting_id}): {e}",
exc_info=True, exc_info=True,
) )
return False return False
@ -1565,6 +1549,133 @@ def send_email_task(
"message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}." "message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}."
}) })
def generate_interview_questions(schedule_id: int) -> dict:
"""
Generate AI-powered interview questions based on job requirements and candidate profile.
Args:
schedule_id (int): The ID of the scheduled interview
Returns:
dict: Result containing status and generated questions or error message
"""
from .models import ScheduledInterview
try:
# Get the scheduled interview with related data
schedule = ScheduledInterview.objects.get(pk=schedule_id)
application = schedule.application
job = schedule.job
logger.info(f"Generating interview questions for schedule {schedule_id}")
# Prepare context for AI
job_description = job.description or ""
job_qualifications = job.qualifications or ""
candidate_resume_text = ""
# Extract candidate resume text if available and parsed
if application.ai_analysis_data:
resume_data_en = application.ai_analysis_data.get('resume_data_en', {})
candidate_resume_text = f"""
Candidate Name: {resume_data_en.get('full_name', 'N/A')}
Current Title: {resume_data_en.get('current_title', 'N/A')}
Summary: {resume_data_en.get('summary', 'N/A')}
Skills: {resume_data_en.get('skills', {})}
Experience: {resume_data_en.get('experience', [])}
Education: {resume_data_en.get('education', [])}
"""
# Create the AI prompt
prompt = f"""
You are an expert technical interviewer and hiring manager. Generate relevant interview questions based on the following information:
JOB INFORMATION:
Job Title: {job.title}
Department: {job.department}
Job Description: {job_description}
Qualifications: {job_qualifications}
CANDIDATE PROFILE:
{candidate_resume_text}
TASK:
Generate 8-10 interview questions in english and arabic that are:
1. Technical questions related to the job requirements
2. Behavioral questions to assess soft skills and cultural fit
3. Situational questions to evaluate problem-solving abilities
4. Questions should be appropriate for the candidate's experience level
For each question, specify:
- Type: "technical", "behavioral", or "situational"
- Difficulty: "easy", "medium", or "hard"
- Category: A brief category name (e.g., "Python Programming", "Team Collaboration", "Problem Solving")
- Question: The actual interview question
OUTPUT FORMAT:
Return a JSON object with the following structure:
{{
"questions": {{
"en":[
{{
"question_text": "The actual question text",
"question_type": "technical|behavioral|situational",
"difficulty_level": "easy|medium|hard",
"category": "Category name"
}}
],
"ar":[
{{
"question_text": "The actual question text",
"question_type": "technical|behavioral|situational",
"difficulty_level": "easy|medium|hard",
"category": "Category name"
}}
]}}
}}
Make questions specific to the job requirements and candidate background. Avoid generic questions.
Output only valid JSON no markdown, no extra text.
"""
# Call AI handler
result = ai_handler(prompt)
if result["status"] == "error":
logger.error(f"AI handler returned error for interview questions: {result['data']}")
return {"status": "error", "message": "Failed to generate questions"}
# Parse AI response
data = result["data"]
if isinstance(data, str):
data = json.loads(data)
questions = data.get("questions", [])
if not questions:
return {"status": "error", "message": "No questions generated"}
schedule.interview_questions.update(questions)
schedule.save(update_fields=["interview_questions"])
logger.info(f"Successfully generated questions for schedule {schedule_id}")
return {
"status": "success",
"message": f"Generated interview questions"
}
except ScheduledInterview.DoesNotExist:
error_msg = f"Scheduled interview with ID {schedule_id} not found"
logger.error(error_msg)
return {"status": "error", "message": error_msg}
except Exception as e:
error_msg = f"Error generating interview questions: {str(e)}"
logger.error(error_msg, exc_info=True)
return {"status": "error", "message": error_msg}
# def send_single_email_task( # def send_single_email_task(
# recipient_emails, # recipient_emails,
# subject: str, # subject: str,
@ -1576,22 +1687,22 @@ def send_email_task(
# """ # """
# from .services.email_service import EmailService # from .services.email_service import EmailService
# if not recipient_emails: # # if not recipient_emails:
# return json.dumps({"status": "error", "message": "No recipients provided."}) # # return json.dumps({"status": "error", "message": "No recipients provided."})
# service = EmailService() # # service = EmailService()
# # Execute the bulk sending method # # # Execute the bulk sending method
# processed_count = service.send_bulk_email( # # processed_count = service.send_bulk_email(
# recipient_emails=recipient_emails, # # recipient_emails=recipient_emails,
# subject=subject, # # subject=subject,
# template_name=template_name, # # template_name=template_name,
# context=context, # # context=context,
# ) # # )
# # The return value is stored in the result object for monitoring # # The return value is stored in the result object for monitoring
# return json.dumps({ # return json.dumps({
# "status": "success", # "status": "success",
# "count": processed_count, # "count": processed_count,
# "message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}." # "message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}."
# }) # })

View File

@ -82,6 +82,7 @@ urlpatterns = [
# Interview CRUD Operations # Interview CRUD Operations
path("interviews/", views.interview_list, name="interview_list"), path("interviews/", views.interview_list, name="interview_list"),
path("interviews/<slug:slug>/", views.interview_detail, name="interview_detail"), path("interviews/<slug:slug>/", views.interview_detail, name="interview_detail"),
path("interviews/<slug:slug>/generate-ai-questions/", views.generate_ai_questions, name="generate_ai_questions"),
path("interviews/<slug:slug>/update_interview_status", views.update_interview_status, name="update_interview_status"), path("interviews/<slug:slug>/update_interview_status", views.update_interview_status, name="update_interview_status"),
path("interviews/<slug:slug>/update_interview_result", views.update_interview_result, name="update_interview_result"), path("interviews/<slug:slug>/update_interview_result", views.update_interview_result, name="update_interview_result"),

View File

@ -4,12 +4,9 @@ Utility functions for recruitment app
from recruitment import models from recruitment import models
from django.conf import settings from django.conf import settings
from datetime import datetime, timedelta, time, date from datetime import datetime, timedelta
from django.utils import timezone from django.utils import timezone
from .models import ScheduledInterview from .models import ScheduledInterview
from django.template.loader import render_to_string
from django.core.mail import send_mail
import random
import os import os
import json import json
import logging import logging
@ -417,12 +414,15 @@ def create_zoom_meeting(topic, start_time, duration):
try: try:
access_token = get_access_token() access_token = get_access_token()
zoom_start_time = start_time.strftime("%Y-%m-%dT%H:%M:%S")
logger.info(zoom_start_time)
meeting_details = { meeting_details = {
"topic": topic, "topic": topic,
"type": 2, "type": 2,
"start_time": start_time.isoformat() + "Z", "start_time": zoom_start_time,
"duration": duration, "duration": duration,
"timezone": "UTC", "timezone": "Asia/Riyadh",
"settings": { "settings": {
"host_video": True, "host_video": True,
"participant_video": True, "participant_video": True,
@ -440,7 +440,7 @@ def create_zoom_meeting(topic, start_time, duration):
"Content-Type": "application/json", "Content-Type": "application/json",
} }
ZOOM_MEETING_URL = get_setting("ZOOM_MEETING_URL") ZOOM_MEETING_URL = get_setting("ZOOM_MEETING_URL")
print(ZOOM_MEETING_URL)
response = requests.post( response = requests.post(
ZOOM_MEETING_URL, headers=headers, json=meeting_details ZOOM_MEETING_URL, headers=headers, json=meeting_details
) )
@ -448,6 +448,7 @@ def create_zoom_meeting(topic, start_time, duration):
# Check response status # Check response status
if response.status_code == 201: if response.status_code == 201:
meeting_data = response.json() meeting_data = response.json()
logger.info(meeting_data)
return { return {
"status": "success", "status": "success",
"message": "Meeting created successfully.", "message": "Meeting created successfully.",
@ -869,7 +870,7 @@ def update_meeting(instance, updated_data):
instance.topic = zoom_details.get("topic", instance.topic) instance.topic = zoom_details.get("topic", instance.topic)
instance.duration = zoom_details.get("duration", instance.duration) instance.duration = zoom_details.get("duration", instance.duration)
instance.details_url = zoom_details.get("join_url", instance.details_url) instance.join_url = zoom_details.get("join_url", instance.join_url)
instance.password = zoom_details.get("password", instance.password) instance.password = zoom_details.get("password", instance.password)
instance.status = zoom_details.get("status") instance.status = zoom_details.get("status")

View File

@ -184,6 +184,7 @@ class PersonListView(StaffRequiredMixin, ListView, LoginRequiredMixin):
model = Person model = Person
template_name = "people/person_list.html" template_name = "people/person_list.html"
context_object_name = "people_list" context_object_name = "people_list"
paginate_by=100
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset().select_related("user") queryset = super().get_queryset().select_related("user")
@ -1605,7 +1606,6 @@ def _handle_preview_submission(request, slug, job):
""" """
SESSION_DATA_KEY = "interview_schedule_data" SESSION_DATA_KEY = "interview_schedule_data"
form = BulkInterviewTemplateForm(slug, request.POST) form = BulkInterviewTemplateForm(slug, request.POST)
# break_formset = BreakTimeFormSet(request.POST,prefix='breaktime')
if form.is_valid(): if form.is_valid():
# Get the form data # Get the form data
@ -1622,7 +1622,6 @@ def _handle_preview_submission(request, slug, job):
schedule_interview_type = form.cleaned_data["schedule_interview_type"] schedule_interview_type = form.cleaned_data["schedule_interview_type"]
physical_address = form.cleaned_data["physical_address"] physical_address = form.cleaned_data["physical_address"]
# Create a temporary schedule object (not saved to DB)
temp_schedule = BulkInterviewTemplate( temp_schedule = BulkInterviewTemplate(
job=job, job=job,
start_date=start_date, start_date=start_date,
@ -1640,7 +1639,6 @@ def _handle_preview_submission(request, slug, job):
# Get available slots (temp_breaks logic moved into get_available_time_slots if needed) # Get available slots (temp_breaks logic moved into get_available_time_slots if needed)
available_slots = get_available_time_slots(temp_schedule) available_slots = get_available_time_slots(temp_schedule)
if len(available_slots) < len(applications): if len(available_slots) < len(applications):
messages.error( messages.error(
request, request,
@ -1760,76 +1758,46 @@ def _handle_confirm_schedule(request, slug, job):
schedule.applications.set(applications) schedule.applications.set(applications)
available_slots = get_available_time_slots(schedule) available_slots = get_available_time_slots(schedule)
if schedule_data.get("schedule_interview_type") == "Remote": for i, application in enumerate(applications):
queued_count = 0 if i >= len(available_slots):
for i, application in enumerate(applications): continue
if i < len(available_slots):
slot = available_slots[i]
# schedule=ScheduledInterview.objects.create(application=application,job=job)
async_task(
"recruitment.tasks.create_interview_and_meeting",
application.pk,
job.pk,
schedule.pk,
slot["date"],
slot["time"],
schedule.interview_duration,
)
queued_count += 1
messages.success( slot = available_slots[i]
request,
f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!", # start_dt = datetime.combine(slot["date"], slot["time"])
start_time = timezone.make_aware(datetime.combine(slot["date"], slot["time"]))
logger.info(f"Creating interview for {application.person.full_name} at {start_time}")
interview = Interview.objects.create(
topic=schedule.topic,
start_time=start_time,
duration=schedule.interview_duration,
location_type="Onsite",
physical_address=schedule.physical_address,
) )
if SESSION_DATA_KEY in request.session: scheduled = ScheduledInterview.objects.create(
del request.session[SESSION_DATA_KEY] application=application,
if SESSION_ID_KEY in request.session: job=job,
del request.session[SESSION_ID_KEY] schedule=schedule,
interview_date=slot["date"],
interview_time=slot["time"],
interview=interview,
)
return redirect("applications_interview_view", slug=slug) if schedule_data.get("schedule_interview_type") == "Remote":
interview.location_type = "Remote"
interview.save(update_fields=["location_type"])
async_task("recruitment.tasks.create_interview_and_meeting",scheduled.pk)
elif schedule_data.get("schedule_interview_type") == "Onsite": messages.success(request,f"Schedule successfully created.")
try:
for i, application in enumerate(applications):
if i < len(available_slots):
slot = available_slots[i]
start_dt = datetime.combine(slot["date"], schedule.start_time) if SESSION_DATA_KEY in request.session:
del request.session[SESSION_DATA_KEY]
if SESSION_ID_KEY in request.session:
del request.session[SESSION_ID_KEY]
interview = Interview.objects.create( return redirect("applications_interview_view", slug=slug)
topic=schedule.topic,
start_time=start_dt,
duration=schedule.interview_duration,
location_type="Onsite",
physical_address=schedule.physical_address,
)
# 2. Create the ScheduledInterview, linking the unique location
ScheduledInterview.objects.create(
application=application,
job=job,
schedule=schedule,
interview_date=slot["date"],
interview_time=slot["time"],
interview=interview,
)
messages.success(
request, f"created successfully for {len(applications)} application."
)
# Clear session data keys upon successful completion
if SESSION_DATA_KEY in request.session:
del request.session[SESSION_DATA_KEY]
if SESSION_ID_KEY in request.session:
del request.session[SESSION_ID_KEY]
return redirect("applications_interview_view", slug=slug)
except Exception as e:
messages.error(request, f"Error creating onsite interviews: {e}")
return redirect("schedule_interviews", slug=slug)
@login_required @login_required
@ -1837,13 +1805,9 @@ def _handle_confirm_schedule(request, slug, job):
def schedule_interviews_view(request, slug): def schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug) job = get_object_or_404(JobPosting, slug=slug)
if request.method == "POST": if request.method == "POST":
# return _handle_confirm_schedule(request, slug, job)
return _handle_preview_submission(request, slug, job) return _handle_preview_submission(request, slug, job)
else: else:
# if request.session.get("interview_schedule_data"):
print(request.session.get("interview_schedule_data"))
return _handle_get_request(request, slug, job) return _handle_get_request(request, slug, job)
# return redirect("applications_interview_view", slug=slug)
@login_required @login_required
@ -2143,7 +2107,6 @@ def reschedule_meeting_for_application(request, slug):
if request.method == "POST": if request.method == "POST":
if interview.location_type == "Remote": if interview.location_type == "Remote":
form = ScheduledInterviewForm(request.POST) form = ScheduledInterviewForm(request.POST)
else: else:
form = OnsiteScheduleInterviewUpdateForm(request.POST) form = OnsiteScheduleInterviewUpdateForm(request.POST)
@ -2156,7 +2119,7 @@ def reschedule_meeting_for_application(request, slug):
if interview.location_type == "Remote": if interview.location_type == "Remote":
updated_data = { updated_data = {
"topic": topic, "topic": topic,
"start_time": start_time.isoformat() + "Z", "start_time": start_time.strftime("%Y-%m-%dT%H:%M:%S"),
"duration": duration, "duration": duration,
} }
result = update_meeting(schedule.interview, updated_data) result = update_meeting(schedule.interview, updated_data)
@ -2537,13 +2500,15 @@ def account_toggle_status(request, pk):
@csrf_exempt @csrf_exempt
def zoom_webhook_view(request): def zoom_webhook_view(request):
from .utils import get_setting
api_key = request.headers.get("X-Zoom-API-KEY") api_key = request.headers.get("X-Zoom-API-KEY")
if api_key != settings.ZOOM_WEBHOOK_API_KEY: if api_key != get_setting("ZOOM_WEBHOOK_API_KEY"):
return HttpResponse(status=405) return HttpResponse(status=405)
if request.method == "POST": if request.method == "POST":
try: try:
payload = json.loads(request.body) payload = json.loads(request.body)
logger.info(payload)
async_task("recruitment.tasks.handle_zoom_webhook_event", payload) async_task("recruitment.tasks.handle_zoom_webhook_event", payload)
return HttpResponse(status=200) return HttpResponse(status=200)
except Exception: except Exception:
@ -2565,13 +2530,14 @@ def agency_list(request):
| Q(contact_person__icontains=search_query) | Q(contact_person__icontains=search_query)
| Q(email__icontains=search_query) | Q(email__icontains=search_query)
| Q(country__icontains=search_query) | Q(country__icontains=search_query)
| Q(phone=search_query)
) )
# Order by most recently created # Order by most recently created
agencies = agencies.order_by("-created_at") agencies = agencies.order_by("-created_at")
# Pagination # Pagination
paginator = Paginator(agencies, 10) # Show 10 agencies per page paginator = Paginator(agencies,20) # Show 10 agencies per page
page_number = request.GET.get("page") page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number) page_obj = paginator.get_page(page_number)
@ -3058,7 +3024,7 @@ def applicant_portal_dashboard(request):
# Get candidate's documents using the Person documents property # Get candidate's documents using the Person documents property
documents = applicant.documents.order_by("-created_at") documents = applicant.documents.order_by("-created_at")
print(documents) print(documents)
# Add password change form for modal # Add password change form for modal
@ -3682,11 +3648,11 @@ def message_create(request):
# from .services.email_service import UnifiedEmailService # from .services.email_service import UnifiedEmailService
# from .dto.email_dto import EmailConfig, EmailPriority # from .dto.email_dto import EmailConfig, EmailPriority
email_addresses = [message.recipient.email] email_addresses = [message.recipient.email]
subject=message.subject subject=message.subject
email_result=async_task( email_result=async_task(
"recruitment.tasks.send_email_task", "recruitment.tasks.send_email_task",
email_addresses, email_addresses,
@ -3700,7 +3666,7 @@ def message_create(request):
}, },
) )
# Send email using unified service # Send email using unified service
if email_result: if email_result:
messages.success( messages.success(
request, "Message sent successfully via email!" request, "Message sent successfully via email!"
@ -3755,7 +3721,7 @@ def message_create(request):
and "HX-Request" in request.headers and "HX-Request" in request.headers
and request.user.user_type in ["candidate", "agency"] and request.user.user_type in ["candidate", "agency"]
): ):
job_id = request.GET.get("job") job_id = request.GET.get("job")
if job_id: if job_id:
job = get_object_or_404(JobPosting, id=job_id) job = get_object_or_404(JobPosting, id=job_id)
@ -4288,9 +4254,11 @@ def update_interview_result(request,slug):
interview = get_object_or_404(Interview,slug=slug) interview = get_object_or_404(Interview,slug=slug)
schedule=interview.scheduled_interview schedule=interview.scheduled_interview
form = InterviewResultForm(request.POST, instance=interview) form = InterviewResultForm(request.POST, instance=interview)
if form.is_valid(): if form.is_valid():
interview.save(update_fields=['interview_result', 'result_comments'])
form.save() # Saves form data form.save() # Saves form data
messages.success(request, _(f"Interview result updated successfully to {interview.interview_result}.")) messages.success(request, _(f"Interview result updated successfully to {interview.interview_result}."))
@ -4453,7 +4421,7 @@ def api_application_detail(request, candidate_id):
# subject = form.cleaned_data.get("subject") # subject = form.cleaned_data.get("subject")
# message = form.get_formatted_message() # message = form.get_formatted_message()
# async_task( # async_task(
# "recruitment.tasks.send_bulk_email_task", # "recruitment.tasks.send_bulk_email_task",
# email_addresses, # email_addresses,
@ -4468,7 +4436,7 @@ def api_application_detail(request, candidate_id):
# }, # },
# ) # )
# return redirect(request.path) # return redirect(request.path)
# else: # else:
# # Form validation errors # # Form validation errors
@ -4508,7 +4476,7 @@ def source_list(request):
"""List all sources with search and pagination""" """List all sources with search and pagination"""
search_query = request.GET.get("q", "") search_query = request.GET.get("q", "")
sources = Source.objects.all() sources = Source.objects.all()
if search_query: if search_query:
sources = sources.filter( sources = sources.filter(
Q(name__icontains=search_query) Q(name__icontains=search_query)
@ -4520,7 +4488,7 @@ def source_list(request):
sources = sources.order_by("-created_at") sources = sources.order_by("-created_at")
# Pagination # Pagination
paginator = Paginator(sources, 15) # Show 15 sources per page paginator = Paginator(sources, 1) # Show 15 sources per page
page_number = request.GET.get("page") page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number) page_obj = paginator.get_page(page_number)
@ -4754,7 +4722,7 @@ def application_signup(request, slug):
@login_required @login_required
@staff_user_required @staff_user_required
def interview_list(request): def interview_list(request):
"""List all interviews with filtering and pagination""" """List all interviews with filtering and pagination"""
interviews = ScheduledInterview.objects.select_related( interviews = ScheduledInterview.objects.select_related(
"application", "application",
@ -4795,12 +4763,63 @@ def interview_list(request):
"status_filter": status_filter, "status_filter": status_filter,
"job_filter": job_filter, "job_filter": job_filter,
"search_query": search_query, "search_query": search_query,
"interviews": interviews, "interviews": page_obj,
"jobs": jobs, "jobs": jobs,
} }
return render(request, "interviews/interview_list.html", context) return render(request, "interviews/interview_list.html", context)
@login_required
@staff_user_required
def generate_ai_questions(request, slug):
"""Generate AI-powered interview questions for a scheduled interview"""
from django_q.tasks import async_task
schedule = get_object_or_404(ScheduledInterview, slug=slug)
if request.method == "POST":
# Queue the AI question generation task
task_id = async_task(
"recruitment.tasks.generate_interview_questions",
schedule.id,
sync=True
)
# if request.headers.get("X-Requested-With") == "XMLHttpRequest":
# return JsonResponse({
# "status": "success",
# "message": "AI question generation started in background",
# "task_id": task_id
# })
# else:
# messages.success(
# request,
# "AI question generation started. Questions will appear shortly."
# )
# return redirect("interview_detail", slug=slug)
# # For GET requests, return existing questions if any
# questions = schedule.ai_questions.all().order_by("created_at")
# if request.headers.get("X-Requested-With") == "XMLHttpRequest":
# return JsonResponse({
# "status": "success",
# "questions": [
# {
# "id": q.id,
# "text": q.question_text,
# "type": q.question_type,
# "difficulty": q.difficulty_level,
# "category": q.category,
# "created_at": q.created_at.isoformat()
# }
# for q in questions
# ]
# })
return redirect("interview_detail", slug=slug)
@login_required @login_required
@staff_user_required @staff_user_required
def interview_detail(request, slug): def interview_detail(request, slug):
@ -4809,15 +4828,11 @@ def interview_detail(request, slug):
ScheduledInterviewUpdateStatusForm, ScheduledInterviewUpdateStatusForm,
OnsiteScheduleInterviewUpdateForm, OnsiteScheduleInterviewUpdateForm,
) )
schedule = get_object_or_404(ScheduledInterview, slug=slug) schedule = get_object_or_404(ScheduledInterview, slug=slug)
interview = schedule.interview interview = schedule.interview
interview_result_form=InterviewResultForm(instance=interview) interview_result_form=InterviewResultForm(instance=interview)
application = schedule.application application = schedule.application
job = schedule.job job = schedule.job
print(interview.location_type)
if interview.location_type == "Remote": if interview.location_type == "Remote":
reschedule_form = ScheduledInterviewForm() reschedule_form = ScheduledInterviewForm()
else: else:
@ -6462,7 +6477,7 @@ def sync_history(request, job_slug=None):
# sender_user = request.user # sender_user = request.user
# job = job # job = job
# try: # try:
# # Send email using background task # # Send email using background task
# email_result= async_task( # email_result= async_task(
# "recruitment.tasks.send_bulk_email_task", # "recruitment.tasks.send_bulk_email_task",
@ -6502,18 +6517,18 @@ def sync_history(request, job_slug=None):
def send_interview_email(request, slug): def send_interview_email(request, slug):
from django.conf import settings from django.conf import settings
from django_q.tasks import async_task from django_q.tasks import async_task
schedule = get_object_or_404(ScheduledInterview, slug=slug) schedule = get_object_or_404(ScheduledInterview, slug=slug)
application = schedule.application application = schedule.application
job = application.job job = application.job
if request.method == "POST": if request.method == "POST":
form = InterviewEmailForm(job, application, schedule, request.POST) form = InterviewEmailForm(job, application, schedule, request.POST)
if form.is_valid(): if form.is_valid():
# 1. Ensure recipient is a list (fixes the "@" error) # 1. Ensure recipient is a list (fixes the "@" error)
recipient_str = form.cleaned_data.get("to").strip() recipient_str = form.cleaned_data.get("to").strip()
recipient_list = [recipient_str] recipient_list = [recipient_str]
body_message = form.cleaned_data.get("message") body_message = form.cleaned_data.get("message")
subject = form.cleaned_data.get("subject") subject = form.cleaned_data.get("subject")
@ -6534,7 +6549,7 @@ def send_interview_email(request, slug):
"logo_url": settings.STATIC_URL + "image/kaauh.png", "logo_url": settings.STATIC_URL + "image/kaauh.png",
}, },
) )
messages.success(request, "Interview email enqueued successfully!") messages.success(request, "Interview email enqueued successfully!")
return redirect("interview_detail", slug=schedule.slug) return redirect("interview_detail", slug=schedule.slug)
@ -6546,14 +6561,14 @@ def send_interview_email(request, slug):
# GET request # GET request
form = InterviewEmailForm(job, application, schedule) form = InterviewEmailForm(job, application, schedule)
# 3. FIX: Instead of always redirecting, render the template # 3. FIX: Instead of always redirecting, render the template
# This allows users to see validation errors. # This allows users to see validation errors.
return render( return render(
request, request,
"recruitment/interview_email_form.html", # Replace with your actual template path "recruitment/interview_email_form.html", # Replace with your actual template path
{ {
"form": form, "form": form,
"schedule": schedule, "schedule": schedule,
"job": job "job": job
} }
) )
@ -6581,7 +6596,10 @@ def compose_application_email(request, slug):
if not email_addresses: if not email_addresses:
messages.error(request, "No email selected") messages.error(request, "No email selected")
referer = request.META.get("HTTP_REFERER") referer = request.META.get("HTTP_REFERER")
if "HX-Request" in request.headers:
response = HttpResponse()
response.headers["HX-Refresh"] = "true"
return response
if referer: if referer:
# Redirect back to the referring page # Redirect back to the referring page
return redirect(referer) return redirect(referer)
@ -6591,7 +6609,7 @@ def compose_application_email(request, slug):
subject = form.cleaned_data.get("subject") subject = form.cleaned_data.get("subject")
message = form.get_formatted_message() message = form.get_formatted_message()
async_task( async_task(
"recruitment.tasks.send_email_task", "recruitment.tasks.send_email_task",
email_addresses, email_addresses,
@ -6608,8 +6626,11 @@ def compose_application_email(request, slug):
}, },
) )
if "HX-Request" in request.headers:
response = HttpResponse()
response.headers["HX-Refresh"] = "true"
return response
return redirect(request.path) return redirect(request.path)
else: else:
# Form validation errors # Form validation errors
@ -6617,12 +6638,9 @@ def compose_application_email(request, slug):
# For HTMX requests, return error response # For HTMX requests, return error response
if "HX-Request" in request.headers: if "HX-Request" in request.headers:
return JsonResponse( response = HttpResponse()
{ response.headers["HX-Refresh"] = "true"
"success": False, return response
"error": "Please correct the form errors and try again.",
}
)
return render( return render(
request, request,

View File

@ -23,7 +23,7 @@ class SourceListView(LoginRequiredMixin, UserPassesTestMixin, ListView):
queryset = super().get_queryset().order_by('name') queryset = super().get_queryset().order_by('name')
# Search functionality # Search functionality
search_query = self.request.GET.get('search', '') search_query = self.request.GET.get('q', '')
if search_query: if search_query:
queryset = queryset.filter( queryset = queryset.filter(
models.Q(name__icontains=search_query) | models.Q(name__icontains=search_query) |

View File

@ -1,6 +1,7 @@
import requests import requests
import jwt import jwt
import time import time
from datetime import timezone
from .utils import get_zoom_config from .utils import get_zoom_config
@ -22,13 +23,30 @@ def create_zoom_meeting(topic, start_time, duration, host_email):
'Authorization': f'Bearer {jwt_token}', 'Authorization': f'Bearer {jwt_token}',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
# Format start_time according to Zoom API requirements
# Convert datetime to ISO 8601 format with Z suffix for UTC
if hasattr(start_time, 'isoformat'):
# If it's a datetime object, format it properly
if hasattr(start_time, 'tzinfo') and start_time.tzinfo is not None:
# Timezone-aware datetime: convert to UTC and format with Z suffix
utc_time = start_time.astimezone(timezone.utc)
zoom_start_time = utc_time.strftime("%Y-%m-%dT%H:%M:%S") + "Z"
else:
# Naive datetime: assume it's in UTC and format with Z suffix
zoom_start_time = start_time.strftime("%Y-%m-%dT%H:%M:%S") + "Z"
else:
# If it's already a string, use as-is (assuming it's properly formatted)
zoom_start_time = str(start_time)
data = { data = {
"topic": topic, "topic": topic,
"type": 2, "type": 2,
"start_time": start_time, "start_time": zoom_start_time,
"duration": duration, "duration": duration,
"schedule_for": host_email, "schedule_for": host_email,
"settings": {"join_before_host": True} "settings": {"join_before_host": True},
"timezone": "UTC" # Explicitly set timezone to UTC
} }
url = f"https://api.zoom.us/v2/users/{host_email}/meetings" url = f"https://api.zoom.us/v2/users/{host_email}/meetings"
return requests.post(url, json=data, headers=headers) return requests.post(url, json=data, headers=headers)

View File

@ -459,7 +459,7 @@
}); });
} }
//form_loader(); form_loader();
try{ try{
document.body.addEventListener('htmx:afterRequest', function(evt) { document.body.addEventListener('htmx:afterRequest', function(evt) {

View File

@ -192,6 +192,127 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
/* AI Questions Styling */
.ai-question-item {
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1rem;
position: relative;
transition: all 0.3s ease;
}
.ai-question-item:hover {
box-shadow: 0 6px 16px rgba(0,0,0,0.08);
transform: translateY(-2px);
}
.ai-question-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.ai-question-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.ai-question-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-technical {
background-color: #e3f2fd;
color: #1976d2;
}
.badge-behavioral {
background-color: #f3e5f5;
color: #7b1fa2;
}
.badge-situational {
background-color: #e8f5e8;
color: #388e3c;
}
.badge-easy {
background-color: #e8f5e8;
color: #2e7d32;
}
.badge-medium {
background-color: #fff3e0;
color: #f57c00;
}
.badge-hard {
background-color: #ffebee;
color: #c62828;
}
.ai-question-text {
font-size: 1rem;
line-height: 1.6;
color: var(--kaauh-primary-text);
margin-bottom: 0.75rem;
font-weight: 500;
}
.ai-question-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
color: #6c757d;
border-top: 1px solid #e9ecef;
padding-top: 0.5rem;
}
.ai-question-category {
display: flex;
align-items: center;
gap: 0.25rem;
}
.ai-question-category i {
color: var(--kaauh-teal);
}
.ai-question-actions {
display: flex;
gap: 0.5rem;
}
.ai-question-actions button {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
border-radius: 0.25rem;
border: 1px solid var(--kaauh-border);
background-color: white;
color: var(--kaauh-primary-text);
transition: all 0.2s ease;
}
.ai-question-actions button:hover {
background-color: var(--kaauh-teal);
color: white;
border-color: var(--kaauh-teal);
}
.ai-questions-empty {
text-align: center;
padding: 3rem 1rem;
color: #6c757d;
}
.ai-questions-empty i {
color: var(--kaauh-teal);
opacity: 0.6;
margin-bottom: 1rem;
}
.ai-questions-loading {
text-align: center;
padding: 2rem;
}
.htmx-indicator {
opacity: 0;
transition: opacity 200ms ease-in;
}
.htmx-request .htmx-indicator {
opacity: 1;
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.action-buttons { .action-buttons {
@ -200,6 +321,19 @@
.action-buttons .btn { .action-buttons .btn {
width: 100%; width: 100%;
} }
.ai-question-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.ai-question-badges {
width: 100%;
}
.ai-question-meta {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
} }
</style> </style>
{% endblock %} {% endblock %}
@ -292,7 +426,7 @@
<i class="fas fa-calendar-check me-2"></i> {% trans "Interview Details" %} <i class="fas fa-calendar-check me-2"></i> {% trans "Interview Details" %}
</h5> </h5>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<span class="bg-primary-theme badge status-badge text-white"> <span class="bg-primary-theme badge status-badge text-white">
{{interview.location_type}} {{interview.location_type}}
</span> </span>
@ -326,7 +460,7 @@
<span class="detail-label">{% trans "Status:" %}</span> <span class="detail-label">{% trans "Status:" %}</span>
<span class="detail-value"> <span class="detail-value">
<span class="badge bg-primary-theme"> <span class="badge bg-primary-theme">
{{ schedule.status }}</span> {{ interview.status }}</span>
</span> </span>
</div> </div>
</div> </div>
@ -348,9 +482,9 @@
<span class="detail-label">{% trans "Password:" %}</span> <span class="detail-label">{% trans "Password:" %}</span>
<span class="detail-value">{{ interview.password }}</span> <span class="detail-value">{{ interview.password }}</span>
</div> </div>
{% if interview.details_url %} {% if interview.join_url %}
<div class="mt-3"> <div class="mt-3">
<a href="{{ interview.zoommeetingdetails.details_url }}" <a href="{{ interview.join_url }}"
target="_blank" target="_blank"
class="btn btn-main-action btn-sm w-100"> class="btn btn-main-action btn-sm w-100">
<i class="fas fa-video me-1"></i> {% trans "Join Meeting" %} <i class="fas fa-video me-1"></i> {% trans "Join Meeting" %}
@ -378,6 +512,112 @@
</div> </div>
</div> </div>
<!-- AI Generated Questions Section -->
<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-brain me-2"></i> {% trans "AI Generated Questions" %}
</h5>
<div class="d-flex gap-2">
<form action="{% url 'generate_ai_questions' schedule.slug %}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-main-action btn-sm">
<span id="button-text-content">
<i class="fas fa-magic me-1"></i> {% trans "Generate Interview Questions" %}
</span>
</button>
</form>
</div>
</div>
<div class="accordion" id="aiQuestionsAccordion">
<div class="accordion-item">
<h2 class="accordion-header" id="aiQuestionsHeading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#aiQuestionsCollapse" aria-expanded="false" aria-controls="aiQuestionsCollapse">
<span class="accordion-icon"></span>
<span class="accordion-header-text">{% trans "Interview Questions" %}</span>
</button>
</h2>
<div id="aiQuestionsCollapse" class="accordion-collapse collapse show" aria-labelledby="aiQuestionsHeading">
<div class="accordion-body table-view">
<div class="table-responsive d-none d-lg-block">
<table class="table table-hover align-middle mb-0 ">
<thead>
<tr>
<th scope="col">{% trans "Question" %}</th>
<th scope="col">{% trans "Type" %}</th>
<th scope="col">{% trans "Difficulty" %}</th>
<th scope="col">{% trans "Category" %}</th>
</tr>
</thead>
<tbody>
{% if LANGUAGE_CODE == "ar" %}
{% for question in schedule.interview_questions.ar %}
<tr>
<td class="text-break">
<span class="d-block" style="font-size: 0.8rem; color: #757575">{{ question.question_text }}</span>
</td>
<td class="text-center">
<span class="badge rounded-pill bg-primary-theme">{{ question.question_type|capfirst }}</span>
</td>
<td class="text-center">
<span class="badge rounded-pill bg-primary-theme">{{ question.difficulty_level|capfirst }}</span>
</td>
<td class="text-center">
<span class="badge rounded-pill bg-primary-theme">{{ question.category|capfirst }}</span>
</td>
</tr>
{% endfor %}
{% else %}
{% for question in schedule.interview_questions.en %}
<tr>
<td class="text-break">
<span class="d-block" style="font-size: 0.8rem; color: #757575">{{ question.question_text }}</span>
</td>
<td class="text-center">
<span class="badge rounded-pill bg-primary-theme">{{ question.question_type|capfirst }}</span>
</td>
<td class="text-center">
<span class="badge rounded-pill bg-primary-theme">{{ question.difficulty_level|capfirst }}</span>
</td>
<td class="text-center">
<span class="badge rounded-pill bg-primary-theme">{{ question.category|capfirst }}</span>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Loading Spinners -->
{% comment %} <div class="text-center py-3" id="generateQuestionsSpinner" class="htmx-indicator d-none">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{% trans "Generating questions..." %}</span>
</div>
<p class="mt-2 text-muted">{% trans "AI is generating personalized interview questions..." %}</p>
</div>
<div class="text-center py-3" id="refreshQuestionsSpinner" class="htmx-indicator d-none">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">{% trans "Refreshing..." %}</span>
</div>
<p class="mt-2 text-muted">{% trans "Loading questions..." %}</p>
</div>
<!-- Questions Container -->
<div id="aiQuestionsContainer">
<div class="text-center py-4 text-muted">
<i class="fas fa-brain fa-2x mb-3"></i>
<p class="mb-0">{% trans "No AI questions generated yet. Click 'Generate Questions' to create personalized interview questions based on the candidate's profile and job requirements." %}</p>
</div>
</div> {% endcomment %}
</div>
<div class="kaauh-card shadow-sm p-4"> <div class="kaauh-card shadow-sm p-4">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;"> <h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;">
<i class="fas fa-history me-2"></i> {% trans "Interview Timeline" %} <i class="fas fa-history me-2"></i> {% trans "Interview Timeline" %}
@ -394,7 +634,7 @@
</div> </div>
</div> </div>
</div> </div>
{% if schedule.status == 'confirmed' %} {% if schedule.status == 'confirmed' %}
<div class="timeline-item"> <div class="timeline-item">
<div class="timeline-content"> <div class="timeline-content">
@ -403,7 +643,7 @@
<h6 class="mb-1">{% trans "Interview Confirmed" %}</h6> <h6 class="mb-1">{% trans "Interview Confirmed" %}</h6>
<p class="mb-0 text-muted">{% trans "Candidate has confirmed attendance" %}</p> <p class="mb-0 text-muted">{% trans "Candidate has confirmed attendance" %}</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@ -416,7 +656,7 @@
<h6 class="mb-1">{% trans "Interview Completed" %}</h6> <h6 class="mb-1">{% trans "Interview Completed" %}</h6>
<p class="mb-0 text-muted">{% trans "Interview has been completed" %}</p> <p class="mb-0 text-muted">{% trans "Interview has been completed" %}</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@ -429,7 +669,7 @@
<h6 class="mb-1">{% trans "Interview Cancelled" %}</h6> <h6 class="mb-1">{% trans "Interview Cancelled" %}</h6>
<p class="mb-0 text-muted">{% trans "Interview was cancelled on: " %}{{ schedule.cancelled_at|date:"d-m-Y" }} {{ schedule.cancelled_at|date:"h:i A" }}</p> <p class="mb-0 text-muted">{% trans "Interview was cancelled on: " %}{{ schedule.cancelled_at|date:"d-m-Y" }} {{ schedule.cancelled_at|date:"h:i A" }}</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@ -490,7 +730,7 @@
<i class="fas fa-user-plus me-1"></i> {% trans "Add Participants" %} <i class="fas fa-user-plus me-1"></i> {% trans "Add Participants" %}
</button> </button>
</div> {% endcomment %} </div> {% endcomment %}
<div class="kaauh-card shadow-sm p-4"> <div class="kaauh-card shadow-sm p-4">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;"> <h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;">
@ -499,13 +739,13 @@
<div class="action-buttons"> <div class="action-buttons">
{% if schedule.status != 'cancelled' and schedule.status != 'completed' %} {% if schedule.status != 'cancelled' and schedule.status != 'completed' %}
<button type="button" class="btn btn-main-action btn-sm" <button type="button" class="btn btn-main-action"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#rescheduleModal"> data-bs-target="#rescheduleModal">
<i class="fas fa-redo-alt me-1"></i> {% trans "Reschedule" %} <i class="fas fa-redo-alt me-1"></i> {% trans "Reschedule" %}
</button> </button>
<button type="button" class="btn btn-outline-danger btn-sm" <button type="button" class="btn btn-outline-danger"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#cancelModal"> data-bs-target="#cancelModal">
<i class="fas fa-times me-1"></i> {% trans "Cancel" %} <i class="fas fa-times me-1"></i> {% trans "Cancel" %}
@ -759,4 +999,4 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -15,6 +15,7 @@
--kaauh-info: #17a2b8; --kaauh-info: #17a2b8;
--kaauh-danger: #dc3545; --kaauh-danger: #dc3545;
--kaauh-warning: #ffc107; --kaauh-warning: #ffc107;
--kaauh-gray-light: #f8f9fa; /* Added for consistency */
} }
/* Primary Color Overrides */ /* Primary Color Overrides */
@ -93,14 +94,12 @@
} }
/* Column Widths */ /* Column Widths */
.interview-table thead th:nth-child(1) { width: 40px; } .interview-table thead th:nth-child(1) { width: 18%; }
.interview-table thead th:nth-child(2) { width: 15%; } .interview-table thead th:nth-child(2) { width: 15%; }
.interview-table thead th:nth-child(3) { width: 12%; } .interview-table thead th:nth-child(3) { width: 15%; }
.interview-table thead th:nth-child(4) { width: 12%; } .interview-table thead th:nth-child(4) { width: 10%; }
.interview-table thead th:nth-child(5) { width: 10%; } .interview-table thead th:nth-child(5) { width: 10%; }
.interview-table thead th:nth-child(6) { width: 8%; } .interview-table thead th:nth-child(6) { width: 10%; }
.interview-table thead th:nth-child(7) { width: 8%; }
.interview-table thead th:nth-child(8) { width: 15%; }
/* Candidate and Job Info */ /* Candidate and Job Info */
.candidate-name { .candidate-name {
@ -130,14 +129,6 @@
font-weight: 600; 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 */ /* Custom Height Optimization */
.form-control-sm, .form-control-sm,
.btn-sm { .btn-sm {
@ -165,7 +156,6 @@
{% block content %} {% block content %}
<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">
<div> <div>
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;"> <h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
@ -173,7 +163,8 @@
{% trans "Interview Management" %} {% trans "Interview Management" %}
</h1> </h1>
<h2 class="h5 text-muted mb-0"> <h2 class="h5 text-muted mb-0">
{% trans "Total Interviews:" %} <span class="fw-bold">{{ interviews|length }}</span> {# Using count() instead of length filter if interviews is the Paginator Page Object #}
{% trans "Total Interviews:" %} <span class="fw-bold">{{ interviews.paginator.count }}</span>
</h2> </h2>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
@ -183,19 +174,9 @@
</div> </div>
</div> </div>
<!-- Filter Controls -->
<div class="filter-controls"> <div class="filter-controls">
<form method="get" class="row g-3"> <form method="get" class="row g-3">
<div class="col-md-3"> <div class="col-md-3">
{% comment %} <label for="job_filter" class="form-label form-label-sm">{% trans "Job" %}</label>
<select name="job" id="job_filter" class="form-select form-select-sm">
<option value="">{% trans "All Jobs" %}</option>
{% for job in jobs %}
<option value="{{ job.id }}" {% if request.GET.job == job.id|stringformat:"s" %}selected{% endif %}>
{{ job.title }}
</option>
{% endfor %}
</select> {% endcomment %}
<label for="job_filter" class="form-label small text-muted">{% trans "Filter by Job" %}</label> <label for="job_filter" class="form-label small text-muted">{% trans "Filter by Job" %}</label>
<select name="job" id="job_filter" class="form-select form-select-sm"> <select name="job" id="job_filter" class="form-select form-select-sm">
@ -238,91 +219,24 @@
</div> </div>
</form> </form>
</div> </div>
{# Using 'meetings' based on the context_object_name provided #}
{% if interviews %} {% if interviews %}
<div id="meetings-list"> <div id="interview-list">
{# View Switcher (kept the name for simplicity) #} {# View Switcher (kept the name for simplicity) #}
{% include "includes/_list_view_switcher.html" with list_id="meetings-list" %} {% include "includes/_list_view_switcher.html" with list_id="interview-list" %}
{# Card View #} {# Table View #}
<div class="card-view active row"> <div class="table-view active d-none d-lg-block">
{% for interview in meetings %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="card meeting-card h-100 shadow-sm">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title flex-grow-1 me-3">
<a href="{% url 'application_detail' interview.candidate.slug %}" class="text-decoration-none text-primary-theme">{{ interview.candidate.name }}</a>
</h5>
<span class="status-badge bg-{{ interview.status }}">
{{ interview.status|title }}
</span>
</div>
<p class="card-text text-muted small mb-3">
<i class="fas fa-briefcase"></i> {% trans "Job" %}:
<a class="text-secondary text-decoration-none" href="{% url 'job_detail' interview.job.slug %}">{{ interview.job.title }}</a><br>
{# --- Remote/Onsite Logic - Handles both cases safely --- #}
<i class="fas {% if interview.schedule.interview_type == 'Remote' %}fa-globe{% else %}fa-map-marker-alt{% endif %}"></i>
{% trans "Type" %}: {{ interview.schedule.get_interview_type_display }}
{% if interview.schedule.interview_type == 'Remote' %}<br>
{# CRITICAL FIX: Safe access to zoom_meeting details #}
<i class="fas fa-hashtag"></i> {% trans "Zoom ID" %}: {{ interview.zoom_meeting.meeting_id|default:"N/A" }}
{% else %}<br>
<i class="fas fa-building"></i> {% trans "Location" %}: {{ interview.schedule.location }}
{% endif %}<br>
<i class="fas fa-clock"></i> {% trans "Date" %}: {{ interview.interview_date|date:"M d, Y" }}<br>
<i class="fas fa-clock"></i> {% trans "Time" %}: {{ interview.interview_time|time:"H:i" }}<br>
<i class="fas fa-stopwatch"></i> {% trans "Duration" %}: {{ interview.schedule.interview_duration }} minutes
</p>
<div class="mt-auto pt-2 border-top">
<div class="d-flex gap-2">
<a href="{% url 'scheduled_interview_detail' interview.slug %}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i> {% trans "View" %}
</a>
{# 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-sm btn-main-action">
<i class="fas fa-link"></i> {% trans "Join" %}
</a>
{% endif %}
<a href="{% url 'update_scheduled_interview' interview.slug %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-edit"></i>
</a>
<button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-target="#deleteModal"
hx-post="{% url 'delete_scheduled_interview' interview.slug %}"
hx-target="#deleteModalBody"
hx-swap="outerHTML"
data-item-name="{{ interview.candidate.name }} Interview">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<form id="interview-form">
{% csrf_token %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table interview-table align-middle"> <table class="table interview-table table-hover align-middle mb-0">
<thead> <thead>
<tr> <tr>
<th><i class="fas fa-user me-1"></i> {% trans "Candidate" %}</th> <th>{% trans "Candidate" %}</th>
<th><i class="fas fa-briefcase me-1"></i> {% trans "Job" %}</th> <th>{% trans "Job" %}</th>
<th><i class="fas fa-calendar me-1"></i> {% trans "Date & Time" %}</th> <th>{% trans "Date & Time" %}</th>
<th><i class="fas fa-tag me-1"></i> {% trans "Type" %}</th> <th>{% trans "Type" %}</th>
<th><i class="fas fa-info-circle me-1"></i> {% trans "Status" %}</th> <th>{% trans "Status" %}</th>
{% comment %} <th><i class="fas fa-users me-1"></i> {% trans "Participants" %}</th> {% endcomment %} <th class="text-end">{% trans "Actions" %}</th>
<th><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -344,14 +258,17 @@
<i class="fas fa-calendar-day me-1"></i> {{ interview.interview_date|date:"d-m-Y" }}<br> <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" }} <i class="fas fa-clock me-1"></i> {{ interview.interview_time|date:"h:i A" }}
</div> </div>
</td> </td>
<td> <td>
{# Assuming interview.interview.location_type is meant to be interview.location_type #}
{% if interview.interview.location_type == 'Remote' %} {% if interview.interview.location_type == 'Remote' %}
<span class="badge interview-type-badge bg-remote"> <span class="badge interview-type-badge bg-primary-theme">
<i class="fas fa-video me-1"></i> {% trans "Remote" %} <i class="fas fa-video me-1"></i> {% trans "Remote" %}
</span> </span>
{% else %} {% else %}
<span class="badge interview-type-badge bg-onsite"> <span class="badge interview-type-badge bg-primary-theme">
<i class="fas fa-building me-1"></i> {% trans "Onsite" %} <i class="fas fa-building me-1"></i> {% trans "Onsite" %}
</span> </span>
{% endif %} {% endif %}
@ -362,103 +279,103 @@
</span> </span>
</td> </td>
<td> <td class="text-end">
<div class="btn-group" role="group">
<a href="{% url 'interview_detail' interview.slug %}" <a href="{% url 'interview_detail' interview.slug %}"
class="btn btn-outline-primary btn-sm" class="btn btn-outline-secondary btn-sm"
title="{% trans 'View Details' %}"> title="{% trans 'View Details' %}">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</a> </a>
{% comment %} {% if interview.status != 'CANCELLED' and interview.status != 'COMPLETED' %} {# Actions for Reschedule/Cancel (Commented out in original, kept commented) #}
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
data-bs-target="#actionModal"
hx-get="#"
hx-target="#actionModalBody"
title="{% trans 'Reschedule' %}">
<i class="fas fa-redo-alt"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-sm"
data-bs-toggle="modal"
data-bs-target="#actionModal"
hx-get="#"
hx-target="#actionModalBody"
title="{% trans 'Cancel' %}">
<i class="fas fa-times"></i>
</button>
{% endif %} {% endcomment %}
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</form> </div>
{# Card View (Mobile/Tablet) #}
<div class="card-view row g-4 d-lg-none">
{% for interview in interviews %}
<div class="col-md-6 col-sm-12">
<div class="card kaauh-card h-100 shadow-sm">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title flex-grow-1 me-3">
<a href="{% url 'interview_detail' interview.slug %}" class="text-decoration-none text-primary-theme">{{ interview.application.name }}</a>
</h5>
<span class="status-badge bg-primary-theme text-white">
{{ interview.status|title }}
</span>
</div>
<!-- Pagination --> <p class="card-text text-muted small mb-3">
{% if is_paginated %} <i class="fas fa-briefcase"></i> {% trans "Job" %}:
<nav aria-label="Interview pagination" class="mt-4"> <a class="text-secondary text-decoration-none" href="{% url 'job_detail' interview.job.slug %}">{{ interview.job.title }}</a><br>
<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 %} <i class="fas fa-calendar-day"></i> {% trans "Date" %}: {{ interview.interview_date|date:"M d, Y" }}<br>
{% if page_obj.number == num %} <i class="fas fa-clock"></i> {% trans "Time" %}: {{ interview.interview_time|time:"H:i A" }}<br>
<li class="page-item active">
<span class="page-link">{{ num }}</span> {# --- Type/Location --- #}
</li> <i class="fas {% if interview.location_type == 'Remote' %}fa-globe{% else %}fa-map-marker-alt{% endif %}"></i>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %} {% trans "Type" %}: {{ interview.location_type }}
<li class="page-item"> {% if interview.location_type == 'Remote' %}<br>
<a class="page-link" href="?page={{ num }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{{ num }}</a> {# Using interview.join_url directly if available, assuming interview is the full object #}
</li> <i class="fas fa-link"></i> {% trans "Link" %}: {% if interview.join_url %}<a href="{{ interview.join_url }}" target="_blank">Join Meeting</a>{% else %}N/A{% endif %}
{% endif %} {% else %}<br>
{% endfor %} <i class="fas fa-building"></i> {% trans "Location" %}: {{ interview.location_details|default:"Onsite" }}
{% endif %}
</p>
{% if page_obj.has_next %} <div class="mt-auto pt-2 border-top">
<li class="page-item"> <div class="d-flex gap-2">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "Next" %}</a> <a href="{% url 'interview_detail' interview.slug %}" class="btn btn-sm btn-main-action">
</li> <i class="fas fa-eye"></i> {% trans "View" %}
<li class="page-item"> </a>
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "Last" %}</a> {# Join button logic simplified #}
</li> {% if interview.location_type == 'Remote' and interview.join_url %}
{% endif %} <a href="{{ interview.join_url }}" target="_blank" class="btn btn-sm btn-outline-secondary">
</ul> <i class="fas fa-link"></i> {% trans "Join" %}
</nav> </a>
{% endif %} {% endif %}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{# FIX: Added the missing pagination include here #}
{% include "includes/paginator.html" %}
{% else %} {% else %}
<!-- Empty State --> <div class="text-center py-5 kaauh-card">
<div class="text-center py-5"> <div class="card-body">
<div class="mb-4"> <i class="fas fa-calendar fa-4x text-muted" style="color: var(--kaauh-teal-dark) !important;"></i>
<i class="fas fa-calendar fa-4x text-muted"></i>
<h4 class="text-muted mt-3 mb-3">
{% if search_query or job_filter or request.GET.status or request.GET.type %}
{% trans "There are no interviews matching your filters." %}
{% else %}
{% trans "There are no interviews scheduled." %}
{% endif %}
</h4>
<p class="text-muted mb-4">
{% trans "Start by setting the interview stage for an application." %}
</p>
{# Add button if needed, otherwise rely on the header button #}
</div> </div>
<h4 class="text-muted mb-3">
{% if search_query %}
{% trans "There are no interviews matching your search filters." %}
{% else %}
{% trans "There are no interviews." %}
{% endif %}
</h4>
<p class="text-muted mb-4">
{% trans "Start by adding your first interview." %}
</p>
{% comment %} <a href="{% url 'interview_list' %}" class="btn btn-main-action">
<i class="fas fa-plus me-2"></i> {% trans "Add Your First Agency" %}
</a> {% endcomment %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div>
<!-- Action Modal -->
<div class="modal fade" id="actionModal" tabindex="-1" aria-labelledby="actionModalLabel" aria-hidden="true"> <div class="modal fade" id="actionModal" tabindex="-1" aria-labelledby="actionModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content kaauh-card"> <div class="modal-content kaauh-card">
@ -539,4 +456,4 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,170 @@
{% load i18n %}
{% if questions %}
{% for question in questions %}
<div class="ai-question-item">
<div class="ai-question-header">
<div class="ai-question-badges">
<span class="ai-question-badge badge-{{ question.type|lower }}">
{% if question.type == 'Technical' %}
<i class="fas fa-code me-1"></i>
{% elif question.type == 'Behavioral' %}
<i class="fas fa-users me-1"></i>
{% elif question.type == 'Situational' %}
<i class="fas fa-lightbulb me-1"></i>
{% endif %}
{{ question.type }}
</span>
<span class="ai-question-badge badge-{{ question.difficulty|lower }}">
{% if question.difficulty == 'Easy' %}
<i class="fas fa-smile me-1"></i>
{% elif question.difficulty == 'Medium' %}
<i class="fas fa-meh me-1"></i>
{% elif question.difficulty == 'Hard' %}
<i class="fas fa-frown me-1"></i>
{% endif %}
{{ question.difficulty }}
</span>
{% if question.category %}
<span class="ai-question-badge badge-technical">
<i class="fas fa-tag me-1"></i>
{{ question.category }}
</span>
{% endif %}
</div>
</div>
<div class="ai-question-text">
{{ question.text|linebreaksbr }}
</div>
<div class="ai-question-meta">
<div class="ai-question-category">
<i class="fas fa-clock"></i>
<small>{% trans "Generated" %}: {{ question.created_at|date:"d M Y, H:i" }}</small>
</div>
<div class="ai-question-actions">
<button type="button"
class="btn btn-sm"
onclick="copyQuestionText('{{ question.id }}')"
title="{% trans 'Copy question' %}">
<i class="fas fa-copy"></i>
</button>
<button type="button"
class="btn btn-sm"
onclick="toggleQuestionNotes('{{ question.id }}')"
title="{% trans 'Add notes' %}">
<i class="fas fa-sticky-note"></i>
</button>
</div>
</div>
<!-- Hidden notes section -->
<div id="questionNotes_{{ question.id }}" class="mt-3" style="display: none;">
<textarea class="form-control"
rows="3"
placeholder="{% trans 'Add your notes for this question...' %}"></textarea>
<div class="mt-2">
<button type="button"
class="btn btn-main-action btn-sm"
onclick="saveQuestionNotes('{{ question.id }}')">
<i class="fas fa-save me-1"></i> {% trans "Save Notes" %}
</button>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="ai-questions-empty">
<i class="fas fa-brain fa-3x mb-3"></i>
<h5 class="mb-3">{% trans "No AI Questions Available" %}</h5>
<p class="mb-0">{% trans "Click 'Generate Questions' to create personalized interview questions based on the candidate's profile and job requirements." %}</p>
</div>
{% endif %}
<script>
// Copy question text to clipboard
function copyQuestionText(questionId) {
const questionText = document.querySelector(`#questionText_${questionId}`);
if (questionText) {
navigator.clipboard.writeText(questionText.textContent).then(() => {
// Show success feedback
showNotification('{% trans "Question copied to clipboard!" %}', 'success');
}).catch(err => {
console.error('Failed to copy text: ', err);
showNotification('{% trans "Failed to copy question" %}', 'error');
});
}
}
// Toggle question notes visibility
function toggleQuestionNotes(questionId) {
const notesSection = document.getElementById(`questionNotes_${questionId}`);
if (notesSection) {
if (notesSection.style.display === 'none') {
notesSection.style.display = 'block';
} else {
notesSection.style.display = 'none';
}
}
}
// Save question notes (placeholder function)
function saveQuestionNotes(questionId) {
const notesTextarea = document.querySelector(`#questionNotes_${questionId} textarea`);
if (notesTextarea) {
// Here you would typically save to backend
const notes = notesTextarea.value;
console.log(`Saving notes for question ${questionId}:`, notes);
showNotification('{% trans "Notes saved successfully!" %}', 'success');
// Hide notes section after saving
setTimeout(() => {
toggleQuestionNotes(questionId);
}, 1000);
}
}
// Show notification (helper function)
function showNotification(message, type = 'info') {
// Create notification element
const notification = document.createElement('div');
notification.className = `alert alert-${type === 'success' ? 'success' : type === 'error' ? 'danger' : 'info'} alert-dismissible fade show position-fixed`;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
min-width: 300px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
`;
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
document.body.appendChild(notification);
// Auto-remove after 3 seconds
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 3000);
}
// Initialize question text elements with IDs for copying
document.addEventListener('DOMContentLoaded', function() {
const questionTexts = document.querySelectorAll('.ai-question-text');
questionTexts.forEach((element, index) => {
// Add ID to question text elements for copying functionality
const questionItem = element.closest('.ai-question-item');
if (questionItem) {
const questionId = questionItem.querySelector('[onclick*="copyQuestionText"]')?.getAttribute('onclick').match(/'(\d+)'/)?.[1];
if (questionId) {
element.id = `questionText_${questionId}`;
}
}
});
});
</script>

View File

@ -144,6 +144,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.topic.id_for_label }}">{% trans "Topic" %}</label> <label for="{{ form.topic.id_for_label }}">{% trans "Topic" %}</label>
{{ form.topic }} {{ form.topic }}
{% if form.topic.errors %}
<div class="text-danger small mt-1">{{ form.topic.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -152,6 +155,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.schedule_interview_type.id_for_label }}">{% trans "Interview Type" %}</label> <label for="{{ form.schedule_interview_type.id_for_label }}">{% trans "Interview Type" %}</label>
{{ form.schedule_interview_type }} {{ form.schedule_interview_type }}
{% if form.schedule_interview_type.errors %}
<div class="text-danger small mt-1">{{ form.schedule_interview_type.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -160,6 +166,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.start_date.id_for_label }}">{% trans "Start Date" %}</label> <label for="{{ form.start_date.id_for_label }}">{% trans "Start Date" %}</label>
{{ form.start_date }} {{ form.start_date }}
{% if form.start_date.errors %}
<div class="text-danger small mt-1">{{ form.start_date.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
@ -167,6 +176,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.end_date.id_for_label }}">{% trans "End Date" %}</label> <label for="{{ form.end_date.id_for_label }}">{% trans "End Date" %}</label>
{{ form.end_date }} {{ form.end_date }}
{% if form.end_date.errors %}
<div class="text-danger small mt-1">{{ form.end_date.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -175,6 +187,9 @@
<label>{% trans "Working Days" %}</label> <label>{% trans "Working Days" %}</label>
<div class="d-flex flex-wrap gap-3 p-2 border rounded" style="background-color: #f8f9fa;"> <div class="d-flex flex-wrap gap-3 p-2 border rounded" style="background-color: #f8f9fa;">
{{ form.working_days }} {{ form.working_days }}
{% if form.working_days.errors %}
<div class="text-danger small mt-1">{{ form.working_days.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
@ -183,6 +198,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.start_time.id_for_label }}">{% trans "Start Time" %}</label> <label for="{{ form.start_time.id_for_label }}">{% trans "Start Time" %}</label>
{{ form.start_time }} {{ form.start_time }}
{% if form.start_time.errors %}
<div class="text-danger small mt-1">{{ form.start_time.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
@ -190,6 +208,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.end_time.id_for_label }}">{% trans "End Time" %}</label> <label for="{{ form.end_time.id_for_label }}">{% trans "End Time" %}</label>
{{ form.end_time }} {{ form.end_time }}
{% if form.end_time.errors %}
<div class="text-danger small mt-1">{{ form.end_time.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
@ -197,6 +218,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.interview_duration.id_for_label }}">{% trans "Duration (min)" %}</label> <label for="{{ form.interview_duration.id_for_label }}">{% trans "Duration (min)" %}</label>
{{ form.interview_duration }} {{ form.interview_duration }}
{% if form.interview_duration.errors %}
<div class="text-danger small mt-1">{{ form.interview_duration.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
@ -204,6 +228,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.buffer_time.id_for_label }}">{% trans "Buffer (min)" %}</label> <label for="{{ form.buffer_time.id_for_label }}">{% trans "Buffer (min)" %}</label>
{{ form.buffer_time }} {{ form.buffer_time }}
{% if form.buffer_time.errors %}
<div class="text-danger small mt-1">{{ form.buffer_time.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -215,10 +242,16 @@
<div class="col-5"> <div class="col-5">
<label for="{{ form.break_start_time.id_for_label }}">{% trans "Start Time" %}</label> <label for="{{ form.break_start_time.id_for_label }}">{% trans "Start Time" %}</label>
{{ form.break_start_time }} {{ form.break_start_time }}
{% if form.break_start_time.errors %}
<div class="text-danger small mt-1">{{ form.break_start_time.errors }}</div>
{% endif %}
</div> </div>
<div class="col-5"> <div class="col-5">
<label for="{{ form.break_end_time.id_for_label }}">{% trans "End Time" %}</label> <label for="{{ form.break_end_time.id_for_label }}">{% trans "End Time" %}</label>
{{ form.break_end_time }} {{ form.break_end_time }}
{% if form.break_end_time.errors %}
<div class="text-danger small mt-1">{{ form.break_end_time.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -108,7 +108,7 @@
<div class="stage-icon"> <div class="stage-icon">
<i class="fas fa-file-signature cd_screening"></i> <i class="fas fa-file-signature cd_screening"></i>
</div> </div>
<div class="stage-label cd_screening">{% trans "Screened" %}</div> <div class="stage-label cd_screening">{% trans "Screening" %}</div>
<div class="stage-count">{{ job.screening_applications.count|default:"0" }}</div> <div class="stage-count">{{ job.screening_applications.count|default:"0" }}</div>
</a> </a>

View File

@ -50,12 +50,12 @@
<div class="input-group"> <div class="input-group">
<input type="text" name="q" id="q" class="form-control" <input type="text" name="q" id="q" class="form-control"
value="{{ search_query }}" placeholder="{% trans 'Search messages...' %}"> value="{{ search_query }}" placeholder="{% trans 'Search messages...' %}">
<button class="btn btn-outline-primary-theme" type="submit"> <button class="btn btn-outline-primary" type="submit">
<i class="fas fa-search"></i> <i class="fas fa-search"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="col-md-2"> <div class="col-md-1">
<label class="form-label">&nbsp;</label> <label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-main-action w-100"> <button type="submit" class="btn btn-main-action w-100">

View File

@ -199,7 +199,7 @@
<div class="col-md-4 d-flex"> <div class="col-md-4 d-flex">
<div class="filter-buttons"> <div class="filter-buttons">
<button type="submit" class="btn btn-main-action btn-sm"> <button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-filter me-1"></i> {% trans "Apply Filter" %} <i class="fas fa-filter me-1"></i> {% trans "Apply Filters" %}
</button> </button>
{% if request.GET.q or request.GET.nationality or request.GET.gender %} {% if request.GET.q or request.GET.nationality or request.GET.gender %}
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary btn-sm"> <a href="{% url 'person_list' %}" class="btn btn-outline-secondary btn-sm">

View File

@ -333,41 +333,8 @@
<!-- Pagination --> <!-- Pagination -->
{% if page_obj.has_other_pages %} {% if page_obj.has_other_pages %}
<nav aria-label="{% trans 'Agency pagination' %}" class="mt-4"> <!-- Pagination -->
<ul class="pagination justify-content-center"> {% include "includes/paginator.html" %}
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}">
<i class="fas fa-chevron-left"></i>
</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 search_query %}&q={{ search_query }}{% endif %}">{{ num }}</a>
</li>
{% elif num == 1 or num == page_obj.paginator.num_pages %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% if search_query %}&q={{ search_query }}{% 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 search_query %}&q={{ search_query }}{% endif %}">
<i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %} {% endif %}
{% else %} {% else %}
<!-- Empty State --> <!-- Empty State -->

View File

@ -407,7 +407,7 @@
</td> </td>
<td> <td>
{% if interview.interview and interview.interview.location_type == 'Remote' %} {% if interview.interview and interview.interview.location_type == 'Remote' %}
<a href="{{ interview.interview.details_url }}" <a href="{{ interview.interview.join_url }}"
target="_blank" target="_blank"
class="btn btn-sm bg-primary-theme text-white"> class="btn btn-sm bg-primary-theme text-white">
<i class="fas fa-video me-1"></i> <i class="fas fa-video me-1"></i>

View File

@ -369,7 +369,7 @@
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#noteModal" data-bs-target="#noteModal"
hx-get="{% url 'application_add_note' application.slug %}" hx-get="{% url 'application_add_note' application.slug %}"
hx-swap="outerHTML" hx-swap="innerHTML"
hx-target=".notemodal"> hx-target=".notemodal">
<i class="fas fa-calendar-plus me-1"></i> <i class="fas fa-calendar-plus me-1"></i>
Add note Add note

View File

@ -403,7 +403,7 @@
</div> </div>
{# Pagination (Standardized to Reference) #} {# Pagination (Standardized to Reference) #}
{% include "includes/paginator.html" %} {% include "includes/paginator.html" %}
{% else %} {% else %}
<div class="text-center py-5 card shadow-sm"> <div class="text-center py-5 card shadow-sm">
<div class="card-body"> <div class="card-body">

View File

@ -128,49 +128,8 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="Sources pagination"> {% include "includes/paginator.html" %}
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if search_query %}&q={{ search_query }}{% endif %}">
<i class="fas fa-angle-double-left"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}">
<i class="fas fa-angle-left"></i>
</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 search_query %}&q={{ search_query }}{% 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 search_query %}&q={{ search_query }}{% endif %}">
<i class="fas fa-angle-right"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if search_query %}&q={{ search_query }}{% endif %}">
<i class="fas fa-angle-double-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %} {% else %}
<div class="text-center py-5"> <div class="text-center py-5">
<i class="fas fa-database fa-3x text-muted mb-3"></i> <i class="fas fa-database fa-3x text-muted mb-3"></i>