1050 lines
35 KiB
Python
1050 lines
35 KiB
Python
from django.db import models
|
|
from django.utils import timezone
|
|
from .validators import validate_hash_tags, validate_image_size
|
|
from django.contrib.auth.models import User
|
|
from django.core.validators import URLValidator
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django_extensions.db.fields import RandomCharField
|
|
from django.core.exceptions import ValidationError
|
|
from django_countries.fields import CountryField
|
|
from django.urls import reverse
|
|
# from ckeditor.fields import RichTextField
|
|
from django_ckeditor_5.fields import CKEditor5Field
|
|
|
|
|
|
|
|
class Profile(models.Model):
|
|
profile_image = models.ImageField(null=True, blank=True, upload_to="profile_pic/")
|
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
|
|
|
|
|
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
|
|
|
|
|
|
# # Create your models here.
|
|
# class Job(Base):
|
|
# title = models.CharField(max_length=255, verbose_name=_('Title'))
|
|
# description_en = models.TextField(verbose_name=_('Description English'))
|
|
# description_ar = models.TextField(verbose_name=_('Description Arabic'))
|
|
# is_published = models.BooleanField(default=False, verbose_name=_('Published'))
|
|
# posted_to_linkedin = models.BooleanField(default=False, verbose_name=_('Posted to LinkedIn'))
|
|
# created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at'))
|
|
# updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated at'))
|
|
|
|
# class Meta:
|
|
# verbose_name = _('Job')
|
|
# verbose_name_plural = _('Jobs')
|
|
|
|
# def __str__(self):
|
|
# return self.title
|
|
|
|
|
|
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"),
|
|
]
|
|
|
|
# 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_start_date=models.DateField(null=True, blank=True)
|
|
application_deadline = models.DateField(null=True, blank=True)
|
|
application_instructions =CKEditor5Field(
|
|
blank=True, null=True,config_name='extends'
|
|
)
|
|
|
|
# Internal Tracking
|
|
internal_job_id = models.CharField(max_length=50, primary_key=True, 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(
|
|
max_length=20, choices=STATUS_CHOICES, default="DRAFT"
|
|
)
|
|
|
|
# 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)
|
|
|
|
published_at = models.DateTimeField(null=True, blank=True)
|
|
# 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"
|
|
)
|
|
joining_date = models.DateField(null=True, blank=True, help_text="Desired start date")
|
|
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.",
|
|
)
|
|
|
|
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)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
verbose_name = "Job Posting"
|
|
verbose_name_plural = "Job Postings"
|
|
|
|
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):
|
|
# Generate unique internal job ID if not exists
|
|
if not self.internal_job_id:
|
|
prefix = "KAAUH"
|
|
year = timezone.now().year
|
|
# Get next sequential number
|
|
last_job = (
|
|
JobPosting.objects.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"
|
|
|
|
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()
|
|
|
|
|
|
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 Candidate(Base):
|
|
class Stage(models.TextChoices):
|
|
APPLIED = "Applied", _("Applied")
|
|
EXAM = "Exam", _("Exam")
|
|
INTERVIEW = "Interview", _("Interview")
|
|
OFFER = "Offer", _("Offer")
|
|
|
|
class ExamStatus(models.TextChoices):
|
|
PASSED = "Passed", _("Passed")
|
|
FAILED = "Failed", _("Failed")
|
|
|
|
class Status(models.TextChoices):
|
|
ACCEPTED = "Accepted", _("Accepted")
|
|
REJECTED = "Rejected", _("Rejected")
|
|
|
|
class ApplicantType(models.TextChoices):
|
|
APPLICANT = "Applicant", _("Applicant")
|
|
CANDIDATE = "Candidate", _("Candidate")
|
|
|
|
# Stage transition validation constants
|
|
# STAGE_SEQUENCE = {
|
|
# "Applied": ["Exam", "Interview", "Offer"],
|
|
# "Exam": ["Interview", "Offer"],
|
|
# "Interview": ["Offer"],
|
|
# "Offer": [], # Final stage - no further transitions
|
|
# }
|
|
|
|
job = models.ForeignKey(
|
|
JobPosting,
|
|
on_delete=models.CASCADE,
|
|
related_name="candidates",
|
|
verbose_name=_("Job"),
|
|
)
|
|
first_name = models.CharField(max_length=255, verbose_name=_("First Name"))
|
|
last_name = models.CharField(max_length=255, verbose_name=_("Last Name"))
|
|
email = models.EmailField(verbose_name=_("Email"))
|
|
phone = models.CharField(max_length=20, verbose_name=_("Phone"))
|
|
address = models.TextField(max_length=200, verbose_name=_("Address"))
|
|
resume = models.FileField(upload_to="resumes/", verbose_name=_("Resume"))
|
|
is_resume_parsed = models.BooleanField(
|
|
default=False, verbose_name=_("Resume Parsed")
|
|
)
|
|
is_potential_candidate = models.BooleanField(
|
|
default=False, verbose_name=_("Potential Candidate")
|
|
)
|
|
parsed_summary = models.TextField(blank=True, verbose_name=_("Parsed Summary"))
|
|
applied = models.BooleanField(default=False, verbose_name=_("Applied"))
|
|
stage = models.CharField(
|
|
max_length=100,
|
|
default="Applied",
|
|
choices=Stage.choices,
|
|
verbose_name=_("Stage"),
|
|
)
|
|
applicant_status = models.CharField(
|
|
choices=ApplicantType.choices,
|
|
default="Applicant",
|
|
max_length=100,
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Applicant Status"),
|
|
)
|
|
exam_date = models.DateTimeField(null=True, blank=True, verbose_name=_("Exam Date"))
|
|
exam_status = models.CharField(
|
|
choices=ExamStatus.choices,
|
|
max_length=100,
|
|
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=Status.choices,
|
|
max_length=100,
|
|
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=Status.choices,
|
|
max_length=100,
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Offer Status"),
|
|
)
|
|
join_date = models.DateField(null=True, blank=True, verbose_name=_("Join Date"))
|
|
|
|
# Scoring fields (populated by signal)
|
|
match_score = models.IntegerField(null=True, blank=True)
|
|
strengths = models.TextField(blank=True)
|
|
weaknesses = models.TextField(blank=True)
|
|
criteria_checklist = models.JSONField(default=dict, blank=True)
|
|
|
|
submitted_by_agency = models.ForeignKey(
|
|
"HiringAgency",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="submitted_candidates",
|
|
verbose_name=_("Submitted by Agency"),
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Candidate")
|
|
verbose_name_plural = _("Candidates")
|
|
|
|
@property
|
|
def name(self):
|
|
return f"{self.first_name} {self.last_name}"
|
|
|
|
@property
|
|
def full_name(self):
|
|
return self.name
|
|
|
|
@property
|
|
def get_file_size(self):
|
|
if self.resume:
|
|
return self.resume.size
|
|
return 0
|
|
|
|
# def clean(self):
|
|
# """Validate stage transitions"""
|
|
# # Only validate if this is an existing record (not being created)
|
|
# if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
|
|
# old_stage = self.__class__.objects.get(pk=self.pk).stage
|
|
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
|
|
|
# if self.stage not in allowed_next_stages:
|
|
# raise ValidationError(
|
|
# {
|
|
# "stage": f'Cannot transition from "{old_stage}" to "{self.stage}". '
|
|
# f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}"
|
|
# }
|
|
# )
|
|
|
|
# # Validate that the stage is a valid choice
|
|
# if self.stage not in [choice[0] for choice in self.Stage.choices]:
|
|
# raise ValidationError(
|
|
# {
|
|
# "stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}"
|
|
# }
|
|
# )
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""Override save to ensure validation is called"""
|
|
self.clean() # Call validation before saving
|
|
super().save(*args, **kwargs)
|
|
|
|
# def can_transition_to(self, new_stage):
|
|
# """Check if a stage transition is allowed"""
|
|
# if not self.pk: # New record - can be in Applied stage
|
|
# return new_stage == "Applied"
|
|
|
|
# old_stage = self.__class__.objects.get(pk=self.pk).stage
|
|
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
|
# return new_stage in allowed_next_stages
|
|
|
|
# def get_available_stages(self):
|
|
# """Get list of stages this candidate 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, [])
|
|
|
|
@property
|
|
def submission(self):
|
|
return FormSubmission.objects.filter(template__job=self.job).first()
|
|
@property
|
|
def responses(self):
|
|
if self.submission:
|
|
return self.submission.responses.all()
|
|
return []
|
|
def __str__(self):
|
|
return self.full_name
|
|
|
|
@property
|
|
def get_meetings(self):
|
|
return self.scheduled_interviews.all()
|
|
@property
|
|
def get_latest_meeting(self):
|
|
schedule = self.scheduled_interviews.order_by('-created_at').first()
|
|
if schedule:
|
|
return schedule.zoom_meeting
|
|
return None
|
|
|
|
@property
|
|
def has_future_meeting(self):
|
|
"""
|
|
Checks if the candidate has any scheduled interviews for a future date/time.
|
|
"""
|
|
# Ensure timezone.now() is used for comparison
|
|
now = timezone.now()
|
|
# Check if any related ScheduledInterview has a future interview_date and interview_time
|
|
# We need to combine date and time for a proper datetime comparison if they are separate fields
|
|
future_meetings = self.scheduled_interviews.filter(
|
|
interview_date__gt=now.date()
|
|
).filter(
|
|
interview_time__gte=now.time()
|
|
).exists()
|
|
|
|
# Also check for interviews happening later today
|
|
today_future_meetings = self.scheduled_interviews.filter(
|
|
interview_date=now.date(),
|
|
interview_time__gte=now.time()
|
|
).exists()
|
|
|
|
return future_meetings or today_future_meetings
|
|
|
|
|
|
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 ZoomMeeting(Base):
|
|
class MeetingStatus(models.TextChoices):
|
|
SCHEDULED = "scheduled", _("Scheduled")
|
|
STARTED = "started", _("Started")
|
|
ENDED = "ended", _("Ended")
|
|
# Basic meeting details
|
|
topic = models.CharField(max_length=255, verbose_name=_("Topic"))
|
|
meeting_id = models.CharField(
|
|
max_length=20, unique=True, verbose_name=_("Meeting ID")
|
|
) # Unique identifier for the meeting
|
|
start_time = models.DateTimeField(verbose_name=_("Start Time"))
|
|
duration = models.PositiveIntegerField(
|
|
verbose_name=_("Duration")
|
|
) # Duration in minutes
|
|
timezone = models.CharField(max_length=50, verbose_name=_("Timezone"))
|
|
join_url = models.URLField(
|
|
verbose_name=_("Join URL")
|
|
) # URL for participants to join
|
|
participant_video = models.BooleanField(
|
|
default=True, verbose_name=_("Participant Video")
|
|
)
|
|
password = models.CharField(
|
|
max_length=20, blank=True, null=True, verbose_name=_("Password")
|
|
)
|
|
join_before_host = models.BooleanField(
|
|
default=False, verbose_name=_("Join Before Host")
|
|
)
|
|
mute_upon_entry = models.BooleanField(
|
|
default=False, verbose_name=_("Mute Upon Entry")
|
|
)
|
|
waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room"))
|
|
|
|
zoom_gateway_response = models.JSONField(
|
|
blank=True, null=True, verbose_name=_("Zoom Gateway Response")
|
|
)
|
|
status = models.CharField(
|
|
max_length=20,
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Status"),
|
|
)
|
|
# Timestamps
|
|
|
|
def __str__(self):
|
|
return self.topic
|
|
|
|
|
|
class FormTemplate(Base):
|
|
"""
|
|
Represents a complete form template with multiple stages
|
|
"""
|
|
|
|
job = models.OneToOneField(
|
|
JobPosting, on_delete=models.CASCADE, related_name="form_template"
|
|
)
|
|
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
|
|
)
|
|
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"
|
|
|
|
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"
|
|
)
|
|
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"
|
|
)
|
|
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"
|
|
)
|
|
submitted_by = models.ForeignKey(
|
|
User,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="form_submissions",
|
|
)
|
|
submitted_at = models.DateTimeField(auto_now_add=True)
|
|
applicant_name = models.CharField(
|
|
max_length=200, blank=True, help_text="Name of the applicant"
|
|
)
|
|
applicant_email = models.EmailField(blank=True, help_text="Email of the applicant")
|
|
|
|
class Meta:
|
|
ordering = ["-submitted_at"]
|
|
verbose_name = "Form Submission"
|
|
verbose_name_plural = "Form Submissions"
|
|
|
|
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"
|
|
)
|
|
field = models.ForeignKey(
|
|
FormField, on_delete=models.CASCADE, related_name="responses"
|
|
)
|
|
|
|
# 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"
|
|
|
|
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"),
|
|
)
|
|
|
|
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=10, 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):
|
|
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 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 InterviewSchedule(Base):
|
|
"""Stores the scheduling criteria for interviews"""
|
|
|
|
job = models.ForeignKey(
|
|
JobPosting, on_delete=models.CASCADE, related_name="interview_schedules"
|
|
)
|
|
candidates = models.ManyToManyField(Candidate, related_name="interview_schedules")
|
|
start_date = models.DateField(verbose_name=_("Start Date"))
|
|
end_date = models.DateField(verbose_name=_("End Date"))
|
|
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"))
|
|
|
|
breaks = models.JSONField(default=list, blank=True, verbose_name=_('Break Times'))
|
|
|
|
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)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
def __str__(self):
|
|
return f"Interview Schedule for {self.job.title}"
|
|
|
|
|
|
class ScheduledInterview(Base):
|
|
"""Stores individual scheduled interviews"""
|
|
|
|
candidate = models.ForeignKey(
|
|
Candidate,
|
|
on_delete=models.CASCADE,
|
|
related_name="scheduled_interviews",
|
|
)
|
|
job = models.ForeignKey(
|
|
"JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews"
|
|
)
|
|
zoom_meeting = models.OneToOneField(
|
|
ZoomMeeting, on_delete=models.CASCADE, related_name="interview"
|
|
)
|
|
schedule = models.ForeignKey(
|
|
InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True
|
|
)
|
|
interview_date = models.DateField(verbose_name=_("Interview Date"))
|
|
interview_time = models.TimeField(verbose_name=_("Interview Time"))
|
|
status = models.CharField(
|
|
max_length=20,
|
|
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.candidate.name} for {self.job.title}"
|