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 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") 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 = "UNIV" 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')) 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 Form(models.Model): title = models.CharField(max_length=200) description = models.TextField(blank=True) structure = models.JSONField(default=dict) # Stores the form schema created_by = models.ForeignKey(User, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) is_active = models.BooleanField(default=True) class Meta: ordering = ['-created_at'] def __str__(self): return self.title class FormSubmission(models.Model): form = models.ForeignKey(Form, on_delete=models.CASCADE, related_name='submissions') submission_data = models.JSONField(default=dict) # Stores form responses submitted_at = models.DateTimeField(auto_now_add=True) ip_address = models.GenericIPAddressField(null=True, blank=True) user_agent = models.TextField(blank=True) class Meta: ordering = ['-submitted_at'] class UploadedFile(models.Model): submission = models.ForeignKey(FormSubmission, on_delete=models.CASCADE, related_name='files') field_id = models.CharField(max_length=100) file = models.FileField(upload_to='form_uploads/%Y/%m/%d/') original_filename = models.CharField(max_length=255) uploaded_at = models.DateTimeField(auto_now_add=True)