From 8a0f7151452fe5dcc098a7457ea750f19f191ca1 Mon Sep 17 00:00:00 2001 From: Faheed Date: Sat, 22 Nov 2025 22:23:14 +0300 Subject: [PATCH] fixes at while testing --- recruitment/forms.py | 4 +- recruitment/models.py | 543 ++++++++------ templates/portal_base.html | 28 +- .../recruitment/agency_assignment_detail.html | 703 +++++++++--------- .../agency_portal_persons_list.html | 4 +- .../candidate_application_detail.html | 16 +- templates/recruitment/candidate_detail.html | 1 + .../candidate_document_review_view.html | 76 +- 8 files changed, 753 insertions(+), 622 deletions(-) diff --git a/recruitment/forms.py b/recruitment/forms.py index 51085ad..74971fd 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -1634,7 +1634,7 @@ class CandidateEmailForm(forms.Form): message_parts = [ f"Than you, for your interest in the {self.job.title} role.", f"We're pleased to inform you that you have cleared your exam!", - f"The next step is the mandatory online assessment exam.", + f"The next step is the mandatory interview.", f"Please complete the assessment by using the following link:", f"https://kaauh/hire/exam", f"We look forward to reviewing your results.", @@ -1659,6 +1659,8 @@ 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" ] + elif candidate: + message_parts="" diff --git a/recruitment/models.py b/recruitment/models.py index 0f7cc7e..2cef9e2 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -99,9 +99,9 @@ class JobPosting(Base): # Core Fields title = models.CharField(max_length=200) department = models.CharField(max_length=100, blank=True) - job_type = models.CharField(max_length=20, choices=JOB_TYPES, default="FULL_TIME") + job_type = models.CharField(max_length=20, choices=JOB_TYPES, default="Full-time") workplace_type = models.CharField( - max_length=20, choices=WORKPLACE_TYPES, default="ON_SITE" + max_length=20, choices=WORKPLACE_TYPES, default="On-site" ) # Location @@ -243,6 +243,11 @@ class JobPosting(Base): help_text=_("Whether the job posting has been parsed by AI"), verbose_name=_("AI Parsed"), ) + # Field to store the generated zip file + cv_zip_file = models.FileField(upload_to='job_zips/', null=True, blank=True) + + # Field to track if the background task has completed + zip_created = models.BooleanField(default=False) class Meta: ordering = ["-created_at"] @@ -906,11 +911,35 @@ class Application(Base): @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): @@ -960,6 +989,14 @@ class Application(Base): 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.hiring_agency: + return True + else: + return False + class TrainingMaterial(Base): @@ -983,137 +1020,326 @@ class TrainingMaterial(Base): return self.title -class OnsiteMeeting(Base): - class MeetingStatus(models.TextChoices): +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") - # 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, + 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, - verbose_name=_("Status"), - default=MeetingStatus.WAITING, + 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 ZoomMeeting(Base): - class MeetingStatus(models.TextChoices): - WAITING = "waiting", _("Waiting") - STARTED = "started", _("Started") - ENDED = "ended", _("Ended") - CANCELLED = "cancelled", _("Cancelled") + class Meta: + verbose_name = _("Interview Location") + verbose_name_plural = _("Interview Locations") - # Basic meeting details - topic = models.CharField(max_length=255, verbose_name=_("Topic")) - meeting_id = models.CharField( + +class ZoomMeetingDetails(InterviewLocation): + """Concrete model for remote interviews (Zoom specifics).""" + + status = models.CharField( db_index=True, max_length=20, - unique=True, - verbose_name=_("Meeting ID"), # Added index - ) # Unique identifier for the meeting + choices=InterviewLocation.Status.choices, + default=InterviewLocation.Status.WAITING, + ) 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") + 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")) - zoom_gateway_response = models.JSONField( - blank=True, null=True, verbose_name=_("Zoom Gateway Response") + # *** 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, # Added index + 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=_("Status"), - default=MeetingStatus.WAITING, + 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 ) - # 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() + return f"Schedule for {self.job.title}" -class MeetingComment(Base): - """ - Model for storing meeting comments/notes - """ +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") - meeting = models.ForeignKey( - ZoomMeeting, + application = models.ForeignKey( + Application, on_delete=models.CASCADE, - related_name="comments", - verbose_name=_("Meeting"), + 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.CASCADE, + 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="meeting_comments", + related_name="interview_notes", verbose_name=_("Author"), + db_index=True ) - content = CKEditor5Field(verbose_name=_("Content"), config_name="extends") - # Inherited from Base: created_at, updated_at, slug - + + 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 = _("Meeting Comment") - verbose_name_plural = _("Meeting Comments") - ordering = ["-created_at"] + verbose_name = _("Interview Note") + verbose_name_plural = _("Interview Notes") + ordering = ["created_at"] def __str__(self): - return f"Comment by {self.author.get_username()} on {self.meeting.topic}" - + return f"{self.get_note_type_display()} by {self.author.get_username()} on {self.interview.id}" + class FormTemplate(Base): """ @@ -1926,143 +2152,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): @@ -2101,7 +2190,7 @@ class Notification(models.Model): verbose_name=_("Status"), ) related_meeting = models.ForeignKey( - ZoomMeeting, + ZoomMeetingDetails, on_delete=models.CASCADE, related_name="notifications", null=True, diff --git a/templates/portal_base.html b/templates/portal_base.html index 46d53d7..30fcb0e 100644 --- a/templates/portal_base.html +++ b/templates/portal_base.html @@ -93,8 +93,26 @@ {% trans "Dashboard" %} + {% endif %} + + {% if request.user.is_authenticated %} + + {% endif %} + + + - {% endif %} -