diff --git a/.env b/.env index b9e2bf0..6bccdbe 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ -DB_NAME=norahuniversity -DB_USER=norahuniversity -DB_PASSWORD=norahuniversity \ No newline at end of file +DB_NAME=haikal_db +DB_USER=faheed +DB_PASSWORD=Faheed@215 diff --git a/NorahUniversity/__pycache__/settings.cpython-312.pyc b/NorahUniversity/__pycache__/settings.cpython-312.pyc index a700acd..741dbde 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-312.pyc and b/NorahUniversity/__pycache__/settings.cpython-312.pyc differ diff --git a/NorahUniversity/__pycache__/urls.cpython-312.pyc b/NorahUniversity/__pycache__/urls.cpython-312.pyc index 29d9d50..f543051 100644 Binary files a/NorahUniversity/__pycache__/urls.cpython-312.pyc and b/NorahUniversity/__pycache__/urls.cpython-312.pyc differ diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 58cb627..2b71991 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -144,6 +144,9 @@ DATABASES = { } } + + + # DATABASES = { # 'default': { # 'ENGINE': 'django.db.backends.sqlite3', diff --git a/recruitment/__pycache__/admin.cpython-312.pyc b/recruitment/__pycache__/admin.cpython-312.pyc index 1ada161..8f7e3a6 100644 Binary files a/recruitment/__pycache__/admin.cpython-312.pyc and b/recruitment/__pycache__/admin.cpython-312.pyc differ diff --git a/recruitment/__pycache__/forms.cpython-312.pyc b/recruitment/__pycache__/forms.cpython-312.pyc index 40890a1..72385f2 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 a99202c..04323b6 100644 Binary files a/recruitment/__pycache__/models.cpython-312.pyc and b/recruitment/__pycache__/models.cpython-312.pyc differ diff --git a/recruitment/__pycache__/serializers.cpython-312.pyc b/recruitment/__pycache__/serializers.cpython-312.pyc index 527ca28..8472924 100644 Binary files a/recruitment/__pycache__/serializers.cpython-312.pyc and b/recruitment/__pycache__/serializers.cpython-312.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-312.pyc b/recruitment/__pycache__/signals.cpython-312.pyc index cd94916..6330397 100644 Binary files a/recruitment/__pycache__/signals.cpython-312.pyc and b/recruitment/__pycache__/signals.cpython-312.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-312.pyc b/recruitment/__pycache__/urls.cpython-312.pyc index c68f7e6..1c54c1d 100644 Binary files a/recruitment/__pycache__/urls.cpython-312.pyc and b/recruitment/__pycache__/urls.cpython-312.pyc differ diff --git a/recruitment/__pycache__/utils.cpython-312.pyc b/recruitment/__pycache__/utils.cpython-312.pyc index dc3c1d7..9ef9f2b 100644 Binary files a/recruitment/__pycache__/utils.cpython-312.pyc and b/recruitment/__pycache__/utils.cpython-312.pyc differ diff --git a/recruitment/__pycache__/views.cpython-312.pyc b/recruitment/__pycache__/views.cpython-312.pyc index 0f15f54..2f473d0 100644 Binary files a/recruitment/__pycache__/views.cpython-312.pyc and b/recruitment/__pycache__/views.cpython-312.pyc differ diff --git a/recruitment/__pycache__/views_frontend.cpython-312.pyc b/recruitment/__pycache__/views_frontend.cpython-312.pyc index d6e4672..c5f727d 100644 Binary files a/recruitment/__pycache__/views_frontend.cpython-312.pyc and b/recruitment/__pycache__/views_frontend.cpython-312.pyc differ diff --git a/recruitment/admin.py b/recruitment/admin.py index 245a14d..649014b 100644 --- a/recruitment/admin.py +++ b/recruitment/admin.py @@ -3,9 +3,9 @@ from django.utils.html import format_html from django.urls import reverse from django.utils import timezone from .models import ( - JobPosting, Application, TrainingMaterial, ZoomMeeting, + JobPosting, Application, TrainingMaterial, ZoomMeetingDetails, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, - SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment, + SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,InterviewNote, AgencyAccessLink, AgencyJobAssignment ) from django.contrib.auth import get_user_model @@ -158,7 +158,7 @@ class TrainingMaterialAdmin(admin.ModelAdmin): save_on_top = True -@admin.register(ZoomMeeting) +@admin.register(ZoomMeetingDetails) class ZoomMeetingAdmin(admin.ModelAdmin): list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at'] list_filter = ['timezone', 'created_at'] @@ -181,24 +181,24 @@ class ZoomMeetingAdmin(admin.ModelAdmin): save_on_top = True -@admin.register(MeetingComment) -class MeetingCommentAdmin(admin.ModelAdmin): - list_display = ['meeting', 'author', 'created_at', 'updated_at'] - list_filter = ['created_at', 'author', 'meeting'] - search_fields = ['content', 'meeting__topic', 'author__username'] - readonly_fields = ['created_at', 'updated_at', 'slug'] - fieldsets = ( - ('Meeting Information', { - 'fields': ('meeting', 'author') - }), - ('Comment Content', { - 'fields': ('content',) - }), - ('Timestamps', { - 'fields': ('created_at', 'updated_at', 'slug') - }), - ) - save_on_top = True +# @admin.register(InterviewNote) +# class MeetingCommentAdmin(admin.ModelAdmin): +# list_display = ['meeting', 'author', 'created_at', 'updated_at'] +# list_filter = ['created_at', 'author', 'meeting'] +# search_fields = ['content', 'meeting__topic', 'author__username'] +# readonly_fields = ['created_at', 'updated_at', 'slug'] +# fieldsets = ( +# ('Meeting Information', { +# 'fields': ('meeting', 'author') +# }), +# ('Comment Content', { +# 'fields': ('content',) +# }), +# ('Timestamps', { +# 'fields': ('created_at', 'updated_at', 'slug') +# }), +# ) +# save_on_top = True @admin.register(FormTemplate) diff --git a/recruitment/decorators.py b/recruitment/decorators.py index 06e68b1..0c3849e 100644 --- a/recruitment/decorators.py +++ b/recruitment/decorators.py @@ -149,6 +149,7 @@ def candidate_user_required(view_func): def staff_user_required(view_func): + """Decorator to restrict view to staff users only.""" return user_type_required(['staff'])(view_func) diff --git a/recruitment/email_service.py b/recruitment/email_service.py index 90447c9..e4adc32 100644 --- a/recruitment/email_service.py +++ b/recruitment/email_service.py @@ -224,11 +224,8 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi logger.error(error_msg, exc_info=True) return {'success': False, 'error': error_msg} -from .models import Candidate -from django.shortcuts import get_object_or_404 -# Assuming other necessary imports like logger, settings, EmailMultiAlternatives, strip_tags are present -from .models import Candidate +from .models import Application from django.shortcuts import get_object_or_404 import logging from django.conf import settings @@ -262,15 +259,16 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= email = email.strip().lower() try: - candidate = get_object_or_404(Candidate, email=email) + candidate = get_object_or_404(Application, person__email=email) except Exception: logger.warning(f"Candidate not found for email: {email}") continue - candidate_name = candidate.first_name + candidate_name = candidate.person.full_name + # --- Candidate belongs to an agency (Final Recipient: Agency) --- - if candidate.belong_to_an_agency and candidate.hiring_agency and candidate.hiring_agency.email: + if candidate.hiring_agency and candidate.hiring_agency.email: agency_email = candidate.hiring_agency.email agency_message = f"Hi, {candidate_name}" + "\n" + message @@ -395,7 +393,7 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= if not from_interview: # Send Emails - Pure Candidates for email in pure_candidate_emails: - candidate_name = Candidate.objects.filter(email=email).first().first_name + candidate_name = Application.objects.filter(email=email).first().first_name candidate_message = f"Hi, {candidate_name}" + "\n" + message send_individual_email(email, candidate_message) @@ -403,7 +401,7 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= i = 0 for email in agency_emails: candidate_email = candidate_through_agency_emails[i] - candidate_name = Candidate.objects.filter(email=candidate_email).first().first_name + candidate_name = Application.objects.filter(email=candidate_email).first().first_name agency_message = f"Hi, {candidate_name}" + "\n" + message send_individual_email(email, agency_message) i += 1 diff --git a/recruitment/forms.py b/recruitment/forms.py index 67e3ee1..e7c4aee 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -10,7 +10,7 @@ from django.contrib.auth.forms import UserCreationForm User = get_user_model() import re from .models import ( - ZoomMeeting, + ZoomMeetingDetails, Application, TrainingMaterial, JobPosting, @@ -19,7 +19,7 @@ from .models import ( BreakTime, JobPostingImage, Profile, - MeetingComment, + InterviewNote, ScheduledInterview, Source, HiringAgency, @@ -27,7 +27,7 @@ from .models import ( AgencyAccessLink, Participants, Message, - Person,OnsiteMeeting + Person,OnsiteLocationDetails ) # from django_summernote.widgets import SummernoteWidget @@ -336,7 +336,7 @@ class ApplicationForm(forms.ModelForm): # person.first_name = self.cleaned_data['first_name'] # person.last_name = self.cleaned_data['last_name'] # person.email = self.cleaned_data['email'] - # person.phone = self.cleaned_data['phone'] + # person.phZoomone = self.cleaned_data['phone'] # if commit: # person.save() @@ -359,10 +359,39 @@ class ApplicationStageForm(forms.ModelForm): "stage": forms.Select(attrs={"class": "form-select"}), } - class ZoomMeetingForm(forms.ModelForm): class Meta: - model = ZoomMeeting + model = ZoomMeetingDetails + fields = ['topic', 'start_time', 'duration'] + labels = { + 'topic': _('Topic'), + 'start_time': _('Start Time'), + 'duration': _('Duration'), + } + widgets = { + 'topic': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter meeting topic'),}), + 'start_time': forms.DateTimeInput(attrs={'class': 'form-control','type': 'datetime-local'}), + 'duration': forms.NumberInput(attrs={'class': 'form-control','min': 1, 'placeholder': _('60')}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'form-horizontal' + self.helper.label_class = 'col-md-3' + self.helper.field_class = 'col-md-9' + self.helper.layout = Layout( + Field('topic', css_class='form-control'), + Field('start_time', css_class='form-control'), + Field('duration', css_class='form-control'), + Submit('submit', _('Create Meeting'), css_class='btn btn-primary') + ) + + +class MeetingForm(forms.ModelForm): + class Meta: + model = ZoomMeetingDetails fields = ["topic", "start_time", "duration"] labels = { "topic": _("Topic"), @@ -687,6 +716,7 @@ class InterviewScheduleForm(forms.ModelForm): class Meta: model = InterviewSchedule fields = [ + 'schedule_interview_type', "applications", "start_date", "end_date", @@ -697,10 +727,8 @@ class InterviewScheduleForm(forms.ModelForm): "buffer_time", "break_start_time", "break_end_time", - "interview_type" ] widgets = { - 'interview_type': forms.Select(attrs={'class': 'form-control'}), "start_date": forms.DateInput( attrs={"type": "date", "class": "form-control"} ), @@ -721,6 +749,7 @@ class InterviewScheduleForm(forms.ModelForm): "break_end_time": forms.TimeInput( attrs={"type": "time", "class": "form-control"} ), + "schedule_interview_type":forms.RadioSelect() } def __init__(self, slug, *args, **kwargs): @@ -734,11 +763,11 @@ class InterviewScheduleForm(forms.ModelForm): return [int(day) for day in working_days] -class MeetingCommentForm(forms.ModelForm): +class InterviewNoteForm(forms.ModelForm): """Form for creating and editing meeting comments""" class Meta: - model = MeetingComment + model = InterviewNote fields = ["content"] widgets = { "content": CKEditor5Widget( @@ -1491,6 +1520,7 @@ class ParticipantsSelectForm(forms.ModelForm): fields = ["participants", "users"] # No direct fields from Participants model + class CandidateEmailForm(forms.Form): """Form for composing emails to participants about a candidate""" to = forms.MultipleChoiceField( @@ -1500,79 +1530,61 @@ class CandidateEmailForm(forms.Form): label=_('Select Candidates'), # Use a descriptive label required=False ) + + subject = forms.CharField( max_length=200, - widget=forms.TextInput( - attrs={ - "class": "form-control", - "placeholder": "Enter email subject", - "required": True, - } - ), - label=_("Subject"), - required=True, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter email subject', + 'required': True + }), + label=_('Subject'), + required=True ) message = forms.CharField( - widget=forms.Textarea( - attrs={ - "class": "form-control", - "rows": 8, - "placeholder": "Enter your message here...", - "required": True, - } - ), - label=_("Message"), - required=True, + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 8, + 'placeholder': 'Enter your message here...', + 'required': True + }), + label=_('Message'), + required=True ) - recipients = forms.MultipleChoiceField( - widget=forms.CheckboxSelectMultiple(attrs={"class": "form-check"}), - label=_("Recipients"), - required=True, - ) + - include_candidate_info = forms.BooleanField( - widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), - label=_("Include candidate information"), - initial=True, - required=False, - ) - - include_meeting_details = forms.BooleanField( - widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), - label=_("Include meeting details"), - initial=True, - required=False, - ) def __init__(self, job, candidates, *args, **kwargs): super().__init__(*args, **kwargs) self.job = job self.candidates=candidates - + candidate_choices=[] for candidate in candidates: candidate_choices.append( (f'candidate_{candidate.id}', f'{candidate.email}') ) - + self.fields['to'].choices =candidate_choices - self.fields['to'].initial = [choice[0] for choice in candidate_choices] - - + self.fields['to'].initial = [choice[0] for choice in candidate_choices] + + # Set initial message with candidate and meeting info initial_message = self._get_initial_message() + if initial_message: - self.fields["message"].initial = initial_message + self.fields['message'].initial = initial_message def _get_initial_message(self): """Generate initial message with candidate and meeting information""" candidate=self.candidates.first() message_parts=[] - + if candidate and candidate.stage == 'Applied': message_parts = [ f"Than you, for your interest in the {self.job.title} role.", @@ -1592,7 +1604,7 @@ class CandidateEmailForm(forms.Form): f"We look forward to reviewing your results.", f"Best regards, The KAAUH Hiring team" ] - + elif candidate and candidate.stage == 'Interview': message_parts = [ f"Than you, for your interest in the {self.job.title} role.", @@ -1603,7 +1615,7 @@ class CandidateEmailForm(forms.Form): f"We look forward to reviewing your results.", f"Best regards, The KAAUH Hiring team" ] - + 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.", @@ -1622,6 +1634,16 @@ class CandidateEmailForm(forms.Form): f"If you have any questions before your start date, please contact [Onboarding Contact].", f"Best regards, The KAAUH Hiring team" ] + + + + + # # Add candidate information + # if self.candidate: + # message_parts.append(f"Candidate Information:") + # message_parts.append(f"Name: {self.candidate.name}") + # message_parts.append(f"Email: {self.candidate.email}") + # message_parts.append(f"Phone: {self.candidate.phone}") # # Add latest meeting information if available # latest_meeting = self.candidate.get_latest_meeting @@ -1633,43 +1655,33 @@ class CandidateEmailForm(forms.Form): # if latest_meeting.join_url: # message_parts.append(f"Join URL: {latest_meeting.join_url}") - return "\n".join(message_parts) + return '\n'.join(message_parts) - def clean_recipients(self): - """Ensure at least one recipient is selected""" - recipients = self.cleaned_data.get('recipients') - if not recipients: - raise forms.ValidationError(_('Please select at least one recipient.')) - return recipients def get_email_addresses(self): """Extract email addresses from selected recipients""" email_addresses = [] - recipients = self.cleaned_data.get('recipients', []) - for recipient in recipients: - if recipient.startswith('participant_'): - participant_id = recipient.split('_')[1] - try: - participant = Participants.objects.get(id=participant_id) - email_addresses.append(participant.email) - except Participants.DoesNotExist: - continue - elif recipient.startswith('user_'): - user_id = recipient.split('_')[1] - try: - user = User.objects.get(id=user_id) - email_addresses.append(user.email) - except User.DoesNotExist: - continue + + candidates=self.cleaned_data.get('to',[]) + + if candidates: + for candidate in candidates: + if candidate.startswith('candidate_'): + print("candidadte: {candidate}") + candidate_id = candidate.split('_')[1] + try: + candidate = Application.objects.get(id=candidate_id) + email_addresses.append(candidate.email) + except Application.DoesNotExist: + continue return list(set(email_addresses)) # Remove duplicates - + def get_formatted_message(self): - """Get formatted message with optional additional information""" - message = self.cleaned_data.get("message", "") - + """Get the formatted message with optional additional information""" + message = self.cleaned_data.get('message', '') return message @@ -1692,70 +1704,183 @@ class InterviewParticpantsForm(forms.ModelForm): +# class InterviewEmailForm(forms.Form): +# subject = forms.CharField( +# max_length=200, +# widget=forms.TextInput(attrs={ +# 'class': 'form-control', +# 'placeholder': 'Enter email subject', +# 'required': True +# }), +# label=_('Subject'), +# required=True +# ) + +# message_for_candidate= forms.CharField( +# widget=forms.Textarea(attrs={ +# 'class': 'form-control', +# 'rows': 8, +# 'placeholder': 'Enter your message here...', +# 'required': True +# }), +# label=_('Message'), +# required=False +# ) +# message_for_agency= forms.CharField( +# widget=forms.Textarea(attrs={ +# 'class': 'form-control', +# 'rows': 8, +# 'placeholder': 'Enter your message here...', +# 'required': True +# }), +# label=_('Message'), +# required=False +# ) +# message_for_participants= forms.CharField( +# widget=forms.Textarea(attrs={ +# 'class': 'form-control', +# 'rows': 8, +# 'placeholder': 'Enter your message here...', +# 'required': True +# }), +# label=_('Message'), +# required=False +# ) + +# def __init__(self, *args,candidate, external_participants, system_participants,meeting,job,**kwargs): +# super().__init__(*args, **kwargs) + +# # --- Data Preparation --- +# # Note: Added error handling for agency name if it's missing (though it shouldn't be based on your check) +# formatted_date = meeting.start_time.strftime('%Y-%m-%d') +# formatted_time = meeting.start_time.strftime('%I:%M %p') +# zoom_link = meeting.join_url +# duration = meeting.duration +# job_title = job.title +# agency_name = candidate.hiring_agency.name if candidate.belong_to_an_agency and candidate.hiring_agency else "Hiring Agency" + +# # --- Combined Participants List for Internal Email --- +# external_participants_names = ", ".join([p.name for p in external_participants ]) +# system_participants_names = ", ".join([p.first_name for p in system_participants ]) + +# # Combine and ensure no leading/trailing commas if one list is empty +# participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names])) + + +# # --- 1. Candidate Message (More concise and structured) --- +# candidate_message = f""" +# Dear {candidate.full_name}, + +# Thank you for your interest in the **{job_title}** position at KAAUH. We're pleased to invite you to an interview! + +# The details of your virtual interview are as follows: + +# - **Date:** {formatted_date} +# - **Time:** {formatted_time} (RIYADH TIME) +# - **Duration:** {duration} +# - **Meeting Link:** {zoom_link} + +# Please click the link at the scheduled time to join the interview. + +# Kindly reply to this email to **confirm your attendance** or to propose an alternative time if necessary. + +# We look forward to meeting you. + +# Best regards, +# KAAUH Hiring Team +# """ + + +# # --- 2. Agency Message (Professional and clear details) --- +# agency_message = f""" +# Dear {agency_name}, + +# We have scheduled an interview for your candidate, **{candidate.full_name}**, for the **{job_title}** role. + +# Please forward the following details to the candidate and ensure they are fully prepared. + +# **Interview Details:** + +# - **Candidate:** {candidate.full_name} +# - **Job Title:** {job_title} +# - **Date:** {formatted_date} +# - **Time:** {formatted_time} (RIYADH TIME) +# - **Duration:** {duration} +# - **Meeting Link:** {zoom_link} + +# Please let us know if you or the candidate have any questions. + +# Best regards, +# KAAUH Hiring Team +# """ + +# # --- 3. Participants Message (Action-oriented and informative) --- +# participants_message = f""" +# Hi Team, + +# This is a reminder of the upcoming interview you are scheduled to participate in for the **{job_title}** position. + +# **Interview Summary:** + +# - **Candidate:** {candidate.full_name} +# - **Date:** {formatted_date} +# - **Time:** {formatted_time} (RIYADH TIME) +# - **Duration:** {duration} +# - **Your Fellow Interviewers:** {participant_names} + +# **Action Items:** + +# 1. Please review **{candidate.full_name}'s** resume and notes. +# 2. The official calendar invite contains the meeting link ({zoom_link}) and should be used to join. +# 3. Be ready to start promptly at the scheduled time. + +# Thank you for your participation. + +# Best regards, +# KAAUH HIRING TEAM +# """ + +# # Set initial data +# self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}" +# # .strip() removes the leading/trailing blank lines caused by the f""" format +# self.initial['message_for_candidate'] = candidate_message.strip() +# self.initial['message_for_agency'] = agency_message.strip() +# self.initial['message_for_participants'] = participants_message.strip() + + class InterviewEmailForm(forms.Form): - subject = forms.CharField( - max_length=200, - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Enter email subject', - 'required': True - }), - label=_('Subject'), - required=True - ) + # ... (Field definitions) - message_for_candidate= forms.CharField( - widget=forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 8, - 'placeholder': 'Enter your message here...', - 'required': True - }), - label=_('Message'), - required=False - ) - message_for_agency= forms.CharField( - widget=forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 8, - 'placeholder': 'Enter your message here...', - 'required': True - }), - label=_('Message'), - required=False - ) - message_for_participants= forms.CharField( - widget=forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 8, - 'placeholder': 'Enter your message here...', - 'required': True - }), - label=_('Message'), - required=False - ) - - def __init__(self, *args,candidate, external_participants, system_participants,meeting,job,**kwargs): + def __init__(self, *args, candidate, external_participants, system_participants, meeting, job, **kwargs): super().__init__(*args, **kwargs) + location = meeting.interview_location + # --- Data Preparation --- - # Note: Added error handling for agency name if it's missing (though it shouldn't be based on your check) - formatted_date = meeting.start_time.strftime('%Y-%m-%d') - formatted_time = meeting.start_time.strftime('%I:%M %p') - zoom_link = meeting.join_url - duration = meeting.duration + + # Safely access details through the related InterviewLocation object + if location and location.start_time: + formatted_date = location.start_time.strftime('%Y-%m-%d') + formatted_time = location.start_time.strftime('%I:%M %p') + duration = location.duration + meeting_link = location.details_url if location.details_url else "N/A (See Location Topic)" + else: + # Handle case where location or time is missing/None + formatted_date = "TBD - Awaiting Scheduling" + formatted_time = "TBD" + duration = "N/A" + meeting_link = "Not Available" + job_title = job.title agency_name = candidate.hiring_agency.name if candidate.belong_to_an_agency and candidate.hiring_agency else "Hiring Agency" # --- Combined Participants List for Internal Email --- external_participants_names = ", ".join([p.name for p in external_participants ]) system_participants_names = ", ".join([p.first_name for p in system_participants ]) - - # Combine and ensure no leading/trailing commas if one list is empty participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names])) - # --- 1. Candidate Message (More concise and structured) --- + # --- 1. Candidate Message (Use meeting_link) --- candidate_message = f""" Dear {candidate.full_name}, @@ -1766,7 +1891,7 @@ The details of your virtual interview are as follows: - **Date:** {formatted_date} - **Time:** {formatted_time} (RIYADH TIME) - **Duration:** {duration} -- **Meeting Link:** {zoom_link} +- **Meeting Link:** {meeting_link} Please click the link at the scheduled time to join the interview. @@ -1777,37 +1902,25 @@ We look forward to meeting you. Best regards, KAAUH Hiring Team """ - - + # ... (Messages for agency and participants remain the same, using the updated safe variables) + # --- 2. Agency Message (Professional and clear details) --- agency_message = f""" Dear {agency_name}, - -We have scheduled an interview for your candidate, **{candidate.full_name}**, for the **{job_title}** role. - -Please forward the following details to the candidate and ensure they are fully prepared. - +... **Interview Details:** - -- **Candidate:** {candidate.full_name} -- **Job Title:** {job_title} +... - **Date:** {formatted_date} - **Time:** {formatted_time} (RIYADH TIME) - **Duration:** {duration} -- **Meeting Link:** {zoom_link} - -Please let us know if you or the candidate have any questions. - -Best regards, -KAAUH Hiring Team +- **Meeting Link:** {meeting_link} +... """ # --- 3. Participants Message (Action-oriented and informative) --- participants_message = f""" Hi Team, - -This is a reminder of the upcoming interview you are scheduled to participate in for the **{job_title}** position. - +... **Interview Summary:** - **Candidate:** {candidate.full_name} @@ -1819,25 +1932,16 @@ This is a reminder of the upcoming interview you are scheduled to participate in **Action Items:** 1. Please review **{candidate.full_name}'s** resume and notes. -2. The official calendar invite contains the meeting link ({zoom_link}) and should be used to join. +2. The official calendar invite contains the meeting link ({meeting_link}) and should be used to join. 3. Be ready to start promptly at the scheduled time. - -Thank you for your participation. - -Best regards, -KAAUH HIRING TEAM +... """ - # Set initial data self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}" - # .strip() removes the leading/trailing blank lines caused by the f""" format self.initial['message_for_candidate'] = candidate_message.strip() self.initial['message_for_agency'] = agency_message.strip() self.initial['message_for_participants'] = participants_message.strip() - - - # class OnsiteLocationForm(forms.ModelForm): # class Meta: # model= @@ -1846,26 +1950,91 @@ KAAUH HIRING TEAM # 'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}), # } - - +#during bulk schedule class OnsiteMeetingForm(forms.ModelForm): class Meta: - model = OnsiteMeeting - fields = ['topic', 'start_time', 'duration', 'timezone', 'location', 'status'] + model = OnsiteLocationDetails + # Include 'room_number' and update the field list + fields = ['topic', 'physical_address', 'room_number'] widgets = { - 'topic': forms.TextInput(attrs={'placeholder': 'Enter the Meeting Topic', 'class': 'form-control'}), - 'start_time': forms.DateTimeInput( - attrs={'type': 'datetime-local', 'class': 'form-control'} + 'topic': forms.TextInput( + attrs={'placeholder': 'Enter the Meeting Topic', 'class': 'form-control'} ), - 'duration': forms.NumberInput( - attrs={'min': 15, 'placeholder': 'Duration in minutes', 'class': 'form-control'} + + 'physical_address': forms.TextInput( + attrs={'placeholder': 'Physical address (e.g., street address)', 'class': 'form-control'} ), - 'location': forms.TextInput(attrs={'placeholder': 'Physical location', 'class': 'form-control'}), - 'timezone': forms.TextInput(attrs={'class': 'form-control'}), - 'status': forms.Select(attrs={'class': 'form-control'}), + + 'room_number': forms.TextInput( + attrs={'placeholder': 'Room Number/Name (Optional)', 'class': 'form-control'} + ), + + } +class OnsiteReshuduleForm(forms.ModelForm): + class Meta: + model = OnsiteLocationDetails + fields = ['topic', 'physical_address', 'room_number','start_time','duration','status'] + widgets = { + 'topic': forms.TextInput( + attrs={'placeholder': 'Enter the Meeting Topic', 'class': 'form-control'} + ), + + 'physical_address': forms.TextInput( + attrs={'placeholder': 'Physical address (e.g., street address)', 'class': 'form-control'} + ), + + 'room_number': forms.TextInput( + attrs={'placeholder': 'Room Number/Name (Optional)', 'class': 'form-control'} + ), + + + } + + +class OnsiteScheduleForm(forms.ModelForm): + # Add fields for the foreign keys required by ScheduledInterview + application = forms.ModelChoiceField( + queryset=Application.objects.all(), + widget=forms.HiddenInput(), # Hide this in the form, set by the view + label=_("Candidate Application") + ) + job = forms.ModelChoiceField( + queryset=JobPosting.objects.all(), + widget=forms.HiddenInput(), # Hide this in the form, set by the view + label=_("Job Posting") + ) + + class Meta: + model = OnsiteLocationDetails + # Include all fields from OnsiteLocationDetails plus the new ones + fields = ['topic', 'physical_address', 'room_number', 'start_time', 'duration', 'status', 'application', 'job'] + + widgets = { + 'topic': forms.TextInput( + attrs={'placeholder': _('Enter the Meeting Topic'), 'class': 'form-control'} + ), + 'physical_address': forms.TextInput( + attrs={'placeholder': _('Physical address (e.g., street address)'), 'class': 'form-control'} + ), + 'room_number': forms.TextInput( + attrs={'placeholder': _('Room Number/Name (Optional)'), 'class': 'form-control'} + ), + # You should explicitly set widgets for start_time, duration, and status here + # if they need Bootstrap classes, otherwise they will use default HTML inputs. + # Example: + 'start_time': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}), + 'duration': forms.NumberInput(attrs={'class': 'form-control', 'min': 15}), + 'status': forms.HiddenInput(), # Status should default to SCHEDULED, so hide it. + } + + + + + + class MessageForm(forms.ModelForm): """Form for creating and editing messages between users""" diff --git a/recruitment/hooks.py b/recruitment/hooks.py index ca9c361..436102e 100644 --- a/recruitment/hooks.py +++ b/recruitment/hooks.py @@ -1,11 +1,11 @@ -from .models import Candidate +from .models import Application from time import sleep def callback_ai_parsing(task): if task.success: try: pk = task.args[0] - c = Candidate.objects.get(pk=pk) + c = Application.objects.get(pk=pk) if c.retry and not c.is_resume_parsed: sleep(30) c.retry -= 1 diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py index 2e786ed..e11b731 100644 --- a/recruitment/migrations/0001_initial.py +++ b/recruitment/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.6 on 2025-11-13 13:12 +# Generated by Django 5.2.7 on 2025-11-14 21:43 import django.contrib.auth.models import django.contrib.auth.validators @@ -49,21 +49,20 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name='OnsiteMeeting', + name='InterviewLocation', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), - ('topic', models.CharField(max_length=255, verbose_name='Topic')), - ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), - ('duration', models.PositiveIntegerField(verbose_name='Duration')), - ('timezone', models.CharField(max_length=50, verbose_name='Timezone')), - ('location', models.CharField(blank=True, null=True)), - ('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')), + ('location_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], db_index=True, max_length=10, verbose_name='Location Type')), + ('details_url', models.URLField(blank=True, max_length=2048, null=True, verbose_name='Meeting/Location URL')), + ('topic', models.CharField(blank=True, help_text="e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'", max_length=255, verbose_name='Location/Meeting Topic')), + ('timezone', models.CharField(default='UTC', max_length=50, verbose_name='Timezone')), ], options={ - 'abstract': False, + 'verbose_name': 'Interview Location', + 'verbose_name_plural': 'Interview Locations', }, ), migrations.CreateModel( @@ -112,31 +111,6 @@ class Migration(migrations.Migration): 'ordering': ['name'], }, ), - migrations.CreateModel( - name='ZoomMeeting', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), - ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), - ('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')), - ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), - ('duration', models.PositiveIntegerField(verbose_name='Duration')), - ('timezone', models.CharField(max_length=50, verbose_name='Timezone')), - ('join_url', models.URLField(verbose_name='Join URL')), - ('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')), - ('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')), - ('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')), - ('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')), - ('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')), - ('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')), - ('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')), - ], - options={ - 'abstract': False, - }, - ), migrations.CreateModel( name='CustomUser', fields=[ @@ -287,6 +261,43 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Applications', }, ), + migrations.CreateModel( + name='OnsiteLocationDetails', + fields=[ + ('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')), + ('physical_address', models.CharField(blank=True, max_length=255, null=True, verbose_name='Physical Address')), + ('room_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='Room Number/Name')), + ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), + ('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')), + ('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)), + ], + options={ + 'verbose_name': 'Onsite Location Details', + 'verbose_name_plural': 'Onsite Location Details', + }, + bases=('recruitment.interviewlocation',), + ), + migrations.CreateModel( + name='ZoomMeetingDetails', + fields=[ + ('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')), + ('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)), + ('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), + ('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')), + ('meeting_id', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='External Meeting ID')), + ('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')), + ('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')), + ('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')), + ('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')), + ('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')), + ('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')), + ], + options={ + 'verbose_name': 'Zoom Meeting Details', + 'verbose_name_plural': 'Zoom Meeting Details', + }, + bases=('recruitment.interviewlocation',), + ), migrations.CreateModel( name='JobPosting', fields=[ @@ -343,7 +354,7 @@ class Migration(migrations.Migration): ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), - ('interview_type', models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type')), + ('schedule_interview_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], default='Remote', max_length=10, verbose_name='Interview Type')), ('start_date', models.DateField(db_index=True, verbose_name='Start Date')), ('end_date', models.DateField(db_index=True, verbose_name='End Date')), ('working_days', models.JSONField(verbose_name='Working Days')), @@ -353,10 +364,14 @@ class Migration(migrations.Migration): ('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')), ('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')), ('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')), - ('applications', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.application')), + ('applications', models.ManyToManyField(blank=True, related_name='interview_schedules', to='recruitment.application')), ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('template_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schedule_templates', to='recruitment.interviewlocation', verbose_name='Location Template (Zoom/Onsite)')), ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')), ], + options={ + 'abstract': False, + }, ), migrations.AddField( model_name='formtemplate', @@ -423,6 +438,27 @@ class Migration(migrations.Migration): 'ordering': ['-created_at'], }, ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', models.TextField(verbose_name='Notification Message')), + ('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')), + ('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')), + ('last_error', models.TextField(blank=True, verbose_name='Last Error Message')), + ('inteview', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.interviewschedule', verbose_name='Related Interview')), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')), + ], + options={ + 'verbose_name': 'Notification', + 'verbose_name_plural': 'Notifications', + 'ordering': ['-scheduled_for', '-created_at'], + }, + ), migrations.CreateModel( name='Person', fields=[ @@ -464,6 +500,42 @@ class Migration(migrations.Migration): ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), ], ), + migrations.CreateModel( + name='ScheduledInterview', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')), + ('interview_time', models.TimeField(verbose_name='Interview Time')), + ('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')), + ('interview_location', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='scheduled_interview', to='recruitment.interviewlocation', verbose_name='Meeting/Location Details')), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')), + ('participants', models.ManyToManyField(blank=True, to='recruitment.participants')), + ('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interviews', to='recruitment.interviewschedule')), + ('system_users', models.ManyToManyField(blank=True, related_name='attended_interviews', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='InterviewNote', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')), + ('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content/Feedback')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_notes', to=settings.AUTH_USER_MODEL, verbose_name='Author')), + ('interview', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.scheduledinterview', verbose_name='Scheduled Interview')), + ], + options={ + 'verbose_name': 'Interview Note', + 'verbose_name_plural': 'Interview Notes', + 'ordering': ['created_at'], + }, + ), migrations.CreateModel( name='SharedFormTemplate', fields=[ @@ -523,63 +595,6 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Training Materials', }, ), - migrations.CreateModel( - name='ScheduledInterview', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), - ('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')), - ('interview_time', models.TimeField(verbose_name='Interview Time')), - ('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')), - ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')), - ('onsite_meeting', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='onsite_interview', to='recruitment.onsitemeeting')), - ('participants', models.ManyToManyField(blank=True, to='recruitment.participants')), - ('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')), - ('system_users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), - ('zoom_meeting', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')), - ], - ), - migrations.CreateModel( - name='Notification', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('message', models.TextField(verbose_name='Notification Message')), - ('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')), - ('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')), - ('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')), - ('last_error', models.TextField(blank=True, verbose_name='Last Error Message')), - ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')), - ('related_meeting', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.zoommeeting', verbose_name='Related Meeting')), - ], - options={ - 'verbose_name': 'Notification', - 'verbose_name_plural': 'Notifications', - 'ordering': ['-scheduled_for', '-created_at'], - }, - ), - migrations.CreateModel( - name='MeetingComment', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), - ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), - ('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content')), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meeting_comments', to=settings.AUTH_USER_MODEL, verbose_name='Author')), - ('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recruitment.zoommeeting', verbose_name='Meeting')), - ], - options={ - 'verbose_name': 'Meeting Comment', - 'verbose_name_plural': 'Meeting Comments', - 'ordering': ['-created_at'], - }, - ), migrations.CreateModel( name='AgencyAccessLink', fields=[ @@ -645,18 +660,6 @@ class Migration(migrations.Migration): model_name='formsubmission', index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'), ), - migrations.AddIndex( - model_name='interviewschedule', - index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'), - ), - migrations.AddIndex( - model_name='interviewschedule', - index=models.Index(fields=['end_date'], name='recruitment_end_dat_aeb00e_idx'), - ), - migrations.AddIndex( - model_name='interviewschedule', - index=models.Index(fields=['created_by'], name='recruitment_created_d0bdcc_idx'), - ), migrations.AddIndex( model_name='formtemplate', index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'), @@ -701,6 +704,14 @@ class Migration(migrations.Migration): model_name='message', index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'), ), + migrations.AddIndex( + model_name='notification', + index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'), + ), + migrations.AddIndex( + model_name='notification', + index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'), + ), migrations.AddIndex( model_name='person', index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'), @@ -733,14 +744,6 @@ class Migration(migrations.Migration): name='application', unique_together={('person', 'job')}, ), - migrations.AddIndex( - model_name='jobposting', - index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'), - ), - migrations.AddIndex( - model_name='jobposting', - index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'), - ), migrations.AddIndex( model_name='scheduledinterview', index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'), @@ -754,11 +757,11 @@ class Migration(migrations.Migration): index=models.Index(fields=['application', 'job'], name='recruitment_applica_927561_idx'), ), migrations.AddIndex( - model_name='notification', - index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'), + model_name='jobposting', + index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'), ), migrations.AddIndex( - model_name='notification', - index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'), + model_name='jobposting', + index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'), ), ] diff --git a/recruitment/migrations/0002_zoommeetingdetails_host_email.py b/recruitment/migrations/0002_zoommeetingdetails_host_email.py new file mode 100644 index 0000000..6425f6a --- /dev/null +++ b/recruitment/migrations/0002_zoommeetingdetails_host_email.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-14 22:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='zoommeetingdetails', + name='host_email', + field=models.CharField(blank=True, null=True), + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index fab3793..e7b0d6f 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -547,6 +547,12 @@ class Person(Base): from django.contrib.contenttypes.models import ContentType content_type = ContentType.objects.get_for_model(self.__class__) return Document.objects.filter(content_type=content_type, object_id=self.id) + @property + def belong_to_an_agency(self): + if self.agency: + return True + else: + return False class Application(Base): @@ -890,13 +896,57 @@ class Application(Base): """Legacy compatibility - get scheduled interviews for this application""" return self.scheduled_interviews.all() + # @property + # def get_latest_meeting(self): + # """Legacy compatibility - get latest meeting for this application""" + # #get parent interview location modal: + + # schedule=self.scheduled_interviews.order_by("-created_at").first() + + + # if schedule: + # print(schedule) + # interview_location=schedule.interview_location + # else: + # return None + # if interview_location and interview_location.location_type=='Remote': + # meeting = interview_location.zoommeetingdetails + + # return meeting + # else: + # meeting = interview_location.onsitelocationdetails + # return meeting @property def get_latest_meeting(self): - """Legacy compatibility - get latest meeting for this application""" + """ + Retrieves the most specific location details (subclass instance) + of the latest ScheduledInterview for this application, or None. + """ + # 1. Get the latest ScheduledInterview schedule = self.scheduled_interviews.order_by("-created_at").first() - if schedule: - return schedule.zoom_meeting - return None + + # Check if a schedule exists and if it has an interview location + if not schedule or not schedule.interview_location: + return None + + # Get the base location instance + interview_location = schedule.interview_location + + # 2. Safely retrieve the specific subclass details + + # Determine the expected subclass accessor name based on the location_type + if interview_location.location_type == 'Remote': + accessor_name = 'zoommeetingdetails' + else: # Assumes 'Onsite' or any other type defaults to Onsite + accessor_name = 'onsitelocationdetails' + + # Use getattr to safely retrieve the specific meeting object (subclass instance). + # If the accessor exists but points to None (because the subclass record was deleted), + # or if the accessor name is wrong for the object's true type, it will return None. + meeting_details = getattr(interview_location, accessor_name, None) + + return meeting_details + @property def has_future_meeting(self): @@ -966,129 +1016,6 @@ class TrainingMaterial(Base): def __str__(self): return self.title -class OnsiteMeeting(Base): - class MeetingStatus(models.TextChoices): - WAITING = "waiting", _("Waiting") - STARTED = "started", _("Started") - ENDED = "ended", _("Ended") - CANCELLED = "cancelled",_("Cancelled") - # Basic meeting details - topic = models.CharField(max_length=255, verbose_name=_("Topic")) - start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time")) # Added index - duration = models.PositiveIntegerField( - verbose_name=_("Duration") - ) # Duration in minutes - timezone = models.CharField(max_length=50, verbose_name=_("Timezone")) - location=models.CharField(null=True,blank=True) - status = models.CharField( - db_index=True, max_length=20, # Added index - null=True, - blank=True, - verbose_name=_("Status"), - default=MeetingStatus.WAITING, - ) - -class ZoomMeeting(Base): - class MeetingStatus(models.TextChoices): - WAITING = "waiting", _("Waiting") - STARTED = "started", _("Started") - ENDED = "ended", _("Ended") - CANCELLED = "cancelled", _("Cancelled") - - # Basic meeting details - 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( - verbose_name=_("Duration") - ) # Duration in minutes - timezone = models.CharField(max_length=50, verbose_name=_("Timezone")) - join_url = models.URLField( - verbose_name=_("Join URL") - ) # URL for participants to join - participant_video = models.BooleanField( - default=True, verbose_name=_("Participant Video") - ) - password = models.CharField( - max_length=20, blank=True, null=True, verbose_name=_("Password") - ) - join_before_host = models.BooleanField( - default=False, verbose_name=_("Join Before Host") - ) - mute_upon_entry = models.BooleanField( - default=False, verbose_name=_("Mute Upon Entry") - ) - waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room")) - - zoom_gateway_response = models.JSONField( - blank=True, null=True, verbose_name=_("Zoom Gateway Response") - ) - status = models.CharField( - db_index=True, - max_length=20, # Added index - null=True, - blank=True, - verbose_name=_("Status"), - default=MeetingStatus.WAITING, - ) - # Timestamps - - def __str__(self): - return self.topic - @property - - def get_job(self): - return self.interview.job - - @property - def get_candidate(self): - return self.interview.application.person - @property - def candidate_full_name(self): - return self.interview.application.person.full_name - - @property - def get_participants(self): - return self.interview.job.participants.all() - - @property - def get_users(self): - return self.interview.job.users.all() - -class MeetingComment(Base): - """ - Model for storing meeting comments/notes - """ - - meeting = models.ForeignKey( - ZoomMeeting, - on_delete=models.CASCADE, - related_name="comments", - verbose_name=_("Meeting"), - ) - author = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name="meeting_comments", - verbose_name=_("Author"), - ) - content = CKEditor5Field(verbose_name=_("Content"), config_name="extends") - # Inherited from Base: created_at, updated_at, slug - - class Meta: - verbose_name = _("Meeting Comment") - verbose_name_plural = _("Meeting Comments") - ordering = ["-created_at"] - - def __str__(self): - return f"Comment by {self.author.get_username()} on {self.meeting.topic}" class FormTemplate(Base): @@ -1894,136 +1821,6 @@ class BreakTime(models.Model): return f"{self.start_time} - {self.end_time}" -class InterviewSchedule(Base): - """Stores the scheduling criteria for 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" - ) - - job = models.ForeignKey( - JobPosting, - on_delete=models.CASCADE, - related_name="interview_schedules", - db_index=True, - ) - applications = models.ManyToManyField( - Application, related_name="interview_schedules", blank=True, null=True - ) - start_date = models.DateField( - db_index=True, verbose_name=_("Start Date") - ) # Added index - end_date = models.DateField( - db_index=True, verbose_name=_("End Date") - ) # Added index - working_days = models.JSONField( - verbose_name=_("Working Days") - ) # Store days of week as [0,1,2,3,4] for Mon-Fri - start_time = models.TimeField(verbose_name=_("Start Time")) - end_time = models.TimeField(verbose_name=_("End Time")) - - break_start_time = models.TimeField( - verbose_name=_("Break Start Time"), null=True, blank=True - ) - break_end_time = models.TimeField( - verbose_name=_("Break End Time"), null=True, blank=True - ) - - interview_duration = models.PositiveIntegerField( - verbose_name=_("Interview Duration (minutes)") - ) - buffer_time = models.PositiveIntegerField( - verbose_name=_("Buffer Time (minutes)"), default=0 - ) - created_by = models.ForeignKey( - User, on_delete=models.CASCADE, db_index=True - ) # Added index - - def __str__(self): - return f"Interview Schedule for {self.job.title}" - - class Meta: - indexes = [ - models.Index(fields=["start_date"]), - models.Index(fields=["end_date"]), - models.Index(fields=["created_by"]), - ] - - -class ScheduledInterview(Base): - """Stores individual scheduled interviews""" - - application = models.ForeignKey( - Application, - on_delete=models.CASCADE, - related_name="scheduled_interviews", - db_index=True, - ) - - - - participants = models.ManyToManyField('Participants', blank=True) - system_users=models.ManyToManyField(User,blank=True) - - - job = models.ForeignKey( - "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, - null=True, blank=True - ) - - onsite_meeting= models.OneToOneField( - OnsiteMeeting, on_delete=models.CASCADE, related_name="onsite_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, - ) - - interview_date = models.DateField( - db_index=True, verbose_name=_("Interview Date") - ) # Added index - interview_time = models.TimeField(verbose_name=_("Interview Time")) - status = models.CharField( - db_index=True, - max_length=20, # Added index - choices=[ - ("scheduled", _("Scheduled")), - ("confirmed", _("Confirmed")), - ("cancelled", _("Cancelled")), - ("completed", _("Completed")), - ], - default="scheduled", - ) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - def __str__(self): - return f"Interview with {self.application.person.full_name} for {self.job.title}" - - class Meta: - indexes = [ - models.Index(fields=["job", "status"]), - models.Index(fields=["interview_date", "interview_time"]), - models.Index(fields=["application", "job"]), - ] class Notification(models.Model): @@ -2061,13 +1858,13 @@ class Notification(models.Model): default=Status.PENDING, verbose_name=_("Status"), ) - related_meeting = models.ForeignKey( - ZoomMeeting, + inteview= models.ForeignKey( + 'InterviewSchedule', on_delete=models.CASCADE, related_name="notifications", null=True, blank=True, - verbose_name=_("Related Meeting"), + verbose_name=_("Related Interview"), ) scheduled_for = models.DateTimeField( verbose_name=_("Scheduled Send Time"), @@ -2234,7 +2031,7 @@ class Message(Base): # If job-related, ensure candidate applied for the job if self.job: - if not Candidate.objects.filter(job=self.job, user=self.sender).exists(): + if not Application.objects.filter(job=self.job, user=self.sender).exists(): raise ValidationError(_("You can only message about jobs you have applied for.")) def save(self, *args, **kwargs): @@ -2332,3 +2129,323 @@ class Document(Base): if self.file: return self.file.name.split('.')[-1].upper() return "" + +class InterviewLocation(Base): + """ + Base model for all interview location/meeting details (remote or onsite) + using Multi-Table Inheritance. + """ + class LocationType(models.TextChoices): + REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)') + ONSITE = 'Onsite', _('In-Person (Physical Location)') + + class Status(models.TextChoices): + """Defines the possible real-time statuses for any interview location/meeting.""" + WAITING = "waiting", _("Waiting") + STARTED = "started", _("Started") + ENDED = "ended", _("Ended") + CANCELLED = "cancelled", _("Cancelled") + + location_type = models.CharField( + max_length=10, + choices=LocationType.choices, + verbose_name=_("Location Type"), + db_index=True + ) + + details_url = models.URLField( + verbose_name=_("Meeting/Location URL"), + max_length=2048, + blank=True, + null=True + ) + + topic = models.CharField( # Renamed from 'description' to 'topic' to match your input + max_length=255, + verbose_name=_("Location/Meeting Topic"), + blank=True, + help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'") + ) + + timezone = models.CharField( + max_length=50, + verbose_name=_("Timezone"), + default='UTC' + ) + + def __str__(self): + # Use 'topic' instead of 'description' + return f"{self.get_location_type_display()} - {self.topic[:50]}" + + class Meta: + verbose_name = _("Interview Location") + verbose_name_plural = _("Interview Locations") + + +class ZoomMeetingDetails(InterviewLocation): + """Concrete model for remote interviews (Zoom specifics).""" + + status = models.CharField( + db_index=True, + max_length=20, + choices=InterviewLocation.Status.choices, + default=InterviewLocation.Status.WAITING, + ) + start_time = models.DateTimeField( + db_index=True, verbose_name=_("Start Time") + ) + duration = models.PositiveIntegerField( + verbose_name=_("Duration (minutes)") + ) + meeting_id = models.CharField( + db_index=True, + max_length=50, + unique=True, + verbose_name=_("External Meeting ID") + ) + password = models.CharField( + max_length=20, blank=True, null=True, verbose_name=_("Password") + ) + zoom_gateway_response = models.JSONField( + blank=True, null=True, verbose_name=_("Zoom Gateway Response") + ) + participant_video = models.BooleanField( + default=True, verbose_name=_("Participant Video") + ) + join_before_host = models.BooleanField( + default=False, verbose_name=_("Join Before Host") + ) + + host_email=models.CharField(null=True,blank=True) + mute_upon_entry = models.BooleanField( + default=False, verbose_name=_("Mute Upon Entry") + ) + waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room")) + + # *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation *** + # @classmethod + # def create(cls, **kwargs): + # """Factory method to ensure location_type is set to REMOTE.""" + # return cls(location_type=InterviewLocation.LocationType.REMOTE, **kwargs) + + class Meta: + verbose_name = _("Zoom Meeting Details") + verbose_name_plural = _("Zoom Meeting Details") + + +class OnsiteLocationDetails(InterviewLocation): + """Concrete model for onsite interviews (Room/Address specifics).""" + + physical_address = models.CharField( + max_length=255, + verbose_name=_("Physical Address"), + blank=True, + null=True + ) + room_number = models.CharField( + max_length=50, + verbose_name=_("Room Number/Name"), + blank=True, + null=True + ) + start_time = models.DateTimeField( + db_index=True, verbose_name=_("Start Time") + ) + duration = models.PositiveIntegerField( + verbose_name=_("Duration (minutes)") + ) + status = models.CharField( + db_index=True, + max_length=20, + choices=InterviewLocation.Status.choices, + default=InterviewLocation.Status.WAITING, + ) + + # *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation *** + # @classmethod + # def create(cls, **kwargs): + # """Factory method to ensure location_type is set to ONSITE.""" + # return cls(location_type=InterviewLocation.LocationType.ONSITE, **kwargs) + + class Meta: + verbose_name = _("Onsite Location Details") + verbose_name_plural = _("Onsite Location Details") + + +# --- 2. Scheduling Models --- + +class InterviewSchedule(Base): + """Stores the TEMPLATE criteria for BULK interview generation.""" + + # We need a field to store the template location details linked to this bulk schedule. + # This location object contains the generic Zoom/Onsite info to be cloned. + template_location = models.ForeignKey( + InterviewLocation, + on_delete=models.SET_NULL, + related_name="schedule_templates", + null=True, + blank=True, + verbose_name=_("Location Template (Zoom/Onsite)") + ) + + # NOTE: schedule_interview_type field is needed in the form, + # but not on the model itself if we use template_location. + # If you want to keep it: + schedule_interview_type = models.CharField( + max_length=10, + choices=InterviewLocation.LocationType.choices, + verbose_name=_("Interview Type"), + default=InterviewLocation.LocationType.REMOTE + ) + + job = models.ForeignKey( + JobPosting, + on_delete=models.CASCADE, + related_name="interview_schedules", + db_index=True, + ) + applications = models.ManyToManyField( + Application, related_name="interview_schedules", blank=True + ) + + start_date = models.DateField(db_index=True, verbose_name=_("Start Date")) + end_date = models.DateField(db_index=True, verbose_name=_("End Date")) + + working_days = models.JSONField( + verbose_name=_("Working Days") + ) + + start_time = models.TimeField(verbose_name=_("Start Time")) + end_time = models.TimeField(verbose_name=_("End Time")) + + break_start_time = models.TimeField( + verbose_name=_("Break Start Time"), null=True, blank=True + ) + break_end_time = models.TimeField( + verbose_name=_("Break End Time"), null=True, blank=True + ) + + interview_duration = models.PositiveIntegerField( + verbose_name=_("Interview Duration (minutes)") + ) + buffer_time = models.PositiveIntegerField( + verbose_name=_("Buffer Time (minutes)"), default=0 + ) + created_by = models.ForeignKey( + User, on_delete=models.CASCADE, db_index=True + ) + + def __str__(self): + return f"Schedule for {self.job.title}" + + +class ScheduledInterview(Base): + """Stores individual scheduled interviews (whether bulk or individually created).""" + + class InterviewStatus(models.TextChoices): + SCHEDULED = "scheduled", _("Scheduled") + CONFIRMED = "confirmed", _("Confirmed") + CANCELLED = "cancelled", _("Cancelled") + COMPLETED = "completed", _("Completed") + + application = models.ForeignKey( + Application, + on_delete=models.CASCADE, + related_name="scheduled_interviews", + db_index=True, + ) + job = models.ForeignKey( + JobPosting, + on_delete=models.CASCADE, + related_name="scheduled_interviews", + db_index=True, + ) + + # Links to the specific, individual location/meeting details for THIS interview + interview_location = models.OneToOneField( + InterviewLocation, + on_delete=models.SET_NULL, + related_name="scheduled_interview", + null=True, + blank=True, + db_index=True, + verbose_name=_("Meeting/Location Details") + ) + + # Link back to the bulk schedule template (optional if individually created) + schedule = models.ForeignKey( + InterviewSchedule, + on_delete=models.SET_NULL, + related_name="interviews", + null=True, + blank=True, + db_index=True, + ) + + participants = models.ManyToManyField('Participants', blank=True) + system_users = models.ManyToManyField(User, related_name="attended_interviews", blank=True) + + interview_date = models.DateField(db_index=True, verbose_name=_("Interview Date")) + interview_time = models.TimeField(verbose_name=_("Interview Time")) + + status = models.CharField( + db_index=True, + max_length=20, + choices=InterviewStatus.choices, + default=InterviewStatus.SCHEDULED, + ) + + def __str__(self): + return f"Interview with {self.application.person.full_name} for {self.job.title}" + + class Meta: + indexes = [ + models.Index(fields=["job", "status"]), + models.Index(fields=["interview_date", "interview_time"]), + models.Index(fields=["application", "job"]), + ] + + +# --- 3. Interview Notes Model (Fixed) --- + +class InterviewNote(Base): + """Model for storing notes, feedback, or comments related to a specific ScheduledInterview.""" + + class NoteType(models.TextChoices): + FEEDBACK = 'Feedback', _('Candidate Feedback') + LOGISTICS = 'Logistics', _('Logistical Note') + GENERAL = 'General', _('General Comment') + + 1 + interview = models.ForeignKey( + ScheduledInterview, + on_delete=models.CASCADE, + related_name="notes", + verbose_name=_("Scheduled Interview"), + db_index=True + ) + + author = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="interview_notes", + verbose_name=_("Author"), + db_index=True + ) + + note_type = models.CharField( + max_length=50, + choices=NoteType.choices, + default=NoteType.FEEDBACK, + verbose_name=_("Note Type") + ) + + content = CKEditor5Field(verbose_name=_("Content/Feedback"), config_name="extends") + + class Meta: + verbose_name = _("Interview Note") + verbose_name_plural = _("Interview Notes") + ordering = ["created_at"] + + def __str__(self): + return f"{self.get_note_type_display()} by {self.author.get_username()} on {self.interview.id}" \ No newline at end of file diff --git a/recruitment/score_utils.py b/recruitment/score_utils.py new file mode 100644 index 0000000..a9b457a --- /dev/null +++ b/recruitment/score_utils.py @@ -0,0 +1,36 @@ +from django.db.models import Value, IntegerField, CharField, F +from django.db.models.functions import Coalesce, Cast, Replace, NullIf, KeyTextTransform + +# Define the path to the match score +# Based on your tracebacks, the path is: ai_analysis_data -> analysis_data -> match_score +SCORE_PATH_RAW = F('ai_analysis_data__analysis_data__match_score') + +# Define a robust annotation expression for safely extracting and casting the match score. +# This sequence handles three common failure points: +# 1. Missing keys (handled by Coalesce). +# 2. Textual scores (e.g., "N/A" or "") (handled by NullIf). +# 3. Quoted numeric scores (e.g., "50") from JSONB extraction (handled by Replace). +def get_safe_score_annotation(): + """ + Returns a Django Expression object that safely extracts a score from the + JSONField, cleans it, and casts it to an IntegerField. + """ + + # 1. Extract the JSON value as text and force a CharField for cleaning functions + # Using the double-underscore path is equivalent to the KeyTextTransform + # for the final nested key in a PostgreSQL JSONField. + extracted_text = Cast(SCORE_PATH_RAW, output_field=CharField()) + + # 2. Clean up any residual double-quotes that sometimes remain if the data + # was stored as a quoted string (e.g., "50") + cleaned_text = Replace(extracted_text, Value('"'), Value('')) + + # 3. Use NullIf to convert the cleaned text to NULL if it is an empty string + # (or if it was a non-numeric string like "N/A" after quote removal) + null_if_empty = NullIf(cleaned_text, Value('')) + + # 4. Cast the result (which is now either a clean numeric string or NULL) to an IntegerField. + final_cast = Cast(null_if_empty, output_field=IntegerField()) + + # 5. Use Coalesce to ensure NULL scores (from errors or missing data) default to 0. + return Coalesce(final_cast, Value(0)) \ No newline at end of file diff --git a/recruitment/tasks.py b/recruitment/tasks.py index a0c05cf..6ff28bc 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 InterviewSchedule,ScheduledInterview,ZoomMeeting +from . models import InterviewSchedule,ScheduledInterview,ZoomMeetingDetails # Add python-docx import for Word document processing try: @@ -26,7 +26,7 @@ except ImportError: logger = logging.getLogger(__name__) OPENROUTER_API_KEY ='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' -OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free' +OPENROUTER_MODEL = 'x-ai/grok-code-fast-1' # OPENROUTER_MODEL = 'openai/gpt-oss-20b:free' # OPENROUTER_MODEL = 'openai/gpt-oss-20b' @@ -440,7 +440,7 @@ def handle_reume_parsing_and_scoring(pk): print(f"Successfully scored and saved analysis for candidate {instance.id}") - +from django.utils import timezone def create_interview_and_meeting( candidate_id, job_id, @@ -457,7 +457,7 @@ def create_interview_and_meeting( job = JobPosting.objects.get(pk=job_id) schedule = InterviewSchedule.objects.get(pk=schedule_id) - interview_datetime = datetime.combine(slot_date, slot_time) + interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time)) meeting_topic = f"Interview for {job.title} - {candidate.name}" # 1. External API Call (Slow) @@ -466,24 +466,26 @@ def create_interview_and_meeting( if result["status"] == "success": # 2. Database Writes (Slow) - zoom_meeting = ZoomMeeting.objects.create( + zoom_meeting = ZoomMeetingDetails.objects.create( topic=meeting_topic, start_time=interview_datetime, duration=duration, meeting_id=result["meeting_details"]["meeting_id"], - join_url=result["meeting_details"]["join_url"], + 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"] + password=result["meeting_details"]["password"], + location_type="Remote" ) ScheduledInterview.objects.create( - application=Application, + application=candidate, job=job, - zoom_meeting=zoom_meeting, + 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 @@ -517,7 +519,7 @@ def handle_zoom_webhook_event(payload): try: # Use filter().first() to avoid exceptions if the meeting doesn't exist yet, # and to simplify the logic flow. - meeting_instance = ZoomMeeting.objects.filter(meeting_id=meeting_id_zoom).first() + meeting_instance = ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first() print(meeting_instance) # --- 1. Creation and Update Events --- if event_type == 'meeting.updated': @@ -698,7 +700,7 @@ def sync_candidate_to_source_task(candidate_id, source_id): dict: Sync result for this specific candidate-source pair """ from .candidate_sync_service import CandidateSyncService - from .models import Candidate, Source, IntegrationLog + from .models import Application, Source, IntegrationLog logger.info(f"Starting sync task for candidate {candidate_id} to source {source_id}") diff --git a/recruitment/urls.py b/recruitment/urls.py index 394687b..2d3e4c0 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -575,7 +575,7 @@ urlpatterns = [ ), # Email composition URLs path( - "jobs//candidates//compose-email/", + "jobs//candidates/compose-email/", views.compose_candidate_email, name="compose_candidate_email", ), @@ -594,16 +594,41 @@ urlpatterns = [ path("documents//delete/", views.document_delete, name="document_delete"), path("documents//download/", views.document_download, name="document_download"), 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'), # # --- SCHEDULED INTERVIEW URLS (New Centralized Management) --- - # path('interview/list/', views.InterviewListView.as_view(), name='interview_list'), + # path('interview/list/', views.interview_list, name='interview_list'), # path('interviews//', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'), # path('interviews//update/', views.ScheduledInterviewUpdateView.as_view(), name='update_scheduled_interview'), # path('interviews//delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'), + path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"), + + # 1. Onsite Reschedule URL + path( + '/candidate//onsite/reschedule//', + views.reschedule_onsite_meeting, + name='reschedule_onsite_meeting' + ), + + # 2. Onsite Delete URL + + path( + 'job//candidates//delete-onsite-meeting//', + views.delete_onsite_meeting_for_candidate, + name='delete_onsite_meeting_for_candidate' + ), - + path( + 'job//candidate//schedule/onsite/', + views.schedule_onsite_meeting_for_candidate, + name='schedule_onsite_meeting_for_candidate' # This is the name used in the button + ), + + + # Detail View (assuming slug is on ScheduledInterview) + # path("interviews/meetings//", views.MeetingDetailView.as_view(), name="meeting_details"), ] diff --git a/recruitment/utils.py b/recruitment/utils.py index afed297..3ef5dd4 100644 --- a/recruitment/utils.py +++ b/recruitment/utils.py @@ -594,7 +594,7 @@ def update_meeting(instance, updated_data): instance.topic = zoom_details.get("topic", instance.topic) instance.duration = zoom_details.get("duration", instance.duration) - instance.join_url = zoom_details.get("join_url", instance.join_url) + instance.details_url = zoom_details.get("join_url", instance.details_url) instance.password = zoom_details.get("password", instance.password) # Corrected status assignment: instance.status, not instance.password instance.status = zoom_details.get("status") diff --git a/recruitment/views.py b/recruitment/views.py index c4e1841..d499bf9 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -1,7 +1,7 @@ import json import io import zipfile - +import datetime from django.core.paginator import Paginator from django.utils.translation import gettext as _ from django.contrib.auth import get_user_model, authenticate, login, logout @@ -39,7 +39,9 @@ from django.db.models import ( Q, ExpressionWrapper, fields, + Value ) +from django.db.models.functions import Coalesce, Cast, Replace, NullIf from django.db.models.functions import Cast, Coalesce, TruncDate from django.db.models.fields.json import KeyTextTransform from django.db.models.expressions import ExpressionWrapper @@ -50,7 +52,7 @@ from .forms import ( CandidateExamDateForm, JobPostingForm, JobPostingImageForm, - MeetingCommentForm, + InterviewNoteForm, InterviewScheduleForm, FormTemplateForm, SourceForm, @@ -61,7 +63,12 @@ from .forms import ( AgencyLoginForm, PortalLoginForm, MessageForm, - PersonForm + PersonForm, + OnsiteMeetingForm, + + OnsiteReshuduleForm, + OnsiteScheduleForm, + InterviewEmailForm ) from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from rest_framework import viewsets @@ -91,14 +98,14 @@ from .models import ( FormSubmission, InterviewSchedule, BreakTime, - ZoomMeeting, + ZoomMeetingDetails, Application, Person, JobPosting, ScheduledInterview, JobPostingImage, Profile, - MeetingComment, + InterviewNote, HiringAgency, AgencyJobAssignment, AgencyAccessLink, @@ -106,6 +113,8 @@ from .models import ( Source, Message, Document, + OnsiteLocationDetails, + InterviewLocation ) import logging from datastar_py.django import ( @@ -153,6 +162,7 @@ class PersonCreateView(CreateView): return super().form_valid(form) + class PersonDetailView(DetailView): model = Person template_name = "people/person_detail.html" @@ -182,7 +192,7 @@ class CandidateViewSet(viewsets.ModelViewSet): class ZoomMeetingCreateView(StaffRequiredMixin, CreateView): - model = ZoomMeeting + model = ZoomMeetingDetails template_name = "meetings/create_meeting.html" form_class = ZoomMeetingForm success_url = "/" @@ -222,7 +232,7 @@ class ZoomMeetingCreateView(StaffRequiredMixin, CreateView): class ZoomMeetingListView(StaffRequiredMixin, ListView): - model = ZoomMeeting + model = ZoomMeetingDetails template_name = "meetings/list_meetings.html" context_object_name = "meetings" paginate_by = 10 @@ -272,6 +282,12 @@ class ZoomMeetingListView(StaffRequiredMixin, ListView): context["candidate_name_filter"] = self.request.GET.get("candidate_name", "") return context + +def interview_list(request): + interviews=ScheduledInterview.objects.all() + print(interviews) + return render(request,'interviews/interview_list.html',{'interviews':interviews}) + # @login_required # def InterviewListView(request): # # interview_type=request.GET.get('interview_type','Remote') @@ -342,7 +358,7 @@ class ZoomMeetingListView(StaffRequiredMixin, ListView): class ZoomMeetingDetailsView(StaffRequiredMixin, DetailView): - model = ZoomMeeting + model = ZoomMeetingDetails template_name = "meetings/meeting_details.html" context_object_name = "meeting" @@ -373,27 +389,29 @@ class ZoomMeetingDetailsView(StaffRequiredMixin, DetailView): context['total_participants']=total_participants return context + + class ZoomMeetingUpdateView(StaffRequiredMixin, UpdateView): - model = ZoomMeeting + model = ZoomMeetingDetails form_class = ZoomMeetingForm context_object_name = "meeting" template_name = "meetings/update_meeting.html" success_url = "/" - # def get_form_kwargs(self): - # kwargs = super().get_form_kwargs() - # # Ensure the form is initialized with the instance's current values - # if self.object: - # kwargs['initial'] = getattr(kwargs, 'initial', {}) - # initial_start_time = "" - # if self.object.start_time: - # try: - # initial_start_time = self.object.start_time.strftime('%m-%d-%Y,T%H:%M') - # except AttributeError: - # print(f"Warning: start_time {self.object.start_time} is not a datetime object.") - # initial_start_time = "" - # kwargs['initial']['start_time'] = initial_start_time - # return kwargs + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + # Ensure the form is initialized with the instance's current values + if self.object: + kwargs['initial'] = getattr(kwargs, 'initial', {}) + initial_start_time = "" + if self.object.start_time: + try: + initial_start_time = self.object.start_time.strftime('%m-%d-%Y,T%H:%M') + except AttributeError: + print(f"Warning: start_time {self.object.start_time} is not a datetime object.") + initial_start_time = "" + kwargs['initial']['start_time'] = initial_start_time + return kwargs def form_valid(self, form): instance = form.save(commit=False) @@ -416,7 +434,7 @@ class ZoomMeetingUpdateView(StaffRequiredMixin, UpdateView): def ZoomMeetingDeleteView(request, slug): - meeting = get_object_or_404(ZoomMeeting, slug=slug) + meeting = get_object_or_404(ZoomMeetingDetails, slug=slug) if "HX-Request" in request.headers: return render( request, @@ -583,20 +601,12 @@ def job_detail(request, slug): # --- 2. Quality Metrics (JSON Aggregation) --- - # Filter for candidates who have been scored and annotate with a sortable score - # candidates_with_score = applicants.filter(is_resume_parsed=True).annotate( - # # Extract the score as TEXT - # score_as_text=KeyTextTransform( - # 'match_score', - # KeyTextTransform('resume_data', F('ai_analysis_data')) - # ) - # ).annotate( - # # Cast the extracted text score to a FloatField for numerical operations - # sortable_score=Cast('score_as_text', output_field=FloatField()) - # ) + + candidates_with_score = applicants.filter(is_resume_parsed=True).annotate( annotated_match_score=Coalesce(Cast(SCORE_PATH, output_field=IntegerField()), 0) ) + total_candidates = applicants.count() avg_match_score_result = candidates_with_score.aggregate( avg_score=Avg("annotated_match_score") @@ -696,7 +706,7 @@ ALLOWED_EXTENSIONS = ('.pdf', '.docx') def job_cvs_download(request,slug): job = get_object_or_404(JobPosting,slug=slug) - entries=Candidate.objects.filter(job=job) + entries=Application.objects.filter(job=job) # 2. Create an in-memory byte stream (BytesIO) @@ -1486,7 +1496,6 @@ def _handle_preview_submission(request, slug, job): if form.is_valid(): # Get the form data applications = form.cleaned_data["applications"] - 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"] @@ -1496,7 +1505,7 @@ def _handle_preview_submission(request, slug, job): 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: @@ -1539,15 +1548,14 @@ def _handle_preview_submission(request, slug, job): # Create a preview schedule preview_schedule = [] - for i, candidate in enumerate(applications): + for i, application in enumerate(applications): slot = available_slots[i] preview_schedule.append( - {"applications": applications, "date": slot["date"], "time": slot["time"]} + {"application": application, "date": slot["date"], "time": slot["time"]} ) - + # 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, @@ -1555,9 +1563,11 @@ def _handle_preview_submission(request, slug, job): "end_time": end_time.isoformat(), "interview_duration": interview_duration, "buffer_time": buffer_time, - "break_start_time": break_start_time.isoformat(), - "break_end_time": break_end_time.isoformat(), + "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 @@ -1568,7 +1578,6 @@ 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, @@ -1578,6 +1587,8 @@ def _handle_preview_submission(request, slug, job): "break_end_time": break_end_time, "interview_duration": interview_duration, "buffer_time": buffer_time, + "schedule_interview_type":schedule_interview_type, + "form":OnsiteMeetingForm() }, ) else: @@ -1589,6 +1600,175 @@ def _handle_preview_submission(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) +# # NOTE: You MUST convert the time strings back to Python time objects here. +# try: +# schedule = InterviewSchedule.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"], +# # Use the simple break times saved in the session +# # If the value is None (because required=False in form), handle it gracefully +# break_start_time=schedule_data.get("break_start_time"), +# break_end_time=schedule_data.get("break_end_time"), +# schedule_interview_type=schedule_data.get("schedule_interview_type") +# ) +# except Exception as e: +# # Handle database creation error +# messages.error(request, f"Error creating schedule: {e}") +# if SESSION_ID_KEY in request.session: +# del request.session[SESSION_ID_KEY] +# return redirect("schedule_interviews", slug=slug) + +# # 3. Setup candidates and get slots +# candidates = Application.objects.filter(id__in=schedule_data["candidate_ids"]) +# print(candidates) +# schedule.applications.set(candidates) +# available_slots = get_available_time_slots( +# schedule +# ) # This should still be synchronous and fast + +# # 4. Queue scheduled interviews asynchronously (FAST RESPONSE) +# if schedule_data.get("schedule_interview_type")=='Remote': +# print('....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 + +# 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] + +# return redirect("job_detail", slug=slug) + +# elif schedule_data.get("schedule_interview_type") == 'Onsite': +# # The form submission for Onsite details should happen here. +# # This block assumes the OnsiteMeetingForm is being submitted NOW. + +# # NOTE: start_time and duration must be passed through the form +# # for OnsiteLocationDetails creation. + +# if request.method == 'POST': + +# if available_slots: +# first_slot = available_slots[0] +# # Combine the first slot's date and the schedule's start time +# location_start_dt = datetime.combine(first_slot['date'], schedule.start_time) +# else: +# # Fallback if no slots (should not happen if candidates > 0) +# location_start_dt = datetime.now() + +# # Create a form using the submitted POST data +# form = OnsiteMeetingForm(request.POST) + +# if form.is_valid(): +# # 1. Extract location-specific data from the form +# topic = form.cleaned_data['topic'] +# physical_address = form.cleaned_data['physical_address'] +# room_number = form.cleaned_data['room_number'] + +# # 2. Create the OnsiteLocationDetails instance (The Location Template) +# # The duration comes from the parent InterviewSchedule +# try: +# onsite_location = OnsiteLocationDetails.create( +# start_time=location_start_dt, # Uses datetime derived from first slot date +# duration=schedule.interview_duration, # Uses duration from parent schedule +# physical_address=physical_address, +# room_number=room_number, +# ) +# onsite_location.save() + +# # 3. Create the ScheduledInterview entries, linking the location +# for i, candidate in enumerate(candidates): +# if i < len(available_slots): +# slot = available_slots[i] + +# # Combine date and time from the slot for the ScheduledInterview creation + +# ScheduledInterview.objects.create( +# application=candidate, +# job=job, +# schedule=schedule, +# interview_date=slot['date'], +# interview_time=slot['time'], + +# # CRITICAL: Link the location object +# interview_location=onsite_location, +# # Assuming 'topic' is stored on the ScheduledInterview model +# # topic=topic +# ) + +# messages.success( +# request, +# f"Onsite schedule Interview Create succesfully" +# ) + +# # 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] + +# # Redirect to a confirmation or job details page +# return redirect('job_detail', slug=job.slug) + +# except Exception as e: +# # Handle database creation error +# messages.error(request, f"Error creating onsite location/interviews: {e}") +# # Keep the form data for re-submission if possible, or redirect +# return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule}) + +# else: +# # Form is invalid, re-render with errors +# return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule,'job':job}) + +# else: +# # For a GET request (First time after InterviewSchedule is created) +# # Render the form to collect location details +# form = OnsiteMeetingForm() +# print(f"job:{job}") +# return render(request,'interviews/onsite_location_form.html',{'form': form, 'schedule': schedule,'job':job}) + def _handle_confirm_schedule(request, slug, job): """ Handles the final POST request (Confirm Schedule). @@ -1606,12 +1786,14 @@ def _handle_confirm_schedule(request, slug, job): return redirect("schedule_interviews", slug=slug) # 2. Create the Interview Schedule (Parent Record) - # NOTE: You MUST convert the time strings back to Python time objects here. try: - schedule = InterviewSchedule.objects.create( + # 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 = 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"], @@ -1619,41 +1801,34 @@ def _handle_confirm_schedule(request, slug, job): end_time=time.fromisoformat(schedule_data["end_time"]), interview_duration=schedule_data["interview_duration"], buffer_time=schedule_data["buffer_time"], - # Use the simple break times saved in the session - # If the value is None (because required=False in form), handle it gracefully - break_start_time=schedule_data.get("break_start_time"), - break_end_time=schedule_data.get("break_end_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: - # Handle database creation error + # 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_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.candidates.set(candidates) - available_slots = get_available_time_slots( - schedule - ) # This should still be synchronous and fast + schedule.applications.set(candidates) + available_slots = get_available_time_slots(schedule) - # 4. Queue scheduled interviews asynchronously (FAST RESPONSE) - if schedule.interview_type=='Remote': + # 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] - # 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, + candidate.pk, job.pk, schedule.pk, slot["date"], slot["time"], schedule.interview_duration, ) queued_count += 1 @@ -1662,36 +1837,84 @@ def _handle_confirm_schedule(request, slug, job): 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] - - 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) + + elif schedule_data.get("schedule_interview_type") == 'Onsite': + print("inside...") + + if request.method == 'POST': + form = OnsiteMeetingForm(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'] + + 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" + + ) + + # 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 = OnsiteMeetingForm() + + return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job}) + + def schedule_interviews_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) if request.method == "POST": @@ -1909,38 +2132,12 @@ def candidate_update_status(request, slug): def candidate_interview_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) - if request.method == "POST": - form = ParticipantsSelectForm(request.POST, instance=job) - print(form.errors) - - if form.is_valid(): - # Save the main instance (JobPosting) - job_instance = form.save(commit=False) - job_instance.save() - - # MANUALLY set the M2M relationships based on submitted data - job_instance.participants.set(form.cleaned_data["participants"]) - job_instance.users.set(form.cleaned_data["users"]) - - messages.success(request, "Interview participants updated successfully.") - return redirect("candidate_interview_view", slug=job.slug) - - else: - initial_data = { - "participants": job.participants.all(), - "users": job.users.all(), - } - form = ParticipantsSelectForm(instance=job, initial=initial_data) - - else: - form = ParticipantsSelectForm(instance=job) context = { "job": job, "candidates": job.interview_candidates, "current_stage": "Interview", - "form": form, - "participants_count": job.participants.count() + job.users.count(), + } return render(request, "recruitment/candidate_interview_view.html", context) @@ -1949,7 +2146,7 @@ def candidate_interview_view(request, slug): def reschedule_meeting_for_candidate(request, slug, candidate_id, meeting_id): job = get_object_or_404(JobPosting, slug=slug) candidate = get_object_or_404(Application, pk=candidate_id) - meeting = get_object_or_404(ZoomMeeting, pk=meeting_id) + meeting = get_object_or_404(ZoomMeetingDetails, pk=meeting_id) form = ZoomMeetingForm(instance=meeting) if request.method == "POST": @@ -1988,7 +2185,7 @@ def reschedule_meeting_for_candidate(request, slug, candidate_id, meeting_id): def delete_meeting_for_candidate(request, slug, candidate_pk, meeting_id): job = get_object_or_404(JobPosting, slug=slug) candidate = get_object_or_404(Application, pk=candidate_pk) - meeting = get_object_or_404(ZoomMeeting, pk=meeting_id) + meeting = get_object_or_404(ZoomMeetingDetails, pk=meeting_id) if request.method == "POST": result = delete_zoom_meeting(meeting.meeting_id) if ( @@ -2016,6 +2213,53 @@ def delete_meeting_for_candidate(request, slug, candidate_pk, meeting_id): } return render(request, "meetings/delete_meeting_form.html", context) +@staff_user_required +def delete_zoom_meeting_for_candidate(request, slug, candidate_pk, meeting_id): + """ + Deletes a specific Zoom (Remote) meeting instance. + The ZoomMeetingDetails object inherits from InterviewLocation, + which is linked to ScheduledInterview. Deleting the subclass + should trigger CASCADE/SET_NULL correctly on the FK chain. + """ + job = get_object_or_404(JobPosting, slug=slug) + candidate = get_object_or_404(Application, pk=candidate_pk) + + # Target the specific Zoom meeting details instance + meeting = get_object_or_404(ZoomMeetingDetails, pk=meeting_id) + + if request.method == "POST": + # 1. Attempt to delete the meeting from the external Zoom API + result = delete_zoom_meeting(meeting.meeting_id) + + # 2. Check for success OR if the meeting was already deleted externally + if ( + result["status"] == "success" + or "Meeting does not exist" in result["details"]["message"] + ): + # 3. Delete the local Django object. This will delete the base + # InterviewLocation object and update the ScheduledInterview FK. + meeting.delete() + messages.success(request, f"Remote meeting for {candidate.name} deleted successfully.") + else: + messages.error(request, result["message"]) + + return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug})) + + context = { + "job": job, + "candidate": candidate, + "meeting": meeting, + "location_type": "Remote", + "delete_url": reverse( + "delete_remote_meeting_for_candidate", # Use the specific new URL name + kwargs={ + "slug": job.slug, + "candidate_pk": candidate_pk, + "meeting_id": meeting_id, + }, + ), + } + return render(request, "meetings/delete_meeting_form.html", context) @staff_user_required def interview_calendar_view(request, slug): @@ -2133,7 +2377,7 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): if result["status"] == "success": zoom_meeting_details = result["meeting_details"] - zoom_meeting = ZoomMeeting.objects.create( + zoom_meeting = ZoomMeetingDetails.objects.create( topic=topic, start_time=start_time, # Store in local timezone duration=duration, @@ -2246,7 +2490,7 @@ def api_schedule_candidate_meeting(request, job_slug, candidate_pk): if result["status"] == "success": zoom_meeting_details = result["meeting_details"] - zoom_meeting = ZoomMeeting.objects.create( + zoom_meeting = ZoomMeetingDetails.objects.create( topic=topic, start_time=start_time, duration=duration, @@ -2679,23 +2923,25 @@ def schedule_meeting_for_candidate(request, slug, candidate_pk): if zoom_creation_result["status"] == "success": zoom_details = zoom_creation_result["meeting_details"] - zoom_meeting_instance = ZoomMeeting.objects.create( + zoom_meeting_instance = ZoomMeetingDetails.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"], + details_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"], + location_type='Remote', + ) # Create a ScheduledInterview record ScheduledInterview.objects.create( application=candidate, job=job, - zoom_meeting=zoom_meeting_instance, + interview_location=zoom_meeting_instance, interview_date=start_time_val.date(), interview_time=start_time_val.time(), status="scheduled", @@ -2959,7 +3205,7 @@ def zoom_webhook_view(request): @staff_user_required def add_meeting_comment(request, slug): """Add a comment to a meeting""" - meeting = get_object_or_404(ZoomMeeting, slug=slug) + meeting = get_object_or_404(ZoomMeetingDetails, slug=slug) if request.method == "POST": form = MeetingCommentForm(request.POST) @@ -3000,8 +3246,8 @@ def add_meeting_comment(request, slug): @staff_user_required def edit_meeting_comment(request, slug, comment_id): """Edit a meeting comment""" - meeting = get_object_or_404(ZoomMeeting, slug=slug) - comment = get_object_or_404(MeetingComment, id=comment_id, meeting=meeting) + meeting = get_object_or_404(ZoomMeetingDetails, slug=slug) + comment = get_object_or_404(InterviewNote, id=comment_id, meeting=meeting) # Check if user is author if comment.author != request.user and not request.user.is_staff: @@ -3036,8 +3282,8 @@ def edit_meeting_comment(request, slug, comment_id): @staff_user_required def delete_meeting_comment(request, slug, comment_id): """Delete a meeting comment""" - meeting = get_object_or_404(ZoomMeeting, slug=slug) - comment = get_object_or_404(MeetingComment, id=comment_id, meeting=meeting) + meeting = get_object_or_404(ZoomMeetingDetails, slug=slug) + comment = get_object_or_404(InterviewNote, id=comment_id, meeting=meeting) # Check if user is the author if comment.author != request.user and not request.user.is_staff: @@ -3081,7 +3327,7 @@ def delete_meeting_comment(request, slug, comment_id): @staff_user_required def set_meeting_candidate(request, slug): - meeting = get_object_or_404(ZoomMeeting, slug=slug) + meeting = get_object_or_404(ZoomMeetingDetails, slug=slug) if request.method == "POST" and "HX-Request" not in request.headers: form = InterviewForm(request.POST) if form.is_valid(): @@ -4806,14 +5052,15 @@ def api_candidate_detail(request, candidate_id): @staff_user_required -def compose_candidate_email(request, job_slug, candidate_slug): +def compose_candidate_email(request, job_slug): """Compose email to participants about a candidate""" from .email_service import send_bulk_email job = get_object_or_404(JobPosting, slug=job_slug) - candidate = get_object_or_404(Application, slug=candidate_slug, job=job) - if request.method == "POST": - form = CandidateEmailForm(job, candidate, request.POST) + + # # candidate = get_object_or_404(Application, slug=candidate_slug, job=job) + # if request.method == "POST": + # form = CandidateEmailForm(job, candidate, request.POST) candidate_ids=request.GET.getlist('candidate_ids') candidates=Application.objects.filter(id__in=candidate_ids) @@ -4827,48 +5074,7 @@ def compose_candidate_email(request, job_slug, candidate_slug): print("form is valid ...") # Get email addresses email_addresses = form.get_email_addresses() - if not email_addresses: - messages.error( - request, "No valid email addresses found for selected recipients." - ) - return render( - request, - "includes/email_compose_form.html", - {"form": form, "job": job, "candidate": candidate}, - ) - - # Check if this is an interview invitation - subject = form.cleaned_data.get("subject", "").lower() - is_interview_invitation = "interview" in subject or "meeting" in subject - - if is_interview_invitation: - # Use HTML template for interview invitations - meeting_details = None - if form.cleaned_data.get("include_meeting_details"): - # Try to get meeting details from candidate - meeting_details = { - "topic": f"Interview for {job.title}", - "date_time": getattr( - candidate, "interview_date", "To be scheduled" - ), - "duration": "60 minutes", - "join_url": getattr(candidate, "meeting_url", ""), - } - - from .email_service import send_interview_invitation_email - - email_result = send_interview_invitation_email( - candidate=candidate, - job=job, - meeting_details=meeting_details, - recipient_list=email_addresses, - ) - else: - # Get formatted message for regular emails - message = form.get_formatted_message() - subject = form.cleaned_data.get("subject") - print(email_addresses) - + if not email_addresses: messages.error(request, 'No email selected') @@ -4903,15 +5109,7 @@ def compose_candidate_email(request, job_slug, candidate_slug): f"Email sent successfully to {len(email_addresses)} recipient(s).", ) - # For HTMX requests, return success response - if "HX-Request" in request.headers: - return JsonResponse( - { - "success": True, - "message": f"Email sent successfully to {len(email_addresses)} recipient(s).", - } - ) - + return redirect("candidate_interview_view", slug=job.slug) else: messages.error( @@ -4933,31 +5131,11 @@ def compose_candidate_email(request, job_slug, candidate_slug): return render( request, "includes/email_compose_form.html", - {"form": form, "job": job, "candidate": candidate}, + {"form": form, "job": job, "candidate": candidates}, ) - return render(request, 'includes/email_compose_form.html', { - 'form': form, - 'job': job, - 'candidate': candidates - }) + - # except Exception as e: - # logger.error(f"Error sending candidate email: {e}") - # messages.error(request, f'An error occurred while sending the email: {str(e)}') - - # # For HTMX requests, return error response - # if 'HX-Request' in request.headers: - # return JsonResponse({ - # 'success': False, - # 'error': f'An error occurred while sending the email: {str(e)}' - # }) - - # return render(request, 'includes/email_compose_form.html', { - # 'form': form, - # 'job': job, - # 'candidate': candidate - # }) else: # Form validation errors print('form is not valid') @@ -4976,17 +5154,18 @@ def compose_candidate_email(request, job_slug, candidate_slug): return render( request, "includes/email_compose_form.html", - {"form": form, "job": job, "candidates": candidate},s + {"form": form, "job": job, "candidates": candidates}, ) else: # GET request - show the form - form = CandidateEmailForm(job, candidate) + form = CandidateEmailForm(job, candidates) return render( request, "includes/email_compose_form.html", - {"form": form, "job": job, "candidate": candidate}, + # {"form": form, "job": job, "candidates": candidates}, + {"form": form,"job":job}, ) @@ -5287,9 +5466,288 @@ def send_interview_email(request, slug): # form=InterviewScheduleLocationForm(instance=schedule) # return render(request,'interviews/schedule_interview_location_form.html',{'form':form,'schedule':schedule}) +class MeetingListView(ListView): + """ + A unified view to list both Remote and Onsite Scheduled Interviews. + """ + model = ScheduledInterview + template_name = "meetings/list_meetings.html" + context_object_name = "meetings" + paginate_by = 100 + + def get_queryset(self): + # Start with a base queryset, ensuring an InterviewLocation link exists. + queryset = super().get_queryset().filter(interview_location__isnull=False).select_related( + 'interview_location', + 'job', + 'application__person', + 'application', + ).prefetch_related( + 'interview_location__zoommeetingdetails', + 'interview_location__onsitelocationdetails', + ) + # Note: Printing the queryset here can consume memory for large sets. + + # Get filters from GET request + search_query = self.request.GET.get("q") + status_filter = self.request.GET.get("status") + candidate_name_filter = self.request.GET.get("candidate_name") + type_filter = self.request.GET.get("type") + print(type_filter) + + # 2. Type Filter: Filter based on the base InterviewLocation's type + if type_filter: + # Use .title() to handle case variations from URL (e.g., 'remote' -> 'Remote') + normalized_type = type_filter.title() + print(normalized_type) + # Assuming InterviewLocation.LocationType is accessible/defined + if normalized_type in ['Remote', 'Onsite']: + queryset = queryset.filter(interview_location__location_type=normalized_type) + print(queryset) + + # 3. Search by Topic (stored on InterviewLocation) + if search_query: + queryset = queryset.filter(interview_location__topic__icontains=search_query) + + # 4. Status Filter + if status_filter: + queryset = queryset.filter(status=status_filter) + + # 5. Candidate Name Filter + if candidate_name_filter: + queryset = queryset.filter( + Q(application__person__first_name__icontains=candidate_name_filter) | + Q(application__person__last_name__icontains=candidate_name_filter) + ) + + return queryset.order_by("-interview_date", "-interview_time") + + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Pass filters back to the template for retention + context["search_query"] = self.request.GET.get("q", "") + context["status_filter"] = self.request.GET.get("status", "") + context["candidate_name_filter"] = self.request.GET.get("candidate_name", "") + context["type_filter"] = self.request.GET.get("type", "") + + + # CORRECTED: Pass the status choices from the model class for the filter dropdown + context["status_choices"] = self.model.InterviewStatus.choices + + meetings_data = [] + + for interview in context.get(self.context_object_name, []): + location = interview.interview_location + details = None + + if not location: + continue + + # Determine and fetch the CONCRETE details object (prefetched) + if location.location_type == location.LocationType.REMOTE: + details = getattr(location, 'zoommeetingdetails', None) + elif location.location_type == location.LocationType.ONSITE: + details = getattr(location, 'onsitelocationdetails', None) + + # Combine date and time for template display/sorting + start_datetime = None + if interview.interview_date and interview.interview_time: + start_datetime = datetime.combine(interview.interview_date, interview.interview_time) + + # SUCCESS: Build the data dictionary + meetings_data.append({ + 'interview': interview, + 'location': location, + 'details': details, + 'type': location.location_type, + 'topic': location.topic, + 'slug': interview.slug, + 'start_time': start_datetime, # Combined datetime object + # Duration should ideally be on ScheduledInterview or fetched from details + 'duration': getattr(details, 'duration', 'N/A'), + # Use details.join_url and fallback to None, if Remote + 'join_url': getattr(details, 'join_url', None) if location.location_type == location.LocationType.REMOTE else None, + 'meeting_id': getattr(details, 'meeting_id', None), + # Use the primary status from the ScheduledInterview record + 'status': interview.status, + }) + + context["meetings_data"] = meetings_data + + return context + +def reschedule_onsite_meeting(request, slug, candidate_id, meeting_id): + """Handles the rescheduling of an Onsite Interview (updates OnsiteLocationDetails).""" + job = get_object_or_404(JobPosting, slug=slug) + candidate = get_object_or_404(Application, pk=candidate_id) + + # Fetch the OnsiteLocationDetails instance, ensuring it belongs to this candidate. + # We use the reverse relationship: onsitelocationdetails -> interviewlocation -> scheduledinterview -> application + # The 'interviewlocation_ptr' is the foreign key field name if OnsiteLocationDetails is a proxy/multi-table inheritance model. + onsite_meeting = get_object_or_404( + OnsiteLocationDetails, + pk=meeting_id, + # Correct filter: Use the reverse link through the ScheduledInterview model. + # This assumes your ScheduledInterview model links back to a generic InterviewLocation base. + interviewlocation_ptr__scheduled_interview__application=candidate + ) + + if request.method == 'POST': + form = OnsiteReshuduleForm(request.POST, instance=onsite_meeting) + + if form.is_valid(): + instance = form.save(commit=False) + + if instance.start_time < timezone.now(): + messages.error(request, "Start time must be in the future for rescheduling.") + return render(request, "meetings/reschedule_onsite.html", {"form": form, "job": job, "candidate": candidate, "meeting": onsite_meeting}) + + # Update parent status + try: + # Retrieve the ScheduledInterview instance via the reverse relationship + scheduled_interview = ScheduledInterview.objects.get( + interview_location=instance.interviewlocation_ptr # Use the base model FK + ) + scheduled_interview.status = ScheduledInterview.InterviewStatus.SCHEDULED + scheduled_interview.save() + except ScheduledInterview.DoesNotExist: + messages.warning(request, "Parent schedule record not found. Status not updated.") + + instance.save() + messages.success(request, "Onsite meeting successfully rescheduled! ✅") + + return redirect(reverse("candidate_interview_view", kwargs={'slug': job.slug})) + + else: + form = OnsiteReshuduleForm(instance=onsite_meeting) + + context = { + "form": form, + "job": job, + "candidate": candidate, + "meeting": onsite_meeting + } + return render(request, "meetings/reschedule_onsite_meeting.html", context) -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}) +# recruitment/views.py +@staff_user_required +def delete_onsite_meeting_for_candidate(request, slug, candidate_pk, meeting_id): + """ + Deletes a specific Onsite Location Details instance. + This does not require an external API call. + """ + job = get_object_or_404(JobPosting, slug=slug) + candidate = get_object_or_404(Application, pk=candidate_pk) + + # Target the specific Onsite meeting details instance + meeting = get_object_or_404(OnsiteLocationDetails, pk=meeting_id) + + if request.method == "POST": + # Delete the local Django object. + # This deletes the base InterviewLocation and updates the ScheduledInterview FK. + meeting.delete() + messages.success(request, f"Onsite meeting for {candidate.name} deleted successfully.") + + return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug})) + + context = { + "job": job, + "candidate": candidate, + "meeting": meeting, + "location_type": "Onsite", + "delete_url": reverse( + "delete_onsite_meeting_for_candidate", # Use the specific new URL name + kwargs={ + "slug": job.slug, + "candidate_pk": candidate_pk, + "meeting_id": meeting_id, + }, + ), + } + return render(request, "meetings/delete_meeting_form.html", context) + + + +def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk): + """ + Handles scheduling a NEW Onsite Interview for a candidate using OnsiteScheduleForm. + """ + job = get_object_or_404(JobPosting, slug=slug) + candidate = get_object_or_404(Application, pk=candidate_pk) + + action_url = reverse('schedule_onsite_meeting_for_candidate', + kwargs={'slug': job.slug, 'candidate_pk': candidate.pk}) + + if request.method == 'POST': + # Use the new form + form = OnsiteScheduleForm(request.POST) + if form.is_valid(): + + cleaned_data = form.cleaned_data + + # 1. Create OnsiteLocationDetails + onsite_loc = OnsiteLocationDetails( + topic=cleaned_data['topic'], + physical_address=cleaned_data['physical_address'], + room_number=cleaned_data['room_number'], + start_time=cleaned_data['start_time'], + duration=cleaned_data['duration'], + status=OnsiteLocationDetails.Status.WAITING, + location_type=InterviewLocation.LocationType.ONSITE, + ) + onsite_loc.save() + + # 2. Extract Date and Time + interview_date = cleaned_data['start_time'].date() + interview_time = cleaned_data['start_time'].time() + + # 3. Create ScheduledInterview linked to the new location + # Use cleaned_data['application'] and cleaned_data['job'] from the form + ScheduledInterview.objects.create( + application=cleaned_data['application'], + job=cleaned_data['job'], + interview_location=onsite_loc, + interview_date=interview_date, + interview_time=interview_time, + status=ScheduledInterview.InterviewStatus.SCHEDULED, + ) + + messages.success(request, "Onsite interview scheduled successfully. ✅") + return redirect(reverse("candidate_interview_view", kwargs={'slug': job.slug})) + + else: + # GET Request: Initialize the hidden fields with the correct objects + initial_data = { + 'application': candidate, # Pass the object itself for ModelChoiceField + 'job': job, # Pass the object itself for ModelChoiceField + } + # Use the new form + form = OnsiteScheduleForm(initial=initial_data) + + context = { + "form": form, + "job": job, + "candidate": candidate, + "action_url": action_url, + } + + return render(request, "meetings/schedule_onsite_meeting_form.html", context) +# def meeting_list_view(request): +# queryset = ScheduledInterview.filter(interview_location__isnull=False).select_related( +# 'interview_location', +# 'job', +# 'application__person', +# 'application', +# ).prefetch_related( +# 'interview_location__zoommeetingdetails', +# 'interview_location__onsitelocationdetails', +# ) +# print(queryset) +# return render(request,) +# ========================================================================= +# 2. Simple Meeting Creation Views (Placeholders) +# ========================================================================= diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index ada854a..8598100 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -8,6 +8,7 @@ from django.db.models.fields.json import KeyTextTransform from recruitment.utils import json_to_markdown_table from django.db.models import Count, Avg, F, FloatField from django.db.models.functions import Cast +from django.db.models.functions import Coalesce, Cast, Replace, NullIf from . import models from django.utils.translation import get_language from . import forms @@ -22,7 +23,7 @@ from django.views.generic import ListView, CreateView, UpdateView, DeleteView, D # JobForm removed - using JobPostingForm instead from django.urls import reverse_lazy from django.db.models import FloatField -from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields +from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields, Value,CharField from django.db.models.functions import Cast, Coalesce, TruncDate from django.contrib.auth.decorators import login_required from django.shortcuts import render @@ -454,6 +455,29 @@ def dashboard_view(request): ) ) + # safe_match_score_cast = Cast( + # # 3. If the result after stripping quotes is an empty string (''), convert it to NULL. + # NullIf( + # # 2. Use Replace to remove the literal double quotes (") that might be present. + # Replace( + # # 1. Use the double-underscore path (which uses the ->> operator for the final value) + # # and cast to CharField for text-based cleanup functions. + # Cast(SCORE_PATH, output_field=CharField()), + # Value('"'), Value('') # Replace the double quote character with an empty string + # ), + # Value('') # Value to check for (empty string) + # ), + # output_field=IntegerField() # 4. Cast the clean, non-empty string (or NULL) to an integer. + # ) + + + # candidates_with_score_query= candidate_queryset.filter(is_resume_parsed=True).annotate( + # # The Coalesce handles NULL values (from missing data, non-numeric data, or NullIf) and sets them to 0. + # annotated_match_score=Coalesce(safe_match_score_cast, Value(0)) + # ) + + + # A. Pipeline & Volume Metrics (Scoped) total_active_jobs = job_scope_queryset.filter(status="ACTIVE").count() last_week = timezone.now() - timedelta(days=7) diff --git a/temp_file.py b/temp_file.py index c821c84..9bc6229 100644 --- a/temp_file.py +++ b/temp_file.py @@ -119,10 +119,10 @@ def create_zoom_meeting(topic, start_time, duration, host_email): # Step 11: Analytics Dashboard (recruitment/dashboard.py) import pandas as pd -from .models import Candidate +from .models import Application def get_dashboard_data(): - df = pd.DataFrame(list(Candidate.objects.all().values('status', 'created_at'))) + df = pd.DataFrame(list( Application.objects.all().values('status', 'created_at'))) summary = df['status'].value_counts().to_dict() return summary diff --git a/templates/interviews/interview_list.html b/templates/interviews/interview_list.html index b94634d..36114d4 100644 --- a/templates/interviews/interview_list.html +++ b/templates/interviews/interview_list.html @@ -11,6 +11,7 @@ {% endblock %} {% block content %} +{{interviews}}

