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

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

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

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

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