From 3ae6d66dbda814989ef5c94355d8eb2e7b15204b Mon Sep 17 00:00:00 2001 From: ismail Date: Mon, 15 Dec 2025 16:46:48 +0300 Subject: [PATCH 1/3] update --- recruitment/models.py | 56 +++++ recruitment/signals.py | 14 +- recruitment/tasks.py | 168 +++++++++++++-- recruitment/urls.py | 1 + recruitment/views.py | 57 ++++- templates/interviews/interview_detail.html | 197 +++++++++++++++++- .../partials/ai_questions_list.html | 170 +++++++++++++++ templates/people/person_list.html | 2 +- 8 files changed, 627 insertions(+), 38 deletions(-) create mode 100644 templates/interviews/partials/ai_questions_list.html diff --git a/recruitment/models.py b/recruitment/models.py index 0598ef1..5c9104d 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -2591,6 +2591,62 @@ class Document(Base): return "" +class InterviewQuestion(models.Model): + """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") + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name=_("Created At") + ) + + class Meta: + verbose_name = _("Interview Question") + verbose_name_plural = _("Interview Questions") + ordering = ["created_at"] + indexes = [ + models.Index(fields=["schedule", "question_type"]), + models.Index(fields=["created_at"]), + ] + + 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( diff --git a/recruitment/signals.py b/recruitment/signals.py index 6d288d4..9afbaac 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -142,22 +142,14 @@ def create_default_stages(sender, instance, created, **kwargs): if created: with transaction.atomic(): # Stage 1: Contact Information - contact_stage = FormStage.objects.create( + resume_upload = FormStage.objects.create( template=instance, - name="Contact Information", + name="Resume Upload", order=0, is_predefined=True, ) FormField.objects.create( - stage=contact_stage, - label="GPA", - field_type="text", - required=False, - order=1, - is_predefined=True, - ) - FormField.objects.create( - stage=contact_stage, + stage=resume_upload, label="Resume Upload", field_type="file", required=True, diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 68105e4..c601196 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -1565,16 +1565,152 @@ def send_email_task( "message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}." }) -# def send_single_email_task( -# recipient_emails, -# subject: str, -# template_name: str, -# context: dict, -# ) -> str: -# """ -# Django-Q task to send a bulk email asynchronously. -# """ -# from .services.email_service import EmailService +def generate_interview_questions(schedule_id: int) -> dict: + """ + Generate AI-powered interview questions based on job requirements and candidate profile. + + Args: + schedule_id (int): The ID of the scheduled interview + + Returns: + dict: Result containing status and generated questions or error message + """ + from .models import ScheduledInterview, InterviewQuestion + + try: + # Get the scheduled interview with related data + schedule = ScheduledInterview.objects.get(pk=schedule_id) + application = schedule.application + job = schedule.job + + logger.info(f"Generating interview questions for schedule {schedule_id}") + + # Prepare context for AI + job_description = job.description or "" + job_qualifications = job.qualifications or "" + candidate_resume_text = "" + + # Extract candidate resume text if available and parsed + if application.ai_analysis_data: + resume_data_en = application.ai_analysis_data.get('resume_data_en', {}) + candidate_resume_text = f""" + Candidate Name: {resume_data_en.get('full_name', 'N/A')} + Current Title: {resume_data_en.get('current_title', 'N/A')} + Summary: {resume_data_en.get('summary', 'N/A')} + Skills: {resume_data_en.get('skills', {})} + Experience: {resume_data_en.get('experience', [])} + Education: {resume_data_en.get('education', [])} + """ + + # Create the AI prompt + prompt = f""" + You are an expert technical interviewer and hiring manager. Generate relevant interview questions based on the following information: + + JOB INFORMATION: + Job Title: {job.title} + Department: {job.department} + Job Description: {job_description} + Qualifications: {job_qualifications} + + CANDIDATE PROFILE: + {candidate_resume_text} + + TASK: + Generate 8-10 interview questions that are: + 1. Technical questions related to the job requirements + 2. Behavioral questions to assess soft skills and cultural fit + 3. Situational questions to evaluate problem-solving abilities + 4. Questions should be appropriate for the candidate's experience level + + For each question, specify: + - Type: "technical", "behavioral", or "situational" + - Difficulty: "easy", "medium", or "hard" + - Category: A brief category name (e.g., "Python Programming", "Team Collaboration", "Problem Solving") + - Question: The actual interview question + + OUTPUT FORMAT: + Return a JSON object with the following structure: + {{ + "questions": [ + {{ + "question_text": "The actual question text", + "question_type": "technical|behavioral|situational", + "difficulty_level": "easy|medium|hard", + "category": "Category name" + }} + ] + }} + + Make questions specific to the job requirements and candidate background. Avoid generic questions. + """ + + # Call AI handler + result = ai_handler(prompt) + + if result["status"] == "error": + logger.error(f"AI handler returned error for interview questions: {result['data']}") + return {"status": "error", "message": "Failed to generate questions"} + + # Parse AI response + data = result["data"] + if isinstance(data, str): + data = json.loads(data) + + questions = data.get("questions", []) + + if not questions: + return {"status": "error", "message": "No questions generated"} + + # Clear existing questions for this schedule + InterviewQuestion.objects.filter(schedule=schedule).delete() + + # Save generated questions to database + created_questions = [] + for q_data in questions: + question = InterviewQuestion.objects.create( + schedule=schedule, + question_text=q_data.get("question_text", ""), + question_type=q_data.get("question_type", "technical"), + difficulty_level=q_data.get("difficulty_level", "medium"), + category=q_data.get("category", "General") + ) + created_questions.append({ + "id": question.id, + "text": question.question_text, + "type": question.question_type, + "difficulty": question.difficulty_level, + "category": question.category + }) + + logger.info(f"Successfully generated {len(created_questions)} questions for schedule {schedule_id}") + + return { + "status": "success", + "questions": created_questions, + "message": f"Generated {len(created_questions)} interview questions" + } + + except ScheduledInterview.DoesNotExist: + error_msg = f"Scheduled interview with ID {schedule_id} not found" + logger.error(error_msg) + return {"status": "error", "message": error_msg} + + except Exception as e: + error_msg = f"Error generating interview questions: {str(e)}" + logger.error(error_msg, exc_info=True) + return {"status": "error", "message": error_msg} + + +def send_single_email_task( + recipient_emails, + subject: str, + template_name: str, + context: dict, +) -> str: + """ + Django-Q task to send a bulk email asynchronously. + """ + from .services.email_service import EmailService # if not recipient_emails: # return json.dumps({"status": "error", "message": "No recipients provided."}) @@ -1589,9 +1725,9 @@ def send_email_task( # context=context, # ) -# # The return value is stored in the result object for monitoring -# return json.dumps({ -# "status": "success", -# "count": processed_count, -# "message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}." -# }) \ No newline at end of file + # The return value is stored in the result object for monitoring + return json.dumps({ + "status": "success", + "count": processed_count, + "message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}." + }) diff --git a/recruitment/urls.py b/recruitment/urls.py index ad41baa..91e9b6d 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -82,6 +82,7 @@ urlpatterns = [ # Interview CRUD Operations path("interviews/", views.interview_list, name="interview_list"), path("interviews//", views.interview_detail, name="interview_detail"), + path("interviews//generate-ai-questions/", views.generate_ai_questions, name="generate_ai_questions"), path("interviews//update_interview_status", views.update_interview_status, name="update_interview_status"), path("interviews//update_interview_result", views.update_interview_result, name="update_interview_result"), diff --git a/recruitment/views.py b/recruitment/views.py index d133771..01c24e0 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -4801,6 +4801,57 @@ def interview_list(request): return render(request, "interviews/interview_list.html", context) +@login_required +@staff_user_required +def generate_ai_questions(request, slug): + """Generate AI-powered interview questions for a scheduled interview""" + from django_q.tasks import async_task + from .models import InterviewQuestion + + schedule = get_object_or_404(ScheduledInterview, slug=slug) + + if request.method == "POST": + # Queue the AI question generation task + task_id = async_task( + "recruitment.tasks.generate_interview_questions", + schedule.id + ) + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({ + "status": "success", + "message": "AI question generation started in background", + "task_id": task_id + }) + else: + messages.success( + request, + "AI question generation started. Questions will appear shortly." + ) + return redirect("interview_detail", slug=slug) + + # For GET requests, return existing questions if any + questions = schedule.ai_questions.all().order_by("created_at") + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({ + "status": "success", + "questions": [ + { + "id": q.id, + "text": q.question_text, + "type": q.question_type, + "difficulty": q.difficulty_level, + "category": q.category, + "created_at": q.created_at.isoformat() + } + for q in questions + ] + }) + + return redirect("interview_detail", slug=slug) + + @login_required @staff_user_required def interview_detail(request, slug): @@ -4824,9 +4875,9 @@ def interview_detail(request, slug): reschedule_form = OnsiteScheduleInterviewUpdateForm() reschedule_form.initial["physical_address"] = interview.physical_address reschedule_form.initial["room_number"] = interview.room_number - reschedule_form.initial["topic"] = interview.topic - reschedule_form.initial["start_time"] = interview.start_time - reschedule_form.initial["duration"] = interview.duration + reschedule_form.initial["topic"] = interview.topic + reschedule_form.initial["start_time"] = interview.start_time + reschedule_form.initial["duration"] = interview.duration meeting = interview interview_email_form = InterviewEmailForm(job, application, schedule) diff --git a/templates/interviews/interview_detail.html b/templates/interviews/interview_detail.html index 7383fb3..d92a864 100644 --- a/templates/interviews/interview_detail.html +++ b/templates/interviews/interview_detail.html @@ -192,6 +192,126 @@ flex-wrap: wrap; } + /* AI Questions Styling */ + .ai-question-item { + background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); + border: 1px solid var(--kaauh-border); + border-radius: 0.75rem; + padding: 1.25rem; + margin-bottom: 1rem; + position: relative; + transition: all 0.3s ease; + } + .ai-question-item:hover { + box-shadow: 0 6px 16px rgba(0,0,0,0.08); + transform: translateY(-2px); + } + .ai-question-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; + } + .ai-question-badges { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } + .ai-question-badge { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + .badge-technical { + background-color: #e3f2fd; + color: #1976d2; + } + .badge-behavioral { + background-color: #f3e5f5; + color: #7b1fa2; + } + .badge-situational { + background-color: #e8f5e8; + color: #388e3c; + } + .badge-easy { + background-color: #e8f5e8; + color: #2e7d32; + } + .badge-medium { + background-color: #fff3e0; + color: #f57c00; + } + .badge-hard { + background-color: #ffebee; + color: #c62828; + } + .ai-question-text { + font-size: 1rem; + line-height: 1.6; + color: var(--kaauh-primary-text); + margin-bottom: 0.75rem; + font-weight: 500; + } + .ai-question-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.85rem; + color: #6c757d; + border-top: 1px solid #e9ecef; + padding-top: 0.5rem; + } + .ai-question-category { + display: flex; + align-items: center; + gap: 0.25rem; + } + .ai-question-category i { + color: var(--kaauh-teal); + } + .ai-question-actions { + display: flex; + gap: 0.5rem; + } + .ai-question-actions button { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + border-radius: 0.25rem; + border: 1px solid var(--kaauh-border); + background-color: white; + color: var(--kaauh-primary-text); + transition: all 0.2s ease; + } + .ai-question-actions button:hover { + background-color: var(--kaauh-teal); + color: white; + border-color: var(--kaauh-teal); + } + .ai-questions-empty { + text-align: center; + padding: 3rem 1rem; + color: #6c757d; + } + .ai-questions-empty i { + color: var(--kaauh-teal); + opacity: 0.6; + margin-bottom: 1rem; + } + .ai-questions-loading { + text-align: center; + padding: 2rem; + } + .htmx-indicator { + display: none; + } + .htmx-indicator.htmx-request { + display: block; + } + /* Responsive adjustments */ @media (max-width: 768px) { .action-buttons { @@ -200,6 +320,19 @@ .action-buttons .btn { width: 100%; } + .ai-question-header { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + .ai-question-badges { + width: 100%; + } + .ai-question-meta { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } } {% endblock %} @@ -292,7 +425,7 @@ {% trans "Interview Details" %}
- + {{interview.location_type}} @@ -378,6 +511,56 @@
+ +
+
+
+ {% trans "AI Generated Questions" %} +
+
+ + +
+
+ + +
+
+ {% trans "Generating questions..." %} +
+

