1084 lines
39 KiB
Markdown
1084 lines
39 KiB
Markdown
# 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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```javascript
|
|
// 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
|
|
```python
|
|
# 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
|
|
```python
|
|
# 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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
# 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
|
|
```python
|
|
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
|
|
```python
|
|
# 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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
# 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
|
|
# 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"]
|
|
```
|
|
|
|
```yaml
|
|
# 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
|
|
```python
|
|
# 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
|
|
```python
|
|
# 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*
|