Compare commits

...

107 Commits

Author SHA1 Message Date
3e99bb3dc9 candidates pipeline status chart 2025-10-23 01:44:33 +03:00
ec3c52579b merge complete 2025-10-22 16:06:18 +03:00
ba911a60d6 small update 2025-10-22 16:03:05 +03:00
f0d3218caa few ui changes' 2025-10-22 15:58:21 +03:00
05e3271152 new chnage 2025-10-22 14:38:42 +03:00
ee78018a5a add source crud 2025-10-22 13:10:03 +03:00
3086b38a23 lot of updates 2025-10-21 19:24:42 +03:00
ef8616c088 conflict issue 2025-10-21 17:22:32 +03:00
2dd90e4d38 fix error 2025-10-21 14:26:29 +03:00
5921e30396 merge 2025-10-21 14:19:13 +03:00
f0ae8f46d9 Merge pull request 'frontend' (#18) from frontend into main
Reviewed-on: #18
2025-10-21 14:14:53 +03:00
c2773d9b4e ui fix 2025-10-21 14:14:00 +03:00
9d955cd184 ...... 2025-10-20 19:11:24 +03:00
ed86772f27 meeting update page ui fix 2025-10-20 18:32:16 +03:00
7ae8c16db2 password reset 2025-10-20 17:30:08 +03:00
2f02f10c16 message on exceeding the applications 2025-10-20 00:41:04 +03:00
a3277c53a7 Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend 2025-10-19 18:40:34 +03:00
012259cd36 ... 2025-10-19 18:38:33 +03:00
2b53a526db resolve conflict 2025-10-19 18:35:59 +03:00
d9b0248334 merged 2025-10-19 18:18:06 +03:00
2f0b46a01b Merge branch 'bm' 2025-10-19 18:17:41 +03:00
9d058feb3f merge faheed changes 2025-10-19 18:10:33 +03:00
408f1d7217 Merge branch 'ai' into HEAD 2025-10-19 18:09:49 +03:00
31155062c4 Merge pull request 'easy audit added' (#16) from frontend into main
Reviewed-on: #16
2025-10-19 17:25:42 +03:00
22870af025 ai parsing update 2025-10-19 17:23:06 +03:00
5f7af358df update successfull 2025-10-19 17:22:11 +03:00
e7d823d707 ... 2025-10-16 18:18:25 +03:00
785caf8ac4 Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend 2025-10-16 18:09:51 +03:00
21135ae821 easy audit added for activity log 2025-10-16 18:07:31 +03:00
ebdf9372dd q 2025-10-16 13:43:18 +03:00
a669564e6d push bulk create meeting to to background and create zoom webhook 2025-10-16 13:15:46 +03:00
0f19a40518 Merge pull request 'base.html update' (#15) from frontend into main
Reviewed-on: #15
2025-10-16 13:14:29 +03:00
4467a56d67 base.html update 2025-10-15 20:00:42 +03:00
3682657527 more update in css and templates 2025-10-15 13:13:59 +03:00
6b74990791 update the interview stage 2025-10-14 19:29:27 +03:00
51583371db Merge pull request 'authentication' (#14) from frontend into main
Reviewed-on: #14
2025-10-14 19:27:57 +03:00
8e1e3452e9 Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend 2025-10-14 19:24:28 +03:00
8c4fa09764 authentication allauth 2025-10-14 19:24:16 +03:00
719d2e73fb Merge pull request 'candidate and job timeline and ui fix' (#13) from frontend into main
Reviewed-on: #13
2025-10-14 15:59:38 +03:00
2ff04cfa6e Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend 2025-10-14 15:56:05 +03:00
302aa8d0bf update the bulk button to select 2025-10-14 15:55:53 +03:00
92013dd4f9 candidate history timeline 2025-10-14 15:55:49 +03:00
d0db3d1323 update1 2025-10-14 14:03:01 +03:00
671ac1a5d7 Merge pull request 'bug fixes' (#12) from frontend into main
Reviewed-on: #12
2025-10-14 14:00:57 +03:00
b9904b3ec8 few bug fixes 2025-10-14 13:53:34 +03:00
6521cdf2be ui changes 2025-10-13 22:58:05 +03:00
60fff6f636 breadcrumb 2025-10-13 17:34:32 +03:00
f992861947 more updates 2025-10-13 17:33:49 +03:00
4a728ff752 ... 2025-10-13 17:24:12 +03:00
cacc8d42b2 small fix 2025-10-13 17:23:12 +03:00
2be0ab083d fix small url issue 2025-10-13 17:22:40 +03:00
6c949f2899 Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend 2025-10-13 17:17:58 +03:00
2dd5eb874e everything before pull 2025-10-13 17:17:40 +03:00
9ae89587fa untrack db.sqlite 2025-10-13 17:14:36 +03:00
ffae8b2e64 update the zoom meeting 2025-10-13 17:13:56 +03:00
ce15603802 Merge pull request 'job list table' (#11) from frontend into main
Reviewed-on: #11
2025-10-13 17:12:32 +03:00
c532ca97b6 table for job list 2025-10-13 17:10:43 +03:00
97d9e034cd update 2025-10-13 17:07:21 +03:00
4caf57a3e0 added public liink for job 2025-10-13 14:46:26 +03:00
c13450ae83 update for job and candidtes stage 2025-10-12 19:17:05 +03:00
30acc14775 Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend 2025-10-12 13:49:07 +03:00
f4f84db0c1 added footer 2025-10-12 13:48:51 +03:00
9a768726e5 update 2025-10-12 13:44:52 +03:00
916cbc4fcf more update and add qcluster 2025-10-12 13:32:14 +03:00
322c98222d Merge pull request 'ckeditor added' (#9) from frontend into main
Reviewed-on: #9
2025-10-12 13:29:23 +03:00
7b02120508 image upload 2025-10-12 13:23:44 +03:00
67a951c45a ckeditor-5 added 2025-10-10 14:28:48 +03:00
1ed7f06a9d ckeditor-5 added 2025-10-10 14:27:42 +03:00
ff42e35855 new list page addded" 2025-10-09 19:51:53 +03:00
d8a7442b9d ... 2025-10-09 18:19:37 +03:00
9390bf97d1 job detail ui update 2025-10-09 18:18:47 +03:00
a0d09feaef fix the form wizard 2025-10-09 18:06:36 +03:00
e95a0415e0 update 2025-10-09 17:08:28 +03:00
e0b88f8b22 modal for job status update 2025-10-09 16:59:09 +03:00
a23c96cc17 update on the models and forms 2025-10-09 16:57:53 +03:00
cc540517a9 Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend 2025-10-09 14:14:24 +03:00
5509f7889c application success page 2025-10-09 14:14:16 +03:00
579cc085e2 more update related to integrations 2025-10-09 13:02:11 +03:00
41cf8ea28a Merge pull request 'django-sumernote and language suppport' (#7) from frontend into main
Reviewed-on: #7
2025-10-09 12:55:30 +03:00
dc007a33c8 small fix 2025-10-09 12:54:23 +03:00
53318a998c safe filter 2025-10-08 19:28:06 +03:00
3b8ed4c93b django summernote added 2025-10-08 14:58:19 +03:00
f02d859c7a update 2025-10-08 14:10:19 +03:00
921fe781d7 Merge pull request 'frontend' (#5) from frontend into main
Reviewed-on: #5
2025-10-07 18:36:18 +03:00
ef952ab596 .. 2025-10-07 18:35:03 +03:00
2b2488f712 job detail page facing the candidate 2025-10-07 18:34:30 +03:00
d26c18fefd add scheduler 2025-10-07 18:33:49 +03:00
7a0bf3262d ... 2025-10-07 17:55:26 +03:00
cbf4630071 added job_not_expired decorator 2025-10-07 17:54:46 +03:00
48f61f173f update the form builder 2025-10-07 17:53:28 +03:00
285d2aea18 Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend 2025-10-07 16:45:35 +03:00
9af34e2e74 before pull 2025-10-07 16:41:33 +03:00
c5c7963df5 update the form builder 2025-10-07 16:26:13 +03:00
7f23cc18fb Merge pull request 'ui chnages' (#4) from frontend into main
Reviewed-on: #4
2025-10-07 16:24:13 +03:00
f502435088 ui chnages 2025-10-07 16:22:56 +03:00
0483e3efc6 update 2025-10-07 13:44:11 +03:00
1dd340f890 add external integration 2025-10-07 13:44:00 +03:00
f28ab751ef Merge pull request 'ui updates' (#3) from frontend into main
Reviewed-on: #3
2025-10-07 13:41:28 +03:00
5856f1e0cb ui changes for theme 2025-10-07 13:39:44 +03:00
221380645d update 2025-10-06 16:29:20 +03:00
d5deb46ad2 Merge pull request 'update' (#1) from frontend into main
Reviewed-on: #1
2025-10-06 16:26:34 +03:00
9b7c633a18 static file 2025-10-06 16:23:40 +03:00
a8a45195c1 before first push 2025-10-06 15:20:47 +03:00
8858391fc7 simple 2025-10-06 15:20:05 +03:00
c2f68a2be9 candidate ai ranking 2025-10-06 15:13:59 +03:00
2a9121e7d0 added the hiring agency and source models 2025-10-05 19:47:08 +03:00
ede6b2760b resume data extraction 2025-10-05 19:29:53 +03:00
762 changed files with 79832 additions and 7407 deletions

113
.gitignore vendored Normal file
View File

@ -0,0 +1,113 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.pyc
*.pyd
*.pyo
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# Django stuff:
*.log
*.pot
*.sqlite3
local_settings.py
db.sqlite3
# Virtual environment
venv/
env/
# IDE files
.idea/
.vscode/
*.swp
*.bak
*.swo
# OS generated files
.DS_Store
Thumbs.db
# Testing
.tox/
.coverage
.pytest_cache/
htmlcov/
# Media and Static files (if served locally and not meant for version control)
media/
static/
# Deployment files
*.tar.gz
*.zip
db.sqlite3
=======
db.sqlite3
# Python
# Byte-compiled / optimized / DLL files
__pycache__/ # nocache: also caches module compiled version
*.py[co]
# CExtensions for Python
*.so
# Distribution / packaging
.egg-info/
dist/
build/
# Installer logs
pip-log.txt
pip-debug.log
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
nosetests.xml
coverage.xml
# Translations
*.mo
# Django stuff:
# Local settings
local_settings.py
# Database sqlite files:
# The base directory for relative paths in .gitignore
# is the directory where the .gitignore file is located.
# The following rules are applied in this order:
# 1. If the first byte of the pattern is `!`, then remove
# the file in the remaining pattern string from the index.
# 2. If not otherwise ignore the file specified by the remaining
# pattern string in step 1.
# If a rule in .gitignore ends with a directory separator (i.e. `/`
# character), then remove the file in the remaining pattern string and all
# files with the same name in subdirectories.
db.sqlite3

454
ATS_PRODUCT_DOCUMENT.md Normal file
View File

@ -0,0 +1,454 @@
# KAAUH Applicant Tracking System (ATS) - Product Document
## 1. Product Overview
### 1.1 Product Description
The King Abdulaziz University Hospital (KAAUH) Applicant Tracking System (ATS) is a comprehensive recruitment management platform designed to streamline and optimize the entire hiring process. The system provides end-to-end functionality for job posting, candidate management, interview coordination, and integration with external recruitment platforms.
### 1.2 Target Users
- **System Administrators**: Manage system configurations, user accounts, and integrations
- **Hiring Managers**: Create job postings, review candidates, and make hiring decisions
- **Recruiters**: Manage candidate pipelines, conduct screenings, and coordinate interviews
- **Interviewers**: Schedule and conduct interviews, provide feedback
- **Candidates**: Apply for positions, track application status, and participate in interviews
- **External Agencies**: Submit candidates and track progress
### 1.3 Key Features
- **Job Management**: Create, edit, and publish job postings with customizable templates
- **Candidate Pipeline**: Track candidates through all stages of recruitment
- **Interview Scheduling**: Automated scheduling with calendar integration
- **Video Interviews**: Zoom integration for seamless virtual interviews
- **Form Builder**: Dynamic application forms with custom fields
- **LinkedIn Integration**: Automated job posting and profile synchronization
- **Reporting & Analytics**: Comprehensive dashboards and reporting tools
- **Multi-language Support**: Arabic and English interfaces
## 2. User Stories
### 2.1 Hiring Manager Stories
```
As a Hiring Manager, I want to:
- Create job postings with detailed requirements and qualifications
- Review and shortlist candidates based on predefined criteria
- Track the status of all recruitment activities
- Generate reports on hiring metrics and trends
- Collaborate with recruiters and interviewers
- Post jobs directly to LinkedIn
Acceptance Criteria:
- Can create job postings with rich text descriptions
- Can filter candidates by stage, skills, and match score
- Can view real-time recruitment metrics
- Can approve or reject candidates
- Can post jobs to LinkedIn with one click
```
### 2.2 Recruiter Stories
```
As a Recruiter, I want to:
- Source and screen candidates from multiple channels
- Move candidates through the recruitment pipeline
- Schedule interviews and manage availability
- Send automated notifications and updates
- Track candidate engagement and response rates
- Maintain a database of potential candidates
Acceptance Criteria:
- Can bulk import candidates from CSV files
- Can update candidate stages in bulk
- Can schedule interviews with calendar sync
- Can send automated email/SMS notifications
- Can track candidate communication history
```
### 2.3 Interviewer Stories
```
As an Interviewer, I want to:
- View my interview schedule and availability
- Join video interviews seamlessly
- Provide structured feedback for candidates
- Access candidate information and resumes
- Confirm or reschedule interviews
- View interview history and patterns
Acceptance Criteria:
- Receive email/SMS reminders for upcoming interviews
- Can join Zoom meetings with one click
- Can submit structured feedback forms
- Can access all candidate materials
- Can update interview status and availability
```
### 2.4 Candidate Stories
```
As a Candidate, I want to:
- Search and apply for relevant positions
- Track my application status in real-time
- Receive timely updates about my application
- Participate in virtual interviews
- Submit required documents securely
- Communicate with recruiters easily
Acceptance Criteria:
- Can create a profile and upload resumes
- Can search jobs by department and keywords
- Can track application status history
- Can schedule interviews within available slots
- Can receive notifications via email/SMS
- Can access all application materials
```
### 2.5 System Administrator Stories
```
As a System Administrator, I want to:
- Manage user accounts and permissions
- Configure system settings and integrations
- Monitor system performance and usage
- Generate audit logs and reports
- Manage integrations with external systems
- Ensure data security and compliance
Acceptance Criteria:
- Can create and manage user roles
- Can configure API keys and integrations
- Can monitor system health and performance
- Can generate audit trails for all actions
- Can backup and restore data
- Can ensure GDPR compliance
```
## 3. Functional Requirements
### 3.1 Job Management Module
#### 3.1.1 Job Creation & Editing
- **FR1.1.1**: Users must be able to create new job postings with all required fields
- **FR1.1.2**: System must auto-generate unique internal job IDs
- **FR1.1.3**: Users must be able to edit job postings at any stage
- **FR1.1.4**: System must support job cloning for similar positions
- **FR1.1.5**: System must support multi-language content
#### 3.1.2 Job Publishing & Distribution
- **FR1.2.1**: System must support job status management (Draft, Active, Closed)
- **FR1.2.2**: System must integrate with LinkedIn for job posting
- **FR1.2.3**: System must generate career pages for active jobs
- **FR1.2.4**: System must support application limits per job posting
- **FR1.2.5**: System must track application sources and effectiveness
### 3.2 Candidate Management Module
#### 3.2.1 Candidate Database
- **FR2.1.1**: System must store comprehensive candidate profiles
- **FR2.1.2**: System must parse and analyze uploaded resumes
- **FR2.1.3**: System must support candidate import from various sources
- **FR2.1.4**: System must provide candidate search and filtering
- **FR2.1.5**: System must calculate match scores for candidates
#### 3.2.2 Candidate Pipeline
- **FR2.2.1**: System must support customizable candidate stages
- **FR2.2.2**: System must enforce stage transition rules
- **FR2.2.3**: System must track all candidate interactions
- **FR2.2.4**: System must support bulk candidate operations
- **FR2.2.5**: System must provide candidate relationship management
### 3.3 Interview Management Module
#### 3.3.1 Interview Scheduling
- **FR3.1.1**: System must support automated interview scheduling
- **FR3.1.2**: System must integrate with calendar systems
- **FR3.1.3**: System must handle timezone conversions
- **FR3.1.4**: System must support buffer times between interviews
- **FR3.1.5**: System must prevent scheduling conflicts
#### 3.3.2 Video Interviews
- **FR3.2.1**: System must integrate with Zoom for video interviews
- **FR3.2.2**: System must create Zoom meetings automatically
- **FR3.2.3**: System must handle meeting updates and cancellations
- **FR3.2.4**: System must support meeting recordings
- **FR3.2.5**: System must manage meeting access controls
### 3.4 Form Builder Module
#### 3.4.1 Form Creation
- **FR4.1.1**: System must support multi-stage form creation
- **FR4.1.2**: System must provide various field types
- **FR4.1.3**: System must support form validation rules
- **FR4.1.4**: System must allow conditional logic
- **FR4.1.5**: System must support form templates
#### 3.4.2 Form Processing
- **FR4.2.1**: System must handle form submissions securely
- **FR4.2.2**: System must support file uploads
- **FR4.2.3**: System must extract data from submissions
- **FR4.2.4**: System must create candidates from submissions
- **FR4.2.5**: System must provide submission analytics
### 3.5 Reporting & Analytics Module
#### 3.5.1 Dashboards
- **FR5.1.1**: System must provide role-based dashboards
- **FR5.1.2**: System must display key performance indicators
- **FR5.1.3**: System must support real-time data updates
- **FR5.1.4**: System must allow customization of dashboard views
- **FR5.1.5**: System must support data visualization
#### 3.5.2 Reports
- **FR5.2.1**: System must generate standard reports
- **FR5.2.2**: System must support custom report generation
- **FR5.2.3**: System must export data in multiple formats
- **FR5.2.4**: System must schedule automated reports
- **FR5.2.5**: System must support report distribution
## 4. Non-Functional Requirements
### 4.1 Performance Requirements
- **NF1.1**: System must support concurrent users (100+)
- **NF1.2**: Page load time must be under 3 seconds
- **NF1.3**: API response time must be under 1 second
- **NF1.4**: System must handle 10,000+ job postings
- **NF1.5**: System must handle 100,000+ candidate records
### 4.2 Security Requirements
- **NF2.1**: All data must be encrypted in transit and at rest
- **NF2.2**: System must support role-based access control
- **NF2.3**: System must maintain audit logs for all actions
- **NF2.4**: System must comply with GDPR regulations
- **NF2.5**: System must protect against common web vulnerabilities
### 4.3 Usability Requirements
- **NF3.1**: Interface must be intuitive and easy to use
- **NF3.2**: System must support both Arabic and English
- **NF3.3**: System must be responsive and mobile-friendly
- **NF3.4**: System must provide clear error messages
- **NF3.5**: System must support keyboard navigation
### 4.4 Reliability Requirements
- **NF4.1**: System must have 99.9% uptime
- **NF4.2**: System must handle failures gracefully
- **NF4.3**: System must support data backup and recovery
- **NF4.4**: System must provide monitoring and alerts
- **NF4.5**: System must support load balancing
### 4.5 Scalability Requirements
- **NF5.1**: System must scale horizontally
- **NF5.2**: System must handle peak loads
- **NF5.3**: System must support database sharding
- **NF5.4**: System must cache frequently accessed data
- **NF5.5**: System must support microservices architecture
## 5. Integration Requirements
### 5.1 External Integrations
- **INT1.1**: Zoom API for video conferencing
- **INT1.2**: LinkedIn API for job posting and profiles
- **INT1.3**: Email/SMS services for notifications
- **INT1.4**: Calendar systems for scheduling
- **INT1.5**: ERP systems for employee data
### 5.2 Internal Integrations
- **INT2.1**: Single Sign-On (SSO) for authentication
- **INT2.2**: File storage system for documents
- **INT2.3**: Search engine for candidate matching
- **INT2.4**: Analytics platform for reporting
- **INT2.5**: Task queue for background processing
## 6. Business Rules
### 6.1 Job Posting Rules
- **BR1.1**: Job postings must be approved before publishing
- **BR1.2**: Application limits must be enforced per job
- **BR1.3**: Job postings must have required fields completed
- **BR1.4**: LinkedIn posts must follow platform guidelines
- **BR1.5**: Job postings must comply with equal opportunity laws
### 6.2 Candidate Management Rules
- **BR2.1**: Candidates can only progress to next stage with approval
- **BR2.2**: Duplicate candidates must be prevented
- **BR2.3**: Candidate data must be kept confidential
- **BR2.4**: Communication must be tracked for all candidates
- **BR2.5**: Background checks must be completed before offers
### 6.3 Interview Scheduling Rules
- **BR3.1**: Interviews must be scheduled during business hours
- **BR3.2**: Buffer time must be respected between interviews
- **BR3.3**: Interviewers must be available for scheduled times
- **BR3.4**: Cancellations must be handled according to policy
- **BR3.5**: Feedback must be collected after each interview
### 6.4 Form Processing Rules
- **BR4.1**: Required fields must be validated before submission
- **BR4.2**: File uploads must be scanned for security
- **BR4.3**: Form submissions must be processed in order
- **BR4.4**: Duplicate submissions must be prevented
- **BR4.5**: Form data must be extracted accurately
## 7. User Interface Requirements
### 7.1 Design Principles
- **UI1.1**: Clean, modern interface with consistent branding
- **UI1.2**: Intuitive navigation with clear hierarchy
- **UI1.3**: Responsive design for all devices
- **UI1.4**: Accessibility compliance (WCAG 2.1)
- **UI1.5**: Fast loading with optimized performance
### 7.2 Key Screens
- **UI2.1**: Dashboard with key metrics and quick actions
- **UI2.2**: Job posting creation and management interface
- **UI2.3**: Candidate pipeline with drag-and-drop stages
- **UI2.4**: Interview scheduling calendar view
- **UI2.5**: Form builder with drag-and-drop fields
- **UI2.6**: Reports and analytics with interactive charts
- **UI2.7**: Candidate profile with comprehensive information
- **UI2.8**: Meeting interface with Zoom integration
### 7.3 Interaction Patterns
- **UI3.1**: Consistent button styles and behaviors
- **UI3.2**: Clear feedback for all user actions
- **UI3.3**: Progressive disclosure for complex forms
- **UI3.4**: Contextual help and tooltips
- **UI3.5**: Keyboard shortcuts for power users
## 8. Data Management
### 8.1 Data Storage
- **DM1.1**: All data must be stored securely
- **DM1.2**: Sensitive data must be encrypted
- **DM1.3**: Data must be backed up regularly
- **DM1.4**: Data retention policies must be enforced
- **DM1.5**: Data must be accessible for reporting
### 8.2 Data Migration
- **DM2.1**: Support import from legacy systems
- **DM2.2**: Provide data validation during migration
- **DM2.3**: Support incremental data updates
- **DM2.4**: Maintain data integrity during migration
- **DM2.5**: Provide rollback capabilities
### 8.3 Data Quality
- **DM3.1**: Implement data validation rules
- **DM3.2**: Provide data cleansing tools
- **DM3.3**: Monitor data quality metrics
- **DM3.4**: Handle duplicate data detection
- **DM3.5**: Support data standardization
## 9. Implementation Plan
### 9.1 Development Phases
#### Phase 1: Core Functionality (Months 1-3)
- User authentication and authorization
- Basic job posting and management
- Candidate database and pipeline
- Basic reporting dashboards
- Form builder with essential fields
#### Phase 2: Enhanced Features (Months 4-6)
- Interview scheduling and Zoom integration
- LinkedIn integration for job posting
- Advanced reporting and analytics
- Candidate matching and scoring
- Mobile-responsive design
#### Phase 3: Advanced Features (Months 7-9)
- AI-powered candidate matching
- Advanced form builder with conditions
- Integration with external systems
- Performance optimization
- Security hardening
#### Phase 4: Production Readiness (Months 10-12)
- Load testing and performance optimization
- Security audit and compliance
- Documentation and training materials
- Beta testing with real users
- Production deployment
### 9.2 Team Structure
- **Project Manager**: Overall project coordination
- **Product Owner**: Requirements and backlog management
- **UI/UX Designer**: Interface design and user experience
- **Backend Developers**: Server-side development
- **Frontend Developers**: Client-side development
- **QA Engineers**: Testing and quality assurance
- **DevOps Engineers**: Deployment and infrastructure
- **Business Analyst**: Requirements gathering and analysis
### 9.3 Technology Stack
- **Frontend**: HTML5, CSS3, JavaScript, Bootstrap 5, HTMX
- **Backend**: Django 5.2.1, Python 3.11
- **Database**: PostgreSQL (production), SQLite (development)
- **APIs**: Django REST Framework
- **Authentication**: Django Allauth, OAuth 2.0
- **Real-time**: HTMX, WebSocket
- **Task Queue**: Celery with Redis
- **Storage**: Local filesystem, AWS S3
- **Monitoring**: Prometheus, Grafana
- **CI/CD**: Docker, Kubernetes
## 10. Success Metrics
### 10.1 Business Metrics
- **BM1.1**: Reduce time-to-hire by 30%
- **BM1.2**: Improve candidate quality by 25%
- **BM1.3**: Increase recruiter efficiency by 40%
- **BM1.4**: Reduce recruitment costs by 20%
- **BM1.5**: Improve candidate satisfaction by 35%
### 10.2 Technical Metrics
- **TM1.1**: System uptime of 99.9%
- **TM1.2**: Page load time under 3 seconds
- **TM1.3**: API response time under 1 second
- **TM1.4**: 0 critical security vulnerabilities
- **TM1.5**: 95% test coverage
### 10.3 User Adoption Metrics
- **UM1.1**: 90% of target users actively using the system
- **UM1.2**: 80% reduction in manual processes
- **UM1.3**: 75% improvement in user satisfaction
- **UM1.4**: 50% reduction in recruitment time
- **UM1.5**: 95% data accuracy in the system
## 11. Risk Assessment
### 11.1 Technical Risks
- **TR1.1**: Integration complexity with external systems
- **TR1.2**: Performance issues with large datasets
- **TR1.3**: Security vulnerabilities in third-party APIs
- **TR1.4**: Data migration challenges
- **TR1.5**: Scalability concerns
### 11.2 Business Risks
- **BR1.1**: User resistance to new system
- **BR1.2**: Changes in recruitment processes
- **BR1.3**: Budget constraints
- **BR1.4**: Timeline delays
- **BR1.5**: Regulatory changes
### 11.3 Mitigation Strategies
- **MS1.1**: Phased implementation with user feedback
- **MS1.2**: Regular performance testing and optimization
- **MS1.3**: Security audits and penetration testing
- **MS1.4**: Comprehensive training and support
- **MS1.5**: Flexible architecture for future changes
## 12. Training & Support
### 12.1 User Training
- **TU1.1**: Role-based training programs
- **TU1.2**: Online documentation and help guides
- **TU1.3**: Video tutorials for key features
- **TU1.4**: In-person training sessions
- **TU1.5**: Refresher courses and updates
### 12.2 Technical Support
- **TS1.1**: Helpdesk with dedicated support staff
- **TS1.2**: Online ticketing system
- **TS1.3**: Remote support capabilities
- **TS1.4**: Knowledge base and FAQs
- **TS1.5**: 24/7 support for critical issues
### 12.3 System Maintenance
- **SM1.1**: Regular system updates and patches
- **SM1.2**: Performance monitoring and optimization
- **SM1.3**: Data backup and recovery procedures
- **SM1.4**: System health checks
- **SM1.5**: Continuous improvement based on feedback
---
*Document Version: 1.0*
*Last Updated: October 17, 2025*

241
ATS_PROJECT_HLD.md Normal file
View File

@ -0,0 +1,241 @@
# KAAUH Applicant Tracking System (ATS) - High Level Design Document
## 1. Executive Summary
This document outlines the High-Level Design (HLD) for the King Abdulaziz University Hospital (KAAUH) Applicant Tracking System (ATS). The system is designed to streamline the recruitment process by providing comprehensive tools for job posting, candidate management, interview scheduling, and integration with external platforms.
## 2. System Overview
### 2.1 Vision
To create a modern, efficient, and user-friendly recruitment management system that automates and optimizes the hiring process at KAAUH.
### 2.2 Mission
The ATS aims to:
- Centralize recruitment activities
- Improve candidate experience
- Enhance recruiter efficiency
- Provide data-driven insights
- Integrate with external platforms (Zoom, LinkedIn, ERP)
### 2.3 Goals
- Reduce time-to-hire
- Improve candidate quality
- Enhance reporting and analytics
- Provide seamless user experience
- Ensure system scalability and maintainability
## 3. Architecture Overview
### 3.1 Technology Stack
- **Backend**: Django 5.2.1 (Python)
- **Frontend**: HTML5, CSS3, JavaScript, Bootstrap 5
- **Database**: SQLite (development), PostgreSQL (production)
- **APIs**: REST API with Django REST Framework
- **Real-time**: HTMX for dynamic UI updates
- **Authentication**: Django Allauth with OAuth (LinkedIn)
- **File Storage**: Local filesystem
- **Task Queue**: Celery with Redis
- **Communication**: Email, Webhooks (Zoom)
### 3.2 System Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Web Browser │ │ Mobile App │ │ Admin Panel │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
┌─────────────────┐
│ Load Balancer │
└─────────────────┘
┌─────────────────┐
│ Web Server │
│ (Gunicorn) │
└─────────────────┘
┌─────────────────┐
│ Application │
│ (Django) │
└─────────────────┘
│ │
┌───────────────┴─────────┐ ┌─────┴────────────────┐
│ Database Layer │ │ External Services│
│ (SQLite/PostgreSQL) │ │ (Zoom, LinkedIn) │
└─────────────────────────┘ └─────────────────────┘
```
## 4. Core Components
### 4.1 User Management
- **Role-based Access Control**:
- System Administrators
- Hiring Managers
- Recruiters
- Interviewers
- Candidates
- **Authentication**:
- User registration and login
- LinkedIn OAuth integration
- Session management
### 4.2 Job Management
- **Job Posting**:
- Create, edit, delete job postings
- Job templates and cloning
- Multi-language support
- Approval workflows
- **Job Distribution**:
- LinkedIn integration
- Career page management
- Application tracking
### 4.3 Candidate Management
- **Candidate Database**:
- Profile management
- Resume parsing and storage
- Skills assessment
- Candidate scoring
- **Candidate Tracking**:
- Application status tracking
- Stage transitions
- Communication logging
- Candidate relationship management
### 4.4 Interview Management
- **Scheduling**:
- Automated interview scheduling
- Calendar integration
- Time slot management
- Buffer time configuration
- **Video Interviews**:
- Zoom API integration
- Meeting creation and management
- Recording and playback
- Interview feedback collection
### 4.5 Form Builder
- **Dynamic Forms**:
- Multi-stage form creation
- Custom field types
- Validation rules
- File upload support
- **Application Processing**:
- Form submission handling
- Data extraction and storage
- Notification systems
### 4.6 Reporting and Analytics
- **Dashboards**:
- Executive dashboard
- Recruitment metrics
- Candidate analytics
- Department-specific reports
- **Data Export**:
- CSV, Excel, PDF exports
- Custom report generation
- Scheduled reports
## 5. Integration Architecture
### 5.1 External API Integrations
- **Zoom Video Conferencing**:
- Meeting creation and management
- Webhook event handling
- Recording and transcription
- **LinkedIn Recruitment**:
- Job posting automation
- Profile synchronization
- Analytics tracking
- **ERP Systems**:
- Employee data synchronization
- Position management
- Financial integration
### 5.2 Internal Integrations
- **Email System**:
- Automated notifications
- Interview reminders
- Status updates
- **Calendar System**:
- Interview scheduling
- Availability management
- Conflict detection
## 6. Security Architecture
### 6.1 Authentication & Authorization
- Multi-factor authentication support
- Role-based access control
- JWT token authentication
- OAuth 2.0 integration
### 6.2 Data Protection
- Data encryption at rest and in transit
- Secure file storage
- Personal data protection
- Audit logging
### 6.3 System Security
- Input validation and sanitization
- SQL injection prevention
- XSS protection
- CSRF protection
- Rate limiting
## 7. Scalability & Performance
### 7.1 Performance Optimization
- Database indexing
- Query optimization
- Caching strategies (Redis)
- Asynchronous task processing (Celery)
### 7.2 Scalability Considerations
- Horizontal scaling support
- Load balancing
- Database replication
- Microservices-ready architecture
## 8. Deployment & Operations
### 8.1 Deployment Strategy
- Container-based deployment (Docker)
- Environment management
- CI/CD pipeline
- Automated testing
### 8.2 Monitoring & Maintenance
- Application monitoring
- Performance metrics
- Error tracking
- Automated backups
## 9. Future Roadmap
### 9.1 Phase 1 (Current)
- Core ATS functionality
- Basic reporting
- Zoom and LinkedIn integration
- Mobile-responsive design
### 9.2 Phase 2 (Next 6 months)
- Advanced analytics
- AI-powered candidate matching
- Enhanced reporting
- Mobile app development
### 9.3 Phase 3 (Next 12 months)
- Voice interview support
- Video interview AI analysis
- Advanced integrations
- Multi-tenant support
## 10. Conclusion
The KAAUH ATS system is designed to be a comprehensive, modern, and scalable solution for managing the recruitment lifecycle. By leveraging Django's robust framework and integrating with external platforms, the system will significantly improve recruitment efficiency and provide valuable insights for decision-making.
---
*Document Version: 1.0*
*Last Updated: October 17, 2025*

1083
ATS_PROJECT_LLD.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
# to make sure that the celery loads whenever in run my project
#Celery app is loaded and configured as soon as Django starts.
from .celery import app as celery_app
# so that the @shared_task decorator will use this app in all the tasks.py files
__all__ = ('celery_app',)

Binary file not shown.

Binary file not shown.

23
NorahUniversity/celery.py Normal file
View File

@ -0,0 +1,23 @@
import os
from celery import Celery
# to tell the celery program which is seperate from where to find our Django projects settings
os.environ.setdefault('DJANGO_SETTINGS_MODULE','NorahUniversity.settings')
# create a Celery app instance
app=Celery('NorahUniversity')
# load the celery app connfiguration from the projects settings:
app.config_from_object('django.conf:settings',namespace='CELERY')
# Auto discover the tasks from the django apps:
app.autodiscover_tasks()

View File

@ -25,7 +25,7 @@ SECRET_KEY = 'django-insecure-_!ew&)1&r--3h17knd27^x8(xu(&-f4q3%x543lv5vx2!784s*
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
ALLOWED_HOSTS = ["*"]
# Application definition
@ -38,7 +38,7 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'recruitment',
'recruitment.apps.RecruitmentConfig',
'corsheaders',
'django.contrib.sites',
'allauth',
@ -48,13 +48,37 @@ INSTALLED_APPS = [
'channels',
'django_filters',
'crispy_forms',
# 'django_summernote',
# 'ckeditor',
'django_ckeditor_5',
'crispy_bootstrap5',
'django_extensions',
'template_partials',
'django_countries',
'django_celery_results',
'django_q',
'widget_tweaks',
'easyaudit'
]
SITE_ID = 1
LOGIN_REDIRECT_URL = '/'
ACCOUNT_LOGOUT_REDIRECT_URL = '/'
ACCOUNT_SIGNUP_REDIRECT_URL = '/'
LOGIN_URL = '/accounts/login/'
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend',
@ -64,12 +88,14 @@ MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'allauth.account.middleware.AccountMiddleware',
'easyaudit.middleware.easyaudit.EasyAuditMiddleware',
]
ROOT_URLCONF = 'NorahUniversity.urls'
@ -108,14 +134,27 @@ WSGI_APPLICATION = 'NorahUniversity.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'haikal_db',
'USER': 'faheed',
'PASSWORD': 'Faheed@215',
'HOST': '127.0.0.1',
'PORT': '5432',
}
}
# DATABASES = {
# 'default': {
# 'ENGINE': 'django.db.backends.sqlite3',
# 'NAME': BASE_DIR / 'db.sqlite3',
# }
# }
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
@ -131,6 +170,23 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
ACCOUNT_LOGIN_METHODS = ['email']
ACCOUNT_SIGNUP_FIELDS = ['email*', 'password1*', 'password2*']
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_EMAIL_VERIFICATION = 'none'
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True
ACCOUNT_FORMS = {'signup': 'recruitment.forms.StaffSignupForm'}
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Crispy Forms Configuration
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
@ -141,6 +197,10 @@ CRISPY_BS5 = {
'use_css_helpers': True,
}
ACCOUNT_RATE_LIMITS = {
'send_email_confirmation': None, # Disables the limit
}
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
@ -155,7 +215,7 @@ LOCALE_PATHS = [
BASE_DIR / 'locale',
]
TIME_ZONE = 'UTC'
TIME_ZONE = 'Asia/Riyadh'
USE_I18N = True
@ -190,23 +250,154 @@ SOCIALACCOUNT_PROVIDERS = {
}
}
UNFOLD = {
"DASHBOARD_CALLBACK": "recruitment.utils.dashboard_callback",
"STYLES": [
lambda request: static("unfold/css/styles.css"),
],
"SCRIPTS": [
lambda request: static("unfold/js/app.js"),
],
}
ZOOM_ACCOUNT_ID = 'HoGikHXsQB2GNDC5Rvyw9A'
ZOOM_CLIENT_ID = 'brC39920R8C8azfudUaQgA'
ZOOM_CLIENT_SECRET = 'rvfhjlbID4ychXPOvZ2lYsoAC0B0Ny2L'
SECRET_TOKEN = '6KdTGyF0SSCSL_V4Xa34aw'
ZOOM_WEBHOOK_API_KEY = "2GNDC5Rvyw9AHoGikHXsQB"
# Maximum file upload size (in bytes)
DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_CREDENTIALS = True
CELERY_BROKER_URL = 'redis://localhost:6379/0' # Or your message broker URL
CELERY_RESULT_BACKEND = 'django-db' # If using django-celery-results
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'UTC'
LINKEDIN_CLIENT_ID = '867jwsiyem1504'
LINKEDIN_CLIENT_SECRET = 'WPL_AP1.QNH5lYnfRSQpp0Qp.GO8Srw=='
LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/jobs/linkedin/callback/'
Q_CLUSTER = {
'name': 'KAAUH_CLUSTER',
'workers': 8,
'recycle': 500,
'timeout': 60,
'max_attempts': 1,
'compress': True,
'save_limit': 250,
'queue_limit': 500,
'cpu_affinity': 1,
'label': 'Django Q2',
'redis': {
'host': '127.0.0.1',
'port': 6379,
'db': 3, },
'ALT_CLUSTERS': {
'long': {
'timeout': 3000,
'retry': 3600,
'max_attempts': 2,
},
'short': {
'timeout': 10,
'max_attempts': 1,
},
}
}
customColorPalette = [
{
'color': 'hsl(4, 90%, 58%)',
'label': 'Red'
},
{
'color': 'hsl(340, 82%, 52%)',
'label': 'Pink'
},
{
'color': 'hsl(291, 64%, 42%)',
'label': 'Purple'
},
{
'color': 'hsl(262, 52%, 47%)',
'label': 'Deep Purple'
},
{
'color': 'hsl(231, 48%, 48%)',
'label': 'Indigo'
},
{
'color': 'hsl(207, 90%, 54%)',
'label': 'Blue'
},
]
# CKEDITOR_5_CUSTOM_CSS = 'path_to.css' # optional
# CKEDITOR_5_FILE_STORAGE = "path_to_storage.CustomStorage" # optional
CKEDITOR_5_CONFIGS = {
'default': {
'toolbar': {
'items': ['heading', '|', 'bold', 'italic', 'link',
'bulletedList', 'numberedList', 'blockQuote', 'imageUpload', ],
}
},
'extends': {
'blockToolbar': [
'paragraph', 'heading1', 'heading2', 'heading3',
'|',
'bulletedList', 'numberedList',
'|',
'blockQuote',
],
'toolbar': {
'items': ['heading', '|', 'outdent', 'indent', '|', 'bold', 'italic', 'link', 'underline', 'strikethrough',
'code','subscript', 'superscript', 'highlight', '|', 'codeBlock', 'sourceEditing', 'insertImage',
'bulletedList', 'numberedList', 'todoList', '|', 'blockQuote', 'imageUpload', '|',
'fontSize', 'fontFamily', 'fontColor', 'fontBackgroundColor', 'mediaEmbed', 'removeFormat',
'insertTable',
],
'shouldNotGroupWhenFull': 'true'
},
'image': {
'toolbar': ['imageTextAlternative', '|', 'imageStyle:alignLeft',
'imageStyle:alignRight', 'imageStyle:alignCenter', 'imageStyle:side', '|'],
'styles': [
'full',
'side',
'alignLeft',
'alignRight',
'alignCenter',
]
},
'table': {
'contentToolbar': [ 'tableColumn', 'tableRow', 'mergeTableCells',
'tableProperties', 'tableCellProperties' ],
'tableProperties': {
'borderColors': customColorPalette,
'backgroundColors': customColorPalette
},
'tableCellProperties': {
'borderColors': customColorPalette,
'backgroundColors': customColorPalette
}
},
'heading' : {
'options': [
{ 'model': 'paragraph', 'title': 'Paragraph', 'class': 'ck-heading_paragraph' },
{ 'model': 'heading1', 'view': 'h1', 'title': 'Heading 1', 'class': 'ck-heading_heading1' },
{ 'model': 'heading2', 'view': 'h2', 'title': 'Heading 2', 'class': 'ck-heading_heading2' },
{ 'model': 'heading3', 'view': 'h3', 'title': 'Heading 3', 'class': 'ck-heading_heading3' }
]
}
},
'list': {
'properties': {
'styles': 'true',
'startIndex': 'true',
'reversed': 'true',
}
}
}
# Define a constant in settings.py to specify file upload permissions
CKEDITOR_5_FILE_UPLOAD_PERMISSION = "staff" # Possible values: "staff", "authenticated", "any"

View File

@ -1,36 +1,45 @@
"""
URL configuration for NorahUniversity project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from recruitment import views
from django.conf import settings
from django.contrib import admin
from django.urls import path, include
from django.conf.urls.static import static
from django.conf import settings
from django.views.generic import RedirectView
from django.conf.urls.i18n import i18n_patterns
from rest_framework.routers import DefaultRouter
from recruitment import views
router = DefaultRouter()
router.register(r'jobs', views.JobPostingViewSet)
router.register(r'candidates', views.CandidateViewSet)
# 1. URLs that DO NOT have a language prefix (admin, API, static files)
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include(router.urls)),
path('accounts/', include('allauth.urls')),
path('', include('recruitment.urls')),
path('i18n/', include('django.conf.urls.i18n')),
# path('summernote/', include('django_summernote.urls')),
# path('', include('recruitment.urls')),
path("ckeditor5/", include('django_ckeditor_5.urls')),
path('application/<slug:template_slug>/', views.application_submit_form, name='application_submit_form'),
path('application/<slug:template_slug>/submit/', views.application_submit, name='application_submit'),
path('application/<slug:slug>/apply/', views.application_detail, name='application_detail'),
path('application/<slug:slug>/success/', views.application_success, name='application_success'),
path('api/templates/', views.list_form_templates, name='list_form_templates'),
path('api/templates/save/', views.save_form_template, name='save_form_template'),
path('api/templates/<slug:template_slug>/', views.load_form_template, name='load_form_template'),
path('api/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'),
path('api/webhook/',views.zoom_webhook_view,name='zoom_webhook_view')
]
urlpatterns += i18n_patterns(
path('', include('recruitment.urls')),
)
# 2. URLs that DO have a language prefix (user-facing views)
# This includes the root path (''), which is handled by 'recruitment.urls'
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

312
TESTING_GUIDE.md Normal file
View File

@ -0,0 +1,312 @@
# Recruitment Application Testing Guide
This guide provides comprehensive information about testing the Recruitment Application (ATS) system.
## Test Structure
The test suite is organized into several modules:
### 1. Basic Tests (`recruitment/tests.py`)
- **BaseTestCase**: Common setup for all tests
- **ModelTests**: Basic model functionality tests
- **ViewTests**: Standard view tests
- **FormTests**: Basic form validation tests
- **IntegrationTests**: Simple integration scenarios
### 2. Advanced Tests (`recruitment/tests_advanced.py`)
- **AdvancedModelTests**: Complex model scenarios and edge cases
- **AdvancedViewTests**: Complex view logic with multiple filters and workflows
- **AdvancedFormTests**: Complex form validation and dynamic fields
- **AdvancedIntegrationTests**: End-to-end workflows and concurrent operations
- **SecurityTests**: Security-focused testing
### 3. Configuration Files
- **`pytest.ini`**: Pytest configuration with coverage settings
- **`conftest.py`**: Pytest fixtures and common test setup
## Running Tests
### Basic Test Execution
```bash
# Run all tests
python manage.py test recruitment
# Run specific test class
python manage.py test recruitment.tests.AdvancedModelTests
# Run with verbose output
python manage.py test recruitment --verbosity=2
# Run tests with coverage
python manage.py test recruitment --coverage
```
### Using Pytest
```bash
# Install pytest and required packages
pip install pytest pytest-django pytest-cov
# Run all tests
pytest
# Run specific test file
pytest recruitment/tests.py
# Run with coverage
pytest --cov=recruitment --cov-report=html
# Run with markers
pytest -m unit # Run only unit tests
pytest -m integration # Run only integration tests
pytest -m "not slow" # Skip slow tests
```
### Test Markers
- `@pytest.mark.unit`: For unit tests
- `@pytest.mark.integration`: For integration tests
- `@pytest.mark.security`: For security tests
- `@pytest.mark.api`: For API tests
- `@pytest.mark.slow`: For performance-intensive tests
## Test Coverage
The test suite aims for 80% code coverage. Coverage reports are generated in:
- HTML: `htmlcov/index.html`
- Terminal: Shows missing lines
### Improving Coverage
1. Add tests for untested branches
2. Test error conditions and edge cases
3. Use mocking for external dependencies
## Key Testing Areas
### 1. Model Testing
- **JobPosting**: ID generation, validation, methods
- **Candidate**: Stage transitions, relationships
- **ZoomMeeting**: Time validation, status handling
- **FormTemplate**: Template integrity, field ordering
- **InterviewSchedule**: Scheduling logic, slot generation
### 2. View Testing
- **Job Management**: CRUD operations, search, filtering
- **Candidate Management**: Stage updates, bulk operations
- **Meeting Management**: Scheduling, API integration
- **Form Handling**: Submission processing, validation
### 3. Form Testing
- **JobPostingForm**: Complex validation, field dependencies
- **CandidateForm**: File upload, validation
- **InterviewScheduleForm**: Dynamic fields, validation
- **MeetingCommentForm**: Comment creation/editing
### 4. Integration Testing
- **Complete Hiring Workflow**: Job → Application → Interview → Hire
- **Data Integrity**: Cross-component data consistency
- **API Integration**: Zoom API, LinkedIn integration
- **Concurrent Operations**: Multi-threading scenarios
### 5. Security Testing
- **Access Control**: Permission validation
- **CSRF Protection**: Form security
- **Input Validation**: SQL injection, XSS prevention
- **Authentication**: User authorization
## Test Fixtures
Common fixtures available in `conftest.py`:
- **User Fixtures**: `user`, `staff_user`, `profile`
- **Model Fixtures**: `job`, `candidate`, `zoom_meeting`, `form_template`
- **Form Data Fixtures**: `job_form_data`, `candidate_form_data`
- **Mock Fixtures**: `mock_zoom_api`, `mock_time_slots`
- **Client Fixtures**: `client`, `authenticated_client`, `authenticated_staff_client`
## Writing New Tests
### Test Naming Convention
- Use descriptive names: `test_user_can_create_job_posting`
- Follow the pattern: `test_[subject]_[action]_[expected_result]`
### Best Practices
1. **Use Fixtures**: Leverage existing fixtures instead of creating test data
2. **Mock External Dependencies**: Use `@patch` for API calls
3. **Test Edge Cases**: Include invalid data, boundary conditions
4. **Maintain Independence**: Each test should be runnable independently
5. **Use Assertions**: Be specific about expected outcomes
### Example Test Structure
```python
from django.test import TestCase
from recruitment.models import JobPosting
from recruitment.tests import BaseTestCase
class JobPostingTests(BaseTestCase):
def test_job_creation_minimal_data(self):
"""Test job creation with minimal required fields"""
job = JobPosting.objects.create(
title='Minimal Job',
department='IT',
job_type='FULL_TIME',
workplace_type='REMOTE',
created_by=self.user
)
self.assertEqual(job.title, 'Minimal Job')
self.assertIsNotNone(job.slug)
def test_job_posting_validation_invalid_data(self):
"""Test that invalid data raises validation errors"""
with self.assertRaises(ValueError):
JobPosting.objects.create(
title='', # Empty title
department='IT',
job_type='FULL_TIME',
workplace_type='REMOTE',
created_by=self.user
)
```
## Testing External Integrations
### Zoom API Integration
```python
@patch('recruitment.views.create_zoom_meeting')
def test_meeting_creation(self, mock_zoom):
"""Test Zoom meeting creation with mocked API"""
mock_zoom.return_value = {
'status': 'success',
'meeting_details': {
'meeting_id': '123456789',
'join_url': 'https://zoom.us/j/123456789'
}
}
# Test meeting creation logic
result = create_zoom_meeting('Test Meeting', start_time, duration)
self.assertEqual(result['status'], 'success')
mock_zoom.assert_called_once()
```
### LinkedIn Integration
```python
@patch('recruitment.views.LinkedinService')
def test_linkedin_posting(self, mock_linkedin):
"""Test LinkedIn job posting with mocked service"""
mock_service = mock_linkedin.return_value
mock_service.create_job_post.return_value = {
'success': True,
'post_id': 'linkedin123',
'post_url': 'https://linkedin.com/jobs/view/linkedin123'
}
# Test LinkedIn posting logic
result = mock_service.create_job_post(job)
self.assertTrue(result['success'])
```
## Performance Testing
### Running Performance Tests
```bash
# Run slow tests only
pytest -m slow
# Profile test execution
pytest --profile
```
### Performance Considerations
1. Use `TransactionTestCase` for tests that require database commits
2. Mock external API calls to avoid network delays
3. Use `select_related` and `prefetch_related` in queries
4. Test with realistic data volumes
## Continuous Integration
### GitHub Actions Integration
```yaml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9, 3.10, 3.11]
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-django pytest-cov
- name: Run tests
run: |
pytest --cov=recruitment --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
```
## Troubleshooting Common Issues
### Database Issues
```python
# Use TransactionTestCase for tests that modify database structure
from django.test import TransactionTestCase
class MyTests(TransactionTestCase):
def test_database_modification(self):
# This test will properly clean up the database
pass
```
### Mocking Issues
```python
# Correct way to mock imports
from unittest.mock import patch
@patch('recruitment.views.zoom_api.ZoomClient')
def test_zoom_integration(self, mock_zoom_client):
mock_instance = mock_zoom_client.return_value
mock_instance.create_meeting.return_value = {'success': True}
# Test code
```
### HTMX Testing
```python
# Test HTMX responses
def test_htmx_partial_update(self):
response = self.client.get('/some-url/', HTTP_HX_REQUEST='true')
self.assertEqual(response.status_code, 200)
self.assertIn('partial-content', response.content)
```
## Contributing to Tests
### Adding New Tests
1. Place tests in appropriate test modules
2. Use existing fixtures when possible
3. Add descriptive docstrings
4. Mark tests with appropriate markers
5. Ensure new tests maintain coverage requirements
### Test Review Checklist
- [ ] Tests are properly isolated
- [ ] Fixtures are used effectively
- [ ] External dependencies are mocked
- [ ] Edge cases are covered
- [ ] Naming conventions are followed
- [ ] Documentation is clear
## Resources
- [Django Testing Documentation](https://docs.djangoproject.com/en/stable/topics/testing/)
- [Pytest Documentation](https://docs.pytest.org/)
- [Test-Driven Development](https://testdriven.io/blog/tdd-with-django-and-react/)
- [Code Coverage Best Practices](https://pytest-cov.readthedocs.io/)

1
ZoomMeetingAPISpec.json Normal file

File diff suppressed because one or more lines are too long

394
conftest.py Normal file
View File

@ -0,0 +1,394 @@
"""
Pytest configuration and fixtures for the recruitment application tests.
"""
import os
import sys
import django
from pathlib import Path
# Setup Django
BASE_DIR = Path(__file__).resolve().parent
# Add the project root to sys.path
sys.path.append(str(BASE_DIR))
# Set the Django settings module
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
# Configure Django
django.setup()
import pytest
from django.test import TestCase
from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from datetime import datetime, time, timedelta, date
from recruitment.models import (
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField,
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
TrainingMaterial, Source, HiringAgency, Profile, MeetingComment, JobPostingImage,
BreakTime
)
from recruitment.forms import (
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
CandidateStageForm, InterviewScheduleForm, BreakTimeFormSet
)
# Removed: django_db_setup fixture conflicts with Django TestCase
# Django TestCase handles its own database setup
@pytest.fixture
def user():
"""Create a regular user for testing"""
return User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123',
is_staff=False
)
@pytest.fixture
def staff_user():
"""Create a staff user for testing"""
return User.objects.create_user(
username='staffuser',
email='staff@example.com',
password='testpass123',
is_staff=True
)
@pytest.fixture
def profile(user):
"""Create a user profile"""
return Profile.objects.create(user=user)
@pytest.fixture
def job(staff_user):
"""Create a job posting for testing"""
return JobPosting.objects.create(
title='Software Engineer',
department='IT',
job_type='FULL_TIME',
workplace_type='REMOTE',
location_country='Saudi Arabia',
description='Job description',
qualifications='Job qualifications',
created_by=staff_user,
status='ACTIVE',
max_applications=100,
open_positions=1
)
@pytest.fixture
def candidate(job):
"""Create a candidate for testing"""
return Candidate.objects.create(
first_name='John',
last_name='Doe',
email='john@example.com',
phone='1234567890',
job=job,
stage='Applied'
)
@pytest.fixture
def zoom_meeting():
"""Create a Zoom meeting for testing"""
return ZoomMeeting.objects.create(
topic='Interview with John Doe',
start_time=timezone.now() + timedelta(hours=1),
duration=60,
timezone='UTC',
join_url='https://zoom.us/j/123456789',
meeting_id='123456789',
status='waiting'
)
@pytest.fixture
def form_template(staff_user, job):
"""Create a form template for testing"""
return FormTemplate.objects.create(
job=job,
name='Test Application Form',
description='Test form template',
created_by=staff_user,
is_active=True
)
@pytest.fixture
def form_stage(form_template):
"""Create a form stage for testing"""
return FormStage.objects.create(
template=form_template,
name='Personal Information',
order=0
)
@pytest.fixture
def form_field(form_stage):
"""Create a form field for testing"""
return FormField.objects.create(
stage=form_stage,
label='First Name',
field_type='text',
order=0,
required=True
)
@pytest.fixture
def form_submission(form_template):
"""Create a form submission for testing"""
return FormSubmission.objects.create(
template=form_template,
applicant_name='John Doe',
applicant_email='john@example.com'
)
@pytest.fixture
def field_response(form_submission, form_field):
"""Create a field response for testing"""
return FieldResponse.objects.create(
submission=form_submission,
field=form_field,
value='John'
)
@pytest.fixture
def interview_schedule(staff_user, job):
"""Create an interview schedule for testing"""
# Create candidates first
candidates = []
for i in range(3):
candidate = Candidate.objects.create(
first_name=f'Candidate{i}',
last_name=f'Test{i}',
email=f'candidate{i}@example.com',
phone=f'12345678{i}',
job=job,
stage='Interview'
)
candidates.append(candidate)
return InterviewSchedule.objects.create(
job=job,
created_by=staff_user,
start_date=date.today() + timedelta(days=1),
end_date=date.today() + timedelta(days=7),
working_days=[0, 1, 2, 3, 4], # Mon-Fri
start_time=time(9, 0),
end_time=time(17, 0),
interview_duration=60,
buffer_time=15,
break_start_time=time(12, 0),
break_end_time=time(13, 0)
)
@pytest.fixture
def scheduled_interview(candidate, job, zoom_meeting):
"""Create a scheduled interview for testing"""
return ScheduledInterview.objects.create(
candidate=candidate,
job=job,
zoom_meeting=zoom_meeting,
interview_date=timezone.now().date(),
interview_time=time(10, 0),
status='scheduled'
)
@pytest.fixture
def meeting_comment(user, zoom_meeting):
"""Create a meeting comment for testing"""
return MeetingComment.objects.create(
meeting=zoom_meeting,
author=user,
content='This is a test comment'
)
@pytest.fixture
def file_content():
"""Create test file content"""
return b'%PDF-1.4\n% ... test content ...'
@pytest.fixture
def uploaded_file(file_content):
"""Create an uploaded file for testing"""
return SimpleUploadedFile(
'test_file.pdf',
file_content,
content_type='application/pdf'
)
@pytest.fixture
def job_form_data():
"""Basic job posting form data for testing"""
return {
'title': 'Test Job Title',
'department': 'IT',
'job_type': 'FULL_TIME',
'workplace_type': 'REMOTE',
'location_city': 'Riyadh',
'location_state': 'Riyadh',
'location_country': 'Saudi Arabia',
'description': 'Job description',
'qualifications': 'Job qualifications',
'salary_range': '5000-7000',
'application_deadline': '2025-12-31',
'max_applications': '100',
'open_positions': '1',
'hash_tags': '#hiring, #jobopening'
}
@pytest.fixture
def candidate_form_data(job):
"""Basic candidate form data for testing"""
return {
'job': job.id,
'first_name': 'John',
'last_name': 'Doe',
'phone': '1234567890',
'email': 'john@example.com'
}
@pytest.fixture
def zoom_meeting_form_data():
"""Basic Zoom meeting form data for testing"""
start_time = timezone.now() + timedelta(hours=1)
return {
'topic': 'Test Meeting',
'start_time': start_time.strftime('%Y-%m-%dT%H:%M'),
'duration': 60
}
@pytest.fixture
def interview_schedule_form_data(job):
"""Basic interview schedule form data for testing"""
# Create candidates first
candidates = []
for i in range(2):
candidate = Candidate.objects.create(
first_name=f'Interview{i}',
last_name=f'Candidate{i}',
email=f'interview{i}@example.com',
phone=f'12345678{i}',
job=job,
stage='Interview'
)
candidates.append(candidate)
return {
'candidates': [c.pk for c in candidates],
'start_date': (date.today() + timedelta(days=1)).isoformat(),
'end_date': (date.today() + timedelta(days=7)).isoformat(),
'working_days': [0, 1, 2, 3, 4],
'start_time': '09:00',
'end_time': '17:00',
'interview_duration': '60',
'buffer_time': '15'
}
@pytest.fixture
def client():
"""Django test client"""
from django.test import Client
return Client()
@pytest.fixture
def authenticated_client(client, user):
"""Authenticated Django test client"""
client.force_login(user)
return client
@pytest.fixture
def authenticated_staff_client(client, staff_user):
"""Authenticated staff Django test client"""
client.force_login(staff_user)
return client
@pytest.fixture
def mock_zoom_api():
"""Mock Zoom API responses"""
with pytest.MonkeyPatch().context() as m:
m.setattr('recruitment.utils.create_zoom_meeting', lambda *args, **kwargs: {
'status': 'success',
'meeting_details': {
'meeting_id': '123456789',
'join_url': 'https://zoom.us/j/123456789',
'password': 'meeting123'
},
'zoom_gateway_response': {'status': 'waiting'}
})
yield
@pytest.fixture
def mock_time_slots():
"""Mock available time slots for interview scheduling"""
return [
{'date': date.today() + timedelta(days=1), 'time': '10:00'},
{'date': date.today() + timedelta(days=1), 'time': '11:00'},
{'date': date.today() + timedelta(days=1), 'time': '14:00'},
{'date': date.today() + timedelta(days=2), 'time': '09:00'},
{'date': date.today() + timedelta(days=2), 'time': '15:00'}
]
# Test markers
def pytest_configure(config):
"""Configure custom markers"""
config.addinivalue_line(
"markers", "slow: marks tests as slow (deselect with '-m \"not slow\"')"
)
config.addinivalue_line(
"markers", "integration: marks tests as integration tests"
)
config.addinivalue_line(
"markers", "unit: marks tests as unit tests"
)
config.addinivalue_line(
"markers", "security: marks tests as security tests"
)
config.addinivalue_line(
"markers", "api: marks tests as API tests"
)
# Pytest hooks for better test output
# Note: HTML reporting hooks are commented out to avoid plugin validation issues
# def pytest_html_report_title(report):
# """Set the HTML report title"""
# report.title = "Recruitment Application Test Report"
# def pytest_runtest_logreport(report):
# """Customize test output"""
# if report.when == 'call' and report.failed:
# # Add custom information for failed tests
# pass

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

20
pytest.ini Normal file
View File

@ -0,0 +1,20 @@
[tool:pytest]
DJANGO_SETTINGS_MODULE = NorahUniversity.settings
python_files = tests.py test_*.py *_tests.py
python_classes = Test*
python_functions = test_*
addopts =
--verbose
--tb=short
--strict-markers
--durations=10
--cov=recruitment
--cov-report=term-missing
--cov-report=html:htmlcov
--cov-fail-under=80
testpaths = recruitment
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
unit: marks tests as unit tests
security: marks tests as security tests

View File

@ -0,0 +1,467 @@
# ERP Integration Guide for ATS
## Table of Contents
1. [Introduction](#introduction)
2. [Setup and Configuration](#setup-and-configuration)
3. [API Documentation](#api-documentation)
4. [Creating Job Postings](#creating-job-postings)
5. [Updating Job Postings](#updating-job-postings)
6. [Monitoring and Troubleshooting](#monitoring-and-troubleshooting)
7. [Best Practices](#best-practices)
8. [Appendix](#appendix)
## Introduction
This guide explains how to integrate your ERP system with the Applicant Tracking System (ATS) for seamless job posting management. The integration allows you to automatically create and update job postings in the ATS directly from your ERP system.
### Benefits
- **Automated Job Management**: Create and update job postings without manual data entry
- **Data Consistency**: Ensure job information is synchronized across systems
- **Audit Trail**: Complete logging of all integration activities
- **Security**: Secure API-based communication with authentication
### System Requirements
- ERP system with HTTP request capabilities
- HTTPS support (required for production)
- JSON data format support
- Access to ATS base URL (e.g., https://your-ats-domain.com/recruitment/)
## Setup and Configuration
### 1. Configure Source in ATS Admin
1. Log in to the ATS Django admin interface
2. Navigate to **Recruitment > Sources**
3. Click **Add Source** to create a new integration source
4. Fill in the following information:
#### Basic Information
- **Name**: Unique identifier for your ERP system (e.g., "Main_ERP")
- **Source Type**: "ERP"
- **Description**: Brief description of the integration
#### Technical Details
- **IP Address**: Your ERP system's IP address (for logging)
- **API Key**: Generate a secure API key for authentication
- **API Secret**: Generate a secure API secret for authentication
- **Trusted IPs**: Comma-separated list of IP addresses allowed to make requests (e.g., "192.168.1.100,10.0.0.50")
#### Integration Status
- **Is Active**: Enable the integration
- **Integration Version**: Your ERP integration version (e.g., "1.0")
- **Sync Status**: Set to "IDLE" initially
5. Save the source configuration
### 2. Test the Connection
Use the health check endpoint to verify connectivity:
```bash
curl -X GET https://your-ats-domain.com/recruitment/integration/erp/health/
```
Expected response:
```json
{
"status": "healthy",
"timestamp": "2025-10-06T14:30:00Z",
"services": {
"erp_integration": "available",
"database": "connected"
}
}
```
## API Documentation
### Base URL
```
https://your-ats-domain.com/recruitment/integration/erp/
```
### Authentication
Include your API key in either of these ways:
- **Header**: `X-API-Key: your_api_key_here`
- **Query Parameter**: `?api_key=your_api_key_here`
### Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | GET | Health check and API info |
| `/create-job/` | POST | Create a new job posting |
| `/update-job/` | POST | Update an existing job posting |
| `/health/` | GET | Health check |
### Response Format
All responses follow this structure:
```json
{
"status": "success" | "error",
"message": "Human-readable message",
"data": { ... }, // Present for successful requests
"processing_time": 0.45 // In seconds
}
```
## Creating Job Postings
### Step-by-Step Guide
1. Prepare your job data in JSON format
2. Send a POST request to `/create-job/`
3. Verify the response and check for errors
4. Monitor the integration logs for confirmation
### Request Format
```json
{
"action": "create_job",
"source_name": "Main_ERP",
"title": "Senior Software Engineer",
"department": "Information Technology",
"job_type": "full-time",
"workplace_type": "hybrid",
"location_city": "Riyadh",
"location_state": "Riyadh",
"location_country": "Saudi Arabia",
"description": "We are looking for an experienced software engineer...",
"qualifications": "Bachelor's degree in Computer Science...",
"salary_range": "SAR 18,000 - 25,000",
"benefits": "Health insurance, Annual leave...",
"application_url": "https://careers.yourcompany.com/job/12345",
"application_deadline": "2025-12-31",
"application_instructions": "Submit your resume and cover letter...",
"auto_publish": true
}
```
### Required Fields
| Field | Type | Description |
|-------|------|-------------|
| `action` | String | Must be "create_job" |
| `source_name` | String | Name of the configured source |
| `title` | String | Job title |
| `application_url` | String | URL where candidates apply |
### Optional Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `department` | String | - | Department/Division |
| `job_type` | String | "FULL_TIME" | FULL_TIME, PART_TIME, CONTRACT, INTERNSHIP, FACULTY, TEMPORARY |
| `workplace_type` | String | "ON_SITE" | ON_SITE, REMOTE, HYBRID |
| `location_city` | String | - | City |
| `location_state` | String | - | State/Province |
| `location_country` | String | "United States" | Country |
| `description` | String | - | Job description |
| `qualifications` | String | - | Required qualifications |
| `salary_range` | String | - | Salary information |
| `benefits` | String | - | Benefits offered |
| `application_deadline` | String | - | Application deadline (YYYY-MM-DD) |
| `application_instructions` | String | - | Special instructions for applicants |
| `auto_publish` | Boolean | false | Automatically publish the job |
### Example Request
```bash
curl -X POST https://your-ats-domain.com/recruitment/integration/erp/create-job/ \
-H "Content-Type: application/json" \
-H "X-API-Key: your_api_key_here" \
-d '{
"action": "create_job",
"source_name": "Main_ERP",
"title": "Senior Software Engineer",
"department": "Information Technology",
"job_type": "full-time",
"workplace_type": "hybrid",
"location_city": "Riyadh",
"location_country": "Saudi Arabia",
"description": "We are looking for an experienced software engineer...",
"application_url": "https://careers.yourcompany.com/job/12345",
"auto_publish": true
}'
```
### Example Response
```json
{
"status": "success",
"message": "Job created successfully",
"data": {
"job_id": "KAAUH-2025-0001",
"title": "Senior Software Engineer",
"status": "PUBLISHED",
"created_at": "2025-10-06T14:30:00Z"
},
"processing_time": 0.32
}
```
## Updating Job Postings
### Step-by-Step Guide
1. Obtain the internal job ID from the ATS (from creation response or job listing)
2. Prepare your update data in JSON format
3. Send a POST request to `/update-job/`
4. Verify the response and check for errors
### Request Format
```json
{
"action": "update_job",
"source_name": "Main_ERP",
"job_id": "KAAUH-2025-0001",
"title": "Senior Software Engineer (Updated)",
"department": "Information Technology",
"salary_range": "SAR 20,000 - 28,000",
"status": "PUBLISHED"
}
```
### Required Fields
| Field | Type | Description |
|-------|------|-------------|
| `action` | String | Must be "update_job" |
| `source_name` | String | Name of the configured source |
| `job_id` | String | Internal job ID from ATS |
### Optional Fields
All fields from the create job are available for update, except:
- `auto_publish` (not applicable for updates)
### Example Request
```bash
curl -X POST https://your-ats-domain.com/recruitment/integration/erp/update-job/ \
-H "Content-Type: application/json" \
-H "X-API-Key: your_api_key_here" \
-d '{
"action": "update_job",
"source_name": "Main_ERP",
"job_id": "KAAUH-2025-0001",
"salary_range": "SAR 20,000 - 28,000",
"application_deadline": "2026-01-15"
}'
```
### Example Response
```json
{
"status": "success",
"message": "Job updated successfully",
"data": {
"job_id": "KAAUH-2025-0001",
"title": "Senior Software Engineer",
"status": "PUBLISHED",
"updated_at": "2025-10-06T14:35:00Z"
},
"processing_time": 0.28
}
```
## Monitoring and Troubleshooting
### Viewing Integration Logs
1. Log in to the ATS Django admin interface
2. Navigate to **Recruitment > Integration Logs**
3. Use the following filters to monitor activity:
- **Source**: Filter by your ERP system
- **Action**: Filter by REQUEST/RESPONSE/ERROR
- **Status Code**: Filter by HTTP status codes
- **Date Range**: View logs for specific time periods
### Common Error Codes
| Status Code | Description | Solution |
|-------------|-------------|----------|
| 400 Bad Request | Invalid request data | Check required fields and data types |
| 401 Unauthorized | Invalid API key | Verify API key is correct and active |
| 403 Forbidden | IP not trusted | Add your ERP IP to trusted IPs list |
| 404 Not Found | Source not found | Verify source name or ID is correct |
| 409 Conflict | Job already exists | Check if job with same title already exists |
| 500 Internal Error | Server error | Contact ATS support |
### Health Check
Regularly test the connection:
```bash
curl -X GET https://your-ats-domain.com/recruitment/integration/erp/health/
```
### Performance Monitoring
Monitor response times and processing durations in the integration logs. If processing times exceed 2 seconds, investigate performance issues.
### Troubleshooting Steps
1. **Check Authentication**
- Verify API key is correct
- Ensure source is active
2. **Check IP Whitelisting**
- Verify your ERP IP is in the trusted list
3. **Validate Request Data**
- Check required fields are present
- Verify data types are correct
- Ensure URLs are valid
4. **Check Logs**
- View integration logs for error details
- Check request/response data in logs
5. **Test with Minimal Data**
- Send a request with only required fields
- Gradually add optional fields
## Best Practices
### Security
- Use HTTPS for all requests
- Rotate API keys regularly
- Store API keys securely in your ERP system
- Limit trusted IPs to only necessary systems
### Data Validation
- Validate all data before sending
- Use consistent date formats (YYYY-MM-DD)
- Sanitize special characters in text fields
- Test with sample data before production
### Error Handling
- Implement retry logic for transient errors
- Log all integration attempts locally
- Set up alerts for frequent failures
- Have a manual fallback process
### Maintenance
- Regularly review integration logs
- Monitor API performance metrics
- Keep API keys and credentials updated
- Schedule regular health checks
### Performance
- Batch multiple job operations when possible
- Avoid sending unnecessary data
- Use compression for large requests
- Monitor response times
## Appendix
### Complete Field Reference
#### Job Types
- `FULL_TIME`: Full-time position
- `PART_TIME`: Part-time position
- `CONTRACT`: Contract position
- `INTERNSHIP`: Internship position
- `FACULTY`: Faculty/academic position
- `TEMPORARY`: Temporary position
#### Workplace Types
- `ON_SITE`: On-site work
- `REMOTE`: Remote work
- `HYBRID`: Hybrid (combination of on-site and remote)
#### Status Values
- `DRAFT`: Job is in draft status
- `PUBLISHED`: Job is published and active
- `CLOSED`: Job is closed to applications
- `ARCHIVED`: Job is archived
### Error Code Dictionary
| Code | Error | Description |
|------|-------|-------------|
| `MISSING_FIELD` | Required field is missing | Check all required fields are provided |
| `INVALID_TYPE` | Invalid data type | Verify field data types match requirements |
| `INVALID_URL` | Invalid application URL | Ensure URL is properly formatted |
| `JOB_EXISTS` | Job already exists | Use update action instead of create |
| `INVALID_SOURCE` | Source not found | Verify source name or ID |
| `IP_NOT_ALLOWED` | IP not trusted | Add IP to trusted list |
### Sample Scripts
#### Python Example
```python
import requests
import json
# Configuration
ATS_BASE_URL = "https://your-ats-domain.com/recruitment/integration/erp/"
API_KEY = "your_api_key_here"
SOURCE_NAME = "Main_ERP"
# Create job
def create_job(job_data):
url = f"{ATS_BASE_URL}create-job/"
headers = {
"Content-Type": "application/json",
"X-API-Key": API_KEY
}
payload = {
"action": "create_job",
"source_name": SOURCE_NAME,
**job_data
}
response = requests.post(url, headers=headers, json=payload)
return response.json()
# Update job
def update_job(job_id, update_data):
url = f"{ATS_BASE_URL}update-job/"
headers = {
"Content-Type": "application/json",
"X-API-Key": API_KEY
}
payload = {
"action": "update_job",
"source_name": SOURCE_NAME,
"job_id": job_id,
**update_data
}
response = requests.post(url, headers=headers, json=payload)
return response.json()
# Example usage
job_data = {
"title": "Software Engineer",
"department": "IT",
"application_url": "https://careers.example.com/job/123"
}
result = create_job(job_data)
print(json.dumps(result, indent=2))
```
### Contact Information
For technical support:
- **Email**: support@ats-domain.com
- **Phone**: +966 50 123 4567
- **Support Hours**: Sunday - Thursday, 8:00 AM - 4:00 PM (GMT+3)
---
*Last Updated: October 6, 2025*
*Version: 1.0*

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,10 +1,288 @@
from django.contrib import admin
from django.utils.html import format_html
from django.urls import reverse
from django.utils import timezone
from .models import (
JobPosting, Candidate, TrainingMaterial, ZoomMeeting,
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment
)
from .models import FormTemplate, FormStage, FormField,FieldResponse,FormSubmission
class FormFieldInline(admin.TabularInline):
model = FormField
extra = 1
ordering = ('order',)
admin.site.register(FormTemplate)
class FormStageInline(admin.TabularInline):
model = FormStage
extra = 1
ordering = ('order',)
inlines = [FormFieldInline]
@admin.register(Source)
class SourceAdmin(admin.ModelAdmin):
list_display = ['name', 'source_type', 'ip_address', 'is_active', 'sync_status', 'created_at']
list_filter = ['source_type', 'is_active', 'sync_status', 'created_at']
search_fields = ['name', 'description']
readonly_fields = ['created_at', 'last_sync_at']
fieldsets = (
('Basic Information', {
'fields': ('name', 'source_type', 'description')
}),
('Technical Details', {
'fields': ('ip_address', 'api_key', 'api_secret', 'trusted_ips')
}),
('Integration Status', {
'fields': ('is_active', 'integration_version', 'sync_status', 'last_sync_at', 'created_at')
}),
)
save_on_top = True
actions = ['activate_sources', 'deactivate_sources']
def activate_sources(self, request, queryset):
updated = queryset.update(is_active=True)
self.message_user(request, f'{updated} sources activated.')
activate_sources.short_description = 'Activate selected sources'
def deactivate_sources(self, request, queryset):
updated = queryset.update(is_active=False)
self.message_user(request, f'{updated} sources deactivated.')
deactivate_sources.short_description = 'Deactivate selected sources'
@admin.register(IntegrationLog)
class IntegrationLogAdmin(admin.ModelAdmin):
list_display = ['source', 'action', 'endpoint', 'status_code', 'ip_address', 'created_at']
list_filter = ['action', 'status_code', 'source', 'created_at']
search_fields = ['source__name', 'endpoint', 'error_message']
readonly_fields = ['source', 'action', 'endpoint', 'method', 'request_data',
'response_data', 'status_code', 'error_message', 'ip_address',
'user_agent', 'processing_time', 'created_at']
fieldsets = (
('Request Information', {
'fields': ('source', 'action', 'endpoint', 'method', 'ip_address', 'user_agent')
}),
('Data', {
'fields': ('request_data', 'response_data')
}),
('Results', {
'fields': ('status_code', 'error_message', 'processing_time', 'created_at')
}),
)
save_on_top = False
date_hierarchy = 'created_at'
@admin.register(HiringAgency)
class HiringAgencyAdmin(admin.ModelAdmin):
list_display = ['name', 'contact_person', 'email', 'phone', 'country', 'created_at']
list_filter = ['country', 'created_at']
search_fields = ['name', 'contact_person', 'email', 'phone', 'notes']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Basic Information', {
'fields': ('name', 'contact_person', 'email', 'phone', 'website')
}),
('Location Details', {
'fields': ('country', 'address')
}),
('Additional Information', {
'fields': ('notes', 'created_at', 'updated_at')
}),
)
save_on_top = True
@admin.register(JobPosting)
class JobPostingAdmin(admin.ModelAdmin):
list_display = ['internal_job_id', 'title', 'department', 'job_type', 'status', 'posted_to_linkedin', 'created_at']
list_filter = ['job_type', 'status', 'workplace_type', 'source', 'created_at']
search_fields = ['title', 'department', 'internal_job_id']
readonly_fields = ['internal_job_id', 'created_at', 'updated_at']
fieldsets = (
('Basic Information', {
'fields': ('title', 'department', 'job_type', 'workplace_type', 'status')
}),
('Location', {
'fields': ('location_city', 'location_state', 'location_country')
}),
('Job Details', {
'fields': ('description', 'qualifications', 'salary_range', 'benefits')
}),
('Application Information', {
'fields': ('application_url', 'application_deadline', 'application_instructions')
}),
('Internal Tracking', {
'fields': ('internal_job_id', 'created_by', 'created_at', 'updated_at')
}),
('Integration', {
'fields': ('source', 'open_positions', 'position_number', 'reporting_to',)
}),
('LinkedIn Integration', {
'fields': ('posted_to_linkedin', 'linkedin_post_id', 'linkedin_post_url', 'linkedin_posted_at')
}),
)
save_on_top = True
actions = ['make_published', 'make_draft', 'mark_as_closed']
def make_published(self, request, queryset):
updated = queryset.update(status='PUBLISHED')
self.message_user(request, f'{updated} job postings marked as published.')
make_published.short_description = 'Mark selected jobs as published'
def make_draft(self, request, queryset):
updated = queryset.update(status='DRAFT')
self.message_user(request, f'{updated} job postings marked as draft.')
make_draft.short_description = 'Mark selected jobs as draft'
def mark_as_closed(self, request, queryset):
updated = queryset.update(status='CLOSED')
self.message_user(request, f'{updated} job postings marked as closed.')
mark_as_closed.short_description = 'Mark selected jobs as closed'
@admin.register(Candidate)
class CandidateAdmin(admin.ModelAdmin):
list_display = ['full_name', 'job', 'email', 'phone', 'stage', 'applied','is_resume_parsed', 'created_at']
list_filter = ['stage', 'applied', 'created_at', 'job__department']
search_fields = ['first_name', 'last_name', 'email', 'phone']
readonly_fields = ['slug', 'created_at', 'updated_at']
fieldsets = (
('Personal Information', {
'fields': ('first_name', 'last_name', 'email', 'phone', 'resume')
}),
('Application Details', {
'fields': ('job', 'applied', 'stage','is_resume_parsed')
}),
('Interview Process', {
'fields': ('exam_date', 'exam_status', 'interview_date', 'interview_status', 'offer_date', 'offer_status', 'join_date')
}),
('Scoring', {
'fields': ('ai_analysis_data',)
}),
('Additional Information', {
'fields': ('submitted_by_agency', 'created_at', 'updated_at')
}),
)
save_on_top = True
actions = ['mark_as_applied', 'mark_as_not_applied']
def mark_as_applied(self, request, queryset):
updated = queryset.update(applied=True)
self.message_user(request, f'{updated} candidates marked as applied.')
mark_as_applied.short_description = 'Mark selected candidates as applied'
def mark_as_not_applied(self, request, queryset):
updated = queryset.update(applied=False)
self.message_user(request, f'{updated} candidates marked as not applied.')
mark_as_not_applied.short_description = 'Mark selected candidates as not applied'
@admin.register(TrainingMaterial)
class TrainingMaterialAdmin(admin.ModelAdmin):
list_display = ['title', 'created_by', 'created_at']
list_filter = ['created_at']
search_fields = ['title', 'content']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Basic Information', {
'fields': ('title', 'content')
}),
('Media', {
'fields': ('video_link', 'file')
}),
('Metadata', {
'fields': ('created_by', 'created_at', 'updated_at')
}),
)
save_on_top = True
@admin.register(ZoomMeeting)
class ZoomMeetingAdmin(admin.ModelAdmin):
list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
list_filter = ['timezone', 'created_at']
search_fields = ['topic', 'meeting_id']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Meeting Details', {
'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone','status')
}),
('Meeting Settings', {
'fields': ('participant_video', 'join_before_host', 'mute_upon_entry', 'waiting_room')
}),
('Access', {
'fields': ('join_url',)
}),
('System Response', {
'fields': ('zoom_gateway_response', 'created_at', 'updated_at')
}),
)
save_on_top = True
@admin.register(MeetingComment)
class MeetingCommentAdmin(admin.ModelAdmin):
list_display = ['meeting', 'author', 'created_at', 'updated_at']
list_filter = ['created_at', 'author', 'meeting']
search_fields = ['content', 'meeting__topic', 'author__username']
readonly_fields = ['created_at', 'updated_at', 'slug']
fieldsets = (
('Meeting Information', {
'fields': ('meeting', 'author')
}),
('Comment Content', {
'fields': ('content',)
}),
('Timestamps', {
'fields': ('created_at', 'updated_at', 'slug')
}),
)
save_on_top = True
@admin.register(FormTemplate)
class FormTemplateAdmin(admin.ModelAdmin):
list_display = ['name', 'created_by', 'created_at', 'is_active']
list_filter = ['is_active', 'created_at']
search_fields = ['name', 'description']
readonly_fields = ['created_at', 'updated_at']
inlines = [FormStageInline]
fieldsets = (
('Basic Information', {
'fields': ('name', 'description', 'created_by', 'is_active')
}),
('Timeline', {
'fields': ('created_at', 'updated_at')
}),
)
save_on_top = True
@admin.register(FormSubmission)
class FormSubmissionAdmin(admin.ModelAdmin):
list_display = ['template', 'applicant_name', 'submitted_at', 'submitted_by']
list_filter = ['submitted_at', 'template']
search_fields = ['applicant_name', 'applicant_email']
readonly_fields = ['submitted_at']
fieldsets = (
('Submission Information', {
'fields': ('template', 'submitted_by', 'submitted_at')
}),
('Applicant Information', {
'fields': ('applicant_name', 'applicant_email')
}),
)
save_on_top = True
# Register other models
admin.site.register(FormStage)
admin.site.register(FormField)
admin.site.register(FormSubmission)
admin.site.register(FieldResponse)
admin.site.register(InterviewSchedule)
admin.site.register(Profile)
# admin.site.register(HiringAgency)
admin.site.register(JobPostingImage)

View File

@ -4,3 +4,5 @@ from django.apps import AppConfig
class RecruitmentConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'recruitment'
def ready(self):
import recruitment.signals

17
recruitment/decorators.py Normal file
View File

@ -0,0 +1,17 @@
from functools import wraps
from datetime import date
from django.shortcuts import redirect, get_object_or_404
from django.http import HttpResponseNotFound
def job_not_expired(view_func):
@wraps(view_func)
def _wrapped_view(request, job_id, *args, **kwargs):
from .models import JobPosting
job = get_object_or_404(JobPosting, pk=job_id)
if job.expiration_date and job.application_deadline< date.today():
return redirect('expired_job_page')
return view_func(request, job_id, *args, **kwargs)
return _wrapped_view

View File

@ -0,0 +1,271 @@
import json
import logging
from datetime import datetime
from typing import Dict, Any, Optional
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.http import HttpRequest
from .models import Source, JobPosting, IntegrationLog
from .serializers import JobPostingSerializer
logger = logging.getLogger(__name__)
class ERPIntegrationService:
"""
Service to handle integration between external ERP system and ATS
"""
def __init__(self, source: Source):
self.source = source
self.logger = logging.getLogger(f'{__name__}.{source.name}')
def validate_request(self, request: HttpRequest) -> tuple[bool, str]:
"""
Validate the incoming request from ERP system
Returns: (is_valid, error_message)
"""
# Check if source is active
if not self.source.is_active:
return False, "Source is not active"
# Check if trusted IPs are configured and validate request IP
if self.source.trusted_ips:
client_ip = self.get_client_ip(request)
trusted_ips = [ip.strip() for ip in self.source.trusted_ips.split(',')]
if client_ip not in trusted_ips:
self.logger.warning(f"Request from untrusted IP: {client_ip}")
return False, f"Request from untrusted IP: {client_ip}"
# Check API key if provided
if self.source.api_key:
api_key = request.headers.get('X-API-Key') or request.GET.get('api_key')
if not api_key or api_key != self.source.api_key:
self.logger.warning("Invalid or missing API key")
return False, "Invalid or missing API key"
return True, ""
def log_integration_request(self, request: HttpRequest, action: str, **kwargs):
"""
Log the integration request/response
"""
IntegrationLog.objects.create(
source=self.source,
action=action,
endpoint=request.path,
method=request.method,
request_data=self.get_request_data(request),
ip_address=self.get_client_ip(request),
user_agent=request.META.get('HTTP_USER_AGENT', ''),
**kwargs
)
def create_job_from_erp(self, request_data: Dict[str, Any]) -> tuple[Optional[JobPosting], str]:
"""
Create a JobPosting from ERP request data
Returns: (job, error_message)
"""
try:
# Map ERP fields to JobPosting fields
job_data = {
'title': request_data.get('title', '').strip(),
'department': request_data.get('department', '').strip(),
'job_type': self.map_job_type(request_data.get('job_type', 'FULL_TIME')),
'workplace_type': self.map_workplace_type(request_data.get('workplace_type', 'ON_SITE')),
'location_city': request_data.get('location_city', '').strip(),
'location_state': request_data.get('location_state', '').strip(),
'location_country': request_data.get('location_country', 'United States').strip(),
'description': request_data.get('description', '').strip(),
'qualifications': request_data.get('qualifications', '').strip(),
'salary_range': request_data.get('salary_range', '').strip(),
'benefits': request_data.get('benefits', '').strip(),
'application_url': request_data.get('application_url', '').strip(),
'application_deadline': self.parse_date(request_data.get('application_deadline')),
'application_instructions': request_data.get('application_instructions', '').strip(),
'created_by': f'ERP Integration: {self.source.name}',
'status': 'DRAFT' if request_data.get('auto_publish', False) else 'DRAFT',
'source': self.source
}
# Validate required fields
if not job_data['title']:
return None, "Job title is required"
# Create the job
job = JobPosting(**job_data)
job.save()
self.logger.info(f"Created job {job.internal_job_id} from ERP integration")
return job, ""
except Exception as e:
error_msg = f"Error creating job from ERP: {str(e)}"
self.logger.error(error_msg)
return None, error_msg
def update_job_from_erp(self, job_id: str, request_data: Dict[str, Any]) -> tuple[Optional[JobPosting], str]:
"""
Update an existing JobPosting from ERP request data
Returns: (job, error_message)
"""
try:
job = JobPosting.objects.get(internal_job_id=job_id)
# Update fields from ERP data
updatable_fields = [
'title', 'department', 'job_type', 'workplace_type',
'location_city', 'location_state', 'location_country',
'description', 'qualifications', 'salary_range', 'benefits',
'application_url', 'application_deadline', 'application_instructions',
'status'
]
for field in updatable_fields:
if field in request_data:
value = request_data[field]
# Special handling for date fields
if field == 'application_deadline':
value = self.parse_date(value)
setattr(job, field, value)
# Update source if provided
if 'source_id' in request_data:
try:
source = Source.objects.get(id=request_data['source_id'])
job.source = source
except Source.DoesNotExist:
pass
job.save()
self.logger.info(f"Updated job {job.internal_job_id} from ERP integration")
return job, ""
except JobPosting.DoesNotExist:
return None, f"Job with ID {job_id} not found"
except Exception as e:
error_msg = f"Error updating job from ERP: {str(e)}"
self.logger.error(error_msg)
return None, error_msg
def validate_erp_data(self, data: Dict[str, Any]) -> tuple[bool, str]:
"""
Validate ERP request data structure
Returns: (is_valid, error_message)
"""
required_fields = ['title']
for field in required_fields:
if field not in data or not data[field]:
return False, f"Required field '{field}' is missing or empty"
# Validate URL format
if data.get('application_url'):
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError as DjangoValidationError
try:
URLValidator()(data['application_url'])
except DjangoValidationError:
return False, "Invalid application URL format"
# Validate job type
if 'job_type' in data and data['job_type']:
valid_job_types = dict(JobPosting.JOB_TYPES)
if data['job_type'] not in valid_job_types:
return False, f"Invalid job type: {data['job_type']}"
# Validate workplace type
if 'workplace_type' in data and data['workplace_type']:
valid_workplace_types = dict(JobPosting.WORKPLACE_TYPES)
if data['workplace_type'] not in valid_workplace_types:
return False, f"Invalid workplace type: {data['workplace_type']}"
return True, ""
# Helper methods
def get_client_ip(self, request: HttpRequest) -> str:
"""Get the client IP address from request"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
def get_request_data(self, request: HttpRequest) -> Dict[str, Any]:
"""Get request data from request object"""
if request.method == 'GET':
return dict(request.GET)
elif request.method in ['POST', 'PUT', 'PATCH']:
try:
if request.content_type == 'application/json':
return json.loads(request.body.decode('utf-8'))
else:
return dict(request.POST)
except:
return {}
return {}
def parse_date(self, date_str: str) -> Optional[datetime.date]:
"""Parse date string from ERP"""
if not date_str:
return None
try:
# Try different date formats
date_formats = [
'%Y-%m-%d',
'%m/%d/%Y',
'%d/%m/%Y',
'%Y-%m-%d %H:%M:%S',
'%m/%d/%Y %H:%M:%S',
'%d/%m/%Y %H:%M:%S'
]
for fmt in date_formats:
try:
dt = datetime.strptime(date_str, fmt)
if fmt.endswith('%H:%M:%S'):
return dt.date()
return dt.date()
except ValueError:
continue
# If no format matches, try to parse with dateutil
from dateutil import parser
dt = parser.parse(date_str)
return dt.date()
except Exception as e:
self.logger.warning(f"Could not parse date '{date_str}': {str(e)}")
return None
def map_job_type(self, erp_job_type: str) -> str:
"""Map ERP job type to ATS job type"""
mapping = {
'full-time': 'FULL_TIME',
'part-time': 'PART_TIME',
'contract': 'CONTRACT',
'internship': 'INTERNSHIP',
'faculty': 'FACULTY',
'temporary': 'TEMPORARY',
}
return mapping.get(erp_job_type.lower(), 'FULL_TIME')
def map_workplace_type(self, erp_workplace_type: str) -> str:
"""Map ERP workplace type to ATS workplace type"""
mapping = {
'onsite': 'ON_SITE',
'on-site': 'ON_SITE',
'remote': 'REMOTE',
'hybrid': 'HYBRID',
}
return mapping.get(erp_workplace_type.lower(), 'ON_SITE')

View File

@ -1,22 +1,182 @@
from django import forms
from .validators import validate_hash_tags
from crispy_forms.helper import FormHelper
from django.core.validators import URLValidator
from django.forms.formsets import formset_factory
from django.utils.translation import gettext_lazy as _
from crispy_forms.layout import Layout, Submit, HTML, Div, Field
from .models import ZoomMeeting, Candidate,Job,TrainingMaterial,JobPosting
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Submit, Row, Column, Field, Div
from django.contrib.auth.models import User
from django.contrib.auth.forms import UserCreationForm
import re
from .models import (
ZoomMeeting, Candidate,TrainingMaterial,JobPosting,
FormTemplate,InterviewSchedule,BreakTime,JobPostingImage,
Profile,MeetingComment,ScheduledInterview,Source
)
# from django_summernote.widgets import SummernoteWidget
from django_ckeditor_5.widgets import CKEditor5Widget
import secrets
import string
from django.core.exceptions import ValidationError
def generate_api_key(length=32):
"""Generate a secure API key"""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
def generate_api_secret(length=64):
"""Generate a secure API secret"""
alphabet = string.ascii_letters + string.digits + '-._~'
return ''.join(secrets.choice(alphabet) for _ in range(length))
class SourceForm(forms.ModelForm):
"""Form for creating and editing sources with API key generation"""
# Hidden field to trigger API key generation
generate_keys = forms.CharField(
widget=forms.HiddenInput(),
required=False,
help_text="Set to 'true' to generate new API keys"
)
# Display fields for generated keys (read-only)
api_key_generated = forms.CharField(
label="Generated API Key",
required=False,
widget=forms.TextInput(attrs={'readonly': True, 'class': 'form-control'})
)
api_secret_generated = forms.CharField(
label="Generated API Secret",
required=False,
widget=forms.TextInput(attrs={'readonly': True, 'class': 'form-control'})
)
class Meta:
model = Source
fields = [
'name', 'source_type', 'description', 'ip_address',
'trusted_ips', 'is_active', 'integration_version'
]
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'e.g., ATS System, ERP Integration',
'required': True
}),
'source_type': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'e.g., ATS, ERP, API',
'required': True
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Brief description of the source system'
}),
'ip_address': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '192.168.1.100'
}),
'trusted_ips': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Comma-separated IP addresses (e.g., 192.168.1.100, 10.0.0.1)'
}),
'integration_version': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'v1.0, v2.1'
}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-3'
self.helper.field_class = 'col-md-9'
# Add generate keys button
self.helper.layout = Layout(
Field('name', css_class='form-control'),
Field('source_type', css_class='form-control'),
Field('description', css_class='form-control'),
Field('ip_address', css_class='form-control'),
Field('trusted_ips', css_class='form-control'),
Field('integration_version', css_class='form-control'),
Field('is_active', css_class='form-check-input'),
# Hidden field for key generation trigger
Field('generate_keys', type='hidden'),
# Display fields for generated keys
Field('api_key_generated', css_class='form-control'),
Field('api_secret_generated', css_class='form-control'),
Submit('submit', 'Save Source', css_class='btn btn-primary mt-3')
)
def clean_name(self):
"""Ensure source name is unique"""
name = self.cleaned_data.get('name')
if name:
# Check for duplicates excluding current instance if editing
instance = self.instance
if not instance.pk: # Creating new instance
if Source.objects.filter(name=name).exists():
raise ValidationError('A source with this name already exists.')
else: # Editing existing instance
if Source.objects.filter(name=name).exclude(pk=instance.pk).exists():
raise ValidationError('A source with this name already exists.')
return name
def clean_trusted_ips(self):
"""Validate and format trusted IP addresses"""
trusted_ips = self.cleaned_data.get('trusted_ips')
if trusted_ips:
# Split by comma and strip whitespace
ips = [ip.strip() for ip in trusted_ips.split(',') if ip.strip()]
# Validate each IP address
for ip in ips:
try:
# Basic IP validation (can be enhanced)
if not (ip.replace('.', '').isdigit() and len(ip.split('.')) == 4):
raise ValidationError(f'Invalid IP address: {ip}')
except Exception:
raise ValidationError(f'Invalid IP address: {ip}')
return ', '.join(ips)
return trusted_ips
def clean(self):
"""Custom validation for the form"""
cleaned_data = super().clean()
# Check if we need to generate API keys
generate_keys = cleaned_data.get('generate_keys')
if generate_keys == 'true':
# Generate new API key and secret
cleaned_data['api_key'] = generate_api_key()
cleaned_data['api_secret'] = generate_api_secret()
# Set display fields for the frontend
cleaned_data['api_key_generated'] = cleaned_data['api_key']
cleaned_data['api_secret_generated'] = cleaned_data['api_secret']
return cleaned_data
class CandidateForm(forms.ModelForm):
class Meta:
model = Candidate
fields = ['job', 'first_name', 'last_name', 'phone', 'email', 'resume', 'stage']
fields = ['job', 'first_name', 'last_name', 'phone', 'email', 'resume',]
labels = {
'first_name': _('First Name'),
'last_name': _('Last Name'),
'phone': _('Phone'),
'email': _('Email'),
'resume': _('Resume'),
'stage': _('Application Stage'),
}
widgets = {
'first_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter first name')}),
@ -63,51 +223,6 @@ class CandidateStageForm(forms.ModelForm):
'stage': forms.Select(attrs={'class': 'form-select'}),
}
def __init__(self, *args, **kwargs):
# Get the current candidate instance for validation
self.candidate = kwargs.pop('candidate', None)
super().__init__(*args, **kwargs)
# Dynamically filter stage choices based on current stage
if self.candidate and self.candidate.pk:
current_stage = self.candidate.stage
available_stages = self.candidate.get_available_stages()
# Filter choices to only include available stages
choices = [(stage, self.candidate.Stage(stage).label)
for stage in available_stages]
self.fields['stage'].choices = choices
# Set initial value to current stage
self.fields['stage'].initial = current_stage
else:
# For new candidates, only show 'Applied' stage
self.fields['stage'].choices = [('Applied', _('Applied'))]
self.fields['stage'].initial = 'Applied'
def clean_stage(self):
"""Validate stage transition"""
new_stage = self.cleaned_data.get('stage')
if not new_stage:
raise forms.ValidationError(_('Please select a stage.'))
# Use model validation for stage transitions
if self.candidate and self.candidate.pk:
current_stage = self.candidate.stage
if new_stage != current_stage:
if not self.candidate.can_transition_to(new_stage):
allowed_stages = self.candidate.get_available_stages()
raise forms.ValidationError(
_('Cannot transition from "%(current)s" to "%(new)s". '
'Allowed transitions: %(allowed)s') % {
'current': current_stage,
'new': new_stage,
'allowed': ', '.join(allowed_stages) or 'None (final stage)'
}
)
return new_stage
class ZoomMeetingForm(forms.ModelForm):
class Meta:
model = ZoomMeeting
@ -137,8 +252,6 @@ class ZoomMeetingForm(forms.ModelForm):
Submit('submit', _('Create Meeting'), css_class='btn btn-primary')
)
# Old JobForm removed - replaced by JobPostingForm
class TrainingMaterialForm(forms.ModelForm):
class Meta:
model = TrainingMaterial
@ -150,8 +263,8 @@ class TrainingMaterialForm(forms.ModelForm):
'file': _('File'),
}
widgets = {
'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter title')}),
'content': forms.Textarea(attrs={'rows': 6, 'class': 'form-control', 'placeholder': _('Enter material content')}),
'title': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter material title')}),
'content': CKEditor5Widget(attrs={'placeholder': _('Enter material content')}),
'video_link': forms.URLInput(attrs={'class': 'form-control', 'placeholder': _('https://www.youtube.com/watch?v=...')}),
'file': forms.FileInput(attrs={'class': 'form-control'}),
}
@ -160,18 +273,21 @@ class TrainingMaterialForm(forms.ModelForm):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-3'
self.helper.field_class = 'col-md-9'
self.helper.form_class = 'g-3'
self.helper.layout = Layout(
Field('title', css_class='form-control'),
Field('content', css_class='form-control'),
Div(
Field('video_link', css_class='form-control'),
Field('file', css_class='form-control'),
css_class='row'
'title',
'content',
Row(
Column('video_link', css_class='col-md-6'),
Column('file', css_class='col-md-6'),
css_class='g-3 mb-4'
),
Submit('submit', _('Save Material'), css_class='btn btn-primary mt-3')
Div(
Submit('submit', _('Create Material'),
css_class='btn btn-main-action'),
css_class='col-12 mt-4'
)
)
@ -184,9 +300,9 @@ class JobPostingForm(forms.ModelForm):
'title', 'department', 'job_type', 'workplace_type',
'location_city', 'location_state', 'location_country',
'description', 'qualifications', 'salary_range', 'benefits',
'application_url', 'application_deadline', 'application_instructions',
'position_number', 'reporting_to', 'start_date', 'status',
'created_by','open_positions','hash_tags'
'application_deadline', 'application_instructions',
'position_number', 'reporting_to',
'open_positions', 'hash_tags', 'max_applications'
]
widgets = {
# Basic Information
@ -222,43 +338,24 @@ class JobPostingForm(forms.ModelForm):
'value': 'United States'
}),
# Job Details
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 6,
'placeholder': 'Provide a comprehensive description of the role, responsibilities, and expectations...',
'required': True
}),
'qualifications': forms.Textarea(attrs={
'class': 'form-control',
'rows': 4,
'placeholder': 'List required qualifications, skills, education, and experience...'
}),
'salary_range': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '$60,000 - $80,000'
}),
'benefits': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Health insurance, retirement plans, tuition reimbursement, etc.'
}),
# Application Information
'application_url': forms.URLInput(attrs={
'class': 'form-control',
'placeholder': 'https://university.edu/careers/job123',
'required': True
}),
# 'application_url': forms.URLInput(attrs={
# 'class': 'form-control',
# 'placeholder': 'https://university.edu/careers/job123',
# 'required': True
# }),
'application_deadline': forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
}),
'application_instructions': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Special instructions for applicants (e.g., required documents, reference requirements, etc.)'
'type': 'date',
'required': True
}),
'open_positions': forms.NumberInput(attrs={
'class': 'form-control',
'min': 1,
@ -267,8 +364,7 @@ class JobPostingForm(forms.ModelForm):
'hash_tags': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '#hiring,#jobopening',
'validators':validate_hash_tags,
# 'validators':validate_hash_tags, # Assuming this is available
}),
# Internal Information
@ -280,89 +376,269 @@ class JobPostingForm(forms.ModelForm):
'class': 'form-control',
'placeholder': 'Department Chair, Director, etc.'
}),
'start_date': forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
}),
'created_by': forms.TextInput(attrs={
'max_applications': forms.NumberInput(attrs={
'class': 'form-control',
'placeholder': 'University Administrator'
'min': 1,
'placeholder': 'Maximum number of applicants'
}),
}
def __init__(self,*args,**kwargs):
def __init__(self, *args, **kwargs):
# Extract your custom argument BEFORE calling super()
self.is_anonymous_user = kwargs.pop('is_anonymous_user', False)
# Now call the parent __init__ with remaining args
super().__init__(*args, **kwargs)
if not self.instance.pk:# Creating new job posting
if not self.is_anonymous_user:
self.fields['created_by'].initial = 'University Administrator'
self.fields['status'].initial = 'Draft'
self.fields['location_city'].initial='Riyadh'
self.fields['location_state'].initial='Riyadh Province'
self.fields['location_country'].initial='Saudi Arabia'
if not self.instance.pk: # Creating new job posting
# self.fields['status'].initial = 'Draft'
self.fields['location_city'].initial = 'Riyadh'
self.fields['location_state'].initial = 'Riyadh Province'
self.fields['location_country'].initial = 'Saudi Arabia'
def clean_hash_tags(self):
hash_tags=self.cleaned_data.get('hash_tags')
hash_tags = self.cleaned_data.get('hash_tags')
if hash_tags:
tags=[tag.strip() for tag in hash_tags.split(',') if tag.strip()]
tags = [tag.strip() for tag in hash_tags.split(',') if tag.strip()]
for tag in tags:
if not tag.startswith('#'):
raise forms.ValidationError("Each hashtag must start with '#' symbol and must be comma(,) sepearted.")
raise forms.ValidationError(
"Each hashtag must start with '#' symbol and must be comma(,) sepearted.")
return ','.join(tags)
return hash_tags # Allow blank
def clean_title(self):
title=self.cleaned_data.get('title')
if not title or len(title.strip())<3:
title = self.cleaned_data.get('title')
if not title or len(title.strip()) < 3:
raise forms.ValidationError("Job title must be at least 3 characters long.")
if len(title)>200:
if len(title) > 200:
raise forms.ValidationError("Job title cannot exceed 200 characters.")
return title.strip()
def clean_description(self):
description=self.cleaned_data.get('description')
if not description or len(description.strip())<20:
description = self.cleaned_data.get('description')
if not description or len(description.strip()) < 20:
raise forms.ValidationError("Job description must be at least 20 characters long.")
return description.strip() # to remove leading/trailing whitespace
def clean_application_url(self):
url=self.cleaned_data.get('application_url')
url = self.cleaned_data.get('application_url')
if url:
validator=URLValidator()
validator = URLValidator()
try:
validator(url)
except forms.ValidationError:
raise forms.ValidationError('Please enter a valid URL (e.g., https://example.com)')
return url
def clean(self):
"""Cross-field validation"""
cleaned_data = super().clean()
class JobPostingImageForm(forms.ModelForm):
class Meta:
model=JobPostingImage
fields=['post_image']
# Validate dates
start_date = cleaned_data.get('start_date')
application_deadline = cleaned_data.get('application_deadline')
class FormTemplateForm(forms.ModelForm):
"""Form for creating form templates"""
class Meta:
model = FormTemplate
fields = ['job','name', 'description', 'is_active']
labels = {
'job': _('Job'),
'name': _('Template Name'),
'description': _('Description'),
'is_active': _('Active'),
}
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('Enter template name'),
'required': True
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': _('Enter template description (optional)')
}),
'is_active': forms.CheckboxInput(attrs={
'class': 'form-check-input'
})
}
# Perform cross-field validation only if both fields have values
if start_date and application_deadline:
if application_deadline > start_date:
self.add_error('application_deadline',
'The application deadline must be set BEFORE the job start date.')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-3'
self.helper.field_class = 'col-md-9'
self.helper.layout = Layout(
Field('job', css_class='form-control'),
Field('name', css_class='form-control'),
Field('description', css_class='form-control'),
Field('is_active', css_class='form-check-input'),
Submit('submit', _('Create Template'), css_class='btn btn-primary mt-3')
)
# # Validate that if status is ACTIVE, we have required fields
# status = cleaned_data.get('status')
# if status == 'ACTIVE':
# if not cleaned_data.get('application_url'):
# self.add_error('application_url',
# 'Application URL is required for active jobs.')
# if not cleaned_data.get('description'):
# self.add_error('description',
# 'Job description is required for active jobs.')
class BreakTimeForm(forms.Form):
"""
A simple Form used for the BreakTimeFormSet.
It is not a ModelForm because the data is stored directly in InterviewSchedule's JSONField,
not in a separate BreakTime model instance.
"""
start_time = forms.TimeField(
widget=forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
label="Start Time"
)
end_time = forms.TimeField(
widget=forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
label="End Time"
)
return cleaned_data
BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
class InterviewScheduleForm(forms.ModelForm):
candidates = forms.ModelMultipleChoiceField(
queryset=Candidate.objects.none(),
widget=forms.CheckboxSelectMultiple,
required=True
)
working_days = forms.MultipleChoiceField(
choices=[
(0, 'Monday'), (1, 'Tuesday'), (2, 'Wednesday'), (3, 'Thursday'),
(4, 'Friday'), (5, 'Saturday'), (6, 'Sunday'),
],
widget=forms.CheckboxSelectMultiple,
required=True
)
class Meta:
model = InterviewSchedule
fields = [
'candidates', 'start_date', 'end_date', 'working_days',
'start_time', 'end_time', 'interview_duration', 'buffer_time',
'break_start_time', 'break_end_time'
]
widgets = {
'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'interview_duration': forms.NumberInput(attrs={'class': 'form-control'}),
'buffer_time': forms.NumberInput(attrs={'class': 'form-control'}),
'break_start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
'break_end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
}
def __init__(self, slug, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['candidates'].queryset = Candidate.objects.filter(
job__slug=slug,
stage='Interview'
)
def clean_working_days(self):
working_days = self.cleaned_data.get('working_days')
return [int(day) for day in working_days]
class MeetingCommentForm(forms.ModelForm):
"""Form for creating and editing meeting comments"""
class Meta:
model = MeetingComment
fields = ['content']
widgets = {
'content': CKEditor5Widget(
attrs={'class': 'form-control', 'placeholder': _('Enter your comment or note')},
config_name='extends'
),
}
labels = {
'content': _('Comment'),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-3'
self.helper.field_class = 'col-md-9'
self.helper.layout = Layout(
Field('content', css_class='form-control'),
Submit('submit', _('Add Comment'), css_class='btn btn-primary mt-3')
)
class InterviewForm(forms.ModelForm):
class Meta:
model = ScheduledInterview
fields = ['job','candidate']
class ProfileImageUploadForm(forms.ModelForm):
class Meta:
model=Profile
fields=['profile_image']
class StaffUserCreationForm(UserCreationForm):
email = forms.EmailField(required=True)
first_name = forms.CharField(max_length=30, required=True)
last_name = forms.CharField(max_length=150, required=True)
class Meta:
model = User
fields = ("email", "first_name", "last_name", "password1", "password2")
def clean_email(self):
email = self.cleaned_data["email"]
if User.objects.filter(email=email).exists():
raise forms.ValidationError("A user with this email already exists.")
return email
def generate_username(self, email):
"""Generate a valid, unique username from email."""
prefix = email.split('@')[0].lower()
username = re.sub(r'[^a-z0-9._]', '', prefix)
if not username:
username = 'user'
base = username
counter = 1
while User.objects.filter(username=username).exists():
username = f"{base}{counter}"
counter += 1
return username
def save(self, commit=True):
user = super().save(commit=False)
user.email = self.cleaned_data["email"]
user.first_name = self.cleaned_data["first_name"]
user.last_name = self.cleaned_data["last_name"]
user.username = self.generate_username(user.email)
user.is_staff = True
if commit:
user.save()
return user
class ToggleAccountForm(forms.Form):
pass
class JobPostingCancelReasonForm(forms.ModelForm):
class Meta:
model = JobPosting
fields = ['cancel_reason']
class JobPostingStatusForm(forms.ModelForm):
class Meta:
model = JobPosting
fields = ['status']
widgets = {
'status': forms.Select(attrs={'class': 'form-select'}),
}
class FormTemplateIsActiveForm(forms.ModelForm):
class Meta:
model = FormTemplate
fields = ['is_active']
class CandidateExamDateForm(forms.ModelForm):
class Meta:
model = Candidate
fields = ['exam_date']
widgets = {
'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
}

1
recruitment/hooks.py Normal file
View File

@ -0,0 +1 @@

View File

@ -2,12 +2,13 @@ import requests
LINKEDIN_API_BASE = "https://api.linkedin.com/v2"
class LinkedInService:
def __init__(self, access_token):
self.headers = {
'Authorization': f'Bearer {access_token}',
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json'
"Authorization": f"Bearer {access_token}",
"X-Restli-Protocol-Version": "2.0.0",
"Content-Type": "application/json",
}
def post_job(self, organization_id, job_data):
@ -17,10 +18,10 @@ class LinkedInService:
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": job_data['text']},
"shareMediaCategory": "NONE"
"shareCommentary": {"text": job_data["text"]},
"shareMediaCategory": "NONE",
}
},
"visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"}
"visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
}
return requests.post(url, json=data, headers=self.headers)
return requests.post(url, json=data, headers=self.headers)

View File

@ -1,19 +1,31 @@
# jobs/linkedin_service.py
import uuid
from urllib.parse import quote
import re
from html import unescape
from urllib.parse import quote, urlencode
import requests
import logging
import time
from django.conf import settings
from urllib.parse import urlencode, quote
logger = logging.getLogger(__name__)
# Define constants
LINKEDIN_API_VERSION = '2.0.0'
LINKEDIN_VERSION = '202409'
MAX_POST_CHARS = 3000 # LinkedIn's maximum character limit for shareCommentary
class LinkedInService:
def __init__(self):
self.client_id = settings.LINKEDIN_CLIENT_ID
self.client_secret = settings.LINKEDIN_CLIENT_SECRET
self.redirect_uri = settings.LINKEDIN_REDIRECT_URI
self.access_token = None
# Configuration for image processing wait time
self.ASSET_STATUS_TIMEOUT = 15
self.ASSET_STATUS_INTERVAL = 2
# ---------------- AUTHENTICATION & PROFILE ----------------
def get_auth_url(self):
"""Generate LinkedIn OAuth URL"""
@ -25,10 +37,9 @@ class LinkedInService:
'state': 'university_ats_linkedin'
}
return f"https://www.linkedin.com/oauth/v2/authorization?{urlencode(params)}"
def get_access_token(self, code):
"""Exchange authorization code for access token"""
# This function exchanges LinkedIns temporary authorization code for a usable access token.
url = "https://www.linkedin.com/oauth/v2/accessToken"
data = {
'grant_type': 'authorization_code',
@ -37,52 +48,62 @@ class LinkedInService:
'client_id': self.client_id,
'client_secret': self.client_secret
}
try:
response = requests.post(url, data=data, timeout=60)
response.raise_for_status()
token_data = response.json()
"""
Example response:{
"access_token": "AQXq8HJkLmNpQrStUvWxYz...",
"expires_in": 5184000
}
"""
self.access_token = token_data.get('access_token')
return self.access_token
except Exception as e:
logger.error(f"Error getting access token: {e}")
raise
def get_user_profile(self):
"""Get user profile information"""
"""Get user profile information (used to get person URN)"""
if not self.access_token:
raise Exception("No access token available")
url = "https://api.linkedin.com/v2/userinfo"
headers = {'Authorization': f'Bearer {self.access_token}'}
try:
response = requests.get(url, headers=headers, timeout=60)
response.raise_for_status() # Ensure we raise an error for bad responses(4xx, 5xx) and does nothing for 2xx(success)
return response.json() # returns a dict from json response (deserialize)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Error getting user profile: {e}")
raise
# ---------------- ASSET UPLOAD & STATUS ----------------
def get_asset_status(self, asset_urn):
"""Checks the status of a registered asset (image) to ensure it's READY."""
url = f"https://api.linkedin.com/v2/assets/{quote(asset_urn)}"
headers = {
'Authorization': f'Bearer {self.access_token}',
'X-Restli-Protocol-Version': LINKEDIN_API_VERSION,
'LinkedIn-Version': LINKEDIN_VERSION,
}
try:
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
return response.json().get('status')
except Exception as e:
logger.error(f"Error checking asset status for {asset_urn}: {e}")
return "FAILED"
def register_image_upload(self, person_urn):
"""Step 1: Register image upload with LinkedIn"""
"""Step 1: Register image upload with LinkedIn, getting the upload URL and asset URN."""
url = "https://api.linkedin.com/v2/assets?action=registerUpload"
headers = {
'Authorization': f'Bearer {self.access_token}',
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0'
'X-Restli-Protocol-Version': LINKEDIN_API_VERSION,
'LinkedIn-Version': LINKEDIN_VERSION,
}
payload = {
"registerUploadRequest": {
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
@ -93,460 +114,305 @@ class LinkedInService:
}]
}
}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
return {
'upload_url': data['value']['uploadMechanism']['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest']['uploadUrl'],
'asset': data['value']['asset']
}
def upload_image_to_linkedin(self, upload_url, image_file):
"""Step 2: Upload actual image file to LinkedIn"""
# Open and read the Django ImageField
def upload_image_to_linkedin(self, upload_url, image_file, asset_urn):
"""Step 2: Upload image file and poll for 'READY' status."""
image_file.open()
image_content = image_file.read()
image_file.seek(0) # Reset pointer after reading
image_file.close()
headers = {
'Authorization': f'Bearer {self.access_token}',
}
response = requests.post(upload_url, headers=headers, data=image_content, timeout=60)
response.raise_for_status()
# --- POLL FOR ASSET STATUS ---
start_time = time.time()
while time.time() - start_time < self.ASSET_STATUS_TIMEOUT:
try:
status = self.get_asset_status(asset_urn)
if status == "READY" or status == "PROCESSING":
if status == "READY":
logger.info(f"Asset {asset_urn} is READY. Proceeding.")
return True
if status == "FAILED":
raise Exception(f"LinkedIn image processing failed for asset {asset_urn}")
logger.info(f"Asset {asset_urn} status: {status}. Waiting...")
time.sleep(self.ASSET_STATUS_INTERVAL)
except Exception as e:
logger.warning(f"Error during asset status check for {asset_urn}: {e}. Retrying.")
time.sleep(self.ASSET_STATUS_INTERVAL * 2)
logger.warning(f"Asset {asset_urn} timed out, but upload succeeded. Forcing post attempt.")
return True
# ---------------- POSTING UTILITIES ----------------
def clean_html_for_social_post(self, html_content):
"""Converts safe HTML to plain text with basic formatting."""
if not html_content:
return ""
text = html_content
# 1. Convert Bolding tags to *Markdown*
text = re.sub(r'<strong>(.*?)</strong>', r'*\1*', text, flags=re.IGNORECASE)
text = re.sub(r'<b>(.*?)</b>', r'*\1*', text, flags=re.IGNORECASE)
# 2. Handle Lists: Convert <li> tags into a bullet point
text = re.sub(r'</(ul|ol|div)>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'<li[^>]*>', '', text, flags=re.IGNORECASE)
text = re.sub(r'</li>', '\n', text, flags=re.IGNORECASE)
# 3. Handle Paragraphs and Line Breaks
text = re.sub(r'</p>', '\n\n', text, flags=re.IGNORECASE)
text = re.sub(r'<br/?>', '\n', text, flags=re.IGNORECASE)
# 4. Strip all remaining, unsupported HTML tags
clean_text = re.sub(r'<[^>]+>', '', text)
# 5. Unescape HTML entities
clean_text = unescape(clean_text)
# 6. Clean up excessive whitespace/newlines
clean_text = re.sub(r'(\n\s*){3,}', '\n\n', clean_text).strip()
return clean_text
def hashtags_list(self, hash_tags_str):
"""Convert comma-separated hashtags string to list"""
if not hash_tags_str:
return ["#HigherEd", "#Hiring", "#UniversityJobs"]
tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()]
tags = [tag if tag.startswith('#') else f'#{tag}' for tag in tags]
if not tags:
return ["#HigherEd", "#Hiring", "#UniversityJobs"]
return tags
def _build_post_message(self, job_posting):
"""
Constructs the final text message.
Includes a unique suffix for duplicate content prevention (422 fix).
"""
message_parts = [
f"🔥 *Job Alert!* Were looking for a talented professional to join our team.",
f"👉 **{job_posting.title}** 👈",
]
if job_posting.department:
message_parts.append(f"*{job_posting.department}*")
message_parts.append("\n" + "=" * 25 + "\n")
# KEY DETAILS SECTION
details_list = []
if job_posting.job_type:
details_list.append(f"💼 Type: {job_posting.get_job_type_display()}")
if job_posting.get_location_display() != 'Not specified':
details_list.append(f"📍 Location: {job_posting.get_location_display()}")
if job_posting.workplace_type:
details_list.append(f"🏠 Workplace: {job_posting.get_workplace_type_display()}")
if job_posting.salary_range:
details_list.append(f"💰 Salary: {job_posting.salary_range}")
if details_list:
message_parts.append("*Key Information*:")
message_parts.extend(details_list)
message_parts.append("\n")
# DESCRIPTION SECTION
clean_description = self.clean_html_for_social_post(job_posting.description)
if clean_description:
message_parts.append(f"🔎 *About the Role:*\n{clean_description}")
# CALL TO ACTION
if job_posting.application_url:
message_parts.append(f"\n\n---")
# CRITICAL: Include the URL explicitly in the text body.
# When media_category is NONE, LinkedIn often makes these URLs clickable.
message_parts.append(f"🔗 **APPLY NOW:** {job_posting.application_url}")
# HASHTAGS
hashtags = self.hashtags_list(job_posting.hash_tags)
if job_posting.department:
dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
hashtags.insert(0, dept_hashtag)
message_parts.append("\n" + " ".join(hashtags))
final_message = "\n".join(message_parts)
# --- FIX: ADD UNIQUE SUFFIX AND HANDLE LENGTH (422 fix) ---
unique_suffix = f"\n\n| Ref: {int(time.time())}"
available_length = MAX_POST_CHARS - len(unique_suffix)
if len(final_message) > available_length:
logger.warning("Post message truncated due to character limit.")
final_message = final_message[:available_length - 3] + "..."
return final_message + unique_suffix
# ---------------- MAIN POSTING METHODS ----------------
def _send_ugc_post(self, person_urn, job_posting, media_category="NONE", media_list=None):
"""
Private method to handle the final UGC post request.
CRITICAL FIX: Avoids ARTICLE category if not using an image to prevent 402 errors.
"""
message = self._build_post_message(job_posting)
# --- FIX FOR 402: Force NONE if no image is present. ---
if media_category != "IMAGE":
# We explicitly force pure text share to avoid LinkedIn's link crawler
# which triggers the commercial 402 error on job reposts.
media_category = "NONE"
media_list = None
# --------------------------------------------------------
url = "https://api.linkedin.com/v2/ugcPosts"
headers = {
'Authorization': f'Bearer {self.access_token}',
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': LINKEDIN_API_VERSION,
'LinkedIn-Version': LINKEDIN_VERSION,
}
specific_content = {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": message},
"shareMediaCategory": media_category,
}
}
if media_list and media_category == "IMAGE":
specific_content["com.linkedin.ugc.ShareContent"]["media"] = media_list
payload = {
"author": f"urn:li:person:{person_urn}",
"lifecycleState": "PUBLISHED",
"specificContent": specific_content,
"visibility": {
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
}
}
response = requests.post(url, headers=headers, json=payload, timeout=60)
# Log 402/422 details
if response.status_code in [402, 422]:
logger.error(f"{response.status_code} UGC Post Error Detail: {response.text}")
response.raise_for_status()
post_id = response.headers.get('x-restli-id', '')
post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
return {
'success': True,
'post_id': post_id,
'post_url': post_url,
'status_code': response.status_code
}
def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn):
"""Creates the final LinkedIn post payload with the image asset."""
if not job_posting.application_url:
raise ValueError("Application URL is required for image link share on LinkedIn.")
# Media list for IMAGE category (retains link details)
# Note: This is an exception where we MUST provide link details for the image card
media_list = [{
"status": "READY",
"media": asset_urn,
"description": {"text": job_posting.title},
"originalUrl": job_posting.application_url,
"title": {"text": "Apply Now"}
}]
return self._send_ugc_post(
person_urn=person_urn,
job_posting=job_posting,
media_category="IMAGE",
media_list=media_list
)
def create_job_post(self, job_posting):
"""Create a job announcement post on LinkedIn (with image support)"""
"""Main method to create a job announcement post (Image or Text)."""
if not self.access_token:
raise Exception("Not authenticated with LinkedIn")
try:
# Get user profile for person URN
profile = self.get_user_profile()
person_urn = profile.get('sub')
if not person_urn:
raise Exception("Could not retrieve LinkedIn user ID")
# Check if job has an image
asset_urn = None
has_image = False
# Check for image and attempt post
try:
image_upload = job_posting.files.first()
has_image = image_upload and image_upload.linkedinpost_image
image_upload = job_posting.post_images.first().post_image
has_image = image_upload is not None
except Exception:
has_image = False
pass
if has_image:
# === POST WITH IMAGE ===
try:
# Step 1: Register image upload
# Steps 1, 2, 3 for image post
upload_info = self.register_image_upload(person_urn)
# Step 2: Upload image
asset_urn = upload_info['asset']
self.upload_image_to_linkedin(
upload_info['upload_url'],
image_upload.linkedinpost_image
upload_info['upload_url'],
image_upload,
asset_urn
)
# Step 3: Create post with image
return self.create_job_post_with_image(
job_posting,
image_upload.linkedinpost_image,
person_urn,
upload_info['asset']
job_posting, image_upload, person_urn, asset_urn
)
except Exception as e:
logger.error(f"Image upload failed: {e}")
# Fall back to text-only post if image upload fails
has_image = False
# === FALLBACK TO URL/ARTICLE POST ===
# Add unique timestamp to prevent duplicates
from django.utils import timezone
import random
unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})"
message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
if job_posting.department:
message_parts.append(f"**Department:** {job_posting.department}")
if job_posting.description:
message_parts.append(f"\n{job_posting.description}")
details = []
if job_posting.job_type:
details.append(f"💼 {job_posting.get_job_type_display()}")
if job_posting.get_location_display() != 'Not specified':
details.append(f"📍 {job_posting.get_location_display()}")
if job_posting.workplace_type:
details.append(f"🏠 {job_posting.get_workplace_type_display()}")
if job_posting.salary_range:
details.append(f"💰 {job_posting.salary_range}")
if details:
message_parts.append("\n" + " | ".join(details))
if job_posting.application_url:
message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
hashtags = self.hashtags_list(job_posting.hash_tags)
if job_posting.department:
dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
hashtags.insert(0, dept_hashtag)
message_parts.append("\n\n" + " ".join(hashtags))
message_parts.append(unique_suffix)
message = "\n".join(message_parts)
# 🔥 FIX URL - REMOVE TRAILING SPACES 🔥
url = "https://api.linkedin.com/v2/ugcPosts"
headers = {
'Authorization': f'Bearer {self.access_token}',
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0'
}
payload = {
"author": f"urn:li:person:{person_urn}",
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": message},
"shareMediaCategory": "ARTICLE",
"media": [{
"status": "READY",
"description": {"text": f"Apply for {job_posting.title} at our university!"},
"originalUrl": job_posting.application_url,
"title": {"text": job_posting.title}
}]
}
},
"visibility": {
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
}
}
response = requests.post(url, headers=headers, json=payload, timeout=60)
response.raise_for_status()
post_id = response.headers.get('x-restli-id', '')
post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
return {
'success': True,
'post_id': post_id,
'post_url': post_url,
'status_code': response.status_code
}
logger.error(f"Image post failed, falling back to text: {e}")
has_image = False
# === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) ===
# The _send_ugc_post method now ensures this is a PURE text post
# to avoid the 402/ARTICLE-related issues.
return self._send_ugc_post(
person_urn=person_urn,
job_posting=job_posting,
media_category="NONE"
)
except Exception as e:
logger.error(f"Error creating LinkedIn post: {e}")
status_code = getattr(getattr(e, 'response', None), 'status_code', 500)
return {
'success': False,
'error': str(e),
'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
}
# def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn):
# """Step 3: Create post with uploaded image"""
# url = "https://api.linkedin.com/v2/ugcPosts"
# headers = {
# 'Authorization': f'Bearer {self.access_token}',
# 'Content-Type': 'application/json',
# 'X-Restli-Protocol-Version': '2.0.0'
# }
# # Build the same message as before
# message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
# if job_posting.department:
# message_parts.append(f"**Department:** {job_posting.department}")
# if job_posting.description:
# message_parts.append(f"\n{job_posting.description}")
# details = []
# if job_posting.job_type:
# details.append(f"💼 {job_posting.get_job_type_display()}")
# if job_posting.get_location_display() != 'Not specified':
# details.append(f"📍 {job_posting.get_location_display()}")
# if job_posting.workplace_type:
# details.append(f"🏠 {job_posting.get_workplace_type_display()}")
# if job_posting.salary_range:
# details.append(f"💰 {job_posting.salary_range}")
# if details:
# message_parts.append("\n" + " | ".join(details))
# if job_posting.application_url:
# message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
# hashtags = self.hashtags_list(job_posting.hash_tags)
# if job_posting.department:
# dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
# hashtags.insert(0, dept_hashtag)
# message_parts.append("\n\n" + " ".join(hashtags))
# message = "\n".join(message_parts)
# # Create image post payload
# payload = {
# "author": f"urn:li:person:{person_urn}",
# "lifecycleState": "PUBLISHED",
# "specificContent": {
# "com.linkedin.ugc.ShareContent": {
# "shareCommentary": {"text": message},
# "shareMediaCategory": "IMAGE",
# "media": [{
# "status": "READY",
# "media": asset_urn
# }]
# }
# },
# "visibility": {
# "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
# }
# }
# response = requests.post(url, headers=headers, json=payload, timeout=30)
# response.raise_for_status()
# post_id = response.headers.get('x-restli-id', '')
# post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
# return {
# 'success': True,
# 'post_id': post_id,
# 'post_url': post_url,
# 'status_code': response.status_code
# }
def hashtags_list(self,hash_tags_str):
"""Convert comma-separated hashtags string to list"""
if not hash_tags_str:
return [""]
tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()]
if not tags:
return ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"]
return tags
# def create_job_post(self, job_posting):
# """Create a job announcement post on LinkedIn (with image support)"""
# if not self.access_token:
# raise Exception("Not authenticated with LinkedIn")
# try:
# # Get user profile for person URN
# profile = self.get_user_profile()
# person_urn = profile.get('sub')
# if not person_urn:
# raise Exception("Could not retrieve LinkedIn user ID")
# # Check if job has an image
# try:
# image_upload = job_posting.files.first()
# has_image = image_upload and image_upload.linkedinpost_image
# except Exception:
# has_image = False
# if has_image:
# # === POST WITH IMAGE ===
# upload_info = self.register_image_upload(person_urn)
# self.upload_image_to_linkedin(
# upload_info['upload_url'],
# image_upload.linkedinpost_image
# )
# return self.create_job_post_with_image(
# job_posting,
# image_upload.linkedinpost_image,
# person_urn,
# upload_info['asset']
# )
# else:
# # === FALLBACK TO URL/ARTICLE POST ===
# # 🔥 ADD UNIQUE TIMESTAMP TO PREVENT DUPLICATES 🔥
# from django.utils import timezone
# import random
# unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})"
# message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
# if job_posting.department:
# message_parts.append(f"**Department:** {job_posting.department}")
# if job_posting.description:
# message_parts.append(f"\n{job_posting.description}")
# details = []
# if job_posting.job_type:
# details.append(f"💼 {job_posting.get_job_type_display()}")
# if job_posting.get_location_display() != 'Not specified':
# details.append(f"📍 {job_posting.get_location_display()}")
# if job_posting.workplace_type:
# details.append(f"🏠 {job_posting.get_workplace_type_display()}")
# if job_posting.salary_range:
# details.append(f"💰 {job_posting.salary_range}")
# if details:
# message_parts.append("\n" + " | ".join(details))
# if job_posting.application_url:
# message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
# hashtags = self.hashtags_list(job_posting.hash_tags)
# if job_posting.department:
# dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
# hashtags.insert(0, dept_hashtag)
# message_parts.append("\n\n" + " ".join(hashtags))
# message_parts.append(unique_suffix) # 🔥 Add unique suffix
# message = "\n".join(message_parts)
# # 🔥 FIX URL - REMOVE TRAILING SPACES 🔥
# url = "https://api.linkedin.com/v2/ugcPosts"
# headers = {
# 'Authorization': f'Bearer {self.access_token}',
# 'Content-Type': 'application/json',
# 'X-Restli-Protocol-Version': '2.0.0'
# }
# payload = {
# "author": f"urn:li:person:{person_urn}",
# "lifecycleState": "PUBLISHED",
# "specificContent": {
# "com.linkedin.ugc.ShareContent": {
# "shareCommentary": {"text": message},
# "shareMediaCategory": "ARTICLE",
# "media": [{
# "status": "READY",
# "description": {"text": f"Apply for {job_posting.title} at our university!"},
# "originalUrl": job_posting.application_url,
# "title": {"text": job_posting.title}
# }]
# }
# },
# "visibility": {
# "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
# }
# }
# response = requests.post(url, headers=headers, json=payload, timeout=60)
# response.raise_for_status()
# post_id = response.headers.get('x-restli-id', '')
# # 🔥 FIX POST URL - REMOVE TRAILING SPACES 🔥
# post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
# return {
# 'success': True,
# 'post_id': post_id,
# 'post_url': post_url,
# 'status_code': response.status_code
# }
# except Exception as e:
# logger.error(f"Error creating LinkedIn post: {e}")
# return {
# 'success': False,
# 'error': str(e),
# 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
# }
# def create_job_post(self, job_posting):
# """Create a job announcement post on LinkedIn"""
# if not self.access_token:
# raise Exception("Not authenticated with LinkedIn")
# try:
# # Get user profile for person URN
# profile = self.get_user_profile()
# person_urn = profile.get('sub')
# if not person_urn: # uniform resource name used to uniquely identify linked-id for internal systems and apis
# raise Exception("Could not retrieve LinkedIn user ID")
# # Build professional job post message
# message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
# if job_posting.department:
# message_parts.append(f"**Department:** {job_posting.department}")
# if job_posting.description:
# message_parts.append(f"\n{job_posting.description}")
# # Add job details
# details = []
# if job_posting.job_type:
# details.append(f"💼 {job_posting.get_job_type_display()}")
# if job_posting.get_location_display() != 'Not specified':
# details.append(f"📍 {job_posting.get_location_display()}")
# if job_posting.workplace_type:
# details.append(f"🏠 {job_posting.get_workplace_type_display()}")
# if job_posting.salary_range:
# details.append(f"💰 {job_posting.salary_range}")
# if details:
# message_parts.append("\n" + " | ".join(details))
# # Add application link
# if job_posting.application_url:
# message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
# # Add hashtags
# hashtags = ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"]
# if job_posting.department:
# dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
# hashtags.insert(0, dept_hashtag)
# message_parts.append("\n\n" + " ".join(hashtags))
# message = "\n".join(message_parts)
# # Create LinkedIn post
# url = "https://api.linkedin.com/v2/ugcPosts"
# headers = {
# 'Authorization': f'Bearer {self.access_token}',
# 'Content-Type': 'application/json',
# 'X-Restli-Protocol-Version': '2.0.0'
# }
# payload = {
# "author": f"urn:li:person:{person_urn}",
# "lifecycleState": "PUBLISHED",
# "specificContent": {
# "com.linkedin.ugc.ShareContent": {
# "shareCommentary": {"text": message},
# "shareMediaCategory": "ARTICLE",
# "media": [{
# "status": "READY",
# "description": {"text": f"Apply for {job_posting.title} at our university!"},
# "originalUrl": job_posting.application_url,
# "title": {"text": job_posting.title}
# }]
# }
# },
# "visibility": {
# "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
# }
# }
# response = requests.post(url, headers=headers, json=payload, timeout=60)
# response.raise_for_status()
# # Extract post ID from response
# post_id = response.headers.get('x-restli-id', '')
# post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
# return {
# 'success': True,
# 'post_id': post_id,
# 'post_url': post_url,
# 'status_code': response.status_code
# }
# except Exception as e:
# logger.error(f"Error creating LinkedIn post: {e}")
# return {
# 'success': False,
# 'error': str(e),
# 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
# }
'status_code': status_code
}

View File

@ -1,48 +0,0 @@
import os
import random
from django.core.management.base import BaseCommand
from faker import Faker
from recruitment.models import Job, Candidate
fake = Faker()
class Command(BaseCommand):
help = 'Generate 20 fake jobs and 50 fake candidates'
def handle(self, *args, **kwargs):
# Clear existing fake data (optional)
Job.objects.filter(title__startswith='Fake Job').delete()
Candidate.objects.filter(name__startswith='Candidate ').delete()
self.stdout.write("Creating fake jobs...")
jobs = []
for i in range(20):
job = Job.objects.create(
title=f"Fake Job {i+1}",
description_en=fake.paragraph(nb_sentences=5),
description_ar=fake.text(max_nb_chars=200),
is_published=True,
posted_to_linkedin=random.choice([True, False])
)
jobs.append(job)
self.stdout.write("Creating fake candidates...")
for i in range(50):
job = random.choice(jobs)
resume_path = f"resumes/fake_resume_{i+1}.pdf"
parsed = {
'name': fake.name(),
'skills': [fake.job() for _ in range(5)],
'summary': fake.text(max_nb_chars=300)
}
Candidate.objects.create(
job=job,
name=f"Candidate {i+1}",
email=fake.email(),
resume=resume_path, # You can create dummy files if needed
parsed_summary=str(parsed),
applied=random.choice([True, False])
)
self.stdout.write(self.style.SUCCESS("✔️ Successfully generated 20 jobs and 50 candidates"))

View File

@ -0,0 +1,156 @@
import uuid
import random
from datetime import date, timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from faker import Faker
from recruitment.models import JobPosting, Candidate, Source, FormTemplate
class Command(BaseCommand):
help = 'Seeds the database with initial JobPosting and Candidate data using Faker.'
def add_arguments(self, parser):
# Add argument for the number of jobs to create, default is 5
parser.add_argument(
'--jobs',
type=int,
help='The number of JobPostings to create.',
default=5,
)
# Add argument for the number of candidates to create, default is 20
parser.add_argument(
'--candidates',
type=int,
help='The number of Candidate applications to create.',
default=20,
)
def handle(self, *args, **options):
# Get the desired counts from command line arguments
jobs_count = options['jobs']
candidates_count = options['candidates']
# Initialize Faker
fake = Faker('en_US') # Using en_US for general data, can be changed if needed
self.stdout.write("--- Starting Database Seeding ---")
self.stdout.write(f"Preparing to create {jobs_count} jobs and {candidates_count} candidates.")
# 1. Clear existing data (Optional, but useful for clean seeding)
Candidate.objects.all().delete()
JobPosting.objects.all().delete()
Source.objects.all().delete()
self.stdout.write(self.style.WARNING("Existing JobPostings and Candidates cleared."))
# 2. Create Foreign Key dependency: Source
default_source, created = Source.objects.get_or_create(
name="Career Website",
defaults={'name': 'Career Website'}
)
self.stdout.write(f"Using Source: {default_source.name}")
# --- Helper Chooser Lists ---
JOB_TYPES = [choice[0] for choice in JobPosting.JOB_TYPES]
WORKPLACE_TYPES = [choice[0] for choice in JobPosting.WORKPLACE_TYPES]
STATUS_CHOICES = [choice[0] for choice in JobPosting.STATUS_CHOICES]
DEPARTMENTS = ["Technology", "Marketing", "Finance", "HR", "Sales", "Research", "Operations"]
REPORTING_TO = ["CTO", "HR Manager", "Department Head", "VP of Sales"]
# 3. Generate JobPostings
created_jobs = []
for i in range(jobs_count):
# Dynamic job details
title = fake.job()
department = random.choice(DEPARTMENTS)
is_faculty = random.random() < 0.1 # 10% chance of being a faculty job
job_type = "FACULTY" if is_faculty else random.choice([t for t in JOB_TYPES if t != "FACULTY"])
# Generate realistic salary range
base_salary = random.randint(50, 200) * 1000
salary_range = f"${base_salary:,.0f} - ${base_salary + random.randint(10, 50) * 1000:,.0f}"
# Random dates
start_date = fake.date_object()
deadline_date = start_date + timedelta(days=random.randint(14, 60))
joining_date = deadline_date + timedelta(days=random.randint(30, 90))
# Use Faker's HTML generation for CKEditor5 fields
description_html = f"<h1>{title} Role</h1>" + "".join(f"<p>{fake.paragraph(nb_sentences=3, variable_nb_sentences=True)}</p>" for _ in range(3))
qualifications_html = "<ul>" + "".join(f"<li>{fake.sentence(nb_words=6)}</li>" for _ in range(random.randint(3, 5))) + "</ul>"
benefits_html = f"<p>Standard benefits include: {fake.sentence(nb_words=8)}</p>"
instructions_html = f"<p>To apply, visit: {fake.url()} and follow the steps below.</p>"
job_data = {
"title": title,
"department": department,
"job_type": job_type,
"workplace_type": random.choice(WORKPLACE_TYPES),
"location_city": fake.city(),
"location_state": fake.state_abbr(),
"location_country": "Saudia Arabia",
"description": description_html,
"qualifications": qualifications_html,
"salary_range": salary_range,
"benefits": benefits_html,
"application_url": fake.url(),
"application_start_date": start_date,
"application_deadline": deadline_date,
"application_instructions": instructions_html,
"created_by": "Faker Script",
"status": random.choice(STATUS_CHOICES),
"hash_tags": f"#{department.lower().replace(' ', '')},#jobopening,#{fake.word()}",
"position_number": f"{department[:3].upper()}{random.randint(100, 999)}",
"reporting_to": random.choice(REPORTING_TO),
"joining_date": joining_date,
"open_positions": random.randint(1, 5),
"source": default_source,
"published_at": timezone.now() if random.random() < 0.7 else None,
}
job = JobPosting.objects.create(
**job_data
)
FormTemplate.objects.create(job=job, name=f"{job.title} Form", description=f"Form for {job.title}",is_active=True)
created_jobs.append(job)
self.stdout.write(self.style.SUCCESS(f'Created JobPosting {i+1}/{jobs_count}: {job.title}'))
# 4. Generate Candidates
if created_jobs:
for i in range(candidates_count):
# Link candidate to a random job
target_job = random.choice(created_jobs)
first_name = fake.first_name()
last_name = fake.last_name()
candidate_data = {
"first_name": first_name,
"last_name": last_name,
# Create a plausible email based on name
"email": f"{first_name.lower()}.{last_name.lower()}@{fake.domain_name()}",
"phone": fake.phone_number(),
"address": fake.address(),
# Placeholder resume path
'match_score': random.randint(0, 100),
"resume": f"resumes/{last_name.lower()}_{target_job.internal_job_id}_{fake.file_name(extension='pdf')}",
"job": target_job,
}
Candidate.objects.create(**candidate_data)
self.stdout.write(self.style.NOTICE(
f'Created Candidate {i+1}/{candidates_count}: {first_name} for {target_job.title[:30]}...'
))
else:
self.stdout.write(self.style.WARNING("No jobs created, skipping candidate generation."))
self.stdout.write(self.style.SUCCESS('\n--- Database Seeding Complete! ---'))
# Summary output
self.stdout.write(f"Total JobPostings created: {JobPosting.objects.count()}")
self.stdout.write(f"Total Candidates created: {Candidate.objects.count()}")

View File

@ -1,6 +1,12 @@
# Generated by Django 5.2.1 on 2025-05-18 17:23
# Generated by Django 5.2.7 on 2025-10-22 16:33
import django.core.validators
import django.db.models.deletion
import django_ckeditor_5.fields
import django_countries.fields
import django_extensions.db.fields
import recruitment.validators
from django.conf import settings
from django.db import migrations, models
@ -9,32 +15,462 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Job',
name='BreakTime',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('description_en', models.TextField()),
('description_ar', models.TextField()),
('is_published', models.BooleanField(default=False)),
('posted_to_linkedin', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('start_time', models.TimeField(verbose_name='Start Time')),
('end_time', models.TimeField(verbose_name='End Time')),
],
),
migrations.CreateModel(
name='FormStage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('name', models.CharField(help_text='Name of the stage', max_length=200)),
('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')),
],
options={
'verbose_name': 'Form Stage',
'verbose_name_plural': 'Form Stages',
'ordering': ['order'],
},
),
migrations.CreateModel(
name='HiringAgency',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('name', models.CharField(max_length=200, unique=True, verbose_name='Agency Name')),
('contact_person', models.CharField(blank=True, max_length=150, verbose_name='Contact Person')),
('email', models.EmailField(blank=True, max_length=254)),
('phone', models.CharField(blank=True, max_length=20)),
('website', models.URLField(blank=True)),
('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)),
('address', models.TextField(blank=True, null=True)),
],
options={
'verbose_name': 'Hiring Agency',
'verbose_name_plural': 'Hiring Agencies',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Source',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('name', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, unique=True, verbose_name='Source Name')),
('source_type', models.CharField(help_text='e.g., ATS, ERP ', max_length=100, verbose_name='Source Type')),
('description', models.TextField(blank=True, help_text='A description of the source', verbose_name='Description')),
('ip_address', models.GenericIPAddressField(blank=True, help_text='The IP address of the source', null=True, verbose_name='IP Address')),
('created_at', models.DateTimeField(auto_now_add=True)),
('api_key', models.CharField(blank=True, help_text='API key for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Key')),
('api_secret', models.CharField(blank=True, help_text='API secret for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Secret')),
('trusted_ips', models.TextField(blank=True, help_text='Comma-separated list of trusted IP addresses', null=True, verbose_name='Trusted IP Addresses')),
('is_active', models.BooleanField(default=True, help_text='Whether this source is active for integration', verbose_name='Active')),
('integration_version', models.CharField(blank=True, help_text='Version of the integration protocol', max_length=50, verbose_name='Integration Version')),
('last_sync_at', models.DateTimeField(blank=True, help_text='Timestamp of the last successful synchronization', null=True, verbose_name='Last Sync At')),
('sync_status', models.CharField(blank=True, choices=[('IDLE', 'Idle'), ('SYNCING', 'Syncing'), ('ERROR', 'Error'), ('DISABLED', 'Disabled')], default='IDLE', max_length=20, verbose_name='Sync Status')),
],
options={
'verbose_name': 'Source',
'verbose_name_plural': 'Sources',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='ZoomMeeting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('topic', models.CharField(max_length=255, verbose_name='Topic')),
('meeting_id', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration')),
('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
('join_url', models.URLField(verbose_name='Join URL')),
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
('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')),
('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='FormField',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('label', models.CharField(help_text='Label for the field', max_length=200)),
('field_type', models.CharField(choices=[('text', 'Text Input'), ('email', 'Email'), ('phone', 'Phone'), ('textarea', 'Text Area'), ('file', 'File Upload'), ('date', 'Date Picker'), ('select', 'Dropdown'), ('radio', 'Radio Buttons'), ('checkbox', 'Checkboxes')], help_text='Type of the field', max_length=20)),
('placeholder', models.CharField(blank=True, help_text='Placeholder text', max_length=200)),
('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')),
('options', models.JSONField(blank=True, default=list, help_text='Options for selection fields (stored as JSON array)')),
('file_types', models.CharField(blank=True, help_text="Allowed file types (comma-separated, e.g., '.pdf,.doc,.docx')", max_length=200)),
('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)')),
('stage', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='recruitment.formstage')),
],
options={
'verbose_name': 'Form Field',
'verbose_name_plural': 'Form Fields',
'ordering': ['order'],
},
),
migrations.CreateModel(
name='FormTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('name', models.CharField(help_text='Name of the form template', max_length=200)),
('description', models.TextField(blank=True, help_text='Description of the form template')),
('is_active', models.BooleanField(default=False, help_text='Whether this template is active')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Form Template',
'verbose_name_plural': 'Form Templates',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FormSubmission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('submitted_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('applicant_name', models.CharField(blank=True, help_text='Name of the applicant', max_length=200)),
('applicant_email', models.EmailField(blank=True, db_index=True, help_text='Email of the applicant', max_length=254)),
('submitted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='form_submissions', to=settings.AUTH_USER_MODEL)),
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='recruitment.formtemplate')),
],
options={
'verbose_name': 'Form Submission',
'verbose_name_plural': 'Form Submissions',
'ordering': ['-submitted_at'],
},
),
migrations.AddField(
model_name='formstage',
name='template',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='recruitment.formtemplate'),
),
migrations.CreateModel(
name='Candidate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('email', models.EmailField(max_length=254)),
('resume', models.FileField(upload_to='resumes/')),
('parsed_summary', models.TextField(blank=True)),
('status', models.CharField(default='Applied', max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.job')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('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(db_index=True, max_length=254, verbose_name='Email')),
('phone', models.CharField(max_length=20, verbose_name='Phone')),
('address', models.TextField(max_length=200, verbose_name='Address')),
('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')),
('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')),
('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')),
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
('applied', models.BooleanField(default=False, verbose_name='Applied')),
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')),
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')),
('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')),
('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status')),
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')),
('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
('ai_analysis_data', models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data')),
('submitted_by_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_candidates', to='recruitment.hiringagency', verbose_name='Submitted by Agency')),
],
options={
'verbose_name': 'Candidate',
'verbose_name_plural': 'Candidates',
},
),
migrations.CreateModel(
name='JobPosting',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('title', models.CharField(max_length=200)),
('department', models.CharField(blank=True, max_length=100)),
('job_type', models.CharField(choices=[('FULL_TIME', 'Full-time'), ('PART_TIME', 'Part-time'), ('CONTRACT', 'Contract'), ('INTERNSHIP', 'Internship'), ('FACULTY', 'Faculty'), ('TEMPORARY', 'Temporary')], default='FULL_TIME', max_length=20)),
('workplace_type', models.CharField(choices=[('ON_SITE', 'On-site'), ('REMOTE', 'Remote'), ('HYBRID', 'Hybrid')], default='ON_SITE', max_length=20)),
('location_city', models.CharField(blank=True, max_length=100)),
('location_state', models.CharField(blank=True, max_length=100)),
('location_country', models.CharField(default='Saudia Arabia', max_length=100)),
('description', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Description')),
('qualifications', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
('salary_range', models.CharField(blank=True, help_text='e.g., $60,000 - $80,000', max_length=200)),
('benefits', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
('application_url', models.URLField(blank=True, help_text='URL where candidates apply', null=True, validators=[django.core.validators.URLValidator()])),
('application_deadline', models.DateField(db_index=True)),
('application_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)),
('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)),
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20)),
('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])),
('linkedin_post_id', models.CharField(blank=True, help_text='LinkedIn post ID after posting', max_length=200)),
('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(blank=True, help_text='Status of LinkedIn posting', max_length=50)),
('linkedin_posted_at', models.DateTimeField(blank=True, null=True)),
('published_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)),
('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)),
('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')),
('max_applications', models.PositiveIntegerField(blank=True, default=1000, help_text='Maximum number of applications allowed', null=True)),
('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')),
('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
('cancelled_at', models.DateTimeField(blank=True, null=True)),
('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')),
],
options={
'verbose_name': 'Job Posting',
'verbose_name_plural': 'Job Postings',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='InterviewSchedule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
('end_date', models.DateField(db_index=True, verbose_name='End Date')),
('working_days', models.JSONField(verbose_name='Working Days')),
('start_time', models.TimeField(verbose_name='Start Time')),
('end_time', models.TimeField(verbose_name='End Time')),
('break_start_time', models.TimeField(blank=True, null=True, verbose_name='Break Start Time')),
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
],
),
migrations.AddField(
model_name='formtemplate',
name='job',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'),
),
migrations.AddField(
model_name='candidate',
name='job',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'),
),
migrations.CreateModel(
name='JobPostingImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('post_image', models.ImageField(upload_to='post/', validators=[recruitment.validators.validate_image_size])),
('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')),
],
),
migrations.CreateModel(
name='Profile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size])),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='SharedFormTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('is_public', models.BooleanField(default=False, help_text='Whether this template is publicly available')),
('shared_with', models.ManyToManyField(blank=True, related_name='shared_templates', to=settings.AUTH_USER_MODEL)),
('template', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='recruitment.formtemplate')),
],
options={
'verbose_name': 'Shared Form Template',
'verbose_name_plural': 'Shared Form Templates',
},
),
migrations.CreateModel(
name='IntegrationLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('action', models.CharField(choices=[('REQUEST', 'Request'), ('RESPONSE', 'Response'), ('ERROR', 'Error'), ('SYNC', 'Sync'), ('CREATE_JOB', 'Create Job'), ('UPDATE_JOB', 'Update Job')], max_length=20, verbose_name='Action')),
('endpoint', models.CharField(blank=True, max_length=255, verbose_name='Endpoint')),
('method', models.CharField(blank=True, max_length=10, 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(blank=True, max_length=10, 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(blank=True, max_length=255, verbose_name='User Agent')),
('processing_time', models.FloatField(blank=True, null=True, verbose_name='Processing Time (seconds)')),
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integration_logs', to='recruitment.source', verbose_name='Source')),
],
options={
'verbose_name': 'Integration Log',
'verbose_name_plural': 'Integration Logs',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='TrainingMaterial',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('title', models.CharField(max_length=255, verbose_name='Title')),
('content', django_ckeditor_5.fields.CKEditor5Field(blank=True, verbose_name='Content')),
('video_link', models.URLField(blank=True, verbose_name='Video Link')),
('file', models.FileField(blank=True, upload_to='training_materials/', verbose_name='File')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Created by')),
],
options={
'verbose_name': 'Training Material',
'verbose_name_plural': 'Training Materials',
},
),
migrations.CreateModel(
name='ScheduledInterview',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
('interview_time', models.TimeField(verbose_name='Interview Time')),
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')),
('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
],
),
migrations.CreateModel(
name='MeetingComment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meeting_comments', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recruitment.zoommeeting', verbose_name='Meeting')),
],
options={
'verbose_name': 'Meeting Comment',
'verbose_name_plural': 'Meeting Comments',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FieldResponse',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('value', models.JSONField(blank=True, help_text='Response value (stored as JSON)', null=True)),
('uploaded_file', models.FileField(blank=True, null=True, upload_to='form_uploads/')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formfield')),
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formsubmission')),
],
options={
'verbose_name': 'Field Response',
'verbose_name_plural': 'Field Responses',
'indexes': [models.Index(fields=['submission'], name='recruitment_submiss_474130_idx'), models.Index(fields=['field'], name='recruitment_field_i_097e5b_idx')],
},
),
migrations.AddIndex(
model_name='formsubmission',
index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'),
),
migrations.AddIndex(
model_name='interviewschedule',
index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'),
),
migrations.AddIndex(
model_name='interviewschedule',
index=models.Index(fields=['end_date'], name='recruitment_end_dat_aeb00e_idx'),
),
migrations.AddIndex(
model_name='interviewschedule',
index=models.Index(fields=['created_by'], name='recruitment_created_d0bdcc_idx'),
),
migrations.AddIndex(
model_name='formtemplate',
index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'),
),
migrations.AddIndex(
model_name='formtemplate',
index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'),
),
migrations.AddIndex(
model_name='candidate',
index=models.Index(fields=['stage'], name='recruitment_stage_f1c6eb_idx'),
),
migrations.AddIndex(
model_name='candidate',
index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'),
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
),
migrations.AddIndex(
model_name='scheduledinterview',
index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'),
),
migrations.AddIndex(
model_name='scheduledinterview',
index=models.Index(fields=['interview_date', 'interview_time'], name='recruitment_intervi_7f5877_idx'),
),
migrations.AddIndex(
model_name='scheduledinterview',
index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'),
),
]

View File

@ -1,28 +0,0 @@
# Generated by Django 5.2.1 on 2025-05-18 17:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TrainingMaterial',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('content', models.TextField(blank=True)),
('video_link', models.URLField(blank=True)),
('file', models.FileField(blank=True, upload_to='training_materials/')),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -1,28 +0,0 @@
# Generated by Django 5.2.1 on 2025-05-18 18:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0002_trainingmaterial'),
]
operations = [
migrations.AddField(
model_name='candidate',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='job',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='trainingmaterial',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@ -1,22 +0,0 @@
# Generated by Django 5.2.1 on 2025-05-18 18:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0003_candidate_updated_at_job_updated_at_and_more'),
]
operations = [
migrations.RemoveField(
model_name='candidate',
name='status',
),
migrations.AddField(
model_name='candidate',
name='applied',
field=models.BooleanField(default=False),
),
]

View File

@ -1,36 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-29 09:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0004_remove_candidate_status_candidate_applied'),
]
operations = [
migrations.CreateModel(
name='ZoomMeeting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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()),
('password', models.CharField(blank=True, max_length=50, null=True)),
('host_email', models.EmailField(max_length=254)),
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended')], default='waiting', max_length=10)),
('host_video', models.BooleanField(default=True)),
('participant_video', models.BooleanField(default=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)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
]

View File

@ -1,318 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-02 14:14
import django.core.validators
import django.db.models.deletion
import recruitment.validators
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0005_zoommeeting'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='JobPosting',
fields=[
('title', models.CharField(max_length=200)),
('department', models.CharField(blank=True, max_length=100)),
('job_type', models.CharField(choices=[('FULL_TIME', 'Full-time'), ('PART_TIME', 'Part-time'), ('CONTRACT', 'Contract'), ('INTERNSHIP', 'Internship'), ('FACULTY', 'Faculty'), ('TEMPORARY', 'Temporary')], default='FULL_TIME', max_length=20)),
('workplace_type', models.CharField(choices=[('ON_SITE', 'On-site'), ('REMOTE', 'Remote'), ('HYBRID', 'Hybrid')], default='ON_SITE', max_length=20)),
('location_city', models.CharField(blank=True, max_length=100)),
('location_state', models.CharField(blank=True, max_length=100)),
('location_country', models.CharField(default='United States', max_length=100)),
('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(blank=True, help_text='e.g., $60,000 - $80,000', max_length=200)),
('benefits', models.TextField(blank=True, help_text='Benefits offered')),
('application_url', models.URLField(help_text='URL where candidates apply', validators=[django.core.validators.URLValidator()])),
('application_deadline', models.DateField(blank=True, null=True)),
('application_instructions', models.TextField(blank=True, help_text='Special instructions for applicants')),
('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)),
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20)),
('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])),
('linkedin_post_id', models.CharField(blank=True, help_text='LinkedIn post ID after posting', max_length=200)),
('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(blank=True, help_text='Status of LinkedIn posting', max_length=50)),
('linkedin_posted_at', models.DateTimeField(blank=True, null=True)),
('position_number', models.CharField(blank=True, help_text='University position number', max_length=50)),
('reporting_to', models.CharField(blank=True, help_text='Who this position reports to', max_length=100)),
('start_date', models.DateField(blank=True, help_text='Desired start date', null=True)),
('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions for this job')),
],
options={
'verbose_name': 'Job Posting',
'verbose_name_plural': 'Job Postings',
'ordering': ['-created_at'],
},
),
migrations.AlterModelOptions(
name='candidate',
options={'verbose_name': 'Candidate', 'verbose_name_plural': 'Candidates'},
),
migrations.AlterModelOptions(
name='job',
options={'verbose_name': 'Job', 'verbose_name_plural': 'Jobs'},
),
migrations.AlterModelOptions(
name='trainingmaterial',
options={'verbose_name': 'Training Material', 'verbose_name_plural': 'Training Materials'},
),
migrations.RemoveField(
model_name='zoommeeting',
name='host_email',
),
migrations.RemoveField(
model_name='zoommeeting',
name='host_video',
),
migrations.RemoveField(
model_name='zoommeeting',
name='password',
),
migrations.RemoveField(
model_name='zoommeeting',
name='status',
),
migrations.AddField(
model_name='candidate',
name='exam_date',
field=models.DateField(blank=True, null=True, verbose_name='Exam Date'),
),
migrations.AddField(
model_name='candidate',
name='exam_status',
field=models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status'),
),
migrations.AddField(
model_name='candidate',
name='first_name',
field=models.CharField(default='user', max_length=255, verbose_name='First Name'),
preserve_default=False,
),
migrations.AddField(
model_name='candidate',
name='interview_date',
field=models.DateField(blank=True, null=True, verbose_name='Interview Date'),
),
migrations.AddField(
model_name='candidate',
name='interview_status',
field=models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Interview Status'),
),
migrations.AddField(
model_name='candidate',
name='join_date',
field=models.DateField(blank=True, null=True, verbose_name='Join Date'),
),
migrations.AddField(
model_name='candidate',
name='last_name',
field=models.CharField(default='user', max_length=255, verbose_name='Last Name'),
preserve_default=False,
),
migrations.AddField(
model_name='candidate',
name='offer_date',
field=models.DateField(blank=True, null=True, verbose_name='Offer Date'),
),
migrations.AddField(
model_name='candidate',
name='offer_status',
field=models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status'),
),
migrations.AddField(
model_name='candidate',
name='phone',
field=models.CharField(default='0569874562', max_length=20, verbose_name='Phone'),
preserve_default=False,
),
migrations.AddField(
model_name='candidate',
name='stage',
field=models.CharField(default='Applied', max_length=100, verbose_name='Stage'),
),
migrations.AlterField(
model_name='candidate',
name='applied',
field=models.BooleanField(default=False, verbose_name='Applied'),
),
migrations.AlterField(
model_name='candidate',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
),
migrations.AlterField(
model_name='candidate',
name='email',
field=models.EmailField(max_length=254, verbose_name='Email'),
),
migrations.AlterField(
model_name='candidate',
name='name',
field=models.CharField(max_length=255, verbose_name='Name'),
),
migrations.AlterField(
model_name='candidate',
name='parsed_summary',
field=models.TextField(blank=True, verbose_name='Parsed Summary'),
),
migrations.AlterField(
model_name='candidate',
name='resume',
field=models.FileField(upload_to='resumes/', verbose_name='Resume'),
),
migrations.AlterField(
model_name='candidate',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
),
migrations.AlterField(
model_name='job',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
),
migrations.AlterField(
model_name='job',
name='description_ar',
field=models.TextField(verbose_name='Description Arabic'),
),
migrations.AlterField(
model_name='job',
name='description_en',
field=models.TextField(verbose_name='Description English'),
),
migrations.AlterField(
model_name='job',
name='is_published',
field=models.BooleanField(default=False, verbose_name='Published'),
),
migrations.AlterField(
model_name='job',
name='posted_to_linkedin',
field=models.BooleanField(default=False, verbose_name='Posted to LinkedIn'),
),
migrations.AlterField(
model_name='job',
name='title',
field=models.CharField(max_length=255, verbose_name='Title'),
),
migrations.AlterField(
model_name='job',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
),
migrations.AlterField(
model_name='trainingmaterial',
name='content',
field=models.TextField(blank=True, verbose_name='Content'),
),
migrations.AlterField(
model_name='trainingmaterial',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
),
migrations.AlterField(
model_name='trainingmaterial',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Created by'),
),
migrations.AlterField(
model_name='trainingmaterial',
name='file',
field=models.FileField(blank=True, upload_to='training_materials/', verbose_name='File'),
),
migrations.AlterField(
model_name='trainingmaterial',
name='title',
field=models.CharField(max_length=255, verbose_name='Title'),
),
migrations.AlterField(
model_name='trainingmaterial',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
),
migrations.AlterField(
model_name='trainingmaterial',
name='video_link',
field=models.URLField(blank=True, verbose_name='Video Link'),
),
migrations.AlterField(
model_name='zoommeeting',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
),
migrations.AlterField(
model_name='zoommeeting',
name='duration',
field=models.PositiveIntegerField(verbose_name='Duration'),
),
migrations.AlterField(
model_name='zoommeeting',
name='join_before_host',
field=models.BooleanField(default=False, verbose_name='Join Before Host'),
),
migrations.AlterField(
model_name='zoommeeting',
name='join_url',
field=models.URLField(verbose_name='Join URL'),
),
migrations.AlterField(
model_name='zoommeeting',
name='meeting_id',
field=models.CharField(max_length=20, unique=True, verbose_name='Meeting ID'),
),
migrations.AlterField(
model_name='zoommeeting',
name='mute_upon_entry',
field=models.BooleanField(default=False, verbose_name='Mute Upon Entry'),
),
migrations.AlterField(
model_name='zoommeeting',
name='participant_video',
field=models.BooleanField(default=True, verbose_name='Participant Video'),
),
migrations.AlterField(
model_name='zoommeeting',
name='start_time',
field=models.DateTimeField(verbose_name='Start Time'),
),
migrations.AlterField(
model_name='zoommeeting',
name='timezone',
field=models.CharField(max_length=50, verbose_name='Timezone'),
),
migrations.AlterField(
model_name='zoommeeting',
name='topic',
field=models.CharField(max_length=255, verbose_name='Topic'),
),
migrations.AlterField(
model_name='zoommeeting',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
),
migrations.AlterField(
model_name='zoommeeting',
name='waiting_room',
field=models.BooleanField(default=False, verbose_name='Waiting Room'),
),
migrations.AlterField(
model_name='zoommeeting',
name='zoom_gateway_response',
field=models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response'),
),
migrations.AlterField(
model_name='candidate',
name='job',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-02 14:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0006_jobposting_alter_candidate_options_alter_job_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='jobposting',
name='status',
field=models.CharField(blank=True, choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20, null=True),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-02 14:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0007_alter_jobposting_status'),
]
operations = [
migrations.AddField(
model_name='jobposting',
name='published_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='jobposting',
name='status',
field=models.CharField(blank=True, choices=[('DRAFT', 'Draft'), ('PUBLISHED', 'Published'), ('CLOSED', 'Closed'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20, null=True),
),
]

View File

@ -1,49 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-02 14:39
import django_extensions.db.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0008_jobposting_published_at_alter_jobposting_status'),
]
operations = [
migrations.AddField(
model_name='candidate',
name='slug',
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
),
migrations.AddField(
model_name='job',
name='slug',
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
),
migrations.AddField(
model_name='jobposting',
name='slug',
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
),
migrations.AddField(
model_name='trainingmaterial',
name='slug',
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
),
migrations.AddField(
model_name='zoommeeting',
name='slug',
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
),
migrations.AlterField(
model_name='jobposting',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
),
migrations.AlterField(
model_name='jobposting',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-02 15:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0009_candidate_slug_job_slug_jobposting_slug_and_more'),
]
operations = [
migrations.RemoveField(
model_name='candidate',
name='name',
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-02 16:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0010_remove_candidate_name'),
]
operations = [
migrations.AlterField(
model_name='candidate',
name='stage',
field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], default='Applied', max_length=100, verbose_name='Stage'),
),
]

View File

@ -1,57 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-04 12:39
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0011_alter_candidate_stage'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Form',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('structure', models.JSONField(default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_active', models.BooleanField(default=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FormSubmission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('submission_data', models.JSONField(default=dict)),
('submitted_at', models.DateTimeField(auto_now_add=True)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.TextField(blank=True)),
('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='recruitment.form')),
],
options={
'ordering': ['-submitted_at'],
},
),
migrations.CreateModel(
name='UploadedFile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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)),
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='recruitment.formsubmission')),
],
),
]

View File

@ -1,156 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-05 09:50
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0012_form_formsubmission_uploadedfile'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='FormField',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('label', models.CharField(help_text='Label for the field', max_length=200)),
('field_type', models.CharField(choices=[('text', 'Text Input'), ('email', 'Email'), ('phone', 'Phone'), ('textarea', 'Text Area'), ('file', 'File Upload'), ('date', 'Date Picker'), ('select', 'Dropdown'), ('radio', 'Radio Buttons'), ('checkbox', 'Checkboxes')], help_text='Type of the field', max_length=20)),
('placeholder', models.CharField(blank=True, help_text='Placeholder text', max_length=200)),
('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')),
('options', models.JSONField(blank=True, default=list, help_text='Options for selection fields (stored as JSON array)')),
('file_types', models.CharField(blank=True, help_text="Allowed file types (comma-separated, e.g., '.pdf,.doc,.docx')", max_length=200)),
('max_file_size', models.PositiveIntegerField(default=5, help_text='Maximum file size in MB (default: 5MB)')),
],
options={
'verbose_name': 'Form Field',
'verbose_name_plural': 'Form Fields',
'ordering': ['order'],
},
),
migrations.CreateModel(
name='FormStage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Name of the stage', max_length=200)),
('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')),
],
options={
'verbose_name': 'Form Stage',
'verbose_name_plural': 'Form Stages',
'ordering': ['order'],
},
),
migrations.RemoveField(
model_name='formsubmission',
name='form',
),
migrations.RemoveField(
model_name='uploadedfile',
name='submission',
),
migrations.AlterModelOptions(
name='formsubmission',
options={'ordering': ['-submitted_at'], 'verbose_name': 'Form Submission', 'verbose_name_plural': 'Form Submissions'},
),
migrations.RemoveField(
model_name='formsubmission',
name='ip_address',
),
migrations.RemoveField(
model_name='formsubmission',
name='submission_data',
),
migrations.RemoveField(
model_name='formsubmission',
name='user_agent',
),
migrations.AddField(
model_name='formsubmission',
name='applicant_email',
field=models.EmailField(blank=True, help_text='Email of the applicant', max_length=254),
),
migrations.AddField(
model_name='formsubmission',
name='applicant_name',
field=models.CharField(blank=True, help_text='Name of the applicant', max_length=200),
),
migrations.AddField(
model_name='formsubmission',
name='submitted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='form_submissions', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='FieldResponse',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.JSONField(blank=True, help_text='Response value (stored as JSON)', null=True)),
('uploaded_file', models.FileField(blank=True, null=True, upload_to='form_uploads/')),
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formsubmission')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='recruitment.formfield')),
],
options={
'verbose_name': 'Field Response',
'verbose_name_plural': 'Field Responses',
},
),
migrations.AddField(
model_name='formfield',
name='stage',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='recruitment.formstage'),
),
migrations.CreateModel(
name='FormTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Name of the form template', max_length=200)),
('description', models.TextField(blank=True, help_text='Description of the form template')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_active', models.BooleanField(default=True, help_text='Whether this template is active')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Form Template',
'verbose_name_plural': 'Form Templates',
'ordering': ['-created_at'],
},
),
migrations.AddField(
model_name='formstage',
name='template',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='recruitment.formtemplate'),
),
migrations.AddField(
model_name='formsubmission',
name='template',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='recruitment.formtemplate'),
preserve_default=False,
),
migrations.CreateModel(
name='SharedFormTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_public', models.BooleanField(default=False, help_text='Whether this template is publicly available')),
('created_at', models.DateTimeField(auto_now_add=True)),
('shared_with', models.ManyToManyField(blank=True, related_name='shared_templates', to=settings.AUTH_USER_MODEL)),
('template', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='recruitment.formtemplate')),
],
options={
'verbose_name': 'Shared Form Template',
'verbose_name_plural': 'Shared Form Templates',
},
),
migrations.DeleteModel(
name='Form',
),
migrations.DeleteModel(
name='UploadedFile',
),
]

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More