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_USER=faheed
DB_PASSWORD=Faheed@215
DB_NAME=norahuniversity
DB_USER=norahuniversity
DB_PASSWORD=norahuniversity

View File

@ -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

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 ""
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):

View File

@ -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}."
# })

View File

@ -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.",

View File

@ -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,53 +1761,25 @@ 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
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!",
)
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)
elif schedule_data.get("schedule_interview_type") == "Onsite":
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)
# 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_dt,
start_time=start_time,
duration=schedule.interview_duration,
location_type="Onsite",
physical_address=schedule.physical_address,
)
# 2. Create the ScheduledInterview, linking the unique location
ScheduledInterview.objects.create(
scheduled = ScheduledInterview.objects.create(
application=application,
job=job,
schedule=schedule,
@ -1815,11 +1788,13 @@ def _handle_confirm_schedule(request, slug, job):
interview=interview,
)
messages.success(
request, f"created successfully for {len(applications)} application."
)
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)
messages.success(request,f"Schedule successfully created.")
# 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:
@ -1827,23 +1802,15 @@ def _handle_confirm_schedule(request, slug, job):
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
@staff_user_required
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
@ -4291,6 +4258,8 @@ def update_interview_result(request,slug):
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}."))

View File

@ -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)

View File

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

View File

@ -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">
<i class="fas fa-calendar-plus me-1"></i>
Add note