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/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/forms.py b/recruitment/forms.py index 222bf35..e31ca66 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 @@ -1563,7 +1583,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") @@ -2191,7 +2211,7 @@ Job: {job.title} 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/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 22dcff1..3c63626 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -1120,9 +1120,10 @@ 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") FAILED="failed",_("Failed") @@ -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 ( @@ -2610,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/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 1424a44..0d47fea 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -728,22 +728,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.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"] 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: @@ -751,7 +755,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. @@ -760,32 +763,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 ) @@ -795,7 +786,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 ) @@ -810,31 +800,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 @@ -1571,6 +1549,133 @@ def send_email_task( "message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}." }) +def generate_interview_questions(schedule_id: int) -> dict: + """ + Generate AI-powered interview questions based on job requirements and candidate profile. + + Args: + schedule_id (int): The ID of the scheduled interview + + Returns: + dict: Result containing status and generated questions or error message + """ + from .models import ScheduledInterview + + 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 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 + 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": {{ + "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 + 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"} + + schedule.interview_questions.update(questions) + schedule.save(update_fields=["interview_questions"]) + + logger.info(f"Successfully generated questions for schedule {schedule_id}") + + return { + "status": "success", + "message": f"Generated 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, @@ -1582,22 +1687,22 @@ def send_email_task( # """ # from .services.email_service import EmailService -# if not recipient_emails: -# return json.dumps({"status": "error", "message": "No recipients provided."}) +# # if not recipient_emails: +# # return json.dumps({"status": "error", "message": "No recipients provided."}) -# service = EmailService() +# # service = EmailService() -# # Execute the bulk sending method -# processed_count = service.send_bulk_email( -# recipient_emails=recipient_emails, -# subject=subject, -# template_name=template_name, -# context=context, -# ) +# # # Execute the bulk sending method +# # processed_count = service.send_bulk_email( +# # recipient_emails=recipient_emails, +# # subject=subject, +# # template_name=template_name, +# # context=context, +# # ) # # The return value is stored in the result object for monitoring # return json.dumps({ # "status": "success", # "count": processed_count, # "message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}." -# }) \ No newline at end of file +# }) 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/utils.py b/recruitment/utils.py index 8acff87..16493f1 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.", @@ -869,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 bf3c7b3..0efc7f0 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -1606,7 +1606,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 @@ -1623,7 +1622,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) temp_schedule = BulkInterviewTemplate( job=job, start_date=start_date, @@ -1641,7 +1639,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, @@ -1761,76 +1758,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 @@ -1838,13 +1805,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 @@ -2144,7 +2107,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) @@ -2157,7 +2119,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) @@ -2538,13 +2500,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: @@ -3059,7 +3023,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 @@ -3683,11 +3647,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, @@ -3701,7 +3665,7 @@ def message_create(request): }, ) # Send email using unified service - + if email_result: messages.success( request, "Message sent successfully via email!" @@ -3756,7 +3720,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) @@ -4289,9 +4253,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}.")) @@ -4454,7 +4420,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, @@ -4469,7 +4435,7 @@ def api_application_detail(request, candidate_id): # }, # ) # return redirect(request.path) - + # else: # # Form validation errors @@ -4755,7 +4721,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", @@ -4802,6 +4768,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 + + 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, + 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) + + # # For GET requests, return existing questions if any + # questions = schedule.ai_questions.all().order_by("created_at") + + # if request.headers.get("X-Requested-With") == "XMLHttpRequest": + # return JsonResponse({ + # "status": "success", + # "questions": [ + # { + # "id": q.id, + # "text": q.question_text, + # "type": q.question_type, + # "difficulty": q.difficulty_level, + # "category": q.category, + # "created_at": q.created_at.isoformat() + # } + # for q in questions + # ] + # }) + + return redirect("interview_detail", slug=slug) + + @login_required @staff_user_required def interview_detail(request, slug): @@ -4810,15 +4827,11 @@ 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: @@ -6463,7 +6476,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", @@ -6503,18 +6516,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") @@ -6535,7 +6548,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) @@ -6547,14 +6560,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 } ) @@ -6582,7 +6595,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) @@ -6592,7 +6608,7 @@ def compose_application_email(request, slug): subject = form.cleaned_data.get("subject") message = form.get_formatted_message() - + async_task( "recruitment.tasks.send_email_task", email_addresses, @@ -6609,8 +6625,11 @@ 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 @@ -6618,12 +6637,9 @@ def compose_application_email(request, slug): # 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/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/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 7383fb3..ffc2427 100644 --- a/templates/interviews/interview_detail.html +++ b/templates/interviews/interview_detail.html @@ -192,6 +192,127 @@ 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 { + opacity: 0; + transition: opacity 200ms ease-in; + } + .htmx-request .htmx-indicator { + opacity: 1; + } + /* Responsive adjustments */ @media (max-width: 768px) { .action-buttons { @@ -200,6 +321,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 +426,7 @@ {% trans "Interview Details" %}
- + {{interview.location_type}} @@ -326,7 +460,7 @@ {% trans "Status:" %} - {{ schedule.status }} + {{ interview.status }}
@@ -348,9 +482,9 @@ {% trans "Password:" %} {{ interview.password }} - {% if interview.details_url %} + {% if interview.join_url %} + +
+
+
+ {% trans "AI Generated Questions" %} +
+
+
+ {% csrf_token %} + +
+
+
+ +
+
+

+ +

+
+
+
+ + + + + + + + + + + {% if LANGUAGE_CODE == "ar" %} + {% for question in schedule.interview_questions.ar %} + + + + + + + {% endfor %} + {% else %} + {% for question in schedule.interview_questions.en %} + + + + + + + {% endfor %} + {% endif %} + +
{% trans "Question" %}{% trans "Type" %}{% trans "Difficulty" %}{% trans "Category" %}
+ {{ question.question_text }} + + {{ question.question_type|capfirst }} + + {{ question.difficulty_level|capfirst }} + + {{ question.category|capfirst }} +
+ {{ question.question_text }} + + {{ question.question_type|capfirst }} + + {{ question.difficulty_level|capfirst }} + + {{ question.category|capfirst }} +
+
+
+
+
+
+ + + {% comment %}
+
+ {% 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." %}

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

{% trans "Candidate has confirmed attendance" %}

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

{% trans "Interview has been completed" %}

- + @@ -429,7 +669,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 +730,7 @@ {% trans "Add Participants" %} {% endcomment %} - +
@@ -499,13 +739,13 @@
{% if schedule.status != 'cancelled' and schedule.status != 'completed' %} - - + +
+
+ + + + + {% 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/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/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 @@