diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 2b4cac5..8da31e5 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -199,7 +199,7 @@ ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"] ACCOUNT_UNIQUE_EMAIL = True ACCOUNT_EMAIL_VERIFICATION = 'none' ACCOUNT_USER_MODEL_USERNAME_FIELD = None -ACCOUNT_EMAIL_VERIFICATION = "mandatory" +ACCOUNT_EMAIL_VERIFICATION = "optional" ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True diff --git a/recruitment/admin.py b/recruitment/admin.py index ca09b15..98620de 100644 --- a/recruitment/admin.py +++ b/recruitment/admin.py @@ -5,7 +5,7 @@ from django.utils import timezone from .models import ( JobPosting, Application, TrainingMaterial, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, - SharedFormTemplate, Source, HiringAgency, IntegrationLog,BulkInterviewTemplate,JobPostingImage,InterviewNote, + SharedFormTemplate, Source, HiringAgency, IntegrationLog,BulkInterviewTemplate,JobPostingImage,Note, AgencyAccessLink, AgencyJobAssignment,Interview,ScheduledInterview ) from django.contrib.auth import get_user_model @@ -250,4 +250,4 @@ admin.site.register(ScheduledInterview) admin.site.register(JobPostingImage) -admin.site.register(User) +# admin.site.register(User) diff --git a/recruitment/forms.py b/recruitment/forms.py index cb6a8dd..82a5b80 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -18,7 +18,7 @@ from .models import ( BulkInterviewTemplate, BreakTime, JobPostingImage, - InterviewNote, + Note, ScheduledInterview, Source, HiringAgency, @@ -720,94 +720,100 @@ class FormTemplateForm(forms.ModelForm): # BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True) -# class BulkInterviewTemplateForm(forms.ModelForm): -# applications = forms.ModelMultipleChoiceField( -# queryset=Application.objects.none(), -# widget=forms.CheckboxSelectMultiple, -# required=True, -# ) -# working_days = forms.MultipleChoiceField( -# choices=[ -# (0, "Monday"), -# (1, "Tuesday"), -# (2, "Wednesday"), -# (3, "Thursday"), -# (4, "Friday"), -# (5, "Saturday"), -# (6, "Sunday"), -# ], -# widget=forms.CheckboxSelectMultiple, -# required=True, -# ) +class BulkInterviewTemplateForm(forms.ModelForm): + applications = forms.ModelMultipleChoiceField( + queryset=Application.objects.none(), + widget=forms.CheckboxSelectMultiple, + required=True, + ) + working_days = forms.MultipleChoiceField( + choices=[ + (0, "Monday"), + (1, "Tuesday"), + (2, "Wednesday"), + (3, "Thursday"), + (4, "Friday"), + (5, "Saturday"), + (6, "Sunday"), + ], + widget=forms.CheckboxSelectMultiple, + required=True, + ) -# class Meta: -# model = BulkInterviewTemplate -# fields = [ -# 'schedule_interview_type', -# "applications", -# "start_date", -# "end_date", -# "working_days", -# "start_time", -# "end_time", -# "interview_duration", -# "buffer_time", -# "break_start_time", -# "break_end_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"}), -# "break_start_time": forms.TimeInput( -# attrs={"type": "time", "class": "form-control"} -# ), -# "break_end_time": forms.TimeInput( -# attrs={"type": "time", "class": "form-control"} -# ), -# "schedule_interview_type":forms.RadioSelect() -# } + class Meta: + model = BulkInterviewTemplate + fields = [ + 'schedule_interview_type', + 'topic', + 'physical_address', + "applications", + "start_date", + "end_date", + "working_days", + "start_time", + "end_time", + "interview_duration", + "buffer_time", + "break_start_time", + "break_end_time", + ] + widgets = { + "topic": forms.TextInput(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"} + ), + "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"}), + "break_start_time": forms.TimeInput( + attrs={"type": "time", "class": "form-control"} + ), + "break_end_time": forms.TimeInput( + attrs={"type": "time", "class": "form-control"} + ), + "schedule_interview_type":forms.RadioSelect(), + "physical_address": forms.Textarea( + attrs={"class": "form-control", "rows": 3, "placeholder": "Enter physical address if 'In-Person' is selected"} + ), + } -# def __init__(self, slug, *args, **kwargs): -# super().__init__(*args, **kwargs) -# self.fields["applications"].queryset = Application.objects.filter( -# job__slug=slug, stage="Interview" -# ) + def __init__(self, slug, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["applications"].queryset = Application.objects.filter( + job__slug=slug, stage="Interview" + ) -# def clean_working_days(self): -# working_days = self.cleaned_data.get("working_days") -# return [int(day) for day in working_days] + def clean_working_days(self): + working_days = self.cleaned_data.get("working_days") + return [int(day) for day in working_days] -# class InterviewNoteForm(forms.ModelForm): -# """Form for creating and editing meeting comments""" +class NoteForm(forms.ModelForm): + """Form for creating and editing meeting comments""" -# class Meta: -# model = InterviewNote -# fields = ["content"] -# widgets = { -# "content": CKEditor5Widget( -# attrs={ -# "class": "form-control", -# "placeholder": _("Enter your comment or note"), -# }, -# config_name="extends", -# ), -# } -# labels = { -# "content": _("Comment"), -# } + class Meta: + model = Note + fields = "__all__" + widgets = { + "content": CKEditor5Widget( + attrs={ + "class": "form-control", + "placeholder": _("Enter your comment or note"), + }, + config_name="extends", + ), + } + labels = { + "content": _("Comment"), + } # def __init__(self, *args, **kwargs): # super().__init__(*args, **kwargs) diff --git a/recruitment/migrations/0002_bulkinterviewtemplate_schedule_interview_type.py b/recruitment/migrations/0002_bulkinterviewtemplate_schedule_interview_type.py new file mode 100644 index 0000000..fd0de2c --- /dev/null +++ b/recruitment/migrations/0002_bulkinterviewtemplate_schedule_interview_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2025-12-01 12:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='bulkinterviewtemplate', + name='schedule_interview_type', + field=models.CharField(choices=[('Remote', 'Remote (e.g., Zoom)'), ('Onsite', 'In-Person (Physical Location)')], default='Onsite', max_length=10, verbose_name='Interview Type'), + ), + ] diff --git a/recruitment/migrations/0003_bulkinterviewtemplate_physical_address.py b/recruitment/migrations/0003_bulkinterviewtemplate_physical_address.py new file mode 100644 index 0000000..ffdfd45 --- /dev/null +++ b/recruitment/migrations/0003_bulkinterviewtemplate_physical_address.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2025-12-01 13:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0002_bulkinterviewtemplate_schedule_interview_type'), + ] + + operations = [ + migrations.AddField( + model_name='bulkinterviewtemplate', + name='physical_address', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/recruitment/migrations/0004_bulkinterviewtemplate_topic.py b/recruitment/migrations/0004_bulkinterviewtemplate_topic.py new file mode 100644 index 0000000..e420b8b --- /dev/null +++ b/recruitment/migrations/0004_bulkinterviewtemplate_topic.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.6 on 2025-12-01 14:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0003_bulkinterviewtemplate_physical_address'), + ] + + operations = [ + migrations.AddField( + model_name='bulkinterviewtemplate', + name='topic', + field=models.CharField(default='', max_length=255, verbose_name='Interview Topic'), + preserve_default=False, + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index 6684933..1da11ba 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -1397,6 +1397,7 @@ class BulkInterviewTemplate(Base): working_days = models.JSONField( verbose_name=_("Working Days") ) + topic = models.CharField(max_length=255, verbose_name=_("Interview Topic")) start_time = models.TimeField(verbose_name=_("Start Time")) end_time = models.TimeField(verbose_name=_("End Time")) @@ -1414,6 +1415,14 @@ class BulkInterviewTemplate(Base): buffer_time = models.PositiveIntegerField( verbose_name=_("Buffer Time (minutes)"), default=0 ) + schedule_interview_type = models.CharField( + max_length=10, + choices=[('Remote', 'Remote (e.g., Zoom)'), ('Onsite', 'In-Person (Physical Location)')], + default='Onsite', + verbose_name=_("Interview Type"), + ) + physical_address = models.CharField(max_length=255, blank=True, null=True) + created_by = models.ForeignKey( User, on_delete=models.CASCADE, db_index=True ) @@ -1509,7 +1518,7 @@ class ScheduledInterview(Base): return self.interview_location # --- 3. Interview Notes Model (Fixed) --- -class InterviewNote(Base): +class Note(Base): """Model for storing notes, feedback, or comments related to a specific ScheduledInterview.""" class NoteType(models.TextChoices): @@ -1517,13 +1526,24 @@ class InterviewNote(Base): LOGISTICS = 'Logistics', _('Logistical Note') GENERAL = 'General', _('General Comment') - 1 + + application = models.ForeignKey( + Application, + on_delete=models.CASCADE, + related_name="notes", + verbose_name=_("Application"), + db_index=True, + null=True, + blank=True + ) interview = models.ForeignKey( Interview, on_delete=models.CASCADE, related_name="notes", verbose_name=_("Scheduled Interview"), - db_index=True + db_index=True, + null=True, + blank=True ) author = models.ForeignKey( @@ -2692,3 +2712,5 @@ class Document(Base): if self.file: return self.file.name.split(".")[-1].upper() return "" + + diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 56f3960..7defa5b 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -12,7 +12,7 @@ from . linkedin_service import LinkedInService from django.shortcuts import get_object_or_404 from . models import JobPosting from django.utils import timezone -from . models import ScheduledInterview,Interview,Message +from . models import BulkInterviewTemplate,Interview,Message,ScheduledInterview from django.contrib.auth import get_user_model User = get_user_model() # Add python-docx import for Word document processing @@ -668,7 +668,7 @@ def handle_resume_parsing_and_scoring(pk: int): from django.utils import timezone def create_interview_and_meeting( - candidate_id, + application_id, job_id, schedule_id, slot_date, @@ -679,24 +679,13 @@ def create_interview_and_meeting( Synchronous task for a single interview slot, dispatched by django-q. """ try: - application = Application.objects.get(pk=candidate_id) + application = Application.objects.get(pk=application_id) job = JobPosting.objects.get(pk=job_id) - schedule = ScheduledInterview.objects.get(pk=schedule_id) + schedule = BulkInterviewTemplate.objects.get(pk=schedule_id) interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time)) - meeting_topic = f"Interview for {job.title} - {application.name}" + meeting_topic = schedule.topic - # 1. External API Call (Slow) - # "status": "success", - # "message": "Meeting created successfully.", - # "meeting_details": { - # "join_url": meeting_data['join_url'], - # "meeting_id": meeting_data['id'], - # "password": meeting_data['password'], - # "host_email": meeting_data['host_email'] - # }, - # "zoom_gateway_response": meeting_data - # } result = create_zoom_meeting(meeting_topic, interview_datetime, duration) if result["status"] == "success": @@ -711,33 +700,19 @@ def create_interview_and_meeting( password=result["meeting_details"]["password"], location_type="Remote" ) + schedule = ScheduledInterview.objects.create( + application=application, + job=job, + schedule=schedule, + interview_date=slot_date, + interview_time=slot_time, + interview=interview + ) schedule.interview = interview schedule.status = "scheduled" schedule.save() - # 2. Database Writes (Slow) - # zoom_meeting = ZoomMeetingDetails.objects.create( - # topic=meeting_topic, - # start_time=interview_datetime, - # duration=duration, - # meeting_id=result["meeting_details"]["meeting_id"], - # details_url=result["meeting_details"]["join_url"], - # zoom_gateway_response=result["zoom_gateway_response"], - # host_email=result["meeting_details"]["host_email"], - # password=result["meeting_details"]["password"], - # location_type="Remote" - # ) - # ScheduledInterview.objects.create( - # application=candidate, - # job=job, - # interview_location=zoom_meeting, - # schedule=schedule, - # interview_date=slot_date, - # interview_time=slot_time - # ) - - # Log success or use Django-Q result system for monitoring logger.info(f"Successfully scheduled interview for {Application.name}") return True # Task succeeded else: diff --git a/recruitment/urls.py b/recruitment/urls.py index e1a17c2..3c509a4 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -587,16 +587,16 @@ urlpatterns = [ # path('interviews//delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'), #interview and meeting related urls - # path( - # "jobs//schedule-interviews/", - # views.schedule_interviews_view, - # name="schedule_interviews", - # ), - # path( - # "jobs//confirm-schedule-interviews/", - # views.confirm_schedule_interviews_view, - # name="confirm_schedule_interviews_view", - # ), + path( + "jobs//schedule-interviews/", + views.schedule_interviews_view, + name="schedule_interviews", + ), + path( + "jobs//confirm-schedule-interviews/", + views.confirm_schedule_interviews_view, + name="confirm_schedule_interviews_view", + ), # path( # "meetings/create-meeting/", @@ -682,5 +682,6 @@ urlpatterns = [ # Email invitation URLs # path("interviews/meetings//send-application-invitation/", views.send_application_invitation, name="send_application_invitation"), # path("interviews/meetings//send-participants-invitation/", views.send_participants_invitation, name="send_participants_invitation"), - + path("note//application_add_note/", views.application_add_note, name="application_add_note"), + path("note//interview_add_note/", views.interview_add_note, name="interview_add_note"), ] diff --git a/recruitment/views.py b/recruitment/views.py index 83e07bc..ada62cb 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -33,7 +33,8 @@ from .forms import ( PasswordResetForm, StaffAssignmentForm, RemoteInterviewForm, - OnsiteInterviewForm + OnsiteInterviewForm, + BulkInterviewTemplateForm ) from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods @@ -132,7 +133,8 @@ from .models import ( Source, Message, Document, - Interview + Interview, + BulkInterviewTemplate ) @@ -1463,323 +1465,293 @@ def form_submission_details(request, template_id, slug): ) -# def _handle_get_request(request, slug, job): -# """ -# Handles GET requests, setting up forms and restoring candidate selections -# from the session for persistence. -# """ -# SESSION_KEY = f"schedule_candidate_ids_{slug}" +def _handle_get_request(request, slug, job): + """ + Handles GET requests, setting up forms and restoring candidate selections + from the session for persistence. + """ + SESSION_KEY = f"schedule_candidate_ids_{slug}" -# form = BulkInterviewTemplateForm(slug=slug) -# # break_formset = BreakTimeFormSet(prefix='breaktime') + form = BulkInterviewTemplateForm(slug=slug) + # break_formset = BreakTimeFormSet(prefix='breaktime') -# selected_ids = [] + selected_ids = [] -# # 1. Capture IDs from HTMX request and store in session (when first clicked) -# if "HX-Request" in request.headers: -# candidate_ids = request.GET.getlist("candidate_ids") + # 1. Capture IDs from HTMX request and store in session (when first clicked) + if "HX-Request" in request.headers: + candidate_ids = request.GET.getlist("candidate_ids") -# if candidate_ids: -# request.session[SESSION_KEY] = candidate_ids -# selected_ids = candidate_ids + if candidate_ids: + request.session[SESSION_KEY] = candidate_ids + selected_ids = candidate_ids -# # 2. Restore IDs from session (on refresh or navigation) -# if not selected_ids: -# selected_ids = request.session.get(SESSION_KEY, []) + # 2. Restore IDs from session (on refresh or navigation) + if not selected_ids: + selected_ids = request.session.get(SESSION_KEY, []) -# # 3. Use the list of IDs to initialize the form -# if selected_ids: -# candidates_to_load = Application.objects.filter(pk__in=selected_ids) -# print(candidates_to_load) -# form.initial["applications"] = candidates_to_load + # 3. Use the list of IDs to initialize the form + if selected_ids: + candidates_to_load = Application.objects.filter(pk__in=selected_ids) + form.initial["applications"] = candidates_to_load -# return render( -# request, -# "interviews/schedule_interviews.html", -# {"form": form, "job": job}, -# ) - - -#TODO:MAIN FUNCTIONS -# 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. -# """ -# SESSION_DATA_KEY = "interview_schedule_data" -# form = BulkInterviewTemplateForm(slug, request.POST) -# # break_formset = BreakTimeFormSet(request.POST,prefix='breaktime') - -# if form.is_valid(): -# # Get the form data -# applications = form.cleaned_data["applications"] -# start_date = form.cleaned_data["start_date"] -# end_date = form.cleaned_data["end_date"] -# working_days = form.cleaned_data["working_days"] -# start_time = form.cleaned_data["start_time"] -# end_time = form.cleaned_data["end_time"] -# interview_duration = form.cleaned_data["interview_duration"] -# buffer_time = form.cleaned_data["buffer_time"] -# break_start_time = form.cleaned_data["break_start_time"] -# break_end_time = form.cleaned_data["break_end_time"] -# schedule_interview_type=form.cleaned_data["schedule_interview_type"] -# # Process break times -# # breaks = [] -# # for break_form in break_formset: -# # print(break_form.cleaned_data) -# # if break_form.cleaned_data and not break_form.cleaned_data.get("DELETE"): -# # breaks.append( -# # { -# # "start_time": break_form.cleaned_data["start_time"].strftime("%H:%M:%S"), -# # "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"), -# # } -# # ) - -# # Create a temporary schedule object (not saved to DB) -# temp_schedule = BulkInterviewTemplate( -# job=job, -# start_date=start_date, -# end_date=end_date, -# working_days=working_days, -# start_time=start_time, -# end_time=end_time, -# interview_duration=interview_duration, -# buffer_time=buffer_time or 5, -# break_start_time=break_start_time or None, -# break_end_time=break_end_time or None, -# ) - -# # Get available slots (temp_breaks logic moved into get_available_time_slots if needed) -# available_slots = get_available_time_slots(temp_schedule) - -# if len(available_slots) < len(applications): -# messages.error( -# request, -# f"Not enough available slots. Required: {len(applications)}, Available: {len(available_slots)}", -# ) -# return render( -# request, -# "interviews/schedule_interviews.html", -# {"form": form, "job": job}, -# ) - -# # Create a preview schedule -# preview_schedule = [] -# for i, application in enumerate(applications): -# slot = available_slots[i] -# preview_schedule.append( -# {"application": application, "date": slot["date"], "time": slot["time"]} -# ) - -# # Save the form data to session for later use -# schedule_data = { -# "start_date": start_date.isoformat(), -# "end_date": end_date.isoformat(), -# "working_days": working_days, -# "start_time": start_time.isoformat(), -# "end_time": end_time.isoformat(), -# "interview_duration": interview_duration, -# "buffer_time": buffer_time, -# "break_start_time": break_start_time.isoformat() if break_start_time else None, -# "break_end_time": break_end_time.isoformat() if break_end_time else None, -# "candidate_ids": [c.id for c in applications], -# "schedule_interview_type":schedule_interview_type - -# } -# request.session[SESSION_DATA_KEY] = schedule_data - -# # Render the preview page -# return render( -# request, -# "interviews/preview_schedule.html", -# { -# "job": job, -# "schedule": preview_schedule, -# "start_date": start_date, -# "end_date": end_date, -# "working_days": working_days, -# "start_time": start_time, -# "end_time": end_time, -# "break_start_time": break_start_time, -# "break_end_time": break_end_time, -# "interview_duration": interview_duration, -# "buffer_time": buffer_time, -# "schedule_interview_type":schedule_interview_type, -# "form":OnsiteLocationForm() -# }, -# ) -# else: -# # Re-render the form if validation fails -# return render( -# request, -# "interviews/schedule_interviews.html", -# {"form": form, "job": job}, -# ) - - -# def _handle_confirm_schedule(request, slug, job): -# """ -# Handles the final POST request (Confirm Schedule). -# Creates the main schedule record and queues individual interviews asynchronously. -# """ - -# SESSION_DATA_KEY = "interview_schedule_data" -# SESSION_ID_KEY = f"schedule_candidate_ids_{slug}" - -# # 1. Get schedule data from session -# schedule_data = request.session.get(SESSION_DATA_KEY) - -# if not schedule_data: -# messages.error(request, "Session expired. Please try again.") -# return redirect("schedule_interviews", slug=slug) - -# # 2. Create the Interview Schedule (Parent Record) -# try: -# # Handle break times: If they exist, convert them; otherwise, pass None. -# break_start = schedule_data.get("break_start_time") -# break_end = schedule_data.get("break_end_time") - -# schedule = BulkInterviewTemplate.objects.create( -# job=job, -# created_by=request.user, -# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), -# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), -# working_days=schedule_data["working_days"], -# start_time=time.fromisoformat(schedule_data["start_time"]), -# end_time=time.fromisoformat(schedule_data["end_time"]), -# interview_duration=schedule_data["interview_duration"], -# buffer_time=schedule_data["buffer_time"], -# # Convert time strings to time objects only if they exist and handle None gracefully -# break_start_time=time.fromisoformat(break_start) if break_start else None, -# break_end_time=time.fromisoformat(break_end) if break_end else None, -# schedule_interview_type=schedule_data.get("schedule_interview_type") -# ) -# except Exception as e: -# # Clear data on failure to prevent stale data causing repeated errors -# messages.error(request, f"Error creating schedule: {e}") -# if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] -# if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY] -# return redirect("schedule_interviews", slug=slug) - -# # 3. Setup candidates and get slots -# candidates = Application.objects.filter(id__in=schedule_data["candidate_ids"]) -# schedule.applications.set(candidates) -# available_slots = get_available_time_slots(schedule) - -# # 4. Handle Remote/Onsite logic -# if schedule_data.get("schedule_interview_type") == 'Remote': -# # ... (Remote logic remains unchanged) -# queued_count = 0 -# for i, candidate in enumerate(candidates): -# if i < len(available_slots): -# slot = available_slots[i] - -# async_task( -# "recruitment.tasks.create_interview_and_meeting", -# candidate.pk, job.pk, schedule.pk, slot["date"], slot["time"], schedule.interview_duration, -# ) -# queued_count += 1 - -# messages.success( -# request, -# f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!", -# ) - -# if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY] -# if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] - -# return redirect("job_detail", slug=slug) - -# elif schedule_data.get("schedule_interview_type") == 'Onsite': -# print("inside...") - -# if request.method == 'POST': -# form = OnsiteLocationForm(request.POST) - -# if form.is_valid(): - -# if not available_slots: -# messages.error(request, "No available slots found for the selected schedule range.") -# return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job}) - -# # Extract common location data from the form -# physical_address = form.cleaned_data['physical_address'] -# room_number = form.cleaned_data['room_number'] -# topic=form.cleaned_data['topic'] - - -# try: -# # 1. Iterate over candidates and create a NEW Location object for EACH -# for i, candidate in enumerate(candidates): -# if i < len(available_slots): -# slot = available_slots[i] - - -# location_start_dt = datetime.combine(slot['date'], schedule.start_time) - -# # --- CORE FIX: Create a NEW Location object inside the loop --- -# onsite_location = OnsiteLocationDetails.objects.create( -# start_time=location_start_dt, -# duration=schedule.interview_duration, -# physical_address=physical_address, -# room_number=room_number, -# location_type="Onsite", -# topic=topic - -# ) - -# # 2. Create the ScheduledInterview, linking the unique location -# ScheduledInterview.objects.create( -# application=candidate, -# job=job, -# schedule=schedule, -# interview_date=slot['date'], -# interview_time=slot['time'], -# interview_location=onsite_location, -# ) - -# messages.success( -# request, -# f"Onsite schedule interviews created successfully for {len(candidates)} candidates." -# ) - -# # Clear session data keys upon successful completion -# if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY] -# if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] - -# return redirect('job_detail', slug=job.slug) - -# except Exception as e: -# messages.error(request, f"Error creating onsite location/interviews: {e}") -# # On failure, re-render the form with the error and ensure 'job' is present -# return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job}) - -# else: -# # Form is invalid, re-render with errors -# # Ensure 'job' is passed to prevent NoReverseMatch -# return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job}) - -# else: -# # For a GET request -# form = OnsiteLocationForm() - -# return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job}) + return render( + request, + "interviews/schedule_interviews.html", + {"form": form, "job": job}, + ) -# def schedule_interviews_view(request, slug): -# job = get_object_or_404(JobPosting, slug=slug) -# if request.method == "POST": -# # return _handle_confirm_schedule(request, slug, job) -# return _handle_preview_submission(request, slug, job) -# else: -# return _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. + """ + SESSION_DATA_KEY = "interview_schedule_data" + form = BulkInterviewTemplateForm(slug, request.POST) + # break_formset = BreakTimeFormSet(request.POST,prefix='breaktime') + + if form.is_valid(): + # Get the form data + applications = form.cleaned_data["applications"] + start_date = form.cleaned_data["start_date"] + end_date = form.cleaned_data["end_date"] + working_days = form.cleaned_data["working_days"] + start_time = form.cleaned_data["start_time"] + end_time = form.cleaned_data["end_time"] + interview_duration = form.cleaned_data["interview_duration"] + buffer_time = form.cleaned_data["buffer_time"] + break_start_time = form.cleaned_data["break_start_time"] + break_end_time = form.cleaned_data["break_end_time"] + schedule_interview_type=form.cleaned_data["schedule_interview_type"] + physical_address=form.cleaned_data["physical_address"] + # Process break times + # breaks = [] + # for break_form in break_formset: + # print(break_form.cleaned_data) + # if break_form.cleaned_data and not break_form.cleaned_data.get("DELETE"): + # breaks.append( + # { + # "start_time": break_form.cleaned_data["start_time"].strftime("%H:%M:%S"), + # "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"), + # } + # ) + + # Create a temporary schedule object (not saved to DB) + temp_schedule = BulkInterviewTemplate( + job=job, + start_date=start_date, + end_date=end_date, + working_days=working_days, + start_time=start_time, + end_time=end_time, + interview_duration=interview_duration, + buffer_time=buffer_time or 5, + break_start_time=break_start_time or None, + break_end_time=break_end_time or None, + schedule_interview_type=schedule_interview_type, + physical_address=physical_address + ) + + # Get available slots (temp_breaks logic moved into get_available_time_slots if needed) + available_slots = get_available_time_slots(temp_schedule) + + if len(available_slots) < len(applications): + messages.error( + request, + f"Not enough available slots. Required: {len(applications)}, Available: {len(available_slots)}", + ) + return render( + request, + "interviews/schedule_interviews.html", + {"form": form, "job": job}, + ) + + # Create a preview schedule + preview_schedule = [] + for i, application in enumerate(applications): + slot = available_slots[i] + preview_schedule.append( + {"application": application, "date": slot["date"], "time": slot["time"]} + ) + + # Save the form data to session for later use + schedule_data = { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "working_days": working_days, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "interview_duration": interview_duration, + "buffer_time": buffer_time, + "break_start_time": break_start_time.isoformat() if break_start_time else None, + "break_end_time": break_end_time.isoformat() if break_end_time else None, + "candidate_ids": [c.id for c in applications], + "schedule_interview_type":schedule_interview_type, + "physical_address":physical_address, + "topic":form.cleaned_data.get("topic"), + + } + request.session[SESSION_DATA_KEY] = schedule_data + + # Render the preview page + return render( + request, + "interviews/preview_schedule.html", + { + "job": job, + "schedule": preview_schedule, + "start_date": start_date, + "end_date": end_date, + "working_days": working_days, + "start_time": start_time, + "end_time": end_time, + "break_start_time": break_start_time, + "break_end_time": break_end_time, + "interview_duration": interview_duration, + "buffer_time": buffer_time, + # "schedule_interview_type":schedule_interview_type, + # "form":OnsiteLocationForm() + }, + ) + else: + # Re-render the form if validation fails + return render( + request, + "interviews/schedule_interviews.html", + {"form": form, "job": job}, + ) -# def confirm_schedule_interviews_view(request, slug): -# job = get_object_or_404(JobPosting, slug=slug) -# if request.method == "POST": -# return _handle_confirm_schedule(request, slug, job) +def _handle_confirm_schedule(request, slug, job): + """ + Handles the final POST request (Confirm Schedule). + Creates the main schedule record and queues individual interviews asynchronously. + """ + + SESSION_DATA_KEY = "interview_schedule_data" + SESSION_ID_KEY = f"schedule_candidate_ids_{slug}" + + # 1. Get schedule data from session + schedule_data = request.session.get(SESSION_DATA_KEY) + + if not schedule_data: + messages.error(request, "Session expired. Please try again.") + return redirect("schedule_interviews", slug=slug) + + # 2. Create the Interview Schedule (Parent Record) + try: + # Handle break times: If they exist, convert them; otherwise, pass None. + break_start = schedule_data.get("break_start_time") + break_end = schedule_data.get("break_end_time") + + schedule = BulkInterviewTemplate.objects.create( + job=job, + created_by=request.user, + start_date=datetime.fromisoformat(schedule_data["start_date"]).date(), + end_date=datetime.fromisoformat(schedule_data["end_date"]).date(), + working_days=schedule_data["working_days"], + start_time=time.fromisoformat(schedule_data["start_time"]), + end_time=time.fromisoformat(schedule_data["end_time"]), + interview_duration=schedule_data["interview_duration"], + buffer_time=schedule_data["buffer_time"], + break_start_time=time.fromisoformat(break_start) if break_start else None, + break_end_time=time.fromisoformat(break_end) if break_end else None, + schedule_interview_type=schedule_data.get("schedule_interview_type"), + physical_address=schedule_data.get("physical_address"), + topic=schedule_data.get("topic"), + ) + except Exception as e: + # Clear data on failure to prevent stale data causing repeated errors + messages.error(request, f"Error creating schedule: {e}") + if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] + if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY] + return redirect("schedule_interviews", slug=slug) + + applications = Application.objects.filter(id__in=schedule_data["candidate_ids"]) + schedule.applications.set(applications) + available_slots = get_available_time_slots(schedule) + + if schedule_data.get("schedule_interview_type") == 'Remote': + queued_count = 0 + for i, application in enumerate(applications): + if i < len(available_slots): + slot = available_slots[i] + async_task( + "recruitment.tasks.create_interview_and_meeting", + application.pk, job.pk, schedule.pk, slot["date"], slot["time"], schedule.interview_duration, + ) + queued_count += 1 + + messages.success( + request, + f"Schedule successfully created. Queued {queued_count} interviews to be booked asynchronously. Check back in a moment!", + ) + + if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY] + if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] + + return redirect("job_detail", slug=slug) + + elif schedule_data.get("schedule_interview_type") == 'Onsite': + try: + for i, application in enumerate(applications): + if i < len(available_slots): + slot = available_slots[i] + + start_dt = datetime.combine(slot['date'], schedule.start_time) + + interview = Interview.objects.create( + topic=schedule.topic, + start_time=start_dt, + duration=schedule.interview_duration, + location_type="Onsite", + physical_address=schedule.physical_address, + ) + + # 2. Create the ScheduledInterview, linking the unique location + ScheduledInterview.objects.create( + application=application, + job=job, + schedule=schedule, + interview_date=slot['date'], + interview_time=slot['time'], + interview=interview + ) + + messages.success( + request, + f"created successfully for {len(applications)} application." + ) + + # Clear session data keys upon successful completion + if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY] + if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY] + + return redirect('job_detail', slug=job.slug) + + except Exception as e: + messages.error(request, f"Error creating onsite interviews: {e}") + return redirect("schedule_interviews", slug=slug) + + +def schedule_interviews_view(request, slug): + job = get_object_or_404(JobPosting, slug=slug) + if request.method == "POST": + # return _handle_confirm_schedule(request, slug, job) + return _handle_preview_submission(request, slug, job) + else: + # if request.session.get("interview_schedule_data"): + print(request.session.get("interview_schedule_data")) + return _handle_get_request(request, slug, job) + # return redirect("applications_interview_view", slug=slug) + + +def confirm_schedule_interviews_view(request, slug): + job = get_object_or_404(JobPosting, slug=slug) + if request.method == "POST": + # print(request.session['interview_schedule_data']) + return _handle_confirm_schedule(request, slug, job) @staff_user_required @@ -6583,3 +6555,57 @@ def interview_detail(request, slug): # messages.error(request, f"Failed to send invitation emails: {str(e)}") # return redirect('meeting_details', slug=slug) + +def application_add_note(request, slug): + from .models import Note + from .forms import NoteForm + + application = get_object_or_404(Application, slug=slug) + notes = Note.objects.filter(application=application).order_by('-created_at') + + if request.method == 'POST': + form = NoteForm(request.POST) + if form.is_valid(): + form.save() + # messages.success(request, "Note added successfully.") + else: + messages.error(request, "Note content cannot be empty.") + + return render(request, 'recruitment/partials/note_form.html', {'notes':notes}) + else: + form = NoteForm() + + form.initial['application'] = application + form.fields['application'].widget = HiddenInput() + form.fields['interview'].widget = HiddenInput() + form.initial['author'] = request.user + form.fields['author'].widget = HiddenInput() + url = reverse('application_add_note', kwargs={'slug':slug}) + notes = Note.objects.filter(application=application).order_by('-created_at') + return render(request, 'recruitment/partials/note_form.html', {'form': form,'instance':application,'notes':notes,'url':url}) + +def interview_add_note(request, slug): + from .models import Note + from .forms import NoteForm + + interview = get_object_or_404(Interview, slug=slug) + + if request.method == 'POST': + form = NoteForm(request.POST) + if form.is_valid(): + form.save() + messages.success(request, "Note added successfully.") + else: + messages.error(request, "Note content cannot be empty.") + + return redirect('interview_detail', slug=slug) + else: + form = NoteForm() + + form.initial['interview'] = interview + form.fields['interview'].widget = HiddenInput() + form.fields['application'].widget = HiddenInput() + form.initial['author'] = request.user + form.fields['author'].widget = HiddenInput() + + return render(request, 'recruitment/partials/note_form.html', {'form': form,'instance':interview,'notes':interview.notes.all()}) diff --git a/templates/base.html b/templates/base.html index 25a6c24..0076768 100644 --- a/templates/base.html +++ b/templates/base.html @@ -444,11 +444,25 @@ }); }); } + function remove_form_loader(){ + const forms = document.querySelectorAll('form'); + forms.forEach(form => { + form.addEventListener('htmx:afterRequest', function(evt) { + const submitButton = form.querySelector('button[type="submit"], input[type="submit"]'); + if (submitButton) { + submitButton.disabled = false; + submitButton.classList.remove('loading'); + } + }); + }); + } //form_loader(); try{ - document.addEventListener('htmx:afterSwap', form_loader); + document.body.addEventListener('htmx:afterRequest', function(evt) { + remove_form_loader(); + }); }catch(e){ console.error(e) } diff --git a/templates/interviews/preview_schedule.html b/templates/interviews/preview_schedule.html index 3f7392c..44bc1a0 100644 --- a/templates/interviews/preview_schedule.html +++ b/templates/interviews/preview_schedule.html @@ -170,11 +170,7 @@ - {% if schedule_interview_type == "Onsite" %} - - {% else %} +
{% csrf_token %} @@ -184,7 +180,6 @@ {% trans "Confirm Schedule" %} - {% endif %} @@ -196,20 +191,20 @@ -
{# Button #} - -