2025-10-06 16:23:40 +03:00

548 lines
22 KiB
Python

from django.db import models
from django.utils import timezone
from .validators import validate_hash_tags
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
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='United States')
# Job Details
description = models.TextField(help_text="Full job description including responsibilities and requirements")
qualifications = models.TextField(blank=True, help_text="Required qualifications and skills")
salary_range = models.CharField(max_length=200, blank=True, help_text="e.g., $60,000 - $80,000")
benefits = models.TextField(blank=True, help_text="Benefits offered")
# Application Information
application_url = models.URLField(validators=[URLValidator()], help_text="URL where candidates apply")
application_deadline = models.DateField(null=True, blank=True)
application_instructions = models.TextField(blank=True, help_text="Special instructions for applicants")
# 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'),
('PUBLISHED', 'Published'),
('CLOSED', 'Closed'),
('ARCHIVED', 'Archived'),
]
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='DRAFT',null=True, blank=True)
#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")
start_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")
)
class Meta:
ordering = ['-created_at']
verbose_name = "Job Posting"
verbose_name_plural = "Job Postings"
def __str__(self):
return f"{self.title} - {self.get_status_display()}"
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:04d}"
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 and self.location_country != 'United States':
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
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')
# 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'))
resume = models.FileField(upload_to='resumes/', verbose_name=_('Resume'))
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'))
exam_date = models.DateField(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.DateField(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
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, [])
def __str__(self):
return self.full_name
class TrainingMaterial(Base):
title = models.CharField(max_length=255, verbose_name=_('Title'))
content = models.TextField(blank=True, verbose_name=_('Content'))
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):
# 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'))
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'))
# Timestamps
def __str__(self):
return self.topic
class FormTemplate(models.Model):
"""
Represents a complete form template with multiple stages
"""
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')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_active = models.BooleanField(default=True, 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(models.Model):
"""
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(models.Model):
"""
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)"
)
class Meta:
ordering = ['order']
verbose_name = 'Form Field'
verbose_name_plural = 'Form Fields'
def __str__(self):
return f"{self.stage.name} - {self.label}"
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")
else:
# Clear file settings for non-file fields
self.file_types = ''
self.max_file_size = 0
if self.order < 0:
raise ValidationError("Order must be a positive integer")
class FormSubmission(models.Model):
"""
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(models.Model):
"""
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 display_value(self):
"""Return a human-readable representation of the response value"""
if self.uploaded_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(models.Model):
"""
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')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = 'Shared Form Template'
verbose_name_plural = 'Shared Form Templates'
def __str__(self):
return f"Shared: {self.template.name}"
class Source(models.Model):
name = models.CharField(
max_length=100,
unique=True,
verbose_name=_('Source Name'),
help_text=_("e.g., ATS, ERP ")
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class Meta:
verbose_name = _('Source')
verbose_name_plural = _('Sources')
ordering = ['name']
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']