diff --git a/recruitment/models.py b/recruitment/models.py
index 2cef9e2..3ddfea0 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,11 +243,6 @@ 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"]
@@ -911,35 +906,11 @@ class Application(Base):
@property
def get_latest_meeting(self):
- """
- Retrieves the most specific location details (subclass instance)
- of the latest ScheduledInterview for this application, or None.
- """
- # 1. Get the latest ScheduledInterview
+ """Legacy compatibility - get latest meeting for this application"""
schedule = self.scheduled_interviews.order_by("-created_at").first()
-
- # 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
-
+ if schedule:
+ return schedule.zoom_meeting
+ return None
@property
def has_future_meeting(self):
@@ -989,14 +960,6 @@ 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):
@@ -1020,326 +983,137 @@ class TrainingMaterial(Base):
return self.title
-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."""
+class OnsiteMeeting(Base):
+ class MeetingStatus(models.TextChoices):
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,
- )
+ # 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 (minutes)")
+ 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=50,
+ max_length=20,
unique=True,
- verbose_name=_("External Meeting ID")
+ 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")
)
- 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)")
+ zoom_gateway_response = models.JSONField(
+ blank=True, null=True, verbose_name=_("Zoom Gateway Response")
)
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",
+ max_length=20, # Added index
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
+ verbose_name=_("Status"),
+ default=MeetingStatus.WAITING,
)
+ # Timestamps
def __str__(self):
- return f"Schedule for {self.job.title}"
+ 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 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")
+class MeetingComment(Base):
+ """
+ Model for storing meeting comments/notes
+ """
- application = models.ForeignKey(
- Application,
+ meeting = models.ForeignKey(
+ ZoomMeeting,
on_delete=models.CASCADE,
- related_name="scheduled_interviews",
- db_index=True,
+ related_name="comments",
+ verbose_name=_("Meeting"),
)
- 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="interview_notes",
+ related_name="meeting_comments",
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")
-
+ content = CKEditor5Field(verbose_name=_("Content"), config_name="extends")
+ # Inherited from Base: created_at, updated_at, slug
+
class Meta:
- verbose_name = _("Interview Note")
- verbose_name_plural = _("Interview Notes")
- ordering = ["created_at"]
+ verbose_name = _("Meeting Comment")
+ verbose_name_plural = _("Meeting Comments")
+ ordering = ["-created_at"]
def __str__(self):
- return f"{self.get_note_type_display()} by {self.author.get_username()} on {self.interview.id}"
-
+ return f"Comment by {self.author.get_username()} on {self.meeting.topic}"
+
class FormTemplate(Base):
"""
@@ -2152,6 +1926,143 @@ 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):
@@ -2190,7 +2101,7 @@ class Notification(models.Model):
verbose_name=_("Status"),
)
related_meeting = models.ForeignKey(
- ZoomMeetingDetails,
+ ZoomMeeting,
on_delete=models.CASCADE,
related_name="notifications",
null=True,
@@ -2470,3 +2381,2455 @@ class Document(Base):
if self.file:
return self.file.name.split(".")[-1].upper()
return ""
+
+from django.db import models
+from django.urls import reverse
+from typing import List, Dict, Any
+from django.utils import timezone
+from django.db.models import FloatField, CharField, IntegerField
+from django.db.models.functions import Cast, Coalesce
+from django.db.models import F
+from django.contrib.auth.models import AbstractUser
+from django.contrib.auth import get_user_model
+from django.core.validators import URLValidator
+from django_countries.fields import CountryField
+from django.core.exceptions import ValidationError
+from django_ckeditor_5.fields import CKEditor5Field
+from django.utils.html import strip_tags
+from django.utils.translation import gettext_lazy as _
+from django_extensions.db.fields import RandomCharField
+from .validators import validate_hash_tags, validate_image_size
+from django.contrib.auth.models import AbstractUser
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+
+
+class CustomUser(AbstractUser):
+ """Custom user model extending AbstractUser"""
+
+ USER_TYPES = [
+ ("staff", _("Staff")),
+ ("agency", _("Agency")),
+ ("candidate", _("Candidate")),
+ ]
+
+ user_type = models.CharField(
+ max_length=20, choices=USER_TYPES, default="staff", verbose_name=_("User Type")
+ )
+ phone = models.CharField(
+ max_length=20, blank=True, null=True, verbose_name=_("Phone")
+ )
+
+ class Meta:
+ verbose_name = _("User")
+ verbose_name_plural = _("Users")
+
+
+User = get_user_model()
+
+
+class Base(models.Model):
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
+ updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at"))
+ slug = RandomCharField(
+ length=8, unique=True, editable=False, verbose_name=_("Slug")
+ )
+
+ class Meta:
+ abstract = True
+
+
+class Profile(models.Model):
+ profile_image = models.ImageField(
+ null=True,
+ blank=True,
+ upload_to="profile_pic/",
+ validators=[validate_image_size],
+ )
+ designation = models.CharField(max_length=100, blank=True, null=True)
+ phone = models.CharField(
+ blank=True, null=True, verbose_name=_("Phone Number"), max_length=12
+ )
+ user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
+
+ def __str__(self):
+ return f"image for user {self.user}"
+
+
+class JobPosting(Base):
+ # Basic Job Information
+
+ JOB_TYPES = [
+ (_("Full-time"), _("Full-time")),
+ (_("Part-time"), _("Part-time")),
+ (_("Contract"), _("Contract")),
+ (_("Internship"), _("Internship")),
+ (_("Faculty"), _("Faculty")),
+ (_("Temporary"), _("Temporary")),
+ ]
+
+ WORKPLACE_TYPES = [
+ (_("On-site"), _("On-site")),
+ (_("Remote"), _("Remote")),
+ (_("Hybrid"), _("Hybrid")),
+ ]
+
+ # users=models.ManyToManyField(
+ # User,
+ # blank=True,related_name="jobs_assigned",
+ # verbose_name=_("Internal Participant"),
+ # help_text=_("Internal staff involved in the recruitment process for this job"),
+ # )
+
+ # participants=models.ManyToManyField('Participants',
+ # blank=True,related_name="jobs_participating",
+ # verbose_name=_("External Participant"),
+ # help_text=_("External participants involved in the recruitment process for this job"),
+ # )
+
+ # 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")
+ workplace_type = models.CharField(
+ max_length=20, choices=WORKPLACE_TYPES, default="ON_SITE"
+ )
+
+ # Location
+ location_city = models.CharField(max_length=100, blank=True)
+ location_state = models.CharField(max_length=100, blank=True)
+ location_country = models.CharField(max_length=100, default="Saudia Arabia")
+
+ # Job Details
+ description = CKEditor5Field(
+ "Description",
+ config_name="extends", # Matches the config name you defined in settings.py
+ )
+
+ qualifications = CKEditor5Field(blank=True, null=True, config_name="extends")
+ salary_range = models.CharField(
+ max_length=200, blank=True, help_text="e.g., $60,000 - $80,000"
+ )
+ benefits = CKEditor5Field(blank=True, null=True, config_name="extends")
+
+ # Application Information ---job detail apply link for the candidates
+ application_url = models.URLField(
+ validators=[URLValidator()],
+ help_text="URL where candidates apply",
+ null=True,
+ blank=True,
+ )
+
+ application_deadline = models.DateField(db_index=True) # Added index
+ application_instructions = CKEditor5Field(
+ blank=True, null=True, config_name="extends"
+ )
+
+ # Internal Tracking
+ internal_job_id = models.CharField(max_length=50, editable=False)
+
+ created_by = models.CharField(
+ max_length=100, blank=True, help_text="Name of person who created this job"
+ )
+
+ # Status Fields
+ STATUS_CHOICES = [
+ ("DRAFT", "Draft"),
+ ("ACTIVE", "Active"),
+ ("CLOSED", "Closed"),
+ ("CANCELLED", "Cancelled"),
+ ("ARCHIVED", "Archived"),
+ ]
+ status = models.CharField(
+ db_index=True,
+ max_length=20,
+ choices=STATUS_CHOICES,
+ default="DRAFT", # Added index
+ )
+
+ # hashtags for social media
+ hash_tags = models.CharField(
+ max_length=200,
+ blank=True,
+ help_text="Comma-separated hashtags for linkedin post like #hiring,#jobopening",
+ validators=[validate_hash_tags],
+ )
+
+ # LinkedIn Integration Fields
+ linkedin_post_id = models.CharField(
+ max_length=200, blank=True, help_text="LinkedIn post ID after posting"
+ )
+ linkedin_post_url = models.URLField(
+ blank=True, help_text="Direct URL to LinkedIn post"
+ )
+ posted_to_linkedin = models.BooleanField(default=False)
+ linkedin_post_status = models.CharField(
+ max_length=50, blank=True, help_text="Status of LinkedIn posting"
+ )
+ linkedin_posted_at = models.DateTimeField(null=True, blank=True)
+ linkedin_post_formated_data = models.TextField(null=True, blank=True)
+
+ published_at = models.DateTimeField(
+ db_index=True, null=True, blank=True
+ ) # Added index
+ # University Specific Fields
+ position_number = models.CharField(
+ max_length=50, blank=True, help_text="University position number"
+ )
+ reporting_to = models.CharField(
+ max_length=100, blank=True, help_text="Who this position reports to"
+ )
+
+ open_positions = models.PositiveIntegerField(
+ default=1, help_text="Number of open positions for this job"
+ )
+
+ source = models.ForeignKey(
+ "Source",
+ on_delete=models.SET_NULL, # Recommended: If a source is deleted, job's source is set to NULL
+ related_name="job_postings",
+ null=True,
+ blank=True,
+ help_text="The system or channel from which this job posting originated or was first published.",
+ db_index=True, # Explicitly index ForeignKey
+ )
+ max_applications = models.PositiveIntegerField(
+ default=1000,
+ help_text="Maximum number of applications allowed",
+ null=True,
+ blank=True,
+ )
+ hiring_agency = models.ManyToManyField(
+ "HiringAgency",
+ blank=True,
+ related_name="jobs",
+ verbose_name=_("Hiring Agency"),
+ help_text=_(
+ "External agency responsible for sourcing candidates for this role"
+ ),
+ )
+ cancel_reason = models.TextField(
+ blank=True,
+ help_text=_("Reason for canceling the job posting"),
+ verbose_name=_("Cancel Reason"),
+ )
+ cancelled_by = models.CharField(
+ max_length=100,
+ blank=True,
+ help_text=_("Name of person who cancelled this job"),
+ verbose_name=_("Cancelled By"),
+ )
+ cancelled_at = models.DateTimeField(null=True, blank=True)
+ assigned_to = models.ForeignKey(
+ User,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="assigned_jobs",
+ help_text=_("The user who has been assigned to this job"),
+ verbose_name=_("Assigned To"),
+ )
+
+ class Meta:
+ ordering = ["-created_at"]
+ verbose_name = "Job Posting"
+ verbose_name_plural = "Job Postings"
+ indexes = [
+ models.Index(fields=["status", "created_at", "title"]),
+ models.Index(fields=["slug"]),
+ ]
+
+ def __str__(self):
+ return f"{self.title} - {self.internal_job_id}-{self.get_status_display()}"
+
+ def get_source(self):
+ return self.source.name if self.source else "System"
+
+ def save(self, *args, **kwargs):
+ from django.db import transaction
+
+ # Generate unique internal job ID if not exists
+ with transaction.atomic():
+ if not self.internal_job_id:
+ prefix = "KAAUH"
+ year = timezone.now().year
+ # Get next sequential number
+ last_job = (
+ JobPosting.objects.select_for_update()
+ .filter(internal_job_id__startswith=f"{prefix}-{year}-")
+ .order_by("internal_job_id")
+ .last()
+ )
+
+ if last_job:
+ last_num = int(last_job.internal_job_id.split("-")[-1])
+ next_num = last_num + 1
+ else:
+ next_num = 1
+
+ self.internal_job_id = f"{prefix}-{year}-{next_num:06d}"
+
+ super().save(*args, **kwargs)
+
+ def get_location_display(self):
+ """Return formatted location string"""
+ parts = []
+ if self.location_city:
+ parts.append(self.location_city)
+ if self.location_state:
+ parts.append(self.location_state)
+ if self.location_country:
+ parts.append(self.location_country)
+ return ", ".join(parts) if parts else "Not specified"
+
+ @property
+ def is_expired(self):
+ """Check if application deadline has passed"""
+ if self.application_deadline:
+ return self.application_deadline < timezone.now().date()
+ return False
+
+ def publish(self):
+ self.status = "PUBLISHED"
+ self.published_at = timezone.now()
+ self.application_url = reverse(
+ "form_wizard", kwargs={"slug": self.form_template.slug}
+ )
+ self.save()
+
+ def _check_content(self, field_value):
+ """Helper to check if a field contains meaningful content."""
+ if not field_value:
+ return False
+
+ # 1. Replace the common HTML non-breaking space entity with a standard space.
+ content = field_value.replace(" ", " ")
+
+ # 2. Remove all HTML tags (leaving only text and remaining spaces).
+ stripped = strip_tags(content)
+
+ # 3. Use .strip() to remove ALL leading/trailing whitespace, including the ones from step 1.
+ final_content = stripped.strip()
+
+ # Return True if any content remains after stripping tags and spaces.
+ return bool(final_content)
+
+ @property
+ def has_description_content(self):
+ """Returns True if the description field has meaningful content."""
+ return self._check_content(self.description)
+
+ @property
+ def has_qualifications_content(self):
+ """Returns True if the qualifications field has meaningful content."""
+ return self._check_content(self.qualifications)
+
+ # Add similar properties for benefits and application_instructions
+ @property
+ def has_benefits_content(self):
+ return self._check_content(self.benefits)
+
+ @property
+ def has_application_instructions_content(self):
+ return self._check_content(self.application_instructions)
+
+ @property
+ def current_applications_count(self):
+ """Returns the current number of candidates associated with this job."""
+ return self.applications.count()
+
+ @property
+ def is_application_limit_reached(self):
+ """Checks if the current application count meets or exceeds the max limit."""
+ if self.max_applications == 0:
+ return True
+
+ return self.current_applications_count >= self.max_applications
+
+ @property
+ def all_candidates(self):
+ return self.applications.annotate(
+ sortable_score=Coalesce(
+ Cast(
+ "ai_analysis_data__analysis_data__match_score",
+ output_field=IntegerField(),
+ ),
+ 0,
+ )
+ ).order_by("-sortable_score")
+
+ @property
+ def screening_candidates(self):
+ return self.all_candidates.filter(stage="Applied")
+
+ @property
+ def exam_candidates(self):
+ return self.all_candidates.filter(stage="Exam")
+
+ @property
+ def interview_candidates(self):
+ return self.all_candidates.filter(stage="Interview")
+
+ @property
+ def offer_candidates(self):
+ return self.all_candidates.filter(stage="Offer")
+
+ @property
+ def accepted_candidates(self):
+ return self.all_candidates.filter(offer_status="Accepted")
+
+ @property
+ def hired_candidates(self):
+ return self.all_candidates.filter(stage="Hired")
+
+ # counts
+ @property
+ def all_candidates_count(self):
+ return (
+ self.candidates.annotate(
+ sortable_score=Cast(
+ "ai_analysis_data__match_score", output_field=CharField()
+ )
+ )
+ .order_by("-sortable_score")
+ .count()
+ or 0
+ )
+
+ @property
+ def screening_candidates_count(self):
+ return self.all_candidates.filter(stage="Applied").count() or 0
+
+ @property
+ def exam_candidates_count(self):
+ return self.all_candidates.filter(stage="Exam").count() or 0
+
+ @property
+ def interview_candidates_count(self):
+ return self.all_candidates.filter(stage="Interview").count() or 0
+
+ @property
+ def offer_candidates_count(self):
+ return self.all_candidates.filter(stage="Offer").count() or 0
+
+ @property
+ def hired_candidates_count(self):
+ return self.all_candidates.filter(stage="Hired").count() or 0
+
+ @property
+ def vacancy_fill_rate(self):
+ total_positions = self.open_positions
+
+ no_of_positions_filled = self.applications.filter(stage__in=["HIRED"]).count()
+
+ if total_positions > 0:
+ vacancy_fill_rate = no_of_positions_filled / total_positions
+ else:
+ vacancy_fill_rate = 0.0
+
+ return vacancy_fill_rate
+
+
+class JobPostingImage(models.Model):
+ job = models.OneToOneField(
+ "JobPosting", on_delete=models.CASCADE, related_name="post_images"
+ )
+ post_image = models.ImageField(upload_to="post/", validators=[validate_image_size])
+
+
+class Person(Base):
+ """Model to store personal information that can be reused across multiple applications"""
+
+ GENDER_CHOICES = [
+ ("M", _("Male")),
+ ("F", _("Female")),
+ ("O", _("Other")),
+ ("P", _("Prefer not to say")),
+ ]
+
+ # Personal Information
+ first_name = models.CharField(max_length=255, verbose_name=_("First Name"))
+ last_name = models.CharField(max_length=255, verbose_name=_("Last Name"))
+ middle_name = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Middle Name"))
+ email = models.EmailField(
+ unique=True,
+ db_index=True,
+ verbose_name=_("Email"),
+ help_text=_("Unique email address for the person")
+ )
+ phone = models.CharField(max_length=20, blank=True, null=True, verbose_name=_("Phone"))
+ date_of_birth = models.DateField(null=True, blank=True, verbose_name=_("Date of Birth"))
+ gender = models.CharField(
+ max_length=1,
+ choices=GENDER_CHOICES,
+ blank=True,
+ null=True,
+ verbose_name=_("Gender")
+ )
+ nationality = CountryField(blank=True, null=True, verbose_name=_("Nationality"))
+ address = models.TextField(blank=True, null=True, verbose_name=_("Address"))
+
+ # Optional linking to user account
+ user = models.OneToOneField(
+ User,
+ on_delete=models.SET_NULL,
+ related_name="person_profile",
+ verbose_name=_("User Account"),
+ null=True,
+ blank=True,
+ )
+
+ # Profile information
+ profile_image = models.ImageField(
+ null=True,
+ blank=True,
+ upload_to="profile_pic/",
+ validators=[validate_image_size],
+ verbose_name=_("Profile Image")
+ )
+ linkedin_profile = models.URLField(
+ blank=True,
+ null=True,
+ verbose_name=_("LinkedIn Profile URL")
+ )
+ agency = models.ForeignKey(
+ "HiringAgency",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ verbose_name=_("Hiring Agency")
+ )
+ class Meta:
+ verbose_name = _("Person")
+ verbose_name_plural = _("People")
+ indexes = [
+ models.Index(fields=["email"]),
+ models.Index(fields=["first_name", "last_name"]),
+ models.Index(fields=["created_at"]),
+ ]
+
+ def __str__(self):
+ return f"{self.first_name} {self.last_name}"
+
+ @property
+ def full_name(self):
+ return f"{self.first_name} {self.last_name}"
+
+ @property
+ def age(self):
+ """Calculate age from date of birth"""
+ if self.date_of_birth:
+ today = timezone.now().date()
+ return today.year - self.date_of_birth.year - (
+ (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day)
+ )
+ return None
+
+ @property
+ def documents(self):
+ """Return all documents associated with this Person"""
+ 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):
+ """Model to store job-specific application data"""
+
+ class Stage(models.TextChoices):
+ APPLIED = "Applied", _("Applied")
+ EXAM = "Exam", _("Exam")
+ INTERVIEW = "Interview", _("Interview")
+ OFFER = "Offer", _("Offer")
+ HIRED = "Hired", _("Hired")
+ REJECTED = "Rejected", _("Rejected")
+
+ class ExamStatus(models.TextChoices):
+ PASSED = "Passed", _("Passed")
+ FAILED = "Failed", _("Failed")
+
+ class OfferStatus(models.TextChoices):
+ ACCEPTED = "Accepted", _("Accepted")
+ REJECTED = "Rejected", _("Rejected")
+ PENDING = "Pending", _("Pending")
+
+ class ApplicantType(models.TextChoices):
+ APPLICANT = "Applicant", _("Applicant")
+ CANDIDATE = "Candidate", _("Candidate")
+
+ # Stage transition validation constants
+ STAGE_SEQUENCE = {
+ "Applied": ["Exam", "Interview", "Offer", "Rejected"],
+ "Exam": ["Interview", "Offer", "Rejected"],
+ "Interview": ["Offer", "Rejected"],
+ "Offer": ["Hired", "Rejected"],
+ "Rejected": [], # Final stage - no further transitions
+ "Hired": [], # Final stage - no further transitions
+ }
+
+ # Core relationships
+ person = models.ForeignKey(
+ Person,
+ on_delete=models.CASCADE,
+ related_name="applications",
+ verbose_name=_("Person"),
+ )
+ job = models.ForeignKey(
+ JobPosting,
+ on_delete=models.CASCADE,
+ related_name="applications",
+ verbose_name=_("Job"),
+ )
+
+ # Application-specific data
+ resume = models.FileField(upload_to="resumes/", verbose_name=_("Resume"))
+ cover_letter = models.FileField(
+ upload_to="cover_letters/",
+ blank=True,
+ null=True,
+ verbose_name=_("Cover Letter")
+ )
+ is_resume_parsed = models.BooleanField(
+ default=False,
+ verbose_name=_("Resume Parsed")
+ )
+ parsed_summary = models.TextField(
+ blank=True,
+ verbose_name=_("Parsed Summary")
+ )
+
+ # Workflow fields
+ applied = models.BooleanField(default=False, verbose_name=_("Applied"))
+ stage = models.CharField(
+ db_index=True,
+ max_length=20,
+ default="Applied",
+ choices=Stage.choices,
+ verbose_name=_("Stage"),
+ )
+ applicant_status = models.CharField(
+ choices=ApplicantType.choices,
+ default="Applicant",
+ max_length=20,
+ null=True,
+ blank=True,
+ verbose_name=_("Applicant Status"),
+ )
+
+ # Timeline fields
+ exam_date = models.DateTimeField(null=True, blank=True, verbose_name=_("Exam Date"))
+ exam_status = models.CharField(
+ choices=ExamStatus.choices,
+ max_length=20,
+ null=True,
+ blank=True,
+ verbose_name=_("Exam Status"),
+ )
+ interview_date = models.DateTimeField(
+ null=True, blank=True, verbose_name=_("Interview Date")
+ )
+ interview_status = models.CharField(
+ choices=ExamStatus.choices,
+ max_length=20,
+ null=True,
+ blank=True,
+ verbose_name=_("Interview Status"),
+ )
+ offer_date = models.DateField(null=True, blank=True, verbose_name=_("Offer Date"))
+ offer_status = models.CharField(
+ choices=OfferStatus.choices,
+ max_length=20,
+ null=True,
+ blank=True,
+ verbose_name=_("Offer Status"),
+ )
+ hired_date = models.DateField(null=True, blank=True, verbose_name=_("Hired Date"))
+ join_date = models.DateField(null=True, blank=True, verbose_name=_("Join Date"))
+
+ # AI Analysis
+ ai_analysis_data = models.JSONField(
+ verbose_name="AI Analysis Data",
+ default=dict,
+ help_text="Full JSON output from the resume scoring model.",
+ null=True,
+ blank=True,
+ )
+ retry = models.SmallIntegerField(
+ verbose_name="Resume Parsing Retry",
+ default=3
+ )
+
+ # Source tracking
+ hiring_source = models.CharField(
+ max_length=255,
+ null=True,
+ blank=True,
+ verbose_name=_("Hiring Source"),
+ choices=[
+ (_("Public"), _("Public")),
+ (_("Internal"), _("Internal")),
+ (_("Agency"), _("Agency")),
+ ],
+ default="Public",
+ )
+ hiring_agency = models.ForeignKey(
+ "HiringAgency",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="applications",
+ verbose_name=_("Hiring Agency"),
+ )
+
+ # Optional linking to user account (for candidate portal access)
+ # user = models.OneToOneField(
+ # User,
+ # on_delete=models.SET_NULL,
+ # related_name="application_profile",
+ # verbose_name=_("User Account"),
+ # null=True,
+ # blank=True,
+ # )
+
+ class Meta:
+ verbose_name = _("Application")
+ verbose_name_plural = _("Applications")
+ indexes = [
+ models.Index(fields=["person", "job"]),
+ models.Index(fields=["stage"]),
+ models.Index(fields=["created_at"]),
+ models.Index(fields=["person", "stage", "created_at"]),
+ ]
+ unique_together = [["person", "job"]] # Prevent duplicate applications
+
+ def __str__(self):
+ return f"{self.person.full_name} - {self.job.title}"
+
+ # ====================================================================
+ # ✨ PROPERTIES (GETTERS) - Migrated from Candidate
+ # ====================================================================
+ @property
+ def resume_data(self):
+ return self.ai_analysis_data.get("resume_data", {})
+
+ @property
+ def analysis_data(self):
+ return self.ai_analysis_data.get("analysis_data", {})
+
+ @property
+ def match_score(self) -> int:
+ """1. A score from 0 to 100 representing how well the candidate fits the role."""
+ return self.analysis_data.get("match_score", 0)
+
+ @property
+ def years_of_experience(self) -> float:
+ """4. The total number of years of professional experience as a numerical value."""
+ return self.analysis_data.get("years_of_experience", 0.0)
+
+ @property
+ def soft_skills_score(self) -> int:
+ """15. A score (0-100) for inferred non-technical skills."""
+ return self.analysis_data.get("soft_skills_score", 0)
+
+ @property
+ def industry_match_score(self) -> int:
+ """16. A score (0-100) for the relevance of the candidate's industry experience."""
+ return self.analysis_data.get("experience_industry_match", 0)
+
+ @property
+ def min_requirements_met(self) -> bool:
+ """14. Boolean (true/false) indicating if all non-negotiable minimum requirements are met."""
+ return self.analysis_data.get("min_req_met_bool", False)
+
+ @property
+ def screening_stage_rating(self) -> str:
+ """13. A standardized rating (e.g., "A - Highly Qualified", "B - Qualified")."""
+ return self.analysis_data.get("screening_stage_rating", "N/A")
+
+ @property
+ def top_3_keywords(self) -> List[str]:
+ """10. A list of the three most dominant and relevant technical skills or technologies."""
+ return self.analysis_data.get("top_3_keywords", [])
+
+ @property
+ def most_recent_job_title(self) -> str:
+ """8. The candidate's most recent or current professional job title."""
+ return self.analysis_data.get("most_recent_job_title", "N/A")
+
+ @property
+ def criteria_checklist(self) -> Dict[str, str]:
+ """5 & 6. An object rating the candidate's match for each specific criterion."""
+ return self.analysis_data.get("criteria_checklist", {})
+
+ @property
+ def professional_category(self) -> str:
+ """7. The most fitting professional field or category for the individual."""
+ return self.analysis_data.get("category", "N/A")
+
+ @property
+ def language_fluency(self) -> List[Dict[str, str]]:
+ """12. A list of languages and their fluency levels mentioned."""
+ return self.analysis_data.get("language_fluency", [])
+
+ @property
+ def strengths(self) -> str:
+ """2. A brief summary of why the candidate is a strong fit."""
+ return self.analysis_data.get("strengths", "")
+
+ @property
+ def weaknesses(self) -> str:
+ """3. A brief summary of where the candidate falls short or what criteria are missing."""
+ return self.analysis_data.get("weaknesses", "")
+
+ @property
+ def job_fit_narrative(self) -> str:
+ """11. A single, concise sentence summarizing the core fit."""
+ return self.analysis_data.get("job_fit_narrative", "")
+
+ @property
+ def recommendation(self) -> str:
+ """9. Provide a detailed final recommendation for the candidate."""
+ return self.analysis_data.get("recommendation", "")
+
+ # ====================================================================
+ # 🔄 HELPER METHODS
+ # ====================================================================
+ def set_field(self, key: str, value: Any):
+ """Generic method to set any single key-value pair and save."""
+ self.ai_analysis_data[key] = value
+
+ def get_available_stages(self):
+ """Get list of stages this application can transition to"""
+ if not self.pk: # New record
+ return ["Applied"]
+
+ old_stage = self.__class__.objects.get(pk=self.pk).stage
+ return self.STAGE_SEQUENCE.get(old_stage, [])
+
+ def save(self, *args, **kwargs):
+ """Override save to ensure validation is called"""
+ self.clean() # Call validation before saving
+ super().save(*args, **kwargs)
+
+ # ====================================================================
+ # 📋 LEGACY COMPATIBILITY PROPERTIES
+ # ====================================================================
+ # These properties maintain compatibility with existing code that expects Candidate model
+ @property
+ def first_name(self):
+ """Legacy compatibility - delegates to person.first_name"""
+ return self.person.first_name
+
+ @property
+ def last_name(self):
+ """Legacy compatibility - delegates to person.last_name"""
+ return self.person.last_name
+
+ @property
+ def email(self):
+ """Legacy compatibility - delegates to person.email"""
+ return self.person.email
+
+ @property
+ def phone(self):
+ """Legacy compatibility - delegates to person.phone if available"""
+ return self.person.phone or ""
+
+ @property
+ def address(self):
+ """Legacy compatibility - delegates to person.address if available"""
+ return self.person.address or ""
+
+ @property
+ def name(self):
+ """Legacy compatibility - delegates to person.full_name"""
+ return self.person.full_name
+
+ @property
+ def full_name(self):
+ """Legacy compatibility - delegates to person.full_name"""
+ return self.person.full_name
+
+ @property
+ def get_file_size(self):
+ """Legacy compatibility - returns resume file size"""
+ if self.resume:
+ return self.resume.size
+ return 0
+
+ @property
+ def submission(self):
+ """Legacy compatibility - get form submission for this application"""
+ return FormSubmission.objects.filter(template__job=self.job).first()
+
+ @property
+ def responses(self):
+ """Legacy compatibility - get form responses for this application"""
+ if self.submission:
+ return self.submission.responses.all()
+ return []
+
+ @property
+ def get_meetings(self):
+ """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):
+ """
+ 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()
+
+ # 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):
+ """Legacy compatibility - check for future meetings"""
+ now = timezone.now()
+ future_meetings = (
+ self.scheduled_interviews.filter(interview_date__gt=now.date())
+ .filter(interview_time__gte=now.time())
+ .exists()
+ )
+ today_future_meetings = self.scheduled_interviews.filter(
+ interview_date=now.date(), interview_time__gte=now.time()
+ ).exists()
+ return future_meetings or today_future_meetings
+
+ @property
+ def scoring_timeout(self):
+ """Legacy compatibility - check scoring timeout"""
+ return timezone.now() <= (self.created_at + timezone.timedelta(minutes=5))
+
+ @property
+ def get_interview_date(self):
+ """Legacy compatibility - get interview date"""
+ if hasattr(self, "scheduled_interview") and self.scheduled_interview:
+ return self.scheduled_interviews.first().interview_date
+ return None
+
+ @property
+ def get_interview_time(self):
+ """Legacy compatibility - get interview time"""
+ if hasattr(self, "scheduled_interview") and self.scheduled_interview:
+ return self.scheduled_interviews.first().interview_time
+ return None
+
+ @property
+ def time_to_hire_days(self):
+ """Legacy compatibility - calculate time to hire"""
+ if self.hired_date and self.created_at:
+ time_to_hire = self.hired_date - self.created_at.date()
+ return time_to_hire.days
+ return 0
+
+ @property
+ def documents(self):
+ """Return all documents associated with this Application"""
+ 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)
+
+class TrainingMaterial(Base):
+ title = models.CharField(max_length=255, verbose_name=_("Title"))
+ content = CKEditor5Field(
+ blank=True, verbose_name=_("Content"), config_name="extends"
+ )
+ video_link = models.URLField(blank=True, verbose_name=_("Video Link"))
+ file = models.FileField(
+ upload_to="training_materials/", blank=True, verbose_name=_("File")
+ )
+ created_by = models.ForeignKey(
+ User, on_delete=models.SET_NULL, null=True, verbose_name=_("Created by")
+ )
+
+ class Meta:
+ verbose_name = _("Training Material")
+ verbose_name_plural = _("Training Materials")
+
+ def __str__(self):
+ return self.title
+
+
+
+class FormTemplate(Base):
+ """
+ Represents a complete form template with multiple stages
+ """
+
+ job = models.OneToOneField(
+ JobPosting,
+ on_delete=models.CASCADE,
+ related_name="form_template",
+ db_index=True,
+ )
+ name = models.CharField(max_length=200, help_text="Name of the form template")
+ description = models.TextField(
+ blank=True, help_text="Description of the form template"
+ )
+ created_by = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ related_name="form_templates",
+ null=True,
+ blank=True,
+ db_index=True,
+ )
+ is_active = models.BooleanField(
+ default=False, help_text="Whether this template is active"
+ )
+
+ class Meta:
+ ordering = ["-created_at"]
+ verbose_name = "Form Template"
+ verbose_name_plural = "Form Templates"
+ indexes = [
+ models.Index(fields=["created_at"]),
+ models.Index(fields=["is_active"]),
+ ]
+
+ def __str__(self):
+ return self.name
+
+ def get_stage_count(self):
+ return self.stages.count()
+
+ def get_field_count(self):
+ return sum(stage.fields.count() for stage in self.stages.all())
+
+
+class FormStage(Base):
+ """
+ Represents a stage/section within a form template
+ """
+
+ template = models.ForeignKey(
+ FormTemplate, on_delete=models.CASCADE, related_name="stages", db_index=True
+ )
+ name = models.CharField(max_length=200, help_text="Name of the stage")
+ order = models.PositiveIntegerField(
+ default=0, help_text="Order of the stage in the form"
+ )
+ is_predefined = models.BooleanField(
+ default=False, help_text="Whether this is a default resume stage"
+ )
+
+ class Meta:
+ ordering = ["order"]
+ verbose_name = "Form Stage"
+ verbose_name_plural = "Form Stages"
+
+ def __str__(self):
+ return f"{self.template.name} - {self.name}"
+
+ def clean(self):
+ if self.order < 0:
+ raise ValidationError("Order must be a positive integer")
+
+
+class FormField(Base):
+ """
+ Represents a single field within a form stage
+ """
+
+ FIELD_TYPES = [
+ ("text", "Text Input"),
+ ("email", "Email"),
+ ("phone", "Phone"),
+ ("textarea", "Text Area"),
+ ("file", "File Upload"),
+ ("date", "Date Picker"),
+ ("select", "Dropdown"),
+ ("radio", "Radio Buttons"),
+ ("checkbox", "Checkboxes"),
+ ]
+
+ stage = models.ForeignKey(
+ FormStage, on_delete=models.CASCADE, related_name="fields", db_index=True
+ )
+ label = models.CharField(max_length=200, help_text="Label for the field")
+ field_type = models.CharField(
+ max_length=20, choices=FIELD_TYPES, help_text="Type of the field"
+ )
+ placeholder = models.CharField(
+ max_length=200, blank=True, help_text="Placeholder text"
+ )
+ required = models.BooleanField(
+ default=False, help_text="Whether the field is required"
+ )
+ order = models.PositiveIntegerField(
+ default=0, help_text="Order of the field in the stage"
+ )
+ is_predefined = models.BooleanField(
+ default=False, help_text="Whether this is a default field"
+ )
+
+ # For selection fields (select, radio, checkbox)
+ options = models.JSONField(
+ default=list,
+ blank=True,
+ help_text="Options for selection fields (stored as JSON array)",
+ )
+
+ # For file upload fields
+ file_types = models.CharField(
+ max_length=200,
+ blank=True,
+ help_text="Allowed file types (comma-separated, e.g., '.pdf,.doc,.docx')",
+ )
+ max_file_size = models.PositiveIntegerField(
+ default=5, help_text="Maximum file size in MB (default: 5MB)"
+ )
+ multiple_files = models.BooleanField(
+ default=False, help_text="Allow multiple files to be uploaded"
+ )
+ max_files = models.PositiveIntegerField(
+ default=1,
+ help_text="Maximum number of files allowed (when multiple_files is True)",
+ )
+
+ class Meta:
+ ordering = ["order"]
+ verbose_name = "Form Field"
+ verbose_name_plural = "Form Fields"
+
+ def clean(self):
+ # Validate options for selection fields
+ if self.field_type in ["select", "radio", "checkbox"]:
+ if not isinstance(self.options, list):
+ raise ValidationError("Options must be a list for selection fields")
+ else:
+ # Clear options for non-selection fields
+ if self.options:
+ self.options = []
+
+ # Validate file settings for file fields
+ if self.field_type == "file":
+ if not self.file_types:
+ self.file_types = ".pdf,.doc,.docx"
+ if self.max_file_size <= 0:
+ raise ValidationError("Max file size must be greater than 0")
+ if self.multiple_files and self.max_files <= 0:
+ raise ValidationError(
+ "Max files must be greater than 0 when multiple files are allowed"
+ )
+ if not self.multiple_files:
+ self.max_files = 1
+ else:
+ # Clear file settings for non-file fields
+ self.file_types = ""
+ self.max_file_size = 0
+ self.multiple_files = False
+ self.max_files = 1
+
+ # Validate order
+ if self.order < 0:
+ raise ValidationError("Order must be a positive integer")
+
+ def __str__(self):
+ return f"{self.stage.template.name} - {self.stage.name} - {self.label}"
+
+
+class FormSubmission(Base):
+ """
+ Represents a completed form submission by an applicant
+ """
+
+ template = models.ForeignKey(
+ FormTemplate,
+ on_delete=models.CASCADE,
+ related_name="submissions",
+ db_index=True,
+ )
+ submitted_by = models.ForeignKey(
+ User,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="form_submissions",
+ db_index=True,
+ )
+ submitted_at = models.DateTimeField(db_index=True, auto_now_add=True) # Added index
+ applicant_name = models.CharField(
+ max_length=200, blank=True, help_text="Name of the applicant"
+ )
+ applicant_email = models.EmailField(
+ db_index=True, blank=True, help_text="Email of the applicant"
+ ) # Added index
+
+ class Meta:
+ ordering = ["-submitted_at"]
+ verbose_name = "Form Submission"
+ verbose_name_plural = "Form Submissions"
+ indexes = [
+ models.Index(fields=["submitted_at"]),
+ ]
+
+ def __str__(self):
+ return f"Submission for {self.template.name} - {self.submitted_at.strftime('%Y-%m-%d %H:%M')}"
+
+
+class FieldResponse(Base):
+ """
+ Represents a response to a specific field in a form submission
+ """
+
+ submission = models.ForeignKey(
+ FormSubmission,
+ on_delete=models.CASCADE,
+ related_name="responses",
+ db_index=True,
+ )
+ field = models.ForeignKey(
+ FormField, on_delete=models.CASCADE, related_name="responses", db_index=True
+ )
+
+ # Store the response value as JSON to handle different data types
+ value = models.JSONField(
+ null=True, blank=True, help_text="Response value (stored as JSON)"
+ )
+
+ # For file uploads, store the file path
+ uploaded_file = models.FileField(upload_to="form_uploads/", null=True, blank=True)
+
+ class Meta:
+ verbose_name = "Field Response"
+ verbose_name_plural = "Field Responses"
+ indexes = [
+ models.Index(fields=["submission"]),
+ models.Index(fields=["field"]),
+ ]
+
+ def __str__(self):
+ return f"Response to {self.field.label} in {self.submission}"
+
+ @property
+ def is_file(self):
+ if self.uploaded_file:
+ return True
+ return False
+
+ @property
+ def get_file(self):
+ if self.is_file:
+ return self.uploaded_file
+ return None
+
+ @property
+ def get_file_size(self):
+ if self.is_file:
+ return self.uploaded_file.size
+ return 0
+
+ @property
+ def display_value(self):
+ """Return a human-readable representation of the response value"""
+ if self.is_file:
+ return f"File: {self.uploaded_file.name}"
+ elif self.value is None:
+ return ""
+ elif isinstance(self.value, list):
+ return ", ".join(str(v) for v in self.value)
+ else:
+ return str(self.value)
+
+
+# Optional: Create a model for form templates that can be shared across organizations
+class SharedFormTemplate(Base):
+ """
+ Represents a form template that can be shared across different organizations/users
+ """
+
+ template = models.OneToOneField(FormTemplate, on_delete=models.CASCADE)
+ is_public = models.BooleanField(
+ default=False, help_text="Whether this template is publicly available"
+ )
+ shared_with = models.ManyToManyField(
+ User, blank=True, related_name="shared_templates"
+ )
+
+ class Meta:
+ verbose_name = "Shared Form Template"
+ verbose_name_plural = "Shared Form Templates"
+
+ def __str__(self):
+ return f"Shared: {self.template.name}"
+
+
+class Source(Base):
+ name = models.CharField(
+ max_length=100,
+ unique=True,
+ verbose_name=_("Source Name"),
+ help_text=_("e.g., ATS, ERP "),
+ )
+ source_type = models.CharField(
+ max_length=100, verbose_name=_("Source Type"), help_text=_("e.g., ATS, ERP ")
+ )
+ description = models.TextField(
+ blank=True,
+ verbose_name=_("Description"),
+ help_text=_("A description of the source"),
+ )
+ ip_address = models.GenericIPAddressField(
+ blank=True,
+ null=True,
+ verbose_name=_("IP Address"),
+ help_text=_("The IP address of the source"),
+ )
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ # Integration specific fields
+ api_key = models.CharField(
+ max_length=255,
+ blank=True,
+ null=True,
+ verbose_name=_("API Key"),
+ help_text=_("API key for authentication (will be encrypted)"),
+ )
+ api_secret = models.CharField(
+ max_length=255,
+ blank=True,
+ null=True,
+ verbose_name=_("API Secret"),
+ help_text=_("API secret for authentication (will be encrypted)"),
+ )
+ trusted_ips = models.TextField(
+ blank=True,
+ null=True,
+ verbose_name=_("Trusted IP Addresses"),
+ help_text=_("Comma-separated list of trusted IP addresses"),
+ )
+ is_active = models.BooleanField(
+ default=True,
+ verbose_name=_("Active"),
+ help_text=_("Whether this source is active for integration"),
+ )
+ integration_version = models.CharField(
+ max_length=50,
+ blank=True,
+ verbose_name=_("Integration Version"),
+ help_text=_("Version of the integration protocol"),
+ )
+ last_sync_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ verbose_name=_("Last Sync At"),
+ help_text=_("Timestamp of the last successful synchronization"),
+ )
+ sync_status = models.CharField(
+ max_length=20,
+ blank=True,
+ choices=[
+ ("IDLE", "Idle"),
+ ("SYNCING", "Syncing"),
+ ("ERROR", "Error"),
+ ("DISABLED", "Disabled"),
+ ],
+ default="IDLE",
+ verbose_name=_("Sync Status"),
+ )
+
+ # Outbound sync configuration
+ sync_endpoint = models.URLField(
+ blank=True,
+ null=True,
+ verbose_name=_("Sync Endpoint"),
+ help_text=_("Endpoint URL for sending candidate data (for outbound sync)"),
+ )
+ sync_method = models.CharField(
+ max_length=10,
+ blank=True,
+ choices=[
+ ("POST", "POST"),
+ ("PUT", "PUT"),
+ ],
+ default="POST",
+ verbose_name=_("Sync Method"),
+ help_text=_("HTTP method for outbound sync requests"),
+ )
+ test_method = models.CharField(
+ max_length=10,
+ blank=True,
+ choices=[
+ ("GET", "GET"),
+ ("POST", "POST"),
+ ],
+ default="GET",
+ verbose_name=_("Test Method"),
+ help_text=_("HTTP method for connection testing"),
+ )
+ custom_headers = models.TextField(
+ blank=True,
+ null=True,
+ verbose_name=_("Custom Headers"),
+ help_text=_("JSON object with custom HTTP headers for sync requests"),
+ )
+ supports_outbound_sync = models.BooleanField(
+ default=False,
+ verbose_name=_("Supports Outbound Sync"),
+ help_text=_("Whether this source supports receiving candidate data from ATS"),
+ )
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ verbose_name = _("Source")
+ verbose_name_plural = _("Sources")
+ ordering = ["name"]
+
+
+class IntegrationLog(Base):
+ """
+ Log all integration requests and responses for audit and debugging purposes
+ """
+
+ class ActionChoices(models.TextChoices):
+ REQUEST = "REQUEST", _("Request")
+ RESPONSE = "RESPONSE", _("Response")
+ ERROR = "ERROR", _("Error")
+ SYNC = "SYNC", _("Sync")
+ CREATE_JOB = "CREATE_JOB", _("Create Job")
+ UPDATE_JOB = "UPDATE_JOB", _("Update Job")
+
+ source = models.ForeignKey(
+ Source,
+ on_delete=models.CASCADE,
+ related_name="integration_logs",
+ verbose_name=_("Source"),
+ )
+ action = models.CharField(
+ max_length=20, choices=ActionChoices.choices, verbose_name=_("Action")
+ )
+ endpoint = models.CharField(max_length=255, blank=True, verbose_name=_("Endpoint"))
+ method = models.CharField(max_length=50, blank=True, verbose_name=_("HTTP Method"))
+ request_data = models.JSONField(
+ blank=True, null=True, verbose_name=_("Request Data")
+ )
+ response_data = models.JSONField(
+ blank=True, null=True, verbose_name=_("Response Data")
+ )
+ status_code = models.CharField(
+ max_length=10, blank=True, verbose_name=_("Status Code")
+ )
+ error_message = models.TextField(blank=True, verbose_name=_("Error Message"))
+ ip_address = models.GenericIPAddressField(verbose_name=_("IP Address"))
+ user_agent = models.CharField(
+ max_length=255, blank=True, verbose_name=_("User Agent")
+ )
+ processing_time = models.FloatField(
+ null=True, blank=True, verbose_name=_("Processing Time (seconds)")
+ )
+
+ def __str__(self):
+ return f"{self.source.name} - {self.action} - {self.created_at}"
+
+ class Meta:
+ ordering = ["-created_at"]
+ verbose_name = _("Integration Log")
+ verbose_name_plural = _("Integration Logs")
+
+ @property
+ def is_successful(self):
+ """Check if the integration action was successful"""
+ if self.action == self.ActionChoices.ERROR:
+ return False
+ if self.action == self.ActionChoices.REQUEST:
+ return True # Requests are always logged, success depends on response
+ if self.status_code and self.status_code.startswith("2"):
+ return True
+ return False
+
+
+class HiringAgency(Base):
+ user = models.OneToOneField(
+ User,
+ on_delete=models.CASCADE,
+ related_name="agency_profile",
+ verbose_name=_("User"),
+ null=True,
+ blank=True,
+ )
+ name = models.CharField(max_length=200, unique=True, verbose_name=_("Agency Name"))
+ contact_person = models.CharField(
+ max_length=150, blank=True, verbose_name=_("Contact Person")
+ )
+ email = models.EmailField(blank=True)
+ phone = models.CharField(max_length=20, blank=True)
+ website = models.URLField(blank=True)
+ notes = models.TextField(blank=True, help_text=_("Internal notes about the agency"))
+ country = CountryField(blank=True, null=True, blank_label=_("Select country"))
+ address = models.TextField(blank=True, null=True)
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ verbose_name = _("Hiring Agency")
+ verbose_name_plural = _("Hiring Agencies")
+ ordering = ["name"]
+
+
+class AgencyJobAssignment(Base):
+ """Assigns specific jobs to agencies with limits and deadlines"""
+
+ class AssignmentStatus(models.TextChoices):
+ ACTIVE = "ACTIVE", _("Active")
+ COMPLETED = "COMPLETED", _("Completed")
+ EXPIRED = "EXPIRED", _("Expired")
+ CANCELLED = "CANCELLED", _("Cancelled")
+
+ agency = models.ForeignKey(
+ HiringAgency,
+ on_delete=models.CASCADE,
+ related_name="job_assignments",
+ verbose_name=_("Agency"),
+ )
+ job = models.ForeignKey(
+ JobPosting,
+ on_delete=models.CASCADE,
+ related_name="agency_assignments",
+ verbose_name=_("Job"),
+ )
+
+ # Limits & Controls
+ max_candidates = models.PositiveIntegerField(
+ verbose_name=_("Maximum Candidates"),
+ help_text=_("Maximum candidates agency can submit for this job"),
+ )
+ candidates_submitted = models.PositiveIntegerField(
+ default=0,
+ verbose_name=_("Candidates Submitted"),
+ help_text=_("Number of candidates submitted so far"),
+ )
+
+ # Timeline
+ assigned_date = models.DateTimeField(
+ auto_now_add=True, verbose_name=_("Assigned Date")
+ )
+ deadline_date = models.DateTimeField(
+ verbose_name=_("Deadline Date"),
+ help_text=_("Deadline for agency to submit candidates"),
+ )
+
+ # Status & Extensions
+ is_active = models.BooleanField(default=True, verbose_name=_("Is Active"))
+ status = models.CharField(
+ max_length=20,
+ choices=AssignmentStatus.choices,
+ default=AssignmentStatus.ACTIVE,
+ verbose_name=_("Status"),
+ )
+
+ # Extension tracking
+ deadline_extended = models.BooleanField(
+ default=False, verbose_name=_("Deadline Extended")
+ )
+ original_deadline = models.DateTimeField(
+ null=True,
+ blank=True,
+ verbose_name=_("Original Deadline"),
+ help_text=_("Original deadline before extensions"),
+ )
+
+ # Admin notes
+ admin_notes = models.TextField(
+ blank=True,
+ verbose_name=_("Admin Notes"),
+ help_text=_("Internal notes about this assignment"),
+ )
+
+ class Meta:
+ verbose_name = _("Agency Job Assignment")
+ verbose_name_plural = _("Agency Job Assignments")
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["agency", "status"]),
+ models.Index(fields=["job", "status"]),
+ models.Index(fields=["deadline_date"]),
+ models.Index(fields=["is_active"]),
+ ]
+ unique_together = ["agency", "job"] # Prevent duplicate assignments
+
+ def __str__(self):
+ return f"{self.agency.name} - {self.job.title}"
+
+ @property
+ def days_remaining(self):
+ """Calculate days remaining until deadline"""
+ if not self.deadline_date:
+ return 0
+ delta = self.deadline_date.date() - timezone.now().date()
+ return max(0, delta.days)
+
+ @property
+ def is_currently_active(self):
+ """Check if assignment is currently active"""
+ return (
+ self.status == "ACTIVE"
+ and self.deadline_date
+ and self.deadline_date > timezone.now()
+ and self.candidates_submitted < self.max_candidates
+ )
+
+ @property
+ def can_submit(self):
+ """Check if candidates can still be submitted"""
+ return self.is_currently_active
+
+ def clean(self):
+ """Validate assignment constraints"""
+ if self.deadline_date and self.deadline_date <= timezone.now():
+ raise ValidationError(_("Deadline date must be in the future"))
+
+ if self.max_candidates <= 0:
+ raise ValidationError(_("Maximum candidates must be greater than 0"))
+
+ if self.candidates_submitted > self.max_candidates:
+ raise ValidationError(
+ _("Candidates submitted cannot exceed maximum candidates")
+ )
+
+ @property
+ def remaining_slots(self):
+ """Return number of remaining candidate slots"""
+ return max(0, self.max_candidates - self.candidates_submitted)
+
+ @property
+ def is_expired(self):
+ """Check if assignment has expired"""
+ return self.deadline_date and self.deadline_date <= timezone.now()
+
+ @property
+ def is_full(self):
+ """Check if assignment has reached maximum candidates"""
+ return self.candidates_submitted >= self.max_candidates
+
+ @property
+ def can_submit(self):
+ """Check if agency can still submit candidates"""
+ return (
+ self.is_active
+ and not self.is_expired
+ and not self.is_full
+ and self.status == self.AssignmentStatus.ACTIVE
+ )
+
+ def increment_submission_count(self):
+ """Safely increment the submitted candidates count"""
+ if self.can_submit:
+ self.candidates_submitted += 1
+ self.save(update_fields=["candidates_submitted"])
+
+ # Check if assignment is now complete
+ # if self.candidates_submitted >= self.max_candidates:
+ # self.status = self.AssignmentStatus.COMPLETED
+ # self.save(update_fields=['status'])
+ return True
+ return False
+
+ def extend_deadline(self, new_deadline):
+ """Extend the deadline for this assignment"""
+ # Convert database deadline to timezone-aware for comparison
+ deadline_aware = (
+ timezone.make_aware(self.deadline_date)
+ if timezone.is_naive(self.deadline_date)
+ else self.deadline_date
+ )
+ if new_deadline > deadline_aware:
+ if not self.deadline_extended:
+ self.original_deadline = self.deadline_date
+ self.deadline_extended = True
+ self.deadline_date = new_deadline
+ self.save(
+ update_fields=[
+ "deadline_date",
+ "original_deadline",
+ "deadline_extended",
+ ]
+ )
+ return True
+ return False
+
+
+class AgencyAccessLink(Base):
+ """Secure access links for agencies to submit candidates"""
+
+ assignment = models.OneToOneField(
+ AgencyJobAssignment,
+ on_delete=models.CASCADE,
+ related_name="access_link",
+ verbose_name=_("Assignment"),
+ )
+
+ # Security
+ unique_token = models.CharField(
+ max_length=64, unique=True, editable=False, verbose_name=_("Unique Token")
+ )
+ access_password = models.CharField(
+ max_length=32,
+ verbose_name=_("Access Password"),
+ help_text=_("Password for agency access"),
+ )
+
+ # Timeline
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
+ expires_at = models.DateTimeField(
+ verbose_name=_("Expires At"), help_text=_("When this access link expires")
+ )
+ last_accessed = models.DateTimeField(
+ null=True, blank=True, verbose_name=_("Last Accessed")
+ )
+
+ # Usage tracking
+ access_count = models.PositiveIntegerField(
+ default=0, verbose_name=_("Access Count")
+ )
+ is_active = models.BooleanField(default=True, verbose_name=_("Is Active"))
+
+ class Meta:
+ verbose_name = _("Agency Access Link")
+ verbose_name_plural = _("Agency Access Links")
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["unique_token"]),
+ models.Index(fields=["expires_at"]),
+ models.Index(fields=["is_active"]),
+ ]
+
+ def __str__(self):
+ return f"Access Link for {self.assignment}"
+
+ def clean(self):
+ """Validate access link constraints"""
+ if self.expires_at and self.expires_at <= timezone.now():
+ raise ValidationError(_("Expiration date must be in the future"))
+
+ @property
+ def is_expired(self):
+ """Check if access link has expired"""
+ return self.expires_at and self.expires_at <= timezone.now()
+
+ @property
+ def is_valid(self):
+ """Check if access link is valid and active"""
+ return self.is_active and not self.is_expired
+
+ def record_access(self):
+ """Record an access to this link"""
+ self.last_accessed = timezone.now()
+ self.access_count += 1
+ self.save(update_fields=["last_accessed", "access_count"])
+
+ def generate_token(self):
+ """Generate a unique secure token"""
+ import secrets
+
+ self.unique_token = secrets.token_urlsafe(48)
+
+ def generate_password(self):
+ """Generate a random password"""
+ import secrets
+ import string
+
+ alphabet = string.ascii_letters + string.digits
+ self.access_password = "".join(secrets.choice(alphabet) for _ in range(12))
+
+ def save(self, *args, **kwargs):
+ """Override save to generate token and password if not set"""
+ if not self.unique_token:
+ self.generate_token()
+ if not self.access_password:
+ self.generate_password()
+ super().save(*args, **kwargs)
+
+
+class BreakTime(models.Model):
+ """Model to store break times for a schedule"""
+
+ start_time = models.TimeField(verbose_name=_("Start Time"))
+ end_time = models.TimeField(verbose_name=_("End Time"))
+
+ def __str__(self):
+ return f"{self.start_time} - {self.end_time}"
+
+
+
+
+class Notification(models.Model):
+ """
+ Model to store system notifications, primarily for emails.
+ """
+
+ class NotificationType(models.TextChoices):
+ EMAIL = "email", _("Email")
+ IN_APP = "in_app", _("In-App") # For future expansion
+
+ class Status(models.TextChoices):
+ PENDING = "pending", _("Pending")
+ SENT = "sent", _("Sent")
+ READ = "read", _("Read")
+ FAILED = "failed", _("Failed")
+ RETRYING = "retrying", _("Retrying")
+
+ recipient = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ related_name="notifications",
+ verbose_name=_("Recipient"),
+ )
+ message = models.TextField(verbose_name=_("Notification Message"))
+ notification_type = models.CharField(
+ max_length=20,
+ choices=NotificationType.choices,
+ default=NotificationType.EMAIL,
+ verbose_name=_("Notification Type"),
+ )
+ status = models.CharField(
+ max_length=20,
+ choices=Status.choices,
+ default=Status.PENDING,
+ verbose_name=_("Status"),
+ )
+ inteview= models.ForeignKey(
+ 'InterviewSchedule',
+ on_delete=models.CASCADE,
+ related_name="notifications",
+ null=True,
+ blank=True,
+ verbose_name=_("Related Interview"),
+ )
+ scheduled_for = models.DateTimeField(
+ verbose_name=_("Scheduled Send Time"),
+ help_text=_("The date and time this notification is scheduled to be sent."),
+ )
+ 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"))
+
+ class Meta:
+ ordering = ["-scheduled_for", "-created_at"]
+ verbose_name = _("Notification")
+ verbose_name_plural = _("Notifications")
+ indexes = [
+ models.Index(fields=["status", "scheduled_for"]),
+ models.Index(fields=["recipient"]),
+ ]
+
+ def __str__(self):
+ return f"Notification for {self.recipient.get_username()} ({self.get_status_display()})"
+
+ def mark_as_sent(self):
+ self.status = Notification.Status.SENT
+ self.last_error = ""
+ self.save(update_fields=["status", "last_error"])
+
+ def mark_as_failed(self, error_message=""):
+ self.status = Notification.Status.FAILED
+ self.last_error = error_message
+ self.attempts += 1
+ self.save(update_fields=["status", "last_error", "attempts"])
+
+
+class Participants(Base):
+ """Model to store Participants details"""
+
+ name = models.CharField(
+ max_length=255, verbose_name=_("Participant Name"), null=True, blank=True
+ )
+ email = models.EmailField(verbose_name=_("Email"))
+ phone = models.CharField(
+ max_length=12, verbose_name=_("Phone Number"), null=True, blank=True
+ )
+ designation = models.CharField(
+ max_length=100, blank=True, verbose_name=_("Designation"), null=True
+ )
+
+ def __str__(self):
+ return f"{self.name} - {self.email}"
+
+
+class Message(Base):
+ """Model for messaging between different user types"""
+
+ class MessageType(models.TextChoices):
+ DIRECT = "direct", _("Direct Message")
+ JOB_RELATED = "job_related", _("Job Related")
+ SYSTEM = "system", _("System Notification")
+
+ sender = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ related_name="sent_messages",
+ verbose_name=_("Sender"),
+ )
+ recipient = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ related_name="received_messages",
+ null=True,
+ blank=True,
+ verbose_name=_("Recipient"),
+ )
+ job = models.ForeignKey(
+ JobPosting,
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ related_name="messages",
+ verbose_name=_("Related Job"),
+ )
+ subject = models.CharField(max_length=200, verbose_name=_("Subject"))
+ content = models.TextField(verbose_name=_("Message Content"))
+ message_type = models.CharField(
+ max_length=20,
+ choices=MessageType.choices,
+ default=MessageType.DIRECT,
+ verbose_name=_("Message Type"),
+ )
+ is_read = models.BooleanField(default=False, verbose_name=_("Is Read"))
+ read_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Read At"))
+
+ class Meta:
+ verbose_name = _("Message")
+ verbose_name_plural = _("Messages")
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["sender", "created_at"]),
+ models.Index(fields=["recipient", "is_read", "created_at"]),
+ models.Index(fields=["job", "created_at"]),
+ models.Index(fields=["message_type", "created_at"]),
+ ]
+
+ def __str__(self):
+ return f"Message from {self.sender.get_username()} to {self.recipient.get_username() if self.recipient else 'N/A'}"
+
+ def mark_as_read(self):
+ """Mark message as read and set read timestamp"""
+ if not self.is_read:
+ self.is_read = True
+ self.read_at = timezone.now()
+ self.save(update_fields=["is_read", "read_at"])
+
+ @property
+ def is_job_related(self):
+ """Check if message is related to a job"""
+ return self.job is not None
+
+ def get_auto_recipient(self):
+ """Get auto recipient based on job assignment"""
+ if self.job and self.job.assigned_to:
+ return self.job.assigned_to
+ return None
+
+ def clean(self):
+ """Validate message constraints"""
+ super().clean()
+
+ # For job-related messages, ensure recipient is assigned to the job
+ if self.job and not self.recipient:
+ if self.job.assigned_to:
+ self.recipient = self.job.assigned_to
+ else:
+ raise ValidationError(_("Job is not assigned to any user. Please assign the job first."))
+
+ # Validate sender can message this recipient based on user types
+ # if self.sender and self.recipient:
+ # self._validate_messaging_permissions()
+
+ def _validate_messaging_permissions(self):
+ """Validate if sender can message recipient based on user types"""
+ sender_type = self.sender.user_type
+ recipient_type = self.recipient.user_type
+
+ # Staff can message anyone
+ if sender_type == "staff":
+ return
+
+ # Agency users can only message staff or their own candidates
+ if sender_type == "agency":
+ if recipient_type not in ["staff", "candidate"]:
+ raise ValidationError(_("Agencies can only message staff or candidates."))
+
+ # If messaging a candidate, ensure candidate is from their agency
+ if recipient_type == "candidate" and self.job:
+ if not self.job.hiring_agency.filter(user=self.sender).exists():
+ raise ValidationError(_("You can only message candidates from your assigned jobs."))
+
+ # Candidate users can only message staff
+ if sender_type == "candidate":
+ if recipient_type != "staff":
+ raise ValidationError(_("Candidates can only message staff."))
+
+ # If job-related, ensure candidate applied for the job
+ if self.job:
+ 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):
+ """Override save to handle auto-recipient logic"""
+ self.clean()
+ super().save(*args, **kwargs)
+
+
+class Document(Base):
+ """Model for storing documents using Generic Foreign Key"""
+
+ class DocumentType(models.TextChoices):
+ RESUME = "resume", _("Resume")
+ COVER_LETTER = "cover_letter", _("Cover Letter")
+ CERTIFICATE = "certificate", _("Certificate")
+ ID_DOCUMENT = "id_document", _("ID Document")
+ PASSPORT = "passport", _("Passport")
+ EDUCATION = "education", _("Education Document")
+ EXPERIENCE = "experience", _("Experience Letter")
+ OTHER = "other", _("Other")
+
+ # Generic Foreign Key fields
+ content_type = models.ForeignKey(
+ ContentType,
+ on_delete=models.CASCADE,
+ verbose_name=_("Content Type"),
+ )
+ object_id = models.PositiveIntegerField(
+ verbose_name=_("Object ID"),
+ )
+ content_object = GenericForeignKey('content_type', 'object_id')
+
+ file = models.FileField(
+ upload_to="documents/%Y/%m/",
+ verbose_name=_("Document File"),
+ validators=[validate_image_size],
+ )
+ document_type = models.CharField(
+ max_length=20,
+ choices=DocumentType.choices,
+ default=DocumentType.OTHER,
+ verbose_name=_("Document Type"),
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True,
+ verbose_name=_("Description"),
+ )
+ uploaded_by = models.ForeignKey(
+ User,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ verbose_name=_("Uploaded By"),
+ )
+
+ class Meta:
+ verbose_name = _("Document")
+ verbose_name_plural = _("Documents")
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["content_type", "object_id", "document_type", "created_at"]),
+ ]
+
+ def __str__(self):
+ try:
+ if hasattr(self.content_object, 'full_name'):
+ object_name = self.content_object.full_name
+ elif hasattr(self.content_object, 'title'):
+ object_name = self.content_object.title
+ elif hasattr(self.content_object, '__str__'):
+ object_name = str(self.content_object)
+ else:
+ object_name = f"Object {self.object_id}"
+ return f"{self.get_document_type_display()} - {object_name}"
+ except:
+ return f"{self.get_document_type_display()} - {self.object_id}"
+
+ @property
+ def file_size(self):
+ """Return file size in human readable format"""
+ if self.file:
+ size = self.file.size
+ if size < 1024:
+ return f"{size} bytes"
+ elif size < 1024 * 1024:
+ return f"{size / 1024:.1f} KB"
+ else:
+ return f"{size / (1024 * 1024):.1f} MB"
+ return "0 bytes"
+
+ @property
+ def file_extension(self):
+ """Return file extension"""
+ 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/views.py b/recruitment/views.py
index 65b5ceb..67efa73 100644
--- a/recruitment/views.py
+++ b/recruitment/views.py
@@ -5784,6 +5784,7 @@ class MeetingListView(ListView):
paginate_by = 100
def get_queryset(self):
+ print("hoo")
# Start with a base queryset, ensuring an InterviewLocation link exists.
queryset = super().get_queryset().filter(interview_location__isnull=False).select_related(
'interview_location',
diff --git a/templates/jobs/job_detail.html b/templates/jobs/job_detail.html
index 4c272b6..d480b04 100644
--- a/templates/jobs/job_detail.html
+++ b/templates/jobs/job_detail.html
@@ -326,15 +326,15 @@
{% trans "Manage Applicants" %}
- {% if not job.zip_created%}
+
- {% trans "Download All CVs" %}
+ {% trans "Generate All CVs" %}
- {% else %}
-
+
+
{% trans "View All CVs" %}
- {% endif %}
+