Compare commits
14 Commits
f502435088
...
ef952ab596
| Author | SHA1 | Date | |
|---|---|---|---|
| ef952ab596 | |||
| 2b2488f712 | |||
| d26c18fefd | |||
| 7a0bf3262d | |||
| cbf4630071 | |||
| 48f61f173f | |||
| 285d2aea18 | |||
| 9af34e2e74 | |||
| c5c7963df5 | |||
| 7f23cc18fb | |||
| 0483e3efc6 | |||
| 1dd340f890 | |||
| f28ab751ef | |||
| d5deb46ad2 |
54
.gitignore
vendored
54
.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
|
<<<<<<< HEAD
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
@ -57,3 +58,56 @@ static/
|
|||||||
# Deployment files
|
# Deployment files
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.zip
|
*.zip
|
||||||
|
db.sqlite3
|
||||||
|
=======
|
||||||
|
db.sqlite3
|
||||||
|
# Python
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/ # nocache: also caches module compiled version
|
||||||
|
*.py[co]
|
||||||
|
|
||||||
|
# CExtensions for Python
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
|
||||||
|
.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-debug.log
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
|
||||||
|
# Local settings
|
||||||
|
local_settings.py
|
||||||
|
|
||||||
|
# Database sqlite files:
|
||||||
|
# The base directory for relative paths in .gitignore
|
||||||
|
# is the directory where the .gitignore file is located.
|
||||||
|
|
||||||
|
# The following rules are applied in this order:
|
||||||
|
# 1. If the first byte of the pattern is `!`, then remove
|
||||||
|
# the file in the remaining pattern string from the index.
|
||||||
|
# 2. If not otherwise ignore the file specified by the remaining
|
||||||
|
# pattern string in step 1.
|
||||||
|
|
||||||
|
# If a rule in .gitignore ends with a directory separator (i.e. `/`
|
||||||
|
# character), then remove the file in the remaining pattern string and all
|
||||||
|
# files with the same name in subdirectories.
|
||||||
|
>>>>>>> 29790ab (add external integration)
|
||||||
|
|||||||
Binary file not shown.
BIN
NorahUniversity/__pycache__/celery.cpython-313.pyc
Normal file
BIN
NorahUniversity/__pycache__/celery.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -51,7 +51,8 @@ INSTALLED_APPS = [
|
|||||||
'crispy_bootstrap5',
|
'crispy_bootstrap5',
|
||||||
'django_extensions',
|
'django_extensions',
|
||||||
'template_partials',
|
'template_partials',
|
||||||
'django_countries'
|
'django_countries',
|
||||||
|
'django_celery_results'
|
||||||
]
|
]
|
||||||
|
|
||||||
SITE_ID = 1
|
SITE_ID = 1
|
||||||
@ -203,12 +204,12 @@ FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
|
|||||||
|
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|
||||||
|
CELERY_BROKER_URL = 'redis://localhost:6379/0' # Or your message broker URL
|
||||||
# Celery + Redis for long running background i will be using it
|
CELERY_RESULT_BACKEND = 'django-db' # If using django-celery-results
|
||||||
CELERY_BROKER_URL = 'redis://127.0.0.1:6379/0'
|
CELERY_ACCEPT_CONTENT = ['application/json']
|
||||||
CELERY_RESULT_BACKEND = 'redis://127.0.0.1:6379/0'
|
CELERY_TASK_SERIALIZER = 'json'
|
||||||
|
CELERY_RESULT_SERIALIZER = 'json'
|
||||||
|
CELERY_TIMEZONE = 'UTC'
|
||||||
|
|
||||||
|
|
||||||
LINKEDIN_CLIENT_ID = '867jwsiyem1504'
|
LINKEDIN_CLIENT_ID = '867jwsiyem1504'
|
||||||
|
|||||||
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
467
recruitment/ERP_INTEGRATION_GUIDE.md
Normal file
467
recruitment/ERP_INTEGRATION_GUIDE.md
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
# ERP Integration Guide for ATS
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
1. [Introduction](#introduction)
|
||||||
|
2. [Setup and Configuration](#setup-and-configuration)
|
||||||
|
3. [API Documentation](#api-documentation)
|
||||||
|
4. [Creating Job Postings](#creating-job-postings)
|
||||||
|
5. [Updating Job Postings](#updating-job-postings)
|
||||||
|
6. [Monitoring and Troubleshooting](#monitoring-and-troubleshooting)
|
||||||
|
7. [Best Practices](#best-practices)
|
||||||
|
8. [Appendix](#appendix)
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
This guide explains how to integrate your ERP system with the Applicant Tracking System (ATS) for seamless job posting management. The integration allows you to automatically create and update job postings in the ATS directly from your ERP system.
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
- **Automated Job Management**: Create and update job postings without manual data entry
|
||||||
|
- **Data Consistency**: Ensure job information is synchronized across systems
|
||||||
|
- **Audit Trail**: Complete logging of all integration activities
|
||||||
|
- **Security**: Secure API-based communication with authentication
|
||||||
|
|
||||||
|
### System Requirements
|
||||||
|
- ERP system with HTTP request capabilities
|
||||||
|
- HTTPS support (required for production)
|
||||||
|
- JSON data format support
|
||||||
|
- Access to ATS base URL (e.g., https://your-ats-domain.com/recruitment/)
|
||||||
|
|
||||||
|
## Setup and Configuration
|
||||||
|
|
||||||
|
### 1. Configure Source in ATS Admin
|
||||||
|
|
||||||
|
1. Log in to the ATS Django admin interface
|
||||||
|
2. Navigate to **Recruitment > Sources**
|
||||||
|
3. Click **Add Source** to create a new integration source
|
||||||
|
4. Fill in the following information:
|
||||||
|
|
||||||
|
#### Basic Information
|
||||||
|
- **Name**: Unique identifier for your ERP system (e.g., "Main_ERP")
|
||||||
|
- **Source Type**: "ERP"
|
||||||
|
- **Description**: Brief description of the integration
|
||||||
|
|
||||||
|
#### Technical Details
|
||||||
|
- **IP Address**: Your ERP system's IP address (for logging)
|
||||||
|
- **API Key**: Generate a secure API key for authentication
|
||||||
|
- **API Secret**: Generate a secure API secret for authentication
|
||||||
|
- **Trusted IPs**: Comma-separated list of IP addresses allowed to make requests (e.g., "192.168.1.100,10.0.0.50")
|
||||||
|
|
||||||
|
#### Integration Status
|
||||||
|
- **Is Active**: Enable the integration
|
||||||
|
- **Integration Version**: Your ERP integration version (e.g., "1.0")
|
||||||
|
- **Sync Status**: Set to "IDLE" initially
|
||||||
|
|
||||||
|
5. Save the source configuration
|
||||||
|
|
||||||
|
### 2. Test the Connection
|
||||||
|
|
||||||
|
Use the health check endpoint to verify connectivity:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET https://your-ats-domain.com/recruitment/integration/erp/health/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": "2025-10-06T14:30:00Z",
|
||||||
|
"services": {
|
||||||
|
"erp_integration": "available",
|
||||||
|
"database": "connected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
### Base URL
|
||||||
|
```
|
||||||
|
https://your-ats-domain.com/recruitment/integration/erp/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Include your API key in either of these ways:
|
||||||
|
- **Header**: `X-API-Key: your_api_key_here`
|
||||||
|
- **Query Parameter**: `?api_key=your_api_key_here`
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/` | GET | Health check and API info |
|
||||||
|
| `/create-job/` | POST | Create a new job posting |
|
||||||
|
| `/update-job/` | POST | Update an existing job posting |
|
||||||
|
| `/health/` | GET | Health check |
|
||||||
|
|
||||||
|
### Response Format
|
||||||
|
|
||||||
|
All responses follow this structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success" | "error",
|
||||||
|
"message": "Human-readable message",
|
||||||
|
"data": { ... }, // Present for successful requests
|
||||||
|
"processing_time": 0.45 // In seconds
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating Job Postings
|
||||||
|
|
||||||
|
### Step-by-Step Guide
|
||||||
|
|
||||||
|
1. Prepare your job data in JSON format
|
||||||
|
2. Send a POST request to `/create-job/`
|
||||||
|
3. Verify the response and check for errors
|
||||||
|
4. Monitor the integration logs for confirmation
|
||||||
|
|
||||||
|
### Request Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "create_job",
|
||||||
|
"source_name": "Main_ERP",
|
||||||
|
"title": "Senior Software Engineer",
|
||||||
|
"department": "Information Technology",
|
||||||
|
"job_type": "full-time",
|
||||||
|
"workplace_type": "hybrid",
|
||||||
|
"location_city": "Riyadh",
|
||||||
|
"location_state": "Riyadh",
|
||||||
|
"location_country": "Saudi Arabia",
|
||||||
|
"description": "We are looking for an experienced software engineer...",
|
||||||
|
"qualifications": "Bachelor's degree in Computer Science...",
|
||||||
|
"salary_range": "SAR 18,000 - 25,000",
|
||||||
|
"benefits": "Health insurance, Annual leave...",
|
||||||
|
"application_url": "https://careers.yourcompany.com/job/12345",
|
||||||
|
"application_deadline": "2025-12-31",
|
||||||
|
"application_instructions": "Submit your resume and cover letter...",
|
||||||
|
"auto_publish": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `action` | String | Must be "create_job" |
|
||||||
|
| `source_name` | String | Name of the configured source |
|
||||||
|
| `title` | String | Job title |
|
||||||
|
| `application_url` | String | URL where candidates apply |
|
||||||
|
|
||||||
|
### Optional Fields
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `department` | String | - | Department/Division |
|
||||||
|
| `job_type` | String | "FULL_TIME" | FULL_TIME, PART_TIME, CONTRACT, INTERNSHIP, FACULTY, TEMPORARY |
|
||||||
|
| `workplace_type` | String | "ON_SITE" | ON_SITE, REMOTE, HYBRID |
|
||||||
|
| `location_city` | String | - | City |
|
||||||
|
| `location_state` | String | - | State/Province |
|
||||||
|
| `location_country` | String | "United States" | Country |
|
||||||
|
| `description` | String | - | Job description |
|
||||||
|
| `qualifications` | String | - | Required qualifications |
|
||||||
|
| `salary_range` | String | - | Salary information |
|
||||||
|
| `benefits` | String | - | Benefits offered |
|
||||||
|
| `application_deadline` | String | - | Application deadline (YYYY-MM-DD) |
|
||||||
|
| `application_instructions` | String | - | Special instructions for applicants |
|
||||||
|
| `auto_publish` | Boolean | false | Automatically publish the job |
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://your-ats-domain.com/recruitment/integration/erp/create-job/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-Key: your_api_key_here" \
|
||||||
|
-d '{
|
||||||
|
"action": "create_job",
|
||||||
|
"source_name": "Main_ERP",
|
||||||
|
"title": "Senior Software Engineer",
|
||||||
|
"department": "Information Technology",
|
||||||
|
"job_type": "full-time",
|
||||||
|
"workplace_type": "hybrid",
|
||||||
|
"location_city": "Riyadh",
|
||||||
|
"location_country": "Saudi Arabia",
|
||||||
|
"description": "We are looking for an experienced software engineer...",
|
||||||
|
"application_url": "https://careers.yourcompany.com/job/12345",
|
||||||
|
"auto_publish": true
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Job created successfully",
|
||||||
|
"data": {
|
||||||
|
"job_id": "KAAUH-2025-0001",
|
||||||
|
"title": "Senior Software Engineer",
|
||||||
|
"status": "PUBLISHED",
|
||||||
|
"created_at": "2025-10-06T14:30:00Z"
|
||||||
|
},
|
||||||
|
"processing_time": 0.32
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating Job Postings
|
||||||
|
|
||||||
|
### Step-by-Step Guide
|
||||||
|
|
||||||
|
1. Obtain the internal job ID from the ATS (from creation response or job listing)
|
||||||
|
2. Prepare your update data in JSON format
|
||||||
|
3. Send a POST request to `/update-job/`
|
||||||
|
4. Verify the response and check for errors
|
||||||
|
|
||||||
|
### Request Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "update_job",
|
||||||
|
"source_name": "Main_ERP",
|
||||||
|
"job_id": "KAAUH-2025-0001",
|
||||||
|
"title": "Senior Software Engineer (Updated)",
|
||||||
|
"department": "Information Technology",
|
||||||
|
"salary_range": "SAR 20,000 - 28,000",
|
||||||
|
"status": "PUBLISHED"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `action` | String | Must be "update_job" |
|
||||||
|
| `source_name` | String | Name of the configured source |
|
||||||
|
| `job_id` | String | Internal job ID from ATS |
|
||||||
|
|
||||||
|
### Optional Fields
|
||||||
|
|
||||||
|
All fields from the create job are available for update, except:
|
||||||
|
- `auto_publish` (not applicable for updates)
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://your-ats-domain.com/recruitment/integration/erp/update-job/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-Key: your_api_key_here" \
|
||||||
|
-d '{
|
||||||
|
"action": "update_job",
|
||||||
|
"source_name": "Main_ERP",
|
||||||
|
"job_id": "KAAUH-2025-0001",
|
||||||
|
"salary_range": "SAR 20,000 - 28,000",
|
||||||
|
"application_deadline": "2026-01-15"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Job updated successfully",
|
||||||
|
"data": {
|
||||||
|
"job_id": "KAAUH-2025-0001",
|
||||||
|
"title": "Senior Software Engineer",
|
||||||
|
"status": "PUBLISHED",
|
||||||
|
"updated_at": "2025-10-06T14:35:00Z"
|
||||||
|
},
|
||||||
|
"processing_time": 0.28
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring and Troubleshooting
|
||||||
|
|
||||||
|
### Viewing Integration Logs
|
||||||
|
|
||||||
|
1. Log in to the ATS Django admin interface
|
||||||
|
2. Navigate to **Recruitment > Integration Logs**
|
||||||
|
3. Use the following filters to monitor activity:
|
||||||
|
- **Source**: Filter by your ERP system
|
||||||
|
- **Action**: Filter by REQUEST/RESPONSE/ERROR
|
||||||
|
- **Status Code**: Filter by HTTP status codes
|
||||||
|
- **Date Range**: View logs for specific time periods
|
||||||
|
|
||||||
|
### Common Error Codes
|
||||||
|
|
||||||
|
| Status Code | Description | Solution |
|
||||||
|
|-------------|-------------|----------|
|
||||||
|
| 400 Bad Request | Invalid request data | Check required fields and data types |
|
||||||
|
| 401 Unauthorized | Invalid API key | Verify API key is correct and active |
|
||||||
|
| 403 Forbidden | IP not trusted | Add your ERP IP to trusted IPs list |
|
||||||
|
| 404 Not Found | Source not found | Verify source name or ID is correct |
|
||||||
|
| 409 Conflict | Job already exists | Check if job with same title already exists |
|
||||||
|
| 500 Internal Error | Server error | Contact ATS support |
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
Regularly test the connection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET https://your-ats-domain.com/recruitment/integration/erp/health/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Monitoring
|
||||||
|
|
||||||
|
Monitor response times and processing durations in the integration logs. If processing times exceed 2 seconds, investigate performance issues.
|
||||||
|
|
||||||
|
### Troubleshooting Steps
|
||||||
|
|
||||||
|
1. **Check Authentication**
|
||||||
|
- Verify API key is correct
|
||||||
|
- Ensure source is active
|
||||||
|
|
||||||
|
2. **Check IP Whitelisting**
|
||||||
|
- Verify your ERP IP is in the trusted list
|
||||||
|
|
||||||
|
3. **Validate Request Data**
|
||||||
|
- Check required fields are present
|
||||||
|
- Verify data types are correct
|
||||||
|
- Ensure URLs are valid
|
||||||
|
|
||||||
|
4. **Check Logs**
|
||||||
|
- View integration logs for error details
|
||||||
|
- Check request/response data in logs
|
||||||
|
|
||||||
|
5. **Test with Minimal Data**
|
||||||
|
- Send a request with only required fields
|
||||||
|
- Gradually add optional fields
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Use HTTPS for all requests
|
||||||
|
- Rotate API keys regularly
|
||||||
|
- Store API keys securely in your ERP system
|
||||||
|
- Limit trusted IPs to only necessary systems
|
||||||
|
|
||||||
|
### Data Validation
|
||||||
|
- Validate all data before sending
|
||||||
|
- Use consistent date formats (YYYY-MM-DD)
|
||||||
|
- Sanitize special characters in text fields
|
||||||
|
- Test with sample data before production
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Implement retry logic for transient errors
|
||||||
|
- Log all integration attempts locally
|
||||||
|
- Set up alerts for frequent failures
|
||||||
|
- Have a manual fallback process
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
- Regularly review integration logs
|
||||||
|
- Monitor API performance metrics
|
||||||
|
- Keep API keys and credentials updated
|
||||||
|
- Schedule regular health checks
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Batch multiple job operations when possible
|
||||||
|
- Avoid sending unnecessary data
|
||||||
|
- Use compression for large requests
|
||||||
|
- Monitor response times
|
||||||
|
|
||||||
|
## Appendix
|
||||||
|
|
||||||
|
### Complete Field Reference
|
||||||
|
|
||||||
|
#### Job Types
|
||||||
|
- `FULL_TIME`: Full-time position
|
||||||
|
- `PART_TIME`: Part-time position
|
||||||
|
- `CONTRACT`: Contract position
|
||||||
|
- `INTERNSHIP`: Internship position
|
||||||
|
- `FACULTY`: Faculty/academic position
|
||||||
|
- `TEMPORARY`: Temporary position
|
||||||
|
|
||||||
|
#### Workplace Types
|
||||||
|
- `ON_SITE`: On-site work
|
||||||
|
- `REMOTE`: Remote work
|
||||||
|
- `HYBRID`: Hybrid (combination of on-site and remote)
|
||||||
|
|
||||||
|
#### Status Values
|
||||||
|
- `DRAFT`: Job is in draft status
|
||||||
|
- `PUBLISHED`: Job is published and active
|
||||||
|
- `CLOSED`: Job is closed to applications
|
||||||
|
- `ARCHIVED`: Job is archived
|
||||||
|
|
||||||
|
### Error Code Dictionary
|
||||||
|
|
||||||
|
| Code | Error | Description |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| `MISSING_FIELD` | Required field is missing | Check all required fields are provided |
|
||||||
|
| `INVALID_TYPE` | Invalid data type | Verify field data types match requirements |
|
||||||
|
| `INVALID_URL` | Invalid application URL | Ensure URL is properly formatted |
|
||||||
|
| `JOB_EXISTS` | Job already exists | Use update action instead of create |
|
||||||
|
| `INVALID_SOURCE` | Source not found | Verify source name or ID |
|
||||||
|
| `IP_NOT_ALLOWED` | IP not trusted | Add IP to trusted list |
|
||||||
|
|
||||||
|
### Sample Scripts
|
||||||
|
|
||||||
|
#### Python Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
ATS_BASE_URL = "https://your-ats-domain.com/recruitment/integration/erp/"
|
||||||
|
API_KEY = "your_api_key_here"
|
||||||
|
SOURCE_NAME = "Main_ERP"
|
||||||
|
|
||||||
|
# Create job
|
||||||
|
def create_job(job_data):
|
||||||
|
url = f"{ATS_BASE_URL}create-job/"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": API_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"action": "create_job",
|
||||||
|
"source_name": SOURCE_NAME,
|
||||||
|
**job_data
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers, json=payload)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
# Update job
|
||||||
|
def update_job(job_id, update_data):
|
||||||
|
url = f"{ATS_BASE_URL}update-job/"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": API_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"action": "update_job",
|
||||||
|
"source_name": SOURCE_NAME,
|
||||||
|
"job_id": job_id,
|
||||||
|
**update_data
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers, json=payload)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
# Example usage
|
||||||
|
job_data = {
|
||||||
|
"title": "Software Engineer",
|
||||||
|
"department": "IT",
|
||||||
|
"application_url": "https://careers.example.com/job/123"
|
||||||
|
}
|
||||||
|
|
||||||
|
result = create_job(job_data)
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contact Information
|
||||||
|
|
||||||
|
For technical support:
|
||||||
|
- **Email**: support@ats-domain.com
|
||||||
|
- **Phone**: +966 50 123 4567
|
||||||
|
- **Support Hours**: Sunday - Thursday, 8:00 AM - 4:00 PM (GMT+3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last Updated: October 6, 2025*
|
||||||
|
*Version: 1.0*
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
recruitment/__pycache__/erp_integration_service.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/erp_integration_service.cpython-313.pyc
Normal file
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.
BIN
recruitment/__pycache__/signals.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/signals.cpython-313.pyc
Normal file
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.
BIN
recruitment/__pycache__/views_integration.cpython-313.pyc
Normal file
BIN
recruitment/__pycache__/views_integration.cpython-313.pyc
Normal file
Binary file not shown.
@ -1,10 +1,264 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from .models import (
|
||||||
|
JobPosting, Candidate, TrainingMaterial, ZoomMeeting,
|
||||||
|
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
|
||||||
|
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule
|
||||||
|
)
|
||||||
|
|
||||||
from .models import FormTemplate, FormStage, FormField,FieldResponse,FormSubmission
|
class FormFieldInline(admin.TabularInline):
|
||||||
|
model = FormField
|
||||||
|
extra = 1
|
||||||
|
ordering = ('order',)
|
||||||
|
|
||||||
admin.site.register(FormTemplate)
|
class FormStageInline(admin.TabularInline):
|
||||||
|
model = FormStage
|
||||||
|
extra = 1
|
||||||
|
ordering = ('order',)
|
||||||
|
inlines = [FormFieldInline]
|
||||||
|
|
||||||
|
@admin.register(Source)
|
||||||
|
class SourceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'source_type', 'ip_address', 'is_active', 'sync_status', 'created_at']
|
||||||
|
list_filter = ['source_type', 'is_active', 'sync_status', 'created_at']
|
||||||
|
search_fields = ['name', 'description']
|
||||||
|
readonly_fields = ['created_at', 'last_sync_at']
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('name', 'source_type', 'description')
|
||||||
|
}),
|
||||||
|
('Technical Details', {
|
||||||
|
'fields': ('ip_address', 'api_key', 'api_secret', 'trusted_ips')
|
||||||
|
}),
|
||||||
|
('Integration Status', {
|
||||||
|
'fields': ('is_active', 'integration_version', 'sync_status', 'last_sync_at', 'created_at')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
save_on_top = True
|
||||||
|
actions = ['activate_sources', 'deactivate_sources']
|
||||||
|
|
||||||
|
def activate_sources(self, request, queryset):
|
||||||
|
updated = queryset.update(is_active=True)
|
||||||
|
self.message_user(request, f'{updated} sources activated.')
|
||||||
|
activate_sources.short_description = 'Activate selected sources'
|
||||||
|
|
||||||
|
def deactivate_sources(self, request, queryset):
|
||||||
|
updated = queryset.update(is_active=False)
|
||||||
|
self.message_user(request, f'{updated} sources deactivated.')
|
||||||
|
deactivate_sources.short_description = 'Deactivate selected sources'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(IntegrationLog)
|
||||||
|
class IntegrationLogAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['source', 'action', 'endpoint', 'status_code', 'ip_address', 'created_at']
|
||||||
|
list_filter = ['action', 'status_code', 'source', 'created_at']
|
||||||
|
search_fields = ['source__name', 'endpoint', 'error_message']
|
||||||
|
readonly_fields = ['source', 'action', 'endpoint', 'method', 'request_data',
|
||||||
|
'response_data', 'status_code', 'error_message', 'ip_address',
|
||||||
|
'user_agent', 'processing_time', 'created_at']
|
||||||
|
fieldsets = (
|
||||||
|
('Request Information', {
|
||||||
|
'fields': ('source', 'action', 'endpoint', 'method', 'ip_address', 'user_agent')
|
||||||
|
}),
|
||||||
|
('Data', {
|
||||||
|
'fields': ('request_data', 'response_data')
|
||||||
|
}),
|
||||||
|
('Results', {
|
||||||
|
'fields': ('status_code', 'error_message', 'processing_time', 'created_at')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
save_on_top = False
|
||||||
|
date_hierarchy = 'created_at'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(HiringAgency)
|
||||||
|
class HiringAgencyAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'contact_person', 'email', 'phone', 'country', 'created_at']
|
||||||
|
list_filter = ['country', 'created_at']
|
||||||
|
search_fields = ['name', 'contact_person', 'email', 'phone', 'notes']
|
||||||
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('name', 'contact_person', 'email', 'phone', 'website')
|
||||||
|
}),
|
||||||
|
('Location Details', {
|
||||||
|
'fields': ('country', 'address')
|
||||||
|
}),
|
||||||
|
('Additional Information', {
|
||||||
|
'fields': ('notes', 'created_at', 'updated_at')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
save_on_top = True
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(JobPosting)
|
||||||
|
class JobPostingAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['internal_job_id', 'title', 'department', 'job_type', 'status', 'posted_to_linkedin', 'created_at']
|
||||||
|
list_filter = ['job_type', 'status', 'workplace_type', 'source', 'created_at']
|
||||||
|
search_fields = ['title', 'department', 'internal_job_id']
|
||||||
|
readonly_fields = ['internal_job_id', 'created_at', 'updated_at']
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('title', 'department', 'job_type', 'workplace_type', 'status')
|
||||||
|
}),
|
||||||
|
('Location', {
|
||||||
|
'fields': ('location_city', 'location_state', 'location_country')
|
||||||
|
}),
|
||||||
|
('Job Details', {
|
||||||
|
'fields': ('description', 'qualifications', 'salary_range', 'benefits')
|
||||||
|
}),
|
||||||
|
('Application Information', {
|
||||||
|
'fields': ('application_url', 'application_deadline', 'application_instructions')
|
||||||
|
}),
|
||||||
|
('Internal Tracking', {
|
||||||
|
'fields': ('internal_job_id', 'created_by', 'created_at', 'updated_at')
|
||||||
|
}),
|
||||||
|
('Integration', {
|
||||||
|
'fields': ('source', 'open_positions', 'position_number', 'reporting_to', 'start_date')
|
||||||
|
}),
|
||||||
|
('LinkedIn Integration', {
|
||||||
|
'fields': ('posted_to_linkedin', 'linkedin_post_id', 'linkedin_post_url', 'linkedin_posted_at')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
save_on_top = True
|
||||||
|
actions = ['make_published', 'make_draft', 'mark_as_closed']
|
||||||
|
|
||||||
|
def make_published(self, request, queryset):
|
||||||
|
updated = queryset.update(status='PUBLISHED')
|
||||||
|
self.message_user(request, f'{updated} job postings marked as published.')
|
||||||
|
make_published.short_description = 'Mark selected jobs as published'
|
||||||
|
|
||||||
|
def make_draft(self, request, queryset):
|
||||||
|
updated = queryset.update(status='DRAFT')
|
||||||
|
self.message_user(request, f'{updated} job postings marked as draft.')
|
||||||
|
make_draft.short_description = 'Mark selected jobs as draft'
|
||||||
|
|
||||||
|
def mark_as_closed(self, request, queryset):
|
||||||
|
updated = queryset.update(status='CLOSED')
|
||||||
|
self.message_user(request, f'{updated} job postings marked as closed.')
|
||||||
|
mark_as_closed.short_description = 'Mark selected jobs as closed'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Candidate)
|
||||||
|
class CandidateAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['full_name', 'job', 'email', 'phone', 'stage', 'applied', 'created_at']
|
||||||
|
list_filter = ['stage', 'applied', 'created_at', 'job__department']
|
||||||
|
search_fields = ['first_name', 'last_name', 'email', 'phone']
|
||||||
|
readonly_fields = ['slug', 'created_at', 'updated_at']
|
||||||
|
fieldsets = (
|
||||||
|
('Personal Information', {
|
||||||
|
'fields': ('first_name', 'last_name', 'email', 'phone', 'resume')
|
||||||
|
}),
|
||||||
|
('Application Details', {
|
||||||
|
'fields': ('job', 'applied', 'stage')
|
||||||
|
}),
|
||||||
|
('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')
|
||||||
|
}),
|
||||||
|
('Additional Information', {
|
||||||
|
'fields': ('submitted_by_agency', 'created_at', 'updated_at')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
save_on_top = True
|
||||||
|
actions = ['mark_as_applied', 'mark_as_not_applied']
|
||||||
|
|
||||||
|
def mark_as_applied(self, request, queryset):
|
||||||
|
updated = queryset.update(applied=True)
|
||||||
|
self.message_user(request, f'{updated} candidates marked as applied.')
|
||||||
|
mark_as_applied.short_description = 'Mark selected candidates as applied'
|
||||||
|
|
||||||
|
def mark_as_not_applied(self, request, queryset):
|
||||||
|
updated = queryset.update(applied=False)
|
||||||
|
self.message_user(request, f'{updated} candidates marked as not applied.')
|
||||||
|
mark_as_not_applied.short_description = 'Mark selected candidates as not applied'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TrainingMaterial)
|
||||||
|
class TrainingMaterialAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['title', 'created_by', 'created_at']
|
||||||
|
list_filter = ['created_at']
|
||||||
|
search_fields = ['title', 'content']
|
||||||
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('title', 'content')
|
||||||
|
}),
|
||||||
|
('Media', {
|
||||||
|
'fields': ('video_link', 'file')
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('created_by', 'created_at', 'updated_at')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
save_on_top = True
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ZoomMeeting)
|
||||||
|
class ZoomMeetingAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
|
||||||
|
list_filter = ['timezone', 'created_at']
|
||||||
|
search_fields = ['topic', 'meeting_id']
|
||||||
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
fieldsets = (
|
||||||
|
('Meeting Details', {
|
||||||
|
'fields': ('topic', 'meeting_id', 'start_time', 'duration', 'timezone')
|
||||||
|
}),
|
||||||
|
('Meeting Settings', {
|
||||||
|
'fields': ('participant_video', 'join_before_host', 'mute_upon_entry', 'waiting_room')
|
||||||
|
}),
|
||||||
|
('Access', {
|
||||||
|
'fields': ('join_url',)
|
||||||
|
}),
|
||||||
|
('System Response', {
|
||||||
|
'fields': ('zoom_gateway_response', 'created_at', 'updated_at')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
save_on_top = True
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FormTemplate)
|
||||||
|
class FormTemplateAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'created_by', 'created_at', 'is_active']
|
||||||
|
list_filter = ['is_active', 'created_at']
|
||||||
|
search_fields = ['name', 'description']
|
||||||
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
inlines = [FormStageInline]
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('name', 'description', 'created_by', 'is_active')
|
||||||
|
}),
|
||||||
|
('Timeline', {
|
||||||
|
'fields': ('created_at', 'updated_at')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
save_on_top = True
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FormSubmission)
|
||||||
|
class FormSubmissionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['template', 'applicant_name', 'submitted_at', 'submitted_by']
|
||||||
|
list_filter = ['submitted_at', 'template']
|
||||||
|
search_fields = ['applicant_name', 'applicant_email']
|
||||||
|
readonly_fields = ['submitted_at']
|
||||||
|
fieldsets = (
|
||||||
|
('Submission Information', {
|
||||||
|
'fields': ('template', 'submitted_by', 'submitted_at')
|
||||||
|
}),
|
||||||
|
('Applicant Information', {
|
||||||
|
'fields': ('applicant_name', 'applicant_email')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
save_on_top = True
|
||||||
|
|
||||||
|
|
||||||
|
# Register other models
|
||||||
admin.site.register(FormStage)
|
admin.site.register(FormStage)
|
||||||
admin.site.register(FormField)
|
admin.site.register(FormField)
|
||||||
admin.site.register(FormSubmission)
|
|
||||||
admin.site.register(FieldResponse)
|
admin.site.register(FieldResponse)
|
||||||
|
admin.site.register(InterviewSchedule)
|
||||||
|
# admin.site.register(HiringAgency)
|
||||||
|
|||||||
17
recruitment/decorators.py
Normal file
17
recruitment/decorators.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from functools import wraps
|
||||||
|
from datetime import date
|
||||||
|
from django.shortcuts import redirect, get_object_or_404
|
||||||
|
from django.http import HttpResponseNotFound
|
||||||
|
|
||||||
|
def job_not_expired(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def _wrapped_view(request, job_id, *args, **kwargs):
|
||||||
|
|
||||||
|
from .models import JobPosting
|
||||||
|
job = get_object_or_404(JobPosting, pk=job_id)
|
||||||
|
|
||||||
|
if job.expiration_date and job.application_deadline< date.today():
|
||||||
|
return redirect('expired_job_page')
|
||||||
|
|
||||||
|
return view_func(request, job_id, *args, **kwargs)
|
||||||
|
return _wrapped_view
|
||||||
271
recruitment/erp_integration_service.py
Normal file
271
recruitment/erp_integration_service.py
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from .models import Source, JobPosting, IntegrationLog
|
||||||
|
from .serializers import JobPostingSerializer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ERPIntegrationService:
|
||||||
|
"""
|
||||||
|
Service to handle integration between external ERP system and ATS
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, source: Source):
|
||||||
|
self.source = source
|
||||||
|
self.logger = logging.getLogger(f'{__name__}.{source.name}')
|
||||||
|
|
||||||
|
def validate_request(self, request: HttpRequest) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate the incoming request from ERP system
|
||||||
|
Returns: (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
# Check if source is active
|
||||||
|
if not self.source.is_active:
|
||||||
|
return False, "Source is not active"
|
||||||
|
|
||||||
|
# Check if trusted IPs are configured and validate request IP
|
||||||
|
if self.source.trusted_ips:
|
||||||
|
client_ip = self.get_client_ip(request)
|
||||||
|
trusted_ips = [ip.strip() for ip in self.source.trusted_ips.split(',')]
|
||||||
|
|
||||||
|
if client_ip not in trusted_ips:
|
||||||
|
self.logger.warning(f"Request from untrusted IP: {client_ip}")
|
||||||
|
return False, f"Request from untrusted IP: {client_ip}"
|
||||||
|
|
||||||
|
# Check API key if provided
|
||||||
|
if self.source.api_key:
|
||||||
|
api_key = request.headers.get('X-API-Key') or request.GET.get('api_key')
|
||||||
|
if not api_key or api_key != self.source.api_key:
|
||||||
|
self.logger.warning("Invalid or missing API key")
|
||||||
|
return False, "Invalid or missing API key"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def log_integration_request(self, request: HttpRequest, action: str, **kwargs):
|
||||||
|
"""
|
||||||
|
Log the integration request/response
|
||||||
|
"""
|
||||||
|
IntegrationLog.objects.create(
|
||||||
|
source=self.source,
|
||||||
|
action=action,
|
||||||
|
endpoint=request.path,
|
||||||
|
method=request.method,
|
||||||
|
request_data=self.get_request_data(request),
|
||||||
|
ip_address=self.get_client_ip(request),
|
||||||
|
user_agent=request.META.get('HTTP_USER_AGENT', ''),
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_job_from_erp(self, request_data: Dict[str, Any]) -> tuple[Optional[JobPosting], str]:
|
||||||
|
"""
|
||||||
|
Create a JobPosting from ERP request data
|
||||||
|
Returns: (job, error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Map ERP fields to JobPosting fields
|
||||||
|
job_data = {
|
||||||
|
'title': request_data.get('title', '').strip(),
|
||||||
|
'department': request_data.get('department', '').strip(),
|
||||||
|
'job_type': self.map_job_type(request_data.get('job_type', 'FULL_TIME')),
|
||||||
|
'workplace_type': self.map_workplace_type(request_data.get('workplace_type', 'ON_SITE')),
|
||||||
|
'location_city': request_data.get('location_city', '').strip(),
|
||||||
|
'location_state': request_data.get('location_state', '').strip(),
|
||||||
|
'location_country': request_data.get('location_country', 'United States').strip(),
|
||||||
|
'description': request_data.get('description', '').strip(),
|
||||||
|
'qualifications': request_data.get('qualifications', '').strip(),
|
||||||
|
'salary_range': request_data.get('salary_range', '').strip(),
|
||||||
|
'benefits': request_data.get('benefits', '').strip(),
|
||||||
|
'application_url': request_data.get('application_url', '').strip(),
|
||||||
|
'application_deadline': self.parse_date(request_data.get('application_deadline')),
|
||||||
|
'application_instructions': request_data.get('application_instructions', '').strip(),
|
||||||
|
'created_by': f'ERP Integration: {self.source.name}',
|
||||||
|
'status': 'DRAFT' if request_data.get('auto_publish', False) else 'DRAFT',
|
||||||
|
'source': self.source
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if not job_data['title']:
|
||||||
|
return None, "Job title is required"
|
||||||
|
|
||||||
|
|
||||||
|
# Create the job
|
||||||
|
job = JobPosting(**job_data)
|
||||||
|
job.save()
|
||||||
|
|
||||||
|
self.logger.info(f"Created job {job.internal_job_id} from ERP integration")
|
||||||
|
return job, ""
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error creating job from ERP: {str(e)}"
|
||||||
|
self.logger.error(error_msg)
|
||||||
|
return None, error_msg
|
||||||
|
|
||||||
|
def update_job_from_erp(self, job_id: str, request_data: Dict[str, Any]) -> tuple[Optional[JobPosting], str]:
|
||||||
|
"""
|
||||||
|
Update an existing JobPosting from ERP request data
|
||||||
|
Returns: (job, error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
job = JobPosting.objects.get(internal_job_id=job_id)
|
||||||
|
|
||||||
|
# Update fields from ERP data
|
||||||
|
updatable_fields = [
|
||||||
|
'title', 'department', 'job_type', 'workplace_type',
|
||||||
|
'location_city', 'location_state', 'location_country',
|
||||||
|
'description', 'qualifications', 'salary_range', 'benefits',
|
||||||
|
'application_url', 'application_deadline', 'application_instructions',
|
||||||
|
'status'
|
||||||
|
]
|
||||||
|
|
||||||
|
for field in updatable_fields:
|
||||||
|
if field in request_data:
|
||||||
|
value = request_data[field]
|
||||||
|
|
||||||
|
# Special handling for date fields
|
||||||
|
if field == 'application_deadline':
|
||||||
|
value = self.parse_date(value)
|
||||||
|
|
||||||
|
setattr(job, field, value)
|
||||||
|
|
||||||
|
# Update source if provided
|
||||||
|
if 'source_id' in request_data:
|
||||||
|
try:
|
||||||
|
source = Source.objects.get(id=request_data['source_id'])
|
||||||
|
job.source = source
|
||||||
|
except Source.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
job.save()
|
||||||
|
|
||||||
|
self.logger.info(f"Updated job {job.internal_job_id} from ERP integration")
|
||||||
|
return job, ""
|
||||||
|
|
||||||
|
except JobPosting.DoesNotExist:
|
||||||
|
return None, f"Job with ID {job_id} not found"
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error updating job from ERP: {str(e)}"
|
||||||
|
self.logger.error(error_msg)
|
||||||
|
return None, error_msg
|
||||||
|
|
||||||
|
def validate_erp_data(self, data: Dict[str, Any]) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate ERP request data structure
|
||||||
|
Returns: (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
required_fields = ['title']
|
||||||
|
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in data or not data[field]:
|
||||||
|
return False, f"Required field '{field}' is missing or empty"
|
||||||
|
|
||||||
|
# Validate URL format
|
||||||
|
if data.get('application_url'):
|
||||||
|
from django.core.validators import URLValidator
|
||||||
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
|
|
||||||
|
try:
|
||||||
|
URLValidator()(data['application_url'])
|
||||||
|
except DjangoValidationError:
|
||||||
|
return False, "Invalid application URL format"
|
||||||
|
|
||||||
|
# Validate job type
|
||||||
|
if 'job_type' in data and data['job_type']:
|
||||||
|
valid_job_types = dict(JobPosting.JOB_TYPES)
|
||||||
|
if data['job_type'] not in valid_job_types:
|
||||||
|
return False, f"Invalid job type: {data['job_type']}"
|
||||||
|
|
||||||
|
# Validate workplace type
|
||||||
|
if 'workplace_type' in data and data['workplace_type']:
|
||||||
|
valid_workplace_types = dict(JobPosting.WORKPLACE_TYPES)
|
||||||
|
if data['workplace_type'] not in valid_workplace_types:
|
||||||
|
return False, f"Invalid workplace type: {data['workplace_type']}"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
# Helper methods
|
||||||
|
def get_client_ip(self, request: HttpRequest) -> str:
|
||||||
|
"""Get the client IP address from request"""
|
||||||
|
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||||
|
if x_forwarded_for:
|
||||||
|
ip = x_forwarded_for.split(',')[0]
|
||||||
|
else:
|
||||||
|
ip = request.META.get('REMOTE_ADDR')
|
||||||
|
return ip
|
||||||
|
|
||||||
|
def get_request_data(self, request: HttpRequest) -> Dict[str, Any]:
|
||||||
|
"""Get request data from request object"""
|
||||||
|
if request.method == 'GET':
|
||||||
|
return dict(request.GET)
|
||||||
|
elif request.method in ['POST', 'PUT', 'PATCH']:
|
||||||
|
try:
|
||||||
|
if request.content_type == 'application/json':
|
||||||
|
return json.loads(request.body.decode('utf-8'))
|
||||||
|
else:
|
||||||
|
return dict(request.POST)
|
||||||
|
except:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def parse_date(self, date_str: str) -> Optional[datetime.date]:
|
||||||
|
"""Parse date string from ERP"""
|
||||||
|
if not date_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try different date formats
|
||||||
|
date_formats = [
|
||||||
|
'%Y-%m-%d',
|
||||||
|
'%m/%d/%Y',
|
||||||
|
'%d/%m/%Y',
|
||||||
|
'%Y-%m-%d %H:%M:%S',
|
||||||
|
'%m/%d/%Y %H:%M:%S',
|
||||||
|
'%d/%m/%Y %H:%M:%S'
|
||||||
|
]
|
||||||
|
|
||||||
|
for fmt in date_formats:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(date_str, fmt)
|
||||||
|
if fmt.endswith('%H:%M:%S'):
|
||||||
|
return dt.date()
|
||||||
|
return dt.date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If no format matches, try to parse with dateutil
|
||||||
|
from dateutil import parser
|
||||||
|
dt = parser.parse(date_str)
|
||||||
|
return dt.date()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Could not parse date '{date_str}': {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def map_job_type(self, erp_job_type: str) -> str:
|
||||||
|
"""Map ERP job type to ATS job type"""
|
||||||
|
mapping = {
|
||||||
|
'full-time': 'FULL_TIME',
|
||||||
|
'part-time': 'PART_TIME',
|
||||||
|
'contract': 'CONTRACT',
|
||||||
|
'internship': 'INTERNSHIP',
|
||||||
|
'faculty': 'FACULTY',
|
||||||
|
'temporary': 'TEMPORARY',
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping.get(erp_job_type.lower(), 'FULL_TIME')
|
||||||
|
|
||||||
|
def map_workplace_type(self, erp_workplace_type: str) -> str:
|
||||||
|
"""Map ERP workplace type to ATS workplace type"""
|
||||||
|
mapping = {
|
||||||
|
'onsite': 'ON_SITE',
|
||||||
|
'on-site': 'ON_SITE',
|
||||||
|
'remote': 'REMOTE',
|
||||||
|
'hybrid': 'HYBRID',
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping.get(erp_workplace_type.lower(), 'ON_SITE')
|
||||||
@ -4,7 +4,7 @@ from crispy_forms.helper import FormHelper
|
|||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from crispy_forms.layout import Layout, Submit, HTML, Div, Field
|
from crispy_forms.layout import Layout, Submit, HTML, Div, Field
|
||||||
from .models import ZoomMeeting, Candidate,TrainingMaterial,JobPosting
|
from .models import ZoomMeeting, Candidate,TrainingMaterial,JobPosting,FormTemplate,InterviewSchedule
|
||||||
|
|
||||||
class CandidateForm(forms.ModelForm):
|
class CandidateForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -366,3 +366,99 @@ class JobPostingForm(forms.ModelForm):
|
|||||||
# 'Job description is required for active jobs.')
|
# 'Job description is required for active jobs.')
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class FormTemplateForm(forms.ModelForm):
|
||||||
|
"""Form for creating form templates"""
|
||||||
|
class Meta:
|
||||||
|
model = FormTemplate
|
||||||
|
fields = ['job','name', 'description', 'is_active']
|
||||||
|
labels = {
|
||||||
|
'job': _('Job'),
|
||||||
|
'name': _('Template Name'),
|
||||||
|
'description': _('Description'),
|
||||||
|
'is_active': _('Active'),
|
||||||
|
}
|
||||||
|
widgets = {
|
||||||
|
'name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('Enter template name'),
|
||||||
|
'required': True
|
||||||
|
}),
|
||||||
|
'description': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': _('Enter template description (optional)')
|
||||||
|
}),
|
||||||
|
'is_active': forms.CheckboxInput(attrs={
|
||||||
|
'class': 'form-check-input'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_method = 'post'
|
||||||
|
self.helper.form_class = 'form-horizontal'
|
||||||
|
self.helper.label_class = 'col-md-3'
|
||||||
|
self.helper.field_class = 'col-md-9'
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
Field('job', css_class='form-control'),
|
||||||
|
Field('name', css_class='form-control'),
|
||||||
|
Field('description', css_class='form-control'),
|
||||||
|
Field('is_active', css_class='form-check-input'),
|
||||||
|
Submit('submit', _('Create Template'), css_class='btn btn-primary mt-3')
|
||||||
|
)
|
||||||
|
|
||||||
|
class InterviewScheduleForm(forms.ModelForm):
|
||||||
|
candidates = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=Candidate.objects.none(),
|
||||||
|
widget=forms.CheckboxSelectMultiple(attrs={'class': 'form-check'}),
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
working_days = forms.MultipleChoiceField(
|
||||||
|
choices=[
|
||||||
|
(0, 'Monday'),
|
||||||
|
(1, 'Tuesday'),
|
||||||
|
(2, 'Wednesday'),
|
||||||
|
(3, 'Thursday'),
|
||||||
|
(4, 'Friday'),
|
||||||
|
(5, 'Saturday'),
|
||||||
|
(6, 'Sunday'),
|
||||||
|
],
|
||||||
|
widget=forms.CheckboxSelectMultiple(attrs={'class': 'form-check'}),
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = InterviewSchedule
|
||||||
|
fields = [
|
||||||
|
'candidates', 'start_date', 'end_date', 'working_days',
|
||||||
|
'start_time', 'end_time', 'break_start_time', 'break_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'}),
|
||||||
|
'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'
|
||||||
|
)
|
||||||
|
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'
|
||||||
|
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]
|
||||||
@ -72,8 +72,6 @@ class LinkedInService:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def register_image_upload(self, person_urn):
|
def register_image_upload(self, person_urn):
|
||||||
"""Step 1: Register image upload with LinkedIn"""
|
"""Step 1: Register image upload with LinkedIn"""
|
||||||
url = "https://api.linkedin.com/v2/assets?action=registerUpload"
|
url = "https://api.linkedin.com/v2/assets?action=registerUpload"
|
||||||
@ -261,4 +259,3 @@ class LinkedInService:
|
|||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
16
recruitment/migrations/0020_delete_job.py
Normal file
16
recruitment/migrations/0020_delete_job.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-06 13:40
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0019_merge_20251006_1224'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Job',
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-06 14:10
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0020_delete_job'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='api_key',
|
||||||
|
field=models.CharField(blank=True, help_text='API key for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Key'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='api_secret',
|
||||||
|
field=models.CharField(blank=True, help_text='API secret for authentication (will be encrypted)', max_length=255, null=True, verbose_name='API Secret'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='description',
|
||||||
|
field=models.TextField(blank=True, help_text='A description of the source', verbose_name='Description'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='integration_version',
|
||||||
|
field=models.CharField(blank=True, help_text='Version of the integration protocol', max_length=50, verbose_name='Integration Version'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='ip_address',
|
||||||
|
field=models.GenericIPAddressField(blank=True, help_text='The IP address of the source', null=True, verbose_name='IP Address'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='is_active',
|
||||||
|
field=models.BooleanField(default=True, help_text='Whether this source is active for integration', verbose_name='Active'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='last_sync_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Timestamp of the last successful synchronization', null=True, verbose_name='Last Sync At'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='source_type',
|
||||||
|
field=models.CharField(default='erp', help_text='e.g., ATS, ERP ', max_length=100, verbose_name='Source Type'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='sync_status',
|
||||||
|
field=models.CharField(blank=True, choices=[('IDLE', 'Idle'), ('SYNCING', 'Syncing'), ('ERROR', 'Error'), ('DISABLED', 'Disabled')], default='IDLE', max_length=20, verbose_name='Sync Status'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='trusted_ips',
|
||||||
|
field=models.GenericIPAddressField(blank=True, help_text='Comma-separated list of trusted IP addresses', null=True, verbose_name='Trusted IP Addresses'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='IntegrationLog',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('action', models.CharField(choices=[('REQUEST', 'Request'), ('RESPONSE', 'Response'), ('ERROR', 'Error'), ('SYNC', 'Sync'), ('CREATE_JOB', 'Create Job'), ('UPDATE_JOB', 'Update Job')], max_length=20, verbose_name='Action')),
|
||||||
|
('endpoint', models.CharField(blank=True, max_length=255, verbose_name='Endpoint')),
|
||||||
|
('method', models.CharField(blank=True, max_length=10, verbose_name='HTTP Method')),
|
||||||
|
('request_data', models.JSONField(blank=True, null=True, verbose_name='Request Data')),
|
||||||
|
('response_data', models.JSONField(blank=True, null=True, verbose_name='Response Data')),
|
||||||
|
('status_code', models.CharField(blank=True, max_length=10, verbose_name='Status Code')),
|
||||||
|
('error_message', models.TextField(blank=True, verbose_name='Error Message')),
|
||||||
|
('ip_address', models.GenericIPAddressField(verbose_name='IP Address')),
|
||||||
|
('user_agent', models.CharField(blank=True, max_length=255, verbose_name='User Agent')),
|
||||||
|
('processing_time', models.FloatField(blank=True, null=True, verbose_name='Processing Time (seconds)')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integration_logs', to='recruitment.source', verbose_name='Source')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Integration Log',
|
||||||
|
'verbose_name_plural': 'Integration Logs',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
recruitment/migrations/0022_alter_source_trusted_ips.py
Normal file
18
recruitment/migrations/0022_alter_source_trusted_ips.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-06 14:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0021_source_api_key_source_api_secret_source_description_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='source',
|
||||||
|
name='trusted_ips',
|
||||||
|
field=models.TextField(blank=True, help_text='Comma-separated list of trusted IP addresses', null=True, verbose_name='Trusted IP Addresses'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-06 14:38
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0022_alter_source_trusted_ips'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='jobposting',
|
||||||
|
name='application_url',
|
||||||
|
field=models.URLField(blank=True, help_text='URL where candidates apply', null=True, validators=[django.core.validators.URLValidator()]),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='jobposting',
|
||||||
|
name='location_country',
|
||||||
|
field=models.CharField(default='Saudia Arabia', max_length=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,141 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-07 10:19
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import django_extensions.db.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0023_alter_jobposting_application_url_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='fieldresponse',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Created at'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='fieldresponse',
|
||||||
|
name='slug',
|
||||||
|
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='fieldresponse',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formfield',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Created at'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formfield',
|
||||||
|
name='slug',
|
||||||
|
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formfield',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formstage',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Created at'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formstage',
|
||||||
|
name='slug',
|
||||||
|
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formstage',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formsubmission',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Created at'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formsubmission',
|
||||||
|
name='slug',
|
||||||
|
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formsubmission',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formtemplate',
|
||||||
|
name='job',
|
||||||
|
field=models.OneToOneField(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formtemplate',
|
||||||
|
name='slug',
|
||||||
|
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='integrationlog',
|
||||||
|
name='slug',
|
||||||
|
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='integrationlog',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sharedformtemplate',
|
||||||
|
name='slug',
|
||||||
|
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sharedformtemplate',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='Updated at'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='slug',
|
||||||
|
field=django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='Updated 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'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='integrationlog',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='sharedformtemplate',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-07 12:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0024_fieldresponse_created_at_fieldresponse_slug_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formfield',
|
||||||
|
name='max_files',
|
||||||
|
field=models.PositiveIntegerField(default=1, help_text='Maximum number of files allowed (when multiple_files is True)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formfield',
|
||||||
|
name='multiple_files',
|
||||||
|
field=models.BooleanField(default=False, help_text='Allow multiple files to be uploaded'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-07 14:12
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0025_formfield_max_files_formfield_multiple_files'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='InterviewSchedule',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||||
|
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||||
|
('start_date', models.DateField(verbose_name='Start Date')),
|
||||||
|
('end_date', models.DateField(verbose_name='End Date')),
|
||||||
|
('working_days', models.JSONField(verbose_name='Working Days')),
|
||||||
|
('start_time', models.TimeField(verbose_name='Start Time')),
|
||||||
|
('end_time', models.TimeField(verbose_name='End Time')),
|
||||||
|
('break_start_time', models.TimeField(blank=True, null=True, verbose_name='Break Start Time')),
|
||||||
|
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
|
||||||
|
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
|
||||||
|
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
|
||||||
|
('candidates', models.ManyToManyField(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')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ScheduledInterview',
|
||||||
|
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')),
|
||||||
|
('interview_date', models.DateField(verbose_name='Interview Date')),
|
||||||
|
('interview_time', models.TimeField(verbose_name='Interview Time')),
|
||||||
|
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], default='scheduled', max_length=20)),
|
||||||
|
('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')),
|
||||||
|
('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
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.
@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django_extensions.db.fields import RandomCharField
|
from django_extensions.db.fields import RandomCharField
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django_countries.fields import CountryField
|
from django_countries.fields import CountryField
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
class Base(models.Model):
|
class Base(models.Model):
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at'))
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at'))
|
||||||
@ -56,11 +57,10 @@ class JobPosting(Base):
|
|||||||
job_type = models.CharField(max_length=20, choices=JOB_TYPES, default='FULL_TIME')
|
job_type = models.CharField(max_length=20, choices=JOB_TYPES, default='FULL_TIME')
|
||||||
workplace_type = models.CharField(max_length=20, choices=WORKPLACE_TYPES, default='ON_SITE')
|
workplace_type = models.CharField(max_length=20, choices=WORKPLACE_TYPES, default='ON_SITE')
|
||||||
|
|
||||||
|
|
||||||
# Location
|
# Location
|
||||||
location_city = models.CharField(max_length=100, blank=True)
|
location_city = models.CharField(max_length=100, blank=True)
|
||||||
location_state = models.CharField(max_length=100, blank=True)
|
location_state = models.CharField(max_length=100, blank=True)
|
||||||
location_country = models.CharField(max_length=100, default='United States')
|
location_country = models.CharField(max_length=100, default='Saudia Arabia')
|
||||||
|
|
||||||
# Job Details
|
# Job Details
|
||||||
description = models.TextField(help_text="Full job description including responsibilities and requirements")
|
description = models.TextField(help_text="Full job description including responsibilities and requirements")
|
||||||
@ -70,7 +70,7 @@ class JobPosting(Base):
|
|||||||
|
|
||||||
|
|
||||||
# Application Information
|
# Application Information
|
||||||
application_url = models.URLField(validators=[URLValidator()], help_text="URL where candidates apply")
|
application_url = models.URLField(validators=[URLValidator()], help_text="URL where candidates apply",null=True, blank=True)
|
||||||
application_deadline = models.DateField(null=True, blank=True)
|
application_deadline = models.DateField(null=True, blank=True)
|
||||||
application_instructions = models.TextField(blank=True, help_text="Special instructions for applicants")
|
application_instructions = models.TextField(blank=True, help_text="Special instructions for applicants")
|
||||||
|
|
||||||
@ -129,6 +129,8 @@ class JobPosting(Base):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title} - {self.get_status_display()}"
|
return f"{self.title} - {self.get_status_display()}"
|
||||||
|
|
||||||
|
def get_source(self):
|
||||||
|
return self.source.name if self.source else 'System'
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
# Generate unique internal job ID if not exists
|
# Generate unique internal job ID if not exists
|
||||||
if not self.internal_job_id:
|
if not self.internal_job_id:
|
||||||
@ -166,6 +168,12 @@ class JobPosting(Base):
|
|||||||
return self.application_deadline < timezone.now().date()
|
return self.application_deadline < timezone.now().date()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def publish(self):
|
||||||
|
self.status = 'PUBLISHED'
|
||||||
|
self.published_at = timezone.now()
|
||||||
|
self.application_url = reverse('form_wizard', kwargs={'slug': self.form_template.slug})
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class Candidate(Base):
|
class Candidate(Base):
|
||||||
class Stage(models.TextChoices):
|
class Stage(models.TextChoices):
|
||||||
@ -278,9 +286,6 @@ class Candidate(Base):
|
|||||||
return self.full_name
|
return self.full_name
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TrainingMaterial(Base):
|
class TrainingMaterial(Base):
|
||||||
title = models.CharField(max_length=255, verbose_name=_('Title'))
|
title = models.CharField(max_length=255, verbose_name=_('Title'))
|
||||||
content = models.TextField(blank=True, verbose_name=_('Content'))
|
content = models.TextField(blank=True, verbose_name=_('Content'))
|
||||||
@ -316,15 +321,14 @@ class ZoomMeeting(Base):
|
|||||||
return self.topic
|
return self.topic
|
||||||
|
|
||||||
|
|
||||||
class FormTemplate(models.Model):
|
class FormTemplate(Base):
|
||||||
"""
|
"""
|
||||||
Represents a complete form template with multiple stages
|
Represents a complete form template with multiple stages
|
||||||
"""
|
"""
|
||||||
|
job = models.OneToOneField(JobPosting, on_delete=models.CASCADE, related_name='form_template')
|
||||||
name = models.CharField(max_length=200, help_text="Name of the form template")
|
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")
|
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')
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='form_templates')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
is_active = models.BooleanField(default=True, help_text="Whether this template is active")
|
is_active = models.BooleanField(default=True, help_text="Whether this template is active")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -342,7 +346,8 @@ class FormTemplate(models.Model):
|
|||||||
return sum(stage.fields.count() for stage in self.stages.all())
|
return sum(stage.fields.count() for stage in self.stages.all())
|
||||||
|
|
||||||
|
|
||||||
class FormStage(models.Model):
|
|
||||||
|
class FormStage(Base):
|
||||||
"""
|
"""
|
||||||
Represents a stage/section within a form template
|
Represents a stage/section within a form template
|
||||||
"""
|
"""
|
||||||
@ -364,7 +369,7 @@ class FormStage(models.Model):
|
|||||||
raise ValidationError("Order must be a positive integer")
|
raise ValidationError("Order must be a positive integer")
|
||||||
|
|
||||||
|
|
||||||
class FormField(models.Model):
|
class FormField(Base):
|
||||||
"""
|
"""
|
||||||
Represents a single field within a form stage
|
Represents a single field within a form stage
|
||||||
"""
|
"""
|
||||||
@ -405,15 +410,20 @@ class FormField(models.Model):
|
|||||||
default=5,
|
default=5,
|
||||||
help_text="Maximum file size in MB (default: 5MB)"
|
help_text="Maximum file size in MB (default: 5MB)"
|
||||||
)
|
)
|
||||||
|
multiple_files = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Allow multiple files to be uploaded"
|
||||||
|
)
|
||||||
|
max_files = models.PositiveIntegerField(
|
||||||
|
default=1,
|
||||||
|
help_text="Maximum number of files allowed (when multiple_files is True)"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['order']
|
ordering = ['order']
|
||||||
verbose_name = 'Form Field'
|
verbose_name = 'Form Field'
|
||||||
verbose_name_plural = 'Form Fields'
|
verbose_name_plural = 'Form Fields'
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.stage.name} - {self.label}"
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
# Validate options for selection fields
|
# Validate options for selection fields
|
||||||
if self.field_type in ['select', 'radio', 'checkbox']:
|
if self.field_type in ['select', 'radio', 'checkbox']:
|
||||||
@ -430,16 +440,23 @@ class FormField(models.Model):
|
|||||||
self.file_types = '.pdf,.doc,.docx'
|
self.file_types = '.pdf,.doc,.docx'
|
||||||
if self.max_file_size <= 0:
|
if self.max_file_size <= 0:
|
||||||
raise ValidationError("Max file size must be greater than 0")
|
raise ValidationError("Max file size must be greater than 0")
|
||||||
|
if self.multiple_files and self.max_files <= 0:
|
||||||
|
raise ValidationError("Max files must be greater than 0 when multiple files are allowed")
|
||||||
|
if not self.multiple_files:
|
||||||
|
self.max_files = 1
|
||||||
else:
|
else:
|
||||||
# Clear file settings for non-file fields
|
# Clear file settings for non-file fields
|
||||||
self.file_types = ''
|
self.file_types = ''
|
||||||
self.max_file_size = 0
|
self.max_file_size = 0
|
||||||
|
self.multiple_files = False
|
||||||
|
self.max_files = 1
|
||||||
|
|
||||||
|
# Validate order
|
||||||
if self.order < 0:
|
if self.order < 0:
|
||||||
raise ValidationError("Order must be a positive integer")
|
raise ValidationError("Order must be a positive integer")
|
||||||
|
|
||||||
|
|
||||||
class FormSubmission(models.Model):
|
class FormSubmission(Base):
|
||||||
"""
|
"""
|
||||||
Represents a completed form submission by an applicant
|
Represents a completed form submission by an applicant
|
||||||
"""
|
"""
|
||||||
@ -458,7 +475,7 @@ class FormSubmission(models.Model):
|
|||||||
return f"Submission for {self.template.name} - {self.submitted_at.strftime('%Y-%m-%d %H:%M')}"
|
return f"Submission for {self.template.name} - {self.submitted_at.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
|
||||||
|
|
||||||
class FieldResponse(models.Model):
|
class FieldResponse(Base):
|
||||||
"""
|
"""
|
||||||
Represents a response to a specific field in a form submission
|
Represents a response to a specific field in a form submission
|
||||||
"""
|
"""
|
||||||
@ -492,14 +509,13 @@ class FieldResponse(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
# Optional: Create a model for form templates that can be shared across organizations
|
# Optional: Create a model for form templates that can be shared across organizations
|
||||||
class SharedFormTemplate(models.Model):
|
class SharedFormTemplate(Base):
|
||||||
"""
|
"""
|
||||||
Represents a form template that can be shared across different organizations/users
|
Represents a form template that can be shared across different organizations/users
|
||||||
"""
|
"""
|
||||||
template = models.OneToOneField(FormTemplate, on_delete=models.CASCADE)
|
template = models.OneToOneField(FormTemplate, on_delete=models.CASCADE)
|
||||||
is_public = models.BooleanField(default=False, help_text="Whether this template is publicly available")
|
is_public = models.BooleanField(default=False, help_text="Whether this template is publicly available")
|
||||||
shared_with = models.ManyToManyField(User, blank=True, related_name='shared_templates')
|
shared_with = models.ManyToManyField(User, blank=True, related_name='shared_templates')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'Shared Form Template'
|
verbose_name = 'Shared Form Template'
|
||||||
@ -509,23 +525,178 @@ class SharedFormTemplate(models.Model):
|
|||||||
return f"Shared: {self.template.name}"
|
return f"Shared: {self.template.name}"
|
||||||
|
|
||||||
|
|
||||||
class Source(models.Model):
|
class Source(Base):
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
unique=True,
|
unique=True,
|
||||||
verbose_name=_('Source Name'),
|
verbose_name=_('Source Name'),
|
||||||
help_text=_("e.g., ATS, ERP ")
|
help_text=_("e.g., ATS, ERP ")
|
||||||
)
|
)
|
||||||
|
source_type = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
verbose_name=_('Source Type'),
|
||||||
|
help_text=_("e.g., ATS, ERP ")
|
||||||
|
)
|
||||||
|
description = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('Description'),
|
||||||
|
help_text=_("A description of the source")
|
||||||
|
)
|
||||||
|
ip_address = models.GenericIPAddressField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('IP Address'),
|
||||||
|
help_text=_("The IP address of the source")
|
||||||
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
# Integration specific fields
|
||||||
|
api_key = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('API Key'),
|
||||||
|
help_text=_("API key for authentication (will be encrypted)")
|
||||||
|
)
|
||||||
|
api_secret = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('API Secret'),
|
||||||
|
help_text=_("API secret for authentication (will be encrypted)")
|
||||||
|
)
|
||||||
|
trusted_ips = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('Trusted IP Addresses'),
|
||||||
|
help_text=_("Comma-separated list of trusted IP addresses")
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name=_('Active'),
|
||||||
|
help_text=_("Whether this source is active for integration")
|
||||||
|
)
|
||||||
|
integration_version = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('Integration Version'),
|
||||||
|
help_text=_("Version of the integration protocol")
|
||||||
|
)
|
||||||
|
last_sync_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('Last Sync At'),
|
||||||
|
help_text=_("Timestamp of the last successful synchronization")
|
||||||
|
)
|
||||||
|
sync_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
('IDLE', 'Idle'),
|
||||||
|
('SYNCING', 'Syncing'),
|
||||||
|
('ERROR', 'Error'),
|
||||||
|
('DISABLED', 'Disabled')
|
||||||
|
],
|
||||||
|
default='IDLE',
|
||||||
|
verbose_name=_('Sync Status')
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _('Source')
|
verbose_name = _('Source')
|
||||||
verbose_name_plural = _('Sources')
|
verbose_name_plural = _('Sources')
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
|
|
||||||
|
class IntegrationLog(Base):
|
||||||
|
"""
|
||||||
|
Log all integration requests and responses for audit and debugging purposes
|
||||||
|
"""
|
||||||
|
class ActionChoices(models.TextChoices):
|
||||||
|
REQUEST = 'REQUEST', _('Request')
|
||||||
|
RESPONSE = 'RESPONSE', _('Response')
|
||||||
|
ERROR = 'ERROR', _('Error')
|
||||||
|
SYNC = 'SYNC', _('Sync')
|
||||||
|
CREATE_JOB = 'CREATE_JOB', _('Create Job')
|
||||||
|
UPDATE_JOB = 'UPDATE_JOB', _('Update Job')
|
||||||
|
|
||||||
|
source = models.ForeignKey(
|
||||||
|
Source,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='integration_logs',
|
||||||
|
verbose_name=_('Source')
|
||||||
|
)
|
||||||
|
action = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=ActionChoices.choices,
|
||||||
|
verbose_name=_('Action')
|
||||||
|
)
|
||||||
|
endpoint = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('Endpoint')
|
||||||
|
)
|
||||||
|
method = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('HTTP Method')
|
||||||
|
)
|
||||||
|
request_data = models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('Request Data')
|
||||||
|
)
|
||||||
|
response_data = models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_('Response Data')
|
||||||
|
)
|
||||||
|
status_code = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('Status Code')
|
||||||
|
)
|
||||||
|
error_message = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('Error Message')
|
||||||
|
)
|
||||||
|
ip_address = models.GenericIPAddressField(
|
||||||
|
verbose_name=_('IP Address')
|
||||||
|
)
|
||||||
|
user_agent = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('User Agent')
|
||||||
|
)
|
||||||
|
processing_time = models.FloatField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('Processing Time (seconds)')
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.source.name} - {self.action} - {self.created_at}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = _('Integration Log')
|
||||||
|
verbose_name_plural = _('Integration Logs')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_successful(self):
|
||||||
|
"""Check if the integration action was successful"""
|
||||||
|
if self.action == self.ActionChoices.ERROR:
|
||||||
|
return False
|
||||||
|
if self.action == self.ActionChoices.REQUEST:
|
||||||
|
return True # Requests are always logged, success depends on response
|
||||||
|
if self.status_code and self.status_code.startswith('2'):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class HiringAgency(Base):
|
class HiringAgency(Base):
|
||||||
name = models.CharField(max_length=200, unique=True, verbose_name=_('Agency Name'))
|
name = models.CharField(max_length=200, unique=True, verbose_name=_('Agency Name'))
|
||||||
contact_person = models.CharField(max_length=150, blank=True, verbose_name=_('Contact Person'))
|
contact_person = models.CharField(max_length=150, blank=True, verbose_name=_('Contact Person'))
|
||||||
@ -546,3 +717,43 @@ class HiringAgency(Base):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class InterviewSchedule(Base):
|
||||||
|
"""Stores the scheduling criteria for interviews"""
|
||||||
|
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name='interview_schedules')
|
||||||
|
candidates = models.ManyToManyField(Candidate, related_name='interview_schedules')
|
||||||
|
start_date = models.DateField(verbose_name=_('Start Date'))
|
||||||
|
end_date = models.DateField(verbose_name=_('End Date'))
|
||||||
|
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'))
|
||||||
|
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)'))
|
||||||
|
buffer_time = models.PositiveIntegerField(verbose_name=_('Buffer Time (minutes)'), default=0)
|
||||||
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Interview Schedule for {self.job.title}"
|
||||||
|
|
||||||
|
class ScheduledInterview(Base):
|
||||||
|
"""Stores individual scheduled interviews"""
|
||||||
|
candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE, related_name='scheduled_interviews')
|
||||||
|
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name='scheduled_interviews')
|
||||||
|
zoom_meeting = models.OneToOneField(ZoomMeeting, on_delete=models.CASCADE, related_name='interview')
|
||||||
|
schedule = models.ForeignKey(InterviewSchedule, on_delete=models.CASCADE, related_name='interviews')
|
||||||
|
interview_date = models.DateField(verbose_name=_('Interview Date'))
|
||||||
|
interview_time = models.TimeField(verbose_name=_('Interview Time'))
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
('scheduled', _('Scheduled')),
|
||||||
|
('confirmed', _('Confirmed')),
|
||||||
|
('cancelled', _('Cancelled')),
|
||||||
|
('completed', _('Completed')),
|
||||||
|
],
|
||||||
|
default='scheduled'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Interview with {self.candidate.name} for {self.job.title}"
|
||||||
@ -1,6 +1,9 @@
|
|||||||
from django.db.models.signals import post_save
|
|
||||||
from django.dispatch import receiver
|
|
||||||
from . import models
|
from . import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.db import transaction
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from .models import FormField,FormStage,FormTemplate
|
||||||
|
|
||||||
# @receiver(post_save, sender=models.Candidate)
|
# @receiver(post_save, sender=models.Candidate)
|
||||||
# def parse_resume(sender, instance, created, **kwargs):
|
# def parse_resume(sender, instance, created, **kwargs):
|
||||||
@ -19,8 +22,6 @@ import os
|
|||||||
from .utils import extract_text_from_pdf,score_resume_with_openrouter
|
from .utils import extract_text_from_pdf,score_resume_with_openrouter
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=models.Candidate)
|
@receiver(post_save, sender=models.Candidate)
|
||||||
def score_candidate_resume(sender, instance, created, **kwargs):
|
def score_candidate_resume(sender, instance, created, **kwargs):
|
||||||
# Skip if no resume or OpenRouter not configured
|
# Skip if no resume or OpenRouter not configured
|
||||||
@ -138,3 +139,284 @@ def score_candidate_resume(sender, instance, created, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=FormTemplate)
|
||||||
|
def create_default_stages(sender, instance, created, **kwargs):
|
||||||
|
"""
|
||||||
|
Create default resume stages when a new FormTemplate is created
|
||||||
|
"""
|
||||||
|
if created: # Only run for new templates, not updates
|
||||||
|
with transaction.atomic():
|
||||||
|
# Stage 1: Contact Information
|
||||||
|
contact_stage = FormStage.objects.create(
|
||||||
|
template=instance,
|
||||||
|
name='Contact Information',
|
||||||
|
order=0,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=contact_stage,
|
||||||
|
label='Full Name',
|
||||||
|
field_type='text',
|
||||||
|
required=True,
|
||||||
|
order=0,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=contact_stage,
|
||||||
|
label='Email Address',
|
||||||
|
field_type='email',
|
||||||
|
required=True,
|
||||||
|
order=1,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=contact_stage,
|
||||||
|
label='Phone Number',
|
||||||
|
field_type='phone',
|
||||||
|
required=True,
|
||||||
|
order=2,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=contact_stage,
|
||||||
|
label='Address',
|
||||||
|
field_type='text',
|
||||||
|
required=False,
|
||||||
|
order=3,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=contact_stage,
|
||||||
|
label='Resume Upload',
|
||||||
|
field_type='file',
|
||||||
|
required=True,
|
||||||
|
order=4,
|
||||||
|
is_predefined=True,
|
||||||
|
file_types='.pdf,.doc,.docx',
|
||||||
|
max_file_size=5
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stage 2: Resume Objective
|
||||||
|
objective_stage = FormStage.objects.create(
|
||||||
|
template=instance,
|
||||||
|
name='Resume Objective',
|
||||||
|
order=1,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=objective_stage,
|
||||||
|
label='Career Objective',
|
||||||
|
field_type='textarea',
|
||||||
|
required=False,
|
||||||
|
order=0,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stage 3: Education
|
||||||
|
education_stage = FormStage.objects.create(
|
||||||
|
template=instance,
|
||||||
|
name='Education',
|
||||||
|
order=2,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=education_stage,
|
||||||
|
label='Degree',
|
||||||
|
field_type='text',
|
||||||
|
required=True,
|
||||||
|
order=0,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=education_stage,
|
||||||
|
label='Institution',
|
||||||
|
field_type='text',
|
||||||
|
required=True,
|
||||||
|
order=1,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=education_stage,
|
||||||
|
label='Location',
|
||||||
|
field_type='text',
|
||||||
|
required=False,
|
||||||
|
order=2,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=education_stage,
|
||||||
|
label='Graduation Date',
|
||||||
|
field_type='date',
|
||||||
|
required=False,
|
||||||
|
order=3,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stage 4: Experience
|
||||||
|
experience_stage = FormStage.objects.create(
|
||||||
|
template=instance,
|
||||||
|
name='Experience',
|
||||||
|
order=3,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=experience_stage,
|
||||||
|
label='Position Title',
|
||||||
|
field_type='text',
|
||||||
|
required=True,
|
||||||
|
order=0,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=experience_stage,
|
||||||
|
label='Company Name',
|
||||||
|
field_type='text',
|
||||||
|
required=True,
|
||||||
|
order=1,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=experience_stage,
|
||||||
|
label='Location',
|
||||||
|
field_type='text',
|
||||||
|
required=False,
|
||||||
|
order=2,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=experience_stage,
|
||||||
|
label='Start Date',
|
||||||
|
field_type='date',
|
||||||
|
required=True,
|
||||||
|
order=3,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=experience_stage,
|
||||||
|
label='End Date',
|
||||||
|
field_type='date',
|
||||||
|
required=True,
|
||||||
|
order=4,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=experience_stage,
|
||||||
|
label='Responsibilities & Achievements',
|
||||||
|
field_type='textarea',
|
||||||
|
required=False,
|
||||||
|
order=5,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stage 5: Skills
|
||||||
|
skills_stage = FormStage.objects.create(
|
||||||
|
template=instance,
|
||||||
|
name='Skills',
|
||||||
|
order=4,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=skills_stage,
|
||||||
|
label='Technical Skills',
|
||||||
|
field_type='checkbox',
|
||||||
|
required=False,
|
||||||
|
order=0,
|
||||||
|
is_predefined=True,
|
||||||
|
options=['Programming Languages', 'Frameworks', 'Tools & Technologies']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stage 6: Summary
|
||||||
|
summary_stage = FormStage.objects.create(
|
||||||
|
template=instance,
|
||||||
|
name='Summary',
|
||||||
|
order=5,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=summary_stage,
|
||||||
|
label='Professional Summary',
|
||||||
|
field_type='textarea',
|
||||||
|
required=False,
|
||||||
|
order=0,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stage 7: Certifications
|
||||||
|
certifications_stage = FormStage.objects.create(
|
||||||
|
template=instance,
|
||||||
|
name='Certifications',
|
||||||
|
order=6,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=certifications_stage,
|
||||||
|
label='Certification Name',
|
||||||
|
field_type='text',
|
||||||
|
required=False,
|
||||||
|
order=0,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=certifications_stage,
|
||||||
|
label='Issuing Organization',
|
||||||
|
field_type='text',
|
||||||
|
required=False,
|
||||||
|
order=1,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=certifications_stage,
|
||||||
|
label='Issue Date',
|
||||||
|
field_type='date',
|
||||||
|
required=False,
|
||||||
|
order=2,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=certifications_stage,
|
||||||
|
label='Expiration Date',
|
||||||
|
field_type='date',
|
||||||
|
required=False,
|
||||||
|
order=3,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stage 8: Awards and Recognitions
|
||||||
|
awards_stage = FormStage.objects.create(
|
||||||
|
template=instance,
|
||||||
|
name='Awards and Recognitions',
|
||||||
|
order=7,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=awards_stage,
|
||||||
|
label='Award Name',
|
||||||
|
field_type='text',
|
||||||
|
required=False,
|
||||||
|
order=0,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=awards_stage,
|
||||||
|
label='Issuing Organization',
|
||||||
|
field_type='text',
|
||||||
|
required=False,
|
||||||
|
order=1,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=awards_stage,
|
||||||
|
label='Date Received',
|
||||||
|
field_type='date',
|
||||||
|
required=False,
|
||||||
|
order=2,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
|
FormField.objects.create(
|
||||||
|
stage=awards_stage,
|
||||||
|
label='Description',
|
||||||
|
field_type='textarea',
|
||||||
|
required=False,
|
||||||
|
order=3,
|
||||||
|
is_predefined=True
|
||||||
|
)
|
||||||
Binary file not shown.
Binary file not shown.
@ -22,31 +22,31 @@ def get_all_responses_flat(stage_responses):
|
|||||||
"""
|
"""
|
||||||
all_responses = []
|
all_responses = []
|
||||||
if stage_responses:
|
if stage_responses:
|
||||||
print(stage_responses.get(9).get("responses")[0].value)
|
|
||||||
for stage_id, responses in stage_responses.items():
|
for stage_id, responses in stage_responses.items():
|
||||||
for response in responses:
|
if responses: # Check if responses list exists and is not empty
|
||||||
# Check if response is an object or string
|
for response in responses:
|
||||||
if hasattr(response, 'stage') and hasattr(response, 'field'):
|
# Check if response is an object or string
|
||||||
stage_name = response.stage.name if hasattr(response.stage, 'name') else f"Stage {stage_id}"
|
if hasattr(response, 'stage') and hasattr(response, 'field'):
|
||||||
field_label = response.field.label if hasattr(response.field, 'label') else "Unknown Field"
|
stage_name = response.stage.name if hasattr(response.stage, 'name') else f"Stage {stage_id}"
|
||||||
field_type = response.field.get_field_type_display() if hasattr(response.field, 'get_field_type_display') else "Unknown Type"
|
field_label = response.field.label if hasattr(response.field, 'label') else "Unknown Field"
|
||||||
required = response.field.required if hasattr(response.field, 'required') else False
|
field_type = response.field.get_field_type_display() if hasattr(response.field, 'get_field_type_display') else "Unknown Type"
|
||||||
value = response.value if hasattr(response, 'value') else response
|
required = response.field.required if hasattr(response.field, 'required') else False
|
||||||
uploaded_file = response.uploaded_file if hasattr(response, 'uploaded_file') else None
|
value = response.value if hasattr(response, 'value') else response
|
||||||
else:
|
uploaded_file = response.uploaded_file if hasattr(response, 'uploaded_file') else None
|
||||||
stage_name = f"Stage {stage_id}"
|
else:
|
||||||
field_label = "Unknown Field"
|
stage_name = f"Stage {stage_id}"
|
||||||
field_type = "Text"
|
field_label = "Unknown Field"
|
||||||
required = False
|
field_type = "Text"
|
||||||
value = response
|
required = False
|
||||||
uploaded_file = None
|
value = response
|
||||||
|
uploaded_file = None
|
||||||
|
|
||||||
all_responses.append({
|
all_responses.append({
|
||||||
'stage_name': stage_name,
|
'stage_name': stage_name,
|
||||||
'field_label': field_label,
|
'field_label': field_label,
|
||||||
'field_type': field_type,
|
'field_type': field_type,
|
||||||
'required': required,
|
'required': required,
|
||||||
'value': value,
|
'value': value,
|
||||||
'uploaded_file': uploaded_file
|
'uploaded_file': uploaded_file
|
||||||
})
|
})
|
||||||
return all_responses
|
return all_responses
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import views_frontend
|
from . import views_frontend
|
||||||
from . import views
|
from . import views
|
||||||
|
from . import views_integration
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('dashboard/', views_frontend.dashboard_view, name='dashboard'),
|
path('dashboard/', views_frontend.dashboard_view, name='dashboard'),
|
||||||
@ -11,12 +12,14 @@ urlpatterns = [
|
|||||||
path('jobs/<slug:slug>/update/', views.edit_job, name='job_update'),
|
path('jobs/<slug:slug>/update/', views.edit_job, name='job_update'),
|
||||||
# path('jobs/<slug:slug>/delete/', views., name='job_delete'),
|
# path('jobs/<slug:slug>/delete/', views., name='job_delete'),
|
||||||
path('jobs/<slug:slug>/', views.job_detail, name='job_detail'),
|
path('jobs/<slug:slug>/', views.job_detail, name='job_detail'),
|
||||||
|
path('jobs/<slug:slug>/candidate/', views.job_detail_candidate, name='job_detail_candidate'),
|
||||||
|
|
||||||
# LinkedIn Integration URLs
|
# LinkedIn Integration URLs
|
||||||
path('jobs/<slug:slug>/post-to-linkedin/', views.post_to_linkedin, name='post_to_linkedin'),
|
path('jobs/<slug:slug>/post-to-linkedin/', views.post_to_linkedin, name='post_to_linkedin'),
|
||||||
path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'),
|
path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'),
|
||||||
path('jobs/linkedin/callback/', views.linkedin_callback, name='linkedin_callback'),
|
path('jobs/linkedin/callback/', views.linkedin_callback, name='linkedin_callback'),
|
||||||
|
|
||||||
|
path('jobs/<slug:slug>/schedule-interviews/', views.schedule_interviews_view, name='schedule_interviews'),
|
||||||
# Candidate URLs
|
# Candidate URLs
|
||||||
path('candidates/', views_frontend.CandidateListView.as_view(), name='candidate_list'),
|
path('candidates/', views_frontend.CandidateListView.as_view(), name='candidate_list'),
|
||||||
path('candidates/create/', views_frontend.CandidateCreateView.as_view(), name='candidate_create'),
|
path('candidates/create/', views_frontend.CandidateCreateView.as_view(), name='candidate_create'),
|
||||||
@ -45,13 +48,18 @@ urlpatterns = [
|
|||||||
path('api/create/', views.create_job, name='create_job_api'),
|
path('api/create/', views.create_job, name='create_job_api'),
|
||||||
path('api/<slug:slug>/edit/', views.edit_job, name='edit_job_api'),
|
path('api/<slug:slug>/edit/', views.edit_job, name='edit_job_api'),
|
||||||
|
|
||||||
#
|
# ERP Integration URLs
|
||||||
|
path('integration/erp/', views_integration.ERPIntegrationView.as_view(), name='erp_integration'),
|
||||||
|
path('integration/erp/create-job/', views_integration.erp_create_job_view, name='erp_create_job'),
|
||||||
|
path('integration/erp/update-job/', views_integration.erp_update_job_view, name='erp_update_job'),
|
||||||
|
path('integration/erp/health/', views_integration.erp_integration_health, name='erp_integration_health'),
|
||||||
|
|
||||||
# Form Preview URLs
|
# Form Preview URLs
|
||||||
# path('forms/', views.form_list, name='form_list'),
|
# path('forms/', views.form_list, name='form_list'),
|
||||||
path('forms/builder/', views.form_builder, name='form_builder'),
|
path('forms/builder/', views.form_builder, name='form_builder'),
|
||||||
path('forms/builder/<int:template_id>/', views.form_builder, name='form_builder'),
|
path('forms/builder/<int:template_id>/', views.form_builder, name='form_builder'),
|
||||||
path('forms/', views.form_templates_list, name='form_templates_list'),
|
path('forms/', views.form_templates_list, name='form_templates_list'),
|
||||||
|
path('forms/create-template/', views.create_form_template, name='create_form_template'),
|
||||||
|
|
||||||
path('forms/form/<int:template_id>/', views.form_wizard_view, name='form_wizard'),
|
path('forms/form/<int:template_id>/', views.form_wizard_view, name='form_wizard'),
|
||||||
path('forms/form/<int:template_id>/submit/', views.submit_form, name='submit_form'),
|
path('forms/form/<int:template_id>/submit/', views.submit_form, name='submit_form'),
|
||||||
|
|||||||
@ -4,7 +4,11 @@
|
|||||||
# import requests
|
# import requests
|
||||||
from recruitment import models
|
from recruitment import models
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from datetime import datetime, timedelta, time, date
|
||||||
|
from django.utils import timezone
|
||||||
|
from .models import ScheduledInterview
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.core.mail import send_mail
|
||||||
# nlp = spacy.load("en_core_web_sm")
|
# nlp = spacy.load("en_core_web_sm")
|
||||||
|
|
||||||
# def extract_text_from_pdf(pdf_path):
|
# def extract_text_from_pdf(pdf_path):
|
||||||
@ -382,3 +386,152 @@ def delete_zoom_meeting(meeting_id):
|
|||||||
"status": "error",
|
"status": "error",
|
||||||
"message": str(e)
|
"message": str(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def schedule_interviews(schedule):
|
||||||
|
"""
|
||||||
|
Schedule interviews for all candidates in the schedule based on the criteria.
|
||||||
|
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)}")
|
||||||
|
|
||||||
|
# Schedule interviews
|
||||||
|
scheduled_count = 0
|
||||||
|
for i, candidate in enumerate(candidates):
|
||||||
|
slot = available_slots[i]
|
||||||
|
interview_datetime = datetime.combine(slot['date'], slot['time'])
|
||||||
|
|
||||||
|
# Create Zoom meeting
|
||||||
|
meeting_topic = f"Interview for {schedule.job.title} - {candidate.name}"
|
||||||
|
meeting = create_zoom_meeting(
|
||||||
|
topic=meeting_topic,
|
||||||
|
start_time=interview_datetime,
|
||||||
|
duration=schedule.interview_duration,
|
||||||
|
timezone=timezone.get_current_timezone_name()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create scheduled interview record
|
||||||
|
scheduled_interview = ScheduledInterview.objects.create(
|
||||||
|
candidate=candidate,
|
||||||
|
job=schedule.job,
|
||||||
|
zoom_meeting=meeting,
|
||||||
|
schedule=schedule,
|
||||||
|
interview_date=slot['date'],
|
||||||
|
interview_time=slot['time']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send email to candidate
|
||||||
|
send_interview_email(scheduled_interview)
|
||||||
|
|
||||||
|
scheduled_count += 1
|
||||||
|
|
||||||
|
return scheduled_count
|
||||||
|
|
||||||
|
def send_interview_email(scheduled_interview):
|
||||||
|
"""
|
||||||
|
Send an interview invitation email to the candidate.
|
||||||
|
"""
|
||||||
|
subject = f"Interview Invitation for {scheduled_interview.job.title}"
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'candidate_name': scheduled_interview.candidate.name,
|
||||||
|
'job_title': scheduled_interview.job.title,
|
||||||
|
'company_name': scheduled_interview.job.company.name,
|
||||||
|
'interview_date': scheduled_interview.interview_date,
|
||||||
|
'interview_time': scheduled_interview.interview_time,
|
||||||
|
'join_url': scheduled_interview.zoom_meeting.join_url,
|
||||||
|
'meeting_id': scheduled_interview.zoom_meeting.meeting_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Render email templates
|
||||||
|
text_message = render_to_string('interviews/email/interview_invitation.txt', context)
|
||||||
|
html_message = render_to_string('interviews/email/interview_invitation.html', context)
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=text_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[scheduled_interview.candidate.email],
|
||||||
|
html_message=html_message,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_available_time_slots(schedule):
|
||||||
|
"""
|
||||||
|
Generate a list of available time slots based on the schedule criteria.
|
||||||
|
Returns a list of dictionaries with 'date' and 'time' keys.
|
||||||
|
"""
|
||||||
|
slots = []
|
||||||
|
current_date = schedule.start_date
|
||||||
|
end_date = schedule.end_date
|
||||||
|
|
||||||
|
# Convert working days to a set for quick lookup
|
||||||
|
# working_days should be a list of integers where 0=Monday, 1=Tuesday, etc.
|
||||||
|
working_days_set = set(int(day) for day in schedule.working_days)
|
||||||
|
|
||||||
|
# Parse times
|
||||||
|
start_time = schedule.start_time
|
||||||
|
end_time = schedule.end_time
|
||||||
|
break_start = schedule.break_start_time
|
||||||
|
break_end = schedule.break_end_time
|
||||||
|
|
||||||
|
# Calculate slot duration (interview duration + buffer time)
|
||||||
|
slot_duration = timedelta(minutes=schedule.interview_duration + schedule.buffer_time)
|
||||||
|
|
||||||
|
# Debug output - remove in production
|
||||||
|
print(f"Working days: {working_days_set}")
|
||||||
|
print(f"Date range: {current_date} to {end_date}")
|
||||||
|
print(f"Time range: {start_time} to {end_time}")
|
||||||
|
print(f"Slot duration: {slot_duration}")
|
||||||
|
|
||||||
|
while current_date <= end_date:
|
||||||
|
# Check if current day is a working day
|
||||||
|
weekday = current_date.weekday() # Monday is 0, Sunday is 6
|
||||||
|
print(f"Checking {current_date}, weekday: {weekday}, in working days: {weekday in working_days_set}")
|
||||||
|
|
||||||
|
if weekday in working_days_set:
|
||||||
|
# Generate slots for this day
|
||||||
|
current_time = start_time
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Calculate the end time of this slot
|
||||||
|
slot_end_time = (datetime.combine(current_date, current_time) + slot_duration).time()
|
||||||
|
|
||||||
|
# Check if the slot fits within the working hours
|
||||||
|
if slot_end_time > end_time:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if slot conflicts with break time
|
||||||
|
conflict_with_break = False
|
||||||
|
if break_start and break_end:
|
||||||
|
# Check if the slot overlaps with break time
|
||||||
|
if not (current_time >= break_end or slot_end_time <= break_start):
|
||||||
|
conflict_with_break = True
|
||||||
|
print(f"Slot {current_time}-{slot_end_time} conflicts with break {break_start}-{break_end}")
|
||||||
|
|
||||||
|
if not conflict_with_break:
|
||||||
|
# Add this slot to available slots
|
||||||
|
slots.append({
|
||||||
|
'date': current_date,
|
||||||
|
'time': current_time
|
||||||
|
})
|
||||||
|
print(f"Added slot: {current_date} {current_time}")
|
||||||
|
|
||||||
|
# Move to next slot
|
||||||
|
current_datetime = datetime.combine(current_date, current_time) + slot_duration
|
||||||
|
current_time = current_datetime.time()
|
||||||
|
|
||||||
|
# Move to next day
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
print(f"Total slots generated: {len(slots)}")
|
||||||
|
return slots
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
|
from rich import print
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
@ -7,21 +8,22 @@ from datetime import datetime
|
|||||||
from django.views import View
|
from django.views import View
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from .forms import ZoomMeetingForm,JobPostingForm
|
from .forms import ZoomMeetingForm,JobPostingForm,FormTemplateForm,InterviewScheduleForm
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from .linkedin_service import LinkedInService
|
from .linkedin_service import LinkedInService
|
||||||
from .models import FormTemplate, FormStage, FormField,FieldResponse,FormSubmission
|
from .models import FormTemplate, FormStage, FormField,FieldResponse,FormSubmission,InterviewSchedule
|
||||||
from .models import ZoomMeeting, Candidate, JobPosting
|
from .models import ZoomMeeting, Candidate, JobPosting
|
||||||
from .serializers import JobPostingSerializer, CandidateSerializer
|
from .serializers import JobPostingSerializer, CandidateSerializer
|
||||||
from django.shortcuts import get_object_or_404, render, redirect
|
from django.shortcuts import get_object_or_404, render, redirect
|
||||||
from django.views.generic import CreateView,UpdateView,DetailView,ListView
|
from django.views.generic import CreateView,UpdateView,DetailView,ListView
|
||||||
from .utils import create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting
|
from .utils import create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting,schedule_interviews,get_available_time_slots
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger=logging.getLogger(__name__)
|
logger=logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -240,7 +242,12 @@ def job_detail(request, slug):
|
|||||||
'offer_count': offer_count,
|
'offer_count': offer_count,
|
||||||
}
|
}
|
||||||
return render(request, 'jobs/job_detail.html', context)
|
return render(request, 'jobs/job_detail.html', context)
|
||||||
\
|
|
||||||
|
|
||||||
|
# job detail facing the candidate:
|
||||||
|
def job_detail_candidate(request,slug):
|
||||||
|
job=get_object_or_404(JobPosting,slug=slug)
|
||||||
|
return render(request,'jobs/job_detail_candidate.html',{'job':job})
|
||||||
|
|
||||||
def post_to_linkedin(request,slug):
|
def post_to_linkedin(request,slug):
|
||||||
"""Post a job to LinkedIn"""
|
"""Post a job to LinkedIn"""
|
||||||
@ -321,6 +328,7 @@ def linkedin_callback(request):
|
|||||||
access_token=service.get_access_token(code)
|
access_token=service.get_access_token(code)
|
||||||
request.session['linkedin_access_token']=access_token
|
request.session['linkedin_access_token']=access_token
|
||||||
request.session['linkedin_authenticated']=True
|
request.session['linkedin_authenticated']=True
|
||||||
|
settings.LINKEDIN_IS_CONNECTED = True
|
||||||
messages.success(request,'Successfully authenticated with LinkedIn!')
|
messages.success(request,'Successfully authenticated with LinkedIn!')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"LinkedIn authentication error: {e}")
|
logger.error(f"LinkedIn authentication error: {e}")
|
||||||
@ -685,10 +693,11 @@ def load_form_template(request, template_id):
|
|||||||
'id': template.id,
|
'id': template.id,
|
||||||
'name': template.name,
|
'name': template.name,
|
||||||
'description': template.description,
|
'description': template.description,
|
||||||
|
'is_active': template.is_active,
|
||||||
|
'job': template.job_id if template.job else None,
|
||||||
'stages': stages
|
'stages': stages
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
def form_templates_list(request):
|
def form_templates_list(request):
|
||||||
"""List all form templates for the current user"""
|
"""List all form templates for the current user"""
|
||||||
query = request.GET.get('q', '')
|
query = request.GET.get('q', '')
|
||||||
@ -703,13 +712,32 @@ def form_templates_list(request):
|
|||||||
paginator = Paginator(templates, 10) # Show 10 templates per page
|
paginator = Paginator(templates, 10) # Show 10 templates per page
|
||||||
page_number = request.GET.get('page')
|
page_number = request.GET.get('page')
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
|
form = FormTemplateForm()
|
||||||
|
form.fields['job'].queryset = JobPosting.objects.filter(form_template__isnull=True)
|
||||||
context = {
|
context = {
|
||||||
'templates': page_obj,
|
'templates': page_obj,
|
||||||
'query': query,
|
'query': query,
|
||||||
|
'form': form
|
||||||
}
|
}
|
||||||
return render(request, 'forms/form_templates_list.html', context)
|
return render(request, 'forms/form_templates_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
def create_form_template(request):
|
||||||
|
"""Create a new form template"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = FormTemplateForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
template = form.save(commit=False)
|
||||||
|
template.created_by = request.user
|
||||||
|
template.save()
|
||||||
|
|
||||||
|
messages.success(request, f'Form template "{template.name}" created successfully!')
|
||||||
|
return redirect('form_builder', template_id=template.id)
|
||||||
|
else:
|
||||||
|
form = FormTemplateForm()
|
||||||
|
|
||||||
|
return render(request, 'forms/create_form_template.html', {'form': form})
|
||||||
|
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def list_form_templates(request):
|
def list_form_templates(request):
|
||||||
"""List all form templates for the current user"""
|
"""List all form templates for the current user"""
|
||||||
@ -816,3 +844,136 @@ def form_submission_details(request, form_id, submission_id):
|
|||||||
'responses': responses,
|
'responses': responses,
|
||||||
'stage_responses': stage_responses
|
'stage_responses': stage_responses
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_interviews_view(request, slug):
|
||||||
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = InterviewScheduleForm(slug, request.POST)
|
||||||
|
|
||||||
|
# Check if this is a confirmation request
|
||||||
|
if 'confirm_schedule' in request.POST:
|
||||||
|
# Get the schedule data from session
|
||||||
|
schedule_data = request.session.get('interview_schedule_data')
|
||||||
|
if not schedule_data:
|
||||||
|
messages.error(request, "Session expired. Please try again.")
|
||||||
|
return redirect('schedule_interviews', slug=slug)
|
||||||
|
|
||||||
|
# Create the interview schedule
|
||||||
|
schedule = InterviewSchedule.objects.create(
|
||||||
|
job=job,
|
||||||
|
created_by=request.user,
|
||||||
|
**schedule_data
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add candidates to the schedule
|
||||||
|
candidates = Candidate.objects.filter(id__in=schedule_data['candidate_ids'])
|
||||||
|
schedule.candidates.set(candidates)
|
||||||
|
|
||||||
|
# Schedule the interviews
|
||||||
|
try:
|
||||||
|
scheduled_count = schedule_interviews(schedule)
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Successfully scheduled {scheduled_count} interviews."
|
||||||
|
)
|
||||||
|
# Clear the session data
|
||||||
|
if 'interview_schedule_data' in request.session:
|
||||||
|
del request.session['interview_schedule_data']
|
||||||
|
return redirect('job_detail', slug=slug)
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f"Error scheduling interviews: {str(e)}"
|
||||||
|
)
|
||||||
|
return redirect('schedule_interviews', slug=slug)
|
||||||
|
|
||||||
|
# This is the initial form submission
|
||||||
|
if form.is_valid():
|
||||||
|
# Get the form data
|
||||||
|
candidates = form.cleaned_data['candidates']
|
||||||
|
start_date = form.cleaned_data['start_date']
|
||||||
|
end_date = form.cleaned_data['end_date']
|
||||||
|
working_days = form.cleaned_data['working_days']
|
||||||
|
start_time = form.cleaned_data['start_time']
|
||||||
|
end_time = form.cleaned_data['end_time']
|
||||||
|
break_start_time = form.cleaned_data['break_start_time']
|
||||||
|
break_end_time = form.cleaned_data['break_end_time']
|
||||||
|
interview_duration = form.cleaned_data['interview_duration']
|
||||||
|
buffer_time = form.cleaned_data['buffer_time']
|
||||||
|
|
||||||
|
# Create a temporary schedule object (not saved to DB)
|
||||||
|
temp_schedule = InterviewSchedule(
|
||||||
|
job=job,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
working_days=working_days,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
break_start_time=break_start_time,
|
||||||
|
break_end_time=break_end_time,
|
||||||
|
interview_duration=interview_duration,
|
||||||
|
buffer_time=buffer_time
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get available slots
|
||||||
|
available_slots = get_available_time_slots(temp_schedule)
|
||||||
|
|
||||||
|
if len(available_slots) < len(candidates):
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}"
|
||||||
|
)
|
||||||
|
return render(request, 'interviews/schedule_interviews.html', {
|
||||||
|
'form': form,
|
||||||
|
'job': job
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create a preview schedule
|
||||||
|
preview_schedule = []
|
||||||
|
for i, candidate in enumerate(candidates):
|
||||||
|
slot = available_slots[i]
|
||||||
|
preview_schedule.append({
|
||||||
|
'candidate': candidate,
|
||||||
|
'date': slot['date'],
|
||||||
|
'time': slot['time']
|
||||||
|
})
|
||||||
|
|
||||||
|
# Save the form data to session for later use
|
||||||
|
schedule_data = {
|
||||||
|
'start_date': start_date.isoformat(),
|
||||||
|
'end_date': end_date.isoformat(),
|
||||||
|
'working_days': working_days,
|
||||||
|
'start_time': start_time.isoformat(),
|
||||||
|
'end_time': end_time.isoformat(),
|
||||||
|
'break_start_time': break_start_time.isoformat() if break_start_time else None,
|
||||||
|
'break_end_time': break_end_time.isoformat() if break_end_time else None,
|
||||||
|
'interview_duration': interview_duration,
|
||||||
|
'buffer_time': buffer_time,
|
||||||
|
'candidate_ids': [c.id for c in candidates]
|
||||||
|
}
|
||||||
|
request.session['interview_schedule_data'] = schedule_data
|
||||||
|
|
||||||
|
# Render the preview page
|
||||||
|
return render(request, 'interviews/preview_schedule.html', {
|
||||||
|
'job': job,
|
||||||
|
'schedule': preview_schedule,
|
||||||
|
'start_date': start_date,
|
||||||
|
'end_date': end_date,
|
||||||
|
'working_days': working_days,
|
||||||
|
'start_time': start_time,
|
||||||
|
'end_time': end_time,
|
||||||
|
'break_start_time': break_start_time,
|
||||||
|
'break_end_time': break_end_time,
|
||||||
|
'interview_duration': interview_duration,
|
||||||
|
'buffer_time': buffer_time
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
form = InterviewScheduleForm(slug=slug)
|
||||||
|
|
||||||
|
return render(request, 'interviews/schedule_interviews.html', {
|
||||||
|
'form': form,
|
||||||
|
'job': job
|
||||||
|
})
|
||||||
226
recruitment/views_integration.py
Normal file
226
recruitment/views_integration.py
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views import View
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
from .models import Source, JobPosting, IntegrationLog
|
||||||
|
from .erp_integration_service import ERPIntegrationService
|
||||||
|
|
||||||
|
|
||||||
|
class ERPIntegrationView(View):
|
||||||
|
"""
|
||||||
|
API endpoint for receiving job requests from ERP system
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'ERP Integration API is available',
|
||||||
|
'version': '1.0.0',
|
||||||
|
'supported_actions': ['create_job', 'update_job']
|
||||||
|
})
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt)
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
"""Handle POST requests from ERP system"""
|
||||||
|
try:
|
||||||
|
# Start timing for processing
|
||||||
|
start_time = datetime.now()
|
||||||
|
|
||||||
|
# Get request data
|
||||||
|
if request.content_type == 'application/json':
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body.decode('utf-8'))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid JSON data'
|
||||||
|
}, status=400)
|
||||||
|
else:
|
||||||
|
data = request.POST.dict()
|
||||||
|
# Get action from request
|
||||||
|
action = data.get('action', '').lower()
|
||||||
|
if not action:
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Action is required'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Validate action
|
||||||
|
valid_actions = ['create_job', 'update_job']
|
||||||
|
if action not in valid_actions:
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid action. Must be one of: {", ".join(valid_actions)}'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Get source identifier
|
||||||
|
source_name = data.get('source_name')
|
||||||
|
source_id = data.get('source_id')
|
||||||
|
|
||||||
|
# Find the source
|
||||||
|
source = None
|
||||||
|
if source_id:
|
||||||
|
source = Source.objects.filter(id=source_id).first()
|
||||||
|
elif source_name:
|
||||||
|
source = Source.objects.filter(name=source_name).first()
|
||||||
|
|
||||||
|
if not source:
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Source not found'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
# Create integration service
|
||||||
|
service = ERPIntegrationService(source)
|
||||||
|
|
||||||
|
# Validate request
|
||||||
|
is_valid, error_msg = service.validate_request(request)
|
||||||
|
if not is_valid:
|
||||||
|
service.log_integration_request(request, 'ERROR', error_message=error_msg, status_code='403')
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': error_msg
|
||||||
|
}, status=403)
|
||||||
|
|
||||||
|
# Log the request
|
||||||
|
service.log_integration_request(request, 'REQUEST')
|
||||||
|
|
||||||
|
# Process based on action
|
||||||
|
if action == 'create_job':
|
||||||
|
result, error_msg = self._create_job(service, data)
|
||||||
|
elif action == 'update_job':
|
||||||
|
result, error_msg = self._update_job(service, data)
|
||||||
|
|
||||||
|
# Calculate processing time
|
||||||
|
processing_time = (datetime.now() - start_time).total_seconds()
|
||||||
|
|
||||||
|
# Log the result
|
||||||
|
status_code = '200' if not error_msg else '400'
|
||||||
|
service.log_integration_request(
|
||||||
|
request,
|
||||||
|
'RESPONSE' if not error_msg else 'ERROR',
|
||||||
|
response_data={'result': result} if result else {},
|
||||||
|
status_code=status_code,
|
||||||
|
processing_time=processing_time,
|
||||||
|
error_message=error_msg
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return response
|
||||||
|
if error_msg:
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': error_msg,
|
||||||
|
'processing_time': processing_time
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Job {action.replace("_", " ")} successfully',
|
||||||
|
'data': result,
|
||||||
|
'processing_time': processing_time
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Error in ERP integration: {str(e)}", exc_info=True)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Internal server error'
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def _create_job(self, service: ERPIntegrationService, data: Dict[str, Any]) -> tuple[Dict[str, Any], str]:
|
||||||
|
"""Create a new job from ERP data"""
|
||||||
|
# Validate ERP data
|
||||||
|
is_valid, error_msg = service.validate_erp_data(data)
|
||||||
|
if not is_valid:
|
||||||
|
return None, error_msg
|
||||||
|
|
||||||
|
# Create job from ERP data
|
||||||
|
job, error_msg = service.create_job_from_erp(data)
|
||||||
|
if error_msg:
|
||||||
|
return None, error_msg
|
||||||
|
|
||||||
|
# Prepare response data
|
||||||
|
response_data = {
|
||||||
|
'job_id': job.internal_job_id,
|
||||||
|
'title': job.title,
|
||||||
|
'status': job.status,
|
||||||
|
'created_at': job.created_at.isoformat(),
|
||||||
|
'message': 'Job created successfully'
|
||||||
|
}
|
||||||
|
|
||||||
|
return response_data, ""
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def _update_job(self, service: ERPIntegrationService, data: Dict[str, Any]) -> tuple[Dict[str, Any], str]:
|
||||||
|
"""Update an existing job from ERP data"""
|
||||||
|
# Get job ID from request
|
||||||
|
job_id = data.get('job_id')
|
||||||
|
if not job_id:
|
||||||
|
return None, "Job ID is required for update"
|
||||||
|
|
||||||
|
# Validate ERP data
|
||||||
|
is_valid, error_msg = service.validate_erp_data(data)
|
||||||
|
if not is_valid:
|
||||||
|
return None, error_msg
|
||||||
|
|
||||||
|
# Update job from ERP data
|
||||||
|
job, error_msg = service.update_job_from_erp(job_id, data)
|
||||||
|
if error_msg:
|
||||||
|
return None, error_msg
|
||||||
|
|
||||||
|
# Prepare response data
|
||||||
|
response_data = {
|
||||||
|
'job_id': job.internal_job_id,
|
||||||
|
'title': job.title,
|
||||||
|
'status': job.status,
|
||||||
|
'updated_at': job.updated_at.isoformat(),
|
||||||
|
'message': 'Job updated successfully'
|
||||||
|
}
|
||||||
|
|
||||||
|
return response_data, ""
|
||||||
|
|
||||||
|
|
||||||
|
# Specific endpoint for creating jobs (POST only)
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
@csrf_exempt
|
||||||
|
def erp_create_job_view(request):
|
||||||
|
"""View for creating jobs from ERP (simpler endpoint)"""
|
||||||
|
view = ERPIntegrationView()
|
||||||
|
return view.post(request)
|
||||||
|
|
||||||
|
|
||||||
|
# Specific endpoint for updating jobs (POST only)
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
@csrf_exempt
|
||||||
|
def erp_update_job_view(request):
|
||||||
|
"""View for updating jobs from ERP (simpler endpoint)"""
|
||||||
|
view = ERPIntegrationView()
|
||||||
|
return view.post(request)
|
||||||
|
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def erp_integration_health(request):
|
||||||
|
"""Health check endpoint for ERP integration"""
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'healthy',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'services': {
|
||||||
|
'erp_integration': 'available',
|
||||||
|
'database': 'connected'
|
||||||
|
}
|
||||||
|
})
|
||||||
208
templates/forms/create_form_template.html
Normal file
208
templates/forms/create_form_template.html
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}Create Form Template - ATS{% endblock %}
|
||||||
|
|
||||||
|
{% block customCSS %}
|
||||||
|
<style>
|
||||||
|
/* ================================================= */
|
||||||
|
/* THEME VARIABLES AND GLOBAL STYLES */
|
||||||
|
/* ================================================= */
|
||||||
|
:root {
|
||||||
|
--kaauh-teal: #00636e;
|
||||||
|
--kaauh-teal-dark: #004a53;
|
||||||
|
--kaauh-border: #eaeff3;
|
||||||
|
--kaauh-primary-text: #343a40;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary Color Overrides */
|
||||||
|
.text-primary { color: var(--kaauh-teal) !important; }
|
||||||
|
|
||||||
|
/* Main Action Button Style */
|
||||||
|
.btn-main-action, .btn-primary {
|
||||||
|
background-color: var(--kaauh-teal);
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.btn-main-action:hover, .btn-primary:hover {
|
||||||
|
background-color: var(--kaauh-teal-dark);
|
||||||
|
border-color: var(--kaauh-teal-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card enhancements */
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Header Theming */
|
||||||
|
.card-header {
|
||||||
|
background-color: #f0f8ff !important; /* Light blue tint for header */
|
||||||
|
border-bottom: 1px solid var(--kaauh-border);
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-radius: 0.75rem 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
.card-header h3 {
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.card-header .fas {
|
||||||
|
color: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styling */
|
||||||
|
.form-control {
|
||||||
|
border-color: var(--kaauh-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--kaauh-primary-text);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input:checked {
|
||||||
|
background-color: var(--kaauh-teal);
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styling */
|
||||||
|
.modal-content {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
border-bottom: 1px solid var(--kaauh-border);
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background-color: #f0f8ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
border-top: 1px solid var(--kaauh-border);
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error message styling */
|
||||||
|
.invalid-feedback {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control.is-invalid {
|
||||||
|
border-color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success message styling */
|
||||||
|
.alert-success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
border-color: #c3e6cb;
|
||||||
|
color: #155724;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4 pb-2 border-bottom border-primary">
|
||||||
|
<h1 class="h3 mb-0 fw-bold text-primary">
|
||||||
|
<i class="fas fa-file-alt me-2"></i>Create Form Template
|
||||||
|
</h1>
|
||||||
|
<a href="{% url 'form_templates_list' %}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-1"></i>Back to Templates
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="h5 mb-0"><i class="fas fa-plus-circle me-2"></i>New Form Template</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<form method="post" id="createFormTemplate">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form|crispy }}
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{% url 'form_templates_list' %}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-times me-1"></i>Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-main-action">
|
||||||
|
<i class="fas fa-save me-1"></i>Create Template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="fas fa-check-circle me-2"></i>{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Add form validation
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('createFormTemplate');
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(event) {
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
// Validate template name
|
||||||
|
const nameField = form.querySelector('#id_name');
|
||||||
|
if (!nameField.value.trim()) {
|
||||||
|
nameField.classList.add('is-invalid');
|
||||||
|
isValid = false;
|
||||||
|
} else {
|
||||||
|
nameField.classList.remove('is-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove validation errors on input
|
||||||
|
const nameField = form.querySelector('#id_name');
|
||||||
|
nameField.addEventListener('input', function() {
|
||||||
|
if (this.value.trim()) {
|
||||||
|
this.classList.remove('is-invalid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -7,228 +7,57 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<h2>Submission Details</h2>
|
||||||
<h1><i class="fas fa-file-alt"></i> Submission Details</h1>
|
<a href="" class="btn btn-outline-secondary">
|
||||||
<p class="text-muted mb-0">{{ form.name }}</p>
|
<i class="fas fa-arrow-left"></i> Back
|
||||||
</div>
|
</a>
|
||||||
<div>
|
|
||||||
<a href="" class="btn btn-outline-primary me-2">
|
|
||||||
<i class="fas fa-arrow-left"></i> Back to Submissions
|
|
||||||
</a>
|
|
||||||
<a href="" class="btn btn-outline-primary me-2" target="_blank">
|
|
||||||
<i class="fas fa-eye"></i> Preview Form
|
|
||||||
</a>
|
|
||||||
<button class="btn btn-success" onclick="exportSubmission()">
|
|
||||||
<i class="fas fa-download"></i> Export
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submission Summary -->
|
<!-- Basic Information -->
|
||||||
<div class="row mb-4">
|
<div class="card mb-3">
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card bg-primary text-white">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<div>
|
|
||||||
<h4 class="mb-0">{{ submission.id }}</h4>
|
|
||||||
<small>Submission ID</small>
|
|
||||||
</div>
|
|
||||||
<div class="align-self-center">
|
|
||||||
<i class="fas fa-hashtag fa-2x opacity-75"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card bg-success text-white">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<div>
|
|
||||||
<h4 class="mb-0">{{ submission.submitted_at|date:"M d, Y" }}</h4>
|
|
||||||
<small>Submitted</small>
|
|
||||||
</div>
|
|
||||||
<div class="align-self-center">
|
|
||||||
<i class="fas fa-calendar-check fa-2x opacity-75"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card bg-info text-white">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<div>
|
|
||||||
<h4 class="mb-0">{{ responses|length }}</h4>
|
|
||||||
<small>Fields Completed</small>
|
|
||||||
</div>
|
|
||||||
<div class="align-self-center">
|
|
||||||
<i class="fas fa-tasks fa-2x opacity-75"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submission Information -->
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0"><i class="fas fa-info-circle"></i> Submission Information</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
<table class="table table-sm">
|
<strong>Submission ID:</strong> {{ submission.id }}
|
||||||
<tr>
|
|
||||||
<td><strong>Form:</strong></td>
|
|
||||||
<td>{{ form.name }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Submitted:</strong></td>
|
|
||||||
<td>{{ submission.submitted_at|date:"F d, Y H:i" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% if submission.submitted_by %}
|
|
||||||
<tr>
|
|
||||||
<td><strong>Submitted By:</strong></td>
|
|
||||||
<td>{{ submission.submitted_by.get_full_name|default:submission.submitted_by.username }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
{% if submission.applicant_name %}
|
<strong>Submitted:</strong> {{ submission.submitted_at|date:"M d, Y H:i" }}
|
||||||
<table class="table table-sm">
|
</div>
|
||||||
<tr>
|
<div class="col-md-4">
|
||||||
<td><strong>Applicant Name:</strong></td>
|
<strong>Form:</strong> {{ form.name }}
|
||||||
<td>{{ submission.applicant_name }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if submission.applicant_email %}
|
|
||||||
<tr>
|
|
||||||
<td><strong>Email:</strong></td>
|
|
||||||
<td>{{ submission.applicant_email }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% if submission.applicant_name or submission.applicant_email %}
|
||||||
</div>
|
<div class="row mt-2">
|
||||||
|
{% if submission.applicant_name %}
|
||||||
<!-- Form Responses by Stage -->
|
<div class="col-md-6">
|
||||||
<div class="card">
|
<strong>Applicant Name:</strong> {{ submission.applicant_name }}
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0"><i class="fas fa-clipboard-list"></i> Submitted Responses</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% for stage in stages %}
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="card border-primary">
|
|
||||||
<div class="card-header bg-primary text-white">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
<i class="fas fa-layer-group"></i> {{ stage.name }}
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% get_stage_responses stage_responses stage.id as stage_data %}
|
|
||||||
{% if stage_data %}
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-hover">
|
|
||||||
<thead class="table-dark">
|
|
||||||
<tr>
|
|
||||||
<th>Field Label</th>
|
|
||||||
<th>Field Type</th>
|
|
||||||
<th>Response Value</th>
|
|
||||||
<th>File</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for response in stage_data %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{{ response.field.label }}
|
|
||||||
{% if response.field.required %}
|
|
||||||
<span class="text-danger">*</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ response.field.get_field_type_display }}</td>
|
|
||||||
<td>
|
|
||||||
{% if response.uploaded_file %}
|
|
||||||
<span class="text-primary">File: {{ response.uploaded_file.name }}</span>
|
|
||||||
{% elif response.value %}
|
|
||||||
{% if response.field.field_type == 'checkbox' and response.value|length > 0 %}
|
|
||||||
<ul class="list-unstyled mb-0">
|
|
||||||
{% for val in response.value %}
|
|
||||||
<li><i class="fas fa-check text-success"></i> {{ val }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% elif response.field.field_type == 'radio' %}
|
|
||||||
<span class="badge bg-info">{{ response.value }}</span>
|
|
||||||
{% elif response.field.field_type == 'select' %}
|
|
||||||
<span class="badge bg-secondary">{{ response.value }}</span>
|
|
||||||
{% else %}
|
|
||||||
<p class="mb-0">{{ response.value|linebreaksbr }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">Not provided</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if response.uploaded_file %}
|
|
||||||
<a href="{{ response.uploaded_file.url }}" class="btn btn-sm btn-outline-primary" target="_blank">
|
|
||||||
<i class="fas fa-download"></i> Download
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center text-muted py-3">
|
|
||||||
<i class="fas fa-inbox fa-2x mb-2"></i>
|
|
||||||
<p>No responses submitted for this stage.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if submission.applicant_email %}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Email:</strong> {{ submission.applicant_email }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if not forloop.last %}
|
|
||||||
<hr>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% empty %}
|
|
||||||
<div class="text-center text-muted py-5">
|
|
||||||
<i class="fas fa-inbox fa-3x mb-3"></i>
|
|
||||||
<h4>No stages found</h4>
|
|
||||||
<p>This form doesn't have any stages defined.</p>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Raw Data Table -->
|
<!-- Responses Table -->
|
||||||
<div class="card mt-4">
|
<div class="card">
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0"><i class="fas fa-table"></i> All Responses (Raw Data)</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<h5 class="mb-3">Responses</h5>
|
||||||
{% get_all_responses_flat stage_responses as all_responses %}
|
{% get_all_responses_flat stage_responses as all_responses %}
|
||||||
{% if all_responses %}
|
{% if all_responses %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped table-hover">
|
<table class="table table-striped">
|
||||||
<thead class="table-dark">
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Stage</th>
|
|
||||||
<th>Field Label</th>
|
<th>Field Label</th>
|
||||||
<th>Field Type</th>
|
|
||||||
<th>Required</th>
|
|
||||||
<th>Response Value</th>
|
<th>Response Value</th>
|
||||||
<th>File</th>
|
<th>File</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -236,35 +65,24 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for response in all_responses %}
|
{% for response in all_responses %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ response.stage_name }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
{{ response.field_label }}
|
<strong>{{ response.field_label }}</strong>
|
||||||
{% if response.required %}
|
{% if response.required %}
|
||||||
<span class="text-danger">*</span>
|
<span class="text-danger">*</span>
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ response.field_type }}</td>
|
|
||||||
<td>
|
|
||||||
{% if response.required %}
|
|
||||||
<span class="badge bg-danger">Yes</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-secondary">No</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if response.uploaded_file %}
|
{% if response.uploaded_file %}
|
||||||
<span class="text-primary">File: {{ response.uploaded_file.name }}</span>
|
<span class="text-primary">File: {{ response.uploaded_file.name }}</span>
|
||||||
{% elif response.value %}
|
{% elif response.value %}
|
||||||
{% if response.field_type == 'checkbox' and response.value|length > 0 %}
|
{% if response.field_type == 'checkbox' and response.value|length > 0 %}
|
||||||
<ul class="list-unstyled mb-0">
|
<div>
|
||||||
{% for val in response.value %}
|
{% for val in response.value %}
|
||||||
<li><i class="fas fa-check text-success"></i> {{ val }}</li>
|
<span class="badge bg-secondary me-1">{{ val }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</div>
|
||||||
{% elif response.field_type == 'radio' %}
|
{% elif response.field_type == 'radio' or response.field_type == 'select' %}
|
||||||
<span class="badge bg-info">{{ response.value }}</span>
|
<span class="badge bg-info">{{ response.value }}</span>
|
||||||
{% elif response.field_type == 'select' %}
|
|
||||||
<span class="badge bg-secondary">{{ response.value }}</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="mb-0">{{ response.value|linebreaksbr }}</p>
|
<p class="mb-0">{{ response.value|linebreaksbr }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -274,9 +92,9 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if response.uploaded_file %}
|
{% if response.uploaded_file %}
|
||||||
<a href="{{ response.uploaded_file.url }}" class="btn btn-sm btn-outline-primary" target="_blank">
|
<a href="{{ response.uploaded_file.url }}" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||||
<i class="fas fa-download"></i> Download
|
<i class="fas fa-download"></i> Download
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -285,138 +103,28 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center text-muted py-3">
|
<div class="text-center text-muted py-4">
|
||||||
<i class="fas fa-inbox fa-2x mb-2"></i>
|
|
||||||
<p>No responses found for this submission.</p>
|
<p>No responses found for this submission.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
|
||||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="deleteModalLabel">
|
|
||||||
<i class="fas fa-exclamation-triangle text-danger"></i> Confirm Delete
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p>Are you sure you want to delete this submission? This action cannot be undone.</p>
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
<strong>Submission ID:</strong> {{ submission.id }}<br>
|
|
||||||
<strong>Submitted:</strong> {{ submission.submitted_at|date:"M d, Y H:i" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
|
|
||||||
<i class="fas fa-trash"></i> Delete Submission
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
.response-value {
|
/* Minimal styling */
|
||||||
max-height: 200px;
|
.table th {
|
||||||
overflow-y: auto;
|
border-top: none;
|
||||||
}
|
|
||||||
.response-value ul {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
|
||||||
transition: box-shadow 0.15s ease-in-out;
|
|
||||||
}
|
|
||||||
.card:hover {
|
|
||||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
.card-title {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: #495057;
|
||||||
|
}
|
||||||
|
.table td {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.response-value {
|
||||||
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
function exportSubmission() {
|
|
||||||
// Create export options modal or direct download
|
|
||||||
const format = prompt('Export format (csv, json, pdf):', 'csv');
|
|
||||||
if (format) {
|
|
||||||
window.open(`/recruitment/api/forms/{{ form.id }}/submissions/{{ submission.id }}/export/?format=${format}`, '_blank');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle delete confirmation
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const deleteBtn = document.querySelector('a[href*="delete"]');
|
|
||||||
if (deleteBtn) {
|
|
||||||
deleteBtn.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
|
||||||
modal.show();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle confirm delete
|
|
||||||
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
|
|
||||||
if (confirmDeleteBtn) {
|
|
||||||
confirmDeleteBtn.addEventListener('click', function() {
|
|
||||||
fetch(`/recruitment/api/forms/{{ form.id }}/submissions/{{ submission.id }}/delete/`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'X-CSRFToken': getCsrfToken(),
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
alert('Submission deleted successfully!');
|
|
||||||
|
|
||||||
} else {
|
|
||||||
alert('Error deleting submission: ' + data.error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Error deleting submission');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function getCsrfToken() {
|
|
||||||
const cookie = document.cookie.split(';').find(c => c.trim().startsWith('csrftoken='));
|
|
||||||
return cookie ? cookie.split('=')[1] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print functionality
|
|
||||||
function printSubmission() {
|
|
||||||
window.print();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add print button to header
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const headerActions = document.querySelector('.d-flex.justify-content-between.align-items-center.mb-4');
|
|
||||||
if (headerActions) {
|
|
||||||
const printBtn = document.createElement('button');
|
|
||||||
printBtn.className = 'btn btn-outline-secondary me-2';
|
|
||||||
printBtn.innerHTML = '<i class="fas fa-print"></i> Print';
|
|
||||||
printBtn.onclick = printSubmission;
|
|
||||||
headerActions.insertBefore(printBtn, headerActions.firstChild);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load static i18n %}
|
{% load static i18n crispy_forms_tags %}
|
||||||
|
|
||||||
{% block title %}Form Templates - ATS{% endblock %}
|
{% block title %}Form Templates - ATS{% endblock %}
|
||||||
|
|
||||||
@ -102,6 +102,8 @@
|
|||||||
color: rgba(255, 255, 255, 0.7) !important;
|
color: rgba(255, 255, 255, 0.7) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stats Theming */
|
||||||
|
|
||||||
/* --- Content Styles (Stats, Description) --- */
|
/* --- Content Styles (Stats, Description) --- */
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
@ -116,12 +118,11 @@
|
|||||||
.card-description {
|
.card-description {
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
color: var(--kaauh-primary-text);
|
color: var(--kaauh-primary-text);
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Form/Search Input Theming (Matching Job List) --- */
|
/* Search Input Theming */
|
||||||
.form-control-search {
|
.form-control {
|
||||||
box-shadow: none;
|
border-radius: 0.5rem 0 0 0.5rem;
|
||||||
border-color: var(--kaauh-border);
|
border-color: var(--kaauh-border);
|
||||||
border-radius: 0 0.5rem 0.5rem 0;
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
}
|
}
|
||||||
@ -147,7 +148,7 @@
|
|||||||
--bs-btn-hover-color: white;
|
--bs-btn-hover-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Empty State Theming --- */
|
/* Empty State Theming */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem 1rem;
|
padding: 3rem 1rem;
|
||||||
@ -188,9 +189,9 @@
|
|||||||
<h1 class="h3 mb-0 fw-bold" style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
<h1 class="h3 mb-0 fw-bold" style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||||
<i class="fas fa-file-alt me-2"></i>{% trans "Form Templates" %}
|
<i class="fas fa-file-alt me-2"></i>{% trans "Form Templates" %}
|
||||||
</h1>
|
</h1>
|
||||||
<a href="{% url 'form_builder' %}" class="btn btn-main-action">
|
<button type="button" class="btn btn-main-action" data-bs-toggle="modal" data-bs-target="#createTemplateModal">
|
||||||
<i class="fas fa-plus me-1"></i> {% trans "Create New Template" %}
|
<i class="fas fa-plus me-1"></i> Create New Template
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Search/Filter Area - Matching Job List Structure #}
|
{# Search/Filter Area - Matching Job List Structure #}
|
||||||
@ -236,7 +237,8 @@
|
|||||||
<div class="card template-card h-100">
|
<div class="card template-card h-100">
|
||||||
<div class="card-header ">
|
<div class="card-header ">
|
||||||
<h3 class="h5 mb-2">{{ template.name }}</h3>
|
<h3 class="h5 mb-2">{{ template.name }}</h3>
|
||||||
<div class="d-flex justify-content-between small">
|
<span><i class="fas fa-sync-alt me-1"></i> {{ template.job }}</span>
|
||||||
|
<div class="d-flex justify-content-between text-muted small">
|
||||||
<span><i class="fas fa-calendar me-1"></i> {{ template.created_at|date:"M d, Y" }}</span>
|
<span><i class="fas fa-calendar me-1"></i> {{ template.created_at|date:"M d, Y" }}</span>
|
||||||
<span><i class="fas fa-sync-alt me-1"></i> {{ template.updated_at|timesince }} {% trans "ago" %}</span>
|
<span><i class="fas fa-sync-alt me-1"></i> {{ template.updated_at|timesince }} {% trans "ago" %}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -267,6 +269,7 @@
|
|||||||
{# Action area - visually separated with pt-2 border-top #}
|
{# Action area - visually separated with pt-2 border-top #}
|
||||||
<div class="mt-auto pt-2 border-top">
|
<div class="mt-auto pt-2 border-top">
|
||||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||||
|
|
||||||
<a href="{% url 'form_wizard' template.id %}" class="btn btn-outline-secondary btn-sm action-btn">
|
<a href="{% url 'form_wizard' template.id %}" class="btn btn-outline-secondary btn-sm action-btn">
|
||||||
<i class="fas fa-eye me-1"></i> {% trans "Preview" %}
|
<i class="fas fa-eye me-1"></i> {% trans "Preview" %}
|
||||||
</a>
|
</a>
|
||||||
@ -337,6 +340,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'includes/delete_modal.html' %}
|
{% include 'includes/delete_modal.html' %}
|
||||||
|
|
||||||
|
<!-- Create Template Modal -->
|
||||||
|
<div class="modal fade" id="createTemplateModal" tabindex="-1" aria-labelledby="createTemplateModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="createTemplateModalLabel">
|
||||||
|
<i class="fas fa-file-alt me-2"></i>Create New Form Template
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="createTemplateForm" method="post" action="{% url 'create_form_template' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{form|crispy}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" form="createTemplateForm" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save me-1"></i>Create Template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block customJS %}
|
{% block customJS %}
|
||||||
@ -416,7 +445,7 @@
|
|||||||
|
|
||||||
if (!templateToDelete) return;
|
if (!templateToDelete) return;
|
||||||
|
|
||||||
// This CSRF token selector assumes it's present in your base template or form
|
// This relies on 'csrfToken' being defined somewhere, which is typical for Django templates.
|
||||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -473,5 +502,50 @@
|
|||||||
document.getElementById('deleteModal').addEventListener('hidden.bs.modal', function() {
|
document.getElementById('deleteModal').addEventListener('hidden.bs.modal', function() {
|
||||||
templateToDelete = null;
|
templateToDelete = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle create template form submission
|
||||||
|
document.getElementById('createTemplateForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const form = e.target;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.success) {
|
||||||
|
// Show success toast
|
||||||
|
createToast(result.message || 'Template created successfully!');
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('createTemplateModal')).hide();
|
||||||
|
|
||||||
|
// Clear form
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
// Redirect to form builder with new template ID
|
||||||
|
if (result.template_id) {
|
||||||
|
window.location.href = `{% url 'form_builder' %}${result.template_id}/`;
|
||||||
|
} else {
|
||||||
|
// Fallback to template list if no ID is returned
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show error toast
|
||||||
|
createToast('Error: ' + (result.message || 'Could not create template.'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
createToast('An error occurred while creating the template.', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
76
templates/interviews/email/interview_invitation.html
Normal file
76
templates/interviews/email/interview_invitation.html
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<!-- templates/interviews/email/interview_invitation.html -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Interview Invitation</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Interview Invitation</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Dear {{ candidate_name }},</p>
|
||||||
|
<p>We are pleased to invite you for an interview for the position of <strong>{{ job_title }}</strong> at <strong>{{ company_name }}</strong>.</p>
|
||||||
|
|
||||||
|
<h3>Interview Details:</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Date:</strong> {{ interview_date|date:"l, F j, Y" }}</li>
|
||||||
|
<li><strong>Time:</strong> {{ interview_time|time:"g:i A" }}</li>
|
||||||
|
<li><strong>Platform:</strong> Zoom Video Conference</li>
|
||||||
|
<li><strong>Meeting ID:</strong> {{ meeting_id }}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Please join the interview using the link below:</p>
|
||||||
|
<a href="{{ join_url }}" class="button">Join Interview</a>
|
||||||
|
|
||||||
|
<p>If you have any questions or need to reschedule, please contact us at your earliest convenience.</p>
|
||||||
|
|
||||||
|
<p>We look forward to speaking with you!</p>
|
||||||
|
|
||||||
|
<p>Best regards,<br>
|
||||||
|
The Hiring Team<br>
|
||||||
|
{{ company_name }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>This is an automated message. Please do not reply to this email.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
templates/interviews/email/interview_invitation.txt
Normal file
23
templates/interviews/email/interview_invitation.txt
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<!-- templates/interviews/email/interview_invitation.txt -->
|
||||||
|
Interview Invitation
|
||||||
|
|
||||||
|
Dear {{ candidate_name }},
|
||||||
|
|
||||||
|
We are pleased to invite you for an interview for the position of {{ job_title }} at {{ company_name }}.
|
||||||
|
|
||||||
|
Interview Details:
|
||||||
|
- Date: {{ interview_date|date:"l, F j, Y" }}
|
||||||
|
- Time: {{ interview_time|time:"g:i A" }}
|
||||||
|
- Platform: Zoom Video Conference
|
||||||
|
- Meeting ID: {{ meeting_id }}
|
||||||
|
|
||||||
|
Please join the interview using the link below:
|
||||||
|
{{ join_url }}
|
||||||
|
|
||||||
|
If you have any questions or need to reschedule, please contact us at your earliest convenience.
|
||||||
|
|
||||||
|
We look forward to speaking with you!
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The Hiring Team
|
||||||
|
{{ company_name }}
|
||||||
125
templates/interviews/preview_schedule.html
Normal file
125
templates/interviews/preview_schedule.html
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<!-- templates/interviews/preview_schedule.html -->
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h1>Interview Schedule Preview for {{ job.title }}</h1>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Schedule Details</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p><strong>Period:</strong> {{ start_date|date:"F j, Y" }} to {{ end_date|date:"F j, Y" }}</p>
|
||||||
|
<p><strong>Working Days:</strong>
|
||||||
|
{% for day_id in working_days %}
|
||||||
|
{% if day_id == 0 %}Monday{% endif %}
|
||||||
|
{% if day_id == 1 %}Tuesday{% endif %}
|
||||||
|
{% if day_id == 2 %}Wednesday{% endif %}
|
||||||
|
{% if day_id == 3 %}Thursday{% endif %}
|
||||||
|
{% if day_id == 4 %}Friday{% endif %}
|
||||||
|
{% if day_id == 5 %}Saturday{% endif %}
|
||||||
|
{% if day_id == 6 %}Sunday{% endif %}
|
||||||
|
{% if not forloop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
<p><strong>Working Hours:</strong> {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
{% if break_start_time and break_end_time %}
|
||||||
|
<p><strong>Break Time:</strong> {{ break_start_time|time:"g:i A" }} to {{ break_end_time|time:"g:i A" }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p><strong>Interview Duration:</strong> {{ interview_duration }} minutes</p>
|
||||||
|
<p><strong>Buffer Time:</strong> {{ buffer_time }} minutes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Scheduled Interviews</h5>
|
||||||
|
|
||||||
|
<!-- Calendar View -->
|
||||||
|
<div id="calendar-container">
|
||||||
|
<div id="calendar"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List View -->
|
||||||
|
<div class="table-responsive mt-4">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Candidate</th>
|
||||||
|
<th>Email</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in schedule %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.date|date:"F j, Y" }}</td>
|
||||||
|
<td>{{ item.time|time:"g:i A" }}</td>
|
||||||
|
<td>{{ item.candidate.name }}</td>
|
||||||
|
<td>{{ item.candidate.email }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="mt-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" name="confirm_schedule" class="btn btn-success">
|
||||||
|
<i class="fas fa-check"></i> Confirm Schedule
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Back to Edit
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Include FullCalendar CSS and JS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.css" rel="stylesheet">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var calendarEl = document.getElementById('calendar');
|
||||||
|
var calendar = new FullCalendar.Calendar(calendarEl, {
|
||||||
|
initialView: 'dayGridMonth',
|
||||||
|
headerToolbar: {
|
||||||
|
left: 'prev,next today',
|
||||||
|
center: 'title',
|
||||||
|
right: 'dayGridMonth,timeGridWeek'
|
||||||
|
},
|
||||||
|
events: [
|
||||||
|
{% for item in schedule %}
|
||||||
|
{
|
||||||
|
title: '{{ item.candidate.name }}',
|
||||||
|
start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}',
|
||||||
|
url: '#',
|
||||||
|
extendedProps: {
|
||||||
|
email: '{{ item.candidate.email }}',
|
||||||
|
time: '{{ item.time|time:"g:i A" }}'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{% endfor %}
|
||||||
|
],
|
||||||
|
eventClick: function(info) {
|
||||||
|
// Show candidate details in a modal or alert
|
||||||
|
alert('Candidate: ' + info.event.title +
|
||||||
|
'\nDate: ' + info.event.start.toLocaleDateString() +
|
||||||
|
'\nTime: ' + info.event.extendedProps.time +
|
||||||
|
'\nEmail: ' + info.event.extendedProps.email);
|
||||||
|
info.jsEvent.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
calendar.render();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
97
templates/interviews/schedule_interviews.html
Normal file
97
templates/interviews/schedule_interviews.html
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<!-- templates/interviews/schedule_interviews.html -->
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h1>Schedule Interviews for {{ job.title }}</h1>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5>Select Candidates</h5>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.candidates }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5>Schedule Details</h5>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.start_date.id_for_label }}">Start Date</label>
|
||||||
|
{{ form.start_date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.end_date.id_for_label }}">End Date</label>
|
||||||
|
{{ form.end_date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Working Days</label>
|
||||||
|
{{ form.working_days }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.start_time.id_for_label }}">Start Time</label>
|
||||||
|
{{ form.start_time }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.end_time.id_for_label }}">End Time</label>
|
||||||
|
{{ form.end_time }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.break_start_time.id_for_label }}">Break Start Time</label>
|
||||||
|
{{ form.break_start_time }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.break_end_time.id_for_label }}">Break End Time</label>
|
||||||
|
{{ form.break_end_time }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.interview_duration.id_for_label }}">Interview Duration (minutes)</label>
|
||||||
|
{{ form.interview_duration }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.buffer_time.id_for_label }}">Buffer Time (minutes)</label>
|
||||||
|
{{ form.buffer_time }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary">Schedule Interviews</button>
|
||||||
|
<a href="{% url 'job_detail' slug=job.slug %}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
230
templates/jobs/job_detail_candidate.html
Normal file
230
templates/jobs/job_detail_candidate.html
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ job.title }} - University ATS{% endblock %}
|
||||||
|
{% block customCSS %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* THEME STYLES (Keep from previous response) */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
:root {
|
||||||
|
--kaauh-teal: #00636e;
|
||||||
|
--kaauh-teal-dark: #004a53;
|
||||||
|
--success: #198754;
|
||||||
|
/* Define a subtle background for mobile sticky bar */
|
||||||
|
--light-bg: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* MOBILE RESPONSIVE STYLES */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* 1. Mobile Fixed Footer Bar for Application */
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
/* Fix the "Apply" button bar to the bottom on mobile/tablet */
|
||||||
|
.mobile-fixed-apply-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: var(--light-bg); /* Use a light background */
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
z-index: 1000; /* Ensure it stays above everything */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust header font size for small screens */
|
||||||
|
.card-header h2 {
|
||||||
|
font-size: 1.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Change job overview grid to single column on small phones */
|
||||||
|
.row-cols-md-2 {
|
||||||
|
--bs-gutter-x: 1.5rem;
|
||||||
|
--bs-gutter-y: 1rem;
|
||||||
|
}
|
||||||
|
.row-cols-md-2 > .col {
|
||||||
|
flex: 0 0 100%; /* force to 1 column */
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-5">
|
||||||
|
|
||||||
|
<div class="col-lg-4 order-lg-2 order-1 d-none d-lg-block">
|
||||||
|
<div class="card shadow-sm sticky-top" style="top: 90px;">
|
||||||
|
<div class="card-header bg-kaauh-teal-dark text-white">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-file-signature me-2"></i>Ready to Apply?</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<p class="text-muted">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> Apply for this Position
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% comment %} <p class="text-muted mt-3 mb-0">
|
||||||
|
<small>Application ID: **{{ job.pk }}**</small>
|
||||||
|
</p> {% endcomment %}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{% with status_class=job.status|lower %}
|
||||||
|
<span class="badge
|
||||||
|
{% if status_class == 'open' %}bg-success
|
||||||
|
{% elif status_class == 'closed' %}bg-danger
|
||||||
|
{% elif status_class == 'draft' %}bg-secondary
|
||||||
|
{% else %}bg-primary
|
||||||
|
{% endif %}
|
||||||
|
status-badge fw-bold p-2">
|
||||||
|
{{ job.get_status_display }}
|
||||||
|
</span>
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<h4 class="mb-3" style="color: var(--kaauh-teal-dark);">Job Overview</h4>
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 g-3 mb-4">
|
||||||
|
|
||||||
|
{% if job.salary_range %}
|
||||||
|
<div class="col">
|
||||||
|
<i class="fas fa-money-bill-wave text-success me-2"></i>
|
||||||
|
<strong>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>Deadline:</strong>
|
||||||
|
{% if job.application_deadline %}
|
||||||
|
{{ job.application_deadline|date:"M d, Y" }}
|
||||||
|
{% if job.is_expired %}
|
||||||
|
<span class="badge bg-danger ms-2">EXPIRED</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Not specified</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<i class="fas fa-briefcase text-muted me-2"></i>
|
||||||
|
<strong>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>Location:</strong> {{ job.get_location_display }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<i class="fas fa-building text-muted me-2"></i>
|
||||||
|
<strong>Department:</strong> {{ job.department|default:"Not specified" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<i class="fas fa-hashtag text-muted me-2"></i>
|
||||||
|
<strong>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>Workplace:</strong> {{ job.get_workplace_type_display }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<i class="fas fa-user-tie text-muted me-2"></i>
|
||||||
|
<strong>Created By:</strong> {{ job.created_by|default:"N/A" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if job.description %}
|
||||||
|
<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>Job Description
|
||||||
|
</h5>
|
||||||
|
<div class="text-secondary">{{ job.description|linebreaks }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if job.qualifications %}
|
||||||
|
<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>Qualifications
|
||||||
|
</h5>
|
||||||
|
<div class="text-secondary">{{ job.qualifications|linebreaks }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if job.benefits %}
|
||||||
|
<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>Benefits
|
||||||
|
</h5>
|
||||||
|
<div class="text-secondary">{{ job.benefits|linebreaks }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if job.application_instructions %}
|
||||||
|
<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>Application Instructions
|
||||||
|
</h5>
|
||||||
|
<div class="text-secondary">{{ job.application_instructions|linebreaks }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</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> Apply for this Position
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -179,11 +179,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="card-text text-muted small mb-3">
|
<p class="card-text text-muted small">
|
||||||
<i class="fas fa-building fa-fw"></i> {{ job.department|default:"No Department" }}<br>
|
<i class="fas fa-building"></i> {{ job.department|default:"No Department" }}<br>
|
||||||
<i class="fas fa-map-marker-alt fa-fw"></i> {{ job.get_location_display }}<br>
|
<i class="fas fa-map-marker-alt"></i> {{ job.get_location_display }}<br>
|
||||||
<i class="fas fa-clock fa-fw"></i> {{ job.get_job_type_display }}
|
<i class="fas fa-clock"></i> {{ job.get_job_type_display }}<br>
|
||||||
</p>
|
<i class="fas fa-briefcase"></i> {{ job.get_source }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="mt-auto pt-2 border-top">
|
<div class="mt-auto pt-2 border-top">
|
||||||
{% if job.posted_to_linkedin %}
|
{% if job.posted_to_linkedin %}
|
||||||
|
|||||||
@ -189,7 +189,7 @@
|
|||||||
data-class="{'bg-primary': $stage == 'Applied', 'bg-info': $stage == 'Exam', 'bg-warning': $stage == 'Interview', 'bg-success': $stage == 'Offer'}"
|
data-class="{'bg-primary': $stage == 'Applied', 'bg-info': $stage == 'Exam', 'bg-warning': $stage == 'Interview', 'bg-success': $stage == 'Offer'}"
|
||||||
data-signals-stage="'{{ candidate.stage }}'">
|
data-signals-stage="'{{ candidate.stage }}'">
|
||||||
{% trans "Stage:" %}
|
{% trans "Stage:" %}
|
||||||
<span data-text="'{{ candidate.stage }}'">{{ candidate.stage }}</span>
|
<span data-text="$stage"></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-white opacity-75">
|
<small class="text-white opacity-75">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user