add external integration
This commit is contained in:
parent
f28ab751ef
commit
1dd340f890
3
.gitignore
vendored
3
.gitignore
vendored
@ -56,4 +56,5 @@ static/
|
||||
|
||||
# Deployment files
|
||||
*.tar.gz
|
||||
*.zip
|
||||
*.zip
|
||||
db.sqlite3
|
||||
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.
@ -51,7 +51,8 @@ INSTALLED_APPS = [
|
||||
'crispy_bootstrap5',
|
||||
'django_extensions',
|
||||
'template_partials',
|
||||
'django_countries'
|
||||
'django_countries',
|
||||
'django_celery_results'
|
||||
]
|
||||
|
||||
SITE_ID = 1
|
||||
@ -203,10 +204,12 @@ FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
|
||||
# Celery + Redis for long running background i will be using it
|
||||
CELERY_BROKER_URL = 'redis://127.0.0.1:6379/0'
|
||||
CELERY_RESULT_BACKEND = 'redis://127.0.0.1:6379/0'
|
||||
CELERY_BROKER_URL = 'redis://localhost:6379/0' # Or your message broker URL
|
||||
CELERY_RESULT_BACKEND = 'django-db' # If using django-celery-results
|
||||
CELERY_ACCEPT_CONTENT = ['application/json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_TIMEZONE = 'UTC'
|
||||
|
||||
|
||||
|
||||
|
||||
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.
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.
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.
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.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
|
||||
)
|
||||
|
||||
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(FormField)
|
||||
admin.site.register(FormSubmission)
|
||||
admin.site.register(FieldResponse)
|
||||
|
||||
admin.site.register(SharedFormTemplate)
|
||||
# admin.site.register(HiringAgency)
|
||||
|
||||
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')
|
||||
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'),
|
||||
),
|
||||
]
|
||||
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.
@ -56,11 +56,10 @@ class JobPosting(Base):
|
||||
job_type = models.CharField(max_length=20, choices=JOB_TYPES, default='FULL_TIME')
|
||||
workplace_type = models.CharField(max_length=20, choices=WORKPLACE_TYPES, default='ON_SITE')
|
||||
|
||||
|
||||
# Location
|
||||
location_city = models.CharField(max_length=100, blank=True)
|
||||
location_state = models.CharField(max_length=100, blank=True)
|
||||
location_country = models.CharField(max_length=100, default='United States')
|
||||
location_country = models.CharField(max_length=100, default='Saudia Arabia')
|
||||
|
||||
# Job Details
|
||||
description = models.TextField(help_text="Full job description including responsibilities and requirements")
|
||||
@ -70,7 +69,7 @@ class JobPosting(Base):
|
||||
|
||||
|
||||
# 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_instructions = models.TextField(blank=True, help_text="Special instructions for applicants")
|
||||
|
||||
@ -108,8 +107,8 @@ class JobPosting(Base):
|
||||
'Source',
|
||||
on_delete=models.SET_NULL, # Recommended: If a source is deleted, job's source is set to NULL
|
||||
related_name='job_postings',
|
||||
null=True,
|
||||
blank=True,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="The system or channel from which this job posting originated or was first published."
|
||||
)
|
||||
|
||||
@ -129,6 +128,8 @@ class JobPosting(Base):
|
||||
def __str__(self):
|
||||
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):
|
||||
# Generate unique internal job ID if not exists
|
||||
if not self.internal_job_id:
|
||||
@ -212,7 +213,7 @@ class Candidate(Base):
|
||||
weaknesses = models.TextField(blank=True)
|
||||
criteria_checklist = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
|
||||
submitted_by_agency = models.ForeignKey(
|
||||
'HiringAgency',
|
||||
on_delete=models.SET_NULL,
|
||||
@ -278,9 +279,6 @@ class Candidate(Base):
|
||||
return self.full_name
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TrainingMaterial(Base):
|
||||
title = models.CharField(max_length=255, verbose_name=_('Title'))
|
||||
content = models.TextField(blank=True, verbose_name=_('Content'))
|
||||
@ -316,15 +314,14 @@ class ZoomMeeting(Base):
|
||||
return self.topic
|
||||
|
||||
|
||||
class FormTemplate(models.Model):
|
||||
class FormTemplate(Base):
|
||||
"""
|
||||
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")
|
||||
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_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")
|
||||
|
||||
class Meta:
|
||||
@ -342,7 +339,7 @@ class FormTemplate(models.Model):
|
||||
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
|
||||
"""
|
||||
@ -364,7 +361,7 @@ class FormStage(models.Model):
|
||||
raise ValidationError("Order must be a positive integer")
|
||||
|
||||
|
||||
class FormField(models.Model):
|
||||
class FormField(Base):
|
||||
"""
|
||||
Represents a single field within a form stage
|
||||
"""
|
||||
@ -439,7 +436,7 @@ class FormField(models.Model):
|
||||
raise ValidationError("Order must be a positive integer")
|
||||
|
||||
|
||||
class FormSubmission(models.Model):
|
||||
class FormSubmission(Base):
|
||||
"""
|
||||
Represents a completed form submission by an applicant
|
||||
"""
|
||||
@ -458,7 +455,7 @@ class FormSubmission(models.Model):
|
||||
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
|
||||
"""
|
||||
@ -492,14 +489,13 @@ class FieldResponse(models.Model):
|
||||
|
||||
|
||||
# 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
|
||||
"""
|
||||
template = models.OneToOneField(FormTemplate, on_delete=models.CASCADE)
|
||||
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')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Shared Form Template'
|
||||
@ -507,25 +503,180 @@ class SharedFormTemplate(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"Shared: {self.template.name}"
|
||||
|
||||
|
||||
class Source(models.Model):
|
||||
|
||||
class Source(Base):
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
unique=True,
|
||||
verbose_name=_('Source Name'),
|
||||
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)
|
||||
|
||||
# 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):
|
||||
return self.name
|
||||
|
||||
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Source')
|
||||
verbose_name_plural = _('Sources')
|
||||
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):
|
||||
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'))
|
||||
@ -543,6 +694,3 @@ class HiringAgency(Base):
|
||||
verbose_name = _('Hiring Agency')
|
||||
verbose_name_plural = _('Hiring Agencies')
|
||||
ordering = ['name']
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from django.urls import path
|
||||
from . import views_frontend
|
||||
from . import views
|
||||
from . import views_integration
|
||||
|
||||
urlpatterns = [
|
||||
path('dashboard/', views_frontend.dashboard_view, name='dashboard'),
|
||||
@ -45,7 +46,11 @@ urlpatterns = [
|
||||
path('api/create/', views.create_job, name='create_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
|
||||
# path('forms/', views.form_list, name='form_list'),
|
||||
|
||||
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'
|
||||
}
|
||||
})
|
||||
@ -24,7 +24,7 @@
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
|
||||
/* Main Action Button Style (Teal Theme) */
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
@ -39,7 +39,7 @@
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
|
||||
/* Secondary Button Style (using theme border) */
|
||||
.btn-outline-secondary {
|
||||
color: var(--kaauh-teal-dark);
|
||||
@ -76,9 +76,9 @@
|
||||
.bg-active { background-color: var(--kaauh-teal) !important; } /* primary teal */
|
||||
.bg-closed { background-color: #dc3545 !important; } /* danger */
|
||||
.bg-archived { background-color: #343a40 !important; } /* dark */
|
||||
|
||||
|
||||
.bg-info { background-color: #17a2b8 !important; } /* LinkedIn badge */
|
||||
|
||||
|
||||
/* Pagination Link Styling */
|
||||
.pagination .page-item .page-link {
|
||||
color: var(--kaauh-teal-dark);
|
||||
@ -92,7 +92,7 @@
|
||||
.pagination .page-item:hover .page-link:not(.active) {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
|
||||
/* Filter & Search Layout Adjustments */
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
@ -124,13 +124,13 @@
|
||||
<div class="card mb-4 shadow-sm no-hover">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-muted mb-3" style="font-weight: 500;">Filter & Search</h5>
|
||||
<form method="get" class="row g-3 align-items-end">
|
||||
|
||||
<form method="get" class="row g-3 align-items-end">
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="search" class="form-label small text-muted">Search by Title or Department</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<span class="input-group-text bg-white border-end-0"><i class="fas fa-search text-muted"></i></span>
|
||||
<input type="text" name="q" id="search" class="form-control form-control-search border-start-0"
|
||||
<input type="text" name="q" id="search" class="form-control form-control-search border-start-0"
|
||||
placeholder="e.g., 'Professor' or 'Marketing'"
|
||||
value="{{ search_query|default_if_none:'' }}">
|
||||
</div>
|
||||
@ -152,7 +152,7 @@
|
||||
<button type="submit" class="btn btn-main-action btn-lg">
|
||||
<i class="fas fa-filter me-1"></i> Apply Filters
|
||||
</button>
|
||||
|
||||
|
||||
{# Show Clear button if any filter/search is active #}
|
||||
{% if status_filter or search_query %}
|
||||
<a href="{% url 'job_list' %}" class="btn btn-outline-danger btn-sm">
|
||||
@ -179,11 +179,12 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="card-text text-muted small mb-3">
|
||||
<i class="fas fa-building fa-fw"></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-clock fa-fw"></i> {{ job.get_job_type_display }}
|
||||
</p>
|
||||
<p class="card-text text-muted small">
|
||||
<i class="fas fa-building"></i> {{ job.department|default:"No Department" }}<br>
|
||||
<i class="fas fa-map-marker-alt"></i> {{ job.get_location_display }}<br>
|
||||
<i class="fas fa-clock"></i> {{ job.get_job_type_display }}<br>
|
||||
<i class="fas fa-briefcase"></i> {{ job.get_source }}
|
||||
</p>
|
||||
|
||||
<div class="mt-auto pt-2 border-top">
|
||||
{% if job.posted_to_linkedin %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user