39 KiB
39 KiB
KAAUH Applicant Tracking System (ATS) - Low Level Design Document
1. Introduction
This document provides the Low-Level Design (LLD) for the KAAUH Applicant Tracking System (ATS). It details the technical specifications, database schema, API endpoints, and implementation details for the system.
2. Database Design
2.1 Entity-Relationship Diagram (ERD)
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ JobPosting │ │ Candidate │ │ ZoomMeeting │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ id (PK) │ │ id (PK) │ │ id (PK) │
│ slug │ │ slug │ │ slug │
│ title │ │ first_name │ │ topic │
│ department │ │ last_name │ │ meeting_id │
│ job_type │ │ email │ │ start_time │
│ workplace_type │ │ phone │ │ duration │
│ location_city │ │ address │ │ timezone │
│ location_state │ │ resume │ │ join_url │
│ location_country│ │ is_resume_parsed│ │ password │
│ description │ │ stage │ │ status │
│ qualifications │ │ exam_status │ │ created_at │
│ salary_range │ │ interview_status│ │ updated_at │
│ benefits │ │ offer_status │ │ │
│ application_url │ │ match_score │ │ │
│ status │ │ strengths │ │ │
│ created_by │ │ weaknesses │ │ │
│ created_at │ │ job (FK) │ │ │
│ updated_at │ │ │ │ │
│ │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
│
┌─────────────────┐
│ ScheduledInterview │
├─────────────────┤
│ id (PK) │
│ candidate (FK) │
│ job (FK) │
│ zoom_meeting (FK)│
│ interview_date │
│ interview_time │
│ status │
│ created_at │
│ updated_at │
└─────────────────┘
│
┌─────────────────┐
│ FormTemplate │
├─────────────────┤
│ id (PK) │
│ slug │
│ name │
│ description │
│ job (FK) │
│ is_active │
│ created_by (FK) │
│ created_at │
│ updated_at │
└─────────────────┘
│
┌─────────────────┐
│ FormStage │
├─────────────────┤
│ id (PK) │
│ name │
│ order │
│ is_predefined │
│ template (FK) │
│ created_at │
│ updated_at │
└─────────────────┘
│
┌─────────────────┐
│ FormField │
├─────────────────┤
│ id (PK) │
│ label │
│ field_type │
│ placeholder │
│ required │
│ order │
│ options │
│ file_types │
│ max_file_size │
│ stage (FK) │
│ created_at │
│ updated_at │
└─────────────────┘
│
┌─────────────────┐
│ FormSubmission │
├─────────────────┤
│ id (PK) │
│ slug │
│ template (FK) │
│ submitted_by (FK)│
│ submitted_at │
│ applicant_name │
│ applicant_email │
└─────────────────┘
│
┌─────────────────┐
│ FieldResponse │
├─────────────────┤
│ id (PK) │
│ submission (FK) │
│ field (FK) │
│ value │
│ uploaded_file │
│ created_at │
│ updated_at │
└─────────────────┘
│
┌─────────────────┐
│ MeetingComment │
├─────────────────┤
│ id (PK) │
│ meeting (FK) │
│ author (FK) │
│ content │
│ created_at │
│ updated_at │
└─────────────────┘
2.2 Detailed Schema Definitions
2.2.1 Base Model
class Base(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
slug = RandomCharField(length=8, unique=True, editable=False)
2.2.2 JobPosting Model
class JobPosting(Base):
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"),
]
STATUS_CHOICES = [
("DRAFT", "Draft"),
("ACTIVE", "Active"),
("CLOSED", "Closed"),
("CANCELLED", "Cancelled"),
("ARCHIVED", "Archived"),
]
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_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")
description = CKEditor5Field(config_name='extends')
qualifications = CKEditor5Field(blank=True, config_name='extends')
salary_range = models.CharField(max_length=200, blank=True)
benefits = CKEditor5Field(blank=True, config_name='extends')
application_url = models.URLField(blank=True)
application_start_date = models.DateField(null=True, blank=True)
application_deadline = models.DateField(null=True, blank=True)
application_instructions = CKEditor5Field(blank=True, config_name='extends')
internal_job_id = models.CharField(max_length=50, primary_key=True, editable=False)
created_by = models.CharField(max_length=100, blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="DRAFT")
hash_tags = models.CharField(max_length=200, blank=True, validators=[validate_hash_tags])
linkedin_post_id = models.CharField(max_length=200, blank=True)
linkedin_post_url = models.URLField(blank=True)
posted_to_linkedin = models.BooleanField(default=False)
linkedin_post_status = models.CharField(max_length=50, blank=True)
linkedin_posted_at = models.DateTimeField(null=True, blank=True)
published_at = models.DateTimeField(null=True, blank=True)
position_number = models.CharField(max_length=50, blank=True)
reporting_to = models.CharField(max_length=100, blank=True)
joining_date = models.DateField(null=True, blank=True)
open_positions = models.PositiveIntegerField(default=1)
source = models.ForeignKey("Source", on_delete=models.SET_NULL, null=True, blank=True)
max_applications = models.PositiveIntegerField(default=1000)
hiring_agency = models.ManyToManyField("HiringAgency", blank=True)
cancel_reason = models.TextField(blank=True)
cancelled_by = models.CharField(max_length=100, blank=True)
cancelled_at = models.DateTimeField(null=True, blank=True)
2.2.3 Candidate Model
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_SEQUENCE = {
"Applied": ["Exam", "Interview", "Offer"],
"Exam": ["Interview", "Offer"],
"Interview": ["Offer"],
"Offer": [],
}
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="candidates")
first_name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255)
email = models.EmailField()
phone = models.CharField(max_length=20)
address = models.TextField(max_length=200)
resume = models.FileField(upload_to="resumes/")
is_resume_parsed = models.BooleanField(default=False)
is_potential_candidate = models.BooleanField(default=False)
parsed_summary = models.TextField(blank=True)
applied = models.BooleanField(default=False)
stage = models.CharField(max_length=100, default="Applied", choices=Stage.choices)
applicant_status = models.CharField(max_length=100, default="Applicant", choices=ApplicantType.choices)
exam_date = models.DateTimeField(null=True, blank=True)
exam_status = models.CharField(max_length=100, null=True, blank=True, choices=ExamStatus.choices)
interview_date = models.DateTimeField(null=True, blank=True)
interview_status = models.CharField(max_length=100, null=True, blank=True, choices=Status.choices)
offer_date = models.DateField(null=True, blank=True)
offer_status = models.CharField(max_length=100, null=True, blank=True, choices=Status.choices)
join_date = models.DateField(null=True, blank=True)
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)
resume_parsed_category = models.TextField(blank=True)
submitted_by_agency = models.ForeignKey("HiringAgency", on_delete=models.SET_NULL, null=True, blank=True)
2.2.4 ZoomMeeting Model
class ZoomMeeting(Base):
class MeetingStatus(models.TextChoices):
SCHEDULED = "waiting", _("Waiting")
STARTED = "started", _("Started")
ENDED = "ended", _("Ended")
CANCELLED = "cancelled", _("Cancelled")
topic = models.CharField(max_length=255)
meeting_id = models.CharField(max_length=20, unique=True)
start_time = models.DateTimeField()
duration = models.PositiveIntegerField()
timezone = models.CharField(max_length=50)
join_url = models.URLField()
participant_video = models.BooleanField(default=True)
password = models.CharField(max_length=20, blank=True, null=True)
join_before_host = models.BooleanField(default=False)
mute_upon_entry = models.BooleanField(default=False)
waiting_room = models.BooleanField(default=False)
zoom_gateway_response = models.JSONField(blank=True, null=True)
status = models.CharField(max_length=20, null=True, blank=True)
2.2.5 FormTemplate Model
class FormTemplate(Base):
job = models.OneToOneField(JobPosting, on_delete=models.CASCADE, related_name="form_template")
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name="form_templates", null=True, blank=True)
is_active = models.BooleanField(default=False)
2.2.6 FormStage Model
class FormStage(Base):
template = models.ForeignKey(FormTemplate, on_delete=models.CASCADE, related_name="stages")
name = models.CharField(max_length=200)
order = models.PositiveIntegerField(default=0)
is_predefined = models.BooleanField(default=False)
2.2.7 FormField Model
class FormField(Base):
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)
field_type = models.CharField(max_length=20, choices=FIELD_TYPES)
placeholder = models.CharField(max_length=200, blank=True)
required = models.BooleanField(default=False)
order = models.PositiveIntegerField(default=0)
is_predefined = models.BooleanField(default=False)
options = models.JSONField(default=list, blank=True)
file_types = models.CharField(max_length=200, blank=True)
max_file_size = models.PositiveIntegerField(default=5)
multiple_files = models.BooleanField(default=False)
max_files = models.PositiveIntegerField(default=1)
2.2.8 FormSubmission Model
class FormSubmission(Base):
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)
applicant_email = models.EmailField(blank=True)
2.2.9 FieldResponse Model
class FieldResponse(Base):
submission = models.ForeignKey(FormSubmission, on_delete=models.CASCADE, related_name="responses")
field = models.ForeignKey(FormField, on_delete=models.CASCADE, related_name="responses")
value = models.JSONField(null=True, blank=True)
uploaded_file = models.FileField(upload_to="form_uploads/", null=True, blank=True)
2.2.10 ScheduledInterview Model
class ScheduledInterview(Base):
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()
interview_time = models.TimeField()
status = models.CharField(max_length=20, choices=[
("scheduled", "Scheduled"),
("confirmed", "Confirmed"),
("cancelled", "Cancelled"),
("completed", "Completed"),
], default="scheduled")
2.2.11 InterviewSchedule Model
class InterviewSchedule(Base):
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="interview_schedules")
candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True, null=True)
start_date = models.DateField()
end_date = models.DateField()
working_days = models.JSONField()
start_time = models.TimeField()
end_time = models.TimeField()
break_start_time = models.TimeField(null=True, blank=True)
break_end_time = models.TimeField(null=True, blank=True)
interview_duration = models.PositiveIntegerField()
buffer_time = models.PositiveIntegerField(default=0)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
2.2.12 MeetingComment Model
class MeetingComment(Base):
meeting = models.ForeignKey(ZoomMeeting, on_delete=models.CASCADE, related_name="comments")
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="meeting_comments")
content = CKEditor5Field(config_name='extends')
3. API Design
3.1 REST API Endpoints
3.1.1 Job Posting Endpoints
GET /api/jobs/ # List all job postings
POST /api/jobs/ # Create new job posting
GET /api/jobs/{id}/ # Get specific job posting
PUT /api/jobs/{id}/ # Update job posting
DELETE /api/jobs/{id}/ # Delete job posting
3.1.2 Candidate Endpoints
GET /api/candidates/ # List all candidates
POST /api/candidates/ # Create new candidate
GET /api/candidates/{id}/ # Get specific candidate
PUT /api/candidates/{id}/ # Update candidate
DELETE /api/candidates/{id}/ # Delete candidate
GET /api/candidates/job/{job_id}/ # Get candidates for specific job
3.1.3 Meeting Endpoints
GET /api/meetings/ # List all meetings
POST /api/meetings/ # Create new meeting
GET /api/meetings/{id}/ # Get specific meeting
PUT /api/meetings/{id}/ # Update meeting
DELETE /api/meetings/{id}/ # Delete meeting
POST /api/meetings/{id}/join/ # Join meeting
3.1.4 Form Template Endpoints
GET /api/templates/ # List form templates
POST /api/templates/ # Create form template
GET /api/templates/{id}/ # Get specific template
PUT /api/templates/{id}/ # Update template
DELETE /api/templates/{id}/ # Delete template
POST /api/templates/{id}/submit/ # Submit form
3.2 WebSocket Events (HTMX)
3.2.1 Real-time Updates
// Meeting status updates
event: 'meeting:status_update'
data: { meeting_id: '123', status: 'started' }
// Candidate status updates
event: 'candidate:stage_update'
data: { candidate_id: '456', stage: 'Interview' }
// Interview schedule updates
event: 'interview:schedule_update'
data: { interview_id: '789', date: '2025-10-20' }
4. Authentication & Authorization
4.1 Authentication Flow
# Authentication Middleware
class CustomAuthenticationMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Check for session authentication
if 'user_id' in request.session:
request.user = get_user_from_session(request.session['user_id'])
# Check for JWT token
elif 'Authorization' in request.headers:
request.user = authenticate_jwt(request.headers['Authorization'])
else:
request.user = AnonymousUser()
return self.get_response(request)
4.2 Permission Classes
# Custom Permission Classes
class IsHiringManager(permissions.BasePermission):
def has_permission(self, request, view):
return request.user.groups.filter(name='Hiring Managers').exists()
class IsRecruiter(permissions.BasePermission):
def has_permission(self, request, view):
return request.user.groups.filter(name='Recruiters').exists()
class IsInterviewer(permissions.BasePermission):
def has_permission(self, request, view):
return request.user.groups.filter(name='Interviewers').exists()
class IsCandidate(permissions.BasePermission):
def has_permission(self, request, view):
return request.user.groups.filter(name='Candidates').exists()
5. Business Logic Implementation
5.1 Candidate Stage Transitions
class CandidateService:
@staticmethod
def advance_candidate_stage(candidate, new_stage):
"""Advance candidate to new stage with validation"""
if new_stage not in candidate.get_available_stages():
raise ValidationError(f"Cannot advance from {candidate.stage} to {new_stage}")
old_stage = candidate.stage
candidate.stage = new_stage
# Auto-set exam date when moving to Exam stage
if new_stage == "Exam" and not candidate.exam_date:
candidate.exam_date = timezone.now() + timedelta(days=7)
# Auto-set interview date when moving to Interview stage
if new_stage == "Interview" and not candidate.interview_date:
candidate.interview_date = timezone.now() + timedelta(days=14)
# Auto-set offer date when moving to Offer stage
if new_stage == "Offer" and not candidate.offer_date:
candidate.offer_date = timezone.now() + timedelta(days=3)
candidate.save()
# Log stage transition
StageTransition.objects.create(
candidate=candidate,
old_stage=old_stage,
new_stage=new_stage,
changed_by=candidate.changed_by
)
return candidate
5.2 Interview Scheduling Logic
class InterviewScheduler:
@staticmethod
def get_available_slots(schedule, date):
"""Get available interview slots for a specific date"""
# Get working hours
start_time = datetime.combine(date, schedule.start_time)
end_time = datetime.combine(date, schedule.end_time)
# Apply break times
if schedule.break_start_time and schedule.break_end_time:
break_start = datetime.combine(date, schedule.break_start_time)
break_end = datetime.combine(date, schedule.break_end_time)
end_time = break_start # Remove break time from available slots
# Calculate available slots
slots = []
current_time = start_time
while current_time < end_time:
slot_end = current_time + timedelta(minutes=schedule.interview_duration + schedule.buffer_time)
if slot_end <= end_time:
slots.append({
'start': current_time.time(),
'end': slot_end.time(),
'available': True
})
current_time = slot_end
# Filter out booked slots
booked_slots = ScheduledInterview.objects.filter(
interview_date=date,
job=schedule.job
).values_list('interview_time', 'interview_time')
for slot in slots:
if slot['start'] in booked_slots:
slot['available'] = False
return slots
@staticmethod
def schedule_interview(candidate, job, schedule_data):
"""Schedule an interview with candidate"""
# Create Zoom meeting
zoom_meeting = create_zoom_meeting(
topic=f"Interview: {job.title} with {candidate.name}",
start_time=schedule_data['start_time'],
duration=schedule_data['duration']
)
# Create scheduled interview
interview = ScheduledInterview.objects.create(
candidate=candidate,
job=job,
zoom_meeting=zoom_meeting,
interview_date=schedule_data['start_date'],
interview_time=schedule_data['start_time'].time(),
status='scheduled'
)
return interview
5.3 Form Submission Processing
class FormSubmissionService:
@staticmethod
def process_submission(template, submission_data, files=None):
"""Process form submission and create candidate"""
with transaction.atomic():
# Create submission record
submission = FormSubmission.objects.create(
template=template,
applicant_name=submission_data.get('applicant_name', ''),
applicant_email=submission_data.get('applicant_email', '')
)
# Process field responses
for field_id, value in submission_data.items():
if field_id.startswith('field_'):
field_id = field_id.replace('field_', '')
try:
field = FormField.objects.get(id=field_id, stage__template=template)
response = FieldResponse.objects.create(
submission=submission,
field=field,
value=value if value else None
)
# Handle file uploads
if field.field_type == 'file' and files:
file_key = f'field_{field_id}'
if file_key in files:
response.uploaded_file = files[file_key]
response.save()
except FormField.DoesNotExist:
continue
# Create candidate if required fields are present
try:
first_name = submission.responses.get(field__label='First Name')
last_name = submission.responses.get(field__label='Last Name')
email = submission.responses.get(field__label='Email Address')
phone = submission.responses.get(field__label='Phone Number')
address = submission.responses.get(field__label='Address')
resume = submission.responses.get(field__label='Resume Upload')
candidate = Candidate.objects.create(
first_name=first_name.display_value,
last_name=last_name.display_value,
email=email.display_value,
phone=phone.display_value,
address=address.display_value,
resume=resume.get_file if resume.is_file else None,
job=template.job
)
return submission, candidate
except Exception as e:
logger.error(f"Candidate creation failed: {e}")
return submission, None
6. Error Handling Strategy
6.1 Custom Exception Classes
class ATSException(Exception):
"""Base exception for ATS system"""
pass
class CandidateStageTransitionError(ATSException):
"""Raised when candidate stage transition fails"""
pass
class InterviewSchedulingError(ATSException):
"""Raised when interview scheduling fails"""
pass
class FormSubmissionError(ATSException):
"""Raised when form submission processing fails"""
pass
class ZoomAPIError(ATSException):
"""Raised when Zoom API calls fail"""
pass
class LinkedInAPIError(ATSException):
"""Raised when LinkedIn API calls fail"""
pass
6.2 Error Response Format
class ErrorResponse:
def __init__(self, error_code, message, details=None):
self.error_code = error_code
self.message = message
self.details = details
self.timestamp = timezone.now()
def to_dict(self):
return {
'error': {
'code': self.error_code,
'message': self.message,
'details': self.details,
'timestamp': self.timestamp.isoformat()
}
}
7. Caching Strategy
7.1 Cache Configuration
# settings.py
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/1',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
}
}
}
# Cache timeouts
CACHE_TIMEOUTS = {
'job_listings': 60 * 15, # 15 minutes
'candidate_profiles': 60 * 30, # 30 minutes
'meeting_details': 60 * 5, # 5 minutes
'form_templates': 60 * 60, # 1 hour
}
7.2 Cache Implementation
class CacheService:
@staticmethod
def get_or_set(key, func, timeout=None):
"""Get from cache or set if not exists"""
cached_value = cache.get(key)
if cached_value is None:
cached_value = func()
cache.set(key, cached_value, timeout or CACHE_TIMEOUTS.get(key, 300))
return cached_value
@staticmethod
def invalidate_pattern(pattern):
"""Invalidate all keys matching pattern"""
keys = cache.keys(pattern)
if keys:
cache.delete_many(keys)
8. Logging Strategy
8.1 Logging Configuration
# settings.py
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
'style': '{',
},
'simple': {
'format': '{levelname} {message}',
'style': '{',
},
},
'handlers': {
'file': {
'level': 'INFO',
'class': 'logging.FileHandler',
'filename': 'logs/ats.log',
'formatter': 'verbose',
},
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'simple',
},
},
'loggers': {
'ats': {
'handlers': ['file', 'console'],
'level': 'DEBUG',
'propagate': False,
},
},
}
8.2 Audit Logging
class AuditLogger:
@staticmethod
def log_action(user, action, model, instance_id, details=None):
"""Log user actions for audit trail"""
AuditLog.objects.create(
user=user,
action=action,
model=model,
instance_id=instance_id,
details=details
)
@staticmethod
def log_candidate_stage_transition(candidate, old_stage, new_stage, user):
"""Log candidate stage transitions"""
CandidateStageTransition.objects.create(
candidate=candidate,
old_stage=old_stage,
new_stage=new_stage,
changed_by=user
)
9. Security Implementation
9.1 Data Encryption
from cryptography.fernet import Fernet
class EncryptionService:
def __init__(self):
self.key = Fernet.generate_key()
self.cipher = Fernet(self.key)
def encrypt(self, data):
"""Encrypt sensitive data"""
return self.cipher.encrypt(data.encode()).decode()
def decrypt(self, encrypted_data):
"""Decrypt sensitive data"""
return self.cipher.decrypt(encrypted_data.encode()).decode()
9.2 File Upload Security
class SecureFileUpload:
@staticmethod
def validate_file(file):
"""Validate uploaded file for security"""
# Check file size
if file.size > 10 * 1024 * 1024: # 10MB
raise ValidationError("File size exceeds 10MB limit")
# Check file type
allowed_types = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
]
if file.content_type not in allowed_types:
raise ValidationError("Invalid file type")
# Scan for malware (placeholder)
if not SecureFileUpload.scan_malware(file):
raise ValidationError("File contains malicious content")
return True
@staticmethod
def scan_malware(file):
"""Placeholder for malware scanning"""
# Implement actual malware scanning logic
return True
10. Testing Strategy
10.1 Unit Test Structure
class CandidateTestCase(TestCase):
def setUp(self):
self.job = JobPosting.objects.create(
title="Software Engineer",
department="IT",
status="ACTIVE"
)
self.candidate = Candidate.objects.create(
first_name="John",
last_name="Doe",
email="john@example.com",
phone="1234567890",
job=self.job
)
def test_candidate_stage_transition(self):
"""Test candidate stage transitions"""
# Test valid transition
self.candidate.stage = "Exam"
self.candidate.save()
self.assertEqual(self.candidate.stage, "Exam")
# Test invalid transition
with self.assertRaises(ValidationError):
self.candidate.stage = "Offer"
self.candidate.save()
10.2 Integration Tests
class InterviewSchedulingTestCase(TestCase):
def setUp(self):
self.job = JobPosting.objects.create(
title="Product Manager",
department="Product",
status="ACTIVE"
)
self.candidate = Candidate.objects.create(
first_name="Jane",
last_name="Smith",
email="jane@example.com",
phone="9876543210",
job=self.job
)
self.schedule = InterviewSchedule.objects.create(
job=self.job,
start_date=timezone.now().date(),
end_date=timezone.now().date() + timedelta(days=7),
working_days=[0, 1, 2, 3, 4],
start_time=time(9, 0),
end_time=time(17, 0),
interview_duration=60,
buffer_time=15,
created_by=self.user
)
def test_interview_scheduling(self):
"""Test interview scheduling process"""
# Test slot availability
available_slots = InterviewScheduler.get_available_slots(
self.schedule,
timezone.now().date()
)
self.assertGreater(len(available_slots), 0)
# Test interview scheduling
schedule_data = {
'start_date': timezone.now().date(),
'start_time': timezone.now().time(),
'duration': 60
}
interview = InterviewScheduler.schedule_interview(
self.candidate,
self.job,
schedule_data
)
self.assertIsNotNone(interview)
11. Deployment Configuration
11.1 Production Settings
# settings/production.py
DEBUG = False
ALLOWED_HOSTS = ['your-domain.com']
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'ats_db',
'USER': 'ats_user',
'PASSWORD': 'secure_password',
'HOST': 'localhost',
'PORT': '5432',
}
}
STATIC_ROOT = '/var/www/ats/static/'
MEDIA_ROOT = '/var/www/ats/media/'
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
11.2 Docker Configuration
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "kaauh_ats.wsgi:application"]
# docker-compose.yml
version: '3.8'
services:
web:
build: .
ports:
- "8000:8000"
depends_on:
- db
- redis
environment:
- DEBUG=0
- DATABASE_URL=postgresql://ats_user:secure_password@db:5432/ats_db
- REDIS_URL=redis://redis:6379/0
db:
image: postgres:13
environment:
- POSTGRES_DB=ats_db
- POSTGRES_USER=ats_user
- POSTGRES_PASSWORD=secure_password
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:6-alpine
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
12. Monitoring & Analytics
12.1 Performance Monitoring
# monitoring.py
class PerformanceMonitor:
@staticmethod
def track_performance(func):
"""Decorator to track function performance"""
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
# Log performance metrics
PerformanceMetric.objects.create(
function_name=func.__name__,
execution_time=end_time - start_time,
timestamp=timezone.now()
)
return result
return wrapper
12.2 Analytics Dashboard
# analytics.py
class AnalyticsService:
@staticmethod
def get_recruitment_metrics():
"""Get recruitment performance metrics"""
return {
'total_jobs': JobPosting.objects.count(),
'active_jobs': JobPosting.objects.filter(status='ACTIVE').count(),
'total_candidates': Candidate.objects.count(),
'conversion_rate': AnalyticsService.calculate_conversion_rate(),
'time_to_hire': AnalyticsService.calculate_average_time_to_hire(),
'source_effectiveness': AnalyticsService.get_source_effectiveness()
}
@staticmethod
def calculate_conversion_rate():
"""Calculate candidate conversion rate"""
total_candidates = Candidate.objects.count()
hired_candidates = Candidate.objects.filter(
stage='Offer',
offer_status='Accepted'
).count()
return (hired_candidates / total_candidates * 100) if total_candidates > 0 else 0
Document Version: 1.0 Last Updated: October 17, 2025