{% trans "AI is generating personalized interview questions..." %}

+
+ +
+
+ {% trans "Refreshing..." %} +
+

{% trans "Loading questions..." %}

+
+ + +
+
+ +

{% trans "No AI questions generated yet. Click 'Generate Questions' to create personalized interview questions based on the candidate's profile and job requirements." %}

+
+
+
+
{% trans "Interview Timeline" %} @@ -394,7 +577,7 @@
- + {% if schedule.status == 'confirmed' %}
@@ -403,7 +586,7 @@
{% trans "Interview Confirmed" %}

{% trans "Candidate has confirmed attendance" %}

- +
@@ -416,7 +599,7 @@
{% trans "Interview Completed" %}

{% trans "Interview has been completed" %}

- + @@ -429,7 +612,7 @@
{% trans "Interview Cancelled" %}

{% trans "Interview was cancelled on: " %}{{ schedule.cancelled_at|date:"d-m-Y" }} {{ schedule.cancelled_at|date:"h:i A" }}

- + @@ -490,7 +673,7 @@ {% trans "Add Participants" %} {% endcomment %} - +
@@ -759,4 +942,4 @@ document.addEventListener('DOMContentLoaded', function () { }); }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/interviews/partials/ai_questions_list.html b/templates/interviews/partials/ai_questions_list.html new file mode 100644 index 0000000..f268956 --- /dev/null +++ b/templates/interviews/partials/ai_questions_list.html @@ -0,0 +1,170 @@ +{% load i18n %} + +{% if questions %} + {% for question in questions %} +
+
+
+ + {% if question.type == 'Technical' %} + + {% elif question.type == 'Behavioral' %} + + {% elif question.type == 'Situational' %} + + {% endif %} + {{ question.type }} + + + {% if question.difficulty == 'Easy' %} + + {% elif question.difficulty == 'Medium' %} + + {% elif question.difficulty == 'Hard' %} + + {% endif %} + {{ question.difficulty }} + + {% if question.category %} + + + {{ question.category }} + + {% endif %} +
+
+ +
+ {{ question.text|linebreaksbr }} +
+ +
+
+ + {% trans "Generated" %}: {{ question.created_at|date:"d M Y, H:i" }} +
+
+ + +
+
+ + + +
+ {% endfor %} +{% else %} +
+ +
{% trans "No AI Questions Available" %}
+

