add ai interview questions

This commit is contained in:
ismail 2025-12-16 14:46:38 +03:00
parent 444eedc208
commit 85f895c891
13 changed files with 298 additions and 200 deletions

View File

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

View File

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

View File

@ -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): class Status(models.TextChoices):
WAITING = "waiting", _("Waiting") WAITING = "waiting", _("Waiting")
STARTED = "started", _("Started") STARTED = "started", _("Started")
UPDATED = "updated", _("Updated")
DELETED = "deleted", _("Deleted")
ENDED = "ended", _("Ended") ENDED = "ended", _("Ended")
CANCELLED = "cancelled", _("Cancelled")
class InterviewResult(models.TextChoices): class InterviewResult(models.TextChoices):
PASSED="passed",_("Passed") PASSED="passed",_("Passed")
@ -1154,7 +1155,7 @@ class Interview(Base):
blank=True, blank=True,
help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room'"), help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room'"),
) )
details_url = models.URLField( join_url = models.URLField(
verbose_name=_("Meeting/Location URL"), max_length=2048, blank=True, null=True verbose_name=_("Meeting/Location URL"), max_length=2048, blank=True, null=True
) )
timezone = models.CharField( timezone = models.CharField(
@ -1351,6 +1352,10 @@ class ScheduledInterview(Base):
choices=InterviewStatus.choices, choices=InterviewStatus.choices,
default=InterviewStatus.SCHEDULED, default=InterviewStatus.SCHEDULED,
) )
interview_questions = models.JSONField(
verbose_name=_("Question Data"),
blank=True
)
def __str__(self): def __str__(self):
return ( return (
@ -2591,58 +2596,6 @@ class Document(Base):
return "" 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): class Settings(Base):
"""Model to store key-value pair settings""" """Model to store key-value pair settings"""
name = models.CharField( name = models.CharField(
@ -2662,7 +2615,7 @@ class Settings(Base):
verbose_name=_("Setting Value"), verbose_name=_("Setting Value"),
help_text=_("Value for the setting"), help_text=_("Value for the setting"),
) )
class Meta: class Meta:
verbose_name = _("Setting") verbose_name = _("Setting")

View File

@ -732,7 +732,7 @@ def create_interview_and_meeting(schedule_id):
if result["status"] == "success": if result["status"] == "success":
interview.meeting_id = result["meeting_details"]["meeting_id"] interview.meeting_id = result["meeting_details"]["meeting_id"]
interview.details_url = result["meeting_details"]["join_url"] interview.join_url = result["meeting_details"]["join_url"]
interview.host_email = result["meeting_details"]["host_email"] interview.host_email = result["meeting_details"]["host_email"]
interview.password = result["meeting_details"]["password"] interview.password = result["meeting_details"]["password"]
interview.zoom_gateway_response = result["zoom_gateway_response"] interview.zoom_gateway_response = result["zoom_gateway_response"]
@ -757,32 +757,20 @@ def handle_zoom_webhook_event(payload):
event_type = payload.get("event") event_type = payload.get("event")
object_data = payload["payload"]["object"] object_data = payload["payload"]["object"]
# Zoom often uses a long 'id' for the scheduled meeting and sometimes a 'uuid'. meeting_id = str(object_data.get("id"))
# We rely on the unique 'id' that maps to your ZoomMeeting.meeting_id field. if not meeting_id:
meeting_id_zoom = str(object_data.get("id"))
if not meeting_id_zoom:
logger.warning(f"Webhook received without a valid Meeting ID: {event_type}") logger.warning(f"Webhook received without a valid Meeting ID: {event_type}")
return False return False
try: try:
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet, meeting_instance = Interview.objects.filter(meeting_id=meeting_id).first()
# and to simplify the logic flow.
meeting_instance = "" # TODO:update #ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first()
print(meeting_instance)
# --- 1. Creation and Update Events ---
if event_type == "meeting.updated": if event_type == "meeting.updated":
logger.info(f"Zoom meeting updated: {meeting_id}")
if meeting_instance: if meeting_instance:
# Update key fields from the webhook payload # Update key fields from the webhook payload
meeting_instance.topic = object_data.get( meeting_instance.topic = object_data.get(
"topic", meeting_instance.topic "topic", meeting_instance.topic
) )
# Check for and update status and time details
# if event_type == 'meeting.created':
# meeting_instance.status = 'scheduled'
# elif event_type == 'meeting.updated':
# Only update time fields if they are in the payload
print(object_data)
meeting_instance.start_time = object_data.get( meeting_instance.start_time = object_data.get(
"start_time", meeting_instance.start_time "start_time", meeting_instance.start_time
) )
@ -792,7 +780,6 @@ def handle_zoom_webhook_event(payload):
meeting_instance.timezone = object_data.get( meeting_instance.timezone = object_data.get(
"timezone", meeting_instance.timezone "timezone", meeting_instance.timezone
) )
meeting_instance.status = object_data.get( meeting_instance.status = object_data.get(
"status", meeting_instance.status "status", meeting_instance.status
) )
@ -807,31 +794,19 @@ def handle_zoom_webhook_event(payload):
] ]
) )
# --- 2. Status Change Events (Start/End) ---
elif event_type == "meeting.started":
if meeting_instance:
meeting_instance.status = "started"
meeting_instance.save(update_fields=["status"])
elif event_type == "meeting.ended":
if meeting_instance:
meeting_instance.status = "ended"
meeting_instance.save(update_fields=["status"])
# --- 3. Deletion Event (User Action) --- # --- 3. Deletion Event (User Action) ---
elif event_type == "meeting.deleted": elif event_type in ["meeting.started","meeting.ended","meeting.deleted"]:
if meeting_instance: if meeting_instance:
try: try:
meeting_instance.status = "cancelled" meeting_instance.status = event_type.split(".")[-1]
meeting_instance.save(update_fields=["status"]) meeting_instance.save(update_fields=["status"])
except Exception as e: except Exception as e:
logger.error(f"Failed to mark Zoom meeting as cancelled: {e}") logger.error(f"Failed to mark Zoom meeting as cancelled: {e}")
return True return True
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Failed to process Zoom webhook for {event_type} (ID: {meeting_id_zoom}): {e}", f"Failed to process Zoom webhook for {event_type} (ID: {meeting_id}): {e}",
exc_info=True, exc_info=True,
) )
return False return False
@ -1578,7 +1553,7 @@ def generate_interview_questions(schedule_id: int) -> dict:
Returns: Returns:
dict: Result containing status and generated questions or error message dict: Result containing status and generated questions or error message
""" """
from .models import ScheduledInterview, InterviewQuestion from .models import ScheduledInterview
try: try:
# Get the scheduled interview with related data # Get the scheduled interview with related data
@ -1619,7 +1594,7 @@ def generate_interview_questions(schedule_id: int) -> dict:
{candidate_resume_text} {candidate_resume_text}
TASK: TASK:
Generate 8-10 interview questions that are: Generate 8-10 interview questions in english and arabic that are:
1. Technical questions related to the job requirements 1. Technical questions related to the job requirements
2. Behavioral questions to assess soft skills and cultural fit 2. Behavioral questions to assess soft skills and cultural fit
3. Situational questions to evaluate problem-solving abilities 3. Situational questions to evaluate problem-solving abilities
@ -1634,17 +1609,27 @@ def generate_interview_questions(schedule_id: int) -> dict:
OUTPUT FORMAT: OUTPUT FORMAT:
Return a JSON object with the following structure: Return a JSON object with the following structure:
{{ {{
"questions": [ "questions": {{
"en":[
{{ {{
"question_text": "The actual question text", "question_text": "The actual question text",
"question_type": "technical|behavioral|situational", "question_type": "technical|behavioral|situational",
"difficulty_level": "easy|medium|hard", "difficulty_level": "easy|medium|hard",
"category": "Category name" "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. 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 # Call AI handler
@ -1664,33 +1649,14 @@ def generate_interview_questions(schedule_id: int) -> dict:
if not questions: if not questions:
return {"status": "error", "message": "No questions generated"} return {"status": "error", "message": "No questions generated"}
# Clear existing questions for this schedule schedule.interview_questions.update(questions)
InterviewQuestion.objects.filter(schedule=schedule).delete() schedule.save(update_fields=["interview_questions"])
# Save generated questions to database logger.info(f"Successfully generated questions for schedule {schedule_id}")
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 { return {
"status": "success", "status": "success",
"questions": created_questions, "message": f"Generated interview questions"
"message": f"Generated {len(created_questions)} interview questions"
} }
except ScheduledInterview.DoesNotExist: except ScheduledInterview.DoesNotExist:

View File

@ -870,7 +870,7 @@ def update_meeting(instance, updated_data):
instance.topic = zoom_details.get("topic", instance.topic) instance.topic = zoom_details.get("topic", instance.topic)
instance.duration = zoom_details.get("duration", instance.duration) instance.duration = zoom_details.get("duration", instance.duration)
instance.details_url = zoom_details.get("join_url", instance.details_url) instance.join_url = zoom_details.get("join_url", instance.join_url)
instance.password = zoom_details.get("password", instance.password) instance.password = zoom_details.get("password", instance.password)
instance.status = zoom_details.get("status") instance.status = zoom_details.get("status")

View File

@ -1605,7 +1605,6 @@ def _handle_preview_submission(request, slug, job):
""" """
SESSION_DATA_KEY = "interview_schedule_data" SESSION_DATA_KEY = "interview_schedule_data"
form = BulkInterviewTemplateForm(slug, request.POST) form = BulkInterviewTemplateForm(slug, request.POST)
# break_formset = BreakTimeFormSet(request.POST,prefix='breaktime')
if form.is_valid(): if form.is_valid():
# Get the form data # Get the form data
@ -1622,9 +1621,6 @@ def _handle_preview_submission(request, slug, job):
schedule_interview_type = form.cleaned_data["schedule_interview_type"] schedule_interview_type = form.cleaned_data["schedule_interview_type"]
physical_address = form.cleaned_data["physical_address"] physical_address = form.cleaned_data["physical_address"]
# Create a temporary schedule object (not saved to DB)
# if start_date == datetime.now().date():
# start_time = (datetime.now() + timedelta(minutes=30)).time()
temp_schedule = BulkInterviewTemplate( temp_schedule = BulkInterviewTemplate(
job=job, job=job,
start_date=start_date, start_date=start_date,
@ -2110,7 +2106,6 @@ def reschedule_meeting_for_application(request, slug):
if request.method == "POST": if request.method == "POST":
if interview.location_type == "Remote": if interview.location_type == "Remote":
form = ScheduledInterviewForm(request.POST) form = ScheduledInterviewForm(request.POST)
else: else:
form = OnsiteScheduleInterviewUpdateForm(request.POST) form = OnsiteScheduleInterviewUpdateForm(request.POST)
@ -2123,7 +2118,7 @@ def reschedule_meeting_for_application(request, slug):
if interview.location_type == "Remote": if interview.location_type == "Remote":
updated_data = { updated_data = {
"topic": topic, "topic": topic,
"start_time": start_time.isoformat() + "Z", "start_time": start_time.strftime("%Y-%m-%dT%H:%M:%S"),
"duration": duration, "duration": duration,
} }
result = update_meeting(schedule.interview, updated_data) result = update_meeting(schedule.interview, updated_data)
@ -2504,13 +2499,15 @@ def account_toggle_status(request, pk):
@csrf_exempt @csrf_exempt
def zoom_webhook_view(request): def zoom_webhook_view(request):
from .utils import get_setting
api_key = request.headers.get("X-Zoom-API-KEY") api_key = request.headers.get("X-Zoom-API-KEY")
if api_key != settings.ZOOM_WEBHOOK_API_KEY: if api_key != get_setting("ZOOM_WEBHOOK_API_KEY"):
return HttpResponse(status=405) return HttpResponse(status=405)
if request.method == "POST": if request.method == "POST":
try: try:
payload = json.loads(request.body) payload = json.loads(request.body)
logger.info(payload)
async_task("recruitment.tasks.handle_zoom_webhook_event", payload) async_task("recruitment.tasks.handle_zoom_webhook_event", payload)
return HttpResponse(status=200) return HttpResponse(status=200)
except Exception: except Exception:
@ -4775,7 +4772,6 @@ def interview_list(request):
def generate_ai_questions(request, slug): def generate_ai_questions(request, slug):
"""Generate AI-powered interview questions for a scheduled interview""" """Generate AI-powered interview questions for a scheduled interview"""
from django_q.tasks import async_task from django_q.tasks import async_task
from .models import InterviewQuestion
schedule = get_object_or_404(ScheduledInterview, slug=slug) schedule = get_object_or_404(ScheduledInterview, slug=slug)
@ -4783,40 +4779,41 @@ def generate_ai_questions(request, slug):
# Queue the AI question generation task # Queue the AI question generation task
task_id = async_task( task_id = async_task(
"recruitment.tasks.generate_interview_questions", "recruitment.tasks.generate_interview_questions",
schedule.id schedule.id,
sync=True
) )
if request.headers.get("X-Requested-With") == "XMLHttpRequest": # if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return JsonResponse({ # return JsonResponse({
"status": "success", # "status": "success",
"message": "AI question generation started in background", # "message": "AI question generation started in background",
"task_id": task_id # "task_id": task_id
}) # })
else: # else:
messages.success( # messages.success(
request, # request,
"AI question generation started. Questions will appear shortly." # "AI question generation started. Questions will appear shortly."
) # )
return redirect("interview_detail", slug=slug) # return redirect("interview_detail", slug=slug)
# For GET requests, return existing questions if any # # For GET requests, return existing questions if any
questions = schedule.ai_questions.all().order_by("created_at") # questions = schedule.ai_questions.all().order_by("created_at")
if request.headers.get("X-Requested-With") == "XMLHttpRequest": # if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return JsonResponse({ # return JsonResponse({
"status": "success", # "status": "success",
"questions": [ # "questions": [
{ # {
"id": q.id, # "id": q.id,
"text": q.question_text, # "text": q.question_text,
"type": q.question_type, # "type": q.question_type,
"difficulty": q.difficulty_level, # "difficulty": q.difficulty_level,
"category": q.category, # "category": q.category,
"created_at": q.created_at.isoformat() # "created_at": q.created_at.isoformat()
} # }
for q in questions # for q in questions
] # ]
}) # })
return redirect("interview_detail", slug=slug) return redirect("interview_detail", slug=slug)
@ -4829,24 +4826,20 @@ def interview_detail(request, slug):
ScheduledInterviewUpdateStatusForm, ScheduledInterviewUpdateStatusForm,
OnsiteScheduleInterviewUpdateForm, OnsiteScheduleInterviewUpdateForm,
) )
schedule = get_object_or_404(ScheduledInterview, slug=slug) schedule = get_object_or_404(ScheduledInterview, slug=slug)
interview = schedule.interview interview = schedule.interview
interview_result_form=InterviewResultForm(instance=interview) interview_result_form=InterviewResultForm(instance=interview)
application = schedule.application application = schedule.application
job = schedule.job job = schedule.job
print(interview.location_type)
if interview.location_type == "Remote": if interview.location_type == "Remote":
reschedule_form = ScheduledInterviewForm() reschedule_form = ScheduledInterviewForm()
else: else:
reschedule_form = OnsiteScheduleInterviewUpdateForm() reschedule_form = OnsiteScheduleInterviewUpdateForm()
reschedule_form.initial["physical_address"] = interview.physical_address reschedule_form.initial["physical_address"] = interview.physical_address
reschedule_form.initial["room_number"] = interview.room_number reschedule_form.initial["room_number"] = interview.room_number
reschedule_form.initial["topic"] = interview.topic reschedule_form.initial["topic"] = interview.topic
reschedule_form.initial["start_time"] = interview.start_time reschedule_form.initial["start_time"] = interview.start_time
reschedule_form.initial["duration"] = interview.duration reschedule_form.initial["duration"] = interview.duration
meeting = interview meeting = interview
interview_email_form = InterviewEmailForm(job, application, schedule) interview_email_form = InterviewEmailForm(job, application, schedule)
@ -6601,7 +6594,10 @@ def compose_application_email(request, slug):
if not email_addresses: if not email_addresses:
messages.error(request, "No email selected") messages.error(request, "No email selected")
referer = request.META.get("HTTP_REFERER") referer = request.META.get("HTTP_REFERER")
if "HX-Request" in request.headers:
response = HttpResponse()
response.headers["HX-Refresh"] = "true"
return response
if referer: if referer:
# Redirect back to the referring page # Redirect back to the referring page
return redirect(referer) return redirect(referer)
@ -6628,21 +6624,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) return redirect(request.path)
else: else:
# Form validation errors # Form validation errors
messages.error(request, "Please correct the errors below.") messages.error(request, "Please correct the errors below.")
# For HTMX requests, return error response # For HTMX requests, return error response
if "HX-Request" in request.headers: if "HX-Request" in request.headers:
return JsonResponse( response = HttpResponse()
{ response.headers["HX-Refresh"] = "true"
"success": False, return response
"error": "Please correct the form errors and try again.",
}
)
return render( return render(
request, request,

View File

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

View File

@ -305,11 +305,12 @@
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
} }
.htmx-indicator { .htmx-indicator {
display: none; opacity: 0;
transition: opacity 200ms ease-in;
} }
.htmx-indicator.htmx-request { .htmx-request .htmx-indicator {
display: block; opacity: 1;
} }
/* Responsive adjustments */ /* Responsive adjustments */
@ -459,7 +460,7 @@
<span class="detail-label">{% trans "Status:" %}</span> <span class="detail-label">{% trans "Status:" %}</span>
<span class="detail-value"> <span class="detail-value">
<span class="badge bg-primary-theme"> <span class="badge bg-primary-theme">
{{ schedule.status }}</span> {{ interview.status }}</span>
</span> </span>
</div> </div>
</div> </div>
@ -481,9 +482,9 @@
<span class="detail-label">{% trans "Password:" %}</span> <span class="detail-label">{% trans "Password:" %}</span>
<span class="detail-value">{{ interview.password }}</span> <span class="detail-value">{{ interview.password }}</span>
</div> </div>
{% if interview.details_url %} {% if interview.join_url %}
<div class="mt-3"> <div class="mt-3">
<a href="{{ interview.zoommeetingdetails.details_url }}" <a href="{{ interview.join_url }}"
target="_blank" target="_blank"
class="btn btn-main-action btn-sm w-100"> class="btn btn-main-action btn-sm w-100">
<i class="fas fa-video me-1"></i> {% trans "Join Meeting" %} <i class="fas fa-video me-1"></i> {% trans "Join Meeting" %}
@ -517,28 +518,84 @@
<h5 class="mb-0" style="color: var(--kaauh-teal-dark); font-weight: 600;"> <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" %} <i class="fas fa-brain me-2"></i> {% trans "AI Generated Questions" %}
</h5> </h5>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" <form action="{% url 'generate_ai_questions' schedule.slug %}" method="post">
class="btn btn-main-action btn-sm" {% csrf_token %}
id="generateQuestionsBtn" <button type="submit" class="btn btn-main-action btn-sm">
hx-post="{% url 'generate_ai_questions' schedule.slug %}" <span id="button-text-content">
hx-target="#aiQuestionsContainer" <i class="fas fa-magic me-1"></i> {% trans "Generate Interview Questions" %}
hx-indicator="#generateQuestionsSpinner"> </span>
<i class="fas fa-magic me-1"></i> {% trans "Generate Questions" %} </button>
</button> </form>
<button type="button" </div>
class="btn btn-outline-secondary btn-sm" </div>
id="refreshQuestionsBtn"
hx-get="{% url 'generate_ai_questions' schedule.slug %}" <div class="accordion" id="aiQuestionsAccordion">
hx-target="#aiQuestionsContainer" <div class="accordion-item">
hx-indicator="#refreshQuestionsSpinner"> <h2 class="accordion-header" id="aiQuestionsHeading">
<i class="fas fa-sync-alt me-1"></i> {% trans "Refresh" %} <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#aiQuestionsCollapse" aria-expanded="false" aria-controls="aiQuestionsCollapse">
</button> <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>
</div> </div>
<!-- Loading Spinners --> <!-- Loading Spinners -->
<div class="text-center py-3" id="generateQuestionsSpinner" class="htmx-indicator d-none"> {% comment %} <div class="text-center py-3" id="generateQuestionsSpinner" class="htmx-indicator d-none">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{% trans "Generating questions..." %}</span> <span class="visually-hidden">{% trans "Generating questions..." %}</span>
</div> </div>
@ -558,7 +615,7 @@
<i class="fas fa-brain fa-2x mb-3"></i> <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> <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> {% endcomment %}
</div> </div>
<div class="kaauh-card shadow-sm p-4"> <div class="kaauh-card shadow-sm p-4">
@ -682,13 +739,13 @@
<div class="action-buttons"> <div class="action-buttons">
{% if schedule.status != 'cancelled' and schedule.status != 'completed' %} {% if schedule.status != 'cancelled' and schedule.status != 'completed' %}
<button type="button" class="btn btn-main-action btn-sm" <button type="button" class="btn btn-main-action"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#rescheduleModal"> data-bs-target="#rescheduleModal">
<i class="fas fa-redo-alt me-1"></i> {% trans "Reschedule" %} <i class="fas fa-redo-alt me-1"></i> {% trans "Reschedule" %}
</button> </button>
<button type="button" class="btn btn-outline-danger btn-sm" <button type="button" class="btn btn-outline-danger"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#cancelModal"> data-bs-target="#cancelModal">
<i class="fas fa-times me-1"></i> {% trans "Cancel" %} <i class="fas fa-times me-1"></i> {% trans "Cancel" %}