diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index d0d4ba3..4fbce26 100644 Binary files a/recruitment/__pycache__/forms.cpython-313.pyc and b/recruitment/__pycache__/forms.cpython-313.pyc differ diff --git a/recruitment/__pycache__/linkedin_service.cpython-313.pyc b/recruitment/__pycache__/linkedin_service.cpython-313.pyc index 5e09bbb..4e6d02a 100644 Binary files a/recruitment/__pycache__/linkedin_service.cpython-313.pyc and b/recruitment/__pycache__/linkedin_service.cpython-313.pyc differ diff --git a/recruitment/__pycache__/models.cpython-313.pyc b/recruitment/__pycache__/models.cpython-313.pyc index 99f8d2a..2b31f32 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..fc601be 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..cf248c2 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..56997da 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..251fa3e 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/migrations/0012_merge_20251014_1403.py b/recruitment/migrations/0012_merge_20251014_1403.py new file mode 100644 index 0000000..2827f2a --- /dev/null +++ b/recruitment/migrations/0012_merge_20251014_1403.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.6 on 2025-10-14 11:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0010_alter_scheduledinterview_schedule'), + ('recruitment', '0011_alter_jobpostingimage_job_and_more'), + ] + + operations = [ + ] diff --git a/recruitment/migrations/0013_alter_formtemplate_created_by.py b/recruitment/migrations/0013_alter_formtemplate_created_by.py new file mode 100644 index 0000000..cbdb0fb --- /dev/null +++ b/recruitment/migrations/0013_alter_formtemplate_created_by.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.6 on 2025-10-14 11:24 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0012_merge_20251014_1403'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='formtemplate', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index b662c0e..3efe497 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -273,12 +273,12 @@ class Candidate(Base): CANDIDATE = "Candidate", _("Candidate") # Stage transition validation constants - STAGE_SEQUENCE = { - "Applied": ["Exam", "Interview", "Offer"], - "Exam": ["Interview", "Offer"], - "Interview": ["Offer"], - "Offer": [], # Final stage - no further transitions - } + # STAGE_SEQUENCE = { + # "Applied": ["Exam", "Interview", "Offer"], + # "Exam": ["Interview", "Offer"], + # "Interview": ["Offer"], + # "Offer": [], # Final stage - no further transitions + # } job = models.ForeignKey( JobPosting, @@ -375,50 +375,50 @@ class Candidate(Base): return self.resume.size return 0 - def clean(self): - """Validate stage transitions""" - # Only validate if this is an existing record (not being created) - if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage: - old_stage = self.__class__.objects.get(pk=self.pk).stage - allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, []) + # def clean(self): + # """Validate stage transitions""" + # # Only validate if this is an existing record (not being created) + # if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage: + # old_stage = self.__class__.objects.get(pk=self.pk).stage + # allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, []) - if self.stage not in allowed_next_stages: - raise ValidationError( - { - "stage": f'Cannot transition from "{old_stage}" to "{self.stage}". ' - f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}" - } - ) + # if self.stage not in allowed_next_stages: + # raise ValidationError( + # { + # "stage": f'Cannot transition from "{old_stage}" to "{self.stage}". ' + # f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}" + # } + # ) - # Validate that the stage is a valid choice - if self.stage not in [choice[0] for choice in self.Stage.choices]: - raise ValidationError( - { - "stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}" - } - ) + # # Validate that the stage is a valid choice + # if self.stage not in [choice[0] for choice in self.Stage.choices]: + # raise ValidationError( + # { + # "stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}" + # } + # ) def save(self, *args, **kwargs): """Override save to ensure validation is called""" self.clean() # Call validation before saving super().save(*args, **kwargs) - def can_transition_to(self, new_stage): - """Check if a stage transition is allowed""" - if not self.pk: # New record - can be in Applied stage - return new_stage == "Applied" + # def can_transition_to(self, new_stage): + # """Check if a stage transition is allowed""" + # if not self.pk: # New record - can be in Applied stage + # return new_stage == "Applied" - old_stage = self.__class__.objects.get(pk=self.pk).stage - allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, []) - return new_stage in allowed_next_stages + # old_stage = self.__class__.objects.get(pk=self.pk).stage + # allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, []) + # return new_stage in allowed_next_stages - def get_available_stages(self): - """Get list of stages this candidate can transition to""" - if not self.pk: # New record - return ["Applied"] + # def get_available_stages(self): + # """Get list of stages this candidate can transition to""" + # if not self.pk: # New record + # return ["Applied"] - old_stage = self.__class__.objects.get(pk=self.pk).stage - return self.STAGE_SEQUENCE.get(old_stage, []) + # old_stage = self.__class__.objects.get(pk=self.pk).stage + # return self.STAGE_SEQUENCE.get(old_stage, []) @property def submission(self): @@ -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")) @@ -521,7 +544,7 @@ class FormTemplate(Base): blank=True, help_text="Description of the form template" ) created_by = models.ForeignKey( - User, on_delete=models.CASCADE, related_name="form_templates" + User, on_delete=models.CASCADE, related_name="form_templates",null=True,blank=True ) is_active = models.BooleanField( default=False, help_text="Whether this template is active" @@ -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..3795b13 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..2ed3d74 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -17,7 +17,6 @@ urlpatterns = [ path('jobs//candidate/application/success', views.application_success, name='application_success'), path('careers/',views.kaauh_career,name='kaauh_career'), - # LinkedIn Integration URLs path('jobs//post-to-linkedin/', views.post_to_linkedin, name='post_to_linkedin'), path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'), @@ -34,7 +33,6 @@ urlpatterns = [ path('candidate//view/', views_frontend.candidate_detail, name='candidate_detail'), path('candidate//update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'), - # Training URLs path('training/', views_frontend.TrainingListView.as_view(), name='training_list'), path('training/create/', views_frontend.TrainingCreateView.as_view(), name='training_create'), @@ -75,7 +73,8 @@ urlpatterns = [ path('htmx//candidate_criteria_view/', views.candidate_criteria_view_htmx, name='candidate_criteria_view_htmx'), path('htmx//candidate_set_exam_date/', views.candidate_set_exam_date, name='candidate_set_exam_date'), - path('htmx/bulk_candidate_move_to_exam/', views.bulk_candidate_move_to_exam, name='bulk_candidate_move_to_exam'), + + path('htmx//candidate_update_status/', views.candidate_update_status, name='candidate_update_status'), path('forms/form//submit/', views.submit_form, name='submit_form'), path('forms/form//', views.form_wizard_view, name='form_wizard'), @@ -94,4 +93,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 c0ac8e6..5748009 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -5,7 +5,7 @@ from rich import print from django.template.loader import render_to_string from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods -from django.http import JsonResponse +from django.http import HttpResponse, JsonResponse from datetime import datetime,time,timedelta from django.views import View from django.db.models import Q @@ -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, @@ -261,12 +262,13 @@ def create_job(request): else: job.created_by = request.POST.get("created_by", "").strip() if not job.created_by: - job.created_by = "University Administrator" + job.created_by = request.user.username job.save() job_apply_url_relative=reverse('job_detail_candidate',kwargs={'slug':job.slug}) job_apply_url_absolute=request.build_absolute_uri(job_apply_url_relative) job.application_url=job_apply_url_absolute + FormTemplate.objects.create(job=job, is_active=True, name=job.title,created_by=request.user) job.save() messages.success(request, f'Job "{job.title}" created successfully!') return redirect("job_list") @@ -327,15 +329,15 @@ def job_detail(request, slug): # Count candidates by stage for summary statistics total_applicant = applicants.count() - + applied_count = applicants.filter(stage="Applied").count() exam_count=applicants.filter(stage="Exam").count - + interview_count = applicants.filter(stage="Interview").count() - + offer_count = applicants.filter(stage="Offer").count() - + status_form = JobPostingStatusForm(instance=job) image_upload_form=JobPostingImageForm(instance=job) @@ -1124,6 +1126,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) @@ -1342,6 +1354,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, @@ -1509,7 +1524,7 @@ def candidate_screening_view(request, slug): offer_count=job.candidates.filter(stage='Offer').count() # Get all candidates for this job, ordered by match score (descending) candidates = job.candidates.filter(stage="Applied").order_by("-match_score") - + # Get tier categorization parameters @@ -1617,33 +1632,31 @@ def candidate_screening_view(request, slug): min_ai_score_str = request.GET.get('min_ai_score') tier1_count_str = request.GET.get('tier1_count') - + try: # Check if the string value exists and is not an empty string before conversion if min_ai_score_str: min_ai_score = int(min_ai_score_str) else: min_ai_score = 0 - + if tier1_count_str: tier1_count = int(tier1_count_str) else: tier1_count = 0 - + except ValueError: # This catches if the user enters non-numeric text (e.g., "abc") min_ai_score = 0 tier1_count = 0 - print(min_ai_score) - print(tier1_count) + # You can now safely use min_ai_score and tier1_count as integers (0 or greater) if min_ai_score > 0: candidates = candidates.filter(match_score__gte=min_ai_score) - print(candidates) - + if tier1_count > 0: candidates = candidates[:tier1_count] - + context = { "job": job, "candidates": candidates, @@ -1662,13 +1675,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 @@ -1691,7 +1698,6 @@ def update_candidate_exam_status(request, slug): def bulk_update_candidate_exam_status(request,slug): job = get_object_or_404(JobPosting, slug=slug) status = request.headers.get('status') - if status: for candidate in get_candidates_from_request(request): try: @@ -1718,19 +1724,18 @@ def candidate_set_exam_date(request, slug): messages.success(request, f"Set exam date for {candidate.name} to {candidate.exam_date}") return redirect("candidate_screening_view", slug=candidate.job.slug) -def bulk_candidate_move_to_exam(request): - for candidate in get_candidates_from_request(request): - candidate.stage = "Exam" - candidate.applicant_status = "Candidate" - candidate.exam_date = timezone.now() - candidate.save() +def candidate_update_status(request, slug): + job = get_object_or_404(JobPosting, slug=slug) + mark_as = request.POST.get('mark_as') + candidate_ids = request.POST.getlist("candidate_ids") - messages.success(request, f"Candidates Moved to Exam stage") - return redirect("candidate_screening_view", slug=candidate.job.slug) - # def response(): - # yield SSE.patch_elements("","") - # yield SSE.execute_script("console.log('hello world');") - # return DatastarResponse(response()) + if c := Candidate.objects.filter(pk__in = candidate_ids): + c.update(stage=mark_as,exam_date=timezone.now(),applicant_status="Candidate" if mark_as in ["Exam","Interview","Offer"] else "Applicant") + + messages.success(request, f"Candidates Updated") + response = HttpResponse(redirect("candidate_screening_view", slug=job.slug)) + response.headers["HX-Refresh"] = "true" + return response def candidate_interview_view(request,slug): job = get_object_or_404(JobPosting,slug=slug) @@ -1750,7 +1755,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: @@ -1810,3 +1815,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/base.html b/templates/base.html index dc0738e..bd2fa28 100644 --- a/templates/base.html +++ b/templates/base.html @@ -30,7 +30,7 @@ padding-right: var(--bs-gutter-x, 0.75rem); /* Add Bootstrap padding for responsiveness */ padding-left: var(--bs-gutter-x, 0.75rem); } - + /* === Top Bar === */ .top-bar { background-color: white; @@ -77,7 +77,7 @@ box-shadow: 0 2px 6px rgba(0,0,0,0.12); } /* Change the outer navbar container to fluid, rely on inner max-width */ - .navbar-dark > .container { + .navbar-dark > .container { max-width: 100%; /* Override default container width */ } .nav-link { @@ -276,7 +276,7 @@
Princess Nourah bint Abdulrahman University
King Abdullah bin Abdulaziz University Hospital
- + KAAUH Logo @@ -325,7 +325,7 @@ {% trans "Form Templates" %} - + {% endcomment %} @@ -349,8 +349,8 @@ - - + + - + - + +
  • @@ -518,13 +518,13 @@ {% block content %} {% endblock %} - +