From 85f895c891dca71d2401bb0339426f79b188b7ab Mon Sep 17 00:00:00 2001 From: ismail Date: Tue, 16 Dec 2025 14:46:38 +0300 Subject: [PATCH] add ai interview questions --- NorahUniversity/urls.py | 2 +- recruitment/admin.py | 3 +- ...04_interviewquestion_0004_settings_name.py | 14 +++ .../migrations/0006_interview_join_url.py | 18 +++ .../migrations/0007_alter_interview_status.py | 18 +++ ...recruitment_schedul_b09a70_idx_and_more.py | 48 ++++++++ ...recruitment_schedul_dbb350_idx_and_more.py | 27 +++++ recruitment/models.py | 63 ++-------- recruitment/tasks.py | 86 ++++--------- recruitment/utils.py | 2 +- recruitment/views.py | 102 ++++++++-------- templates/base.html | 2 +- templates/interviews/interview_detail.html | 113 +++++++++++++----- 13 files changed, 298 insertions(+), 200 deletions(-) create mode 100644 recruitment/migrations/0005_merge_0004_interviewquestion_0004_settings_name.py create mode 100644 recruitment/migrations/0006_interview_join_url.py create mode 100644 recruitment/migrations/0007_alter_interview_status.py create mode 100644 recruitment/migrations/0008_remove_interviewquestion_recruitment_schedul_b09a70_idx_and_more.py create mode 100644 recruitment/migrations/0009_remove_interviewquestion_recruitment_schedul_dbb350_idx_and_more.py diff --git a/NorahUniversity/urls.py b/NorahUniversity/urls.py index fc32f03..59170ce 100644 --- a/NorahUniversity/urls.py +++ b/NorahUniversity/urls.py @@ -33,12 +33,12 @@ urlpatterns = [ path('api/v1/templates/save/', views.save_form_template, name='save_form_template'), path('api/v1/templates//', views.load_form_template, name='load_form_template'), path('api/v1/templates//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//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_job'), + path('api/v1/webhooks/zoom/', views.zoom_webhook_view, name='zoom_webhook_view'), ] urlpatterns += i18n_patterns( diff --git a/recruitment/admin.py b/recruitment/admin.py index 6fca673..f1dd957 100644 --- a/recruitment/admin.py +++ b/recruitment/admin.py @@ -27,4 +27,5 @@ admin.site.register(IntegrationLog) admin.site.register(HiringAgency) admin.site.register(JobPosting) admin.site.register(Settings) -admin.site.register(FormSubmission) \ No newline at end of file +admin.site.register(FormSubmission) +# admin.site.register(InterviewQuestion) \ No newline at end of file diff --git a/recruitment/migrations/0005_merge_0004_interviewquestion_0004_settings_name.py b/recruitment/migrations/0005_merge_0004_interviewquestion_0004_settings_name.py new file mode 100644 index 0000000..1ba0c59 --- /dev/null +++ b/recruitment/migrations/0005_merge_0004_interviewquestion_0004_settings_name.py @@ -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 = [ + ] diff --git a/recruitment/migrations/0006_interview_join_url.py b/recruitment/migrations/0006_interview_join_url.py new file mode 100644 index 0000000..b7641f2 --- /dev/null +++ b/recruitment/migrations/0006_interview_join_url.py @@ -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'), + ), + ] diff --git a/recruitment/migrations/0007_alter_interview_status.py b/recruitment/migrations/0007_alter_interview_status.py new file mode 100644 index 0000000..3951a2f --- /dev/null +++ b/recruitment/migrations/0007_alter_interview_status.py @@ -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), + ), + ] diff --git a/recruitment/migrations/0008_remove_interviewquestion_recruitment_schedul_b09a70_idx_and_more.py b/recruitment/migrations/0008_remove_interviewquestion_recruitment_schedul_b09a70_idx_and_more.py new file mode 100644 index 0000000..906a5c0 --- /dev/null +++ b/recruitment/migrations/0008_remove_interviewquestion_recruitment_schedul_b09a70_idx_and_more.py @@ -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', + ), + ] diff --git a/recruitment/migrations/0009_remove_interviewquestion_recruitment_schedul_dbb350_idx_and_more.py b/recruitment/migrations/0009_remove_interviewquestion_recruitment_schedul_dbb350_idx_and_more.py new file mode 100644 index 0000000..291e5be --- /dev/null +++ b/recruitment/migrations/0009_remove_interviewquestion_recruitment_schedul_dbb350_idx_and_more.py @@ -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', + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index 7360588..af5a137 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -1120,8 +1120,9 @@ class Interview(Base): class Status(models.TextChoices): WAITING = "waiting", _("Waiting") STARTED = "started", _("Started") + UPDATED = "updated", _("Updated") + DELETED = "deleted", _("Deleted") ENDED = "ended", _("Ended") - CANCELLED = "cancelled", _("Cancelled") class InterviewResult(models.TextChoices): PASSED="passed",_("Passed") @@ -1154,7 +1155,7 @@ class Interview(Base): blank=True, help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room'"), ) - details_url = models.URLField( + join_url = models.URLField( verbose_name=_("Meeting/Location URL"), max_length=2048, blank=True, null=True ) timezone = models.CharField( @@ -1351,6 +1352,10 @@ class ScheduledInterview(Base): choices=InterviewStatus.choices, default=InterviewStatus.SCHEDULED, ) + interview_questions = models.JSONField( + verbose_name=_("Question Data"), + blank=True + ) def __str__(self): return ( @@ -2591,58 +2596,6 @@ 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( @@ -2662,7 +2615,7 @@ class Settings(Base): verbose_name=_("Setting Value"), help_text=_("Value for the setting"), ) - + class Meta: verbose_name = _("Setting") diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 6ae6453..243300c 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -732,7 +732,7 @@ def create_interview_and_meeting(schedule_id): if result["status"] == "success": 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.password = result["meeting_details"]["password"] interview.zoom_gateway_response = result["zoom_gateway_response"] @@ -757,32 +757,20 @@ def handle_zoom_webhook_event(payload): event_type = payload.get("event") object_data = payload["payload"]["object"] - # Zoom often uses a long 'id' for the scheduled meeting and sometimes a 'uuid'. - # We rely on the unique 'id' that maps to your ZoomMeeting.meeting_id field. - meeting_id_zoom = str(object_data.get("id")) - if not meeting_id_zoom: + meeting_id = str(object_data.get("id")) + if not meeting_id: logger.warning(f"Webhook received without a valid Meeting ID: {event_type}") return False try: - # Use filter().first() to avoid exceptions if the meeting doesn't exist yet, - # and to simplify the logic flow. - meeting_instance = "" # TODO:update #ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first() - print(meeting_instance) - # --- 1. Creation and Update Events --- + meeting_instance = Interview.objects.filter(meeting_id=meeting_id).first() if event_type == "meeting.updated": + logger.info(f"Zoom meeting updated: {meeting_id}") if meeting_instance: # Update key fields from the webhook payload meeting_instance.topic = object_data.get( "topic", meeting_instance.topic ) - - # Check for and update status and time details - # if event_type == 'meeting.created': - # meeting_instance.status = 'scheduled' - # elif event_type == 'meeting.updated': - # Only update time fields if they are in the payload - print(object_data) meeting_instance.start_time = object_data.get( "start_time", meeting_instance.start_time ) @@ -792,7 +780,6 @@ def handle_zoom_webhook_event(payload): meeting_instance.timezone = object_data.get( "timezone", meeting_instance.timezone ) - meeting_instance.status = object_data.get( "status", meeting_instance.status ) @@ -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) --- - elif event_type == "meeting.deleted": + elif event_type in ["meeting.started","meeting.ended","meeting.deleted"]: if meeting_instance: try: - meeting_instance.status = "cancelled" + meeting_instance.status = event_type.split(".")[-1] meeting_instance.save(update_fields=["status"]) except Exception as e: logger.error(f"Failed to mark Zoom meeting as cancelled: {e}") - return True except Exception as e: logger.error( - f"Failed to process Zoom webhook for {event_type} (ID: {meeting_id_zoom}): {e}", + f"Failed to process Zoom webhook for {event_type} (ID: {meeting_id}): {e}", exc_info=True, ) return False @@ -1578,7 +1553,7 @@ def generate_interview_questions(schedule_id: int) -> dict: Returns: dict: Result containing status and generated questions or error message """ - from .models import ScheduledInterview, InterviewQuestion + from .models import ScheduledInterview try: # Get the scheduled interview with related data @@ -1619,7 +1594,7 @@ def generate_interview_questions(schedule_id: int) -> dict: {candidate_resume_text} 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 2. Behavioral questions to assess soft skills and cultural fit 3. Situational questions to evaluate problem-solving abilities @@ -1634,17 +1609,27 @@ def generate_interview_questions(schedule_id: int) -> dict: OUTPUT FORMAT: Return a JSON object with the following structure: {{ - "questions": [ + "questions": {{ + "en":[ {{ "question_text": "The actual question text", "question_type": "technical|behavioral|situational", "difficulty_level": "easy|medium|hard", "category": "Category name" }} - ] + ], + "ar":[ + {{ + "question_text": "The actual question text", + "question_type": "technical|behavioral|situational", + "difficulty_level": "easy|medium|hard", + "category": "Category name" + }} + ]}} }} Make questions specific to the job requirements and candidate background. Avoid generic questions. + Output only valid JSON — no markdown, no extra text. """ # Call AI handler @@ -1664,33 +1649,14 @@ def generate_interview_questions(schedule_id: int) -> dict: if not questions: return {"status": "error", "message": "No questions generated"} - # Clear existing questions for this schedule - InterviewQuestion.objects.filter(schedule=schedule).delete() + schedule.interview_questions.update(questions) + schedule.save(update_fields=["interview_questions"]) - # 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}") + logger.info(f"Successfully generated questions for schedule {schedule_id}") return { "status": "success", - "questions": created_questions, - "message": f"Generated {len(created_questions)} interview questions" + "message": f"Generated interview questions" } except ScheduledInterview.DoesNotExist: diff --git a/recruitment/utils.py b/recruitment/utils.py index 5441e1f..16493f1 100644 --- a/recruitment/utils.py +++ b/recruitment/utils.py @@ -870,7 +870,7 @@ def update_meeting(instance, updated_data): instance.topic = zoom_details.get("topic", instance.topic) instance.duration = zoom_details.get("duration", instance.duration) - instance.details_url = zoom_details.get("join_url", instance.details_url) + instance.join_url = zoom_details.get("join_url", instance.join_url) instance.password = zoom_details.get("password", instance.password) instance.status = zoom_details.get("status") diff --git a/recruitment/views.py b/recruitment/views.py index cc4af48..659aab0 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -1605,7 +1605,6 @@ def _handle_preview_submission(request, slug, job): """ SESSION_DATA_KEY = "interview_schedule_data" form = BulkInterviewTemplateForm(slug, request.POST) - # break_formset = BreakTimeFormSet(request.POST,prefix='breaktime') if form.is_valid(): # Get the form data @@ -1622,9 +1621,6 @@ def _handle_preview_submission(request, slug, job): schedule_interview_type = form.cleaned_data["schedule_interview_type"] physical_address = form.cleaned_data["physical_address"] - # Create a temporary schedule object (not saved to DB) - # if start_date == datetime.now().date(): - # start_time = (datetime.now() + timedelta(minutes=30)).time() temp_schedule = BulkInterviewTemplate( job=job, start_date=start_date, @@ -2110,7 +2106,6 @@ def reschedule_meeting_for_application(request, slug): if request.method == "POST": if interview.location_type == "Remote": - form = ScheduledInterviewForm(request.POST) else: form = OnsiteScheduleInterviewUpdateForm(request.POST) @@ -2123,7 +2118,7 @@ def reschedule_meeting_for_application(request, slug): if interview.location_type == "Remote": updated_data = { "topic": topic, - "start_time": start_time.isoformat() + "Z", + "start_time": start_time.strftime("%Y-%m-%dT%H:%M:%S"), "duration": duration, } result = update_meeting(schedule.interview, updated_data) @@ -2504,13 +2499,15 @@ def account_toggle_status(request, pk): @csrf_exempt def zoom_webhook_view(request): + from .utils import get_setting api_key = request.headers.get("X-Zoom-API-KEY") - if api_key != settings.ZOOM_WEBHOOK_API_KEY: + if api_key != get_setting("ZOOM_WEBHOOK_API_KEY"): return HttpResponse(status=405) if request.method == "POST": try: payload = json.loads(request.body) + logger.info(payload) async_task("recruitment.tasks.handle_zoom_webhook_event", payload) return HttpResponse(status=200) except Exception: @@ -4775,7 +4772,6 @@ def interview_list(request): 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) @@ -4783,40 +4779,41 @@ def generate_ai_questions(request, slug): # Queue the AI question generation task task_id = async_task( "recruitment.tasks.generate_interview_questions", - schedule.id + schedule.id, + sync=True ) - if request.headers.get("X-Requested-With") == "XMLHttpRequest": - return JsonResponse({ - "status": "success", - "message": "AI question generation started in background", - "task_id": task_id - }) - else: - messages.success( - request, - "AI question generation started. Questions will appear shortly." - ) - return redirect("interview_detail", slug=slug) + # 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") + # # 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 - ] - }) + # 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) @@ -4829,24 +4826,20 @@ def interview_detail(request, slug): ScheduledInterviewUpdateStatusForm, OnsiteScheduleInterviewUpdateForm, ) - - - schedule = get_object_or_404(ScheduledInterview, slug=slug) interview = schedule.interview interview_result_form=InterviewResultForm(instance=interview) application = schedule.application job = schedule.job - print(interview.location_type) if interview.location_type == "Remote": reschedule_form = ScheduledInterviewForm() else: 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) @@ -6601,7 +6594,10 @@ def compose_application_email(request, slug): if not email_addresses: messages.error(request, "No email selected") referer = request.META.get("HTTP_REFERER") - + if "HX-Request" in request.headers: + response = HttpResponse() + response.headers["HX-Refresh"] = "true" + return response if referer: # Redirect back to the referring page return redirect(referer) @@ -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) - else: # Form validation errors messages.error(request, "Please correct the errors below.") # For HTMX requests, return error response if "HX-Request" in request.headers: - return JsonResponse( - { - "success": False, - "error": "Please correct the form errors and try again.", - } - ) + response = HttpResponse() + response.headers["HX-Refresh"] = "true" + return response return render( request, diff --git a/templates/base.html b/templates/base.html index 4eb2044..a2bbb1e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -459,7 +459,7 @@ }); } - //form_loader(); + form_loader(); try{ document.body.addEventListener('htmx:afterRequest', function(evt) { diff --git a/templates/interviews/interview_detail.html b/templates/interviews/interview_detail.html index d92a864..ffc2427 100644 --- a/templates/interviews/interview_detail.html +++ b/templates/interviews/interview_detail.html @@ -305,11 +305,12 @@ text-align: center; padding: 2rem; } - .htmx-indicator { - display: none; + .htmx-indicator { + opacity: 0; + transition: opacity 200ms ease-in; } - .htmx-indicator.htmx-request { - display: block; + .htmx-request .htmx-indicator { + opacity: 1; } /* Responsive adjustments */ @@ -459,7 +460,7 @@ {% trans "Status:" %} - {{ schedule.status }} + {{ interview.status }} @@ -481,9 +482,9 @@ {% trans "Password:" %} {{ interview.password }} - {% if interview.details_url %} + {% if interview.join_url %} {% endcomment %}
@@ -682,13 +739,13 @@
{% if schedule.status != 'cancelled' and schedule.status != 'completed' %} - -