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 from django.urls import reverse 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 = 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",null=True, blank=True) 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 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: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 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 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(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') 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(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") 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 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(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 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')) 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) 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') 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' ) def __str__(self): return f"Interview with {self.candidate.name} for {self.job.title}"