Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend

This commit is contained in:
Faheed 2025-10-07 16:45:35 +03:00
commit 285d2aea18
49 changed files with 3014 additions and 469 deletions

56
.gitignore vendored
View File

@ -1,3 +1,4 @@
<<<<<<< HEAD
# Byte-compiled / optimized / DLL files
__pycache__/
*.pyc
@ -56,4 +57,57 @@ static/
# Deployment files
*.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.

View File

@ -51,7 +51,8 @@ INSTALLED_APPS = [
'crispy_bootstrap5',
'django_extensions',
'template_partials',
'django_countries'
'django_countries',
'django_celery_results'
]
SITE_ID = 1
@ -203,14 +204,14 @@ 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'
LINKEDIN_CLIENT_ID = '867jwsiyem1504'
LINKEDIN_CLIENT_SECRET = 'WPL_AP1.QNH5lYnfRSQpp0Qp.GO8Srw=='
LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/jobs/linkedin/callback/'
LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/jobs/linkedin/callback/'

View File

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

Binary file not shown.

View File

@ -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)

View File

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

View File

@ -4,7 +4,7 @@ from crispy_forms.helper import FormHelper
from django.core.validators import URLValidator
from django.utils.translation import gettext_lazy as _
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
class CandidateForm(forms.ModelForm):
class Meta:
@ -366,3 +366,46 @@ class JobPostingForm(forms.ModelForm):
# 'Job description is required for active jobs.')
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')
)

View File

@ -14,7 +14,7 @@ class LinkedInService:
self.client_secret = settings.LINKEDIN_CLIENT_SECRET
self.redirect_uri = settings.LINKEDIN_REDIRECT_URI
self.access_token = None
def get_auth_url(self):
"""Generate LinkedIn OAuth URL"""
params = {
@ -25,7 +25,7 @@ class LinkedInService:
'state': 'university_ats_linkedin'
}
return f"https://www.linkedin.com/oauth/v2/authorization?{urlencode(params)}"
def get_access_token(self, code):
"""Exchange authorization code for access token"""
# This function exchanges LinkedIns temporary authorization code for a usable access token.
@ -37,7 +37,7 @@ class LinkedInService:
'client_id': self.client_id,
'client_secret': self.client_secret
}
try:
response = requests.post(url, data=data, timeout=60)
response.raise_for_status()
@ -53,15 +53,15 @@ class LinkedInService:
except Exception as e:
logger.error(f"Error getting access token: {e}")
raise
def get_user_profile(self):
"""Get user profile information"""
if not self.access_token:
raise Exception("No access token available")
url = "https://api.linkedin.com/v2/userinfo"
headers = {'Authorization': f'Bearer {self.access_token}'}
try:
response = requests.get(url, headers=headers, timeout=60)
response.raise_for_status() # Ensure we raise an error for bad responses(4xx, 5xx) and does nothing for 2xx(success)
@ -72,8 +72,6 @@ class LinkedInService:
def register_image_upload(self, person_urn):
"""Step 1: Register image upload with LinkedIn"""
url = "https://api.linkedin.com/v2/assets?action=registerUpload"
@ -82,7 +80,7 @@ class LinkedInService:
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0'
}
payload = {
"registerUploadRequest": {
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
@ -93,10 +91,10 @@ class LinkedInService:
}]
}
}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
return {
'upload_url': data['value']['uploadMechanism']['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest']['uploadUrl'],
@ -109,11 +107,11 @@ class LinkedInService:
image_file.open()
image_content = image_file.read()
image_file.close()
headers = {
'Authorization': f'Bearer {self.access_token}',
}
response = requests.post(upload_url, headers=headers, data=image_content, timeout=60)
response.raise_for_status()
return True
@ -121,59 +119,59 @@ class LinkedInService:
"""Create a job announcement post on LinkedIn (with image support)"""
if not self.access_token:
raise Exception("Not authenticated with LinkedIn")
try:
# Get user profile for person URN
profile = self.get_user_profile()
person_urn = profile.get('sub')
if not person_urn:
raise Exception("Could not retrieve LinkedIn user ID")
# Check if job has an image
try:
image_upload = job_posting.files.first()
has_image = image_upload and image_upload.linkedinpost_image
except Exception:
has_image = False
if has_image:
# === POST WITH IMAGE ===
try:
# Step 1: Register image upload
upload_info = self.register_image_upload(person_urn)
# Step 2: Upload image
self.upload_image_to_linkedin(
upload_info['upload_url'],
upload_info['upload_url'],
image_upload.linkedinpost_image
)
# Step 3: Create post with image
return self.create_job_post_with_image(
job_posting,
job_posting,
image_upload.linkedinpost_image,
person_urn,
upload_info['asset']
)
except Exception as e:
logger.error(f"Image upload failed: {e}")
# Fall back to text-only post if image upload fails
has_image = False
# === FALLBACK TO URL/ARTICLE POST ===
# Add unique timestamp to prevent duplicates
from django.utils import timezone
import random
unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})"
message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
if job_posting.department:
message_parts.append(f"**Department:** {job_posting.department}")
if job_posting.description:
message_parts.append(f"\n{job_posting.description}")
details = []
if job_posting.job_type:
details.append(f"💼 {job_posting.get_job_type_display()}")
@ -183,22 +181,22 @@ class LinkedInService:
details.append(f"🏠 {job_posting.get_workplace_type_display()}")
if job_posting.salary_range:
details.append(f"💰 {job_posting.salary_range}")
if details:
message_parts.append("\n" + " | ".join(details))
if job_posting.application_url:
message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
hashtags = self.hashtags_list(job_posting.hash_tags)
if job_posting.department:
dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
hashtags.insert(0, dept_hashtag)
message_parts.append("\n\n" + " ".join(hashtags))
message_parts.append(unique_suffix)
message = "\n".join(message_parts)
# 🔥 FIX URL - REMOVE TRAILING SPACES 🔥
url = "https://api.linkedin.com/v2/ugcPosts"
headers = {
@ -206,7 +204,7 @@ class LinkedInService:
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0'
}
payload = {
"author": f"urn:li:person:{person_urn}",
"lifecycleState": "PUBLISHED",
@ -226,29 +224,29 @@ class LinkedInService:
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
}
}
response = requests.post(url, headers=headers, json=payload, timeout=60)
response.raise_for_status()
post_id = response.headers.get('x-restli-id', '')
post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
return {
'success': True,
'post_id': post_id,
'post_url': post_url,
'status_code': response.status_code
}
except Exception as e:
logger.error(f"Error creating LinkedIn post: {e}")
return {
'success': False,
'error': str(e),
'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
}
}
def hashtags_list(self,hash_tags_str):
"""Convert comma-separated hashtags string to list"""
@ -257,8 +255,7 @@ class LinkedInService:
tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()]
if not tags:
return ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"]
return tags

