Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend

This commit is contained in:
Faheed 2025-12-16 16:05:15 +03:00
commit 954f256997
23 changed files with 969 additions and 207 deletions

6
.env
View File

@ -1,3 +1,3 @@
DB_NAME=haikal_db
DB_USER=faheed
DB_PASSWORD=Faheed@215
DB_NAME=norahuniversity
DB_USER=norahuniversity
DB_PASSWORD=norahuniversity

View File

@ -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

@ -28,3 +28,4 @@ admin.site.register(HiringAgency)
admin.site.register(JobPosting)
admin.site.register(Settings)
admin.site.register(FormSubmission)
# admin.site.register(InterviewQuestion)

View File

@ -690,20 +690,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

View File

@ -0,0 +1,35 @@
# Generated by Django 6.0 on 2025-12-15 13:59
import django.db.models.deletion
import django_extensions.db.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0003_interview_interview_result_interview_result_comments'),
]
operations = [
migrations.CreateModel(
name='InterviewQuestion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('question_text', models.TextField(verbose_name='Question Text')),
('question_type', models.CharField(choices=[('technical', 'Technical'), ('behavioral', 'Behavioral'), ('situational', 'Situational')], default='technical', max_length=20, verbose_name='Question Type')),
('difficulty_level', models.CharField(choices=[('easy', 'Easy'), ('medium', 'Medium'), ('hard', 'Hard')], default='medium', max_length=20, verbose_name='Difficulty Level')),
('category', models.CharField(blank=True, max_length=100, verbose_name='Category')),
('schedule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_questions', to='recruitment.scheduledinterview', verbose_name='Interview Schedule')),
],
options={
'verbose_name': 'Interview Question',
'verbose_name_plural': 'Interview Questions',
'ordering': ['created_at'],
'indexes': [models.Index(fields=['schedule', 'question_type'], name='recruitment_schedul_b09a70_idx')],
},
),
]

View File

@ -0,0 +1,14 @@
# Generated by Django 6.0 on 2025-12-16 08:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0004_interviewquestion'),
('recruitment', '0004_settings_name'),
]
operations = [
]

View File

@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2025-12-16 09:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0005_merge_0004_interviewquestion_0004_settings_name'),
]
operations = [
migrations.AddField(
model_name='interview',
name='join_url',
field=models.URLField(blank=True, max_length=2048, null=True, verbose_name='Meeting/Location URL'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2025-12-16 10:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0006_interview_join_url'),
]
operations = [
migrations.AlterField(
model_name='interview',
name='status',
field=models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('updated', 'Updated'), ('ended', 'Ended'), ('deleted', 'Deleted')], db_index=True, default='waiting', max_length=20),
),
]

View File

@ -0,0 +1,48 @@
# Generated by Django 6.0 on 2025-12-16 10:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0007_alter_interview_status'),
]
operations = [
migrations.RemoveIndex(
model_name='interviewquestion',
name='recruitment_schedul_b09a70_idx',
),
migrations.AddField(
model_name='interviewquestion',
name='data',
field=models.JSONField(blank=True, default=1, verbose_name='Question Data'),
preserve_default=False,
),
migrations.AlterField(
model_name='interview',
name='status',
field=models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('updated', 'Updated'), ('deleted', 'Deleted'), ('ended', 'Ended')], db_index=True, default='waiting', max_length=20),
),
migrations.AddIndex(
model_name='interviewquestion',
index=models.Index(fields=['schedule'], name='recruitment_schedul_dbb350_idx'),
),
migrations.RemoveField(
model_name='interviewquestion',
name='category',
),
migrations.RemoveField(
model_name='interviewquestion',
name='difficulty_level',
),
migrations.RemoveField(
model_name='interviewquestion',
name='question_text',
),
migrations.RemoveField(
model_name='interviewquestion',
name='question_type',
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 6.0 on 2025-12-16 10:48
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0008_remove_interviewquestion_recruitment_schedul_b09a70_idx_and_more'),
]
operations = [
migrations.RemoveIndex(
model_name='interviewquestion',
name='recruitment_schedul_dbb350_idx',
),
migrations.AddField(
model_name='scheduledinterview',
name='interview_questions',
field=models.JSONField(blank=True, default={}, verbose_name='Question Data'),
preserve_default=False,
),
migrations.DeleteModel(
name='InterviewQuestion',
),
]

View File

@ -1120,8 +1120,9 @@ 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")
@ -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
)
def __str__(self):
return (

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

@ -728,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:
@ -751,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.
@ -760,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
)
@ -795,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
)
@ -810,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
@ -1571,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,
@ -1582,18 +1687,18 @@ 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({

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

@ -1606,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
@ -1623,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,
@ -1641,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,
@ -1761,53 +1758,25 @@ 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
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!",
)
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)
elif schedule_data.get("schedule_interview_type") == "Onsite":
try:
for i, application in enumerate(applications):
if i < len(available_slots):
slot = available_slots[i]
start_dt = datetime.combine(slot["date"], schedule.start_time)
# 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_dt,
start_time=start_time,
duration=schedule.interview_duration,
location_type="Onsite",
physical_address=schedule.physical_address,
)
# 2. Create the ScheduledInterview, linking the unique location
ScheduledInterview.objects.create(
scheduled = ScheduledInterview.objects.create(
application=application,
job=job,
schedule=schedule,
@ -1816,11 +1785,13 @@ def _handle_confirm_schedule(request, slug, job):
interview=interview,
)
messages.success(
request, f"created successfully for {len(applications)} application."
)
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)
messages.success(request,f"Schedule successfully created.")
# 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:
@ -1828,23 +1799,15 @@ def _handle_confirm_schedule(request, slug, job):
return redirect("applications_interview_view", slug=slug)
except Exception as e:
messages.error(request, f"Error creating onsite interviews: {e}")
return redirect("schedule_interviews", slug=slug)
@login_required
@staff_user_required
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
@ -2144,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)
@ -2157,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)
@ -2538,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:
@ -4292,6 +4256,8 @@ def update_interview_result(request,slug):
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}."))
@ -4802,6 +4768,57 @@ def interview_list(request):
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):
@ -4810,15 +4827,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:
@ -6582,7 +6595,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)
@ -6609,21 +6625,21 @@ 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
messages.error(request, "Please correct the errors below.")
# 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

@ -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 %}
@ -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" %}
@ -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" %}

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

@ -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

@ -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