add ai interview questions
This commit is contained in:
parent
444eedc208
commit
85f895c891
@ -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(
|
||||||
|
|||||||
@ -28,3 +28,4 @@ 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)
|
||||||
@ -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 = [
|
||||||
|
]
|
||||||
18
recruitment/migrations/0006_interview_join_url.py
Normal file
18
recruitment/migrations/0006_interview_join_url.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
recruitment/migrations/0007_alter_interview_status.py
Normal file
18
recruitment/migrations/0007_alter_interview_status.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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,15 +4826,11 @@ def interview_detail(request, slug):
|
|||||||
ScheduledInterviewUpdateStatusForm,
|
ScheduledInterviewUpdateStatusForm,
|
||||||
OnsiteScheduleInterviewUpdateForm,
|
OnsiteScheduleInterviewUpdateForm,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
schedule = get_object_or_404(ScheduledInterview, slug=slug)
|
schedule = get_object_or_404(ScheduledInterview, slug=slug)
|
||||||
interview = schedule.interview
|
interview = schedule.interview
|
||||||
interview_result_form=InterviewResultForm(instance=interview)
|
interview_result_form=InterviewResultForm(instance=interview)
|
||||||
application = schedule.application
|
application = schedule.application
|
||||||
job = schedule.job
|
job = schedule.job
|
||||||
print(interview.location_type)
|
|
||||||
if interview.location_type == "Remote":
|
if interview.location_type == "Remote":
|
||||||
reschedule_form = ScheduledInterviewForm()
|
reschedule_form = ScheduledInterviewForm()
|
||||||
else:
|
else:
|
||||||
@ -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,
|
||||||
|
|||||||
@ -459,7 +459,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//form_loader();
|
form_loader();
|
||||||
|
|
||||||
try{
|
try{
|
||||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||||
|
|||||||
@ -306,10 +306,11 @@
|
|||||||
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" %}
|
||||||
@ -518,27 +519,83 @@
|
|||||||
<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>
|
||||||
<button type="button"
|
</form>
|
||||||
class="btn btn-outline-secondary btn-sm"
|
</div>
|
||||||
id="refreshQuestionsBtn"
|
</div>
|
||||||
hx-get="{% url 'generate_ai_questions' schedule.slug %}"
|
|
||||||
hx-target="#aiQuestionsContainer"
|
<div class="accordion" id="aiQuestionsAccordion">
|
||||||
hx-indicator="#refreshQuestionsSpinner">
|
<div class="accordion-item">
|
||||||
<i class="fas fa-sync-alt me-1"></i> {% trans "Refresh" %}
|
<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>
|
</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" %}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user