View 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',
),
]

View File

@ -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'],
},
),
]

View 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'),
),
]

View File

@ -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),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
from django_extensions.db.fields import RandomCharField
from django.core.exceptions import ValidationError
from django_countries.fields import CountryField
from django.urls import reverse
class Base(models.Model):
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')
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 +70,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 +108,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 +129,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:
@ -166,6 +168,12 @@ class JobPosting(Base):
return self.application_deadline < timezone.now().date()
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 Stage(models.TextChoices):
@ -212,7 +220,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 +286,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 +321,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 +346,8 @@ 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 +369,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
"""
@ -405,15 +410,20 @@ class FormField(models.Model):
default=5,
help_text="Maximum file size in MB (default: 5MB)"
)
multiple_files = models.BooleanField(
default=False,
help_text="Allow multiple files to be uploaded"
)
max_files = models.PositiveIntegerField(
default=1,
help_text="Maximum number of files allowed (when multiple_files is True)"
)
class Meta:
ordering = ['order']
verbose_name = 'Form Field'
verbose_name_plural = 'Form Fields'
def __str__(self):
return f"{self.stage.name} - {self.label}"
def clean(self):
# Validate options for selection fields
if self.field_type in ['select', 'radio', 'checkbox']:
@ -430,16 +440,23 @@ class FormField(models.Model):
self.file_types = '.pdf,.doc,.docx'
if self.max_file_size <= 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:
# Clear file settings for non-file fields
self.file_types = ''
self.max_file_size = 0
self.multiple_files = False
self.max_files = 1
# Validate order
if self.order < 0:
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 +475,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 +509,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 +523,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 +714,3 @@ class HiringAgency(Base):
verbose_name = _('Hiring Agency')
verbose_name_plural = _('Hiring Agencies')
ordering = ['name']

View File

@ -1,6 +1,9 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
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)
# def parse_resume(sender, instance, created, **kwargs):
@ -19,8 +22,6 @@ import os
from .utils import extract_text_from_pdf,score_resume_with_openrouter
import asyncio
@receiver(post_save, sender=models.Candidate)
def score_candidate_resume(sender, instance, created, **kwargs):
# Skip if no resume or OpenRouter not configured
@ -105,9 +106,9 @@ def score_candidate_resume(sender, instance, created, **kwargs):
Only output valid JSON. Do not include any other text.
"""
result1 = score_resume_with_openrouter(prompt)
result1 = score_resume_with_openrouter(prompt)
instance.parsed_summary = str(result)
# Update candidate with scoring results
@ -115,8 +116,8 @@ def score_candidate_resume(sender, instance, created, **kwargs):
instance.strengths = result1.get('strengths', '')
instance.weaknesses = result1.get('weaknesses', '')
instance.criteria_checklist = result1.get('criteria_checklist', {})
# Save only scoring-related fields to avoid recursion
instance.save(update_fields=[
@ -131,10 +132,291 @@ def score_candidate_resume(sender, instance, created, **kwargs):
# instance.scoring_error = error_msg
# instance.save(update_fields=['scoring_error'])
logger.error(f"Failed to score resume for candidate {instance.id}: {e}")
# @receiver(post_save,sender=models.Candidate)
# def trigger_scoring(sender,intance,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
)

View File

@ -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'),
@ -46,13 +47,18 @@ 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'),
path('forms/builder/', 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/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>/submit/', views.submit_form, name='submit_form'),

View File

@ -7,8 +7,9 @@ from datetime import datetime
from django.views import View
from django.db.models import Q
from django.urls import reverse
from django.conf import settings
from django.utils import timezone
from .forms import ZoomMeetingForm,JobPostingForm
from .forms import ZoomMeetingForm,JobPostingForm,FormTemplateForm
from rest_framework import viewsets
from django.contrib import messages
from django.core.paginator import Paginator
@ -20,8 +21,8 @@ from django.shortcuts import get_object_or_404, render, redirect
from django.views.generic import CreateView,UpdateView,DetailView,ListView
from .utils import create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting
from django.views.decorators.csrf import ensure_csrf_cookie
import logging
logger=logging.getLogger(__name__)
@ -326,6 +327,7 @@ def linkedin_callback(request):
access_token=service.get_access_token(code)
request.session['linkedin_access_token']=access_token
request.session['linkedin_authenticated']=True
settings.LINKEDIN_IS_CONNECTED = True
messages.success(request,'Successfully authenticated with LinkedIn!')
except Exception as e:
logger.error(f"LinkedIn authentication error: {e}")
@ -690,10 +692,11 @@ def load_form_template(request, template_id):
'id': template.id,
'name': template.name,
'description': template.description,
'is_active': template.is_active,
'job': template.job_id if template.job else None,
'stages': stages
}
})
def form_templates_list(request):
"""List all form templates for the current user"""
query = request.GET.get('q', '')
@ -708,13 +711,32 @@ def form_templates_list(request):
paginator = Paginator(templates, 10) # Show 10 templates per page
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
form = FormTemplateForm()
form.fields['job'].queryset = JobPosting.objects.filter(form_template__isnull=True)
context = {
'templates': page_obj,
'query': query,
'form': form
}
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"])
def list_form_templates(request):
"""List all form templates for the current user"""

