diff --git a/recruitment/__pycache__/forms.cpython-312.pyc b/recruitment/__pycache__/forms.cpython-312.pyc index 3a0a81a..54a2743 100644 Binary files a/recruitment/__pycache__/forms.cpython-312.pyc and b/recruitment/__pycache__/forms.cpython-312.pyc differ diff --git a/recruitment/__pycache__/models.cpython-312.pyc b/recruitment/__pycache__/models.cpython-312.pyc index 84a3449..649b789 100644 Binary files a/recruitment/__pycache__/models.cpython-312.pyc and b/recruitment/__pycache__/models.cpython-312.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-312.pyc b/recruitment/__pycache__/urls.cpython-312.pyc index 06a411f..3222e0f 100644 Binary files a/recruitment/__pycache__/urls.cpython-312.pyc and b/recruitment/__pycache__/urls.cpython-312.pyc differ diff --git a/recruitment/__pycache__/views.cpython-312.pyc b/recruitment/__pycache__/views.cpython-312.pyc index 675f4d0..3b9e9d6 100644 Binary files a/recruitment/__pycache__/views.cpython-312.pyc and b/recruitment/__pycache__/views.cpython-312.pyc differ diff --git a/recruitment/forms.py b/recruitment/forms.py index 0e969c6..3d33c2a 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -584,11 +584,12 @@ class InterviewScheduleForm(forms.ModelForm): class Meta: model = InterviewSchedule fields = [ - 'candidates', 'start_date', 'end_date', 'working_days', + 'candidates', 'interview_type', 'start_date', 'end_date', 'working_days', 'start_time', 'end_time', 'interview_duration', 'buffer_time', 'break_start_time', 'break_end_time' ] widgets = { + 'interview_type': forms.Select(attrs={'class': 'form-control'}), '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'}), @@ -1326,7 +1327,8 @@ class CandidateEmailForm(forms.Form): """Generate initial message with candidate and meeting information""" candidate=self.candidates.first() message_parts=[] - if candidate.stage == 'Applied': + + if candidate and candidate.stage == 'Applied': message_parts = [ f"Than you, for your interest in the {self.job.title} role.", f"We regret to inform you that you were not selected to move forward to the exam round at this time.", @@ -1335,7 +1337,7 @@ class CandidateEmailForm(forms.Form): f"Wishing you the best in your job search,", f"The KAAUH Hiring team" ] - elif candidate.stage == 'Exam': + elif candidate and candidate.stage == 'Exam': message_parts = [ f"Than you,for your interest in the {self.job.title} role.", f"We're pleased to inform you that your initial screening was successful!", @@ -1346,7 +1348,7 @@ class CandidateEmailForm(forms.Form): f"Best regards, The KAAUH Hiring team" ] - elif candidate.stage == 'Interview': + elif candidate and candidate.stage == 'Interview': message_parts = [ f"Than you, for your interest in the {self.job.title} role.", f"We're pleased to inform you that your initial screening was successful!", @@ -1357,7 +1359,7 @@ class CandidateEmailForm(forms.Form): f"Best regards, The KAAUH Hiring team" ] - elif candidate.stage == 'Offer': + elif candidate and candidate.stage == 'Offer': message_parts = [ f"Congratulations, ! We are delighted to inform you that we are extending a formal offer of employment for the {self.job.title} role.", f"This is an exciting moment, and we look forward to having you join the KAAUH team.", @@ -1366,7 +1368,7 @@ class CandidateEmailForm(forms.Form): f"Welcome to the team!", f"Best regards, The KAAUH Hiring team" ] - elif candidate.stage == 'Hired': + elif candidate and candidate.stage == 'Hired': message_parts = [ f"Welcome aboard,!", f"We are thrilled to officially confirm your employment as our new {self.job.title}.", @@ -1589,6 +1591,17 @@ KAAUH HIRING TEAM self.initial['message_for_agency'] = agency_message.strip() self.initial['message_for_participants'] = participants_message.strip() + + + +class InterviewScheduleLocationForm(forms.ModelForm): + class Meta: + model=InterviewSchedule + fields=['location'] + widgets={ + 'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}), + } + diff --git a/recruitment/migrations/0005_scheduledinterview_meeting_type.py b/recruitment/migrations/0005_scheduledinterview_meeting_type.py new file mode 100644 index 0000000..fe94194 --- /dev/null +++ b/recruitment/migrations/0005_scheduledinterview_meeting_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-09 11:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0004_remove_jobposting_participants_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='scheduledinterview', + name='meeting_type', + field=models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type'), + ), + ] diff --git a/recruitment/migrations/0006_remove_scheduledinterview_meeting_type_and_more.py b/recruitment/migrations/0006_remove_scheduledinterview_meeting_type_and_more.py new file mode 100644 index 0000000..9270e59 --- /dev/null +++ b/recruitment/migrations/0006_remove_scheduledinterview_meeting_type_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.7 on 2025-11-09 11:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0005_scheduledinterview_meeting_type'), + ] + + operations = [ + migrations.RemoveField( + model_name='scheduledinterview', + name='meeting_type', + ), + migrations.AddField( + model_name='interviewschedule', + name='meeting_type', + field=models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type'), + ), + ] diff --git a/recruitment/migrations/0007_rename_meeting_type_interviewschedule_interview_type.py b/recruitment/migrations/0007_rename_meeting_type_interviewschedule_interview_type.py new file mode 100644 index 0000000..85e6f83 --- /dev/null +++ b/recruitment/migrations/0007_rename_meeting_type_interviewschedule_interview_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-09 11:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0006_remove_scheduledinterview_meeting_type_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='interviewschedule', + old_name='meeting_type', + new_name='interview_type', + ), + ] diff --git a/recruitment/migrations/0008_interviewschedule_location.py b/recruitment/migrations/0008_interviewschedule_location.py new file mode 100644 index 0000000..2800b75 --- /dev/null +++ b/recruitment/migrations/0008_interviewschedule_location.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-09 12:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0007_rename_meeting_type_interviewschedule_interview_type'), + ] + + operations = [ + migrations.AddField( + model_name='interviewschedule', + name='location', + field=models.CharField(blank=True, default='Remote', null=True), + ), + ] diff --git a/recruitment/migrations/0009_alter_zoommeeting_meeting_id.py b/recruitment/migrations/0009_alter_zoommeeting_meeting_id.py new file mode 100644 index 0000000..602917a --- /dev/null +++ b/recruitment/migrations/0009_alter_zoommeeting_meeting_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-09 13:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0008_interviewschedule_location'), + ] + + operations = [ + migrations.AlterField( + model_name='zoommeeting', + name='meeting_id', + field=models.CharField(blank=True, db_index=True, max_length=20, null=True, unique=True, verbose_name='Meeting ID'), + ), + ] diff --git a/recruitment/migrations/0010_alter_zoommeeting_meeting_id.py b/recruitment/migrations/0010_alter_zoommeeting_meeting_id.py new file mode 100644 index 0000000..d64f578 --- /dev/null +++ b/recruitment/migrations/0010_alter_zoommeeting_meeting_id.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.7 on 2025-11-09 13:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0009_alter_zoommeeting_meeting_id'), + ] + + operations = [ + migrations.AlterField( + model_name='zoommeeting', + name='meeting_id', + field=models.CharField(db_index=True, default=1, max_length=20, unique=True, verbose_name='Meeting ID'), + preserve_default=False, + ), + ] diff --git a/recruitment/migrations/0011_alter_scheduledinterview_zoom_meeting.py b/recruitment/migrations/0011_alter_scheduledinterview_zoom_meeting.py new file mode 100644 index 0000000..dba9c62 --- /dev/null +++ b/recruitment/migrations/0011_alter_scheduledinterview_zoom_meeting.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.7 on 2025-11-09 13:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0010_alter_zoommeeting_meeting_id'), + ] + + operations = [ + migrations.AlterField( + model_name='scheduledinterview', + name='zoom_meeting', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting'), + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index b712d0c..a4c54d2 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -742,6 +742,7 @@ class ZoomMeeting(Base): topic = models.CharField(max_length=255, verbose_name=_("Topic")) meeting_id = models.CharField( db_index=True, max_length=20, unique=True, verbose_name=_("Meeting ID") # Added index + ) # Unique identifier for the meeting start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time")) # Added index duration = models.PositiveIntegerField( @@ -1599,6 +1600,20 @@ class BreakTime(models.Model): class InterviewSchedule(Base): """Stores the scheduling criteria for interviews""" + """Stores individual scheduled interviews""" + + class InterviewType(models.TextChoices): + REMOTE = 'Remote', 'Remote Interview' + ONSITE = 'Onsite', 'In-Person Interview' + + interview_type = models.CharField( + max_length=10, + choices=InterviewType.choices, + default=InterviewType.REMOTE, + verbose_name="Interview Meeting Type" + ) + + location=models.CharField(null=True,blank=True,default='Remote') job = models.ForeignKey( JobPosting, on_delete=models.CASCADE, related_name="interview_schedules", db_index=True @@ -1636,14 +1651,16 @@ class InterviewSchedule(Base): class ScheduledInterview(Base): - """Stores individual scheduled interviews""" - + + #for one candidate candidate = models.ForeignKey( Candidate, on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True ) + + participants = models.ManyToManyField('Participants', blank=True) system_users=models.ManyToManyField(User,blank=True) @@ -1653,7 +1670,8 @@ class ScheduledInterview(Base): "JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True ) zoom_meeting = models.OneToOneField( - ZoomMeeting, on_delete=models.CASCADE, related_name="interview", db_index=True + ZoomMeeting, on_delete=models.CASCADE, related_name="interview", db_index=True, + null=True, blank=True ) schedule = models.ForeignKey( InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True, db_index=True diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 415aebc..4515e1b 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -461,6 +461,7 @@ def create_interview_and_meeting( meeting_topic = f"Interview for {job.title} - {candidate.name}" # 1. External API Call (Slow) + result = create_zoom_meeting(meeting_topic, interview_datetime, duration) if result["status"] == "success": diff --git a/recruitment/urls.py b/recruitment/urls.py index e8ed14e..ad838ea 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -234,5 +234,8 @@ urlpatterns = [ path('jobs//candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'), path('interview/partcipants//',views.create_interview_participants,name='create_interview_participants'), path('interview/email//',views.send_interview_email,name='send_interview_email'), + path('interview/schedule/location//',views.schedule_interview_location_form,name='schedule_interview_location_form'), + path('interview/list',views.InterviewListView,name='interview_list') + ] diff --git a/recruitment/views.py b/recruitment/views.py index 10d7e38..6cb373f 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -43,7 +43,8 @@ from .forms import ( LinkedPostContentForm, CandidateEmailForm, SourceForm, - InterviewEmailForm + InterviewEmailForm, + InterviewScheduleLocationForm ) from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from rest_framework import viewsets @@ -192,6 +193,43 @@ class ZoomMeetingListView(LoginRequiredMixin, ListView): context["status_filter"] = self.request.GET.get("status", "") context["candidate_name_filter"] = self.request.GET.get("candidate_name", "") return context + +@login_required +def InterviewListView(request): + interview_type=request.GET.get('interview_type','Remote') + print(interview_type) + if interview_type=='Onsite': + meetings=ScheduledInterview.objects.filter(schedule__interview_type=interview_type) + else: + meetings=ZoomMeeting.objects.all() + print(meetings) + return render(request, "meetings/list_meetings.html",{ + 'meetings':meetings, + 'current_interview_type':interview_type + }) + + # search_query = request.GET.get("q", "") # Renamed from 'search' to 'q' for consistency + # if search_query: + # interviews = interviews.filter( + # Q(topic__icontains=search_query) | Q(meeting_id__icontains=search_query) + # ) + + # # Handle filter by status + # status_filter = request.GET.get("status", "") + # if status_filter: + # queryset = queryset.filter(status=status_filter) + + # # Handle search by candidate name + # candidate_name = request.GET.get("candidate_name", "") + # if candidate_name: + # # Filter based on the name of the candidate associated with the meeting's interview + # queryset = queryset.filter( + # Q(interview__candidate__first_name__icontains=candidate_name) | + # Q(interview__candidate__last_name__icontains=candidate_name) + # ) + + + class ZoomMeetingDetailsView(LoginRequiredMixin, DetailView): @@ -1126,6 +1164,7 @@ def _handle_get_request(request, slug, job): def _handle_preview_submission(request, slug, job): + """ Handles the initial POST request (Preview Schedule). Validates forms, calculates slots, saves data to session, and renders preview. @@ -1137,6 +1176,7 @@ def _handle_preview_submission(request, slug, job): if form.is_valid(): # Get the form data candidates = form.cleaned_data["candidates"] + interview_type=form.cleaned_data["interview_type"] start_date = form.cleaned_data["start_date"] end_date = form.cleaned_data["end_date"] working_days = form.cleaned_data["working_days"] @@ -1197,6 +1237,7 @@ def _handle_preview_submission(request, slug, job): # Save the form data to session for later use schedule_data = { + "interview_type":interview_type, "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), "working_days": working_days, @@ -1217,6 +1258,7 @@ def _handle_preview_submission(request, slug, job): { "job": job, "schedule": preview_schedule, + "interview_type":interview_type, "start_date": start_date, "end_date": end_date, "working_days": working_days, @@ -1260,6 +1302,7 @@ def _handle_confirm_schedule(request, slug, job): schedule = InterviewSchedule.objects.create( job=job, created_by=request.user, + interview_type=schedule_data["interview_type"], start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), working_days=schedule_data["working_days"], @@ -1286,34 +1329,59 @@ def _handle_confirm_schedule(request, slug, job): available_slots = get_available_time_slots(schedule) # This should still be synchronous and fast # 4. Queue scheduled interviews asynchronously (FAST RESPONSE) - queued_count = 0 - for i, candidate in enumerate(candidates): - if i < len(available_slots): - slot = available_slots[i] + if schedule.interview_type=='Remote': + queued_count = 0 + for i, candidate in enumerate(candidates): + if i < len(available_slots): + slot = available_slots[i] - # Dispatch the individual creation task to the background queue - async_task( - "recruitment.tasks.create_interview_and_meeting", - candidate.pk, - job.pk, - schedule.pk, - slot['date'], - slot['time'], - schedule.interview_duration, - ) - queued_count += 1 + # Dispatch the individual creation task to the background queue + + async_task( + "recruitment.tasks.create_interview_and_meeting", + candidate.pk, + job.pk, + schedule.pk, + slot['date'], + slot['time'], + schedule.interview_duration, + ) + queued_count += 1 - # 5. Success and Cleanup (IMMEDIATE RESPONSE) - messages.success( - request, - f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!" - ) + # 5. Success and Cleanup (IMMEDIATE RESPONSE) + messages.success( + request, + f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!" + ) - # Clear both 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] + # Clear both 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("job_detail", slug=slug) + else: + for i, candidate in enumerate(candidates): + if i < len(available_slots): + slot = available_slots[i] + ScheduledInterview.objects.create( + candidate=candidate, + job=job, + # zoom_meeting=None, + schedule=schedule, + interview_date=slot['date'], + interview_time= slot['time'] + ) + + messages.success( + request, + f"Onsite schedule Interview Create succesfully" + ) + + # Clear both 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('schedule_interview_location_form',slug=schedule.slug) - return redirect("job_detail", slug=slug) def schedule_interviews_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) @@ -1322,6 +1390,7 @@ def schedule_interviews_view(request, slug): return _handle_preview_submission(request, slug, job) else: return _handle_get_request(request, slug, job) + def confirm_schedule_interviews_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) if request.method == "POST": @@ -4062,4 +4131,18 @@ def send_interview_email(request, slug): +def schedule_interview_location_form(request,slug): + schedule=get_object_or_404(InterviewSchedule,slug=slug) + if request.method=='POST': + form=InterviewScheduleLocationForm(request.POST,instance=schedule) + form.save() + return redirect('list_meetings') + else: + form=InterviewScheduleLocationForm(instance=schedule) + return render(request,'interviews/schedule_interview_location_form.html',{'form':form,'schedule':schedule}) + + +def onsite_interview_list_view(request): + onsite_interviews=ScheduledInterview.objects.filter(schedule__interview_type='Onsite') + return render(request,'interviews/onsite_interview_list.html',{'onsite_interviews':onsite_interviews}) diff --git a/templates/base.html b/templates/base.html index bfded3d..0463281 100644 --- a/templates/base.html +++ b/templates/base.html @@ -255,6 +255,16 @@ +