Compare commits

...

2 Commits

Author SHA1 Message Date
444eedc208 fix the bulk remote meetings creation 2025-12-16 11:13:53 +03:00
3ae6d66dbd update 2025-12-16 11:12:32 +03:00
15 changed files with 815 additions and 151 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

@ -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
@ -1536,7 +1556,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")
@ -2167,7 +2187,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

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

@ -1122,7 +1122,7 @@ class Interview(Base):
STARTED = "started", _("Started")
ENDED = "ended", _("Ended")
CANCELLED = "cancelled", _("Cancelled")
class InterviewResult(models.TextChoices):
PASSED="passed",_("Passed")
FAILED="failed",_("Failed")
@ -2591,6 +2591,58 @@ class Document(Base):
return ""
class InterviewQuestion(Base):
"""Model to store AI-generated interview questions"""
class QuestionType(models.TextChoices):
TECHNICAL = "technical", _("Technical")
BEHAVIORAL = "behavioral", _("Behavioral")
SITUATIONAL = "situational", _("Situational")
schedule = models.ForeignKey(
'ScheduledInterview',
on_delete=models.CASCADE,
related_name="ai_questions",
verbose_name=_("Interview Schedule")
)
question_text = models.TextField(
verbose_name=_("Question Text")
)
question_type = models.CharField(
max_length=20,
choices=QuestionType.choices,
default=QuestionType.TECHNICAL,
verbose_name=_("Question Type")
)
difficulty_level = models.CharField(
max_length=20,
choices=[
("easy", _("Easy")),
("medium", _("Medium")),
("hard", _("Hard")),
],
default="medium",
verbose_name=_("Difficulty Level")
)
category = models.CharField(
max_length=100,
blank=True,
verbose_name=_("Category")
)
class Meta:
verbose_name = _("Interview Question")
verbose_name_plural = _("Interview Questions")
ordering = ["created_at"]
indexes = [
models.Index(fields=["schedule", "question_type"]),
]
def __str__(self):
return f"{self.get_question_type_display()} Question for {self.schedule}"
class Settings(Base):
"""Model to store key-value pair settings"""
name = models.CharField(

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

@ -722,22 +722,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.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 +749,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.
@ -1565,6 +1568,142 @@ 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, InterviewQuestion
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 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": [
{{
"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.
"""
# 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"}
# Clear existing questions for this schedule
InterviewQuestion.objects.filter(schedule=schedule).delete()
# Save generated questions to database
created_questions = []
for q_data in questions:
question = InterviewQuestion.objects.create(
schedule=schedule,
question_text=q_data.get("question_text", ""),
question_type=q_data.get("question_type", "technical"),
difficulty_level=q_data.get("difficulty_level", "medium"),
category=q_data.get("category", "General")
)
created_questions.append({
"id": question.id,
"text": question.question_text,
"type": question.question_type,
"difficulty": question.difficulty_level,
"category": question.category
})
logger.info(f"Successfully generated {len(created_questions)} questions for schedule {schedule_id}")
return {
"status": "success",
"questions": created_questions,
"message": f"Generated {len(created_questions)} 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 +1715,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.",

View File

@ -1623,6 +1623,8 @@ def _handle_preview_submission(request, slug, job):
physical_address = form.cleaned_data["physical_address"]
# Create a temporary schedule object (not saved to DB)
# if start_date == datetime.now().date():
# start_time = (datetime.now() + timedelta(minutes=30)).time()
temp_schedule = BulkInterviewTemplate(
job=job,
start_date=start_date,
@ -1640,7 +1642,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 +1761,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 +1808,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 +2110,7 @@ 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)
@ -3058,7 +3025,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 +3649,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 +3667,7 @@ def message_create(request):
},
)
# Send email using unified service
if email_result:
messages.success(
request, "Message sent successfully via email!"
@ -3755,7 +3722,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 +4255,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 +4422,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 +4437,7 @@ def api_application_detail(request, candidate_id):
# },
# )
# return redirect(request.path)
# else:
# # Form validation errors
@ -4754,7 +4723,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",
@ -4801,6 +4770,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
from .models import InterviewQuestion
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
)
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,7 +4830,7 @@ def interview_detail(request, slug):
OnsiteScheduleInterviewUpdateForm,
)
schedule = get_object_or_404(ScheduledInterview, slug=slug)
interview = schedule.interview
@ -4824,9 +4844,9 @@ def interview_detail(request, slug):
reschedule_form = OnsiteScheduleInterviewUpdateForm()
reschedule_form.initial["physical_address"] = interview.physical_address
reschedule_form.initial["room_number"] = interview.room_number
reschedule_form.initial["topic"] = interview.topic
reschedule_form.initial["start_time"] = interview.start_time
reschedule_form.initial["duration"] = interview.duration
reschedule_form.initial["topic"] = interview.topic
reschedule_form.initial["start_time"] = interview.start_time
reschedule_form.initial["duration"] = interview.duration
meeting = interview
interview_email_form = InterviewEmailForm(job, application, schedule)
@ -6462,7 +6482,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 +6522,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 +6554,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 +6566,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
}
)
@ -6591,7 +6611,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,
@ -6609,7 +6629,7 @@ def compose_application_email(request, slug):
},
)
return redirect(request.path)
else:
# Form validation errors

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

@ -192,6 +192,126 @@
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 {
display: none;
}
.htmx-indicator.htmx-request {
display: block;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.action-buttons {
@ -200,6 +320,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 +425,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>
@ -378,6 +511,56 @@
</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">
<button type="button"
class="btn btn-main-action btn-sm"
id="generateQuestionsBtn"
hx-post="{% url 'generate_ai_questions' schedule.slug %}"
hx-target="#aiQuestionsContainer"
hx-indicator="#generateQuestionsSpinner">
<i class="fas fa-magic me-1"></i> {% trans "Generate Questions" %}
</button>
<button type="button"
class="btn btn-outline-secondary btn-sm"
id="refreshQuestionsBtn"
hx-get="{% url 'generate_ai_questions' schedule.slug %}"
hx-target="#aiQuestionsContainer"
hx-indicator="#refreshQuestionsSpinner">
<i class="fas fa-sync-alt me-1"></i> {% trans "Refresh" %}
</button>
</div>
</div>
<!-- Loading Spinners -->
<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>
</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 +577,7 @@
</div>
</div>
</div>
{% if schedule.status == 'confirmed' %}
<div class="timeline-item">
<div class="timeline-content">
@ -403,7 +586,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 +599,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 +612,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 +673,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;">
@ -759,4 +942,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

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