fix the bulk remote meetings creation

This commit is contained in:
ismail 2025-12-16 11:12:09 +03:00
parent 3ae6d66dbd
commit 444eedc208
10 changed files with 225 additions and 150 deletions

6
.env
View File

@ -1,3 +1,3 @@
DB_NAME=haikal_db DB_NAME=norahuniversity
DB_USER=faheed DB_USER=norahuniversity
DB_PASSWORD=Faheed@215 DB_PASSWORD=norahuniversity

View File

@ -690,20 +690,40 @@ class BulkInterviewTemplateForm(forms.ModelForm):
self.fields["applications"].queryset.first().job.title self.fields["applications"].queryset.first().job.title
) )
self.fields["start_date"].initial = timezone.now().date() 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["working_days"].initial = working_days_initial
self.fields["start_time"].initial = "08:00" self.fields["start_time"].initial = "08:00"
self.fields["end_time"].initial = "14:00" self.fields["end_time"].initial = "14:00"
self.fields["interview_duration"].initial = 30 self.fields["interview_duration"].initial = 30
self.fields["buffer_time"].initial = 10 self.fields["buffer_time"].initial = 10
self.fields["break_start_time"].initial = "11:30" 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" self.fields["physical_address"].initial = "Airport Road, King Khalid International Airport, Riyadh 11564, Saudi Arabia"
def clean_working_days(self): def clean_working_days(self):
working_days = self.cleaned_data.get("working_days") working_days = self.cleaned_data.get("working_days")
return [int(day) for day in 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 InterviewCancelForm(forms.ModelForm):
class Meta: class Meta:
model = ScheduledInterview model = ScheduledInterview

View File

@ -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')],
},
),
]

View File

@ -2591,7 +2591,7 @@ class Document(Base):
return "" return ""
class InterviewQuestion(models.Model): class InterviewQuestion(Base):
"""Model to store AI-generated interview questions""" """Model to store AI-generated interview questions"""
class QuestionType(models.TextChoices): class QuestionType(models.TextChoices):
@ -2629,10 +2629,7 @@ class InterviewQuestion(models.Model):
blank=True, blank=True,
verbose_name=_("Category") verbose_name=_("Category")
) )
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_("Created At")
)
class Meta: class Meta:
verbose_name = _("Interview Question") verbose_name = _("Interview Question")
@ -2640,7 +2637,6 @@ class InterviewQuestion(models.Model):
ordering = ["created_at"] ordering = ["created_at"]
indexes = [ indexes = [
models.Index(fields=["schedule", "question_type"]), models.Index(fields=["schedule", "question_type"]),
models.Index(fields=["created_at"]),
] ]
def __str__(self): def __str__(self):

View File

