Merge branch 'bm'
This commit is contained in:
commit
2f0b46a01b
454
ATS_PRODUCT_DOCUMENT.md
Normal file
454
ATS_PRODUCT_DOCUMENT.md
Normal 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
241
ATS_PROJECT_HLD.md
Normal 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
1083
ATS_PROJECT_LLD.md
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -59,7 +59,6 @@ INSTALLED_APPS = [
|
||||
'django_q',
|
||||
'widget_tweaks',
|
||||
'easyaudit'
|
||||
|
||||
]
|
||||
|
||||
|
||||
@ -67,13 +66,13 @@ INSTALLED_APPS = [
|
||||
SITE_ID = 1
|
||||
|
||||
|
||||
LOGIN_REDIRECT_URL = '/dashboard/'
|
||||
LOGIN_REDIRECT_URL = '/'
|
||||
|
||||
|
||||
ACCOUNT_LOGOUT_REDIRECT_URL = '/'
|
||||
|
||||
|
||||
ACCOUNT_SIGNUP_REDIRECT_URL = '/dashboard/'
|
||||
ACCOUNT_SIGNUP_REDIRECT_URL = '/'
|
||||
|
||||
|
||||
LOGIN_URL = '/accounts/login/'
|
||||
@ -135,11 +134,14 @@ WSGI_APPLICATION = 'NorahUniversity.wsgi.application'
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
'ENGINE': 'django.db.backends.postgresql_psycopg2',
|
||||
'NAME': 'norahuniversity',
|
||||
'USER': 'norahuniversity',
|
||||
'PASSWORD': 'norahuniversity',
|
||||
'HOST': '127.0.0.1',
|
||||
'PORT': '5432',
|
||||
}
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||
|
||||
@ -160,6 +162,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"
|
||||
@ -223,6 +242,7 @@ 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
|
||||
@ -245,9 +265,10 @@ LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/jobs/linkedin/callback/'
|
||||
|
||||
Q_CLUSTER = {
|
||||
'name': 'KAAUH_CLUSTER',
|
||||
'workers': 4,
|
||||
'workers': 8,
|
||||
'recycle': 500,
|
||||
'timeout': 60,
|
||||
'max_attempts': 1,
|
||||
'compress': True,
|
||||
'save_limit': 250,
|
||||
'queue_limit': 500,
|
||||
@ -256,7 +277,7 @@ Q_CLUSTER = {
|
||||
'redis': {
|
||||
'host': '127.0.0.1',
|
||||
'port': 6379,
|
||||
'db': 0, },
|
||||
'db': 3, },
|
||||
'ALT_CLUSTERS': {
|
||||
'long': {
|
||||
'timeout': 3000,
|
||||
@ -298,8 +319,8 @@ customColorPalette = [
|
||||
},
|
||||
]
|
||||
|
||||
CKEDITOR_5_CUSTOM_CSS = 'path_to.css' # optional
|
||||
CKEDITOR_5_FILE_STORAGE = "path_to_storage.CustomStorage" # optional
|
||||
# CKEDITOR_5_CUSTOM_CSS = 'path_to.css' # optional
|
||||
# CKEDITOR_5_FILE_STORAGE = "path_to_storage.CustomStorage" # optional
|
||||
CKEDITOR_5_CONFIGS = {
|
||||
'default': {
|
||||
'toolbar': {
|
||||
@ -368,4 +389,4 @@ CKEDITOR_5_CONFIGS = {
|
||||
}
|
||||
|
||||
# Define a constant in settings.py to specify file upload permissions
|
||||
CKEDITOR_5_FILE_UPLOAD_PERMISSION = "staff" # Possible values: "staff", "authenticated", "any"
|
||||
CKEDITOR_5_FILE_UPLOAD_PERMISSION = "staff" # Possible values: "staff", "authenticated", "any"
|
||||
|
||||
@ -16,6 +16,7 @@ urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/', include(router.urls)),
|
||||
path('accounts/', include('allauth.urls')),
|
||||
|
||||
path('i18n/', include('django.conf.urls.i18n')),
|
||||
# path('summernote/', include('django_summernote.urls')),
|
||||
# path('', include('recruitment.urls')),
|
||||
@ -28,10 +29,12 @@ urlpatterns = [
|
||||
path('api/templates/save/', views.save_form_template, name='save_form_template'),
|
||||
path('api/templates/<int:template_id>/', views.load_form_template, name='load_form_template'),
|
||||
path('api/templates/<int:template_id>/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'
|
||||
|
||||
312
TESTING_GUIDE.md
Normal file
312
TESTING_GUIDE.md
Normal 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/)
|
||||
394
conftest.py
Normal file
394
conftest.py
Normal 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
|
||||
20
pytest.ini
Normal file
20
pytest.ini
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -5,7 +5,7 @@ from django.utils import timezone
|
||||
from .models import (
|
||||
JobPosting, Candidate, TrainingMaterial, ZoomMeeting,
|
||||
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
|
||||
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage
|
||||
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment
|
||||
)
|
||||
|
||||
class FormFieldInline(admin.TabularInline):
|
||||
@ -152,13 +152,13 @@ class CandidateAdmin(admin.ModelAdmin):
|
||||
'fields': ('first_name', 'last_name', 'email', 'phone', 'resume')
|
||||
}),
|
||||
('Application Details', {
|
||||
'fields': ('job', 'applied', 'stage')
|
||||
'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': ('match_score', 'strengths', 'weaknesses', 'criteria_checklist')
|
||||
'fields': ('ai_analysis_data',)
|
||||
}),
|
||||
('Additional Information', {
|
||||
'fields': ('submitted_by_agency', 'created_at', 'updated_at')
|
||||
@ -206,7 +206,7 @@ class ZoomMeetingAdmin(admin.ModelAdmin):
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
fieldsets = (
|
||||
('Meeting Details', {
|
||||
'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone')
|
||||
'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone','status')
|
||||
}),
|
||||
('Meeting Settings', {
|
||||
'fields': ('participant_video', 'join_before_host', 'mute_upon_entry', 'waiting_room')
|
||||
@ -221,6 +221,26 @@ class ZoomMeetingAdmin(admin.ModelAdmin):
|
||||
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']
|
||||
@ -265,4 +285,4 @@ admin.site.register(Profile)
|
||||
# admin.site.register(HiringAgency)
|
||||
|
||||
|
||||
admin.site.register(JobPostingImage)
|
||||
admin.site.register(JobPostingImage)
|
||||
|
||||
@ -5,9 +5,11 @@ from django.forms.formsets import formset_factory
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
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 .models import (
|
||||
ZoomMeeting, Candidate,TrainingMaterial,JobPosting,
|
||||
FormTemplate,InterviewSchedule,BreakTime,JobPostingImage
|
||||
FormTemplate,InterviewSchedule,BreakTime,JobPostingImage,
|
||||
Profile,MeetingComment,ScheduledInterview
|
||||
)
|
||||
# from django_summernote.widgets import SummernoteWidget
|
||||
from django_ckeditor_5.widgets import CKEditor5Widget
|
||||
@ -70,50 +72,50 @@ 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)
|
||||
# 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()
|
||||
# # 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
|
||||
# # 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'
|
||||
# # 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.'))
|
||||
# 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)'
|
||||
}
|
||||
)
|
||||
# # 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
|
||||
# return new_stage
|
||||
|
||||
class ZoomMeetingForm(forms.ModelForm):
|
||||
class Meta:
|
||||
@ -197,8 +199,8 @@ class JobPostingForm(forms.ModelForm):
|
||||
'location_city', 'location_state', 'location_country',
|
||||
'description', 'qualifications', 'salary_range', 'benefits','application_start_date'
|
||||
,'application_deadline', 'application_instructions',
|
||||
'position_number', 'reporting_to', 'joining_date', 'status',
|
||||
'created_by','open_positions','hash_tags'
|
||||
'position_number', 'reporting_to', 'joining_date',
|
||||
'created_by','open_positions','hash_tags','max_applications'
|
||||
]
|
||||
widgets = {
|
||||
# Basic Information
|
||||
@ -285,6 +287,11 @@ class JobPostingForm(forms.ModelForm):
|
||||
'class': 'form-control',
|
||||
'placeholder': 'University Administrator'
|
||||
}),
|
||||
'max_applications': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'min': 1,
|
||||
'placeholder': 'Maximum number of applicants'
|
||||
}),
|
||||
}
|
||||
|
||||
def __init__(self,*args,**kwargs):
|
||||
@ -297,7 +304,7 @@ class JobPostingForm(forms.ModelForm):
|
||||
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['status'].initial = 'Draft'
|
||||
self.fields['location_city'].initial='Riyadh'
|
||||
self.fields['location_state'].initial='Riyadh Province'
|
||||
self.fields['location_country'].initial='Saudi Arabia'
|
||||
@ -409,64 +416,64 @@ class FormTemplateForm(forms.ModelForm):
|
||||
Field('is_active', css_class='form-check-input'),
|
||||
Submit('submit', _('Create Template'), css_class='btn btn-primary mt-3')
|
||||
)
|
||||
class BreakTimeForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = BreakTime
|
||||
fields = ['start_time', 'end_time']
|
||||
widgets = {
|
||||
'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
|
||||
'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
|
||||
}
|
||||
# class BreakTimeForm(forms.ModelForm):
|
||||
# class Meta:
|
||||
# model = BreakTime
|
||||
# fields = ['start_time', 'end_time']
|
||||
# widgets = {
|
||||
# 'start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
|
||||
# 'end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}),
|
||||
# }
|
||||
|
||||
BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
|
||||
# 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 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'
|
||||
]
|
||||
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'}),
|
||||
}
|
||||
# class Meta:
|
||||
# model = InterviewSchedule
|
||||
# fields = [
|
||||
# 'candidates', 'start_date', 'end_date', 'working_days',
|
||||
# 'start_time', 'end_time', 'interview_duration', 'buffer_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'}),
|
||||
# }
|
||||
|
||||
def __init__(self, slug, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Filter candidates based on the selected job
|
||||
self.fields['candidates'].queryset = Candidate.objects.filter(
|
||||
job__slug=slug,
|
||||
stage='Interview'
|
||||
)
|
||||
# def __init__(self, slug, *args, **kwargs):
|
||||
# super().__init__(*args, **kwargs)
|
||||
# # Filter candidates based on the selected job
|
||||
# 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')
|
||||
# Convert string values to integers
|
||||
return [int(day) for day in working_days]
|
||||
# def clean_working_days(self):
|
||||
# working_days = self.cleaned_data.get('working_days')
|
||||
# # Convert string values to integers
|
||||
# return [int(day) for day in working_days]
|
||||
|
||||
|
||||
class JobPostingCancelReasonForm(forms.ModelForm):
|
||||
@ -494,7 +501,101 @@ class CandidateExamDateForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
# Use the non-model form for the formset factory
|
||||
BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
|
||||
|
||||
# --- InterviewScheduleForm remains unchanged ---
|
||||
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)
|
||||
# Filter candidates based on the selected job
|
||||
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')
|
||||
# Convert string values to integers
|
||||
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')
|
||||
)
|
||||
|
||||
# --- ScheduleInterviewForCandiateForm remains unchanged ---
|
||||
class ScheduleInterviewForCandiateForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = InterviewSchedule
|
||||
fields = ['start_date', 'end_date', 'start_time', 'end_time', 'interview_duration', 'buffer_time']
|
||||
@ -505,4 +606,90 @@ class ScheduleInterviewForCandiateForm(forms.ModelForm):
|
||||
'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'}),
|
||||
}
|
||||
|
||||
|
||||
class InterviewForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = ScheduledInterview
|
||||
fields = ['job','candidate']
|
||||
|
||||
class ProfileImageUploadForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model=Profile
|
||||
fields=['profile_image']
|
||||
|
||||
|
||||
|
||||
# class UserEditForms(forms.ModelForm):
|
||||
# class Meta:
|
||||
# model = User
|
||||
# fields = ['first_name', 'last_name']
|
||||
|
||||
|
||||
from django.contrib.auth.forms import UserCreationForm
|
||||
# class StaffUserCreationForm(UserCreationForm):
|
||||
# email = forms.EmailField(required=True)
|
||||
# first_name = forms.CharField(max_length=30)
|
||||
# last_name = forms.CharField(max_length=150)
|
||||
|
||||
# class Meta:
|
||||
# model = User
|
||||
# fields = ("email", "first_name", "last_name", "password1", "password2")
|
||||
|
||||
# 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.cleaned_data["email"] # or generate
|
||||
# user.is_staff = True
|
||||
# if commit:
|
||||
# user.save()
|
||||
# return user
|
||||
|
||||
import re
|
||||
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) # never use raw email if it has dots, etc.
|
||||
user.is_staff = True
|
||||
if commit:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
|
||||
|
||||
1
recruitment/hooks.py
Normal file
1
recruitment/hooks.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -12,6 +12,10 @@ from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Define a constant for the API version for better maintenance
|
||||
LINKEDIN_API_VERSION = '2.0.0'
|
||||
LINKEDIN_VERSION = '202409' # Modern API version for header control
|
||||
|
||||
class LinkedInService:
|
||||
def __init__(self):
|
||||
self.client_id = settings.LINKEDIN_CLIENT_ID
|
||||
@ -79,7 +83,8 @@ class LinkedInService:
|
||||
url = f"https://api.linkedin.com/v2/assets/{quote(asset_urn)}"
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.access_token}',
|
||||
'X-Restli-Protocol-Version': '2.0.0'
|
||||
'X-Restli-Protocol-Version': LINKEDIN_API_VERSION,
|
||||
'LinkedIn-Version': LINKEDIN_VERSION,
|
||||
}
|
||||
|
||||
try:
|
||||
@ -96,7 +101,8 @@ class LinkedInService:
|
||||
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 = {
|
||||
@ -138,9 +144,6 @@ class LinkedInService:
|
||||
try:
|
||||
status = self.get_asset_status(asset_urn)
|
||||
if status == "READY" or status == "PROCESSING":
|
||||
# Exit successfully on READY, but also exit successfully on PROCESSING
|
||||
# if the timeout is short, relying on the final API call to succeed.
|
||||
# However, returning True on READY is safest.
|
||||
if status == "READY":
|
||||
logger.info(f"Asset {asset_urn} is READY. Proceeding.")
|
||||
return True
|
||||
@ -151,12 +154,10 @@ class LinkedInService:
|
||||
time.sleep(self.ASSET_STATUS_INTERVAL)
|
||||
|
||||
except Exception as e:
|
||||
# If the status check fails for any reason (400, connection, etc.),
|
||||
# we log it, wait a bit longer, and try again, instead of crashing.
|
||||
logger.warning(f"Error during asset status check for {asset_urn}: {e}. Retrying.")
|
||||
time.sleep(self.ASSET_STATUS_INTERVAL * 2)
|
||||
|
||||
# If the loop times out, force the post anyway (mimicking the successful manual fix)
|
||||
# If the loop times out, return True to attempt post, but log warning
|
||||
logger.warning(f"Asset {asset_urn} timed out, but upload succeeded. Forcing post attempt.")
|
||||
return True
|
||||
|
||||
@ -253,9 +254,12 @@ class LinkedInService:
|
||||
message_parts.append("\n" + " ".join(hashtags))
|
||||
|
||||
return "\n".join(message_parts)
|
||||
|
||||
def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn):
|
||||
"""Step 3: Creates the final LinkedIn post payload with the image asset."""
|
||||
|
||||
def _send_ugc_post(self, person_urn, job_posting, media_category="NONE", media_list=None):
|
||||
"""
|
||||
New private method to handle the final UGC post request (text or image).
|
||||
This eliminates the duplication between create_job_post and create_job_post_with_image.
|
||||
"""
|
||||
|
||||
message = self._build_post_message(job_posting)
|
||||
|
||||
@ -263,25 +267,24 @@ class LinkedInService:
|
||||
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,
|
||||
}
|
||||
|
||||
specific_content = {
|
||||
"com.linkedin.ugc.ShareContent": {
|
||||
"shareCommentary": {"text": message},
|
||||
"shareMediaCategory": media_category,
|
||||
}
|
||||
}
|
||||
|
||||
if media_list:
|
||||
specific_content["com.linkedin.ugc.ShareContent"]["media"] = media_list
|
||||
|
||||
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,
|
||||
"description": {"text": job_posting.title},
|
||||
"originalUrl": job_posting.application_url,
|
||||
"title": {"text": "Apply Now"}
|
||||
}]
|
||||
}
|
||||
},
|
||||
"specificContent": specific_content,
|
||||
"visibility": {
|
||||
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
||||
}
|
||||
@ -300,6 +303,28 @@ class LinkedInService:
|
||||
'status_code': response.status_code
|
||||
}
|
||||
|
||||
|
||||
def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn):
|
||||
"""Step 3: Creates the final LinkedIn post payload with the image asset."""
|
||||
|
||||
# Prepare the media list for the _send_ugc_post helper
|
||||
media_list = [{
|
||||
"status": "READY",
|
||||
"media": asset_urn,
|
||||
"description": {"text": job_posting.title},
|
||||
"originalUrl": job_posting.application_url,
|
||||
"title": {"text": "Apply Now"}
|
||||
}]
|
||||
|
||||
# Use the helper method to send the post
|
||||
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):
|
||||
"""Main method to create a job announcement post (Image or Text)."""
|
||||
if not self.access_token:
|
||||
@ -346,41 +371,12 @@ class LinkedInService:
|
||||
has_image = False
|
||||
|
||||
# === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) ===
|
||||
message = self._build_post_message(job_posting)
|
||||
|
||||
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": "NONE", # Pure text post
|
||||
}
|
||||
},
|
||||
"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
|
||||
}
|
||||
# Use the single helper method here
|
||||
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}")
|
||||
|
||||
@ -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"))
|
||||
156
recruitment/management/commands/seed.py
Normal file
156
recruitment/management/commands/seed.py
Normal 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()}")
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-12 10:34
|
||||
# Generated by Django 5.2.7 on 2025-10-17 19:41
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
@ -105,10 +105,12 @@ class Migration(migrations.Migration):
|
||||
('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, max_length=20, null=True, verbose_name='Status')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
@ -185,7 +187,7 @@ class Migration(migrations.Migration):
|
||||
('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(on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL)),
|
||||
('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',
|
||||
@ -217,13 +219,14 @@ class Migration(migrations.Migration):
|
||||
('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')], 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.DateField(blank=True, null=True, verbose_name='Exam Date')),
|
||||
('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.DateField(blank=True, null=True, verbose_name='Interview Date')),
|
||||
('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
|
||||
('interview_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], 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')),
|
||||
@ -257,11 +260,12 @@ class Migration(migrations.Migration):
|
||||
('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_start_date', models.DateField(blank=True, null=True)),
|
||||
('application_deadline', models.DateField(blank=True, null=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(blank=True, choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20, null=True)),
|
||||
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('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')),
|
||||
@ -271,7 +275,7 @@ class Migration(migrations.Migration):
|
||||
('published_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)),
|
||||
('joining_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')),
|
||||
('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')),
|
||||
@ -296,10 +300,10 @@ class Migration(migrations.Migration):
|
||||
('working_days', models.JSONField(verbose_name='Working Days')),
|
||||
('start_time', models.TimeField(verbose_name='Start Time')),
|
||||
('end_time', models.TimeField(verbose_name='End Time')),
|
||||
('breaks', models.JSONField(blank=True, default=list, verbose_name='Break Times')),
|
||||
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
|
||||
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('breaks', models.ManyToManyField(blank=True, related_name='schedules', to='recruitment.breaktime')),
|
||||
('candidates', models.ManyToManyField(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')),
|
||||
@ -322,8 +326,8 @@ class Migration(migrations.Migration):
|
||||
name='JobPostingImage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('post_image', models.ImageField(upload_to='post/')),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting')),
|
||||
('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(
|
||||
@ -405,7 +409,7 @@ class Migration(migrations.Migration):
|
||||
('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(on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')),
|
||||
('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')),
|
||||
],
|
||||
options={
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-12 10:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='jobposting',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20),
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-12 12:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='is_potential_candidate',
|
||||
field=models.BooleanField(default=False, verbose_name='Potential Candidate'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='jobposting',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-12 15:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0002_candidate_is_potential_candidate_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='exam_date',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Exam Date'),
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-12 13:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0002_alter_jobposting_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='jobposting',
|
||||
old_name='start_date',
|
||||
new_name='joining_date',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='jobposting',
|
||||
name='application_start_date',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-12 15:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0003_alter_candidate_exam_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='interview_date',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Interview Date'),
|
||||
),
|
||||
]
|
||||
@ -1,22 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-12 21:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0004_alter_candidate_interview_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='interviewschedule',
|
||||
name='breaks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interviewschedule',
|
||||
name='breaks',
|
||||
field=models.JSONField(blank=True, default=list, verbose_name='Break Times'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-13 12:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0005_remove_interviewschedule_breaks_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='zoommeeting',
|
||||
name='meeting_status',
|
||||
field=models.CharField(choices=[('scheduled', 'Scheduled'), ('started', 'Started'), ('ended', 'Ended')], default='scheduled', max_length=20, verbose_name='Meeting Status'),
|
||||
),
|
||||
]
|
||||
@ -1,22 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-13 12:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0006_zoommeeting_meeting_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='zoommeeting',
|
||||
name='meeting_status',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='zoommeeting',
|
||||
name='status',
|
||||
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Status'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-13 12:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0007_remove_zoommeeting_meeting_status_zoommeeting_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='zoommeeting',
|
||||
name='password',
|
||||
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Password'),
|
||||
),
|
||||
]
|
||||
@ -1,14 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-13 14:14
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0003_rename_start_date_jobposting_joining_date_and_more'),
|
||||
('recruitment', '0008_zoommeeting_password'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
@ -1,14 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-13 14:18
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0003_rename_start_date_jobposting_joining_date_and_more'),
|
||||
('recruitment', '0008_zoommeeting_password'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
@ -1,19 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-13 19:55
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0009_merge_20251013_1714'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='scheduledinterview',
|
||||
name='schedule',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule'),
|
||||
),
|
||||
]
|
||||
@ -1,14 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-13 15:19
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0009_merge_20251013_1714'),
|
||||
('recruitment', '0009_merge_20251013_1718'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
@ -1,25 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-13 22:16
|
||||
|
||||
import django.db.models.deletion
|
||||
import recruitment.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0010_merge_20251013_1819'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='jobpostingimage',
|
||||
name='job',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='jobpostingimage',
|
||||
name='post_image',
|
||||
field=models.ImageField(upload_to='post/', validators=[recruitment.validators.validate_image_size]),
|
||||
),
|
||||
]
|
||||
@ -1,14 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-14 11:03
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0010_alter_scheduledinterview_schedule'),
|
||||
('recruitment', '0011_alter_jobpostingimage_job_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
@ -1,21 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-14 11:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0012_merge_20251014_1403'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='formtemplate',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-15 10:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0013_alter_formtemplate_created_by'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='formtemplate',
|
||||
name='close_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Date and time at which applications close', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='formtemplate',
|
||||
name='max_applications',
|
||||
field=models.PositiveIntegerField(default=1000, help_text='Maximum number of applications allowed'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='formtemplate',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='formtemplate',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-15 10:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0014_formtemplate_close_at_formtemplate_max_applications_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='formtemplate',
|
||||
name='close_at',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='formtemplate',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='formtemplate',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-15 10:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0015_remove_formtemplate_close_at_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='formtemplate',
|
||||
name='max_applications',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='jobposting',
|
||||
name='max_applications',
|
||||
field=models.PositiveIntegerField(default=1000, help_text='Maximum number of applications allowed'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-15 15:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0016_remove_formtemplate_max_applications_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='interviewschedule',
|
||||
name='breaks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interviewschedule',
|
||||
name='break_end',
|
||||
field=models.TimeField(blank=True, null=True, verbose_name='Break End Time'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interviewschedule',
|
||||
name='break_start',
|
||||
field=models.TimeField(blank=True, null=True, verbose_name='Break Start Time'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-15 15:55
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0017_remove_interviewschedule_breaks_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='interviewschedule',
|
||||
old_name='break_end',
|
||||
new_name='break_end_time',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='interviewschedule',
|
||||
old_name='break_start',
|
||||
new_name='break_start_time',
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-15 16:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0018_rename_break_end_interviewschedule_break_end_time_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='interviewschedule',
|
||||
name='candidates',
|
||||
field=models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-15 16:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0019_alter_interviewschedule_candidates'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='interviewschedule',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
|
||||
),
|
||||
]
|
||||
35
recruitment/migrations/0021_meetingcomment.py
Normal file
35
recruitment/migrations/0021_meetingcomment.py
Normal file
@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-16 13:52
|
||||
|
||||
import django.db.models.deletion
|
||||
import django_ckeditor_5.fields
|
||||
import django_extensions.db.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0020_alter_interviewschedule_created_at'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-16 19:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0021_meetingcomment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='resume_parsed_category',
|
||||
field=models.TextField(blank=True, verbose_name='Resume Parsed Category'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-16 19:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0022_candidate_resume_parsed_category'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='jobposting',
|
||||
name='max_applications',
|
||||
field=models.PositiveIntegerField(blank=True, default=1000, help_text='Maximum number of applications allowed', null=True),
|
||||
),
|
||||
]
|
||||
18
recruitment/migrations/0024_alter_zoommeeting_status.py
Normal file
18
recruitment/migrations/0024_alter_zoommeeting_status.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.4 on 2025-10-17 20:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0023_alter_jobposting_max_applications'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='status',
|
||||
field=models.CharField(blank=True, default='waiting', max_length=20, null=True, verbose_name='Status'),
|
||||
),
|
||||
]
|
||||
18
recruitment/migrations/0025_candidate_recommendation.py
Normal file
18
recruitment/migrations/0025_candidate_recommendation.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.4 on 2025-10-17 21:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0024_alter_zoommeeting_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='recommendation',
|
||||
field=models.TextField(blank=True, verbose_name='Recommendation'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.4 on 2025-10-17 21:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0025_candidate_recommendation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='candidate',
|
||||
name='resume_parsed_category',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='major_category_name',
|
||||
field=models.TextField(blank=True, verbose_name='Major Category Name'),
|
||||
),
|
||||
]
|
||||
159
recruitment/migrations/0027_alter_candidate_email_and_more.py
Normal file
159
recruitment/migrations/0027_alter_candidate_email_and_more.py
Normal file
@ -0,0 +1,159 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-18 17:51
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0026_remove_candidate_resume_parsed_category_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='email',
|
||||
field=models.EmailField(db_index=True, max_length=254, verbose_name='Email'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='major_category_name',
|
||||
field=models.TextField(blank=True, db_index=True, verbose_name='Major Category Name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='match_score',
|
||||
field=models.IntegerField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='stage',
|
||||
field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], db_index=True, default='Applied', max_length=100, verbose_name='Stage'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='formsubmission',
|
||||
name='applicant_email',
|
||||
field=models.EmailField(blank=True, db_index=True, help_text='Email of the applicant', max_length=254),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='formsubmission',
|
||||
name='submitted_at',
|
||||
field=models.DateTimeField(auto_now_add=True, db_index=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interviewschedule',
|
||||
name='end_date',
|
||||
field=models.DateField(db_index=True, verbose_name='End Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interviewschedule',
|
||||
name='start_date',
|
||||
field=models.DateField(db_index=True, verbose_name='Start Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='jobposting',
|
||||
name='application_deadline',
|
||||
field=models.DateField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='jobposting',
|
||||
name='published_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='jobposting',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scheduledinterview',
|
||||
name='interview_date',
|
||||
field=models.DateField(db_index=True, verbose_name='Interview Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scheduledinterview',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='meeting_id',
|
||||
field=models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='start_time',
|
||||
field=models.DateTimeField(db_index=True, verbose_name='Start Time'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='zoommeeting',
|
||||
name='status',
|
||||
field=models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='candidate',
|
||||
index=models.Index(fields=['job', 'stage'], name='recruitment_job_id_766dbe_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='candidate',
|
||||
index=models.Index(fields=['job', 'stage', 'match_score'], name='recruitment_job_id_bd6512_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='candidate',
|
||||
index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='fieldresponse',
|
||||
index=models.Index(fields=['submission'], name='recruitment_submiss_474130_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='fieldresponse',
|
||||
index=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='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='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='jobposting',
|
||||
index=models.Index(fields=['status', 'created_at'], name='recruitment_status_42c036_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'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.4 on 2025-10-18 21:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0027_alter_candidate_email_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='interview_status',
|
||||
field=models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.2.4 on 2025-10-19 10:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0028_alter_candidate_interview_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='candidate',
|
||||
name='recruitment_job_id_766dbe_idx',
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name='candidate',
|
||||
name='recruitment_job_id_bd6512_idx',
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name='jobposting',
|
||||
name='recruitment_status_42c036_idx',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='candidate',
|
||||
name='criteria_checklist',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='candidate',
|
||||
name='major_category_name',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='candidate',
|
||||
name='match_score',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='candidate',
|
||||
name='recommendation',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='candidate',
|
||||
name='strengths',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='candidate',
|
||||
name='weaknesses',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='ai_analysis_data',
|
||||
field=models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='candidate',
|
||||
index=models.Index(fields=['stage'], name='recruitment_stage_f1c6eb_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='jobposting',
|
||||
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
|
||||
),
|
||||
]
|
||||
17
recruitment/migrations/0030_alter_candidate_options.py
Normal file
17
recruitment/migrations/0030_alter_candidate_options.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-19 13:39
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0029_remove_candidate_recruitment_job_id_766dbe_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='candidate',
|
||||
options={'ordering': ['-ai_analysis_data__match_score', '-created_at'], 'verbose_name': 'Candidate', 'verbose_name_plural': 'Candidates'},
|
||||
),
|
||||
]
|
||||
17
recruitment/migrations/0031_alter_candidate_options.py
Normal file
17
recruitment/migrations/0031_alter_candidate_options.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-19 13:43
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0030_alter_candidate_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='candidate',
|
||||
options={'verbose_name': 'Candidate', 'verbose_name_plural': 'Candidates'},
|
||||
),
|
||||
]
|
||||
@ -1,21 +1,18 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from typing import List,Dict,Any
|
||||
from django.utils import timezone
|
||||
from .validators import validate_hash_tags, validate_image_size
|
||||
from django.db.models import FloatField,CharField
|
||||
from django.db.models.functions import Cast
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import URLValidator
|
||||
from django_countries.fields import CountryField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django_ckeditor_5.fields import CKEditor5Field
|
||||
from django.utils.html import strip_tags
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_extensions.db.fields import RandomCharField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django_countries.fields import CountryField
|
||||
from django.urls import reverse
|
||||
# from ckeditor.fields import RichTextField
|
||||
from django_ckeditor_5.fields import CKEditor5Field
|
||||
|
||||
|
||||
|
||||
class Profile(models.Model):
|
||||
profile_image = models.ImageField(null=True, blank=True, upload_to="profile_pic/")
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||
from .validators import validate_hash_tags, validate_image_size
|
||||
|
||||
|
||||
class Base(models.Model):
|
||||
@ -28,24 +25,9 @@ class Base(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
# # Create your models here.
|
||||
# class Job(Base):
|
||||
# title = models.CharField(max_length=255, verbose_name=_('Title'))
|
||||
# description_en = models.TextField(verbose_name=_('Description English'))
|
||||
# description_ar = models.TextField(verbose_name=_('Description Arabic'))
|
||||
# is_published = models.BooleanField(default=False, verbose_name=_('Published'))
|
||||
# posted_to_linkedin = models.BooleanField(default=False, verbose_name=_('Posted to LinkedIn'))
|
||||
# created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at'))
|
||||
# updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated at'))
|
||||
|
||||
# class Meta:
|
||||
# verbose_name = _('Job')
|
||||
# verbose_name_plural = _('Jobs')
|
||||
|
||||
# def __str__(self):
|
||||
# return self.title
|
||||
|
||||
class Profile(models.Model):
|
||||
profile_image = models.ImageField(null=True, blank=True, upload_to="profile_pic/")
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||
|
||||
class JobPosting(Base):
|
||||
# Basic Job Information
|
||||
@ -99,7 +81,7 @@ class JobPosting(Base):
|
||||
blank=True,
|
||||
)
|
||||
application_start_date=models.DateField(null=True, blank=True)
|
||||
application_deadline = models.DateField(null=True, blank=True)
|
||||
application_deadline = models.DateField(db_index=True, null=True, blank=True) # Added index
|
||||
application_instructions =CKEditor5Field(
|
||||
blank=True, null=True,config_name='extends'
|
||||
)
|
||||
@ -119,7 +101,7 @@ class JobPosting(Base):
|
||||
("ARCHIVED", "Archived"),
|
||||
]
|
||||
status = models.CharField(
|
||||
max_length=20, choices=STATUS_CHOICES, default="DRAFT"
|
||||
db_index=True, max_length=20, choices=STATUS_CHOICES, default="DRAFT" # Added index
|
||||
)
|
||||
|
||||
# hashtags for social media
|
||||
@ -143,7 +125,7 @@ class JobPosting(Base):
|
||||
)
|
||||
linkedin_posted_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
published_at = models.DateTimeField(db_index=True, null=True, blank=True) # Added index
|
||||
# University Specific Fields
|
||||
position_number = models.CharField(
|
||||
max_length=50, blank=True, help_text="University position number"
|
||||
@ -163,8 +145,11 @@ class JobPosting(Base):
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="The system or channel from which this job posting originated or was first published.",
|
||||
db_index=True # Explicitly index ForeignKey
|
||||
)
|
||||
|
||||
max_applications = models.PositiveIntegerField(
|
||||
default=1000, help_text="Maximum number of applications allowed",null=True,blank=True
|
||||
)
|
||||
hiring_agency = models.ManyToManyField(
|
||||
"HiringAgency",
|
||||
blank=True,
|
||||
@ -191,6 +176,10 @@ class JobPosting(Base):
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Job Posting"
|
||||
verbose_name_plural = "Job Postings"
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'created_at','title']),
|
||||
models.Index(fields=['slug']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} - {self.internal_job_id}-{self.get_status_display()}"
|
||||
@ -247,6 +236,71 @@ class JobPosting(Base):
|
||||
)
|
||||
self.save()
|
||||
|
||||
def _check_content(self, field_value):
|
||||
"""Helper to check if a field contains meaningful content."""
|
||||
if not field_value:
|
||||
return False
|
||||
|
||||
# 1. Replace the common HTML non-breaking space entity with a standard space.
|
||||
content = field_value.replace(' ', ' ')
|
||||
|
||||
# 2. Remove all HTML tags (leaving only text and remaining spaces).
|
||||
stripped = strip_tags(content)
|
||||
|
||||
# 3. Use .strip() to remove ALL leading/trailing whitespace, including the ones from step 1.
|
||||
final_content = stripped.strip()
|
||||
|
||||
# Return True if any content remains after stripping tags and spaces.
|
||||
return bool(final_content)
|
||||
|
||||
|
||||
@property
|
||||
def has_description_content(self):
|
||||
"""Returns True if the description field has meaningful content."""
|
||||
return self._check_content(self.description)
|
||||
|
||||
@property
|
||||
def has_qualifications_content(self):
|
||||
"""Returns True if the qualifications field has meaningful content."""
|
||||
return self._check_content(self.qualifications)
|
||||
|
||||
# Add similar properties for benefits and application_instructions
|
||||
@property
|
||||
def has_benefits_content(self):
|
||||
return self._check_content(self.benefits)
|
||||
|
||||
@property
|
||||
def has_application_instructions_content(self):
|
||||
return self._check_content(self.application_instructions)
|
||||
@property
|
||||
def current_applications_count(self):
|
||||
"""Returns the current number of candidates associated with this job."""
|
||||
return self.candidates.count()
|
||||
|
||||
@property
|
||||
def is_application_limit_reached(self):
|
||||
"""Checks if the current application count meets or exceeds the max limit."""
|
||||
if self.max_applications == 0:
|
||||
return True
|
||||
|
||||
return self.current_applications_count >= self.max_applications
|
||||
@property
|
||||
def all_candidates(self):
|
||||
return self.candidates.annotate(sortable_score=Cast('ai_analysis_data__match_score',output_field=CharField())).order_by('-sortable_score')
|
||||
@property
|
||||
def screening_candidates(self):
|
||||
return self.all_candidates.filter(stage="Applied")
|
||||
|
||||
@property
|
||||
def exam_candidates(self):
|
||||
return self.all_candidates.filter(stage="Exam")
|
||||
@property
|
||||
def interview_candidates(self):
|
||||
return self.all_candidates.filter(stage="Interview")
|
||||
|
||||
@property
|
||||
def offer_candidates(self):
|
||||
return self.all_candidates.filter(stage="Offer")
|
||||
|
||||
class JobPostingImage(models.Model):
|
||||
job=models.OneToOneField('JobPosting',on_delete=models.CASCADE,related_name='post_images')
|
||||
@ -288,7 +342,7 @@ class Candidate(Base):
|
||||
)
|
||||
first_name = models.CharField(max_length=255, verbose_name=_("First Name"))
|
||||
last_name = models.CharField(max_length=255, verbose_name=_("Last Name"))
|
||||
email = models.EmailField(verbose_name=_("Email"))
|
||||
email = models.EmailField(db_index=True, verbose_name=_("Email")) # Added index
|
||||
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"))
|
||||
@ -301,7 +355,7 @@ class Candidate(Base):
|
||||
parsed_summary = models.TextField(blank=True, verbose_name=_("Parsed Summary"))
|
||||
applied = models.BooleanField(default=False, verbose_name=_("Applied"))
|
||||
stage = models.CharField(
|
||||
max_length=100,
|
||||
db_index=True, max_length=100, # Added index
|
||||
default="Applied",
|
||||
choices=Stage.choices,
|
||||
verbose_name=_("Stage"),
|
||||
@ -326,7 +380,7 @@ class Candidate(Base):
|
||||
null=True, blank=True, verbose_name=_("Interview Date")
|
||||
)
|
||||
interview_status = models.CharField(
|
||||
choices=Status.choices,
|
||||
choices=ExamStatus.choices,
|
||||
max_length=100,
|
||||
null=True,
|
||||
blank=True,
|
||||
@ -341,13 +395,18 @@ class Candidate(Base):
|
||||
verbose_name=_("Offer Status"),
|
||||
)
|
||||
join_date = models.DateField(null=True, blank=True, verbose_name=_("Join Date"))
|
||||
|
||||
ai_analysis_data = models.JSONField(
|
||||
verbose_name="AI Analysis Data",
|
||||
default=dict,
|
||||
help_text="Full JSON output from the resume scoring model."
|
||||
)
|
||||
# Scoring fields (populated by signal)
|
||||
match_score = models.IntegerField(null=True, blank=True)
|
||||
strengths = models.TextField(blank=True)
|
||||
weaknesses = models.TextField(blank=True)
|
||||
criteria_checklist = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# match_score = models.IntegerField(db_index=True, null=True, blank=True) # Added index
|
||||
# strengths = models.TextField(blank=True)
|
||||
# weaknesses = models.TextField(blank=True)
|
||||
# criteria_checklist = models.JSONField(default=dict, blank=True)
|
||||
# major_category_name = models.TextField(db_index=True, blank=True, verbose_name=_("Major Category Name")) # Added index
|
||||
# recommendation = models.TextField(blank=True, verbose_name=_("Recommendation"))
|
||||
submitted_by_agency = models.ForeignKey(
|
||||
"HiringAgency",
|
||||
on_delete=models.SET_NULL,
|
||||
@ -360,6 +419,103 @@ class Candidate(Base):
|
||||
class Meta:
|
||||
verbose_name = _("Candidate")
|
||||
verbose_name_plural = _("Candidates")
|
||||
indexes = [
|
||||
models.Index(fields=['stage']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
def set_field(self, key: str, value: Any):
|
||||
"""
|
||||
Generic method to set any single key-value pair and save.
|
||||
"""
|
||||
self.ai_analysis_data[key] = value
|
||||
self.save(update_fields=['ai_analysis_data'])
|
||||
|
||||
# ====================================================================
|
||||
# ✨ PROPERTIES (GETTERS)
|
||||
# ====================================================================
|
||||
|
||||
@property
|
||||
def match_score(self) -> int:
|
||||
"""1. A score from 0 to 100 representing how well the candidate fits the role."""
|
||||
return self.ai_analysis_data.get('match_score', 0)
|
||||
|
||||
@property
|
||||
def years_of_experience(self) -> float:
|
||||
"""4. The total number of years of professional experience as a numerical value."""
|
||||
return self.ai_analysis_data.get('years_of_experience', 0.0)
|
||||
|
||||
@property
|
||||
def soft_skills_score(self) -> int:
|
||||
"""15. A score (0-100) for inferred non-technical skills."""
|
||||
return self.ai_analysis_data.get('soft_skills_score', 0)
|
||||
|
||||
@property
|
||||
def industry_match_score(self) -> int:
|
||||
"""16. A score (0-100) for the relevance of the candidate's industry experience."""
|
||||
# Renamed to clarify: experience_industry_match
|
||||
return self.ai_analysis_data.get('experience_industry_match', 0)
|
||||
|
||||
# --- Properties for Funnel & Screening Efficiency ---
|
||||
|
||||
@property
|
||||
def min_requirements_met(self) -> bool:
|
||||
"""14. Boolean (true/false) indicating if all non-negotiable minimum requirements are met."""
|
||||
return self.ai_analysis_data.get('min_req_met_bool', False)
|
||||
|
||||
@property
|
||||
def screening_stage_rating(self) -> str:
|
||||
"""13. A standardized rating (e.g., "A - Highly Qualified", "B - Qualified")."""
|
||||
return self.ai_analysis_data.get('screening_stage_rating', 'N/A')
|
||||
|
||||
@property
|
||||
def top_3_keywords(self) -> List[str]:
|
||||
"""10. A list of the three most dominant and relevant technical skills or technologies."""
|
||||
return self.ai_analysis_data.get('top_3_keywords', [])
|
||||
|
||||
@property
|
||||
def most_recent_job_title(self) -> str:
|
||||
"""8. The candidate's most recent or current professional job title."""
|
||||
return self.ai_analysis_data.get('most_recent_job_title', 'N/A')
|
||||
|
||||
# --- Properties for Structured Detail ---
|
||||
|
||||
@property
|
||||
def criteria_checklist(self) -> Dict[str, str]:
|
||||
"""5 & 6. An object rating the candidate's match for each specific criterion."""
|
||||
return self.ai_analysis_data.get('criteria_checklist', {})
|
||||
|
||||
@property
|
||||
def professional_category(self) -> str:
|
||||
"""7. The most fitting professional field or category for the individual."""
|
||||
return self.ai_analysis_data.get('category', 'N/A')
|
||||
|
||||
@property
|
||||
def language_fluency(self) -> List[Dict[str, str]]:
|
||||
"""12. A list of languages and their fluency levels mentioned."""
|
||||
return self.ai_analysis_data.get('language_fluency', [])
|
||||
|
||||
# --- Properties for Summaries and Narrative ---
|
||||
|
||||
@property
|
||||
def strengths(self) -> str:
|
||||
"""2. A brief summary of why the candidate is a strong fit."""
|
||||
return self.ai_analysis_data.get('strengths', '')
|
||||
|
||||
@property
|
||||
def weaknesses(self) -> str:
|
||||
"""3. A brief summary of where the candidate falls short or what criteria are missing."""
|
||||
return self.ai_analysis_data.get('weaknesses', '')
|
||||
|
||||
@property
|
||||
def job_fit_narrative(self) -> str:
|
||||
"""11. A single, concise sentence summarizing the core fit."""
|
||||
return self.ai_analysis_data.get('job_fit_narrative', '')
|
||||
|
||||
@property
|
||||
def recommendation(self) -> str:
|
||||
"""9. Provide a detailed final recommendation for the candidate."""
|
||||
# Using a more descriptive name to avoid conflict with potential built-in methods
|
||||
return self.ai_analysis_data.get('recommendation', '')
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@ -375,43 +531,12 @@ class Candidate(Base):
|
||||
return self.resume.size
|
||||
return 0
|
||||
|
||||
# def clean(self):
|
||||
# """Validate stage transitions"""
|
||||
# # Only validate if this is an existing record (not being created)
|
||||
# if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
|
||||
# old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
|
||||
# if self.stage not in allowed_next_stages:
|
||||
# raise ValidationError(
|
||||
# {
|
||||
# "stage": f'Cannot transition from "{old_stage}" to "{self.stage}". '
|
||||
# f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}"
|
||||
# }
|
||||
# )
|
||||
|
||||
# # Validate that the stage is a valid choice
|
||||
# if self.stage not in [choice[0] for choice in self.Stage.choices]:
|
||||
# raise ValidationError(
|
||||
# {
|
||||
# "stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}"
|
||||
# }
|
||||
# )
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to ensure validation is called"""
|
||||
self.clean() # Call validation before saving
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# def can_transition_to(self, new_stage):
|
||||
# """Check if a stage transition is allowed"""
|
||||
# if not self.pk: # New record - can be in Applied stage
|
||||
# return new_stage == "Applied"
|
||||
|
||||
# old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
# return new_stage in allowed_next_stages
|
||||
|
||||
def get_available_stages(self):
|
||||
"""Get list of stages this candidate can transition to"""
|
||||
if not self.pk: # New record
|
||||
@ -486,15 +611,16 @@ class TrainingMaterial(Base):
|
||||
|
||||
class ZoomMeeting(Base):
|
||||
class MeetingStatus(models.TextChoices):
|
||||
SCHEDULED = "scheduled", _("Scheduled")
|
||||
WAITING = "waiting", _("Waiting")
|
||||
STARTED = "started", _("Started")
|
||||
ENDED = "ended", _("Ended")
|
||||
CANCELLED = "cancelled",_("Cancelled")
|
||||
# Basic meeting details
|
||||
topic = models.CharField(max_length=255, verbose_name=_("Topic"))
|
||||
meeting_id = models.CharField(
|
||||
max_length=20, unique=True, verbose_name=_("Meeting ID")
|
||||
db_index=True, max_length=20, unique=True, verbose_name=_("Meeting ID") # Added index
|
||||
) # Unique identifier for the meeting
|
||||
start_time = models.DateTimeField(verbose_name=_("Start Time"))
|
||||
start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time")) # Added index
|
||||
duration = models.PositiveIntegerField(
|
||||
verbose_name=_("Duration")
|
||||
) # Duration in minutes
|
||||
@ -520,10 +646,11 @@ class ZoomMeeting(Base):
|
||||
blank=True, null=True, verbose_name=_("Zoom Gateway Response")
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
db_index=True, max_length=20, # Added index
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Status"),
|
||||
default=MeetingStatus.WAITING,
|
||||
)
|
||||
# Timestamps
|
||||
|
||||
@ -531,20 +658,51 @@ class ZoomMeeting(Base):
|
||||
return self.topic
|
||||
|
||||
|
||||
class MeetingComment(Base):
|
||||
"""
|
||||
Model for storing meeting comments/notes
|
||||
"""
|
||||
meeting = models.ForeignKey(
|
||||
ZoomMeeting,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="comments",
|
||||
verbose_name=_("Meeting")
|
||||
)
|
||||
author = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="meeting_comments",
|
||||
verbose_name=_("Author")
|
||||
)
|
||||
content = CKEditor5Field(
|
||||
verbose_name=_("Content"),
|
||||
config_name='extends'
|
||||
)
|
||||
# Inherited from Base: created_at, updated_at, slug
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Meeting Comment")
|
||||
verbose_name_plural = _("Meeting Comments")
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Comment by {self.author.get_username()} on {self.meeting.topic}"
|
||||
|
||||
|
||||
class FormTemplate(Base):
|
||||
"""
|
||||
Represents a complete form template with multiple stages
|
||||
"""
|
||||
|
||||
job = models.OneToOneField(
|
||||
JobPosting, on_delete=models.CASCADE, related_name="form_template"
|
||||
JobPosting, on_delete=models.CASCADE, related_name="form_template", db_index=True
|
||||
)
|
||||
name = models.CharField(max_length=200, help_text="Name of the form template")
|
||||
description = models.TextField(
|
||||
blank=True, help_text="Description of the form template"
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, related_name="form_templates",null=True,blank=True
|
||||
User, on_delete=models.CASCADE, related_name="form_templates",null=True,blank=True, db_index=True
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=False, help_text="Whether this template is active"
|
||||
@ -554,6 +712,10 @@ class FormTemplate(Base):
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Form Template"
|
||||
verbose_name_plural = "Form Templates"
|
||||
indexes = [
|
||||
models.Index(fields=['created_at']),
|
||||
models.Index(fields=['is_active']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@ -571,7 +733,7 @@ class FormStage(Base):
|
||||
"""
|
||||
|
||||
template = models.ForeignKey(
|
||||
FormTemplate, on_delete=models.CASCADE, related_name="stages"
|
||||
FormTemplate, on_delete=models.CASCADE, related_name="stages", db_index=True
|
||||
)
|
||||
name = models.CharField(max_length=200, help_text="Name of the stage")
|
||||
order = models.PositiveIntegerField(
|
||||
@ -612,7 +774,7 @@ class FormField(Base):
|
||||
]
|
||||
|
||||
stage = models.ForeignKey(
|
||||
FormStage, on_delete=models.CASCADE, related_name="fields"
|
||||
FormStage, on_delete=models.CASCADE, related_name="fields", db_index=True
|
||||
)
|
||||
label = models.CharField(max_length=200, help_text="Label for the field")
|
||||
field_type = models.CharField(
|
||||
@ -703,7 +865,7 @@ class FormSubmission(Base):
|
||||
"""
|
||||
|
||||
template = models.ForeignKey(
|
||||
FormTemplate, on_delete=models.CASCADE, related_name="submissions"
|
||||
FormTemplate, on_delete=models.CASCADE, related_name="submissions", db_index=True
|
||||
)
|
||||
submitted_by = models.ForeignKey(
|
||||
User,
|
||||
@ -711,17 +873,21 @@ class FormSubmission(Base):
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="form_submissions",
|
||||
db_index=True
|
||||
)
|
||||
submitted_at = models.DateTimeField(auto_now_add=True)
|
||||
submitted_at = models.DateTimeField(db_index=True, auto_now_add=True) # Added index
|
||||
applicant_name = models.CharField(
|
||||
max_length=200, blank=True, help_text="Name of the applicant"
|
||||
)
|
||||
applicant_email = models.EmailField(blank=True, help_text="Email of the applicant")
|
||||
applicant_email = models.EmailField(db_index=True, blank=True, help_text="Email of the applicant") # Added index
|
||||
|
||||
class Meta:
|
||||
ordering = ["-submitted_at"]
|
||||
verbose_name = "Form Submission"
|
||||
verbose_name_plural = "Form Submissions"
|
||||
indexes = [
|
||||
models.Index(fields=['submitted_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Submission for {self.template.name} - {self.submitted_at.strftime('%Y-%m-%d %H:%M')}"
|
||||
@ -733,10 +899,10 @@ class FieldResponse(Base):
|
||||
"""
|
||||
|
||||
submission = models.ForeignKey(
|
||||
FormSubmission, on_delete=models.CASCADE, related_name="responses"
|
||||
FormSubmission, on_delete=models.CASCADE, related_name="responses", db_index=True
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
FormField, on_delete=models.CASCADE, related_name="responses"
|
||||
FormField, on_delete=models.CASCADE, related_name="responses", db_index=True
|
||||
)
|
||||
|
||||
# Store the response value as JSON to handle different data types
|
||||
@ -750,6 +916,10 @@ class FieldResponse(Base):
|
||||
class Meta:
|
||||
verbose_name = "Field Response"
|
||||
verbose_name_plural = "Field Responses"
|
||||
indexes = [
|
||||
models.Index(fields=['submission']),
|
||||
models.Index(fields=['field']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Response to {self.field.label} in {self.submission}"
|
||||
@ -987,18 +1157,19 @@ class InterviewSchedule(Base):
|
||||
"""Stores the scheduling criteria for interviews"""
|
||||
|
||||
job = models.ForeignKey(
|
||||
JobPosting, on_delete=models.CASCADE, related_name="interview_schedules"
|
||||
JobPosting, on_delete=models.CASCADE, related_name="interview_schedules", db_index=True
|
||||
)
|
||||
candidates = models.ManyToManyField(Candidate, related_name="interview_schedules")
|
||||
start_date = models.DateField(verbose_name=_("Start Date"))
|
||||
end_date = models.DateField(verbose_name=_("End Date"))
|
||||
candidates = models.ManyToManyField(Candidate, related_name="interview_schedules", blank=True,null=True)
|
||||
start_date = models.DateField(db_index=True, verbose_name=_("Start Date")) # Added index
|
||||
end_date = models.DateField(db_index=True, verbose_name=_("End Date")) # Added index
|
||||
working_days = models.JSONField(
|
||||
verbose_name=_("Working Days")
|
||||
) # Store days of week as [0,1,2,3,4] for Mon-Fri
|
||||
start_time = models.TimeField(verbose_name=_("Start Time"))
|
||||
end_time = models.TimeField(verbose_name=_("End Time"))
|
||||
|
||||
breaks = models.JSONField(default=list, blank=True, verbose_name=_('Break Times'))
|
||||
break_start_time = models.TimeField(verbose_name=_("Break Start Time"),null=True,blank=True)
|
||||
break_end_time = models.TimeField(verbose_name=_("Break End Time"),null=True,blank=True)
|
||||
|
||||
interview_duration = models.PositiveIntegerField(
|
||||
verbose_name=_("Interview Duration (minutes)")
|
||||
@ -1006,12 +1177,18 @@ class InterviewSchedule(Base):
|
||||
buffer_time = models.PositiveIntegerField(
|
||||
verbose_name=_("Buffer Time (minutes)"), default=0
|
||||
)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE, db_index=True) # Added index
|
||||
|
||||
def __str__(self):
|
||||
return f"Interview Schedule for {self.job.title}"
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['start_date']),
|
||||
models.Index(fields=['end_date']),
|
||||
models.Index(fields=['created_by']),
|
||||
]
|
||||
|
||||
|
||||
class ScheduledInterview(Base):
|
||||
"""Stores individual scheduled interviews"""
|
||||
@ -1020,20 +1197,22 @@ class ScheduledInterview(Base):
|
||||
Candidate,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="scheduled_interviews",
|
||||
db_index=True
|
||||
)
|
||||
job = models.ForeignKey(
|
||||
"JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews"
|
||||
"JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True
|
||||
)
|
||||
zoom_meeting = models.OneToOneField(
|
||||
ZoomMeeting, on_delete=models.CASCADE, related_name="interview"
|
||||
ZoomMeeting, on_delete=models.CASCADE, related_name="interview", db_index=True
|
||||
)
|
||||
schedule = models.ForeignKey(
|
||||
InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True
|
||||
InterviewSchedule, on_delete=models.CASCADE, related_name="interviews",null=True,blank=True, db_index=True
|
||||
)
|
||||
interview_date = models.DateField(verbose_name=_("Interview Date"))
|
||||
|
||||
interview_date = models.DateField(db_index=True, verbose_name=_("Interview Date")) # Added index
|
||||
interview_time = models.TimeField(verbose_name=_("Interview Time"))
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
db_index=True, max_length=20, # Added index
|
||||
choices=[
|
||||
("scheduled", _("Scheduled")),
|
||||
("confirmed", _("Confirmed")),
|
||||
@ -1047,3 +1226,10 @@ class ScheduledInterview(Base):
|
||||
|
||||
def __str__(self):
|
||||
return f"Interview with {self.candidate.name} for {self.job.title}"
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['job', 'status']),
|
||||
models.Index(fields=['interview_date', 'interview_time']),
|
||||
models.Index(fields=['candidate', 'job']),
|
||||
]
|
||||
|
||||
@ -3,12 +3,24 @@ import json
|
||||
import logging
|
||||
import requests
|
||||
from PyPDF2 import PdfReader
|
||||
from datetime import datetime
|
||||
from django.db import transaction
|
||||
from .utils import create_zoom_meeting
|
||||
from recruitment.models import Candidate
|
||||
from . linkedin_service import LinkedInService
|
||||
from django.shortcuts import get_object_or_404
|
||||
from . models import JobPosting
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import ScheduledInterview, ZoomMeeting, Candidate, JobPosting, InterviewSchedule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPENROUTER_API_KEY ='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'
|
||||
OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free'
|
||||
OPENROUTER_API_KEY ='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'
|
||||
# OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free'
|
||||
# OPENROUTER_MODEL = 'openai/gpt-oss-20b:free'
|
||||
OPENROUTER_MODEL = 'openai/gpt-oss-20b'
|
||||
# OPENROUTER_MODEL = 'mistralai/mistral-small-3.2-24b-instruct:free'
|
||||
|
||||
if not OPENROUTER_API_KEY:
|
||||
logger.warning("OPENROUTER_API_KEY not set. Resume scoring will be skipped.")
|
||||
@ -46,110 +58,479 @@ def ai_handler(prompt):
|
||||
res = response.json()
|
||||
content = res["choices"][0]['message']['content']
|
||||
try:
|
||||
|
||||
# print(content)
|
||||
content = content.replace("```json","").replace("```","")
|
||||
|
||||
res = json.loads(content)
|
||||
|
||||
print("success response")
|
||||
return {"status": "success", "data": res}
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
# res = raw_output["choices"][0]["message"]["content"]
|
||||
return {"status": "error", "data": str(e)}
|
||||
else:
|
||||
print("error response")
|
||||
return res
|
||||
return {"status": "error", "data": response.json()}
|
||||
|
||||
|
||||
# def handle_reume_parsing_and_scoring(pk):
|
||||
# from django.db import transaction
|
||||
|
||||
# logger.info(f"Scoring resume for candidate {pk}")
|
||||
# instance = Candidate.objects.get(pk=pk)
|
||||
# try:
|
||||
# file_path = instance.resume.path
|
||||
# with transaction.atomic():
|
||||
# if not os.path.exists(file_path):
|
||||
# logger.warning(f"Resume file not found: {file_path}")
|
||||
# return
|
||||
|
||||
# resume_text = extract_text_from_pdf(file_path)
|
||||
# job_detail= f"{instance.job.description} {instance.job.qualifications}"
|
||||
# resume_parser_prompt = f"""
|
||||
# You are an expert resume parser and summarizer. Given a resume in plain text format, extract and organize the following key-value information into a clean, valid JSON object:
|
||||
|
||||
# full_name: Full name of the candidate
|
||||
# current_title: Most recent or current job title
|
||||
# location: City and state (or country if outside the U.S.)
|
||||
# contact: Phone number and email (as a single string or separate fields)
|
||||
# linkedin: LinkedIn profile URL (if present)
|
||||
# github: GitHub or portfolio URL (if present)
|
||||
# summary: Brief professional profile or summary (1–2 sentences)
|
||||
# education: List of degrees, each with:
|
||||
# institution
|
||||
# degree
|
||||
# year
|
||||
# gpa (if provided)
|
||||
# relevant_courses (as a list, if mentioned)
|
||||
# skills: Grouped by category if possible (e.g., programming, big data, visualization), otherwise as a flat list of technologies/tools
|
||||
# experience: List of roles, each with:
|
||||
# company
|
||||
# job_title
|
||||
# location
|
||||
# start_date and end_date (or "Present" if applicable)
|
||||
# key_achievements (as a list of concise bullet points)
|
||||
# projects: List of notable projects (if clearly labeled), each with:
|
||||
# name
|
||||
# year
|
||||
# technologies_used
|
||||
# brief_description
|
||||
# Instructions:
|
||||
|
||||
# Be concise but preserve key details.
|
||||
# Normalize formatting (e.g., “Jun. 2014” → “2014-06”).
|
||||
# Omit redundant or promotional language.
|
||||
# If a section is missing, omit the key or set it to null/empty list as appropriate.
|
||||
# Output only valid JSON—no markdown, no extra text.
|
||||
# Now, process the following resume text:
|
||||
# {resume_text}
|
||||
# """
|
||||
# resume_parser_result = ai_handler(resume_parser_prompt)
|
||||
# resume_scoring_prompt = f"""
|
||||
# You are an expert technical recruiter. Your task is to score the following candidate for the role based on the provided job criteria.
|
||||
|
||||
# **Job Criteria:**
|
||||
# {job_detail}
|
||||
|
||||
# **Candidate's Extracted Resume Json:**
|
||||
# \"\"\"
|
||||
# {resume_parser_result}
|
||||
# \"\"\"
|
||||
|
||||
# **Your Task:**
|
||||
# Provide a response in strict JSON format with the following keys:
|
||||
# 1. 'match_score': A score from 0 to 100 representing how well the candidate fits the role.
|
||||
# 2. 'strengths': A brief summary of why the candidate is a strong fit, referencing specific criteria.
|
||||
# 3. 'weaknesses': A brief summary of where the candidate falls short or what criteria are missing.
|
||||
# 4. 'years_of_experience': The total number of years of professional experience mentioned in the resume as a numerical value (e.g., 6.5).
|
||||
# 5. 'criteria_checklist': An object where you rate the candidate's match for each specific criterion (e.g., {{'Python': 'Met', 'AWS': 'Not Mentioned'}}).
|
||||
# 6. 'criteria_checklist': An object where you rate the candidate's match for each specific criterion (e.g., {{'Python': 'Met', 'AWS': 'Not Mentioned'}}).
|
||||
# 7. 'category': Based on the content provided, determine the most fitting professional field or category for the individual. (e.g., {{"category" : "Data Science"}}) only output the category name and no other text example ('Software Development', 'correct') , ('Software Development and devops','wrong').
|
||||
# 8. 'most_recent_job_title': The candidate's most recent or current professional job title.
|
||||
# 9. 'recommendation': Provide a recommendation for the candidate (e.g., {{"recommendation": "
|
||||
# Conclusion and Minor Considerations
|
||||
# Overall Assessment: Highly Recommended Candidate.
|
||||
|
||||
# [Candidate] is an exceptionally strong candidate for this role. His proven track record with the core technology stack (Django, Python, Docker, CI/CD) and relevant experience in large-scale, high-impact enterprise projects (Telecom BPM/MDM) make him an excellent technical fit. His fluency in Arabic and English directly addresses a major non-negotiable requirement.
|
||||
|
||||
# The only minor area not explicitly mentioned is the mentoring aspect, but his senior level of experience and technical breadth strongly suggest he possesses the capability to mentor junior engineers.
|
||||
|
||||
# The hiring manager should move forward with this candidate with high confidence.
|
||||
# ."}}).
|
||||
# 10. 'top_3_keywords': A list of the three most dominant and relevant technical skills or technologies from the resume that match the job criteria.
|
||||
# 11. 'job_fit_narrative': A single, concise sentence summarizing the core fit.
|
||||
# 12. 'language_fluency': A list of languages and their fluency levels mentioned.
|
||||
# 13. 'screening_stage_rating': A standardized rating (e.g., "A - Highly Qualified", "B - Qualified").
|
||||
# 14. 'min_req_met_bool': Boolean (true/false) indicating if all non-negotiable minimum requirements are met.
|
||||
# 15. 'soft_skills_score': A score (0-100) for inferred non-technical skills like leadership and communication.
|
||||
# 16. 'experience_industry_match': A score (0-100) for the relevance of the candidate's industry experience.
|
||||
|
||||
# Only output valid JSON. Do not include any other text.
|
||||
# """
|
||||
|
||||
# resume_scoring_result = ai_handler(resume_scoring_prompt)
|
||||
|
||||
# print(resume_scoring_result)
|
||||
|
||||
# instance.parsed_summary = str(resume_parser_result)
|
||||
|
||||
|
||||
# # Core Scores
|
||||
# instance.set_field('match_score', resume_scoring_result.get('match_score', 0)) # Set default for int
|
||||
# instance.set_field('years_of_experience', resume_scoring_result.get('years_of_experience', 0.0)) # Set default for float
|
||||
# instance.set_field('soft_skills_score', resume_scoring_result.get('soft_skills_score', 0))
|
||||
# instance.set_field('experience_industry_match', resume_scoring_result.get('experience_industry_match', 0))
|
||||
|
||||
# # Screening & Funnel
|
||||
# instance.set_field('min_req_met_bool', resume_scoring_result.get('min_req_met_bool', False)) # Set default for bool
|
||||
# instance.set_field('screening_stage_rating', resume_scoring_result.get('screening_stage_rating', 'N/A'))
|
||||
# instance.set_field('most_recent_job_title', resume_scoring_result.get('most_recent_job_title', 'N/A'))
|
||||
# instance.set_field('top_3_keywords', resume_scoring_result.get('top_3_keywords', [])) # Set default for list
|
||||
|
||||
# # Summaries & Narrative
|
||||
# instance.set_field('strengths', resume_scoring_result.get('strengths', ''))
|
||||
# instance.set_field('weaknesses', resume_scoring_result.get('weaknesses', ''))
|
||||
# instance.set_field('job_fit_narrative', resume_scoring_result.get('job_fit_narrative', ''))
|
||||
# instance.set_field('recommendation', resume_scoring_result.get('recommendation', ''))
|
||||
|
||||
# # Structured Data
|
||||
# instance.set_field('criteria_checklist', resume_scoring_result.get('criteria_checklist', {})) # Set default for dict
|
||||
# instance.set_field('language_fluency', resume_scoring_result.get('language_fluency', [])) # Set default for list
|
||||
|
||||
# instance.set_field('category', resume_scoring_result.get('category', 'Uncategorized')) # Use 'category' key
|
||||
|
||||
# instance.is_resume_parsed = True
|
||||
|
||||
# instance.save(update_fields=['ai_analysis_data', 'is_resume_parsed','parsed_summary'])
|
||||
|
||||
# logger.info(f"Successfully scored resume for candidate {instance.id}")
|
||||
|
||||
# except Exception as e:
|
||||
# instance.is_resume_parsed = False
|
||||
# instance.save(update_fields=['is_resume_parsed'])
|
||||
# logger.error(f"Failed to score resume for candidate:{instance.pk} {e}")
|
||||
|
||||
def handle_reume_parsing_and_scoring(pk):
|
||||
logger.info(f"Scoring resume for candidate {pk}")
|
||||
"""
|
||||
Optimized Django-Q task to parse a resume, score the candidate against a job,
|
||||
and atomically save the results.
|
||||
"""
|
||||
|
||||
# --- 1. Robust Object Retrieval (Prevents looping on DoesNotExist) ---
|
||||
try:
|
||||
instance = Candidate.objects.get(pk=pk)
|
||||
except Candidate.DoesNotExist:
|
||||
# Exit gracefully if the candidate was deleted after the task was queued
|
||||
logger.warning(f"Candidate matching query does not exist for pk={pk}. Exiting task.")
|
||||
print(f"Candidate matching query does not exist for pk={pk}. Exiting task.")
|
||||
return
|
||||
|
||||
logger.info(f"Scoring resume for candidate {pk}")
|
||||
print(f"Scoring resume for candidate {pk}")
|
||||
|
||||
# --- 2. I/O and Initial Data Check ---
|
||||
try:
|
||||
file_path = instance.resume.path
|
||||
if not os.path.exists(file_path):
|
||||
logger.warning(f"Resume file not found: {file_path}")
|
||||
print(f"Resume file not found: {file_path}")
|
||||
# Consider marking the task as unsuccessful but don't re-queue
|
||||
return
|
||||
|
||||
resume_text = extract_text_from_pdf(file_path)
|
||||
job_detail= f"{instance.job.description} {instance.job.qualifications}"
|
||||
resume_parser_prompt = f"""
|
||||
You are an expert resume parser and summarizer. Given a resume in plain text format, extract and organize the following key-value information into a clean, valid JSON object:
|
||||
|
||||
full_name: Full name of the candidate
|
||||
current_title: Most recent or current job title
|
||||
location: City and state (or country if outside the U.S.)
|
||||
contact: Phone number and email (as a single string or separate fields)
|
||||
linkedin: LinkedIn profile URL (if present)
|
||||
github: GitHub or portfolio URL (if present)
|
||||
summary: Brief professional profile or summary (1–2 sentences)
|
||||
education: List of degrees, each with:
|
||||
institution
|
||||
degree
|
||||
year
|
||||
gpa (if provided)
|
||||
relevant_courses (as a list, if mentioned)
|
||||
skills: Grouped by category if possible (e.g., programming, big data, visualization), otherwise as a flat list of technologies/tools
|
||||
experience: List of roles, each with:
|
||||
company
|
||||
job_title
|
||||
location
|
||||
start_date and end_date (or "Present" if applicable)
|
||||
key_achievements (as a list of concise bullet points)
|
||||
projects: List of notable projects (if clearly labeled), each with:
|
||||
name
|
||||
year
|
||||
technologies_used
|
||||
brief_description
|
||||
Instructions:
|
||||
|
||||
Be concise but preserve key details.
|
||||
Normalize formatting (e.g., “Jun. 2014” → “2014-06”).
|
||||
Omit redundant or promotional language.
|
||||
If a section is missing, omit the key or set it to null/empty list as appropriate.
|
||||
Output only valid JSON—no markdown, no extra text.
|
||||
Now, process the following resume text:
|
||||
{resume_text}
|
||||
"""
|
||||
resume_parser_result = ai_handler(resume_parser_prompt)
|
||||
resume_scoring_prompt = f"""
|
||||
You are an expert technical recruiter. Your task is to score the following candidate for the role of a Senior Data Analyst based on the provided job criteria.
|
||||
|
||||
**Job Criteria:**
|
||||
{job_detail}
|
||||
|
||||
**Candidate's Extracted Resume Json:**
|
||||
\"\"\"
|
||||
{resume_parser_result}
|
||||
\"\"\"
|
||||
|
||||
**Your Task:**
|
||||
Provide a response in strict JSON format with the following keys:
|
||||
1. 'match_score': A score from 0 to 100 representing how well the candidate fits the role.
|
||||
2. 'strengths': A brief summary of why the candidate is a strong fit, referencing specific criteria.
|
||||
3. 'weaknesses': A brief summary of where the candidate falls short or what criteria are missing.
|
||||
4. 'criteria_checklist': An object where you rate the candidate's match for each specific criterion (e.g., {{'Python': 'Met', 'AWS': 'Not Mentioned'}}).
|
||||
|
||||
|
||||
Only output valid JSON. Do not include any other text.
|
||||
"""
|
||||
|
||||
resume_scoring_result = ai_handler(resume_scoring_prompt)
|
||||
|
||||
instance.parsed_summary = str(resume_parser_result)
|
||||
|
||||
# Update candidate with scoring results
|
||||
instance.match_score = resume_scoring_result.get('match_score')
|
||||
instance.strengths = resume_scoring_result.get('strengths', '')
|
||||
instance.weaknesses = resume_scoring_result.get('weaknesses', '')
|
||||
instance.criteria_checklist = resume_scoring_result.get('criteria_checklist', {})
|
||||
|
||||
instance.is_resume_parsed = True
|
||||
|
||||
# Save only scoring-related fields to avoid recursion
|
||||
instance.save(update_fields=[
|
||||
'match_score', 'strengths', 'weaknesses',
|
||||
'criteria_checklist','parsed_summary', 'is_resume_parsed'
|
||||
])
|
||||
|
||||
logger.info(f"Successfully scored resume for candidate {instance.id}")
|
||||
job_detail = f"{instance.job.description} {instance.job.qualifications}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to score resume for candidate {instance.id}: {e}")
|
||||
logger.error(f"Error during initial data retrieval/parsing for candidate {instance.pk}: {e}")
|
||||
print(f"Error during initial data retrieval/parsing for candidate {instance.pk}: {e}")
|
||||
return
|
||||
|
||||
# --- 3. Single, Combined LLM Prompt (Major Cost & Latency Optimization) ---
|
||||
prompt = f"""
|
||||
You are an expert AI system functioning as both a Resume Parser and a Technical Recruiter.
|
||||
|
||||
Your task is to:
|
||||
1. **PARSE**: Extract all key-value information from the provided RESUME TEXT into a clean JSON structure under the key 'parsed_data'.
|
||||
2. **SCORE**: Analyze the parsed data against the JOB CRITERIA and generate a comprehensive score and analysis under the key 'scoring_data'.
|
||||
|
||||
**JOB CRITERIA:**
|
||||
{job_detail}
|
||||
|
||||
**RESUME TEXT:**
|
||||
{resume_text}
|
||||
|
||||
**STRICT JSON OUTPUT INSTRUCTIONS:**
|
||||
Output a single, valid JSON object with ONLY the following two top-level keys:
|
||||
|
||||
1. "parsed_data": {{
|
||||
"full_name": "Full name of the candidate",
|
||||
"current_title": "Most recent or current job title",
|
||||
"location": "City and state",
|
||||
"contact": "Phone number and email",
|
||||
"linkedin": "LinkedIn profile URL",
|
||||
"github": "GitHub or portfolio URL",
|
||||
"summary": "Brief professional profile or summary (1–2 sentences)",
|
||||
"education": [{{
|
||||
"institution": "Institution name",
|
||||
"degree": "Degree name",
|
||||
"year": "Year of graduation",
|
||||
"gpa": "GPA (if provided)",
|
||||
"relevant_courses": ["list", "of", "courses"]
|
||||
}}],
|
||||
"skills": {{
|
||||
"category_1": ["skill_a", "skill_b"],
|
||||
"uncategorized": ["tool_x"]
|
||||
}},
|
||||
"experience": [{{
|
||||
"company": "Company name",
|
||||
"job_title": "Job Title",
|
||||
"location": "Location",
|
||||
"start_date": "YYYY-MM",
|
||||
"end_date": "YYYY-MM or Present",
|
||||
"key_achievements": ["concise", "bullet", "points"]
|
||||
}}],
|
||||
"projects": [{{
|
||||
"name": "Project name",
|
||||
"year": "Year",
|
||||
"technologies_used": ["list", "of", "tech"],
|
||||
"brief_description": "description"
|
||||
}}]
|
||||
}}
|
||||
|
||||
2. "scoring_data": {{
|
||||
"match_score": "Score 0-100",
|
||||
"strengths": "Brief summary of strengths",
|
||||
"weaknesses": "Brief summary of weaknesses",
|
||||
"years_of_experience": "Total years of experience (float, e.g., 6.5)",
|
||||
"criteria_checklist": {{ "Python": "Met", "AWS": "Not Mentioned"}},
|
||||
"category": "Most fitting professional field (e.g., Data Science)",
|
||||
"most_recent_job_title": "Candidate's most recent job title",
|
||||
"recommendation": "Detailed hiring recommendation narrative",
|
||||
"top_3_keywords": ["keyword1", "keyword2", "keyword3"],
|
||||
"job_fit_narrative": "Single, concise summary sentence",
|
||||
"language_fluency": ["language: fluency_level"],
|
||||
"screening_stage_rating": "Standardized rating (e.g., A - Highly Qualified)",
|
||||
"min_req_met_bool": "Boolean (true/false)",
|
||||
"soft_skills_score": "Score 0-100 for inferred non-technical skills",
|
||||
"experience_industry_match": "Score 0-100 for industry relevance"
|
||||
}}
|
||||
|
||||
If a top-level key or its required fields are missing, set the field to null, an empty list, or an empty object as appropriate.
|
||||
|
||||
Output only valid JSON—no markdown, no extra text.
|
||||
"""
|
||||
|
||||
try:
|
||||
result = ai_handler(prompt)
|
||||
if result['status'] == 'error':
|
||||
logger.error(f"AI handler returned error for candidate {instance.pk}")
|
||||
print(f"AI handler returned error for candidate {instance.pk}")
|
||||
return
|
||||
# Ensure the result is parsed as a Python dict (if ai_handler returns a JSON string)
|
||||
data = result['data']
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
print(data)
|
||||
|
||||
parsed_summary = data.get('parsed_data', {})
|
||||
scoring_result = data.get('scoring_data', {})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AI handler failed for candidate {instance.pk}: {e}")
|
||||
print(f"AI handler failed for candidate {instance.pk}: {e}")
|
||||
return
|
||||
|
||||
# --- 4. Atomic Database Update (Ensures data integrity) ---
|
||||
with transaction.atomic():
|
||||
|
||||
# Map JSON keys to model fields with appropriate defaults
|
||||
update_map = {
|
||||
'match_score': ('match_score', 0),
|
||||
'years_of_experience': ('years_of_experience', 0.0),
|
||||
'soft_skills_score': ('soft_skills_score', 0),
|
||||
'experience_industry_match': ('experience_industry_match', 0),
|
||||
|
||||
'min_req_met_bool': ('min_req_met_bool', False),
|
||||
'screening_stage_rating': ('screening_stage_rating', 'N/A'),
|
||||
'most_recent_job_title': ('most_recent_job_title', 'N/A'),
|
||||
'top_3_keywords': ('top_3_keywords', []),
|
||||
|
||||
'strengths': ('strengths', ''),
|
||||
'weaknesses': ('weaknesses', ''),
|
||||
'job_fit_narrative': ('job_fit_narrative', ''),
|
||||
'recommendation': ('recommendation', ''),
|
||||
|
||||
'criteria_checklist': ('criteria_checklist', {}),
|
||||
'language_fluency': ('language_fluency', []),
|
||||
'category': ('category', 'N/A'),
|
||||
}
|
||||
|
||||
# Apply scoring results to the instance
|
||||
for model_field, (json_key, default_value) in update_map.items():
|
||||
instance.ai_analysis_data[model_field] = scoring_result.get(json_key, default_value)
|
||||
# instance.set_field(model_field, scoring_result.get(json_key, default_value))
|
||||
|
||||
# Apply parsing results
|
||||
instance.parsed_summary = json.dumps(parsed_summary)
|
||||
instance.is_resume_parsed = True
|
||||
|
||||
instance.save(update_fields=['ai_analysis_data','parsed_summary', 'is_resume_parsed'])
|
||||
|
||||
logger.info(f"Successfully scored and saved analysis for candidate {instance.id}")
|
||||
print(f"Successfully scored and saved analysis for candidate {instance.id}")
|
||||
|
||||
def create_interview_and_meeting(
|
||||
candidate_id,
|
||||
job_id,
|
||||
schedule_id,
|
||||
slot_date,
|
||||
slot_time,
|
||||
duration
|
||||
):
|
||||
"""
|
||||
Synchronous task for a single interview slot, dispatched by django-q.
|
||||
"""
|
||||
try:
|
||||
candidate = Candidate.objects.get(pk=candidate_id)
|
||||
job = JobPosting.objects.get(pk=job_id)
|
||||
schedule = InterviewSchedule.objects.get(pk=schedule_id)
|
||||
|
||||
interview_datetime = datetime.combine(slot_date, slot_time)
|
||||
meeting_topic = f"Interview for {job.title} - {candidate.name}"
|
||||
|
||||
# 1. External API Call (Slow)
|
||||
result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
|
||||
|
||||
if result["status"] == "success":
|
||||
# 2. Database Writes (Slow)
|
||||
zoom_meeting = ZoomMeeting.objects.create(
|
||||
topic=meeting_topic,
|
||||
start_time=interview_datetime,
|
||||
duration=duration,
|
||||
meeting_id=result["meeting_details"]["meeting_id"],
|
||||
join_url=result["meeting_details"]["join_url"],
|
||||
zoom_gateway_response=result["zoom_gateway_response"],
|
||||
host_email=result["meeting_details"]["host_email"],
|
||||
password=result["meeting_details"]["password"]
|
||||
)
|
||||
ScheduledInterview.objects.create(
|
||||
candidate=candidate,
|
||||
job=job,
|
||||
zoom_meeting=zoom_meeting,
|
||||
schedule=schedule,
|
||||
interview_date=slot_date,
|
||||
interview_time=slot_time
|
||||
)
|
||||
# Log success or use Django-Q result system for monitoring
|
||||
logger.info(f"Successfully scheduled interview for {candidate.name}")
|
||||
return True # Task succeeded
|
||||
else:
|
||||
# Handle Zoom API failure (e.g., log it or notify administrator)
|
||||
logger.error(f"Zoom API failed for {candidate.name}: {result['message']}")
|
||||
return False # Task failed
|
||||
|
||||
except Exception as e:
|
||||
# Catch any unexpected errors during database lookups or processing
|
||||
logger.error(f"Critical error scheduling interview: {e}")
|
||||
return False # Task failed
|
||||
|
||||
|
||||
def handle_zoom_webhook_event(payload):
|
||||
"""
|
||||
Background task to process a Zoom webhook event and update the local ZoomMeeting status.
|
||||
It handles: created, updated, started, ended, and deleted events.
|
||||
"""
|
||||
event_type = payload.get('event')
|
||||
object_data = payload['payload']['object']
|
||||
|
||||
# Zoom often uses a long 'id' for the scheduled meeting and sometimes a 'uuid'.
|
||||
# We rely on the unique 'id' that maps to your ZoomMeeting.meeting_id field.
|
||||
meeting_id_zoom = str(object_data.get('id'))
|
||||
print(meeting_id_zoom)
|
||||
if not meeting_id_zoom:
|
||||
logger.warning(f"Webhook received without a valid Meeting ID: {event_type}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
|
||||
# and to simplify the logic flow.
|
||||
meeting_instance = ZoomMeeting.objects.filter(meeting_id=meeting_id_zoom).first()
|
||||
print(meeting_instance)
|
||||
# --- 1. Creation and Update Events ---
|
||||
if event_type == 'meeting.updated':
|
||||
if meeting_instance:
|
||||
# Update key fields from the webhook payload
|
||||
meeting_instance.topic = object_data.get('topic', meeting_instance.topic)
|
||||
|
||||
# Check for and update status and time details
|
||||
# if event_type == 'meeting.created':
|
||||
# meeting_instance.status = 'scheduled'
|
||||
# elif event_type == 'meeting.updated':
|
||||
# Only update time fields if they are in the payload
|
||||
print(object_data)
|
||||
meeting_instance.start_time = object_data.get('start_time', meeting_instance.start_time)
|
||||
meeting_instance.duration = object_data.get('duration', meeting_instance.duration)
|
||||
meeting_instance.timezone = object_data.get('timezone', meeting_instance.timezone)
|
||||
|
||||
meeting_instance.status = object_data.get('status', meeting_instance.status)
|
||||
|
||||
meeting_instance.save(update_fields=['topic', 'start_time', 'duration', 'timezone', 'status'])
|
||||
|
||||
# --- 2. Status Change Events (Start/End) ---
|
||||
elif event_type == 'meeting.started':
|
||||
if meeting_instance:
|
||||
meeting_instance.status = 'started'
|
||||
meeting_instance.save(update_fields=['status'])
|
||||
|
||||
elif event_type == 'meeting.ended':
|
||||
if meeting_instance:
|
||||
meeting_instance.status = 'ended'
|
||||
meeting_instance.save(update_fields=['status'])
|
||||
|
||||
# --- 3. Deletion Event (User Action) ---
|
||||
elif event_type == 'meeting.deleted':
|
||||
if meeting_instance:
|
||||
try:
|
||||
meeting_instance.status = 'cancelled'
|
||||
meeting_instance.save(update_fields=['status'])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to mark Zoom meeting as cancelled: {e}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process Zoom webhook for {event_type} (ID: {meeting_id_zoom}): {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def linkedin_post_task(job_slug, access_token):
|
||||
# for linked post background tasks
|
||||
|
||||
job=get_object_or_404(JobPosting,slug=job_slug)
|
||||
|
||||
try:
|
||||
service=LinkedInService()
|
||||
service.access_token=access_token
|
||||
# long running task
|
||||
result=service.create_job_post(job)
|
||||
|
||||
#update the jobposting object with the final result
|
||||
if result['success']:
|
||||
job.posted_to_linkedin=True
|
||||
job.linkedin_post_id=result['post_id']
|
||||
job.linkedin_post_url=result['post_url']
|
||||
job.linkedin_post_status='SUCCESSS'
|
||||
job.linkedin_posted_at=timezone.now()
|
||||
else:
|
||||
error_msg=result.get('error',"Unknown API error")
|
||||
job.linkedin_post_status = 'FAILED'
|
||||
logger.error(f"LinkedIn post failed for job {job_slug}: {error_msg}")
|
||||
job.save()
|
||||
return result['success']
|
||||
except Exception as e:
|
||||
logger.error(f"Critical error in LinkedIn task for job {job_slug}: {e}", exc_info=True)
|
||||
# Update job status with the critical error
|
||||
job.linkedin_post_status = f"CRITICAL_ERROR: {str(e)}"
|
||||
job.save()
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@ -72,3 +72,12 @@ def to_list(data):
|
||||
Usage: {% to_list "item1,item2,item3" as list %}
|
||||
"""
|
||||
return data.split(",") if data else []
|
||||
|
||||
@register.filter
|
||||
def get_schedule_candidate_ids(session, slug):
|
||||
"""
|
||||
Retrieves the list of candidate IDs stored in the session for a specific job slug.
|
||||
"""
|
||||
session_key = f"schedule_candidate_ids_{slug}"
|
||||
# Returns the list of IDs (or an empty list if not found)
|
||||
return session.get(session_key, [])
|
||||
@ -1,3 +1,626 @@
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from datetime import datetime, time, timedelta
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# Create your tests here.
|
||||
from .models import (
|
||||
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
||||
TrainingMaterial, Source, HiringAgency, Profile, MeetingComment
|
||||
)
|
||||
from .forms import (
|
||||
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
|
||||
CandidateStageForm, InterviewScheduleForm
|
||||
)
|
||||
from .views import (
|
||||
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, candidate_screening_view,
|
||||
candidate_exam_view, candidate_interview_view, submit_form, api_schedule_candidate_meeting
|
||||
)
|
||||
from .views_frontend import CandidateListView, JobListView
|
||||
from .utils import create_zoom_meeting, get_candidates_from_request
|
||||
|
||||
|
||||
class BaseTestCase(TestCase):
|
||||
"""Base test case setup with common test data"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='test@example.com',
|
||||
password='testpass123',
|
||||
is_staff=True
|
||||
)
|
||||
self.profile = Profile.objects.create(user=self.user)
|
||||
|
||||
# Create test data
|
||||
self.job = 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=self.user
|
||||
)
|
||||
|
||||
self.candidate = Candidate.objects.create(
|
||||
first_name='John',
|
||||
last_name='Doe',
|
||||
email='john@example.com',
|
||||
phone='1234567890',
|
||||
resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'),
|
||||
job=self.job,
|
||||
stage='Applied'
|
||||
)
|
||||
|
||||
self.zoom_meeting = 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'
|
||||
)
|
||||
|
||||
|
||||
class ModelTests(BaseTestCase):
|
||||
"""Test cases for models"""
|
||||
|
||||
def test_job_posting_creation(self):
|
||||
"""Test JobPosting model creation"""
|
||||
self.assertEqual(self.job.title, 'Software Engineer')
|
||||
self.assertEqual(self.job.department, 'IT')
|
||||
self.assertIsNotNone(self.job.slug)
|
||||
self.assertEqual(self.job.status, 'DRAFT')
|
||||
|
||||
def test_job_posting_unique_id_generation(self):
|
||||
"""Test unique internal job ID generation"""
|
||||
self.assertTrue(self.job.internal_job_id.startswith('KAAUH'))
|
||||
self.assertIn(str(timezone.now().year), self.job.internal_job_id)
|
||||
|
||||
def test_job_posting_methods(self):
|
||||
"""Test JobPosting model methods"""
|
||||
# Test is_expired method
|
||||
self.assertFalse(self.job.is_expired())
|
||||
|
||||
# Test location display
|
||||
self.assertIn('Saudi Arabia', self.job.get_location_display())
|
||||
|
||||
def test_candidate_creation(self):
|
||||
"""Test Candidate model creation"""
|
||||
self.assertEqual(self.candidate.first_name, 'John')
|
||||
self.assertEqual(self.candidate.stage, 'Applied')
|
||||
self.assertEqual(self.candidate.job, self.job)
|
||||
|
||||
def test_candidate_stage_transitions(self):
|
||||
"""Test candidate stage transition logic"""
|
||||
# Test current available stages
|
||||
available_stages = self.candidate.get_available_stages()
|
||||
self.assertIn('Exam', available_stages)
|
||||
self.assertIn('Interview', available_stages)
|
||||
|
||||
def test_zoom_meeting_creation(self):
|
||||
"""Test ZoomMeeting model creation"""
|
||||
self.assertEqual(self.zoom_meeting.topic, 'Interview with John Doe')
|
||||
self.assertEqual(self.zoom_meeting.duration, 60)
|
||||
self.assertIsNotNone(self.zoom_meeting.meeting_id)
|
||||
|
||||
def test_template_creation(self):
|
||||
"""Test FormTemplate model creation"""
|
||||
template = FormTemplate.objects.create(
|
||||
name='Test Template',
|
||||
job=self.job,
|
||||
created_by=self.user
|
||||
)
|
||||
self.assertEqual(template.name, 'Test Template')
|
||||
self.assertEqual(template.job, self.job)
|
||||
|
||||
def test_scheduled_interview_creation(self):
|
||||
"""Test ScheduledInterview model creation"""
|
||||
scheduled = ScheduledInterview.objects.create(
|
||||
candidate=self.candidate,
|
||||
job=self.job,
|
||||
zoom_meeting=self.zoom_meeting,
|
||||
interview_date=timezone.now().date(),
|
||||
interview_time=time(10, 0),
|
||||
status='scheduled'
|
||||
)
|
||||
self.assertEqual(scheduled.candidate, self.candidate)
|
||||
self.assertEqual(scheduled.status, 'scheduled')
|
||||
|
||||
|
||||
class ViewTests(BaseTestCase):
|
||||
"""Test cases for views"""
|
||||
|
||||
def test_job_list_view(self):
|
||||
"""Test JobListView"""
|
||||
response = self.client.get(reverse('job_list'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Software Engineer')
|
||||
|
||||
def test_job_list_search(self):
|
||||
"""Test JobListView search functionality"""
|
||||
response = self.client.get(reverse('job_list'), {'search': 'Software'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Software Engineer')
|
||||
|
||||
def test_job_detail_view(self):
|
||||
"""Test job_detail view"""
|
||||
response = self.client.get(reverse('job_detail', kwargs={'slug': self.job.slug}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Software Engineer')
|
||||
self.assertContains(response, 'John Doe')
|
||||
|
||||
def test_zoom_meeting_list_view(self):
|
||||
"""Test ZoomMeetingListView"""
|
||||
response = self.client.get(reverse('list_meetings'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Interview with John Doe')
|
||||
|
||||
def test_zoom_meeting_list_search(self):
|
||||
"""Test ZoomMeetingListView search functionality"""
|
||||
response = self.client.get(reverse('list_meetings'), {'q': 'Interview'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Interview with John Doe')
|
||||
|
||||
def test_zoom_meeting_list_filter_status(self):
|
||||
"""Test ZoomMeetingListView status filter"""
|
||||
response = self.client.get(reverse('list_meetings'), {'status': 'waiting'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_zoom_meeting_create_view(self):
|
||||
"""Test ZoomMeetingCreateView"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
response = self.client.get(reverse('create_meeting'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_candidate_screening_view(self):
|
||||
"""Test candidate_screening_view"""
|
||||
response = self.client.get(reverse('candidate_screening_view', kwargs={'slug': self.job.slug}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'John Doe')
|
||||
|
||||
def test_candidate_screening_view_filters(self):
|
||||
"""Test candidate_screening_view with filters"""
|
||||
response = self.client.get(
|
||||
reverse('candidate_screening_view', kwargs={'slug': self.job.slug}),
|
||||
{'min_ai_score': '50', 'tier1_count': '5'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_candidate_exam_view(self):
|
||||
"""Test candidate_exam_view"""
|
||||
response = self.client.get(reverse('candidate_exam_view', kwargs={'slug': self.job.slug}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'John Doe')
|
||||
|
||||
def test_candidate_interview_view(self):
|
||||
"""Test candidate_interview_view"""
|
||||
response = self.client.get(reverse('candidate_interview_view', kwargs={'slug': self.job.slug}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@patch('recruitment.views.create_zoom_meeting')
|
||||
def test_schedule_candidate_meeting(self, mock_create_zoom):
|
||||
"""Test api_schedule_candidate_meeting view"""
|
||||
mock_create_zoom.return_value = {
|
||||
'status': 'success',
|
||||
'meeting_details': {
|
||||
'meeting_id': '987654321',
|
||||
'join_url': 'https://zoom.us/j/987654321',
|
||||
'password': '123456'
|
||||
},
|
||||
'zoom_gateway_response': {'status': 'waiting'}
|
||||
}
|
||||
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
data = {
|
||||
'start_time': (timezone.now() + timedelta(hours=2)).isoformat(),
|
||||
'duration': 60
|
||||
}
|
||||
response = self.client.post(
|
||||
reverse('api_schedule_candidate_meeting',
|
||||
kwargs={'job_slug': self.job.slug, 'candidate_pk': self.candidate.pk}),
|
||||
data
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'success')
|
||||
|
||||
def test_submit_form(self):
|
||||
"""Test submit_form view"""
|
||||
# Create a form template first
|
||||
template = FormTemplate.objects.create(
|
||||
job=self.job,
|
||||
name='Test Template',
|
||||
created_by=self.user,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
data = {
|
||||
'field_1': 'John', # Assuming field ID 1 corresponds to First Name
|
||||
'field_2': 'Doe', # Assuming field ID 2 corresponds to Last Name
|
||||
'field_3': 'john@example.com', # Email
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
reverse('submit_form', kwargs={'template_id': template.id}),
|
||||
data
|
||||
)
|
||||
# After successful submission, should redirect to success page
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
|
||||
class FormTests(BaseTestCase):
|
||||
"""Test cases for forms"""
|
||||
|
||||
def test_job_posting_form(self):
|
||||
"""Test JobPostingForm"""
|
||||
form_data = {
|
||||
'title': 'New Job Title',
|
||||
'department': 'New Department',
|
||||
'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': '2',
|
||||
'hash_tags': '#hiring, #jobopening'
|
||||
}
|
||||
form = JobPostingForm(data=form_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_candidate_form(self):
|
||||
"""Test CandidateForm"""
|
||||
form_data = {
|
||||
'job': self.job.id,
|
||||
'first_name': 'Jane',
|
||||
'last_name': 'Smith',
|
||||
'phone': '9876543210',
|
||||
'email': 'jane@example.com',
|
||||
'resume': SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf')
|
||||
}
|
||||
form = CandidateForm(data=form_data, files=form_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_zoom_meeting_form(self):
|
||||
"""Test ZoomMeetingForm"""
|
||||
form_data = {
|
||||
'topic': 'Test Meeting',
|
||||
'start_time': (timezone.now() + timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M'),
|
||||
'duration': 60
|
||||
}
|
||||
form = ZoomMeetingForm(data=form_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_meeting_comment_form(self):
|
||||
"""Test MeetingCommentForm"""
|
||||
form_data = {
|
||||
'content': 'This is a test comment'
|
||||
}
|
||||
form = MeetingCommentForm(data=form_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_candidate_stage_form(self):
|
||||
"""Test CandidateStageForm with valid transition"""
|
||||
form_data = {
|
||||
'stage': 'Exam'
|
||||
}
|
||||
form = CandidateStageForm(data=form_data, candidate=self.candidate)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_interview_schedule_form(self):
|
||||
"""Test InterviewScheduleForm"""
|
||||
form_data = {
|
||||
'candidates': [self.candidate.id],
|
||||
'start_date': (timezone.now() + timedelta(days=1)).date(),
|
||||
'end_date': (timezone.now() + timedelta(days=7)).date(),
|
||||
'working_days': [0, 1, 2, 3, 4], # Monday to Friday
|
||||
'start_time': '09:00',
|
||||
'end_time': '17:00',
|
||||
'interview_duration': 60,
|
||||
'buffer_time': 15
|
||||
}
|
||||
form = InterviewScheduleForm(slug=self.job.slug, data=form_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
|
||||
class IntegrationTests(BaseTestCase):
|
||||
"""Integration tests for multiple components"""
|
||||
|
||||
def test_candidate_journey(self):
|
||||
"""Test the complete candidate journey from application to interview"""
|
||||
# 1. Create candidate
|
||||
candidate = Candidate.objects.create(
|
||||
first_name='Jane',
|
||||
last_name='Smith',
|
||||
email='jane@example.com',
|
||||
phone='9876543210',
|
||||
resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'),
|
||||
job=self.job,
|
||||
stage='Applied'
|
||||
)
|
||||
|
||||
# 2. Move to Exam stage
|
||||
candidate.stage = 'Exam'
|
||||
candidate.save()
|
||||
|
||||
# 3. Move to Interview stage
|
||||
candidate.stage = 'Interview'
|
||||
candidate.save()
|
||||
|
||||
# 4. Create interview schedule
|
||||
scheduled_interview = ScheduledInterview.objects.create(
|
||||
candidate=candidate,
|
||||
job=self.job,
|
||||
zoom_meeting=self.zoom_meeting,
|
||||
interview_date=timezone.now().date(),
|
||||
interview_time=time(10, 0),
|
||||
status='scheduled'
|
||||
)
|
||||
|
||||
# 5. Verify all stages and relationships
|
||||
self.assertEqual(Candidate.objects.count(), 2)
|
||||
self.assertEqual(ScheduledInterview.objects.count(), 1)
|
||||
self.assertEqual(candidate.stage, 'Interview')
|
||||
self.assertEqual(scheduled_interview.candidate, candidate)
|
||||
|
||||
def test_meeting_candidate_association(self):
|
||||
"""Test the association between meetings and candidates"""
|
||||
# Create a scheduled interview
|
||||
scheduled_interview = ScheduledInterview.objects.create(
|
||||
candidate=self.candidate,
|
||||
job=self.job,
|
||||
zoom_meeting=self.zoom_meeting,
|
||||
interview_date=timezone.now().date(),
|
||||
interview_time=time(10, 0),
|
||||
status='scheduled'
|
||||
)
|
||||
|
||||
# Verify the relationship
|
||||
self.assertEqual(self.zoom_meeting.interview, scheduled_interview)
|
||||
self.assertEqual(self.candidate.get_meetings().count(), 1)
|
||||
|
||||
def test_form_submission_candidate_creation(self):
|
||||
"""Test creating a candidate through form submission"""
|
||||
# Create a form template
|
||||
template = FormTemplate.objects.create(
|
||||
job=self.job,
|
||||
name='Application Form',
|
||||
created_by=self.user,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Create form stages and fields
|
||||
stage = FormStage.objects.create(
|
||||
template=template,
|
||||
name='Personal Information',
|
||||
order=0
|
||||
)
|
||||
|
||||
FormField.objects.create(
|
||||
stage=stage,
|
||||
label='First Name',
|
||||
field_type='text',
|
||||
order=0
|
||||
)
|
||||
FormField.objects.create(
|
||||
stage=stage,
|
||||
label='Last Name',
|
||||
field_type='text',
|
||||
order=1
|
||||
)
|
||||
FormField.objects.create(
|
||||
stage=stage,
|
||||
label='Email',
|
||||
field_type='email',
|
||||
order=2
|
||||
)
|
||||
|
||||
# Submit form data
|
||||
form_data = {
|
||||
'field_1': 'New',
|
||||
'field_2': 'Candidate',
|
||||
'field_3': 'new@example.com'
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
reverse('submit_form', kwargs={'template_id': template.id}),
|
||||
form_data
|
||||
)
|
||||
|
||||
# Verify candidate was created
|
||||
self.assertEqual(Candidate.objects.filter(email='new@example.com').count(), 1)
|
||||
|
||||
|
||||
class PerformanceTests(BaseTestCase):
|
||||
"""Basic performance tests"""
|
||||
|
||||
def test_large_dataset_pagination(self):
|
||||
"""Test pagination with large datasets"""
|
||||
# Create many candidates
|
||||
for i in range(100):
|
||||
Candidate.objects.create(
|
||||
first_name=f'Candidate{i}',
|
||||
last_name=f'Test{i}',
|
||||
email=f'candidate{i}@example.com',
|
||||
phone=f'123456789{i}',
|
||||
job=self.job,
|
||||
stage='Applied'
|
||||
)
|
||||
|
||||
# Test pagination
|
||||
response = self.client.get(reverse('candidate_list'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Candidate')
|
||||
|
||||
|
||||
class AuthenticationTests(BaseTestCase):
|
||||
"""Authentication and permission tests"""
|
||||
|
||||
def test_unauthorized_access(self):
|
||||
"""Test that unauthorized users cannot access protected views"""
|
||||
# Create a non-staff user
|
||||
regular_user = User.objects.create_user(
|
||||
username='regularuser',
|
||||
email='regular@example.com',
|
||||
password='testpass123'
|
||||
)
|
||||
|
||||
# Try to access a view that requires staff privileges
|
||||
self.client.login(username='regularuser', password='testpass123')
|
||||
response = self.client.get(reverse('job_list'))
|
||||
# Should still be accessible for now (can be adjusted based on actual requirements)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_login_required(self):
|
||||
"""Test that login is required for certain operations"""
|
||||
# Test form submission without login
|
||||
template = FormTemplate.objects.create(
|
||||
job=self.job,
|
||||
name='Test Template',
|
||||
created_by=self.user,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse('submit_form', kwargs={'template_id': template.id}),
|
||||
{}
|
||||
)
|
||||
# Should redirect to login page
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
|
||||
class EdgeCaseTests(BaseTestCase):
|
||||
"""Tests for edge cases and error handling"""
|
||||
|
||||
def test_invalid_job_id(self):
|
||||
"""Test handling of invalid job slug"""
|
||||
response = self.client.get(reverse('job_detail', kwargs={'slug': 'invalid-slug'}))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_meeting_past_time(self):
|
||||
"""Test handling of meeting with past time"""
|
||||
# This would be tested in the view that validates meeting time
|
||||
pass
|
||||
|
||||
def test_duplicate_submission(self):
|
||||
"""Test handling of duplicate form submissions"""
|
||||
# Create form template
|
||||
template = FormTemplate.objects.create(
|
||||
job=self.job,
|
||||
name='Test Template',
|
||||
created_by=self.user,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Submit form twice
|
||||
response1 = self.client.post(
|
||||
reverse('submit_form', kwargs={'template_id': template.id}),
|
||||
{'field_1': 'John', 'field_2': 'Doe'}
|
||||
)
|
||||
|
||||
# This should be handled by the view logic
|
||||
# Currently, it would create a duplicate candidate
|
||||
# We can add validation to prevent this if needed
|
||||
|
||||
def test_invalid_stage_transition(self):
|
||||
"""Test invalid candidate stage transitions"""
|
||||
# Try to transition from Interview back to Applied (should be invalid)
|
||||
self.candidate.stage = 'Interview'
|
||||
self.candidate.save()
|
||||
|
||||
# The model should prevent this through validation
|
||||
# This would be tested in the model's clean method or view logic
|
||||
pass
|
||||
|
||||
|
||||
class UtilityFunctionTests(BaseTestCase):
|
||||
"""Tests for utility functions"""
|
||||
|
||||
@patch('recruitment.views.create_zoom_meeting')
|
||||
def test_create_zoom_meeting_utility(self, mock_create):
|
||||
"""Test the create_zoom_meeting utility function"""
|
||||
mock_create.return_value = {
|
||||
'status': 'success',
|
||||
'meeting_details': {
|
||||
'meeting_id': '123456789',
|
||||
'join_url': 'https://zoom.us/j/123456789'
|
||||
}
|
||||
}
|
||||
|
||||
result = create_zoom_meeting(
|
||||
topic='Test Meeting',
|
||||
start_time=timezone.now() + timedelta(hours=1),
|
||||
duration=60
|
||||
)
|
||||
|
||||
self.assertEqual(result['status'], 'success')
|
||||
self.assertIn('meeting_id', result['meeting_details'])
|
||||
|
||||
def test_get_candidates_from_request(self):
|
||||
"""Test the get_candidates_from_request utility function"""
|
||||
# This would be tested with a request that has candidate_ids
|
||||
pass
|
||||
|
||||
|
||||
# Factory classes for test data (can be expanded with factory_boy)
|
||||
class TestFactories:
|
||||
"""Factory methods for creating test data"""
|
||||
|
||||
@staticmethod
|
||||
def create_job_posting(**kwargs):
|
||||
defaults = {
|
||||
'title': 'Test Job',
|
||||
'department': 'Test Department',
|
||||
'job_type': 'FULL_TIME',
|
||||
'workplace_type': 'ON_SITE',
|
||||
'location_country': 'Saudi Arabia',
|
||||
'description': 'Test description',
|
||||
'created_by': User.objects.create_user('factoryuser', 'factory@example.com', 'password')
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return JobPosting.objects.create(**defaults)
|
||||
|
||||
@staticmethod
|
||||
def create_candidate(**kwargs):
|
||||
job = TestFactories.create_job_posting()
|
||||
defaults = {
|
||||
'first_name': 'Test',
|
||||
'last_name': 'Candidate',
|
||||
'email': 'test@example.com',
|
||||
'phone': '1234567890',
|
||||
'job': job,
|
||||
'stage': 'Applied'
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return Candidate.objects.create(**defaults)
|
||||
|
||||
@staticmethod
|
||||
def create_zoom_meeting(**kwargs):
|
||||
defaults = {
|
||||
'topic': 'Test Meeting',
|
||||
'start_time': timezone.now() + timedelta(hours=1),
|
||||
'duration': 60,
|
||||
'timezone': 'UTC',
|
||||
'join_url': 'https://zoom.us/j/test123',
|
||||
'meeting_id': 'test123'
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return ZoomMeeting.objects.create(**defaults)
|
||||
|
||||
|
||||
# Test runner configuration (can be added to settings)
|
||||
"""
|
||||
TEST_RUNNER = 'django.test.runner.DiscoverRunner'
|
||||
TEST_DISCOVER_TOPS = ['recruitment']
|
||||
"""
|
||||
|
||||
1078
recruitment/tests_advanced.py
Normal file
1078
recruitment/tests_advanced.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,7 @@ from . import views
|
||||
from . import views_integration
|
||||
|
||||
urlpatterns = [
|
||||
path('dashboard/', views_frontend.dashboard_view, name='dashboard'),
|
||||
path('', views_frontend.dashboard_view, name='dashboard'),
|
||||
|
||||
# Job URLs (using JobPosting model)
|
||||
path('jobs/', views_frontend.JobListView.as_view(), name='job_list'),
|
||||
@ -23,6 +23,7 @@ urlpatterns = [
|
||||
path('jobs/linkedin/callback/', views.linkedin_callback, name='linkedin_callback'),
|
||||
|
||||
path('jobs/<slug:slug>/schedule-interviews/', views.schedule_interviews_view, name='schedule_interviews'),
|
||||
path('jobs/<slug:slug>/confirm-schedule-interviews/', views.confirm_schedule_interviews_view, name='confirm_schedule_interviews_view'),
|
||||
# Candidate URLs
|
||||
path('candidates/', views_frontend.CandidateListView.as_view(), name='candidate_list'),
|
||||
path('candidates/create/', views_frontend.CandidateCreateView.as_view(), name='candidate_create'),
|
||||
@ -67,6 +68,8 @@ urlpatterns = [
|
||||
path('jobs/<slug:slug>/candidate_screening_view/', views.candidate_screening_view, name='candidate_screening_view'),
|
||||
path('jobs/<slug:slug>/candidate_exam_view/', views.candidate_exam_view, name='candidate_exam_view'),
|
||||
path('jobs/<slug:slug>/candidate_interview_view/', views.candidate_interview_view, name='candidate_interview_view'),
|
||||
path('jobs/<slug:slug>/candidate_offer_view/', views_frontend.candidate_offer_view, name='candidate_offer_view'),
|
||||
path('jobs/<slug:job_slug>/candidates/<slug:candidate_slug>/update_status/<str:stage_type>/<str:status>/', views_frontend.update_candidate_status, name='update_candidate_status'),
|
||||
|
||||
path('jobs/<slug:slug>/<int:candidate_id>/reschedule_meeting_for_candidate/<int:meeting_id>/', views.reschedule_meeting_for_candidate, name='reschedule_meeting_for_candidate'),
|
||||
|
||||
@ -106,5 +109,17 @@ urlpatterns = [
|
||||
path('jobs/<slug:slug>/candidates/<int:candidate_pk>/delete_meeting_for_candidate/<int:meeting_id>/', views.delete_meeting_for_candidate, name='delete_meeting_for_candidate'),
|
||||
|
||||
# users urls
|
||||
path('user/<int:pk>',views.user_detail,name='user_detail')
|
||||
path('user/<int:pk>',views.user_detail,name='user_detail'),
|
||||
path('user/user_profile_image_update/<int:pk>',views.user_profile_image_update,name='user_profile_image_update'),
|
||||
path('easy_logs/',views.easy_logs,name='easy_logs'),
|
||||
path('settings/',views.admin_settings,name='admin_settings'),
|
||||
path('staff/create',views.create_staff_user,name='create_staff_user'),
|
||||
path('set_staff_password/<int:pk>/',views.set_staff_password,name='set_staff_password'),
|
||||
|
||||
# Meeting Comments URLs
|
||||
path('meetings/<slug:slug>/comments/add/', views.add_meeting_comment, name='add_meeting_comment'),
|
||||
path('meetings/<slug:slug>/comments/<int:comment_id>/edit/', views.edit_meeting_comment, name='edit_meeting_comment'),
|
||||
path('meetings/<slug:slug>/comments/<int:comment_id>/delete/', views.delete_meeting_comment, name='delete_meeting_comment'),
|
||||
|
||||
path('meetings/<slug:slug>/set_meeting_candidate/', views.set_meeting_candidate, name='set_meeting_candidate'),
|
||||
]
|
||||
|
||||
@ -416,13 +416,12 @@ def schedule_interviews(schedule):
|
||||
Returns the number of interviews successfully scheduled.
|
||||
"""
|
||||
candidates = list(schedule.candidates.all())
|
||||
print(candidates)
|
||||
if not candidates:
|
||||
return 0
|
||||
|
||||
# Calculate available time slots
|
||||
available_slots = get_available_time_slots(schedule)
|
||||
print(available_slots)
|
||||
|
||||
if len(available_slots) < len(candidates):
|
||||
raise ValueError(f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}")
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
def validate_image_size(image):
|
||||
max_size_mb = 2
|
||||
max_size_mb = 1
|
||||
if image.size > max_size_mb * 1024 * 1024:
|
||||
raise ValidationError(f"Image size should not exceed {max_size_mb}MB.")
|
||||
|
||||
|
||||
1382
recruitment/views.py
1382
recruitment/views.py
File diff suppressed because it is too large
Load Diff
@ -16,7 +16,8 @@ from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView
|
||||
# JobForm removed - using JobPostingForm instead
|
||||
from django.urls import reverse_lazy
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, Count, Avg
|
||||
from django.db.models import FloatField
|
||||
|
||||
from datastar_py.django import (
|
||||
DatastarResponse,
|
||||
@ -134,13 +135,15 @@ class CandidateListView(LoginRequiredMixin, ListView):
|
||||
model = models.Candidate
|
||||
template_name = 'recruitment/candidate_list.html'
|
||||
context_object_name = 'candidates'
|
||||
paginate_by = 10
|
||||
paginate_by = 100
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get('search', '')
|
||||
job = self.request.GET.get('job', '')
|
||||
stage = self.request.GET.get('stage', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(first_name__icontains=search_query) |
|
||||
@ -150,7 +153,10 @@ class CandidateListView(LoginRequiredMixin, ListView):
|
||||
Q(stage__icontains=search_query) |
|
||||
Q(job__title__icontains=search_query)
|
||||
)
|
||||
|
||||
if job:
|
||||
queryset = queryset.filter(job__slug=job)
|
||||
if stage:
|
||||
queryset = queryset.filter(stage=stage)
|
||||
# Filter for non-staff users
|
||||
if not self.request.user.is_staff:
|
||||
return models.Candidate.objects.none() # Restrict for non-staff
|
||||
@ -160,6 +166,9 @@ class CandidateListView(LoginRequiredMixin, ListView):
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
context['job_filter'] = self.request.GET.get('job', '')
|
||||
context['stage_filter'] = self.request.GET.get('stage', '')
|
||||
context['available_jobs'] = models.JobPosting.objects.all().order_by('created_at').distinct()
|
||||
return context
|
||||
|
||||
|
||||
@ -216,6 +225,7 @@ def training_list(request):
|
||||
return render(request, 'recruitment/training_list.html', {'materials': materials})
|
||||
|
||||
|
||||
@login_required
|
||||
def candidate_detail(request, slug):
|
||||
from rich.json import JSON
|
||||
candidate = get_object_or_404(models.Candidate, slug=slug)
|
||||
@ -227,7 +237,7 @@ def candidate_detail(request, slug):
|
||||
# Create stage update form for staff users
|
||||
stage_form = None
|
||||
if request.user.is_staff:
|
||||
stage_form = forms.CandidateStageForm(candidate=candidate)
|
||||
stage_form = forms.CandidateStageForm()
|
||||
|
||||
# parsed = JSON(json.dumps(parsed), indent=2, highlight=True, skip_keys=False, ensure_ascii=False, check_circular=True, allow_nan=True, default=None, sort_keys=False)
|
||||
# parsed = json_to_markdown_table([parsed])
|
||||
@ -237,10 +247,11 @@ def candidate_detail(request, slug):
|
||||
'stage_form': stage_form,
|
||||
})
|
||||
|
||||
@login_required
|
||||
def candidate_update_stage(request, slug):
|
||||
"""Handle HTMX stage update requests"""
|
||||
candidate = get_object_or_404(models.Candidate, slug=slug)
|
||||
form = forms.CandidateStageForm(request.POST, candidate=candidate)
|
||||
form = forms.CandidateStageForm(request.POST, instance=candidate)
|
||||
if form.is_valid():
|
||||
stage_value = form.cleaned_data['stage']
|
||||
candidate.stage = stage_value
|
||||
@ -310,6 +321,7 @@ class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
success_message = 'Training material deleted successfully.'
|
||||
|
||||
|
||||
@login_required
|
||||
def dashboard_view(request):
|
||||
total_jobs = models.JobPosting.objects.count()
|
||||
total_candidates = models.Candidate.objects.count()
|
||||
@ -327,3 +339,79 @@ def dashboard_view(request):
|
||||
'average_applications': average_applications,
|
||||
}
|
||||
return render(request, 'recruitment/dashboard.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def candidate_offer_view(request, slug):
|
||||
"""View for candidates in the Offer stage"""
|
||||
job = get_object_or_404(models.JobPosting, slug=slug)
|
||||
|
||||
# Filter candidates for this specific job and stage
|
||||
candidates = job.offer_candidates
|
||||
|
||||
# Handle search
|
||||
search_query = request.GET.get('search', '')
|
||||
if search_query:
|
||||
candidates = candidates.filter(
|
||||
Q(first_name__icontains=search_query) |
|
||||
Q(last_name__icontains=search_query) |
|
||||
Q(email__icontains=search_query) |
|
||||
Q(phone__icontains=search_query)
|
||||
)
|
||||
|
||||
candidates = candidates.order_by('-created_at')
|
||||
|
||||
context = {
|
||||
'job': job,
|
||||
'candidates': candidates,
|
||||
'search_query': search_query,
|
||||
'current_stage': 'Offer',
|
||||
}
|
||||
return render(request, 'recruitment/candidate_offer_view.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def update_candidate_status(request, job_slug, candidate_slug, stage_type, status):
|
||||
"""Handle exam/interview/offer status updates"""
|
||||
from django.utils import timezone
|
||||
|
||||
job = get_object_or_404(models.JobPosting, slug=job_slug)
|
||||
candidate = get_object_or_404(models.Candidate, slug=candidate_slug, job=job)
|
||||
print(stage_type,status)
|
||||
|
||||
if request.method == "POST":
|
||||
if stage_type == 'exam':
|
||||
candidate.exam_status = status
|
||||
candidate.exam_date = timezone.now()
|
||||
candidate.save(update_fields=['exam_status', 'exam_date'])
|
||||
elif stage_type == 'interview':
|
||||
candidate.interview_status = status
|
||||
candidate.interview_date = timezone.now()
|
||||
candidate.save(update_fields=['interview_status', 'interview_date'])
|
||||
elif stage_type == 'offer':
|
||||
candidate.offer_status = status
|
||||
candidate.offer_date = timezone.now()
|
||||
candidate.save(update_fields=['offer_status', 'offer_date'])
|
||||
messages.success(request, f"Candidate {status} successfully!")
|
||||
else:
|
||||
messages.error(request, "No changes made.")
|
||||
|
||||
if stage_type == 'exam':
|
||||
return redirect('candidate_exam_view', job.slug)
|
||||
elif stage_type == 'interview':
|
||||
return redirect('candidate_interview_view', job.slug)
|
||||
elif stage_type == 'offer':
|
||||
return redirect('candidate_offer_view', job.slug)
|
||||
|
||||
return redirect('candidate_detail', candidate.slug)
|
||||
else:
|
||||
if stage_type == 'exam':
|
||||
return render(request,"includes/candidate_update_exam_form.html",{'candidate':candidate,'job':job})
|
||||
elif stage_type == 'interview':
|
||||
return render(request,"includes/candidate_update_interview_form.html",{'candidate':candidate,'job':job})
|
||||
elif stage_type == 'offer':
|
||||
return render(request,"includes/candidate_update_offer_form.html",{'candidate':candidate,'job':job})
|
||||
|
||||
|
||||
# Removed incorrect JobDetailView class.
|
||||
# The job_detail view is handled by function-based view in recruitment.views
|
||||
|
||||
@ -142,4 +142,5 @@ PyMuPDF
|
||||
pytesseract
|
||||
Pillow
|
||||
python-dotenv
|
||||
django-countries
|
||||
django-countries
|
||||
django-q2
|
||||
6
run.py
6
run.py
@ -37,4 +37,8 @@ if __name__ == "__main__":
|
||||
duration = 60
|
||||
host_email = "your_zoom_email"
|
||||
response = create_zoom_meeting(topic, start_time, duration, host_email)
|
||||
print(response.json())
|
||||
print(response.json())
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,947 +0,0 @@
|
||||
|
||||
/* Custom CSS for NorahUniversity ATS */
|
||||
/* Keep only essential custom styles that Bootstrap doesn't handle */
|
||||
|
||||
/* Primary Brand Color */
|
||||
:root {
|
||||
--primary-color: #1b8354;
|
||||
--primary-hover: #155f3e;
|
||||
}
|
||||
|
||||
/* Header and Navigation */
|
||||
.header {
|
||||
background-color: white !important;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
|
||||
border-bottom: 1px solid #e0e0e0 !important;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: white !important;
|
||||
border-bottom: 1px solid #e0e0e0 !important;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
/* Buttons - Override Bootstrap primary color */
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color) !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-hover) !important;
|
||||
border-color: var(--primary-hover) !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
border-color: var(--primary-color) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--primary-color) !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
border: 1px solid #e0e0e0 !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
border-bottom: 1px solid #e0e0e0 !important;
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
/* Table Improvements */
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: rgba(27, 131, 84, 0.05) !important;
|
||||
}
|
||||
|
||||
/* Custom Badge Colors */
|
||||
.badge.bg-success {
|
||||
background-color: #28a745 !important;
|
||||
}
|
||||
|
||||
.badge.bg-warning {
|
||||
background-color: #ffc107 !important;
|
||||
}
|
||||
|
||||
/* Form Improvements */
|
||||
.form-control:focus {
|
||||
border-color: var(--primary-color) !important;
|
||||
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.25) !important;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.nav-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.text-primary-custom {
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.bg-primary-custom {
|
||||
background-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.border-primary-custom {
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Icon Styling */
|
||||
.heroicon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.size-6 {
|
||||
width: 1.5rem !important;
|
||||
height: 1.5rem !important;
|
||||
}
|
||||
|
||||
/* Responsive icon sizing */
|
||||
.icon-sm {
|
||||
width: 0.875rem !important;
|
||||
height: 0.875rem !important;
|
||||
margin-right: 0.375rem !important;
|
||||
}
|
||||
|
||||
.icon-md {
|
||||
width: 1.125rem !important;
|
||||
height: 1.125rem !important;
|
||||
margin-right: 0.625rem !important;
|
||||
}
|
||||
|
||||
.icon-lg {
|
||||
width: 1.5rem !important;
|
||||
height: 1.5rem !important;
|
||||
margin-right: 0.75rem !important;
|
||||
}
|
||||
|
||||
.icon-xl {
|
||||
width: 2rem !important;
|
||||
height: 2rem !important;
|
||||
margin-right: 1rem !important;
|
||||
}
|
||||
|
||||
/* Context-specific icon adjustments */
|
||||
.btn-sm .heroicon,
|
||||
.btn-sm .size-6,
|
||||
.btn-sm .icon-md {
|
||||
width: 0.875rem !important;
|
||||
height: 0.875rem !important;
|
||||
margin-right: 0.375rem !important;
|
||||
}
|
||||
|
||||
.nav-link .heroicon,
|
||||
.nav-link .size-6 {
|
||||
width: 1.25rem !important;
|
||||
height: 1.25rem !important;
|
||||
margin-right: 0.5rem !important;
|
||||
}
|
||||
|
||||
.card-header .heroicon,
|
||||
.card-header .size-6 {
|
||||
width: 1.375rem !important;
|
||||
height: 1.375rem !important;
|
||||
margin-right: 0.625rem !important;
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.navbar,
|
||||
.header,
|
||||
.btn,
|
||||
.pagination {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments for icons */
|
||||
@media (max-width: 768px) {
|
||||
.nav-link .heroicon,
|
||||
.nav-link .size-6 {
|
||||
width: 1rem !important;
|
||||
height: 1rem !important;
|
||||
margin-right: 0.375rem !important;
|
||||
}
|
||||
|
||||
.card-header .heroicon,
|
||||
.card-header .size-6 {
|
||||
width: 1.125rem !important;
|
||||
height: 1.125rem !important;
|
||||
margin-right: 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header and Search Enhancements */
|
||||
.card-header {
|
||||
background-color: white !important;
|
||||
border-bottom: 1px solid #e0e0e0 !important;
|
||||
padding: 1.25rem 1.5rem !important;
|
||||
}
|
||||
|
||||
.card-header h1,
|
||||
.card-header h2,
|
||||
.card-header h3 {
|
||||
margin-bottom: 0 !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.card-header h1.h3,
|
||||
.card-header h2.h3,
|
||||
.card-header h3.h3 {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
|
||||
/* Search Form Enhancements */
|
||||
.search-form-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.input-group-text {
|
||||
background-color: #f8f9fa !important;
|
||||
border: 1px solid #ced4da !important;
|
||||
/* border-right: none !important; */
|
||||
color: #495057 !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.input-group-text:hover {
|
||||
background-color: #e9ecef !important;
|
||||
}
|
||||
|
||||
.input-group-text .heroicon {
|
||||
width: 1rem !important;
|
||||
height: 1rem !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
.form-control:focus {
|
||||
border-color: var(--primary-color) !important;
|
||||
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.15) !important;
|
||||
}
|
||||
|
||||
.form-control:focus + .input-group-text {
|
||||
border-color: var(--primary-color) !important;
|
||||
background-color: rgba(27, 131, 84, 0.05) !important;
|
||||
}
|
||||
|
||||
/* Button Group Enhancements */
|
||||
.d-flex.gap-2 .btn {
|
||||
white-space: nowrap !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.d-flex.gap-2 .btn:hover {
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
.d-flex.gap-2 .btn svg {
|
||||
margin-right: 0.375rem !important;
|
||||
}
|
||||
|
||||
/* Responsive Header Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
padding: 1rem 1.25rem !important;
|
||||
}
|
||||
|
||||
.card-header h1.h3,
|
||||
.card-header h2.h3,
|
||||
.card-header h3.h3 {
|
||||
font-size: 1.125rem !important;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
min-width: 200px !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.d-flex.gap-2 {
|
||||
flex-wrap: wrap !important;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
.d-flex.gap-2 .btn {
|
||||
flex: 1 !important;
|
||||
min-width: 120px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.card-header {
|
||||
padding: 0.875rem 1rem !important;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
.d-flex.gap-2 .btn {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 0.375rem 0.75rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Search Input Placeholder */
|
||||
.form-control::placeholder {
|
||||
color: #6c757d !important;
|
||||
opacity: 0.7 !important;
|
||||
}
|
||||
|
||||
/* Enhanced Focus States */
|
||||
.form-control:focus::placeholder {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Detail Page Enhancements */
|
||||
.detail-page-header {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, rgba(27, 131, 84, 0.1) 100%);
|
||||
border-bottom: 3px solid var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.detail-page-header h1 {
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
/* Information Cards Enhancement */
|
||||
.info-card {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.info-card:hover {
|
||||
background: #e9ecef;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.info-card .info-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.info-card .info-value {
|
||||
font-size: 1rem;
|
||||
color: #212529;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Action Cards Enhancement */
|
||||
.action-card {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-radius: 0.75rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Resume File Display */
|
||||
.resume-file {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.resume-file:hover {
|
||||
background: #e9ecef;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.resume-file .file-name {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.resume-file .file-info {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Parsed Data Grid Enhancement */
|
||||
.parsed-data-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.parsed-data-item {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.parsed-data-item:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 2px 8px rgba(27, 131, 84, 0.1);
|
||||
}
|
||||
|
||||
.parsed-data-item .data-key {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.parsed-data-item .data-value {
|
||||
font-size: 0.875rem;
|
||||
color: #495057;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Status Badge Enhancement */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 2rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.status-badge .heroicon {
|
||||
width: 1rem !important;
|
||||
height: 1rem !important;
|
||||
}
|
||||
|
||||
/* Contact Information Enhancement */
|
||||
.contact-info-item {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.contact-info-item:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 2px 8px rgba(27, 131, 84, 0.1);
|
||||
}
|
||||
|
||||
.contact-info-item .contact-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: #6c757d;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.contact-info-item .contact-value {
|
||||
font-size: 1rem;
|
||||
color: #212529;
|
||||
font-weight: 500;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* Responsive Detail Page Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.detail-page-header {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, rgba(27, 131, 84, 0.05) 100%);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.detail-page-header h1 {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.parsed-data-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.contact-info-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.detail-page-header h1 {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
|
||||
.info-card .info-label {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.info-card .info-value {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading Animation for Detail Pages */
|
||||
.detail-loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Print Styles for Detail Pages */
|
||||
@media print {
|
||||
.detail-page-header {
|
||||
background: white !important;
|
||||
border: 2px solid #dee2e6 !important;
|
||||
}
|
||||
|
||||
.detail-page-header h1 {
|
||||
color: #212529 !important;
|
||||
}
|
||||
|
||||
.contact-info-item,
|
||||
.info-card,
|
||||
.parsed-data-item {
|
||||
border: 1px solid #dee2e6 !important;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.btn,
|
||||
.action-card {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form and Update Page Enhancements */
|
||||
.form-page-header {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, rgba(27, 131, 84, 0.1) 100%);
|
||||
border-bottom: 3px solid var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.form-page-header h1 {
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.form-page-header p {
|
||||
color: rgba(27, 131, 84, 0.8) !important;
|
||||
}
|
||||
|
||||
/* Form Section Enhancement */
|
||||
.form-section {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
border-radius: 0.375rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-section:hover {
|
||||
background: #e9ecef;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-section h5 {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-section .section-icon {
|
||||
width: 1.25rem !important;
|
||||
height: 1.25rem !important;
|
||||
}
|
||||
|
||||
/* Form Field Enhancement */
|
||||
.form-field-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-field-wrapper label {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-field-wrapper .required-indicator {
|
||||
color: #dc3545;
|
||||
font-size: 0.875rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-field-wrapper .field-icon {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 2.5rem;
|
||||
color: #6c757d;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.15);
|
||||
color: #212529;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.form-control.is-invalid,
|
||||
.form-select.is-invalid {
|
||||
border-color: #dc3545;
|
||||
padding-right: 2.5rem;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.form-control.is-valid,
|
||||
.form-select.is-valid {
|
||||
border-color: #28a745;
|
||||
padding-right: 2.5rem;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.invalid-feedback,
|
||||
.valid-feedback {
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.invalid-feedback {
|
||||
color: #dc3545;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.valid-feedback {
|
||||
color: #28a745;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Form Enhancement for Special Fields */
|
||||
.form-check-input {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form-check-input:checked {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
/* Action Buttons Enhancement */
|
||||
.form-action-buttons {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.form-action-buttons .btn {
|
||||
min-width: 120px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-action-buttons .btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Responsive Form Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.form-section {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-section h5 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
}
|
||||
|
||||
.form-field-wrapper label {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-text,
|
||||
.invalid-feedback,
|
||||
.valid-feedback {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form-action-buttons {
|
||||
padding: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.form-action-buttons .btn {
|
||||
min-width: 100px;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.form-section {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-section h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
font-size: 0.813rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
}
|
||||
|
||||
.form-field-wrapper {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-action-buttons {
|
||||
flex-direction: column !important;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
.form-action-buttons .btn {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading State for Forms */
|
||||
.form-loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.form-loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.form-loading .spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Print Styles for Forms */
|
||||
@media print {
|
||||
.form-page-header,
|
||||
.form-section,
|
||||
.form-action-buttons {
|
||||
border: 1px solid #dee2e6 !important;
|
||||
background: white !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
border: 1px solid #000 !important;
|
||||
background: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* File Upload Enhancement */
|
||||
.form-control[type="file"] {
|
||||
padding: 0.5rem;
|
||||
border: 2px dashed #dee2e6;
|
||||
background: #f8f9fa;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control[type="file"]:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: #f0f8f4;
|
||||
}
|
||||
|
||||
.form-control[type="file"]:focus {
|
||||
border-color: var(--primary-color);
|
||||
background: white;
|
||||
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.15);
|
||||
}
|
||||
|
||||
/* Checkbox and Radio Enhancement */
|
||||
.form-check-input:checked ~ .form-check-label::before {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-check-input:focus ~ .form-check-label::before {
|
||||
box-shadow: 0 0 0 0.2rem rgba(27, 131, 84, 0.25);
|
||||
}
|
||||
|
||||
/* Help Text Enhancement */
|
||||
.help-text {
|
||||
font-size: 0.813rem;
|
||||
color: #6c757d;
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.help-text .help-icon {
|
||||
width: 1rem !important;
|
||||
height: 1rem !important;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Error State Enhancement */
|
||||
.field-error {
|
||||
border-color: #dc3545 !important;
|
||||
background-color: #fff5f5 !important;
|
||||
}
|
||||
|
||||
.field-error:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25) !important;
|
||||
}
|
||||
|
||||
/* Success State Enhancement */
|
||||
.field-success {
|
||||
border-color: #28a745 !important;
|
||||
background-color: #f8fff9 !important;
|
||||
}
|
||||
|
||||
.field-success:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25) !important;
|
||||
}
|
||||
1
template_partials/__init__.py
Normal file
1
template_partials/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Template partials app
|
||||
7
template_partials/apps.py
Normal file
7
template_partials/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TemplatePartialsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'template_partials'
|
||||
verbose_name = 'Template Partials'
|
||||
@ -1,5 +1,6 @@
|
||||
{% load i18n static %}
|
||||
{% load partials %}
|
||||
{% load static i18n %}
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ LANGUAGE_CODE }}" dir="{% if LANGUAGE_CODE == 'ar' %}rtl{% else %}ltr{% endif %}">
|
||||
<head>
|
||||
@ -8,12 +9,17 @@
|
||||
<meta name="description" content="{% trans 'King Abdullah Academic University Hospital - Applicant Tracking System' %}">
|
||||
<title>{% block title %}{% trans 'University ATS' %}{% endblock %}</title>
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
{% comment %} Load the correct Bootstrap CSS file for RTL/LTR {% endcomment %}
|
||||
{% if LANGUAGE_CODE == 'ar' %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
|
||||
{% else %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
{% endif %}
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||
{% comment %} <link href="https://unpkg.com/filepond/dist/filepond.css" rel="stylesheet">
|
||||
<link href="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css" rel="stylesheet"> {% endcomment %}
|
||||
<link rel="stylesheet" href="{% static 'css/main.css' %}">
|
||||
|
||||
|
||||
{% block customCSS %}{% endblock %}
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
||||
@ -21,218 +27,227 @@
|
||||
<div class="top-bar d-none d-md-block">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2 max-width-1600">
|
||||
<div class="d-flex align-items-center gap-3 social-icons">
|
||||
</div>
|
||||
<div class="contact-info d-flex gap-3">
|
||||
</div>
|
||||
<div class="logo-container d-flex gap-2">
|
||||
<img src="{% static 'image/vision.svg' %}" alt="{% trans 'Saudi Vision 2030' %}" loading="lazy">
|
||||
<div class="kaauh-logo-container d-flex flex-column flex-md-row align-items-center gap-2 ms-4">
|
||||
<div class="hospital-text text-center text-md-start me-3">
|
||||
<div class="ar small">جامعة الأميرة نورة بنت عبدالرحمن الأكاديمية</div>
|
||||
<div class="ar small">ومستشفى الملك عبدالله بن عبدالعزيز التخصصي</div>
|
||||
<div class="en small">Princess Nourah bint Abdulrahman University</div>
|
||||
<div class="en small">King Abdullah bin Abdulaziz University Hospital</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clogo-container d-flex gap-2">
|
||||
</div>
|
||||
<div class="logo-container d-flex gap-2 align-items-center">
|
||||
<img src="{% static 'image/vision.svg' %}" alt="{% trans 'Saudi Vision 2030' %}" loading="lazy" style="height: 35px; object-fit: contain;">
|
||||
|
||||
<div class="kaauh-logo-container d-flex flex-column flex-md-row align-items-center gap-2 me-0">
|
||||
<div class="hospital-text text-center text-md-start me-0">
|
||||
<div class="ar text-xs">جامعة الأميرة نورة بنت عبدالرحمن الأكاديمية</div>
|
||||
<div class="ar text-xs">ومستشفى الملك عبدالله بن عبدالرحمن التخصصي</div>
|
||||
<div class="en text-xs">Princess Nourah bint Abdulrahman University</div>
|
||||
<div class="en text-xs">King Abdullah bin Abdulaziz University Hospital</div>
|
||||
</div>
|
||||
</div>
|
||||
<img src="{% static 'image/kaauh.png' %}" alt="KAAUH Logo" style="max-height: 100px;max-width:100px;">
|
||||
<img src="{% static 'image/kaauh.png' %}" alt="KAAUH Logo" style="max-height: 40px; max-width: 40px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark sticky-top">
|
||||
<div class="container-fluid">
|
||||
<div class="navbar-content-wrapper max-width-1600 d-flex justify-content-between align-items-center" style="width: 100%;">
|
||||
<a class="navbar-brand text-white d-none d-md-block" href="{% url 'dashboard' %}">
|
||||
<img src="{% static 'image/kaauh_green1.png' %}" alt="{% trans 'kaauh logo green bg' %}" style="width: 60px; height: 60px;">
|
||||
</a>
|
||||
<div class="container-fluid max-width-1600">
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav" aria-expanded="false" aria-label="{% trans 'Toggle navigation' %}">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
{# --- MOBILE BRAND LOGIC: Show small logo on mobile, large on desktop (lg) --- #}
|
||||
<a class="navbar-brand text-white d-block d-lg-none" href="{% url 'dashboard' %}" aria-label="Home">
|
||||
<img src="{% static 'image/kaauh_green1.png' %}" alt="{% trans 'kaauh logo green bg' %}" class="navbar-brand-mobile">
|
||||
</a>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item me-4">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'job_list' %}active{% endif %}" href="{% url 'job_list' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{% include "icons/jobs.html" %}
|
||||
{% trans "Jobs" %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<a class="navbar-brand text-white d-none d-lg-block" href="{% url 'dashboard' %}" aria-label="Home">
|
||||
<img src="{% static 'image/kaauh_green1.png' %}" alt="{% trans 'kaauh logo green bg' %}" style="width: 60px; height: 60px;">
|
||||
</a>
|
||||
|
||||
<li class="nav-item me-4">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'candidate_list' %}active{% endif %}" href="{% url 'candidate_list' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{% include "icons/users.html" %}
|
||||
{% trans "Applicants" %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{# Toggler: order-lg-0 ensures it's before navigation links on desktop, but it stays where it is on mobile #}
|
||||
<button class="navbar-toggler order-lg-0" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav" aria-expanded="false" aria-label="{% trans 'Toggle navigation' %}">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<li class="nav-item me-4">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'list_meetings' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||
</svg>
|
||||
{% trans "Meetings" %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item me-4">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'training_list' %}active{% endif %}" href="{% url 'training_list' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" />
|
||||
</svg>
|
||||
|
||||
{% trans "Training" %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item dropdown ms-2">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
|
||||
data-bs-offset="0, 8" data-bs-auto-close="outside">
|
||||
{% trans "More" %}
|
||||
</a>
|
||||
<ul class="dropdown-menu" data-bs-popper="static">
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-calendar me-2"></i> {% trans "Meetings" %}</a></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-clock me-2"></i> {% trans "Schedule" %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-briefcase me-2"></i> {% trans "Active Jobs" %}</a></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-file-alt me-2"></i> {% trans "Draft Jobs" %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-users me-2"></i> {% trans "All Candidates" %}</a></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-user-plus me-2"></i> {% trans "New Candidates" %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav me-2">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="language-toggle-btn dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
|
||||
data-bs-offset="0, 8" aria-expanded="false" aria-label="{% trans 'Toggle language menu' %}">
|
||||
<i class="fas fa-globe"></i>
|
||||
<span class="d-none d-lg-inline">{{ LANGUAGE_CODE|upper }}</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" data-bs-popper="static">
|
||||
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="en" class="dropdown-item {% if LANGUAGE_CODE == 'en' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="me-2">🇺🇸</span> English
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="ar" class="dropdown-item {% if LANGUAGE_CODE == 'ar' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="me-2">🇸🇦</span> العربية (Arabic)
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{# Language and Profile Controls (Keep outside collapse for mobile access) #}
|
||||
<div class="d-flex align-items-center order-lg-3">
|
||||
<ul class="navbar-nav flex-row">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="language-toggle-btn dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
|
||||
data-bs-offset="0, 8" aria-expanded="false" aria-label="{% trans 'Toggle language menu' %}">
|
||||
<i class="fas fa-globe"></i>
|
||||
<span class="d-none d-lg-inline">{{ LANGUAGE_CODE|upper }}</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu {% if LANGUAGE_CODE == 'ar' %}dropdown-menu-start{% else %}dropdown-menu-end{% endif %}" data-bs-popper="static">
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="en" class="dropdown-item {% if LANGUAGE_CODE == 'en' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="me-2">🇺🇸</span> English
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="ar" class="dropdown-item {% if LANGUAGE_CODE == 'ar' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="me-2">🇸🇦</span> العربية (Arabic)
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav ms-4">
|
||||
<li class="nav-item dropdown">
|
||||
<button
|
||||
class="nav-link p-0 border-0 bg-transparent dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
aria-label="{% trans 'Toggle user menu' %}"
|
||||
data-bs-auto-close="outside"
|
||||
data-bs-offset="0, 8"
|
||||
>
|
||||
{% if user.profile.profile_image %}
|
||||
<img src="{{ user.profile.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar"
|
||||
style="width: 36px; height: 36px; object-fit: cover; background-color: var(--kaauh-teal); display: inline-block; vertical-align: middle;"
|
||||
title="{% trans 'Your account' %}">
|
||||
{% else %}
|
||||
<div class="profile-avatar" title="{% trans 'Your account' %}">
|
||||
{{ user.username|first|upper }}
|
||||
<ul class="navbar-nav ms-2 ms-lg-4">
|
||||
<li class="nav-item dropdown">
|
||||
<button
|
||||
class="nav-link p-0 border-0 bg-transparent dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
aria-label="{% trans 'Toggle user menu' %}"
|
||||
data-bs-auto-close="outside"
|
||||
data-bs-offset="0, 16" {# Vertical offset remains 16px to prevent clipping #}
|
||||
>
|
||||
{% if user.profile.profile_image %}
|
||||
<img src="{{ user.profile.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar"
|
||||
style="width: 36px; height: 36px; object-fit: cover; background-color: var(--kaauh-teal); display: inline-block; vertical-align: middle;"
|
||||
title="{% trans 'Your account' %}">
|
||||
{% else %}
|
||||
<div class="profile-avatar" title="{% trans 'Your account' %}">
|
||||
{{ user.username|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</button>
|
||||
<ul
|
||||
{# FINAL FIX: Always use dropdown-menu-end. In LTR (English), this aligns right. In RTL (Arabic), this aligns left. #}
|
||||
class="dropdown-menu dropdown-menu-end py-0 shadow border-0 rounded-3"
|
||||
style="min-width: 240px;"
|
||||
>
|
||||
<li class="px-4 py-3 ">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3 d-flex align-items-center justify-content-center" style="min-width: 48px;">
|
||||
{% if user.profile.profile_image %}
|
||||
<img src="{{ user.profile.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar shadow-sm border"
|
||||
style="width: 44px; height: 44px; object-fit: cover; background-color: var(--kaauh-teal); display: block;"
|
||||
title="{% trans 'Your account' %}">
|
||||
{% else %}
|
||||
<div class="profile-avatar shadow-sm border d-flex align-items-center justify-content-center"
|
||||
style="width: 44px; height: 44px; background-color: var(--kaauh-teal); font-size: 1.2rem;">
|
||||
{{ user.username|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</button>
|
||||
<ul
|
||||
class="dropdown-menu dropdown-menu-end py-0 shadow border-0 rounded-3"
|
||||
data-bs-popper="static"
|
||||
style="min-width: 240px;"
|
||||
>
|
||||
<li class="px-4 py-3 ">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3 d-flex align-items-center justify-content-center" style="min-width: 48px;">
|
||||
{% if user.profile.profile_image %}
|
||||
<img src="{{ user.profile.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar shadow-sm border"
|
||||
style="width: 44px; height: 44px; object-fit: cover; background-color: var(--kaauh-teal); display: block;"
|
||||
title="{% trans 'Your account' %}">
|
||||
{% else %}
|
||||
<div class="profile-avatar shadow-sm border d-flex align-items-center justify-content-center"
|
||||
style="width: 44px; height: 44px; background-color: var(--kaauh-teal); font-size: 1.2rem;">
|
||||
{{ user.username|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold text-dark">{{ user.get_full_name|default:user.username }}</div>
|
||||
<div class="text-muted small">{{ user.email|truncatechars:24 }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold text-dark">{{ user.get_full_name|default:user.username }}</div>
|
||||
<div class="text-muted small">{{ user.email|truncatechars:24 }}</div>
|
||||
</div>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="{% url 'user_detail' request.user.pk %}"><i class="fas fa-user-circle me-3 text-primary fs-5"></i> <span>{% trans "My Profile" %}</span></a></li>
|
||||
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="#"><i class="fas fa-cog me-3 text-primary fs-5"></i> <span>{% trans "Settings" %}</span></a></li>
|
||||
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="#"><i class="fas fa-history me-3 text-primary fs-5"></i> <span>{% trans "Activity Log" %}</span></a></li>
|
||||
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="#"><i class="fas fa-question-circle me-3 text-primary fs-5"></i> <span>{% trans "Help & Support" %}</span></a></li>
|
||||
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="#">
|
||||
{% if not request.session.linkedin_authenticated %}
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'linkedin_login' %}">
|
||||
<i class="fab fa-linkedin me-1"></i> {% trans "Connect LinkedIn" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% else %}
|
||||
<i class="fab fa-linkedin text-primary me-1"></i>
|
||||
<span class="text-primary d-none d-lg-inline ms-auto me-3">
|
||||
{% trans "LinkedIn Connected" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a></li>
|
||||
</div>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
{% if request.user.is_authenticated %}
|
||||
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="{% url 'user_detail' request.user.pk %}"><i class="fas fa-user-circle me-3 text-primary fs-5"></i> <span>{% trans "My Profile" %}</span></a></li>
|
||||
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="{% url 'admin_settings' %}"><i class="fas fa-cog me-3 text-primary fs-5"></i> <span>{% trans "Settings" %}</span></a></li>
|
||||
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="{% url 'easy_logs' %}"><i class="fas fa-history me-3 text-primary fs-5"></i> <span>{% trans "Activity Log" %}</span></a></li>
|
||||
<li><a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="#"><i class="fas fa-question-circle me-3 text-primary fs-5"></i> <span>{% trans "Help & Support" %}</span></a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% comment %} CORRECTED LINKEDIN BLOCK {% endcomment %}
|
||||
{% if not request.session.linkedin_authenticated %}
|
||||
<li>
|
||||
<form method="post" action="{% url 'account_logout'%}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button
|
||||
type="submit"
|
||||
class="dropdown-item py-2 px-4 text-danger d-flex align-items-center border-0 bg-transparent text-start"
|
||||
aria-label="{% trans 'Sign out' %}"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt me-3 fs-5"></i>
|
||||
<span>{% trans "Sign Out" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
<a class="dropdown-item py-2 px-4 d-flex align-items-center text-decoration-none" href="{% url 'linkedin_login' %}">
|
||||
<i class="fab fa-linkedin me-3 text-primary fs-5"></i>
|
||||
<span>{% trans "Connect LinkedIn" %}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<li class="px-4 py-2 text-muted small">
|
||||
<i class="fab fa-linkedin text-primary me-2"></i>
|
||||
{% trans "LinkedIn Connected" %}
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
{% if request.user.is_authenticated %}
|
||||
<li>
|
||||
<form method="post" action="{% url 'account_logout'%}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button
|
||||
type="submit"
|
||||
class="dropdown-item py-2 px-4 text-danger d-flex align-items-center border-0 bg-transparent text-start"
|
||||
aria-label="{% trans 'Sign out' %}"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt me-3 fs-5"></i>
|
||||
<span>{% trans "Sign Out" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{# End Language and Profile Controls #}
|
||||
|
||||
{# Main Navigation Links (This collapses on mobile) - order-lg-1 ensures it is centered on desktop #}
|
||||
<div class="collapse navbar-collapse order-lg-1" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
{# Changed me-4 to me-lg-4 so they stack tightly on mobile #}
|
||||
<li class="nav-item me-lg-4">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'job_list' %}active{% endif %}" href="{% url 'job_list' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{% include "icons/jobs.html" %}
|
||||
{% trans "Jobs" %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item me-lg-4">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'candidate_list' %}active{% endif %}" href="{% url 'candidate_list' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{% include "icons/users.html" %}
|
||||
{% trans "Applicants" %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item me-lg-4">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'list_meetings' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||
</svg>
|
||||
{% trans "Meetings" %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item me-lg-4">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'training_list' %}active{% endif %}" href="{% url 'training_list' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" />
|
||||
</svg>
|
||||
{% trans "Training" %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown ms-lg-2">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
|
||||
data-bs-offset="0, 8" data-bs-auto-close="outside">
|
||||
{% trans "More" %}
|
||||
</a>
|
||||
<ul class="dropdown-menu" data-bs-popper="static">
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-calendar me-2"></i> {% trans "Meetings" %}</a></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-clock me-2"></i> {% trans "Schedule" %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-briefcase me-2"></i> {% trans "Active Jobs" %}</a></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-file-alt me-2"></i> {% trans "Draft Jobs" %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-users me-2"></i> {% trans "All Candidates" %}</a></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-user-plus me-2"></i> {% trans "New Candidates" %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@ -241,7 +256,7 @@
|
||||
<main class="container-fluid flex-grow-1" style="max-width: 1600px; margin: 0 auto;">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mt-2" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% trans 'Close' %}"></button>
|
||||
</div>
|
||||
@ -272,43 +287,24 @@
|
||||
{% include 'includes/delete_modal.html' %}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const forms = document.querySelectorAll('form');
|
||||
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
// Find the submit button within this form
|
||||
const submitButton = form.querySelector('button[type="submit"], input[type="submit"]');
|
||||
|
||||
if (submitButton) {
|
||||
// Disable the button
|
||||
submitButton.disabled = true;
|
||||
|
||||
// Optional: Add a loading class for styling
|
||||
submitButton.classList.add('loading');
|
||||
|
||||
// Re-enable the button if the form submission fails
|
||||
// This ensures the button doesn't stay disabled if there's an error
|
||||
window.addEventListener('unload', function() {
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('loading');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Navbar collapse auto-close on link click (Standard Mobile UX)
|
||||
const navbarCollapse = document.getElementById('navbarNav');
|
||||
if (navbarCollapse) {
|
||||
const navLinks = navbarCollapse.querySelectorAll('.nav-link:not(.dropdown-toggle)');
|
||||
// Select all links, including those inside the "More" dropdown
|
||||
const navLinks = navbarCollapse.querySelectorAll('.nav-link:not(.dropdown-toggle), .dropdown-item');
|
||||
const bsCollapse = bootstrap.Collapse.getInstance(navbarCollapse) || new bootstrap.Collapse(navbarCollapse, { toggle: false });
|
||||
|
||||
navLinks.forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
// Only collapse if the nav is actually shown (i.e., on mobile)
|
||||
if (navbarCollapse.classList.contains('show')) {
|
||||
bsCollapse.hide();
|
||||
// Check if the click was on a non-dropdown-toggle or a dropdown item (which navigate away)
|
||||
if (!link.classList.contains('dropdown-toggle')) {
|
||||
bsCollapse.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -318,34 +314,15 @@
|
||||
const logoutButton = document.querySelector('form[action$="/logout/"] button');
|
||||
if (logoutButton) {
|
||||
logoutButton.addEventListener('click', (e) => {
|
||||
// Check if screen is small (e.g., Bootstrap large breakpoint is 992px)
|
||||
if (window.innerWidth <= 992) {
|
||||
// Check if screen is small (Bootstrap 'lg' breakpoint is 992px)
|
||||
if (window.innerWidth < 992) {
|
||||
const confirmed = confirm('{% trans "Are you sure you want to sign out?" %}');
|
||||
if (!confirmed) e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle language form submission: Manually trigger click on button inside form
|
||||
document.querySelectorAll('.language-toggle-btn').forEach(toggle => {
|
||||
const menu = toggle.nextElementSibling;
|
||||
if (menu) {
|
||||
menu.querySelectorAll('.dropdown-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
// Find the containing form and submit it
|
||||
const form = item.closest('form');
|
||||
if (form) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
|
||||
{% comment %} <script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.5/bundles/datastar.js"></script> {% endcomment %}
|
||||
|
||||
{% block customJS %}{% endblock %}
|
||||
|
||||
</body>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static i18n form_filters %}
|
||||
{% load partials %}
|
||||
|
||||
|
||||
{% block title %}All Submissions for {{ template.name }} - ATS{% endblock %}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static i18n crispy_forms_tags %}
|
||||
{% load partials %}
|
||||
|
||||
|
||||
{% block title %}Submissions for {{ template.name }} - ATS{% endblock %}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static i18n crispy_forms_tags %}
|
||||
{% load partials %}
|
||||
|
||||
|
||||
{% block title %}Form Templates - {{ block.super }}{% endblock %}
|
||||
|
||||
@ -76,13 +76,13 @@
|
||||
transform: none;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
|
||||
/* Card Header (For Search/Filter Card) */
|
||||
.card-header {
|
||||
font-weight: 600;
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
background-color: var(--kaauh-gray-light);
|
||||
background-color: var(--kaauh-gray-light);
|
||||
}
|
||||
|
||||
/* Stats Theming */
|
||||
@ -205,7 +205,7 @@
|
||||
<span class="text-muted small mb-3">
|
||||
<i class="fas fa-briefcase me-1"></i> {{ template.job|default:"N/A" }}
|
||||
</span>
|
||||
|
||||
|
||||
{# Stats #}
|
||||
<div class="row text-center mb-3">
|
||||
<div class="col-6 border-end">
|
||||
@ -217,7 +217,7 @@
|
||||
<div class="stat-label">{% trans "Fields" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{# Description #}
|
||||
<p class="card-text small text-muted flex-grow-1">
|
||||
{% if template.description %}
|
||||
|
||||
@ -1,40 +1,6 @@
|
||||
{% extends 'forms/partials/candidate_facing_base.html'%}
|
||||
{% load static i18n %}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% translate "Application Form" %}</title>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||||
/>
|
||||
<style>
|
||||
/* KAAT-S Theme Variables */
|
||||
:root {
|
||||
--kaauh-teal: #00636e; /* Main Primary Color */
|
||||
--kaauh-teal-dark: #004a53; /* Dark Primary Color */
|
||||
|
||||
/* Mapping wizard defaults to theme colors */
|
||||
--primary: var(--kaauh-teal);
|
||||
--primary-light: #007c89; /* Slightly lighter shade for subtle hover/border */
|
||||
--secondary: var(--kaauh-teal-dark);
|
||||
--success: #198754; /* Keeping a standard success green for Submit */
|
||||
--error: #dc3545; /* Standard danger red */
|
||||
|
||||
--light: #f8f9fa;
|
||||
--dark: #212529;
|
||||
--gray: #6c757d;
|
||||
--light-gray: #e9ecef;
|
||||
--border: #dee2e6;
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--radius: 16px; /* Increased radius for a softer look */
|
||||
--transition: all 0.3s ease;
|
||||
}
|
||||
{% block content %}
|
||||
<style>
|
||||
/* KAAT-S Theme Variables */
|
||||
:root {
|
||||
@ -501,57 +467,7 @@
|
||||
/* The z-index is already 1030 in the inline style, which is correct */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav
|
||||
id="topNavbar"
|
||||
class="navbar navbar-expand-lg"
|
||||
style="background-color: white; z-index: 1030"
|
||||
>
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand text-white fw-bold" href="/">
|
||||
<img
|
||||
src="{% static 'image/kaauh.jpeg' %}"
|
||||
alt="{% translate 'KAAUH IMAGE' %}"
|
||||
style="height: 50px; margin-right: 10px"
|
||||
/>
|
||||
</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link text-secondary"
|
||||
href="/applications/"
|
||||
>{% translate "Applications" %}</a
|
||||
>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-secondary" href="/profile/"
|
||||
>{% translate "Profile" %}</a
|
||||
>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link text-secondary"
|
||||
href="https://kaauh.edu.sa/career"
|
||||
>{% translate "Careers" %}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<nav
|
||||
id="bottomNavbar"
|
||||
class="navbar navbar-expand-lg sticky-top"
|
||||
@ -616,7 +532,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Application State
|
||||
const csrfToken = '{{ csrf_token }}';
|
||||
@ -1344,5 +1260,4 @@
|
||||
// Start the application
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock content %}
|
||||
88
templates/forms/job_detail_candidate.html
Normal file
88
templates/forms/job_detail_candidate.html
Normal file
@ -0,0 +1,88 @@
|
||||
{% extends 'forms/partials/candidate_facing_base.html'%}
|
||||
{% load static i18n %}
|
||||
{% block content %}
|
||||
|
||||
<nav id="bottomNavbar" class="navbar navbar-expand-lg sticky-top" style="background-color: var(--kaauh-teal); z-index: 1030;">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-text text-white fw-bold">{% trans "Job Overview" %}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<div class="row mb-5 mt-3 main-content-area">
|
||||
|
||||
<div class="col-lg-4 order-lg-2 order-1 d-none d-lg-block">
|
||||
<div class="card shadow-sm sticky-top">
|
||||
<div class="card-header bg-kaauh-teal-dark text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-file-signature me-2"></i>{% trans "Ready to Apply?" %}</h5>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<p class="text-muted">{% trans "Review the job details, then apply below." %}</p>
|
||||
|
||||
{% if job.form_template %}
|
||||
<a href="{% url 'form_wizard' job.form_template.pk %}" class="btn btn-main-action btn-lg w-100">
|
||||
<i class="fas fa-paper-plane me-2"></i> {% trans "Apply for this Position" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8 order-lg-1 order-2">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-kaauh-teal-dark text-white d-flex justify-content-between align-items-center">
|
||||
<h2 class="h3 mb-0 fw-bold">{{ job.title }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
<h4 class="mb-3" style="color: var(--kaauh-teal-dark);">{% trans "Job Overview" %}</h4>
|
||||
<div class="row row-cols-1 row-cols-md-2 g-3 mb-4 small text-secondary">
|
||||
{% if job.salary_range %}
|
||||
<div class="col">
|
||||
<i class="fas fa-money-bill-wave text-success me-2"></i>
|
||||
<strong>{% trans "Salary:" %}</strong>
|
||||
<span class="fw-bold text-success">{{ job.salary_range }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col">
|
||||
<i class="fas fa-calendar-alt text-muted me-2"></i>
|
||||
<strong>{% trans "Deadline:" %}</strong>
|
||||
{% if job.application_deadline %}
|
||||
{{ job.application_deadline|date:"M d, Y" }}
|
||||
{% if job.is_expired %}
|
||||
<span class="badge bg-danger ms-2">{% trans "EXPIRED" %}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">{% trans "Not specified" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col"> <i class="fas fa-briefcase text-muted me-2"></i> <strong>{% trans "Job Type:" %}</strong> {{ job.get_job_type_display }} </div>
|
||||
<div class="col"> <i class="fas fa-map-marker-alt text-muted me-2"></i> <strong>{% trans "Location:" %}</strong> {{ job.get_location_display }} </div>
|
||||
<div class="col"> <i class="fas fa-building text-muted me-2"></i> <strong>{% trans "Department:" %}</strong> {{ job.department|default:"N/A" }} </div>
|
||||
<div class="col"> <i class="fas fa-hashtag text-muted me-2"></i> <strong>{% trans "JOB ID:" %}</strong> {{ job.internal_job_id|default:"N/A" }} </div>
|
||||
<div class="col"> <i class="fas fa-desktop text-muted me-2"></i> <strong>{% trans "Workplace:" %}</strong> {{ job.get_workplace_type_display }} </div>
|
||||
</div>
|
||||
|
||||
{% if job.has_description_content %}<hr class="my-4"><div class="mb-4"><h5 class="fw-bold" style="color: var(--kaauh-teal-dark);"><i class="fas fa-info-circle me-2"></i>{% trans "Job Description" %}</h5><div class="text-secondary">{{ job.description|safe }}</div></div>{% endif %}
|
||||
{% if job.has_qualifications_content %}<hr class="my-4"><div class="mb-4"><h5 class="fw-bold" style="color: var(--kaauh-teal-dark);"><i class="fas fa-graduation-cap me-2"></i>{% trans "Qualifications" %}</h5><div class="text-secondary">{{ job.qualifications|safe }}</div></div>{% endif %}
|
||||
{% if job.has_benefits_content %}<hr class="my-4"><div class="mb-4"><h5 class="fw-bold" style="color: var(--kaauh-teal-dark);"><i class="fas fa-hand-holding-usd me-2"></i>{% trans "Benefits" %}</h5><div class="text-secondary">{{ job.benefits|safe }}</div></div>{% endif %}
|
||||
{% if job.has_application_instructions_content %}<hr class="my-4"><div class="mb-4"><h5 class="fw-bold" style="color: var(--kaauh-teal-dark);"><i class="fas fa-file-alt me-2"></i>{% trans "Application Instructions" %}</h5><div class="text-secondary">{{ job.application_instructions|safe }}</div></div>{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-fixed-apply-bar d-lg-none">
|
||||
{% if job.form_template %}
|
||||
<a href="{% url 'form_wizard' job.form_template.pk %}" class="btn btn-main-action btn-lg w-100">
|
||||
<i class="fas fa-paper-plane me-2"></i> {% trans "Apply for this Position" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content%}
|
||||
265
templates/forms/partials/candidate_facing_base.html
Normal file
265
templates/forms/partials/candidate_facing_base.html
Normal file
@ -0,0 +1,265 @@
|
||||
{% load static i18n %}
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
{% get_available_languages as LANGUAGES %}
|
||||
{% get_language_info_list for LANGUAGES as language_info_list %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ LANGUAGE_CODE }}" dir="{% if LANGUAGE_CODE == 'ar' %}rtl{% else %}ltr{% endif %}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% translate "Application Form" %}</title>
|
||||
|
||||
{% comment %} Load the correct Bootstrap CSS file for RTL/LTR {% endcomment %}
|
||||
{% if LANGUAGE_CODE == 'ar' %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
|
||||
{% else %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
{% endif %}
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* THEME & UTILITY VARIABLES */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--success: #198754;
|
||||
--danger: #dc3545;
|
||||
--light-bg: #f8f9fa;
|
||||
--gray-text: #6c757d;
|
||||
--kaauh-border: #eaeff3; /* Added for dropdown styling */
|
||||
|
||||
/* CALCULATED STICKY HEIGHTS */
|
||||
--navbar-height: 56px;
|
||||
--navbar-gap: 16px;
|
||||
--sticky-navbar-total-height: 128px;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background-color: #f0f0f5; /* Light gray background for contrast */
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
border: none;
|
||||
transition: background-color 0.3s ease, transform 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 99, 110, 0.3);
|
||||
}
|
||||
|
||||
.btn-main-action:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bg-kaauh-teal-dark {
|
||||
background-color: var(--kaauh-teal-dark) !important;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* LANGUAGE TOGGLE STYLES (COPIED FROM MAIN LAYOUT) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
.language-toggle-btn {
|
||||
color: var(--gray-text) !important; /* Use secondary color */
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.language-toggle-btn:hover {
|
||||
background: var(--light-bg) !important;
|
||||
border-radius: 4px;
|
||||
color: var(--kaauh-teal) !important;
|
||||
}
|
||||
|
||||
/* Dropdown Menu styling for language */
|
||||
.dropdown-menu {
|
||||
backdrop-filter: blur(4px);
|
||||
background-color: rgba(255, 255, 255, 0.98);
|
||||
border: 1px solid var(--kaauh-border);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.12);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0;
|
||||
min-width: 150px;
|
||||
}
|
||||
.dropdown-item {
|
||||
padding: 0.5rem 1.25rem;
|
||||
transition: background-color 0.15s;
|
||||
text-align: inherit; /* Ensure text alignment is controlled by dir="rtl" */
|
||||
}
|
||||
|
||||
/* Use button as dropdown-item inside form for full click area */
|
||||
.dropdown-item[type="submit"] {
|
||||
width: 100%;
|
||||
text-align: inherit;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* LAYOUT & STICKY POSITIONING FIXES (Desktop/Tablet) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
#topNavbar {
|
||||
z-index: 1040; /* Higher than the bottom bar */
|
||||
}
|
||||
|
||||
/* 1. Position the dark navbar below the white navbar + gap */
|
||||
#bottomNavbar {
|
||||
/* 56px (white nav) + 16px (mb-3) = 72px */
|
||||
top: calc(var(--navbar-height) + var(--navbar-gap));
|
||||
z-index: 1030;
|
||||
}
|
||||
|
||||
/* 2. Pushes the main content down so it's not hidden under the navbars */
|
||||
.main-content-area {
|
||||
/* Total Sticky Height (128px) + Extra Margin (12px) = 140px */
|
||||
margin-top: calc(var(--sticky-navbar-total-height) + 12px);
|
||||
}
|
||||
|
||||
/* 3. Positions the sticky sidebar correctly */
|
||||
.card.sticky-top {
|
||||
/* Start scrolling the sidebar just below the two navbars + a small gap */
|
||||
top: calc(var(--sticky-navbar-total-height) + 15px);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* RTL / ARABIC SUPPORT - Optimized */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
html[dir="rtl"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Flip Margin Utilities (m-end and m-start) */
|
||||
html[dir="rtl"] .ms-auto { margin-right: auto !important; margin-left: 0 !important; }
|
||||
html[dir="rtl"] .me-auto { margin-left: auto !important; margin-right: 0 !important; }
|
||||
html[dir="rtl"] .ms-2 { margin-right: 0.5rem !important; margin-left: 0 !important; }
|
||||
html[dir="rtl"] .me-2 { margin-left: 0.5rem !important; margin-right: 0 !important; }
|
||||
html[dir="rtl"] .me-1 { margin-left: 0.25rem !important; margin-right: 0 !important; } /* For the globe icon */
|
||||
|
||||
/* Flip alignment for text-end/text-start */
|
||||
html[dir="rtl"] .text-end { text-align: left !important; }
|
||||
html[dir="rtl"] .text-start { text-align: right !important; }
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* MOBILE RESPONSIVE STYLES (Below 992px) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
@media (max-width: 991.98px) {
|
||||
|
||||
/* Ensures dropdown items in mobile menu align correctly */
|
||||
html[dir="rtl"] .navbar-collapse .dropdown-menu {
|
||||
text-align: right;
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* On mobile, the top navbar is generally only 56px tall when collapsed. */
|
||||
#bottomNavbar {
|
||||
top: calc(var(--navbar-height) + var(--navbar-gap));
|
||||
}
|
||||
|
||||
.main-content-area {
|
||||
/* Reduced margin-top for smaller screens */
|
||||
margin-top: calc(var(--sticky-navbar-total-height) / 2);
|
||||
}
|
||||
|
||||
/* Mobile Fixed Footer Bar for Application */
|
||||
.mobile-fixed-apply-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
background-color: var(--light-bg);
|
||||
border-top: 1px solid #ddd;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -4px 10px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Add padding to the bottom of the body content to prevent it from hiding under the fixed bar */
|
||||
body {
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
/* Fix job overview grid responsiveness (ensures 1 column) */
|
||||
.row-cols-md-2 > .col {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav id="topNavbar" class="navbar navbar-expand-lg sticky-top" style="background-color: white; z-index: 1040;">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand text-white fw-bold" href="{% url 'kaauh_career' %}">
|
||||
<img src="{% static 'image/kaauh.jpeg' %}" alt="{% translate 'KAAUH IMAGE' %}" style="height: 50px; margin-right: 10px;">
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-secondary" href="/applications/">{% translate "Applications" %}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-secondary" href="/profile/">{% translate "Profile" %}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-secondary" href="{% url 'kaauh_career' %}">{% translate "Careers" %}</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<button class="language-toggle-btn dropdown-toggle" type="button"
|
||||
data-bs-toggle="dropdown" data-bs-offset="0, 8" aria-expanded="false"
|
||||
aria-label="{% trans 'Toggle language menu' %}">
|
||||
<i class="fas fa-globe"></i>
|
||||
<span class="d-inline">{{ LANGUAGE_CODE|upper }}</span>
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu {% if LANGUAGE_CODE == 'ar' %}dropdown-menu-start{% else %}dropdown-menu-end{% endif %}" aria-labelledby="navbarLanguageDropdown">
|
||||
|
||||
{% comment %} English Button {% endcomment %}
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="en" class="dropdown-item {% if LANGUAGE_CODE == 'en' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="me-2">🇺🇸</span> English
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
|
||||
{% comment %} Arabic Button {% endcomment %}
|
||||
<li>
|
||||
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
|
||||
<input name="next" type="hidden" value="{{ request.get_full_path }}">
|
||||
<button name="language" value="ar" class="dropdown-item {% if LANGUAGE_CODE == 'ar' %}active bg-light-subtle{% endif %}" type="submit">
|
||||
<span class="me-2">🇸🇦</span> العربية (Arabic)
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% block content %}
|
||||
|
||||
{% endblock content %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,3 +1,3 @@
|
||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg class="size-2 heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 330 B After Width: | Height: | Size: 337 B |
3
templates/icons/video.html
Normal file
3
templates/icons/video.html
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 heroicon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 412 B |
@ -1,25 +1,31 @@
|
||||
{% load i18n %}
|
||||
<h5 class="modal-title" id="candidateviewModalLabel">{{ candidate.name }} - {% trans "Score" %}: <span class="badge bg-success"> {{ candidate.match_score }} </span></h5>
|
||||
<h5> {% trans "AI Score" %}: <span class="badge bg-success"><i class="fas fa-robot me-1"></i> {{ candidate.match_score }}</span> <span class="badge bg-success"><i class="fas fa-graduation-cap me-1"></i> {{ candidate.professional_category }} </span></h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Strengths" %}</label>
|
||||
<textarea class="form-control" rows="3" readonly>{{ candidate.strengths }}</textarea>
|
||||
<label class="form-label"><i class="fas fa-comment me-1 text-info"></i> {% trans "Recommendation" %}</label>
|
||||
<textarea class="form-control" rows="10" readonly>{{ candidate.recommendation }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Weaknesses" %}</label>
|
||||
<textarea class="form-control" rows="3" readonly>{{ candidate.weaknesses }}</textarea>
|
||||
<label class="form-label"><i class="fas fa-thumbs-up me-1 text-success"></i> {% trans "Strengths" %}</label>
|
||||
<textarea class="form-control" rows="6" readonly>{{ candidate.strengths }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Criteria Checklist" %}</label>
|
||||
<label class="form-label"><i class="fas fa-thumbs-down me-1 text-danger"></i> {% trans "Weaknesses" %}</label>
|
||||
<textarea class="form-control" rows="6" readonly>{{ candidate.weaknesses }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="fas fa-list-check me-1"></i> {% trans "Criteria Checklist" %}</label>
|
||||
<ul class="list-group">
|
||||
{% for key, value in candidate.criteria_checklist.items %}
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span>{{ key }}</span>
|
||||
<span class="fw-" style="font-size: smaller;">{{ key }}</span>
|
||||
{% if value == 'Met' %}
|
||||
<span class="badge bg-success">Yes</span>
|
||||
<span class="badge bg-success"><i class="fas fa-check me-1"></i> Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Not Mentioned</span>
|
||||
<span class="badge bg-danger"><i class="fas fa-times me-1"></i> Not Mentioned</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
10
templates/includes/candidate_update_exam_form.html
Normal file
10
templates/includes/candidate_update_exam_form.html
Normal file
@ -0,0 +1,10 @@
|
||||
{% load i18n %}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2" hx-swap='outerHTML' hx-select=".table-responsive" hx-target=".table-responsive"
|
||||
hx-on::after-request="const modal = bootstrap.Modal.getInstance(document.getElementById('candidateviewModal')); if (modal) { modal.hide(); }">
|
||||
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'Passed' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-check me-1"></i> {% trans "Passed" %}
|
||||
</a>
|
||||
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'Failed' %}" class="btn btn-danger">
|
||||
<i class="fas fa-times me-1"></i> {% trans "Failed" %}
|
||||
</a>
|
||||
</div>
|
||||
10
templates/includes/candidate_update_interview_form.html
Normal file
10
templates/includes/candidate_update_interview_form.html
Normal file
@ -0,0 +1,10 @@
|
||||
{% load i18n %}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2" hx-swap='outerHTML' hx-select=".table-responsive" hx-target=".table-responsive"
|
||||
hx-on::after-request="const modal = bootstrap.Modal.getInstance(document.getElementById('candidateviewModal')); if (modal) { modal.hide(); }">
|
||||
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'interview' 'Passed' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-check me-1"></i> {% trans "Passed" %}
|
||||
</a>
|
||||
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'interview' 'Failed' %}" class="btn btn-danger">
|
||||
<i class="fas fa-times me-1"></i> {% trans "Failed" %}
|
||||
</a>
|
||||
</div>
|
||||
10
templates/includes/candidate_update_offer_form.html
Normal file
10
templates/includes/candidate_update_offer_form.html
Normal file
@ -0,0 +1,10 @@
|
||||
{% load i18n %}
|
||||
<div class="d-flex justify-content-center align-items-center gap-2" hx-swap='outerHTML' hx-select=".table-responsive" hx-target=".table-responsive"
|
||||
hx-on::after-request="const modal = bootstrap.Modal.getInstance(document.getElementById('candidateviewModal')); if (modal) { modal.hide(); }">
|
||||
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'offer' 'Accepted' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-check me-1"></i> {% trans "Accepted" %}
|
||||
</a>
|
||||
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'offer' 'Rejected' %}" class="btn btn-danger">
|
||||
<i class="fas fa-times me-1"></i> {% trans "Rejected" %}
|
||||
</a>
|
||||
</div>
|
||||
24
templates/includes/comment_form.html
Normal file
24
templates/includes/comment_form.html
Normal file
@ -0,0 +1,24 @@
|
||||
<div class="card mt-4">
|
||||
<div class="card-header text-primary-theme">
|
||||
<h5 class="card-title mb-0">Add Comment</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{% url 'add_meeting_comment' meeting.slug %}">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
{{ form.content }}
|
||||
{% if form.content.errors %}
|
||||
<div class="text-danger mt-1">
|
||||
{% for error in form.content.errors %}
|
||||
<small>{{ error }}</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Comment</button>
|
||||
{% if 'HX-Request' in request.headers %}
|
||||
<button type="button" class="btn btn-secondary" hx-get="{% url 'meeting_details' meeting.slug %}" hx-target="#comment-section">Cancel</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
56
templates/includes/comment_list.html
Normal file
56
templates/includes/comment_list.html
Normal file
@ -0,0 +1,56 @@
|
||||
<div id="comment-section">
|
||||
<div class="card mt-4">
|
||||
<div class="card-header text-primary-theme d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">Comments ({{ comments.count }})</h5>
|
||||
{% if 'HX-Request' in request.headers %}
|
||||
<button type="button" class="btn btn-light btn-sm" hx-get="{% url 'meeting_details' meeting.slug %}" hx-target="#comment-section">
|
||||
<i class="bi bi-x-lg"></i> Close
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if comments %}
|
||||
<div class="row">
|
||||
{% for comment in comments %}
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-start ">
|
||||
<div>
|
||||
<strong>{{ comment.author.get_full_name|default:comment.author.username }}</strong>
|
||||
{% if comment.author != user %}
|
||||
<span class="badge bg-secondary ms-2">Comment</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<small class="text-muted">{{ comment.created_at|date:"M d, Y P" }}</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">{{ comment.content|safe }}</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
{% if comment.author == user or user.is_staff %}
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-primary"
|
||||
hx-get="{% url 'edit_meeting_comment' meeting.slug comment.id %}"
|
||||
hx-target="#comment-section"
|
||||
title="Edit Comment">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
hx-get="{% url 'delete_meeting_comment' meeting.slug comment.id %}"
|
||||
hx-target="#comment-section"
|
||||
title="Delete Comment">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No comments yet. Be the first to comment!</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user