{% trans "Click 'Generate Questions' to create personalized interview questions based on the candidate's profile and job requirements." %}

+
+{% endif %} + + diff --git a/templates/people/person_list.html b/templates/people/person_list.html index d6d82f1..56c8a82 100644 --- a/templates/people/person_list.html +++ b/templates/people/person_list.html @@ -199,7 +199,7 @@
{% if request.GET.q or request.GET.nationality or request.GET.gender %} From 444eedc20889b2ac52ed7cc5e4d19a43121fde97 Mon Sep 17 00:00:00 2001 From: ismail Date: Tue, 16 Dec 2025 11:12:09 +0300 Subject: [PATCH 2/3] fix the bulk remote meetings creation --- .env | 6 +- recruitment/forms.py | 28 +++- .../migrations/0004_interviewquestion.py | 35 ++++ recruitment/models.py | 10 +- recruitment/tasks.py | 69 ++++---- recruitment/utils.py | 15 +- recruitment/views.py | 155 +++++++----------- recruitment/zoom_api.py | 22 ++- templates/interviews/schedule_interviews.html | 33 ++++ .../applications_interview_view.html | 2 +- 10 files changed, 225 insertions(+), 150 deletions(-) create mode 100644 recruitment/migrations/0004_interviewquestion.py diff --git a/.env b/.env index 8d7fbd5..b9e2bf0 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ -DB_NAME=haikal_db -DB_USER=faheed -DB_PASSWORD=Faheed@215 \ No newline at end of file +DB_NAME=norahuniversity +DB_USER=norahuniversity +DB_PASSWORD=norahuniversity \ No newline at end of file diff --git a/recruitment/forms.py b/recruitment/forms.py index 2457534..1263e86 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -690,20 +690,40 @@ class BulkInterviewTemplateForm(forms.ModelForm): self.fields["applications"].queryset.first().job.title ) self.fields["start_date"].initial = timezone.now().date() - working_days_initial = [0, 1, 2, 3, 6] # Monday to Friday + working_days_initial = [0, 1, 2, 3, 6] self.fields["working_days"].initial = working_days_initial self.fields["start_time"].initial = "08:00" self.fields["end_time"].initial = "14:00" self.fields["interview_duration"].initial = 30 self.fields["buffer_time"].initial = 10 self.fields["break_start_time"].initial = "11:30" - self.fields["break_end_time"].initial = "12:00" + self.fields["break_end_time"].initial = "12:30" self.fields["physical_address"].initial = "Airport Road, King Khalid International Airport, Riyadh 11564, Saudi Arabia" def clean_working_days(self): working_days = self.cleaned_data.get("working_days") return [int(day) for day in working_days] + def clean_start_date(self): + start_date = self.cleaned_data.get("start_date") + if start_date and start_date <= timezone.now().date(): + raise forms.ValidationError(_("Start date must be in the future")) + return start_date + + def clean_end_date(self): + start_date = self.cleaned_data.get("start_date") + end_date = self.cleaned_data.get("end_date") + if end_date and start_date and end_date < start_date: + raise forms.ValidationError(_("End date must be after start date")) + return end_date + + def clean_end_time(self): + start_time = self.cleaned_data.get("start_time") + end_time = self.cleaned_data.get("end_time") + if end_time and start_time and end_time < start_time: + raise forms.ValidationError(_("End time must be after start time")) + return end_time + class InterviewCancelForm(forms.ModelForm): class Meta: model = ScheduledInterview @@ -1536,7 +1556,7 @@ class MessageForm(forms.ModelForm): print(person) applications=person.applications.all() print(applications) - + self.fields["job"].queryset = JobPosting.objects.filter( applications__in=applications, ).distinct().order_by("-created_at") @@ -2167,7 +2187,7 @@ KAAUH Hiring Team class InterviewResultForm(forms.ModelForm): class Meta: model = Interview - + fields = ['interview_result', 'result_comments'] widgets = { 'interview_result': forms.Select(attrs={ diff --git a/recruitment/migrations/0004_interviewquestion.py b/recruitment/migrations/0004_interviewquestion.py new file mode 100644 index 0000000..17b0c18 --- /dev/null +++ b/recruitment/migrations/0004_interviewquestion.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0 on 2025-12-15 13:59 + +import django.db.models.deletion +import django_extensions.db.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0003_interview_interview_result_interview_result_comments'), + ] + + operations = [ + migrations.CreateModel( + name='InterviewQuestion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('question_text', models.TextField(verbose_name='Question Text')), + ('question_type', models.CharField(choices=[('technical', 'Technical'), ('behavioral', 'Behavioral'), ('situational', 'Situational')], default='technical', max_length=20, verbose_name='Question Type')), + ('difficulty_level', models.CharField(choices=[('easy', 'Easy'), ('medium', 'Medium'), ('hard', 'Hard')], default='medium', max_length=20, verbose_name='Difficulty Level')), + ('category', models.CharField(blank=True, max_length=100, verbose_name='Category')), + ('schedule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_questions', to='recruitment.scheduledinterview', verbose_name='Interview Schedule')), + ], + options={ + 'verbose_name': 'Interview Question', + 'verbose_name_plural': 'Interview Questions', + 'ordering': ['created_at'], + 'indexes': [models.Index(fields=['schedule', 'question_type'], name='recruitment_schedul_b09a70_idx')], + }, + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index 5c9104d..7360588 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -1122,7 +1122,7 @@ class Interview(Base): STARTED = "started", _("Started") ENDED = "ended", _("Ended") CANCELLED = "cancelled", _("Cancelled") - + class InterviewResult(models.TextChoices): PASSED="passed",_("Passed") FAILED="failed",_("Failed") @@ -2591,7 +2591,7 @@ class Document(Base): return "" -class InterviewQuestion(models.Model): +class InterviewQuestion(Base): """Model to store AI-generated interview questions""" class QuestionType(models.TextChoices): @@ -2629,10 +2629,7 @@ class InterviewQuestion(models.Model): blank=True, verbose_name=_("Category") ) - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_("Created At") - ) + class Meta: verbose_name = _("Interview Question") @@ -2640,7 +2637,6 @@ class InterviewQuestion(models.Model): ordering = ["created_at"] indexes = [ models.Index(fields=["schedule", "question_type"]), - models.Index(fields=["created_at"]), ] def __str__(self): diff --git a/recruitment/tasks.py b/recruitment/tasks.py index c601196..6ae6453 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -722,22 +722,26 @@ def create_interview_and_meeting(schedule_id): try: schedule = ScheduledInterview.objects.get(pk=schedule_id) interview = schedule.interview - result = create_zoom_meeting( - interview.topic, interview.start_time, interview.duration - ) + + logger.info(f"Processing schedule {schedule_id} with interview {interview.id}") + logger.info(f"Interview topic: {interview.topic}") + logger.info(f"Interview start_time: {interview.start_time}") + logger.info(f"Interview duration: {interview.duration}") + + result = create_zoom_meeting(interview.topic, interview.start_time, interview.duration) if result["status"] == "success": interview.meeting_id = result["meeting_details"]["meeting_id"] interview.details_url = result["meeting_details"]["join_url"] - interview.zoom_gateway_response = result["zoom_gateway_response"] interview.host_email = result["meeting_details"]["host_email"] interview.password = result["meeting_details"]["password"] + interview.zoom_gateway_response = result["zoom_gateway_response"] interview.save() - logger.info(f"Successfully scheduled interview for {Application.name}") + logger.info(f"Successfully scheduled interview for {schedule.application.name}") return True else: # Handle Zoom API failure (e.g., log it or notify administrator) - logger.error(f"Zoom API failed for {Application.name}: {result['message']}") + logger.error(f"Zoom API failed for {schedule.application.name}: {result['message']}") return False # Task failed except Exception as e: @@ -745,7 +749,6 @@ def create_interview_and_meeting(schedule_id): logger.error(f"Critical error scheduling interview: {e}") return False # Task failed - def handle_zoom_webhook_event(payload): """ Background task to process a Zoom webhook event and update the local ZoomMeeting status. @@ -1701,33 +1704,33 @@ def generate_interview_questions(schedule_id: int) -> dict: return {"status": "error", "message": error_msg} -def send_single_email_task( - recipient_emails, - subject: str, - template_name: str, - context: dict, -) -> str: - """ - Django-Q task to send a bulk email asynchronously. - """ - from .services.email_service import EmailService +# def send_single_email_task( +# recipient_emails, +# subject: str, +# template_name: str, +# context: dict, +# ) -> str: +# """ +# Django-Q task to send a bulk email asynchronously. +# """ +# from .services.email_service import EmailService -# if not recipient_emails: -# return json.dumps({"status": "error", "message": "No recipients provided."}) +# # if not recipient_emails: +# # return json.dumps({"status": "error", "message": "No recipients provided."}) -# service = EmailService() +# # service = EmailService() -# # Execute the bulk sending method -# processed_count = service.send_bulk_email( -# recipient_emails=recipient_emails, -# subject=subject, -# template_name=template_name, -# context=context, -# ) +# # # Execute the bulk sending method +# # processed_count = service.send_bulk_email( +# # recipient_emails=recipient_emails, +# # subject=subject, +# # template_name=template_name, +# # context=context, +# # ) - # The return value is stored in the result object for monitoring - return json.dumps({ - "status": "success", - "count": processed_count, - "message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}." - }) +# # The return value is stored in the result object for monitoring +# return json.dumps({ +# "status": "success", +# "count": processed_count, +# "message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}." +# }) diff --git a/recruitment/utils.py b/recruitment/utils.py index 8acff87..5441e1f 100644 --- a/recruitment/utils.py +++ b/recruitment/utils.py @@ -4,12 +4,9 @@ Utility functions for recruitment app from recruitment import models from django.conf import settings -from datetime import datetime, timedelta, time, date +from datetime import datetime, timedelta from django.utils import timezone from .models import ScheduledInterview -from django.template.loader import render_to_string -from django.core.mail import send_mail -import random import os import json import logging @@ -417,12 +414,15 @@ def create_zoom_meeting(topic, start_time, duration): try: access_token = get_access_token() + zoom_start_time = start_time.strftime("%Y-%m-%dT%H:%M:%S") + logger.info(zoom_start_time) + meeting_details = { "topic": topic, "type": 2, - "start_time": start_time.isoformat() + "Z", + "start_time": zoom_start_time, "duration": duration, - "timezone": "UTC", + "timezone": "Asia/Riyadh", "settings": { "host_video": True, "participant_video": True, @@ -440,7 +440,7 @@ def create_zoom_meeting(topic, start_time, duration): "Content-Type": "application/json", } ZOOM_MEETING_URL = get_setting("ZOOM_MEETING_URL") - print(ZOOM_MEETING_URL) + response = requests.post( ZOOM_MEETING_URL, headers=headers, json=meeting_details ) @@ -448,6 +448,7 @@ def create_zoom_meeting(topic, start_time, duration): # Check response status if response.status_code == 201: meeting_data = response.json() + logger.info(meeting_data) return { "status": "success", "message": "Meeting created successfully.", diff --git a/recruitment/views.py b/recruitment/views.py index 01c24e0..cc4af48 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -1623,6 +1623,8 @@ def _handle_preview_submission(request, slug, job): physical_address = form.cleaned_data["physical_address"] # Create a temporary schedule object (not saved to DB) + # if start_date == datetime.now().date(): + # start_time = (datetime.now() + timedelta(minutes=30)).time() temp_schedule = BulkInterviewTemplate( job=job, start_date=start_date, @@ -1640,7 +1642,6 @@ def _handle_preview_submission(request, slug, job): # Get available slots (temp_breaks logic moved into get_available_time_slots if needed) available_slots = get_available_time_slots(temp_schedule) - if len(available_slots) < len(applications): messages.error( request, @@ -1760,76 +1761,46 @@ def _handle_confirm_schedule(request, slug, job): schedule.applications.set(applications) available_slots = get_available_time_slots(schedule) - if schedule_data.get("schedule_interview_type") == "Remote": - queued_count = 0 - for i, application in enumerate(applications): - if i < len(available_slots): - slot = available_slots[i] - # schedule=ScheduledInterview.objects.create(application=application,job=job) - async_task( - "recruitment.tasks.create_interview_and_meeting", - application.pk, - job.pk, - schedule.pk, - slot["date"], - slot["time"], - schedule.interview_duration, - ) - queued_count += 1 + for i, application in enumerate(applications): + if i >= len(available_slots): + continue - messages.success( - request, - f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!", + slot = available_slots[i] + + # start_dt = datetime.combine(slot["date"], slot["time"]) + start_time = timezone.make_aware(datetime.combine(slot["date"], slot["time"])) + logger.info(f"Creating interview for {application.person.full_name} at {start_time}") + + interview = Interview.objects.create( + topic=schedule.topic, + start_time=start_time, + duration=schedule.interview_duration, + location_type="Onsite", + physical_address=schedule.physical_address, ) - if SESSION_DATA_KEY in request.session: - del request.session[SESSION_DATA_KEY] - if SESSION_ID_KEY in request.session: - del request.session[SESSION_ID_KEY] + scheduled = ScheduledInterview.objects.create( + application=application, + job=job, + schedule=schedule, + interview_date=slot["date"], + interview_time=slot["time"], + interview=interview, + ) - return redirect("applications_interview_view", slug=slug) + if schedule_data.get("schedule_interview_type") == "Remote": + interview.location_type = "Remote" + interview.save(update_fields=["location_type"]) + async_task("recruitment.tasks.create_interview_and_meeting",scheduled.pk) - elif schedule_data.get("schedule_interview_type") == "Onsite": - try: - for i, application in enumerate(applications): - if i < len(available_slots): - slot = available_slots[i] + messages.success(request,f"Schedule successfully created.") - start_dt = datetime.combine(slot["date"], schedule.start_time) + if SESSION_DATA_KEY in request.session: + del request.session[SESSION_DATA_KEY] + if SESSION_ID_KEY in request.session: + del request.session[SESSION_ID_KEY] - interview = Interview.objects.create( - topic=schedule.topic, - start_time=start_dt, - duration=schedule.interview_duration, - location_type="Onsite", - physical_address=schedule.physical_address, - ) - - # 2. Create the ScheduledInterview, linking the unique location - ScheduledInterview.objects.create( - application=application, - job=job, - schedule=schedule, - interview_date=slot["date"], - interview_time=slot["time"], - interview=interview, - ) - - messages.success( - request, f"created successfully for {len(applications)} application." - ) - - # Clear session data keys upon successful completion - if SESSION_DATA_KEY in request.session: - del request.session[SESSION_DATA_KEY] - if SESSION_ID_KEY in request.session: - del request.session[SESSION_ID_KEY] - - return redirect("applications_interview_view", slug=slug) - - except Exception as e: - messages.error(request, f"Error creating onsite interviews: {e}") - return redirect("schedule_interviews", slug=slug) + return redirect("applications_interview_view", slug=slug) @login_required @@ -1837,13 +1808,9 @@ def _handle_confirm_schedule(request, slug, job): def schedule_interviews_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) if request.method == "POST": - # return _handle_confirm_schedule(request, slug, job) return _handle_preview_submission(request, slug, job) else: - # if request.session.get("interview_schedule_data"): - print(request.session.get("interview_schedule_data")) return _handle_get_request(request, slug, job) - # return redirect("applications_interview_view", slug=slug) @login_required @@ -2143,7 +2110,7 @@ def reschedule_meeting_for_application(request, slug): if request.method == "POST": if interview.location_type == "Remote": - + form = ScheduledInterviewForm(request.POST) else: form = OnsiteScheduleInterviewUpdateForm(request.POST) @@ -3058,7 +3025,7 @@ def applicant_portal_dashboard(request): # Get candidate's documents using the Person documents property documents = applicant.documents.order_by("-created_at") - + print(documents) # Add password change form for modal @@ -3682,11 +3649,11 @@ def message_create(request): # from .services.email_service import UnifiedEmailService # from .dto.email_dto import EmailConfig, EmailPriority - - + + email_addresses = [message.recipient.email] subject=message.subject - + email_result=async_task( "recruitment.tasks.send_email_task", email_addresses, @@ -3700,7 +3667,7 @@ def message_create(request): }, ) # Send email using unified service - + if email_result: messages.success( request, "Message sent successfully via email!" @@ -3755,7 +3722,7 @@ def message_create(request): and "HX-Request" in request.headers and request.user.user_type in ["candidate", "agency"] ): - + job_id = request.GET.get("job") if job_id: job = get_object_or_404(JobPosting, id=job_id) @@ -4288,9 +4255,11 @@ def update_interview_result(request,slug): interview = get_object_or_404(Interview,slug=slug) schedule=interview.scheduled_interview form = InterviewResultForm(request.POST, instance=interview) - + if form.is_valid(): - + + interview.save(update_fields=['interview_result', 'result_comments']) + form.save() # Saves form data messages.success(request, _(f"Interview result updated successfully to {interview.interview_result}.")) @@ -4453,7 +4422,7 @@ def api_application_detail(request, candidate_id): # subject = form.cleaned_data.get("subject") # message = form.get_formatted_message() - + # async_task( # "recruitment.tasks.send_bulk_email_task", # email_addresses, @@ -4468,7 +4437,7 @@ def api_application_detail(request, candidate_id): # }, # ) # return redirect(request.path) - + # else: # # Form validation errors @@ -4754,7 +4723,7 @@ def application_signup(request, slug): @login_required @staff_user_required def interview_list(request): - + """List all interviews with filtering and pagination""" interviews = ScheduledInterview.objects.select_related( "application", @@ -4861,7 +4830,7 @@ def interview_detail(request, slug): OnsiteScheduleInterviewUpdateForm, ) - + schedule = get_object_or_404(ScheduledInterview, slug=slug) interview = schedule.interview @@ -6513,7 +6482,7 @@ def sync_history(request, job_slug=None): # sender_user = request.user # job = job # try: - + # # Send email using background task # email_result= async_task( # "recruitment.tasks.send_bulk_email_task", @@ -6553,18 +6522,18 @@ def sync_history(request, job_slug=None): def send_interview_email(request, slug): from django.conf import settings from django_q.tasks import async_task - + schedule = get_object_or_404(ScheduledInterview, slug=slug) application = schedule.application job = application.job - + if request.method == "POST": form = InterviewEmailForm(job, application, schedule, request.POST) if form.is_valid(): # 1. Ensure recipient is a list (fixes the "@" error) recipient_str = form.cleaned_data.get("to").strip() - recipient_list = [recipient_str] - + recipient_list = [recipient_str] + body_message = form.cleaned_data.get("message") subject = form.cleaned_data.get("subject") @@ -6585,7 +6554,7 @@ def send_interview_email(request, slug): "logo_url": settings.STATIC_URL + "image/kaauh.png", }, ) - + messages.success(request, "Interview email enqueued successfully!") return redirect("interview_detail", slug=schedule.slug) @@ -6597,14 +6566,14 @@ def send_interview_email(request, slug): # GET request form = InterviewEmailForm(job, application, schedule) - # 3. FIX: Instead of always redirecting, render the template + # 3. FIX: Instead of always redirecting, render the template # This allows users to see validation errors. return render( - request, + request, "recruitment/interview_email_form.html", # Replace with your actual template path { - "form": form, - "schedule": schedule, + "form": form, + "schedule": schedule, "job": job } ) @@ -6642,7 +6611,7 @@ def compose_application_email(request, slug): subject = form.cleaned_data.get("subject") message = form.get_formatted_message() - + async_task( "recruitment.tasks.send_email_task", email_addresses, @@ -6660,7 +6629,7 @@ def compose_application_email(request, slug): }, ) return redirect(request.path) - + else: # Form validation errors diff --git a/recruitment/zoom_api.py b/recruitment/zoom_api.py index d1ab6cd..0c273ff 100644 --- a/recruitment/zoom_api.py +++ b/recruitment/zoom_api.py @@ -1,6 +1,7 @@ import requests import jwt import time +from datetime import timezone from .utils import get_zoom_config @@ -22,13 +23,30 @@ def create_zoom_meeting(topic, start_time, duration, host_email): 'Authorization': f'Bearer {jwt_token}', 'Content-Type': 'application/json' } + + # Format start_time according to Zoom API requirements + # Convert datetime to ISO 8601 format with Z suffix for UTC + if hasattr(start_time, 'isoformat'): + # If it's a datetime object, format it properly + if hasattr(start_time, 'tzinfo') and start_time.tzinfo is not None: + # Timezone-aware datetime: convert to UTC and format with Z suffix + utc_time = start_time.astimezone(timezone.utc) + zoom_start_time = utc_time.strftime("%Y-%m-%dT%H:%M:%S") + "Z" + else: + # Naive datetime: assume it's in UTC and format with Z suffix + zoom_start_time = start_time.strftime("%Y-%m-%dT%H:%M:%S") + "Z" + else: + # If it's already a string, use as-is (assuming it's properly formatted) + zoom_start_time = str(start_time) + data = { "topic": topic, "type": 2, - "start_time": start_time, + "start_time": zoom_start_time, "duration": duration, "schedule_for": host_email, - "settings": {"join_before_host": True} + "settings": {"join_before_host": True}, + "timezone": "UTC" # Explicitly set timezone to UTC } url = f"https://api.zoom.us/v2/users/{host_email}/meetings" return requests.post(url, json=data, headers=headers) diff --git a/templates/interviews/schedule_interviews.html b/templates/interviews/schedule_interviews.html index 541e439..6d8ec71 100644 --- a/templates/interviews/schedule_interviews.html +++ b/templates/interviews/schedule_interviews.html @@ -144,6 +144,9 @@
{{ form.topic }} + {% if form.topic.errors %} +
{{ form.topic.errors }}
+ {% endif %}
@@ -152,6 +155,9 @@
{{ form.schedule_interview_type }} + {% if form.schedule_interview_type.errors %} +
{{ form.schedule_interview_type.errors }}
+ {% endif %}
@@ -160,6 +166,9 @@
{{ form.start_date }} + {% if form.start_date.errors %} +
{{ form.start_date.errors }}
+ {% endif %}
@@ -167,6 +176,9 @@
{{ form.end_date }} + {% if form.end_date.errors %} +
{{ form.end_date.errors }}
+ {% endif %}
@@ -175,6 +187,9 @@
{{ form.working_days }} + {% if form.working_days.errors %} +
{{ form.working_days.errors }}
+ {% endif %}
@@ -183,6 +198,9 @@
{{ form.start_time }} + {% if form.start_time.errors %} +
{{ form.start_time.errors }}
+ {% endif %}
@@ -190,6 +208,9 @@
{{ form.end_time }} + {% if form.end_time.errors %} +
{{ form.end_time.errors }}
+ {% endif %}
@@ -197,6 +218,9 @@
{{ form.interview_duration }} + {% if form.interview_duration.errors %} +
{{ form.interview_duration.errors }}
+ {% endif %}
@@ -204,6 +228,9 @@
{{ form.buffer_time }} + {% if form.buffer_time.errors %} +
{{ form.buffer_time.errors }}
+ {% endif %}
@@ -215,10 +242,16 @@
{{ form.break_start_time }} + {% if form.break_start_time.errors %} +
{{ form.break_start_time.errors }}
+ {% endif %}
{{ form.break_end_time }} + {% if form.break_end_time.errors %} +
{{ form.break_end_time.errors }}
+ {% endif %}
diff --git a/templates/recruitment/applications_interview_view.html b/templates/recruitment/applications_interview_view.html index a53d891..d2402aa 100644 --- a/templates/recruitment/applications_interview_view.html +++ b/templates/recruitment/applications_interview_view.html @@ -369,7 +369,7 @@ data-bs-toggle="modal" data-bs-target="#noteModal" hx-get="{% url 'application_add_note' application.slug %}" - hx-swap="outerHTML" + hx-swap="innerHTML" hx-target=".notemodal"> Add note From 85f895c891dca71d2401bb0339426f79b188b7ab Mon Sep 17 00:00:00 2001 From: ismail Date: Tue, 16 Dec 2025 14:46:38 +0300 Subject: [PATCH 3/3] 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' %} - -