View 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'
}
})

View 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 %}

View File

@ -12,21 +12,23 @@
--primary: #004a53; /* Deep Teal/Cyan for main actions */
--primary-light: #00b4d8; /* Brighter Aqua/Cyan */
--secondary: #005a78; /* Darker Teal for hover/accent */
--success: #00cc99; /* Bright Greenish-Teal for success */
--success: #005a78; /* Bright Greenish-Teal for success */
/* Neutral Colors (Kept for consistency) */
--light: #f4fcfc; /* Very light off-white (slightly blue tinted) */
--dark: #212529; /* Near black text */
--gray: #6c757d; /* Standard gray text */
--light-gray: #e0f0f4; /* Lighter background for hover/disabled */
--border: #c4d7e0; /* Lighter, softer border color */
/* Structural Variables (Kept exactly the same) */
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
--radius: 8px;
--transition: all 0.3s ease;
}
/* All other structural and component styles below remain the same,
/* All other structural and component styles below remain the same,
but will automatically adopt the new colors defined above. */
* {
margin: 0;
@ -670,18 +672,20 @@
<!-- Pass Django CSRF token and other data -->
<script>
// Django template variables - these will be processed by Django
const djangoConfig = {
csrfToken: "{{ csrf_token }}",
saveUrl: "{% url 'save_form_template' %}",
loadUrl: {% if template_id %}"{% url 'load_form_template' template_id %}"{% else %}null{% endif %},
templateId: {% if template_id %}{{ template_id }}{% else %}null{% endif %}
};
const djangoConfig = {
csrfToken: "{{ csrf_token }}",
saveUrl: "{% url 'save_form_template' %}",
loadUrl: {% if template_id %}"{% url 'load_form_template' template_id %}"{% else %}null{% endif %},
templateId: {% if template_id %}{{ template_id }}{% else %}null{% endif %},
jobId: {% if job_id %}{{ job_id }}{% else %}null{% endif %} // Add this if you need it
};
</script>
<div class="container">
<!-- Sidebar with form elements -->
<div class="sidebar">
<div class="sidebar-header">
<a class="" href="{% url 'form_templates_list' %}"></a>
<h2><i class="fas fa-cube"></i> Form Elements</h2>
</div>
<div class="field-categories">
@ -853,29 +857,51 @@
</div>
<!-- File Type Specific Settings -->
<div class="editor-section" id="fileSettings" style="display: none;">
<h4><i class="fas fa-file"></i> File Settings</h4>
<div class="form-group">
<label for="fileTypes">Allowed File Types</label>
<input
type="text"
id="fileTypes"
class="form-control"
placeholder=".pdf, .doc, .docx"
>
</div>
<div class="form-group">
<label for="maxFileSize">Max File Size (MB)</label>
<input
type="number"
id="maxFileSize"
class="form-control"
min="1"
max="100"
value="5"
>
</div>
</div>
</div>
<h4><i class="fas fa-file"></i> File Settings</h4>
<div class="form-group">
<label for="fileTypes">Allowed File Types</label>
<input
type="text"
id="fileTypes"
class="form-control"
placeholder=".pdf, .doc, .docx"
>
</div>
<div class="form-group">
<label for="maxFileSize">Max File Size (MB)</label>
<input
type="number"
id="maxFileSize"
class="form-control"
min="1"
max="100"
value="5"
>
</div>
<div class="form-group">
<div class="checkbox-group">
<input
type="checkbox"
id="multipleFiles"
>
<label for="multipleFiles">Allow Multiple Files</label>
</div>
<small class="form-text text-muted">Enable this to allow uploading multiple files for this field.</small>
</div>
<div class="form-group">
<label for="maxFiles">Maximum Number of Files</label>
<input
type="number"
id="maxFiles"
class="form-control"
min="1"
max="10"
value="1"
disabled
>
<small class="form-text text-muted">Only applicable when multiple files are allowed.</small>
</div>
</div>
</div>
</div>
</div>
@ -1156,97 +1182,109 @@
// API Functions
async function saveFormTemplate() {
const formData = {
name: state.formName,
description: state.formDescription,
is_active: state.formActive,
template_id: state.templateId, // Include template_id for updates
stages: state.stages.map(stage => ({
name: stage.name,
predefined: stage.predefined,
fields: stage.fields.map(field => ({
type: field.type,
label: field.label,
placeholder: field.placeholder || '',
required: field.required || false,
options: field.options || [],
fileTypes: field.fileTypes || '',
maxFileSize: field.maxFileSize || 5,
predefined: field.predefined
}))
}))
};
const formData = {
name: state.formName,
description: state.formDescription,
is_active: state.formActive,
template_id: state.templateId,
stages: state.stages.map(stage => ({
name: stage.name,
predefined: stage.predefined,
fields: stage.fields.map(field => ({
type: field.type,
label: field.label,
placeholder: field.placeholder || '',
required: field.required || false,
options: field.options || [],
fileTypes: field.fileTypes || '',
maxFileSize: field.maxFileSize || 5,
predefined: field.predefined
}))
}))
};
try {
const response = await fetch(djangoConfig.saveUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': djangoConfig.csrfToken,
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(formData)
});
// If there's a job_id in the Django context, include it
if (djangoConfig.jobId) {
formData.job = djangoConfig.jobId;
}
const result = await response.json();
try {
const response = await fetch(djangoConfig.saveUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': djangoConfig.csrfToken,
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(formData)
});
if (result.success) {
alert('Form template saved successfully! Template ID: ' + result.template_id);
// Update templateId for future saves (important for new templates)
state.templateId = result.template_id;
} else {
alert('Error saving form template: ' + result.error);
}
} catch (error) {
console.error('Error:', error);
alert('Error saving form template. Please try again.');
}
const result = await response.json();
if (result.success) {
state.templateId = result.template_id;
window.location.href = "{% url 'form_templates_list' %}";
} else {
alert('Error saving form template: ' + result.error);
}
} catch (error) {
console.error('Error:', error);
alert('Error saving form template. Please try again.');
}
}
// Load existing template if editing
async function loadExistingTemplate() {
if (djangoConfig.loadUrl) {
try {
const response = await fetch(djangoConfig.loadUrl);
const result = await response.json();
if (djangoConfig.loadUrl) {
try {
const response = await fetch(djangoConfig.loadUrl);
const result = await response.json();
if (result.success) {
const templateData = result.template;
// Set form settings
state.formName = templateData.name || 'Untitled Form';
state.formDescription = templateData.description || '';
state.formActive = templateData.is_active !== false;
if (result.success) {
const templateData = result.template;
// Set form settings
state.formName = templateData.name || 'Untitled Form';
state.formDescription = templateData.description || '';
state.formActive = templateData.is_active !== false; // Default to true if not set
// Update form title
elements.formTitle.textContent = state.formName;
elements.formName.value = state.formName;
elements.formDescription.value = state.formDescription;
elements.formActive.checked = state.formActive;
// Update form title
elements.formTitle.textContent = state.formName;
elements.formName.value = state.formName;
elements.formDescription.value = state.formDescription;
elements.formActive.checked = state.formActive;
// Set stages (this is where your actual stages come from)
state.stages = templateData.stages;
state.templateId = templateData.id;
// Set stages
state.stages = templateData.stages;
state.templateId = templateData.id;
// Update next IDs to avoid conflicts
let maxFieldId = 0;
let maxStageId = 0;
templateData.stages.forEach(stage => {
maxStageId = Math.max(maxStageId, stage.id);
stage.fields.forEach(field => {
maxFieldId = Math.max(maxFieldId, field.id);
});
});
state.nextFieldId = maxFieldId + 1;
state.nextStageId = maxStageId + 1;
state.currentStage = 0;
renderStageNavigation();
renderCurrentStage();
}
} catch (error) {
console.error('Error loading template:', error);
alert('Error loading template data.');
}
// Update next IDs to avoid conflicts
let maxFieldId = 0;
let maxStageId = 0;
templateData.stages.forEach(stage => {
maxStageId = Math.max(maxStageId, stage.id);
stage.fields.forEach(field => {
maxFieldId = Math.max(maxFieldId, field.id);
});
});
state.nextFieldId = maxFieldId + 1;
state.nextStageId = maxStageId + 1;
state.currentStage = 0;
// Now show the form content
elements.formStage.style.display = 'block';
elements.emptyState.style.display = 'none';
renderStageNavigation();
renderCurrentStage();
}
} catch (error) {
console.error('Error loading template:', error);
elements.formTitle.textContent = 'Error Loading Template';
elements.emptyState.style.display = 'block';
elements.emptyState.innerHTML = '<i class="fas fa-exclamation-triangle"></i><p>Error loading template data.</p>';
elements.formStage.style.display = 'none';
}
}
}
// DOM Rendering Functions (same as before)
function renderStageNavigation() {
@ -1319,164 +1357,255 @@
}
function createFieldElement(field, index) {
const fieldDiv = document.createElement('div');
fieldDiv.className = `form-field ${state.selectedField && state.selectedField.id === field.id ? 'selected' : ''}`;
fieldDiv.dataset.fieldId = field.id;
fieldDiv.dataset.fieldIndex = index;
const fieldHeader = document.createElement('div');
fieldHeader.className = 'field-header';
fieldHeader.innerHTML = `
<div class="field-title">
<i class="${getFieldIcon(field.type)}"></i>
${field.label || field.type.charAt(0).toUpperCase() + field.type.slice(1)}
${field.required ? '<span class="required-indicator"> *</span>' : ''}
</div>
<div class="field-actions">
<div class="action-btn edit-field" data-field-id="${field.id}">
<i class="fas fa-edit"></i>
</div>
${!field.predefined ? `<div class="action-btn remove-field" data-field-index="${index}">
<i class="fas fa-trash"></i>
</div>` : ''}
</div>
`;
const fieldContent = document.createElement('div');
fieldContent.className = 'field-content';
fieldContent.innerHTML = `
<label class="field-label">
${field.label || 'Field Label'}
${field.required ? '<span class="required-indicator"> *</span>' : ''}
</label>
`;
// Add field input based on type
if (field.type === 'text' || field.type === 'email' || field.type === 'phone' || field.type === 'date') {
const input = document.createElement('input');
input.type = 'text';
input.className = 'field-input';
input.placeholder = field.placeholder || 'Enter value';
input.disabled = true;
fieldContent.appendChild(input);
} else if (field.type === 'textarea') {
const textarea = document.createElement('textarea');
textarea.className = 'field-input';
textarea.rows = 3;
textarea.placeholder = field.placeholder || 'Enter text';
textarea.disabled = true;
fieldContent.appendChild(textarea);
} else if (field.type === 'file') {
const fileUpload = document.createElement('div');
fileUpload.className = 'file-upload-area';
fileUpload.innerHTML = `
<div class="file-upload-icon">
<i class="fas fa-cloud-upload-alt"></i>
</div>
<div class="file-upload-text">
<p>Drag & drop your resume here or <strong>click to browse</strong></p>
</div>
<div class="file-upload-info">
<p>Supported formats: ${field.fileTypes || '.pdf, .doc, .docx'} (Max ${field.maxFileSize || 5}MB)</p>
</div>
<input type="file" class="file-input" style="display: none;" accept="${field.fileTypes || '.pdf,.doc,.docx'}">
`;
if (field.uploadedFile) {
const uploadedFile = document.createElement('div');
uploadedFile.className = 'uploaded-file';
uploadedFile.innerHTML = `
<div class="file-info">
<i class="fas fa-file file-icon"></i>
<div>
<div class="file-name">${field.uploadedFile.name}</div>
<div class="file-size">${formatFileSize(field.uploadedFile.size)}</div>
</div>
const fieldDiv = document.createElement('div');
fieldDiv.className = `form-field ${state.selectedField && state.selectedField.id === field.id ? 'selected' : ''}`;
fieldDiv.dataset.fieldId = field.id;
fieldDiv.dataset.fieldIndex = index;
const fieldHeader = document.createElement('div');
fieldHeader.className = 'field-header';
fieldHeader.innerHTML = `
<div class="field-title">
<i class="${getFieldIcon(field.type)}"></i>
${field.label || field.type.charAt(0).toUpperCase() + field.type.slice(1)}
${field.required ? '<span class="required-indicator"> *</span>' : ''}
</div>
<div class="field-actions">
<div class="action-btn edit-field" data-field-id="${field.id}">
<i class="fas fa-edit"></i>
</div>
${!field.predefined ? `<div class="action-btn remove-field" data-field-index="${index}">
<i class="fas fa-trash"></i>
</div>` : ''}
</div>
`;
const fieldContent = document.createElement('div');
fieldContent.className = 'field-content';
fieldContent.innerHTML = `
<label class="field-label">
${field.label || 'Field Label'}
${field.required ? '<span class="required-indicator"> *</span>' : ''}
</label>
`;
// Add field input based on type
if (field.type === 'text' || field.type === 'email' || field.type === 'phone' || field.type === 'date') {
const input = document.createElement('input');
input.type = 'text';
input.className = 'field-input';
input.placeholder = field.placeholder || 'Enter value';
input.disabled = true;
fieldContent.appendChild(input);
} else if (field.type === 'textarea') {
const textarea = document.createElement('textarea');
textarea.className = 'field-input';
textarea.rows = 3;
textarea.placeholder = field.placeholder || 'Enter text';
textarea.disabled = true;
fieldContent.appendChild(textarea);
} else if (field.type === 'file') {
const fileUpload = document.createElement('div');
fileUpload.className = 'file-upload-area';
fileUpload.innerHTML = `
<div class="file-upload-icon">
<i class="fas fa-cloud-upload-alt"></i>
</div>
<div class="file-upload-text">
<p>Drag & drop your ${field.label.toLowerCase()} here or <strong>click to browse</strong></p>
</div>
<div class="file-upload-info">
<p>Supported formats: ${field.fileTypes || '.pdf, .doc, .docx'} (Max ${field.maxFileSize || 5}MB)</p>
${field.multipleFiles ? `<p>Multiple files allowed (Max ${field.maxFiles || 1} files)</p>` : ''}
</div>
<input type="file" class="file-input" style="display: none;"
accept="${field.fileTypes || '.pdf,.doc,.docx'}"
${field.multipleFiles ? 'multiple' : ''}>
`;
// Show uploaded files
if (field.uploadedFiles && field.uploadedFiles.length > 0) {
field.uploadedFiles.forEach((file, fileIndex) => {
const uploadedFile = document.createElement('div');
uploadedFile.className = 'uploaded-file';
uploadedFile.innerHTML = `
<div class="file-info">
<i class="fas fa-file file-icon"></i>
<div>
<div class="file-name">${file.name}</div>
<div class="file-size">${formatFileSize(file.size)}</div>
</div>
<button class="remove-file-btn">
<i class="fas fa-times"></i>
</button>
`;
fileUpload.appendChild(uploadedFile);
}
fieldContent.appendChild(fileUpload);
} else if (field.type === 'select') {
const select = document.createElement('select');
select.className = 'field-input';
select.disabled = true;
field.options.forEach(option => {
const optionEl = document.createElement('option');
optionEl.textContent = option;
select.appendChild(optionEl);
});
fieldContent.appendChild(select);
} else if (field.type === 'radio' || field.type === 'checkbox') {
const optionsDiv = document.createElement('div');
optionsDiv.className = 'field-options';
field.options.forEach((option, idx) => {
const optionItem = document.createElement('div');
optionItem.className = 'option-item';
optionItem.innerHTML = `
<input type="${field.type === 'radio' ? 'radio' : 'checkbox'}"
id="${field.type}-${field.id}-${idx}"
name="${field.type}-${field.id}"
disabled>
<label for="${field.type}-${field.id}-${idx}">${option}</label>
`;
optionsDiv.appendChild(optionItem);
});
fieldContent.appendChild(optionsDiv);
}
fieldDiv.appendChild(fieldHeader);
fieldDiv.appendChild(fieldContent);
// Add event listeners
fieldDiv.addEventListener('click', (e) => {
if (!e.target.closest('.edit-field') && !e.target.closest('.remove-field') &&
!e.target.closest('.remove-file-btn')) {
selectField(field);
}
</div>
<button class="remove-file-btn" data-file-index="${fileIndex}">
<i class="fas fa-times"></i>
</button>
`;
fileUpload.appendChild(uploadedFile);
});
const editBtn = fieldDiv.querySelector('.edit-field');
if (editBtn) {
editBtn.addEventListener('click', (e) => {
e.stopPropagation();
selectField(field);
});
}
const removeBtn = fieldDiv.querySelector('.remove-field');
if (removeBtn) {
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeField(parseInt(removeBtn.dataset.fieldIndex));
});
}
const removeFileBtn = fieldDiv.querySelector('.remove-file-btn');
if (removeFileBtn) {
removeFileBtn.addEventListener('click', (e) => {
e.stopPropagation();
const fieldId = parseInt(fieldDiv.dataset.fieldId);
const stage = state.stages[state.currentStage];
const field = stage.fields.find(f => f.id === fieldId);
if (field) {
field.uploadedFile = null;
renderCurrentStage();
}
});
}
// Make draggable
fieldDiv.draggable = true;
fieldDiv.addEventListener('dragstart', (e) => {
state.draggedFieldIndex = parseInt(fieldDiv.dataset.fieldIndex);
e.dataTransfer.setData('text/plain', 'reorder');
e.dataTransfer.effectAllowed = 'move';
});
fieldDiv.addEventListener('dragover', (e) => {
e.preventDefault();
});
fieldDiv.addEventListener('drop', (e) => {
e.preventDefault();
const targetIndex = parseInt(fieldDiv.dataset.fieldIndex);
dropField(targetIndex);
});
return fieldDiv;
}
fieldContent.appendChild(fileUpload);
} else if (field.type === 'select') {
const select = document.createElement('select');
select.className = 'field-input';
select.disabled = true;
field.options.forEach(option => {
const optionEl = document.createElement('option');
optionEl.textContent = option;
select.appendChild(optionEl);
});
fieldContent.appendChild(select);
} else if (field.type === 'radio' || field.type === 'checkbox') {
const optionsDiv = document.createElement('div');
optionsDiv.className = 'field-options';
field.options.forEach((option, idx) => {
const optionItem = document.createElement('div');
optionItem.className = 'option-item';
optionItem.innerHTML = `
<input type="${field.type === 'radio' ? 'radio' : 'checkbox'}"
id="${field.type}-${field.id}-${idx}"
name="${field.type}-${field.id}"
disabled>
<label for="${field.type}-${field.id}-${idx}">${option}</label>
`;
optionsDiv.appendChild(optionItem);
});
fieldContent.appendChild(optionsDiv);
}
fieldDiv.appendChild(fieldHeader);
fieldDiv.appendChild(fieldContent);
// Add event listeners
fieldDiv.addEventListener('click', (e) => {
if (!e.target.closest('.edit-field') && !e.target.closest('.remove-field') &&
!e.target.closest('.remove-file-btn')) {
selectField(field);
}
});
const editBtn = fieldDiv.querySelector('.edit-field');
if (editBtn) {
editBtn.addEventListener('click', (e) => {
e.stopPropagation();
selectField(field);
});
}
const removeBtn = fieldDiv.querySelector('.remove-field');
if (removeBtn) {
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeField(parseInt(removeBtn.dataset.fieldIndex));
});
}
const removeFileBtns = fieldDiv.querySelectorAll('.remove-file-btn');
removeFileBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const fileIndex = parseInt(btn.dataset.fileIndex);
const fieldId = parseInt(fieldDiv.dataset.fieldId);
const stage = state.stages[state.currentStage];
const field = stage.fields.find(f => f.id === fieldId);
if (field && field.uploadedFiles) {
field.uploadedFiles.splice(fileIndex, 1);
renderCurrentStage();
}
});
});
// Make draggable
fieldDiv.draggable = true;
fieldDiv.addEventListener('dragstart', (e) => {
state.draggedFieldIndex = parseInt(fieldDiv.dataset.fieldIndex);
e.dataTransfer.setData('text/plain', 'reorder');
e.dataTransfer.effectAllowed = 'move';
});
fieldDiv.addEventListener('dragover', (e) => {
e.preventDefault();
});
fieldDiv.addEventListener('drop', (e) => {
e.preventDefault();
const targetIndex = parseInt(fieldDiv.dataset.fieldIndex);
dropField(targetIndex);
});
// Add file input event listener
const fileInput = fieldDiv.querySelector('.file-input');
if (fileInput) {
fileInput.addEventListener('change', (e) => {
handleFileUpload(e, field);
});
// Make the file upload area clickable
const fileUploadArea = fieldDiv.querySelector('.file-upload-area');
if (fileUploadArea) {
fileUploadArea.addEventListener('click', () => {
fileInput.click();
});
}
}
return fieldDiv;
}
function handleFileUpload(event, field) {
const files = Array.from(event.target.files);
if (files.length === 0) return;
// Validate file count for multiple files
if (field.multipleFiles) {
const maxFiles = field.maxFiles || 1;
if (files.length > maxFiles) {
alert(`You can only upload ${maxFiles} files for this field.`);
return;
}
} else if (files.length > 1) {
// For single file fields, only take the first file
files.splice(1);
}
// Validate each file
const validFiles = [];
const allowedTypes = (field.fileTypes || '.pdf,.doc,.docx').split(',').map(type => type.trim().toLowerCase());
const maxFileSize = field.maxFileSize || 5;
for (const file of files) {
// Validate file type
const fileType = '.' + file.name.split('.').pop().toLowerCase();
if (!allowedTypes.includes(fileType)) {
alert(`Invalid file type for ${file.name}. Allowed types: ${field.fileTypes || '.pdf, .doc, .docx'}`);
return;
}
// Validate file size
const fileSizeMB = file.size / (1024 * 1024);
if (fileSizeMB > maxFileSize) {
alert(`File ${file.name} exceeds ${maxFileSize}MB limit.`);
return;
}
validFiles.push(file);
}
// Store the files
if (field.multipleFiles) {
// Initialize or update the uploadedFiles array
if (!field.uploadedFiles) {
field.uploadedFiles = [];
}
field.uploadedFiles = [...validFiles];
} else {
// Single file - store as array with one file for consistency
field.uploadedFiles = [validFiles[0]];
}
// Re-render the current stage to show uploaded files
renderCurrentStage();
}
function showFieldEditor(field) {
elements.fieldEditor.style.display = 'flex';
elements.fieldLabel.value = field.label || '';
@ -1499,30 +1628,51 @@
}
function renderOptionsEditor(field) {
elements.optionsList.innerHTML = '';
field.options.forEach((option, index) => {
const optionInput = document.createElement('div');
optionInput.className = 'option-input';
optionInput.innerHTML = `
<input type="text" class="form-control" value="${option}" placeholder="Option ${index + 1}">
<button class="remove-option">
<i class="fas fa-times"></i>
</button>
`;
elements.optionsList.appendChild(optionInput);
const input = optionInput.querySelector('input');
const removeBtn = optionInput.querySelector('.remove-option');
input.addEventListener('input', () => {
field.options[index] = input.value;
});
removeBtn.addEventListener('click', () => {
if (field.options.length > 1) {
field.options.splice(index, 1);
renderOptionsEditor(field);
elements.optionsList.innerHTML = '';
field.options.forEach((option, index) => {
const optionInput = document.createElement('div');
optionInput.className = 'option-input';
optionInput.innerHTML = `
<input type="text" class="form-control" value="${option}" placeholder="Option ${index + 1}">
<button class="remove-option">
<i class="fas fa-times"></i>
</button>
`;
elements.optionsList.appendChild(optionInput);
const input = optionInput.querySelector('input');
const removeBtn = optionInput.querySelector('.remove-option');
input.addEventListener('input', () => {
field.options[index] = input.value;
});
removeBtn.addEventListener('click', () => {
if (field.options.length > 1) {
field.options.splice(index, 1);
renderOptionsEditor(field);
}
});
});
// Add event listener for multiple files checkbox if this is a file field
if (field.type === 'file') {
const multipleFilesCheckbox = elements.multipleFiles;
if (multipleFilesCheckbox) {
multipleFilesCheckbox.addEventListener('change', function() {
elements.maxFiles.disabled = !this.checked;
if (!this.checked) {
elements.maxFiles.value = 1;
// Update the field configuration
if (state.selectedField) {
state.selectedField.maxFiles = 1;
}
});
}
});
}
}
}
// Event Handlers (same as before, but updated saveForm function)
function selectField(field) {
@ -1668,29 +1818,33 @@
}
function drop(event) {
event.preventDefault();
event.target.classList.remove('drag-over');
if (state.draggedField) {
const newField = {
id: state.nextFieldId++,
type: state.draggedField.type,
label: state.draggedField.label,
placeholder: '',
required: false,
options: state.draggedField.type === 'select' || state.draggedField.type === 'radio' || state.draggedField.type === 'checkbox'
? ['Option 1', 'Option 2']
: [],
fileTypes: state.draggedField.type === 'file' ? '.pdf,.doc,.docx' : '',
maxFileSize: state.draggedField.type === 'file' ? 5 : 0,
predefined: false,
uploadedFile: null
};
state.stages[state.currentStage].fields.push(newField);
selectField(newField);
state.draggedField = null;
renderCurrentStage();
}
}
event.preventDefault();
event.target.classList.remove('drag-over');
if (state.draggedField) {
const newField = {
id: state.nextFieldId++,
type: state.draggedField.type,
label: state.draggedField.label,
placeholder: '',
required: false,
options: state.draggedField.type === 'select' || state.draggedField.type === 'radio' || state.draggedField.type === 'checkbox'
? ['Option 1', 'Option 2']
: [],
fileTypes: state.draggedField.type === 'file' ? '.pdf,.doc,.docx' : '',
maxFileSize: state.draggedField.type === 'file' ? 5 : 0,
multipleFiles: state.draggedField.type === 'file' ? false : undefined,
maxFiles: state.draggedField.type === 'file' ? 1 : undefined,
predefined: false,
uploadedFiles: state.draggedField.type === 'file' ? [] : undefined
};
state.stages[state.currentStage].fields.push(newField);
selectField(newField);
state.draggedField = null;
renderCurrentStage();
}
}
function dropField(targetIndex) {
if (state.draggedFieldIndex !== null && state.draggedFieldIndex !== targetIndex) {
@ -1790,15 +1944,23 @@
// Initialize Application
function init() {
// Initialize form title
elements.formTitle.textContent = state.formName;
elements.formTitle.textContent = 'Loading...';
renderStageNavigation();
renderCurrentStage();
initEventListeners();
// Load existing template if editing
if (djangoConfig.loadUrl) {
loadExistingTemplate();
}
// Hide the form stage initially to prevent flickering
elements.formStage.style.display = 'none';
elements.emptyState.style.display = 'block';
elements.emptyState.innerHTML = '<i class="fas fa-spinner fa-spin"></i><p>Loading form template...</p>';
// Only render navigation if we have a template to load
if (djangoConfig.loadUrl) {
loadExistingTemplate();
} else {
// For new templates, show empty state
elements.formTitle.textContent = 'New Form Template';
elements.formStage.style.display = 'block';
renderStageNavigation();
renderCurrentStage();
}
}
// Start the application

View File

@ -1,5 +1,5 @@
{% extends 'base.html' %}
{% load static i18n %}
{% load static i18n crispy_forms_tags %}
{% block title %}Form Templates - ATS{% endblock %}
@ -13,7 +13,7 @@
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-gray-light: #f8f9fa;
--kaauh-gray-light: #f8f9fa;
}
/* --- Typography and Color Overrides --- */
@ -25,7 +25,7 @@
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
padding: 0.375rem 0.75rem;
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
transition: all 0.2s ease;
display: inline-flex;
@ -37,7 +37,7 @@
border-color: var(--kaauh-teal-dark);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
color: white;
color: white;
}
/* Secondary Button Style (for Edit/Preview) */
@ -69,39 +69,41 @@
background-color: white;
transition: transform 0.2s, box-shadow 0.2s;
}
/* Template Card Hover Effect (Consistent with job list card hover) */
.template-card {
height: 100%;
}
.template-card:hover {
transform: translateY(-2px);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0,0,0,0.1) !important;
}
/* Card Header Theming */
.card-header {
/* FIX: Use !important to override default white/light backgrounds from Bootstrap */
background-color: var(--kaauh-teal-dark) !important;
background-color: var(--kaauh-teal-dark) !important;
border-bottom: 1px solid var(--kaauh-border);
color: white !important; /* Base color for header text */
font-weight: 600;
padding: 1rem 1.25rem;
border-radius: 0.75rem 0.75rem 0 0;
}
/* Ensure all elements within the header are visible */
.card-header h3 {
color: white !important;
color: white !important;
font-weight: 700;
}
.card-header .fas {
color: white !important;
color: white !important;
}
.card-header .small {
color: rgba(255, 255, 255, 0.7) !important;
}
/* Stats Theming */
/* --- Content Styles (Stats, Description) --- */
.stat-value {
font-size: 1.5rem;
@ -116,14 +118,13 @@
.card-description {
min-height: 60px;
color: var(--kaauh-primary-text);
margin-bottom: 1rem;
}
/* --- Form/Search Input Theming (Matching Job List) --- */
.form-control-search {
box-shadow: none;
/* Search Input Theming */
.form-control {
border-radius: 0.5rem 0 0 0.5rem;
border-color: var(--kaauh-border);
border-radius: 0 0.5rem 0.5rem 0;
border-radius: 0 0.5rem 0.5rem 0;
}
.form-control-search:focus {
border-color: var(--kaauh-teal);
@ -146,8 +147,8 @@
--bs-btn-hover-bg: #dc3545;
--bs-btn-hover-color: white;
}
/* --- Empty State Theming --- */
/* Empty State Theming */
.empty-state {
text-align: center;
padding: 3rem 1rem;
@ -159,7 +160,7 @@
.empty-state i {
font-size: 3.5rem;
margin-bottom: 1rem;
color: var(--kaauh-teal-dark);
color: var(--kaauh-teal-dark);
}
.empty-state .btn-main-action .fas {
color: white !important;
@ -188,23 +189,23 @@
<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" %}
</h1>
<a href="{% url 'form_builder' %}" class="btn btn-main-action">
<i class="fas fa-plus me-1"></i> {% trans "Create New Template" %}
</a>
<button type="button" class="btn btn-main-action" data-bs-toggle="modal" data-bs-target="#createTemplateModal">
<i class="fas fa-plus me-1"></i> Create New Template
</button>
</div>
{# Search/Filter Area - Matching Job List Structure #}
<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;">Search Templates</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-6">
<label for="search" class="form-label small text-muted">Search by Template Name</label>
<div class="input-group input-group-lg input-group-search">
<span class="input-group-text"><i class="fas fa-search text-muted"></i></span>
<input type="text" name="q" id="searchInput" class="form-control form-control-search"
placeholder="{% trans 'Search templates by name...' %}"
<input type="text" name="q" id="searchInput" class="form-control form-control-search"
placeholder="{% trans 'Search templates by name...' %}"
value="{{ query|default_if_none:'' }}">
</div>
</div>
@ -214,7 +215,7 @@
<button type="submit" class="btn btn-main-action btn-lg">
<i class="fas fa-filter me-1"></i> Search
</button>
{# Show Clear button if search is active #}
{% if query %}
<a href="{% url 'form_templates_list' %}" class="btn btn-outline-danger btn-sm">
@ -236,13 +237,14 @@
<div class="card template-card h-100">
<div class="card-header ">
<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-sync-alt me-1"></i> {{ template.updated_at|timesince }} {% trans "ago" %}</span>
</div>
</div>
<div class="card-body d-flex flex-column">
{# Content area - includes stats and description #}
<div class="flex-grow-1">
<div class="row text-center mb-3">
@ -263,7 +265,7 @@
{% endif %}
</p>
</div>
{# Action area - visually separated with 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">
@ -336,7 +338,33 @@
{% endif %}
</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 %}
{% block customJS %}
@ -387,7 +415,7 @@
window.location.href = query ? `?q=${encodeURIComponent(query)}` : '{% url "form_templates_list" %}';
}
});
// Bind search form submit to the main button click event for consistency
document.querySelector('.filter-buttons button[type="submit"]').addEventListener('click', function(e) {
// Prevent default submission to handle URL construction correctly
@ -415,18 +443,18 @@
e.preventDefault();
if (!templateToDelete) return;
// This CSRF token selector assumes it's present in your base template or form
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
// This relies on 'csrfToken' being defined somewhere, which is typical for Django templates.
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
try {
// NOTE: Update this URL to match your actual Django API endpoint for deletion
const response = await fetch(`/api/templates/${templateToDelete}/delete/`, {
const response = await fetch(`/api/templates/${templateToDelete}/delete/`, {
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json'
'Content-Type': 'application/json'
}
});
@ -473,5 +501,50 @@
document.getElementById('deleteModal').addEventListener('hidden.bs.modal', function() {
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>
{% endblock %}
{% endblock %}

View File

@ -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 %}