diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index d0d4ba3..4b8d695 100644 Binary files a/recruitment/__pycache__/forms.cpython-313.pyc and b/recruitment/__pycache__/forms.cpython-313.pyc differ diff --git a/recruitment/__pycache__/models.cpython-313.pyc b/recruitment/__pycache__/models.cpython-313.pyc index 99f8d2a..a9e9a06 100644 Binary files a/recruitment/__pycache__/models.cpython-313.pyc and b/recruitment/__pycache__/models.cpython-313.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-313.pyc b/recruitment/__pycache__/signals.cpython-313.pyc index 3a25d0c..61b103c 100644 Binary files a/recruitment/__pycache__/signals.cpython-313.pyc and b/recruitment/__pycache__/signals.cpython-313.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-313.pyc b/recruitment/__pycache__/urls.cpython-313.pyc index fef6675..3221ce3 100644 Binary files a/recruitment/__pycache__/urls.cpython-313.pyc and b/recruitment/__pycache__/urls.cpython-313.pyc differ diff --git a/recruitment/__pycache__/utils.cpython-313.pyc b/recruitment/__pycache__/utils.cpython-313.pyc index 7c07f6b..4c58b5f 100644 Binary files a/recruitment/__pycache__/utils.cpython-313.pyc and b/recruitment/__pycache__/utils.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index e73d9b1..eceab8e 100644 Binary files a/recruitment/__pycache__/views.cpython-313.pyc and b/recruitment/__pycache__/views.cpython-313.pyc differ diff --git a/recruitment/forms.py b/recruitment/forms.py index 6d8a716..9c3c193 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -491,4 +491,18 @@ class CandidateExamDateForm(forms.ModelForm): fields = ['exam_date'] widgets = { 'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}), + } + + +class ScheduleInterviewForCandiateForm(forms.ModelForm): + class Meta: + model = InterviewSchedule + fields = ['start_date', 'end_date', 'start_time', 'end_time', 'interview_duration', 'buffer_time'] + widgets = { + 'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), + 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), + 'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}), + 'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}), } \ No newline at end of file diff --git a/recruitment/migrations/0010_alter_scheduledinterview_schedule.py b/recruitment/migrations/0010_alter_scheduledinterview_schedule.py new file mode 100644 index 0000000..0a24d5e --- /dev/null +++ b/recruitment/migrations/0010_alter_scheduledinterview_schedule.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.6 on 2025-10-13 19:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0009_merge_20251013_1714'), + ] + + operations = [ + migrations.AlterField( + model_name='scheduledinterview', + name='schedule', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule'), + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index b662c0e..91b82dc 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -441,6 +441,29 @@ class Candidate(Base): return schedule.zoom_meeting return None + @property + def has_future_meeting(self): + """ + Checks if the candidate has any scheduled interviews for a future date/time. + """ + # Ensure timezone.now() is used for comparison + now = timezone.now() + # Check if any related ScheduledInterview has a future interview_date and interview_time + # We need to combine date and time for a proper datetime comparison if they are separate fields + future_meetings = self.scheduled_interviews.filter( + interview_date__gt=now.date() + ).filter( + interview_time__gte=now.time() + ).exists() + + # Also check for interviews happening later today + today_future_meetings = self.scheduled_interviews.filter( + interview_date=now.date(), + interview_time__gte=now.time() + ).exists() + + return future_meetings or today_future_meetings + class TrainingMaterial(Base): title = models.CharField(max_length=255, verbose_name=_("Title")) @@ -1005,7 +1028,7 @@ class ScheduledInterview(Base): ZoomMeeting, on_delete=models.CASCADE, related_name="interview" ) schedule = models.ForeignKey( - InterviewSchedule, on_delete=models.CASCADE, related_name="interviews" + InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True ) interview_date = models.DateField(verbose_name=_("Interview Date")) interview_time = models.TimeField(verbose_name=_("Interview Time")) diff --git a/recruitment/signals.py b/recruitment/signals.py index 2649cb0..2420f64 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -3,10 +3,14 @@ from django.db import transaction from django.dispatch import receiver from django_q.tasks import async_task from django.db.models.signals import post_save -from .models import FormField,FormStage,FormTemplate,Candidate +from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting logger = logging.getLogger(__name__) +@receiver(post_save, sender=JobPosting) +def create_form_for_job(sender, instance, created, **kwargs): + if created: + FormTemplate.objects.create(job=instance, is_active=True, name=instance.title) @receiver(post_save, sender=Candidate) def score_candidate_resume(sender, instance, created, **kwargs): if not instance.is_resume_parsed: diff --git a/recruitment/urls.py b/recruitment/urls.py index aa42625..c75a741 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -94,4 +94,12 @@ urlpatterns = [ path('jobs//calendar/', views.interview_calendar_view, name='interview_calendar'), path('jobs//calendar/interview//', views.interview_detail_view, name='interview_detail'), + + # Candidate Meeting Scheduling/Rescheduling URLs + path('jobs//candidates//schedule-meeting/', views.schedule_candidate_meeting, name='schedule_candidate_meeting'), + path('api/jobs//candidates//schedule-meeting/', views.api_schedule_candidate_meeting, name='api_schedule_candidate_meeting'), + path('jobs//candidates//reschedule-meeting//', views.reschedule_candidate_meeting, name='reschedule_candidate_meeting'), + path('api/jobs//candidates//reschedule-meeting//', views.api_reschedule_candidate_meeting, name='api_reschedule_candidate_meeting'), + # New URL for simple page-based meeting scheduling + path('jobs//candidates//schedule-meeting-page/', views.schedule_meeting_for_candidate, name='schedule_meeting_for_candidate'), ] diff --git a/recruitment/utils.py b/recruitment/utils.py index d96f509..bb51ebf 100644 --- a/recruitment/utils.py +++ b/recruitment/utils.py @@ -273,7 +273,7 @@ def get_zoom_meeting_details(meeting_id): Returns: dict: A dictionary containing the meeting details or an error message. - The 'start_time' in 'meeting_details' will be a Python datetime object. + Date/datetime fields in 'meeting_details' will be ISO format strings. """ try: access_token = get_access_token() @@ -289,19 +289,26 @@ def get_zoom_meeting_details(meeting_id): if response.status_code == 200: meeting_data = response.json() - if 'start_time' in meeting_data and meeting_data['start_time']: - try: - # Convert ISO 8601 string (with 'Z' for UTC) to datetime object - meeting_data['start_time'] = str(datetime.fromisoformat( - meeting_data['start_time'].replace('Z', '+00:00') - )) - except (ValueError, TypeError) as e: - logger.error( - f"Failed to parse start_time '{meeting_data['start_time']}' for meeting {meeting_id}: {e}" - ) - meeting_data['start_time'] = None # Ensure it's None on failure - else: - meeting_data['start_time'] = None # Explicitly set to None if not present + datetime_fields = [ + 'start_time', 'created_at', 'updated_at', + 'password_changed_at', 'host_join_before_start_time', + 'audio_recording_start', 'recording_files_end' # Add any other known datetime fields + ] + for field_name in datetime_fields: + if field_name in meeting_data and meeting_data[field_name] is not None: + try: + # Convert ISO 8601 string to datetime object, then back to ISO string + # This ensures consistent string format, handling 'Z' for UTC + dt_obj = datetime.fromisoformat(meeting_data[field_name].replace('Z', '+00:00')) + meeting_data[field_name] = dt_obj.isoformat() + except (ValueError, TypeError) as e: + logger.warning( + f"Could not parse or re-serialize datetime field '{field_name}' " + f"for meeting {meeting_id}: {e}. Original value: '{meeting_data[field_name]}'" + ) + # Keep original string if re-serialization fails, or set to None + # meeting_data[field_name] = None + return { "status": "success", "message": "Meeting details retrieved successfully.", @@ -563,3 +570,12 @@ def json_to_markdown_table(data_list): values = [str(row.get(header, "")) for header in headers] markdown += "| " + " | ".join(values) + " |\n" return markdown + + +def get_candidates_from_request(request): + for c in request.POST.items(): + try: + yield models.Candidate.objects.get(pk=c[0]) + except Exception as e: + logger.error(e) + yield None diff --git a/recruitment/views.py b/recruitment/views.py index a297a59..9d30f3b 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -31,6 +31,7 @@ from django.views.generic import CreateView, UpdateView, DetailView, ListView from .utils import ( create_zoom_meeting, delete_zoom_meeting, + get_candidates_from_request, update_zoom_meeting, get_zoom_meeting_details, schedule_interviews, @@ -1122,6 +1123,16 @@ def form_submission_details(request, template_id, slug): def schedule_interviews_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) + # if request.method == "POST" and "Datastar-Request" in request.headers: + # form = InterviewScheduleForm(slug=slug) + # break_formset = BreakTimeFormSet() + # form.initial["candidates"] = get_candidates_from_request(request) + # def response(): + # html = render_to_string("includes/schedule_interview_div.html",{"form": form, "break_formset": break_formset, "job": job}) + # yield SSE.patch_elements(html,"#candidateviewModalBody") + # return DatastarResponse(response()) + + if request.method == "POST": form = InterviewScheduleForm(slug, request.POST) break_formset = BreakTimeFormSet(request.POST) @@ -1340,6 +1351,9 @@ def schedule_interviews_view(request, slug): else: form = InterviewScheduleForm(slug=slug) break_formset = BreakTimeFormSet() + print(request.headers) + if "Hx-Request" in request.headers: + form.initial["candidates"] = [Candidate.objects.get(pk=c[0]) for c in request.GET.items()] return render( request, @@ -1660,13 +1674,7 @@ def candidate_screening_view(request, slug): return render(request, "recruitment/candidate_screening_view.html", context) -def get_candidates_from_request(request): - for c in request.POST.items(): - try: - yield Candidate.objects.get(pk=c[0]) - except Exception as e: - logger.error(e) - yield None + def candidate_exam_view(request, slug): """ Manage candidate tiers and stage transitions @@ -1748,7 +1756,7 @@ def interview_calendar_view(request, slug): scheduled_interviews = ScheduledInterview.objects.filter( job=job ).select_related('candidate', 'zoom_meeting') - print(scheduled_interviews) + # Convert interviews to calendar events events = [] for interview in scheduled_interviews: @@ -1808,3 +1816,515 @@ def interview_detail_view(request, slug, interview_id): } return render(request, 'recruitment/interview_detail.html', context) + +# Candidate Meeting Scheduling/Rescheduling Views +@require_POST +def api_schedule_candidate_meeting(request, job_slug, candidate_pk): + """ + Handle POST request to schedule a Zoom meeting for a candidate via HTMX. + Returns JSON response for modal update. + """ + job = get_object_or_404(JobPosting, slug=job_slug) + candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + + topic = f"Interview: {job.title} with {candidate.name}" + start_time_str = request.POST.get('start_time') + duration = int(request.POST.get('duration', 60)) + + if not start_time_str: + return JsonResponse({'success': False, 'error': 'Start time is required.'}, status=400) + + try: + # Parse datetime from datetime-local input (YYYY-MM-DDTHH:MM) + # This will be in server's timezone, create_zoom_meeting will handle UTC conversion + naive_start_time = datetime.fromisoformat(start_time_str) + # Ensure it's timezone-aware if your system requires it, or let create_zoom_meeting handle it. + # For simplicity, assuming create_zoom_meeting handles naive datetimes or they are in UTC. + # If start_time is expected to be in a specific timezone, convert it here. + # e.g., start_time = timezone.make_aware(naive_start_time, timezone.get_current_timezone()) + start_time = naive_start_time # Or timezone.make_aware(naive_start_time) + except ValueError: + return JsonResponse({'success': False, 'error': 'Invalid date/time format for start time.'}, status=400) + + if start_time <= timezone.now(): + return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400) + + result = create_zoom_meeting(topic=topic, start_time=start_time, duration=duration) + + if result["status"] == "success": + zoom_meeting_details = result["meeting_details"] + zoom_meeting = ZoomMeeting.objects.create( + topic=topic, + start_time=start_time, # Store in local timezone + duration=duration, + meeting_id=zoom_meeting_details["meeting_id"], + join_url=zoom_meeting_details["join_url"], + password=zoom_meeting_details["password"], + # host_email=zoom_meeting_details["host_email"], + status=result["zoom_gateway_response"].get("status", "waiting"), + zoom_gateway_response=result["zoom_gateway_response"], + ) + scheduled_interview = ScheduledInterview.objects.create( + candidate=candidate, + job=job, + zoom_meeting=zoom_meeting, + interview_date=start_time.date(), + interview_time=start_time.time(), + status='scheduled' # Or 'confirmed' depending on your workflow + ) + messages.success(request, f"Meeting scheduled with {candidate.name}.") + + # Return updated table row or a success message + # For HTMX, you might want to return a fragment of the updated table + # For now, returning JSON to indicate success and close modal + return JsonResponse({ + 'success': True, + 'message': 'Meeting scheduled successfully!', + 'join_url': zoom_meeting.join_url, + 'meeting_id': zoom_meeting.meeting_id, + 'candidate_name': candidate.name, + 'interview_datetime': start_time.strftime("%Y-%m-%d %H:%M") + }) + else: + messages.error(request, result["message"]) + return JsonResponse({'success': False, 'error': result["message"]}, status=400) + + +def schedule_candidate_meeting(request, job_slug, candidate_pk): + """ + GET: Render modal form to schedule a meeting. (For HTMX) + POST: Handled by api_schedule_candidate_meeting. + """ + job = get_object_or_404(JobPosting, slug=job_slug) + candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + + if request.method == "POST": + return api_schedule_candidate_meeting(request, job_slug, candidate_pk) + + # GET request - render the form snippet for HTMX + context = { + 'job': job, + 'candidate': candidate, + 'action_url': reverse('api_schedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk}), + 'scheduled_interview': None, # Explicitly None for schedule + } + # Render just the form part, or the whole modal body content + return render(request, "includes/meeting_form.html", context) + + +@require_http_methods(["GET", "POST"]) +def api_schedule_candidate_meeting(request, job_slug, candidate_pk): + """ + Handles GET to render form and POST to process scheduling. + """ + job = get_object_or_404(JobPosting, slug=job_slug) + candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + + if request.method == "GET": + # This GET is for HTMX to fetch the form + context = { + 'job': job, + 'candidate': candidate, + 'action_url': reverse('api_schedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk}), + 'scheduled_interview': None, + } + return render(request, "includes/meeting_form.html", context) + + # POST logic (remains the same) + topic = f"Interview: {job.title} with {candidate.name}" + start_time_str = request.POST.get('start_time') + duration = int(request.POST.get('duration', 60)) + + if not start_time_str: + return JsonResponse({'success': False, 'error': 'Start time is required.'}, status=400) + + try: + naive_start_time = datetime.fromisoformat(start_time_str) + start_time = naive_start_time + except ValueError: + return JsonResponse({'success': False, 'error': 'Invalid date/time format for start time.'}, status=400) + + if start_time <= timezone.now(): + return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400) + + result = create_zoom_meeting(topic=topic, start_time=start_time, duration=duration) + + if result["status"] == "success": + zoom_meeting_details = result["meeting_details"] + zoom_meeting = ZoomMeeting.objects.create( + topic=topic, + start_time=start_time, + duration=duration, + meeting_id=zoom_meeting_details["meeting_id"], + join_url=zoom_meeting_details["join_url"], + password=zoom_meeting_details["password"], + host_email=zoom_meeting_details["host_email"], + status=result["zoom_gateway_response"].get("status", "waiting"), + zoom_gateway_response=result["zoom_gateway_response"], + ) + scheduled_interview = ScheduledInterview.objects.create( + candidate=candidate, + job=job, + zoom_meeting=zoom_meeting, + interview_date=start_time.date(), + interview_time=start_time.time(), + status='scheduled' + ) + messages.success(request, f"Meeting scheduled with {candidate.name}.") + return JsonResponse({ + 'success': True, + 'message': 'Meeting scheduled successfully!', + 'join_url': zoom_meeting.join_url, + 'meeting_id': zoom_meeting.meeting_id, + 'candidate_name': candidate.name, + 'interview_datetime': start_time.strftime("%Y-%m-%d %H:%M") + }) + else: + messages.error(request, result["message"]) + return JsonResponse({'success': False, 'error': result["message"]}, status=400) + + +@require_http_methods(["GET", "POST"]) +def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): + """ + Handles GET to render form and POST to process rescheduling. + """ + job = get_object_or_404(JobPosting, slug=job_slug) + scheduled_interview = get_object_or_404( + ScheduledInterview.objects.select_related('zoom_meeting'), + pk=interview_pk, + candidate__pk=candidate_pk, + job=job + ) + zoom_meeting = scheduled_interview.zoom_meeting + + if request.method == "GET": + # This GET is for HTMX to fetch the form + initial_data = { + 'topic': zoom_meeting.topic, + 'start_time': zoom_meeting.start_time.strftime('%Y-%m-%dT%H:%M'), + 'duration': zoom_meeting.duration, + } + context = { + 'job': job, + 'candidate': scheduled_interview.candidate, + 'scheduled_interview': scheduled_interview, # Pass for conditional logic in template + 'initial_data': initial_data, + 'action_url': reverse('api_reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}) + } + return render(request, "includes/meeting_form.html", context) + + # POST logic (remains the same) + new_start_time_str = request.POST.get('start_time') + new_duration = int(request.POST.get('duration', zoom_meeting.duration)) + + if not new_start_time_str: + return JsonResponse({'success': False, 'error': 'New start time is required.'}, status=400) + + try: + naive_new_start_time = datetime.fromisoformat(new_start_time_str) + new_start_time = naive_new_start_time + except ValueError: + return JsonResponse({'success': False, 'error': 'Invalid date/time format for new start time.'}, status=400) + + if new_start_time <= timezone.now(): + return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400) + + updated_data = { + "topic": f"Interview: {job.title} with {scheduled_interview.candidate.name}", + "start_time": new_start_time.isoformat() + "Z", + "duration": new_duration, + } + + result = update_zoom_meeting(zoom_meeting.meeting_id, updated_data) + + if result["status"] == "success": + details_result = get_zoom_meeting_details(zoom_meeting.meeting_id) + if details_result["status"] == "success": + updated_zoom_details = details_result["meeting_details"] + zoom_meeting.topic = updated_zoom_details.get("topic", zoom_meeting.topic) + zoom_meeting.start_time = new_start_time + zoom_meeting.duration = new_duration + zoom_meeting.join_url = updated_zoom_details.get("join_url", zoom_meeting.join_url) + zoom_meeting.password = updated_zoom_details.get("password", zoom_meeting.password) + zoom_meeting.status = updated_zoom_details.get("status", zoom_meeting.status) + zoom_meeting.zoom_gateway_response = updated_zoom_details + zoom_meeting.save() + + scheduled_interview.interview_date = new_start_time.date() + scheduled_interview.interview_time = new_start_time.time() + scheduled_interview.status = 'rescheduled' + scheduled_interview.save() + messages.success(request, f"Meeting for {scheduled_interview.candidate.name} rescheduled.") + else: + logger.warning(f"Zoom meeting {zoom_meeting.meeting_id} updated, but failed to fetch latest details.") + zoom_meeting.start_time = new_start_time + zoom_meeting.duration = new_duration + zoom_meeting.save() + scheduled_interview.interview_date = new_start_time.date() + scheduled_interview.interview_time = new_start_time.time() + scheduled_interview.save() + messages.success(request, f"Meeting for {scheduled_interview.candidate.name} rescheduled. (Note: Could not refresh all details from Zoom.)") + + return JsonResponse({ + 'success': True, + 'message': 'Meeting rescheduled successfully!', + 'join_url': zoom_meeting.join_url, + 'new_interview_datetime': new_start_time.strftime("%Y-%m-%d %H:%M") + }) + else: + messages.error(request, result["message"]) + return JsonResponse({'success': False, 'error': result["message"]}, status=400) + +# The original schedule_candidate_meeting and reschedule_candidate_meeting (without api_ prefix) +# can be removed if their only purpose was to be called by the JS onclicks. +# If they were intended for other direct URL access, they can be kept as simple redirects +# or wrappers to the api_ versions. +# For now, let's assume the api_ versions are the primary ones for HTMX. + + +def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): + """ + Handles GET to display a form for rescheduling a meeting. + Handles POST to process the rescheduling of a meeting. + """ + job = get_object_or_404(JobPosting, slug=job_slug) + candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + scheduled_interview = get_object_or_404( + ScheduledInterview.objects.select_related('zoom_meeting'), + pk=interview_pk, + candidate=candidate, + job=job + ) + zoom_meeting = scheduled_interview.zoom_meeting + + # Determine if the candidate has other future meetings + # This helps in providing context in the template + # Note: This checks for *any* future meetings for the candidate, not just the one being rescheduled. + # If candidate.has_future_meeting is True, it implies they have at least one other upcoming meeting, + # or the specific meeting being rescheduled is itself in the future. + # We can refine this logic if needed, e.g., check for meetings *other than* the current `interview_pk`. + has_other_future_meetings = candidate.has_future_meeting + # More precise check: if the current meeting being rescheduled is in the future, then by definition + # the candidate will have a future meeting (this one). The UI might want to know if there are *others*. + # For now, `candidate.has_future_meeting` is a good general indicator. + + if request.method == "POST": + form = ZoomMeetingForm(request.POST) + if form.is_valid(): + new_topic = form.cleaned_data.get('topic') + new_start_time = form.cleaned_data.get('start_time') + new_duration = form.cleaned_data.get('duration') + + # Use a default topic if not provided, keeping the original structure + if not new_topic: + new_topic = f"Interview: {job.title} with {candidate.name}" + + # Ensure new_start_time is in the future + if new_start_time <= timezone.now(): + messages.error(request, "Start time must be in the future.") + # Re-render form with error and initial data + return render(request, "recruitment/schedule_meeting_form.html", { # Reusing the same form template + 'form': form, + 'job': job, + 'candidate': candidate, + 'scheduled_interview': scheduled_interview, + 'initial_topic': new_topic, + 'initial_start_time': new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else '', + 'initial_duration': new_duration, + 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), + 'has_future_meeting': has_other_future_meetings # Pass status for template + }) + + # Prepare data for Zoom API update + # The update_zoom_meeting expects start_time as ISO string with 'Z' + zoom_update_data = { + "topic": new_topic, + "start_time": new_start_time.isoformat() + "Z", + "duration": new_duration, + } + + # Update Zoom meeting using utility function + zoom_update_result = update_zoom_meeting(zoom_meeting.meeting_id, zoom_update_data) + + if zoom_update_result["status"] == "success": + # Fetch the latest details from Zoom after successful update + details_result = get_zoom_meeting_details(zoom_meeting.meeting_id) + + if details_result["status"] == "success": + updated_zoom_details = details_result["meeting_details"] + # Update local ZoomMeeting record + zoom_meeting.topic = updated_zoom_details.get("topic", new_topic) + zoom_meeting.start_time = new_start_time # Store the original datetime + zoom_meeting.duration = new_duration + zoom_meeting.join_url = updated_zoom_details.get("join_url", zoom_meeting.join_url) + zoom_meeting.password = updated_zoom_details.get("password", zoom_meeting.password) + zoom_meeting.status = updated_zoom_details.get("status", zoom_meeting.status) + zoom_meeting.zoom_gateway_response = details_result.get("meeting_details") + zoom_meeting.save() + + # Update ScheduledInterview record + scheduled_interview.interview_date = new_start_time.date() + scheduled_interview.interview_time = new_start_time.time() + scheduled_interview.status = 'rescheduled' # Or 'scheduled' if you prefer + scheduled_interview.save() + messages.success(request, f"Meeting for {candidate.name} rescheduled successfully.") + else: + # If fetching details fails, update with form data and log a warning + logger.warning( + f"Successfully updated Zoom meeting {zoom_meeting.meeting_id}, but failed to fetch updated details. " + f"Error: {details_result.get('message', 'Unknown error')}" + ) + # Update with form data as a fallback + zoom_meeting.topic = new_topic + zoom_meeting.start_time = new_start_time + zoom_meeting.duration = new_duration + zoom_meeting.save() + scheduled_interview.interview_date = new_start_time.date() + scheduled_interview.interview_time = new_start_time.time() + scheduled_interview.save() + messages.success(request, f"Meeting for {candidate.name} rescheduled. (Note: Could not refresh all details from Zoom.)") + + return redirect('candidate_interview_view', slug=job.slug) + else: + messages.error(request, f"Failed to update Zoom meeting: {zoom_update_result['message']}") + # Re-render form with error + return render(request, "recruitment/schedule_meeting_form.html", { + 'form': form, + 'job': job, + 'candidate': candidate, + 'scheduled_interview': scheduled_interview, + 'initial_topic': new_topic, + 'initial_start_time': new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else '', + 'initial_duration': new_duration, + 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), + 'has_future_meeting': has_other_future_meetings + }) + else: + # Form validation errors + return render(request, "recruitment/schedule_meeting_form.html", { + 'form': form, + 'job': job, + 'candidate': candidate, + 'scheduled_interview': scheduled_interview, + 'initial_topic': request.POST.get('topic', new_topic), + 'initial_start_time': request.POST.get('start_time', new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else ''), + 'initial_duration': request.POST.get('duration', new_duration), + 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), + 'has_future_meeting': has_other_future_meetings + }) + else: # GET request + # Pre-populate form with existing meeting details + initial_data = { + 'topic': zoom_meeting.topic, + 'start_time': zoom_meeting.start_time.strftime('%Y-%m-%dT%H:%M'), + 'duration': zoom_meeting.duration, + } + form = ZoomMeetingForm(initial=initial_data) + return render(request, "recruitment/schedule_meeting_form.html", { + 'form': form, + 'job': job, + 'candidate': candidate, + 'scheduled_interview': scheduled_interview, # Pass to template for title/differentiation + 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), + 'has_future_meeting': has_other_future_meetings # Pass status for template + }) + + +def schedule_meeting_for_candidate(request, job_slug, candidate_pk): + """ + Handles GET to display a simple form for scheduling a meeting for a candidate. + Handles POST to process the form, create the meeting, and redirect back. + """ + job = get_object_or_404(JobPosting, slug=job_slug) + candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + + if request.method == "POST": + form = ZoomMeetingForm(request.POST) + if form.is_valid(): + topic_val = form.cleaned_data.get('topic') + start_time_val = form.cleaned_data.get('start_time') + duration_val = form.cleaned_data.get('duration') + + # Use a default topic if not provided + if not topic_val: + topic_val = f"Interview: {job.title} with {candidate.name}" + + # Ensure start_time is in the future + if start_time_val <= timezone.now(): + messages.error(request, "Start time must be in the future.") + # Re-render form with error and initial data + return render(request, "recruitment/schedule_meeting_form.html", { + 'form': form, + 'job': job, + 'candidate': candidate, + 'initial_topic': topic_val, + 'initial_start_time': start_time_val.strftime('%Y-%m-%dT%H:%M') if start_time_val else '', + 'initial_duration': duration_val + }) + + # Create Zoom meeting using utility function + # The create_zoom_meeting expects start_time as a datetime object + # and handles its own conversion to UTC for the API call. + zoom_creation_result = create_zoom_meeting( + topic=topic_val, + start_time=start_time_val, # Pass the datetime object + duration=duration_val + ) + + if zoom_creation_result["status"] == "success": + zoom_details = zoom_creation_result["meeting_details"] + zoom_meeting_instance = ZoomMeeting.objects.create( + topic=topic_val, + start_time=start_time_val, # Store the original datetime + duration=duration_val, + meeting_id=zoom_details["meeting_id"], + join_url=zoom_details["join_url"], + password=zoom_details.get("password"), # password might be None + status=zoom_creation_result["zoom_gateway_response"].get("status", "waiting"), + zoom_gateway_response=zoom_creation_result["zoom_gateway_response"], + ) + # Create a ScheduledInterview record + ScheduledInterview.objects.create( + candidate=candidate, + job=job, + zoom_meeting=zoom_meeting_instance, + interview_date=start_time_val.date(), + interview_time=start_time_val.time(), + status='scheduled' + ) + messages.success(request, f"Meeting scheduled with {candidate.name}.") + return redirect('candidate_interview_view', slug=job.slug) + else: + messages.error(request, f"Failed to create Zoom meeting: {zoom_creation_result['message']}") + # Re-render form with error + return render(request, "recruitment/schedule_meeting_form.html", { + 'form': form, + 'job': job, + 'candidate': candidate, + 'initial_topic': topic_val, + 'initial_start_time': start_time_val.strftime('%Y-%m-%dT%H:%M') if start_time_val else '', + 'initial_duration': duration_val + }) + else: + # Form validation errors + return render(request, "recruitment/schedule_meeting_form.html", { + 'form': form, + 'job': job, + 'candidate': candidate, + 'initial_topic': request.POST.get('topic', f"Interview: {job.title} with {candidate.name}"), + 'initial_start_time': request.POST.get('start_time', ''), + 'initial_duration': request.POST.get('duration', 60) + }) + else: # GET request + initial_data = { + 'topic': f"Interview: {job.title} with {candidate.name}", + 'start_time': (timezone.now() + timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M'), # Default to 1 hour from now + 'duration': 60, # Default duration + } + form = ZoomMeetingForm(initial=initial_data) + return render(request, "recruitment/schedule_meeting_form.html", { + 'form': form, + 'job': job, + 'candidate': candidate + }) diff --git a/templates/includes/meeting_form.html b/templates/includes/meeting_form.html new file mode 100644 index 0000000..e568f61 --- /dev/null +++ b/templates/includes/meeting_form.html @@ -0,0 +1,150 @@ + +
+ {% csrf_token %} + + {% if scheduled_interview %} + + {% endif %} + +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + + + +
+ + +
+
+ +{% block customJS %} + +{% endblock %} diff --git a/templates/includes/schedule_interview_div.html b/templates/includes/schedule_interview_div.html new file mode 100644 index 0000000..d498b46 --- /dev/null +++ b/templates/includes/schedule_interview_div.html @@ -0,0 +1,148 @@ +
+