@ -722,22 +722,26 @@ def create_interview_and_meeting(schedule_id):
try: try:
schedule = ScheduledInterview.objects.get(pk=schedule_id) schedule = ScheduledInterview.objects.get(pk=schedule_id)
interview = schedule.interview 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": if result["status"] == "success":
interview.meeting_id = result["meeting_details"]["meeting_id"] interview.meeting_id = result["meeting_details"]["meeting_id"]
interview.details_url = result["meeting_details"]["join_url"] interview.details_url = result["meeting_details"]["join_url"]
interview.zoom_gateway_response = result["zoom_gateway_response"]
interview.host_email = result["meeting_details"]["host_email"] interview.host_email = result["meeting_details"]["host_email"]
interview.password = result["meeting_details"]["password"] interview.password = result["meeting_details"]["password"]
interview.zoom_gateway_response = result["zoom_gateway_response"]
interview.save() interview.save()
logger.info(f"Successfully scheduled interview for {Application.name}") logger.info(f"Successfully scheduled interview for {schedule.application.name}")
return True return True
else: else:
# Handle Zoom API failure (e.g., log it or notify administrator) # 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 return False # Task failed
except Exception as e: except Exception as e:
@ -745,7 +749,6 @@ def create_interview_and_meeting(schedule_id):
logger.error(f"Critical error scheduling interview: {e}") logger.error(f"Critical error scheduling interview: {e}")
return False # Task failed return False # Task failed
def handle_zoom_webhook_event(payload): def handle_zoom_webhook_event(payload):
""" """
Background task to process a Zoom webhook event and update the local ZoomMeeting status. 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} return {"status": "error", "message": error_msg}
def send_single_email_task( # def send_single_email_task(
recipient_emails, # recipient_emails,
subject: str, # subject: str,
template_name: str, # template_name: str,
context: dict, # context: dict,
) -> str: # ) -> str:
""" # """
Django-Q task to send a bulk email asynchronously. # Django-Q task to send a bulk email asynchronously.
""" # """
from .services.email_service import EmailService # from .services.email_service import EmailService
# if not recipient_emails: # # if not recipient_emails:
# return json.dumps({"status": "error", "message": "No recipients provided."}) # # return json.dumps({"status": "error", "message": "No recipients provided."})
# service = EmailService() # # service = EmailService()
# # Execute the bulk sending method # # # Execute the bulk sending method
# processed_count = service.send_bulk_email( # # processed_count = service.send_bulk_email(
# recipient_emails=recipient_emails, # # recipient_emails=recipient_emails,
# subject=subject, # # subject=subject,
# template_name=template_name, # # template_name=template_name,
# context=context, # # context=context,
# ) # # )
# The return value is stored in the result object for monitoring # # The return value is stored in the result object for monitoring
return json.dumps({ # return json.dumps({
"status": "success", # "status": "success",
"count": processed_count, # "count": processed_count,
"message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}." # "message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}."
}) # })

View File

