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