Schedule Interviews for {{ job.title }}

+ +
+
+
+ {% csrf_token %} +
+
+
Select Candidates
+
+ {{ form.candidates }} +
+
+ +
+
Schedule Details
+ +
+ + {{ form.start_date }} +
+ +
+ + {{ form.end_date }} +
+ +
+ + {{ form.working_days }} +
+ +
+
+
+ + {{ form.start_time }} +
+
+ +
+
+ + {{ form.end_time }} +
+
+
+ +
+
+
+ + {{ form.interview_duration }} +
+
+ +
+
+ + {{ form.buffer_time }} +
+
+
+
+
+ +
+
+
Break Times
+
+ {{ break_formset.management_form }} + {% for form in break_formset %} +
+
+ + {{ form.start_time }} +
+
+ + {{ form.end_time }} +
+
+
+ {{ form.DELETE }} + +
+
+ {% endfor %} +
+ +
+
+ +
+ + Cancel +
+
+
+
+
+ + \ No newline at end of file diff --git a/templates/interviews/schedule_interviews.html b/templates/interviews/schedule_interviews.html index a31fd7f..931be74 100644 --- a/templates/interviews/schedule_interviews.html +++ b/templates/interviews/schedule_interviews.html @@ -2,7 +2,7 @@ {% extends "base.html" %} {% block content %} -
+

Schedule Interviews for {{ job.title }}

diff --git a/templates/recruitment/candidate_interview_view.html b/templates/recruitment/candidate_interview_view.html index 29fc098..2df9259 100644 --- a/templates/recruitment/candidate_interview_view.html +++ b/templates/recruitment/candidate_interview_view.html @@ -214,15 +214,16 @@

{% trans "Candidate Tiers" %}

- {% url "candidate_interview_view" job.slug as bulk_update_candidate_exam_status_url %} + {% url "schedule_interviews" job.slug as bulk_update_candidate_exam_status_url %} {% if candidates %} {% endif %} -
+ @@ -282,14 +283,9 @@ {% endfor %} @@ -299,7 +295,7 @@ -
{{candidate.get_latest_meeting.start_time|date:"m-d-Y h:i A"}} {% include "icons/link.html" %} - + + +
{% if not candidates %} {% endif %}
- +