diff --git a/templates/interviews/schedule_interview_location_form.html b/templates/interviews/onsite_location_form.html similarity index 88% rename from templates/interviews/schedule_interview_location_form.html rename to templates/interviews/onsite_location_form.html index ca21030..dde8002 100644 --- a/templates/interviews/schedule_interview_location_form.html +++ b/templates/interviews/onsite_location_form.html @@ -9,7 +9,7 @@

Set Interview Location

-
+ {% csrf_token %} {# Renders the single 'location' field using the crispy filter #} diff --git a/templates/interviews/preview_schedule.html b/templates/interviews/preview_schedule.html index 1d520ae..3f7392c 100644 --- a/templates/interviews/preview_schedule.html +++ b/templates/interviews/preview_schedule.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% load static %} +{% load static crispy_forms_tags %} {%load i18n %} {% block customCSS %} @@ -119,7 +119,7 @@ {% if not forloop.last %}, {% endif %} {% endfor %}

-

Interview Type: {{interview_type}}

+

Interview Type: {{schedule_interview_type}}

@@ -163,23 +163,57 @@ {{ item.date|date:"F j, Y" }} {{ item.time|time:"g:i A" }} - {{ item.applications.name }} - {{ item.applications.email }} + {{ item.application.name }} + {{ item.application.email }} {% endfor %} - - - {% csrf_token %} - - {% trans "Back to Edit" %} - - - + {% else %} +
+ {% csrf_token %} + + {% trans "Back to Edit" %} + + +
+ {% endif %} + + + + + @@ -200,13 +234,13 @@ document.addEventListener('DOMContentLoaded', function() { events: [ {% for item in schedule %} { - title: '{{ item.candidate.name }}', + title: '{{ item.application.name }}', start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}', url: '#', // Use the theme color for candidate events color: 'var(--kaauh-teal-dark)', extendedProps: { - email: '{{ item.candidate.email }}', + email: '{{ item.application.email }}', time: '{{ item.time|time:"g:i A" }}' } }, diff --git a/templates/interviews/schedule_interviews.html b/templates/interviews/schedule_interviews.html index 31239ba..ae594a5 100644 --- a/templates/interviews/schedule_interviews.html +++ b/templates/interviews/schedule_interviews.html @@ -142,8 +142,8 @@
- - {{ form.interview_type }} + + {{ form.schedule_interview_type }}
diff --git a/templates/meetings/create_remote_meeting.html b/templates/meetings/create_remote_meeting.html new file mode 100644 index 0000000..390937a --- /dev/null +++ b/templates/meetings/create_remote_meeting.html @@ -0,0 +1,136 @@ +{% extends "base.html" %} +{% load static i18n widget_tweaks %} + +{% block title %}{% trans "Schedule Remote Meeting" %} - {{ block.super }}{% endblock %} + +{% block content %} +
+
+
+ +
+ + + +

+ {% trans "Create Remote Interview" %} +

+
+ +
+
+
{% trans "Remote Meeting Details" %}
+
+
+
+ {% csrf_token %} + + {# --- Non-Field Errors --- #} + {% if form.non_field_errors %} + + {% endif %} + + {# --- Core Meeting Details (BaseMeetingForm fields) --- #} +
+
+ + {% render_field form.application class="form-select" %} +
{{ form.application.help_text }}
+ {% for error in form.application.errors %} +
{{ error }}
+ {% endfor %} +
+
+ + {% render_field form.job class="form-select" %} +
{{ form.job.help_text }}
+ {% for error in form.job.errors %} +
{{ error }}
+ {% endfor %} +
+
+ +
+
+ + {% render_field form.topic class="form-control" placeholder=form.topic.label %} +
{{ form.topic.help_text }}
+ {% for error in form.topic.errors %} +
{{ error }}
+ {% endfor %} +
+
+ + {# Note: input type='datetime-local' is set in the form definition (forms.py) #} + {% render_field form.start_time class="form-control" %} +
{{ form.start_time.help_text }}
+ {% for error in form.start_time.errors %} +
{{ error }}
+ {% endfor %} +
+
+ + {% render_field form.duration class="form-control" placeholder="30" %} +
{{ form.duration.help_text }}
+ {% for error in form.duration.errors %} +
{{ error }}
+ {% endfor %} +
+
+ +
+ + {# --- Remote Specific Details (SimpleRemoteMeetingForm fields) --- #} +
{% trans "Remote Configuration" %}
+ +
+
+ + {% render_field form.host_email class="form-control" %} +
{{ form.host_email.help_text }}
+ {% for error in form.host_email.errors %} +
{{ error }}
+ {% endfor %} +
+
+ + {% render_field form.password class="form-control" %} +
{{ form.password.help_text }}
+ {% for error in form.password.errors %} +
{{ error }}
+ {% endfor %} +
+
+ +
+
+ {% render_field form.participant_video class="form-check-input" %} + +
+
{{ form.participant_video.help_text }}
+ {% for error in form.participant_video.errors %} +
{{ error }}
+ {% endfor %} +
+ + {# Hidden status field #} + {% render_field form.status type="hidden" %} + +
+ +
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/meetings/delete_onsite_meeting.html b/templates/meetings/delete_onsite_meeting.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/meetings/list_meetings.html b/templates/meetings/list_meetings.html index 8dfbdb2..bb743ed 100644 --- a/templates/meetings/list_meetings.html +++ b/templates/meetings/list_meetings.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static i18n %} -{% block title %}{% trans "Zoom Meetings" %} - {{ block.super }}{% endblock %} +{% block title %}{% trans "Interviews & Meetings" %} - {{ block.super }}{% endblock %} {% block customCSS %} {% endblock %} @@ -172,48 +119,66 @@

- {% trans "Zoom Meetings" %} + {% trans "Interviews & Meetings" %}

- - {% trans "Create Meeting" %} - + {% comment %} {% endcomment %}
-
+
+ {# Assuming includes/search_form.html handles the 'q' parameter #} {% include "includes/search_form.html" with search_query=search_query %}
-
+
- {% if search_query %}{% endif %} - {% if status_filter %}{% endif %} + {# Hidden inputs to persist other filters #} + {% if search_query %}{% endif %} + {% if status_filter %}{% endif %} + {% if candidate_name_filter %}{% endif %} -
+
+ + +
+ +
-
+
-
+
- {% if status_filter or search_query or candidate_name_filter %} + {% if status_filter or search_query or candidate_name_filter or type_filter %} {% trans "Clear" %} @@ -225,59 +190,63 @@
- {% if meetings %} + + {% if meetings_data %}
- {# View Switcher #} + {# View Switcher (not provided, assuming standard include) #} {% include "includes/_list_view_switcher.html" with list_id="meetings-list" %} {# Card View #}
- {% for meeting in meetings %} + {% for meeting in meetings_data %}
-
{{ meeting.topic }}
- - {{ meeting.status|title }} +
{{ meeting.topic }}
+ {# Display the type badge (Remote/Onsite) #} + + {{ meeting.type|title }}

- {% trans "Candidate" %}: {% if meeting.interview %}{{ meeting.interview.candidate.name }}{% else %} - - {% endif %}
- {% trans "Job" %}: {% if meeting.interview %}{{ meeting.interview.job.title }}{% else %} - - {% endif %}
- {% trans "ID" %}: {{ meeting.meeting_id|default:meeting.id }}
+ {% trans "Candidate" %}: {{ meeting.interview.application.person.full_name|default:"N/A" }}
+ {% trans "Job" %}: {{ meeting.interview.job.title|default:"N/A" }}
+ + {# Dynamic location/type details #} + {% if meeting.type == 'Remote' %} + {% trans "Remote ID" %}: {{ meeting.meeting_id|default:meeting.location.id }}
+ {% elif meeting.type == 'Onsite' %} + {# Use the details object for concrete location info #} + {% trans "Location" %}: {{ meeting.details.room_number|default:meeting.details.physical_address|truncatechars:30 }}
+ {% endif %} {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }}
- {% trans "Duration" %}: {{ meeting.duration }} minutes{% if meeting.password %}
{% trans "Password" %}: Yes{% endif %} + {% trans "Duration" %}: {{ meeting.duration }} minutes

+ + + {{ meeting.interview.get_status_display }} +
- + {% trans "View" %} - {% if meeting.join_url %} + {% if meeting.type == 'Remote' and meeting.join_url %} - {% trans "Join" %} + {% trans "Join Remote" %} + {% elif meeting.type == 'Onsite' %} + {% endif %} - - + + {# CORRECTED: Passing the slug to the update URL #} + - {% endif %} + {# Display the event type badge #} + {{ meeting.type|title }} + + + {{ meeting.interview.application.person.full_name }} - {% if meeting.interview %} {{ meeting.interview.job.title }} - {% else %} - - {% endif %} - {{ meeting.meeting_id|default:meeting.id }} {{ meeting.start_time|date:"M d, Y H:i" }} {{ meeting.duration }} min - {% if meeting %} - - {% if meeting.status == 'started' %} - - {% endif %} - {{ meeting.status|title }} + {# Display the meeting status badge from the ScheduledInterview model #} + + {{ meeting.interview.get_status_display }} - {% else %} - -- - {% endif %}
- {% if meeting.join_url %} + {% if meeting.type == 'Remote' and meeting.join_url %} {% endif %} - + - + {# CORRECTED: Passing the slug to the update URL #} +
- {# Pagination (Standardized) #} + {# Pagination (All filters correctly included in query strings) #} {% if is_paginated %}
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/templates/meetings/reschedule_onsite_meeting.html b/templates/meetings/reschedule_onsite_meeting.html new file mode 100644 index 0000000..6a81ca4 --- /dev/null +++ b/templates/meetings/reschedule_onsite_meeting.html @@ -0,0 +1,111 @@ +{% load static i18n %} +{% load widget_tweaks %} + +
+
+
+
+ + {% trans "Update Onsite Interview" %} for **{{ candidate.name }}** +
+

{% trans "Job" %}: {{ job.title }}

+

{% trans "Location Type" %}: {% trans "Onsite" %}

+
+
+ +
+ + {% csrf_token %} + +
+ + {# --- STATUS FIELD (Now Visible and Selectable) --- #} +
+ + {{ form.status|add_class:"form-select" }} + {% for error in form.status.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ {# --- TOPIC FIELD --- #} +
+
+ + {{ form.topic|add_class:"form-control" }} + {% for error in form.topic.errors %} +
{{ error }}
+ {% endfor %} +
+
+ + {# --- ROOM NUMBER FIELD --- #} +
+
+ + {{ form.room_number|add_class:"form-control" }} + {% for error in form.room_number.errors %} +
{{ error }}
+ {% endfor %} +
+
+
+ + + {# --- ADDRESS FIELD --- #} +
+ + {{ form.physical_address|add_class:"form-control" }} + {% for error in form.physical_address.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ +
+ {# --- START TIME FIELD --- #} +
+
+ + {{ form.start_time|add_class:"form-control" }} + {% for error in form.start_time.errors %} +
{{ error }}
+ {% endfor %} +
+
+ + {# --- DURATION FIELD --- #} +
+
+ + {{ form.duration|add_class:"form-control" }} + {% for error in form.duration.errors %} +
{{ error }}
+ {% endfor %} +
+
+
+ +
+ +
+ +
+
\ No newline at end of file diff --git a/templates/meetings/schedule_onsite_meeting_form.html b/templates/meetings/schedule_onsite_meeting_form.html new file mode 100644 index 0000000..2c2fecd --- /dev/null +++ b/templates/meetings/schedule_onsite_meeting_form.html @@ -0,0 +1,98 @@ +{% load static i18n %} +{% load widget_tweaks %} + +
+
+
+
+ + {% trans "Schedule New Onsite Interview" %} for **{{ candidate.name }}** +
+

{% trans "Job" %}: {{ job.title }}

+

{% trans "Location Type" %}: {% trans "Onsite" %}

+
+
+ +
+ {# The action_url is passed from the view and points back to the POST handler #} +
+ {% csrf_token %} + + {# --- HIDDEN FIELDS (application, job, status) --- #} + {# These fields are crucial for creating the ScheduledInterview record #} + {{ form.application }} + {{ form.job }} + {{ form.status }} + + {# --- TOPIC FIELD --- #} +
+ + {{ form.topic|add_class:"form-control"|attr:"required" }} + {% for error in form.topic.errors %} +
{{ error }}
+ {% endfor %} +
+ + {# --- ADDRESS FIELD --- #} +
+ + {{ form.physical_address|add_class:"form-control"|attr:"required" }} + {% for error in form.physical_address.errors %} +
{{ error }}
+ {% endfor %} +
+ + {# --- ROOM NUMBER FIELD --- #} +
+ + {{ form.room_number|add_class:"form-control" }} + {% for error in form.room_number.errors %} +
{{ error }}
+ {% endfor %} +
+ +
+ +
+ {# --- START TIME FIELD --- #} +
+
+ + {# Assumes start_time widget is DateTimeInput with type='datetime-local' #} + {{ form.start_time|add_class:"form-control"|attr:"required" }} + {% for error in form.start_time.errors %} +
{{ error }}
+ {% endfor %} +
+
+ + {# --- DURATION FIELD --- #} +
+
+ + {{ form.duration|add_class:"form-control"|attr:"required" }} + {% for error in form.duration.errors %} +
{{ error }}
+ {% endfor %} +
+
+
+ +
+ +
+
+
+
\ No newline at end of file diff --git a/templates/recruitment/candidate_interview_view.html b/templates/recruitment/candidate_interview_view.html index 98ab26f..fb6a4fa 100644 --- a/templates/recruitment/candidate_interview_view.html +++ b/templates/recruitment/candidate_interview_view.html @@ -304,8 +304,8 @@
- {% if candidate.get_latest_meeting.topic %} - {{ candidate.get_latest_meeting.topic }} + {% if candidate.get_latest_meeting %} + {{ candidate.get_latest_meeting }} {% else %} -- {% endif %} @@ -380,8 +380,10 @@ {% endif %} - - {% if candidate.get_latest_meeting %} + + {% if candidate.get_latest_meeting %} + {% if candidate.get_latest_meeting.location_type == 'Remote'%} + + {% else%} + + + + + + {% endif %} {% else %} + {% endif %}