@ -4,12 +4,9 @@ Utility functions for recruitment app
from recruitment import models from recruitment import models
from django.conf import settings from django.conf import settings
from datetime import datetime, timedelta, time, date from datetime import datetime, timedelta
from django.utils import timezone from django.utils import timezone
from .models import ScheduledInterview from .models import ScheduledInterview
from django.template.loader import render_to_string
from django.core.mail import send_mail
import random
import os import os
import json import json
import logging import logging
@ -417,12 +414,15 @@ def create_zoom_meeting(topic, start_time, duration):
try: try:
access_token = get_access_token() access_token = get_access_token()
zoom_start_time = start_time.strftime("%Y-%m-%dT%H:%M:%S")
logger.info(zoom_start_time)
meeting_details = { meeting_details = {
"topic": topic, "topic": topic,
"type": 2, "type": 2,
"start_time": start_time.isoformat() + "Z", "start_time": zoom_start_time,
"duration": duration, "duration": duration,
"timezone": "UTC", "timezone": "Asia/Riyadh",
"settings": { "settings": {
"host_video": True, "host_video": True,
"participant_video": True, "participant_video": True,
@ -440,7 +440,7 @@ def create_zoom_meeting(topic, start_time, duration):
"Content-Type": "application/json", "Content-Type": "application/json",
} }
ZOOM_MEETING_URL = get_setting("ZOOM_MEETING_URL") ZOOM_MEETING_URL = get_setting("ZOOM_MEETING_URL")
print(ZOOM_MEETING_URL)
response = requests.post( response = requests.post(
ZOOM_MEETING_URL, headers=headers, json=meeting_details ZOOM_MEETING_URL, headers=headers, json=meeting_details
) )
@ -448,6 +448,7 @@ def create_zoom_meeting(topic, start_time, duration):
# Check response status # Check response status
if response.status_code == 201: if response.status_code == 201:
meeting_data = response.json() meeting_data = response.json()
logger.info(meeting_data)
return { return {
"status": "success", "status": "success",
"message": "Meeting created successfully.", "message": "Meeting created successfully.",

View File

@ -1623,6 +1623,8 @@ def _handle_preview_submission(request, slug, job):
physical_address = form.cleaned_data["physical_address"] physical_address = form.cleaned_data["physical_address"]
# Create a temporary schedule object (not saved to DB) # Create a temporary schedule object (not saved to DB)
# if start_date == datetime.now().date():
# start_time = (datetime.now() + timedelta(minutes=30)).time()
temp_schedule = BulkInterviewTemplate( temp_schedule = BulkInterviewTemplate(
job=job, job=job,
start_date=start_date, start_date=start_date,
@ -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) # Get available slots (temp_breaks logic moved into get_available_time_slots if needed)
available_slots = get_available_time_slots(temp_schedule) available_slots = get_available_time_slots(temp_schedule)
if len(available_slots) < len(applications): if len(available_slots) < len(applications):
messages.error( messages.error(
request, request,
@ -1760,76 +1761,46 @@ def _handle_confirm_schedule(request, slug, job):
schedule.applications.set(applications) schedule.applications.set(applications)
available_slots = get_available_time_slots(schedule) available_slots = get_available_time_slots(schedule)
if schedule_data.get("schedule_interview_type") == "Remote": for i, application in enumerate(applications):
queued_count = 0 if i >= len(available_slots):
for i, application in enumerate(applications): continue
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
messages.success( slot = available_slots[i]
request,
f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!", # 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: scheduled = ScheduledInterview.objects.create(
del request.session[SESSION_DATA_KEY] application=application,
if SESSION_ID_KEY in request.session: job=job,
del request.session[SESSION_ID_KEY] 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": messages.success(request,f"Schedule successfully created.")
try:
for i, application in enumerate(applications):
if i < len(available_slots):
slot = available_slots[i]
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( return redirect("applications_interview_view", slug=slug)
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)
@login_required @login_required
@ -1837,13 +1808,9 @@ def _handle_confirm_schedule(request, slug, job):
def schedule_interviews_view(request, slug): def schedule_interviews_view(request, slug):
job = get_object_or_404(JobPosting, slug=slug) job = get_object_or_404(JobPosting, slug=slug)
if request.method == "POST": if request.method == "POST":
# return _handle_confirm_schedule(request, slug, job)
return _handle_preview_submission(request, slug, job) return _handle_preview_submission(request, slug, job)
else: else:
# if request.session.get("interview_schedule_data"):
print(request.session.get("interview_schedule_data"))
return _handle_get_request(request, slug, job) return _handle_get_request(request, slug, job)
# return redirect("applications_interview_view", slug=slug)
@login_required @login_required
@ -4291,6 +4258,8 @@ def update_interview_result(request,slug):
if form.is_valid(): if form.is_valid():
interview.save(update_fields=['interview_result', 'result_comments'])
form.save() # Saves form data form.save() # Saves form data
messages.success(request, _(f"Interview result updated successfully to {interview.interview_result}.")) messages.success(request, _(f"Interview result updated successfully to {interview.interview_result}."))

View File

@ -1,6 +1,7 @@
import requests import requests
import jwt import jwt
import time import time
from datetime import timezone
from .utils import get_zoom_config 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}', 'Authorization': f'Bearer {jwt_token}',
'Content-Type': 'application/json' '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 = { data = {
"topic": topic, "topic": topic,
"type": 2, "type": 2,
"start_time": start_time, "start_time": zoom_start_time,
"duration": duration, "duration": duration,
"schedule_for": host_email, "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" url = f"https://api.zoom.us/v2/users/{host_email}/meetings"
return requests.post(url, json=data, headers=headers) return requests.post(url, json=data, headers=headers)

View File

@ -144,6 +144,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.topic.id_for_label }}">{% trans "Topic" %}</label> <label for="{{ form.topic.id_for_label }}">{% trans "Topic" %}</label>
{{ form.topic }} {{ form.topic }}
{% if form.topic.errors %}
<div class="text-danger small mt-1">{{ form.topic.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -152,6 +155,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.schedule_interview_type.id_for_label }}">{% trans "Interview Type" %}</label> <label for="{{ form.schedule_interview_type.id_for_label }}">{% trans "Interview Type" %}</label>
{{ form.schedule_interview_type }} {{ form.schedule_interview_type }}
{% if form.schedule_interview_type.errors %}
<div class="text-danger small mt-1">{{ form.schedule_interview_type.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -160,6 +166,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.start_date.id_for_label }}">{% trans "Start Date" %}</label> <label for="{{ form.start_date.id_for_label }}">{% trans "Start Date" %}</label>
{{ form.start_date }} {{ form.start_date }}
{% if form.start_date.errors %}
<div class="text-danger small mt-1">{{ form.start_date.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
@ -167,6 +176,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.end_date.id_for_label }}">{% trans "End Date" %}</label> <label for="{{ form.end_date.id_for_label }}">{% trans "End Date" %}</label>
{{ form.end_date }} {{ form.end_date }}
{% if form.end_date.errors %}
<div class="text-danger small mt-1">{{ form.end_date.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -175,6 +187,9 @@
<label>{% trans "Working Days" %}</label> <label>{% trans "Working Days" %}</label>
<div class="d-flex flex-wrap gap-3 p-2 border rounded" style="background-color: #f8f9fa;"> <div class="d-flex flex-wrap gap-3 p-2 border rounded" style="background-color: #f8f9fa;">
{{ form.working_days }} {{ form.working_days }}
{% if form.working_days.errors %}
<div class="text-danger small mt-1">{{ form.working_days.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
@ -183,6 +198,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.start_time.id_for_label }}">{% trans "Start Time" %}</label> <label for="{{ form.start_time.id_for_label }}">{% trans "Start Time" %}</label>
{{ form.start_time }} {{ form.start_time }}
{% if form.start_time.errors %}
<div class="text-danger small mt-1">{{ form.start_time.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
@ -190,6 +208,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.end_time.id_for_label }}">{% trans "End Time" %}</label> <label for="{{ form.end_time.id_for_label }}">{% trans "End Time" %}</label>
{{ form.end_time }} {{ form.end_time }}
{% if form.end_time.errors %}
<div class="text-danger small mt-1">{{ form.end_time.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
@ -197,6 +218,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.interview_duration.id_for_label }}">{% trans "Duration (min)" %}</label> <label for="{{ form.interview_duration.id_for_label }}">{% trans "Duration (min)" %}</label>
{{ form.interview_duration }} {{ form.interview_duration }}
{% if form.interview_duration.errors %}
<div class="text-danger small mt-1">{{ form.interview_duration.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
@ -204,6 +228,9 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.buffer_time.id_for_label }}">{% trans "Buffer (min)" %}</label> <label for="{{ form.buffer_time.id_for_label }}">{% trans "Buffer (min)" %}</label>
{{ form.buffer_time }} {{ form.buffer_time }}
{% if form.buffer_time.errors %}
<div class="text-danger small mt-1">{{ form.buffer_time.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -215,10 +242,16 @@
<div class="col-5"> <div class="col-5">
<label for="{{ form.break_start_time.id_for_label }}">{% trans "Start Time" %}</label> <label for="{{ form.break_start_time.id_for_label }}">{% trans "Start Time" %}</label>
{{ form.break_start_time }} {{ form.break_start_time }}
{% if form.break_start_time.errors %}
<div class="text-danger small mt-1">{{ form.break_start_time.errors }}</div>
{% endif %}
</div> </div>
<div class="col-5"> <div class="col-5">
<label for="{{ form.break_end_time.id_for_label }}">{% trans "End Time" %}</label> <label for="{{ form.break_end_time.id_for_label }}">{% trans "End Time" %}</label>
{{ form.break_end_time }} {{ form.break_end_time }}
{% if form.break_end_time.errors %}
<div class="text-danger small mt-1">{{ form.break_end_time.errors }}</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -369,7 +369,7 @@
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#noteModal" data-bs-target="#noteModal"
hx-get="{% url 'application_add_note' application.slug %}" hx-get="{% url 'application_add_note' application.slug %}"
hx-swap="outerHTML" hx-swap="innerHTML"
hx-target=".notemodal"> hx-target=".notemodal">
<i class="fas fa-calendar-plus me-1"></i> <i class="fas fa-calendar-plus me-1"></i>
Add note Add note