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/<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/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/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/webhooks/zoom/', views.zoom_webhook_view, name='zoom_webhook_view'),
]
urlpatterns += i18n_patterns(

View File

@ -27,4 +27,5 @@ admin.site.register(IntegrationLog)
admin.site.register(HiringAgency)
admin.site.register(JobPosting)
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'].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
job_value = self.initial.get("job")
@ -690,20 +694,40 @@ class BulkInterviewTemplateForm(forms.ModelForm):
self.fields["applications"].queryset.first().job.title
)
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["start_time"].initial = "08:00"
self.fields["end_time"].initial = "14:00"
self.fields["interview_duration"].initial = 30
self.fields["buffer_time"].initial = 10
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"
def clean_working_days(self):
working_days = self.cleaned_data.get("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 Meta:
model = ScheduledInterview
@ -1369,43 +1393,70 @@ class CandidateEmailForm(forms.Form):
if candidate and candidate.stage == 'Applied':
message_parts = [
f"Than you, for your interest in the {self.job.title} role.",
f"We regret to inform you that you were not selected to move forward to the exam round at this time.",
f"We encourage you to check our career page for further updates and future opportunities:",
f"https://kaauh/careers",
f"Wishing you the best in your job search,",
f"The KAAUH Hiring team"
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 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"The selection process was highly competitive, and we had a large number of highly qualified applicants.",
f"We encourage you to review other opportunities and apply for roles that align with your skills on our career portal:",
f"[settings.CAREER_PAGE_URL]", # Use a Django setting for the URL for flexibility
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':
message_parts = [
f"Than you,for your interest in the {self.job.title} role.",
f"We're pleased to inform you that your initial screening was successful!",
f"The next step is the mandatory online assessment exam.",
f"Please complete the assessment by using the following link:",
f"https://kaauh/hire/exam",
f"We look forward to reviewing your results.",
f"Best regards, The KAAUH Hiring team"
f"Dear Candidate,",
f"Thank you once again for your continued interest in the **{self.job.title}** position.",
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"The next mandatory step is the **Online Assessment Examination** designed to evaluate essential skills for this role.",
f"\n**Action Required:**",
f"Please click on the link below to access and complete the assessment:",
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 = [
f"Than you, for your interest in the {self.job.title} role.",
f"We're pleased to inform you that you have cleared your exam!",
f"The next step is the mandatory interview.",
f"Please complete the assessment by using the following link:",
f"https://kaauh/hire/exam",
f"We look forward to reviewing your results.",
f"Best regards, The KAAUH Hiring team"
f"Dear Candidate,",
f"Thank you for your performance in the recent assessment for the **{self.job.title}** role.",
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"The interview is a mandatory step that allows us to learn more about your experience and fit for the role.",
f"\n**Next Steps:**",
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"\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 = [
f"Congratulations, ! We are delighted to inform you that we are extending a formal offer of employment for the {self.job.title} role.",
f"This is an exciting moment, and we look forward to having you join the KAAUH team.",
f"A detailed offer letter and compensation package will be sent to you via email within 24 hours.",
f"In the meantime, please contact our HR department at [HR Contact] if you have immediate questions.",
f"Welcome to the team!",
f"Best regards, The KAAUH Hiring team"
f"Congratulations on progressing to the final stage for the {self.job.title} role!",
f"The next critical step is to complete your application by uploading the required employment verification documents.",
f"**Please log into the Candidate Portal immediately** to access the 'Document Upload' section.",
f"Required documents typically include: National ID/Iqama, Academic Transcripts, and Professional Certifications.",
f"You have **7 days** to upload all documents. Failure to do so may delay or invalidate your candidacy.",
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':
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"We look forward to seeing you at KAAUH.",
f"If you have any questions before your start date, please contact [Onboarding Contact].",
f"Best regards, The KAAUH Hiring team"
]
elif candidate:
message_parts=""
@ -1536,7 +1587,7 @@ class MessageForm(forms.ModelForm):
print(person)
applications=person.applications.all()
print(applications)
self.fields["job"].queryset = JobPosting.objects.filter(
applications__in=applications,
).distinct().order_by("-created_at")
@ -2151,14 +2202,11 @@ Job: {job.title}
"""
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:
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
@ -2167,7 +2215,7 @@ KAAUH Hiring Team
class InterviewResultForm(forms.ModelForm):
class Meta:
model = Interview
fields = ['interview_result', 'result_comments']
widgets = {
'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.validators
@ -92,11 +92,14 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('location_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], db_index=True, max_length=10, verbose_name='Location Type')),
('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')),
('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')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
('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_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')),
@ -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')),
('name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Participant Name')),
('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')),
],
options={
@ -139,8 +142,9 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('name', models.CharField(blank=True, 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')),
('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={
'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')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('name', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, unique=True, verbose_name='Source Name')),
('source_type', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, verbose_name='Source Type')),
('name', models.CharField(help_text='Name of the source', max_length=100, unique=True, verbose_name='Source Name')),
('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')),
('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)),
@ -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_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')),
('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')),
('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')),
('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)),
@ -358,7 +362,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')),
('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')),
('email', models.EmailField(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)),
('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
('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')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('first_name', 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')),
('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')),
('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')),
('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')),
@ -620,6 +624,7 @@ class Migration(migrations.Migration):
('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)),
('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')),
('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')),

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_extensions.db.fields import RandomCharField
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
@ -45,7 +45,7 @@ class CustomUser(AbstractUser):
("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(
max_length=20,
@ -55,7 +55,7 @@ class CustomUser(AbstractUser):
db_index=True, # Added index for user_type filtering
)
phone = EncryptedCharField(
blank=True, null=True, verbose_name=_("Phone")
blank=True, null=True, verbose_name=_("Phone"),searchable=True
)
profile_image = models.ImageField(
null=True,
@ -535,7 +535,7 @@ class Person(Base):
]
# 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"))
middle_name = models.CharField(
max_length=255, blank=True, null=True, verbose_name=_("Middle Name")
@ -546,7 +546,7 @@ class Person(Base):
verbose_name=_("Email"),
)
phone = EncryptedCharField(
blank=True, null=True, verbose_name=_("Phone")
blank=True, null=True, verbose_name=_("Phone"),searchable=True
)
date_of_birth = models.DateField(
null=True, blank=True, verbose_name=_("Date of Birth")
@ -1120,9 +1120,10 @@ class Interview(Base):
class Status(models.TextChoices):
WAITING = "waiting", _("Waiting")
STARTED = "started", _("Started")
UPDATED = "updated", _("Updated")
DELETED = "deleted", _("Deleted")
ENDED = "ended", _("Ended")
CANCELLED = "cancelled", _("Cancelled")
class InterviewResult(models.TextChoices):
PASSED="passed",_("Passed")
FAILED="failed",_("Failed")
@ -1154,7 +1155,7 @@ class Interview(Base):
blank=True,
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
)
timezone = models.CharField(
@ -1351,6 +1352,10 @@ class ScheduledInterview(Base):
choices=InterviewStatus.choices,
default=InterviewStatus.SCHEDULED,
)
interview_questions = models.JSONField(
verbose_name=_("Question Data"),
blank=True,null=True
)
def __str__(self):
return (
@ -1963,7 +1968,7 @@ class HiringAgency(Base):
max_length=150, blank=True, verbose_name=_("Contact Person")
)
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)
notes = models.TextField(blank=True, help_text=_("Internal notes about the agency"))
country = CountryField(blank=True, null=True, blank_label=_("Select country"))
@ -2357,7 +2362,7 @@ class Participants(Base):
)
email =models.EmailField(verbose_name=_("Email"))
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(
max_length=100, blank=True, verbose_name=_("Designation"), null=True
@ -2606,11 +2611,11 @@ class Settings(Base):
verbose_name=_("Setting Key"),
help_text=_("Unique key for the setting"),
)
value = models.TextField(
value = EncryptedTextField(
verbose_name=_("Setting Value"),
help_text=_("Value for the setting"),
)
class Meta:
verbose_name = _("Setting")

View File

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

View File

@ -27,6 +27,8 @@ from django.template.loader import render_to_string
from .models import BulkInterviewTemplate, Interview, Message, ScheduledInterview
from django.contrib.auth import get_user_model
from .utils import get_setting
from pypdf import PdfReader
User = get_user_model()
# Add python-docx import for Word document processing
@ -227,6 +229,10 @@ def format_job_description(pk):
def ai_handler(prompt):
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(
url=OPENROUTER_API_URL,
headers={
@ -722,22 +728,26 @@ def create_interview_and_meeting(schedule_id):
try:
schedule = ScheduledInterview.objects.get(pk=schedule_id)
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":
interview.meeting_id = result["meeting_details"]["meeting_id"]
interview.details_url = result["meeting_details"]["join_url"]
interview.zoom_gateway_response = result["zoom_gateway_response"]
interview.join_url = result["meeting_details"]["join_url"]
interview.host_email = result["meeting_details"]["host_email"]
interview.password = result["meeting_details"]["password"]
interview.zoom_gateway_response = result["zoom_gateway_response"]
interview.save()
logger.info(f"Successfully scheduled interview for {Application.name}")
logger.info(f"Successfully scheduled interview for {schedule.application.name}")
return True
else:
# 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
except Exception as e:
@ -745,7 +755,6 @@ def create_interview_and_meeting(schedule_id):
logger.error(f"Critical error scheduling interview: {e}")
return False # Task failed
def handle_zoom_webhook_event(payload):
"""
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")
object_data = payload["payload"]["object"]
# Zoom often uses a long 'id' for the scheduled meeting and sometimes a 'uuid'.
# We rely on the unique 'id' that maps to your ZoomMeeting.meeting_id field.
meeting_id_zoom = str(object_data.get("id"))
if not meeting_id_zoom:
meeting_id = str(object_data.get("id"))
if not meeting_id:
logger.warning(f"Webhook received without a valid Meeting ID: {event_type}")
return False
try:
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
# 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 ---
meeting_instance = Interview.objects.filter(meeting_id=meeting_id).first()
if event_type == "meeting.updated":
logger.info(f"Zoom meeting updated: {meeting_id}")
if meeting_instance:
# Update key fields from the webhook payload
meeting_instance.topic = object_data.get(
"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(
"start_time", meeting_instance.start_time
)
@ -789,7 +786,6 @@ def handle_zoom_webhook_event(payload):
meeting_instance.timezone = object_data.get(
"timezone", meeting_instance.timezone
)
meeting_instance.status = object_data.get(
"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) ---
elif event_type == "meeting.deleted":
elif event_type in ["meeting.started","meeting.ended","meeting.deleted"]:
if meeting_instance:
try:
meeting_instance.status = "cancelled"
meeting_instance.status = event_type.split(".")[-1]
meeting_instance.save(update_fields=["status"])
except Exception as e:
logger.error(f"Failed to mark Zoom meeting as cancelled: {e}")
return True
except Exception as e:
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,
)
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}."
})
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(
# recipient_emails,
# subject: str,
@ -1576,22 +1687,22 @@ def send_email_task(
# """
# from .services.email_service import EmailService
# if not recipient_emails:
# return json.dumps({"status": "error", "message": "No recipients provided."})
# # if not recipient_emails:
# # return json.dumps({"status": "error", "message": "No recipients provided."})
# service = EmailService()
# # service = EmailService()
# # Execute the bulk sending method
# processed_count = service.send_bulk_email(
# recipient_emails=recipient_emails,
# subject=subject,
# template_name=template_name,
# context=context,
# )
# # # Execute the bulk sending method
# # processed_count = service.send_bulk_email(
# # recipient_emails=recipient_emails,
# # subject=subject,
# # template_name=template_name,
# # context=context,
# # )
# # The return value is stored in the result object for monitoring
# return json.dumps({
# "status": "success",
# "count": 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
path("interviews/", views.interview_list, name="interview_list"),
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_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 django.conf import settings
from datetime import datetime, timedelta, time, date
from datetime import datetime, timedelta
from django.utils import timezone
from .models import ScheduledInterview
from django.template.loader import render_to_string
from django.core.mail import send_mail
import random
import os
import json
import logging
@ -417,12 +414,15 @@ def create_zoom_meeting(topic, start_time, duration):
try:
access_token = get_access_token()
zoom_start_time = start_time.strftime("%Y-%m-%dT%H:%M:%S")
logger.info(zoom_start_time)
meeting_details = {
"topic": topic,
"type": 2,
"start_time": start_time.isoformat() + "Z",
"start_time": zoom_start_time,
"duration": duration,
"timezone": "UTC",
"timezone": "Asia/Riyadh",
"settings": {
"host_video": True,
"participant_video": True,
@ -440,7 +440,7 @@ def create_zoom_meeting(topic, start_time, duration):
"Content-Type": "application/json",
}
ZOOM_MEETING_URL = get_setting("ZOOM_MEETING_URL")
print(ZOOM_MEETING_URL)
response = requests.post(
ZOOM_MEETING_URL, headers=headers, json=meeting_details
)
@ -448,6 +448,7 @@ def create_zoom_meeting(topic, start_time, duration):
# Check response status
if response.status_code == 201:
meeting_data = response.json()
logger.info(meeting_data)
return {
"status": "success",
"message": "Meeting created successfully.",
@ -869,7 +870,7 @@ def update_meeting(instance, updated_data):
instance.topic = zoom_details.get("topic", instance.topic)
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.status = zoom_details.get("status")

View File

@ -184,6 +184,7 @@ class PersonListView(StaffRequiredMixin, ListView, LoginRequiredMixin):
model = Person
template_name = "people/person_list.html"
context_object_name = "people_list"
paginate_by=100
def get_queryset(self):
queryset = super().get_queryset().select_related("user")
@ -1605,7 +1606,6 @@ def _handle_preview_submission(request, slug, job):
"""
SESSION_DATA_KEY = "interview_schedule_data"
form = BulkInterviewTemplateForm(slug, request.POST)
# break_formset = BreakTimeFormSet(request.POST,prefix='breaktime')
if form.is_valid():
# Get the form data
@ -1622,7 +1622,6 @@ def _handle_preview_submission(request, slug, job):
schedule_interview_type = form.cleaned_data["schedule_interview_type"]
physical_address = form.cleaned_data["physical_address"]
# Create a temporary schedule object (not saved to DB)
temp_schedule = BulkInterviewTemplate(
job=job,
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)
available_slots = get_available_time_slots(temp_schedule)
if len(available_slots) < len(applications):
messages.error(
request,
@ -1760,76 +1758,46 @@ def _handle_confirm_schedule(request, slug, job):
schedule.applications.set(applications)
available_slots = get_available_time_slots(schedule)
if schedule_data.get("schedule_interview_type") == "Remote":
queued_count = 0
for i, application in enumerate(applications):
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
for i, application in enumerate(applications):
if i >= len(available_slots):
continue
messages.success(
request,
f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!",
slot = available_slots[i]
# 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:
del request.session[SESSION_DATA_KEY]
if SESSION_ID_KEY in request.session:
del request.session[SESSION_ID_KEY]
scheduled = ScheduledInterview.objects.create(
application=application,
job=job,
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":
try:
for i, application in enumerate(applications):
if i < len(available_slots):
slot = available_slots[i]
messages.success(request,f"Schedule successfully created.")
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(
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)
return redirect("applications_interview_view", slug=slug)
@login_required
@ -1837,13 +1805,9 @@ def _handle_confirm_schedule(request, slug, job):
def schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
if request.method == "POST":
# return _handle_confirm_schedule(request, slug, job)
return _handle_preview_submission(request, slug, job)
else:
# if request.session.get("interview_schedule_data"):
print(request.session.get("interview_schedule_data"))
return _handle_get_request(request, slug, job)
# return redirect("applications_interview_view", slug=slug)
@login_required
@ -2143,7 +2107,6 @@ def reschedule_meeting_for_application(request, slug):
if request.method == "POST":
if interview.location_type == "Remote":
form = ScheduledInterviewForm(request.POST)
else:
form = OnsiteScheduleInterviewUpdateForm(request.POST)
@ -2156,7 +2119,7 @@ def reschedule_meeting_for_application(request, slug):
if interview.location_type == "Remote":
updated_data = {
"topic": topic,
"start_time": start_time.isoformat() + "Z",
"start_time": start_time.strftime("%Y-%m-%dT%H:%M:%S"),
"duration": duration,
}
result = update_meeting(schedule.interview, updated_data)
@ -2537,13 +2500,15 @@ def account_toggle_status(request, pk):
@csrf_exempt
def zoom_webhook_view(request):
from .utils import get_setting
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)
if request.method == "POST":
try:
payload = json.loads(request.body)
logger.info(payload)
async_task("recruitment.tasks.handle_zoom_webhook_event", payload)
return HttpResponse(status=200)
except Exception:
@ -2565,13 +2530,14 @@ def agency_list(request):
| Q(contact_person__icontains=search_query)
| Q(email__icontains=search_query)
| Q(country__icontains=search_query)
| Q(phone=search_query)
)
# Order by most recently created
agencies = agencies.order_by("-created_at")
# 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_obj = paginator.get_page(page_number)
@ -3058,7 +3024,7 @@ def applicant_portal_dashboard(request):
# Get candidate's documents using the Person documents property
documents = applicant.documents.order_by("-created_at")
print(documents)
# Add password change form for modal
@ -3682,11 +3648,11 @@ def message_create(request):
# from .services.email_service import UnifiedEmailService
# from .dto.email_dto import EmailConfig, EmailPriority
email_addresses = [message.recipient.email]
subject=message.subject
email_result=async_task(
"recruitment.tasks.send_email_task",
email_addresses,
@ -3700,7 +3666,7 @@ def message_create(request):
},
)
# Send email using unified service
if email_result:
messages.success(
request, "Message sent successfully via email!"
@ -3755,7 +3721,7 @@ def message_create(request):
and "HX-Request" in request.headers
and request.user.user_type in ["candidate", "agency"]
):
job_id = request.GET.get("job")
if 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)
schedule=interview.scheduled_interview
form = InterviewResultForm(request.POST, instance=interview)
if form.is_valid():
interview.save(update_fields=['interview_result', 'result_comments'])
form.save() # Saves form data
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")
# message = form.get_formatted_message()
# async_task(
# "recruitment.tasks.send_bulk_email_task",
# email_addresses,
@ -4468,7 +4436,7 @@ def api_application_detail(request, candidate_id):
# },
# )
# return redirect(request.path)
# else:
# # Form validation errors
@ -4508,7 +4476,7 @@ def source_list(request):
"""List all sources with search and pagination"""
search_query = request.GET.get("q", "")
sources = Source.objects.all()
if search_query:
sources = sources.filter(
Q(name__icontains=search_query)
@ -4520,7 +4488,7 @@ def source_list(request):
sources = sources.order_by("-created_at")
# 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_obj = paginator.get_page(page_number)
@ -4754,7 +4722,7 @@ def application_signup(request, slug):
@login_required
@staff_user_required
def interview_list(request):
"""List all interviews with filtering and pagination"""
interviews = ScheduledInterview.objects.select_related(
"application",
@ -4795,12 +4763,63 @@ def interview_list(request):
"status_filter": status_filter,
"job_filter": job_filter,
"search_query": search_query,
"interviews": interviews,
"interviews": page_obj,
"jobs": jobs,
}
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
@staff_user_required
def interview_detail(request, slug):
@ -4809,15 +4828,11 @@ def interview_detail(request, slug):
ScheduledInterviewUpdateStatusForm,
OnsiteScheduleInterviewUpdateForm,
)
schedule = get_object_or_404(ScheduledInterview, slug=slug)
interview = schedule.interview
interview_result_form=InterviewResultForm(instance=interview)
application = schedule.application
job = schedule.job
print(interview.location_type)
if interview.location_type == "Remote":
reschedule_form = ScheduledInterviewForm()
else:
@ -6462,7 +6477,7 @@ def sync_history(request, job_slug=None):
# sender_user = request.user
# job = job
# try:
# # Send email using background task
# email_result= async_task(
# "recruitment.tasks.send_bulk_email_task",
@ -6502,18 +6517,18 @@ def sync_history(request, job_slug=None):
def send_interview_email(request, slug):
from django.conf import settings
from django_q.tasks import async_task
schedule = get_object_or_404(ScheduledInterview, slug=slug)
application = schedule.application
job = application.job
if request.method == "POST":
form = InterviewEmailForm(job, application, schedule, request.POST)
if form.is_valid():
# 1. Ensure recipient is a list (fixes the "@" error)
recipient_str = form.cleaned_data.get("to").strip()
recipient_list = [recipient_str]
recipient_list = [recipient_str]
body_message = form.cleaned_data.get("message")
subject = form.cleaned_data.get("subject")
@ -6534,7 +6549,7 @@ def send_interview_email(request, slug):
"logo_url": settings.STATIC_URL + "image/kaauh.png",
},
)
messages.success(request, "Interview email enqueued successfully!")
return redirect("interview_detail", slug=schedule.slug)
@ -6546,14 +6561,14 @@ def send_interview_email(request, slug):
# GET request
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.
return render(
request,
request,
"recruitment/interview_email_form.html", # Replace with your actual template path
{
"form": form,
"schedule": schedule,
"form": form,
"schedule": schedule,
"job": job
}
)
@ -6581,7 +6596,10 @@ def compose_application_email(request, slug):
if not email_addresses:
messages.error(request, "No email selected")
referer = request.META.get("HTTP_REFERER")
if "HX-Request" in request.headers:
response = HttpResponse()
response.headers["HX-Refresh"] = "true"
return response
if referer:
# Redirect back to the referring page
return redirect(referer)
@ -6591,7 +6609,7 @@ def compose_application_email(request, slug):
subject = form.cleaned_data.get("subject")
message = form.get_formatted_message()
async_task(
"recruitment.tasks.send_email_task",
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)
else:
# Form validation errors
@ -6617,12 +6638,9 @@ def compose_application_email(request, slug):
# For HTMX requests, return error response
if "HX-Request" in request.headers:
return JsonResponse(
{
"success": False,
"error": "Please correct the form errors and try again.",
}
)
response = HttpResponse()
response.headers["HX-Refresh"] = "true"
return response
return render(
request,

View File

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

View File

@ -1,6 +1,7 @@
import requests
import jwt
import time
from datetime import timezone
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}',
'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 = {
"topic": topic,
"type": 2,
"start_time": start_time,
"start_time": zoom_start_time,
"duration": duration,
"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"
return requests.post(url, json=data, headers=headers)

View File

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

View File

@ -192,6 +192,127 @@
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 */
@media (max-width: 768px) {
.action-buttons {
@ -200,6 +321,19 @@
.action-buttons .btn {
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>
{% endblock %}
@ -292,7 +426,7 @@
<i class="fas fa-calendar-check me-2"></i> {% trans "Interview Details" %}
</h5>
<div class="d-flex gap-2">
<span class="bg-primary-theme badge status-badge text-white">
{{interview.location_type}}
</span>
@ -326,7 +460,7 @@
<span class="detail-label">{% trans "Status:" %}</span>
<span class="detail-value">
<span class="badge bg-primary-theme">
{{ schedule.status }}</span>
{{ interview.status }}</span>
</span>
</div>
</div>
@ -348,9 +482,9 @@
<span class="detail-label">{% trans "Password:" %}</span>
<span class="detail-value">{{ interview.password }}</span>
</div>
{% if interview.details_url %}
{% if interview.join_url %}
<div class="mt-3">
<a href="{{ interview.zoommeetingdetails.details_url }}"
<a href="{{ interview.join_url }}"
target="_blank"
class="btn btn-main-action btn-sm w-100">
<i class="fas fa-video me-1"></i> {% trans "Join Meeting" %}
@ -378,6 +512,112 @@
</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">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;">
<i class="fas fa-history me-2"></i> {% trans "Interview Timeline" %}
@ -394,7 +634,7 @@
</div>
</div>
</div>
{% if schedule.status == 'confirmed' %}
<div class="timeline-item">
<div class="timeline-content">
@ -403,7 +643,7 @@
<h6 class="mb-1">{% trans "Interview Confirmed" %}</h6>
<p class="mb-0 text-muted">{% trans "Candidate has confirmed attendance" %}</p>
</div>
</div>
</div>
</div>
@ -416,7 +656,7 @@
<h6 class="mb-1">{% trans "Interview Completed" %}</h6>
<p class="mb-0 text-muted">{% trans "Interview has been completed" %}</p>
</div>
</div>
</div>
</div>
@ -429,7 +669,7 @@
<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>
</div>
</div>
</div>
</div>
@ -490,7 +730,7 @@
<i class="fas fa-user-plus me-1"></i> {% trans "Add Participants" %}
</button>
</div> {% endcomment %}
<div class="kaauh-card shadow-sm p-4">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); font-weight: 600;">
@ -499,13 +739,13 @@
<div class="action-buttons">
{% 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-target="#rescheduleModal">
<i class="fas fa-redo-alt me-1"></i> {% trans "Reschedule" %}
</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-target="#cancelModal">
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
@ -759,4 +999,4 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
</script>
{% endblock %}
{% endblock %}

View File

@ -15,6 +15,7 @@
--kaauh-info: #17a2b8;
--kaauh-danger: #dc3545;
--kaauh-warning: #ffc107;
--kaauh-gray-light: #f8f9fa; /* Added for consistency */
}
/* Primary Color Overrides */
@ -93,14 +94,12 @@
}
/* 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(3) { width: 12%; }
.interview-table thead th:nth-child(4) { width: 12%; }
.interview-table thead th:nth-child(3) { width: 15%; }
.interview-table thead th:nth-child(4) { 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(7) { width: 8%; }
.interview-table thead th:nth-child(8) { width: 15%; }
.interview-table thead th:nth-child(6) { width: 10%; }
/* Candidate and Job Info */
.candidate-name {
@ -130,14 +129,6 @@
font-weight: 600;
}
/* Status Colors */
.bg-scheduled { background-color: #6c757d !important; color: white; }
.bg-confirmed { background-color: var(--kaauh-info) !important; color: white; }
.bg-cancelled { background-color: var(--kaauh-danger) !important; color: white; }
.bg-completed { background-color: var(--kaauh-success) !important; color: white; }
.bg-remote { background-color: #007bff !important; color: white; }
.bg-onsite { background-color: #6f42c1 !important; color: white; }
/* Custom Height Optimization */
.form-control-sm,
.btn-sm {
@ -165,7 +156,6 @@
{% block content %}
<div class="container-fluid py-4">
<!-- Header Section -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
@ -173,7 +163,8 @@
{% trans "Interview Management" %}
</h1>
<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>
</div>
<div class="d-flex gap-2">
@ -183,19 +174,9 @@
</div>
</div>
<!-- Filter Controls -->
<div class="filter-controls">
<form method="get" class="row g-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>
<select name="job" id="job_filter" class="form-select form-select-sm">
@ -238,91 +219,24 @@
</div>
</form>
</div>
{# Using 'meetings' based on the context_object_name provided #}
{% if interviews %}
<div id="meetings-list">
<div id="interview-list">
{# 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 #}
<div class="card-view active row">
{% 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 %}
{# Table View #}
<div class="table-view active d-none d-lg-block">
<div class="table-responsive">
<table class="table interview-table align-middle">
<table class="table interview-table table-hover align-middle mb-0">
<thead>
<tr>
<th><i class="fas fa-user me-1"></i> {% trans "Candidate" %}</th>
<th><i class="fas fa-briefcase me-1"></i> {% trans "Job" %}</th>
<th><i class="fas fa-calendar me-1"></i> {% trans "Date & Time" %}</th>
<th><i class="fas fa-tag me-1"></i> {% trans "Type" %}</th>
<th><i class="fas fa-info-circle me-1"></i> {% trans "Status" %}</th>
{% comment %} <th><i class="fas fa-users me-1"></i> {% trans "Participants" %}</th> {% endcomment %}
<th><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
<th>{% trans "Candidate" %}</th>
<th>{% trans "Job" %}</th>
<th>{% trans "Date & Time" %}</th>
<th>{% trans "Type" %}</th>
<th>{% trans "Status" %}</th>
<th class="text-end">{% trans "Actions" %}</th>
</tr>
</thead>
<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-clock me-1"></i> {{ interview.interview_time|date:"h:i A" }}
</div>
</td>
<td>
{# Assuming interview.interview.location_type is meant to be interview.location_type #}
{% 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" %}
</span>
{% 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" %}
</span>
{% endif %}
@ -362,103 +279,103 @@
</span>
</td>
<td>
<div class="btn-group" role="group">
<td class="text-end">
<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' %}">
<i class="fas fa-eye"></i>
</a>
{% comment %} {% if interview.status != 'CANCELLED' and interview.status != 'COMPLETED' %}
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
data-bs-target="#actionModal"
hx-get="#"
hx-target="#actionModalBody"
title="{% trans 'Reschedule' %}">
<i class="fas fa-redo-alt"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-sm"
data-bs-toggle="modal"
data-bs-target="#actionModal"
hx-get="#"
hx-target="#actionModalBody"
title="{% trans 'Cancel' %}">
<i class="fas fa-times"></i>
</button>
{% endif %} {% endcomment %}
</div>
{# Actions for Reschedule/Cancel (Commented out in original, kept commented) #}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</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 -->
{% if is_paginated %}
<nav aria-label="Interview pagination" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "First" %}</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "Previous" %}</a>
</li>
{% endif %}
<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>
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
<i class="fas fa-calendar-day"></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 A" }}<br>
{# --- Type/Location --- #}
<i class="fas {% if interview.location_type == 'Remote' %}fa-globe{% else %}fa-map-marker-alt{% endif %}"></i>
{% trans "Type" %}: {{ interview.location_type }}
{% if interview.location_type == 'Remote' %}<br>
{# Using interview.join_url directly if available, assuming interview is the full object #}
<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 %}
{% else %}<br>
<i class="fas fa-building"></i> {% trans "Location" %}: {{ interview.location_details|default:"Onsite" }}
{% endif %}
</p>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "Next" %}</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.urlencode %}&{{ request.GET.urlencode }}{% endif %}">{% trans "Last" %}</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
<div class="mt-auto pt-2 border-top">
<div class="d-flex gap-2">
<a href="{% url 'interview_detail' interview.slug %}" class="btn btn-sm btn-main-action">
<i class="fas fa-eye"></i> {% trans "View" %}
</a>
{# Join button logic simplified #}
{% if interview.location_type == 'Remote' and interview.join_url %}
<a href="{{ interview.join_url }}" target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-link"></i> {% trans "Join" %}
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{# FIX: Added the missing pagination include here #}
{% include "includes/paginator.html" %}
{% else %}
<!-- Empty State -->
<div class="text-center py-5">
<div class="mb-4">
<i class="fas fa-calendar fa-4x text-muted"></i>
<div class="text-center py-5 kaauh-card">
<div class="card-body">
<i class="fas fa-calendar fa-4x text-muted" style="color: var(--kaauh-teal-dark) !important;"></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>
<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>
{% endif %}
</div>
</div>
<!-- Action Modal -->
<div class="modal fade" id="actionModal" tabindex="-1" aria-labelledby="actionModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content kaauh-card">
@ -539,4 +456,4 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
</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">
<label for="{{ form.topic.id_for_label }}">{% trans "Topic" %}</label>
{{ form.topic }}
{% if form.topic.errors %}
<div class="text-danger small mt-1">{{ form.topic.errors }}</div>
{% endif %}
</div>
</div>
</div>
@ -152,6 +155,9 @@
<div class="form-group mb-3">
<label for="{{ form.schedule_interview_type.id_for_label }}">{% trans "Interview Type" %}</label>
{{ 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>
@ -160,6 +166,9 @@
<div class="form-group mb-3">
<label for="{{ form.start_date.id_for_label }}">{% trans "Start Date" %}</label>
{{ form.start_date }}
{% if form.start_date.errors %}
<div class="text-danger small mt-1">{{ form.start_date.errors }}</div>
{% endif %}
</div>
</div>
@ -167,6 +176,9 @@
<div class="form-group mb-3">
<label for="{{ form.end_date.id_for_label }}">{% trans "End Date" %}</label>
{{ form.end_date }}
{% if form.end_date.errors %}
<div class="text-danger small mt-1">{{ form.end_date.errors }}</div>
{% endif %}
</div>
</div>
</div>
@ -175,6 +187,9 @@
<label>{% trans "Working Days" %}</label>
<div class="d-flex flex-wrap gap-3 p-2 border rounded" style="background-color: #f8f9fa;">
{{ form.working_days }}
{% if form.working_days.errors %}
<div class="text-danger small mt-1">{{ form.working_days.errors }}</div>
{% endif %}
</div>
</div>
@ -183,6 +198,9 @@
<div class="form-group mb-3">
<label for="{{ form.start_time.id_for_label }}">{% trans "Start Time" %}</label>
{{ form.start_time }}
{% if form.start_time.errors %}
<div class="text-danger small mt-1">{{ form.start_time.errors }}</div>
{% endif %}
</div>
</div>
@ -190,6 +208,9 @@
<div class="form-group mb-3">
<label for="{{ form.end_time.id_for_label }}">{% trans "End Time" %}</label>
{{ form.end_time }}
{% if form.end_time.errors %}
<div class="text-danger small mt-1">{{ form.end_time.errors }}</div>
{% endif %}
</div>
</div>
@ -197,6 +218,9 @@
<div class="form-group mb-3">
<label for="{{ form.interview_duration.id_for_label }}">{% trans "Duration (min)" %}</label>
{{ form.interview_duration }}
{% if form.interview_duration.errors %}
<div class="text-danger small mt-1">{{ form.interview_duration.errors }}</div>
{% endif %}
</div>
</div>
@ -204,6 +228,9 @@
<div class="form-group mb-3">
<label for="{{ form.buffer_time.id_for_label }}">{% trans "Buffer (min)" %}</label>
{{ form.buffer_time }}
{% if form.buffer_time.errors %}
<div class="text-danger small mt-1">{{ form.buffer_time.errors }}</div>
{% endif %}
</div>
</div>
</div>
@ -215,10 +242,16 @@
<div class="col-5">
<label for="{{ form.break_start_time.id_for_label }}">{% trans "Start Time" %}</label>
{{ 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 class="col-5">
<label for="{{ form.break_end_time.id_for_label }}">{% trans "End Time" %}</label>
{{ 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>

View File

@ -108,7 +108,7 @@
<div class="stage-icon">
<i class="fas fa-file-signature cd_screening"></i>
</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>
</a>

View File

@ -50,12 +50,12 @@
<div class="input-group">
<input type="text" name="q" id="q" class="form-control"
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>
</button>
</div>
</div>
<div class="col-md-2">
<div class="col-md-1">
<label class="form-label">&nbsp;</label>
<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="filter-buttons">
<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>
{% if request.GET.q or request.GET.nationality or request.GET.gender %}
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary btn-sm">

View File

@ -333,41 +333,8 @@
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="{% trans 'Agency pagination' %}" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ 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>
<!-- Pagination -->
{% include "includes/paginator.html" %}
{% endif %}
{% else %}
<!-- Empty State -->

View File

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

View File

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

View File

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

View File

@ -128,49 +128,8 @@
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="Sources pagination">
<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 %}
{% include "includes/paginator.html" %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-database fa-3x text-muted mb-3"></i>