From 1dd340f89027a20b699e844cf87f3203fed167da Mon Sep 17 00:00:00 2001 From: ismail Date: Tue, 7 Oct 2025 13:39:52 +0300 Subject: [PATCH 1/3] add external integration --- .gitignore | 3 +- .../__pycache__/__init__.cpython-313.pyc | Bin 160 -> 238 bytes .../__pycache__/celery.cpython-313.pyc | Bin 0 -> 598 bytes .../__pycache__/settings.cpython-313.pyc | Bin 5038 -> 4955 bytes NorahUniversity/settings.py | 13 +- recruitment/ERP_INTEGRATION_GUIDE.md | 467 ++++++++++++++++++ recruitment/__pycache__/admin.cpython-313.pyc | Bin 717 -> 11317 bytes recruitment/__pycache__/apps.cpython-313.pyc | Bin 530 -> 691 bytes .../erp_integration_service.cpython-313.pyc | Bin 0 -> 13176 bytes recruitment/__pycache__/forms.cpython-313.pyc | Bin 16958 -> 16944 bytes .../linkedin_service.cpython-313.pyc | Bin 11109 -> 11109 bytes .../__pycache__/models.cpython-313.pyc | Bin 29425 -> 36795 bytes .../__pycache__/signals.cpython-313.pyc | Bin 0 -> 5567 bytes recruitment/__pycache__/urls.cpython-313.pyc | Bin 5367 -> 5954 bytes recruitment/__pycache__/utils.cpython-313.pyc | Bin 9529 -> 10658 bytes recruitment/__pycache__/views.cpython-313.pyc | Bin 27587 -> 27573 bytes .../views_integration.cpython-313.pyc | Bin 0 -> 8310 bytes recruitment/admin.py | 262 +++++++++- recruitment/erp_integration_service.py | 271 ++++++++++ recruitment/migrations/0020_delete_job.py | 16 + ..._api_secret_source_description_and_more.py | 88 ++++ .../0022_alter_source_trusted_ips.py | 18 + ...ter_jobposting_application_url_and_more.py | 24 + ..._created_at_fieldresponse_slug_and_more.py | 141 ++++++ ...idate_match_score_and_more.cpython-313.pyc | Bin 0 -> 1253 bytes ...4_source_jobposting_source.cpython-313.pyc | Bin 0 -> 1859 bytes ...bmitted_by_agency_and_more.cpython-313.pyc | Bin 0 -> 2717 bytes ...ingagency_address_and_more.cpython-313.pyc | Bin 0 -> 2548 bytes ...r_jobposting_hiring_agency.cpython-313.pyc | Bin 0 -> 1028 bytes ...r_jobposting_hiring_agency.cpython-313.pyc | Bin 0 -> 1023 bytes .../0019_merge_20251006_1224.cpython-313.pyc | Bin 0 -> 673 bytes .../0020_delete_job.cpython-313.pyc | Bin 0 -> 658 bytes ...ource_description_and_more.cpython-313.pyc | Bin 0 -> 4812 bytes ...2_alter_source_trusted_ips.cpython-313.pyc | Bin 0 -> 961 bytes ...g_application_url_and_more.cpython-313.pyc | Bin 0 -> 1265 bytes ...ieldresponse_slug_and_more.cpython-313.pyc | Bin 0 -> 4902 bytes recruitment/models.py | 196 +++++++- recruitment/urls.py | 7 +- recruitment/views_integration.py | 226 +++++++++ templates/jobs/job_list.html | 29 +- 40 files changed, 1712 insertions(+), 49 deletions(-) create mode 100644 NorahUniversity/__pycache__/celery.cpython-313.pyc create mode 100644 recruitment/ERP_INTEGRATION_GUIDE.md create mode 100644 recruitment/__pycache__/erp_integration_service.cpython-313.pyc create mode 100644 recruitment/__pycache__/signals.cpython-313.pyc create mode 100644 recruitment/__pycache__/views_integration.cpython-313.pyc create mode 100644 recruitment/erp_integration_service.py create mode 100644 recruitment/migrations/0020_delete_job.py create mode 100644 recruitment/migrations/0021_source_api_key_source_api_secret_source_description_and_more.py create mode 100644 recruitment/migrations/0022_alter_source_trusted_ips.py create mode 100644 recruitment/migrations/0023_alter_jobposting_application_url_and_more.py create mode 100644 recruitment/migrations/0024_fieldresponse_created_at_fieldresponse_slug_and_more.py create mode 100644 recruitment/migrations/__pycache__/0013_candidate_criteria_checklist_candidate_match_score_and_more.cpython-313.pyc create mode 100644 recruitment/migrations/__pycache__/0014_source_jobposting_source.cpython-313.pyc create mode 100644 recruitment/migrations/__pycache__/0015_hiringagency_candidate_submitted_by_agency_and_more.cpython-313.pyc create mode 100644 recruitment/migrations/__pycache__/0016_alter_source_options_hiringagency_address_and_more.cpython-313.pyc create mode 100644 recruitment/migrations/__pycache__/0017_alter_jobposting_hiring_agency.cpython-313.pyc create mode 100644 recruitment/migrations/__pycache__/0018_alter_jobposting_hiring_agency.cpython-313.pyc create mode 100644 recruitment/migrations/__pycache__/0019_merge_20251006_1224.cpython-313.pyc create mode 100644 recruitment/migrations/__pycache__/0020_delete_job.cpython-313.pyc create mode 100644 recruitment/migrations/__pycache__/0021_source_api_key_source_api_secret_source_description_and_more.cpython-313.pyc create mode 100644 recruitment/migrations/__pycache__/0022_alter_source_trusted_ips.cpython-313.pyc create mode 100644 recruitment/migrations/__pycache__/0023_alter_jobposting_application_url_and_more.cpython-313.pyc create mode 100644 recruitment/migrations/__pycache__/0024_fieldresponse_created_at_fieldresponse_slug_and_more.cpython-313.pyc create mode 100644 recruitment/views_integration.py diff --git a/.gitignore b/.gitignore index ad516dd..ccf3159 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,5 @@ static/ # Deployment files *.tar.gz -*.zip \ No newline at end of file +*.zip +db.sqlite3 \ No newline at end of file diff --git a/NorahUniversity/__pycache__/__init__.cpython-313.pyc b/NorahUniversity/__pycache__/__init__.cpython-313.pyc index e3b52a3502c61ce51c8793fd4c43e6bbb947c1f6..7a5827f8f16ff8fe58f1845d7fbeb372bb562966 100644 GIT binary patch delta 171 zcmZ3$_>QsuGcPX}0}#wO`Zz+Fhek-H-i^r5t9N#FjEn8 zIKEC7?P4EX>6 diff --git a/NorahUniversity/__pycache__/celery.cpython-313.pyc b/NorahUniversity/__pycache__/celery.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..202981b2a2537b41f3767362b5951e82d448de0d GIT binary patch literal 598 zcmZ8dPfHs?6o0duT{o-25^8F}f_TfJ*;5WJZ9z1M(MH&bhk~$7HTIb#j5RacDKxOHIpr73>s zv5n`j^Gfqr{^G;R6xVOBR-uQg#XmZ0Rgtte=z#K(tZX*dTU&bpZnWFmtt}k9-`m{Z zZJaE(7$^PxBs!*CMA@h&XqH7uPsqhbm6NBPLz47ZEo4de^q^m|dv`dDAOqwK3WmzwbdO?@7IA9+SX_(0}8D^{# ziI6E>K}N)p_y!aGDEZdwvzU63h)Fc?QqJ^%@Q6A`gbe$EMvt_6Qzf-@B%L=g>kJ3< z9WSeG`qM>Qxv_~5`US<`@O`cTND`wTk10wnFL!EnF6s{uzHdC<{~C{qE$DoRZ* zD$Oj(P0cIOODrfT)(b)iIOpf3Wu{NQ#qvz!mOx5YVqSWFd~$wiUP)1AYVj>mm`G|) zYEfl;QEG8%PRZu|tczF~StciOf1dn`H{ZTWq9`>bv)D>sKPNvqF()IxxWvlL*xXXz zpo$ZsUpFPGN&skcPG)jqNoIbYepYdQ-YpgoscA7en$LofW%Cq15vIu-1g6yA5_ER; zaSe)$cM9_Nb`6RT4f44q3Ka@+4G#4QiFa~z_ICAifeSl2JG%yi#5?=@g}C~K+!BMT z4si_jjt_PX@^tj^jB*XSC4p)Rk_)(Ym~pA>q>^tpct8am&ZjYH$SB`C)KVf z7bw68#Kj(yQw9ClC4`t7_&%6y-X*w#adM-us@??#u?HeRQt*a^@&yLb8S85vhjKEmwJC^}h$Wk2sN?xNJ>qSDNg+|<01 z$=_L?vA<*lT2rMtIgnjsvm#p?DXnvc<`nCtBo=2RE zD-!0PfjkEC+#&#VOIERdVnKmk7Q`7v1|Uno_7@p3FnBV6Z3Yv^K{5#I4X;QT6`26V zG?{M+xi|)UIQcsUxx_m=`uI3GI(y$@3l53&aSgu39_$?C84xmg7Eb|_rtxG?UJFLn z%}u-_Ozdo-es2CgE|aJ7PgPvW05%I`C&Mico80`A(wtPgqI@71|#Pp z4SXLAHYW(KV3d<~WPHeB{(+f^pY0NCBXKPn}ds~f$f9HWOiXSj*ARpU}FIU CH;}ym diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 962943d..07e45d9 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -51,7 +51,8 @@ INSTALLED_APPS = [ 'crispy_bootstrap5', 'django_extensions', 'template_partials', - 'django_countries' + 'django_countries', + 'django_celery_results' ] SITE_ID = 1 @@ -203,10 +204,12 @@ FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10MB CORS_ALLOW_CREDENTIALS = True - -# Celery + Redis for long running background i will be using it -CELERY_BROKER_URL = 'redis://127.0.0.1:6379/0' -CELERY_RESULT_BACKEND = 'redis://127.0.0.1:6379/0' +CELERY_BROKER_URL = 'redis://localhost:6379/0' # Or your message broker URL +CELERY_RESULT_BACKEND = 'django-db' # If using django-celery-results +CELERY_ACCEPT_CONTENT = ['application/json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = 'UTC' diff --git a/recruitment/ERP_INTEGRATION_GUIDE.md b/recruitment/ERP_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..4245aab --- /dev/null +++ b/recruitment/ERP_INTEGRATION_GUIDE.md @@ -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* diff --git a/recruitment/__pycache__/admin.cpython-313.pyc b/recruitment/__pycache__/admin.cpython-313.pyc index d3967fd223b9a8c1d2eef68f41f218604a877861..149ef4d021f0e08e211a99cd955015875ba06858 100644 GIT binary patch literal 11317 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 YPYFB|v+z)v=%Q)$oqeR7W28I(2Pct{0RR91 literal 717 zcmbVJJxc>Y5S`6^Bsok}(reV}KysBJB9;L!nj&WzkV~@UHr^Mrm%`G@*2W)U?Jp4$ zlm$UZA?bwt0e3?}NF`=q-@KXKH^XCRrIH0Qj$bFiJ^`4hVj0?k%+BP^GkD;U2Dk)C z6}+6MHFBeGZ7B|RE)G-QFMg1mx)f}#YWb0&8dkoFnRD89f< zc0rsvK@w4i@yKVPlVUQU-HbcFJa@i73~m)e+QlKuA{uAT+)ypO7lm`Vav0JJ+?9ir zLmo)@(Fh@*Q2hpP4Bi(QkJf|r4wW~kJY9|8;A5?GIfA{vI?WLrt-rY*!y#_VWxqd; BiE{t| diff --git a/recruitment/__pycache__/apps.cpython-313.pyc b/recruitment/__pycache__/apps.cpython-313.pyc index bdcec307e07e37d0f473b8913e4e226a61edccca..a63c05a9ad05c16e4ca477a33411f7b286750504 100644 GIT binary patch delta 347 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 delta 2921 zcma)8Z){Ul6yIyRuHDvdh3;R+w!$`8w{><*#|DFKxD7WM|29zmWX+@Ru|4ei%6%P^ z8XW`@5@Ry9iyDJ~MvNJ#AVpC@G!c!G=m!#!yr}VuQ4^7<-~52y-+djMSj2w#_1tsM zIrrT2=bm@wfN<%6;P}Jg$V_9uuV49YXnev^B=k%y(%e1yB;T7>n6@CT&+bX<%iJbx zsGGP|JaN-%&tmIrV?E#QU<+q*GsnZ_a~LaknpMnMF2-%o0x1T}1C#(t0cG?f%ZRU%k$-0GP|2`glf!Z-=1V83RLbZ;`pZJ~q_tZp zpgq>ltWB`7G*OuGl&PhN(fzuO$fBf1!h1+LTTcnPJ)~*DP*~Ko)Fxq}lwP&(Ul3rV zZXH#mK{-aMz_$Y&00fcL)6vY8)@G1hi4Qa93VxhFC6HD4vymF?)B^l~I>1su7hnnf zD=RO*pOKotpp9@tZfv<}b#{eu8F!*TyH6M^W9)d|8hO;)9*u^TkQh_ch_^2$4$1`4 zB0j((KsAFkK1a&ab0BaZ0GA{6fW-ha-~m7*UnuW5-yt6gEki#YPoK0d_k$8MM2~ml0N$!sXp)&ey zUO|ftQOizMiTEu1WZfQ-M}ryPE0|m9WI?SF;jOfwbX&q(C<|k#-uT?kNK7W) zVOfqTkwI@vjVd8h0VQNU?qt_yJHxU+tcJw!l!=#yZi(%Q$^`!FrpCaO+2ERol?Yh~ zU87)fQr&?#A;1czAa%tw&|;6bEM>_+kyp%xWP#UA>MJOt?BBIh8|Za!`90!3Nj>vVPRISq{G&oss<*-r zI9^^;@qa>3e=RR8<&O%2O`c&e-8Z*mTTyZKUa=jA@;EK7OvZLR(OKDR5e_G)uST#M z9@ER!0gn+-ILe;+T_XySCF2d8qRtxs0Pl>GFoxIBXS4(P0E|K z!`y!r;)RBfg(kYM-kqTLj|##?n$@_`8ii&r-O*THvkRogAUPq(MMmppHX+!HDh~c* zBAjc?H5r9@nO`LG-zS7C|=7)Dt&@mYsL)=UgARA{jI7<1=V^9|uN{bgxu zkv>Zj1vewvv5a!bBKq61>#TIHw6rweK9FQE*;!|qjp=3{Az|G8@rs zJ=7}i#AC{aB@*UyrmV)t+19K7qx G6#@YJ0T1N> delta 54 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 diff --git a/recruitment/__pycache__/signals.cpython-313.pyc b/recruitment/__pycache__/signals.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6369bd07181631a1251473436dd13ace2d80ece7 GIT binary patch literal 5567 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 Bv55cx literal 0 HcmV?d00001 diff --git a/recruitment/__pycache__/urls.cpython-313.pyc b/recruitment/__pycache__/urls.cpython-313.pyc index 9dc2251654ee58507e8f7e47fb6b6edf869df129..56c1900938960dcbc336d6873fd7beb2c3dacc47 100644 GIT binary patch literal 5954 zcmbW4OHdPA8pji9lve@-foKxIh?odTt2ZbL@-)a>B*Z5}CXg1Qd2qXfUVG+xYj!N% zOchJ+>M}c)E?rL5Z2D9dr>3j!>aOmtr`*nBzH?5xbAZT9hSJXI@BjOs$JgIENq==X z8f@@a{Ks$Gtru;!-^h*mccIky`fq6c)fThG?DMucyKL{V#m>cQ=FiR5$ThSrKwFRn z+OCCmtz6q<6K!jDzb$rtt!Aak?6UkByAZ3pSF>!VOg%8pvSzkcTFqAZbFkEmHE{mM zHDSe7X3SQsiDMkVzzR?P#he_|3{1yK!e)!La7-&O?h1^HW7>eZT!FdBG3~&3D=-}# z(+PR$I?bG!7dPj>1pb~X{g*kv=l}3~IlmA5{wl}s;{4s<@2%3`!}+g(zpqNapYvY@ zf8ew~*2^*1fVokD>EoE|zzm#b&U}sm&L0GSxJv&G&fgFIn^pP;IDZKIx2yDrIll=0 zNR|GZoc|W=l{;1XZ*%_dz&})_Kf?J3!GFI>{~gYM7yKht`iD6GJ@Aj7_Q&pX%rG#O z&cG3lc>qkMGjNn+#@1?A#!ruRX048M{t56;p7zHkIc5r&hZUHI91{g*x&kxJF^^z9 zGZmN_j+y-*m^onPD)ckY^|JuXLIq}#W0rtfs=z$vm}Ov=D=<$uW(Am)3QUY+R)JZq zz{EM`DKJkfFwZ#VIWW&FFfSM*hqhh@U;R#ipj{Vsl7;Qx?d-<+E-k%R60hZat{~-7 zAlK3eG+LRI24PE17Sj2g+K@^r+Z*|$oD%hVt4;dMC9{%R`zF7kh>Xh7{ePHiSLnT_P;TTySdKr^N`ZYFc7 zG?_lE-f7*i9Js7|MKm+fV#UmSbXgIL;*+DhExqJOyw1xBn4{`Fl>`fg*|dS0CqiF>kHZH=7YN^X+Ef*gW+zS1LAdMb1oF882n5GH&LpjI)St z(;Z~TB(joJfQ2Y(Gpm(QDM0FqZX=UWy(J~Y=BJ#Peo0M~RMk^TrP&|-wYCAE91QS#z?7XZZZtSKrDM^O4 zko!dGR_!^ijzI7C3sQC`1E)hGof;L@^Ni5jsL#|CRJ%0%1NHCwE{S8Xbn@^ zimGkLAtm_^wz7+S&^aYcQtdeoR#?6(lTW5jxUT;T zcjb&C*TDCpOy1YzT5>s0F61RzcUs?f337<(jdS^p1=zAr$<2kN!lDi3GSutLB9|Sc z7xHo}HX>92Ll{L-*`X|qC8bg$E{+hH6lu2f*-^ox_S7PBJ8U+tpiE zOXsB3i_45~16r|%^jns8Y{j_2tXO$0z3pW_pIu~Um^DZ{7v#q2;KFPug)NwibHnky za3i{+E6cOxpC~L1$Ik=yWZFouyzgmH4wBrEH$SWy%9)rj5I2fpO)nJnX(QwXw=y4_FrvY`mgO*Tel(1*qhJ& zQ>feXe)@AbxyOs{Np1E85?&ftDC4Jo-~LD3J%PF>aQ7_go-KCIYbz-v zNXAtP^-?H!IEwohQU4V^CuBN+Aras!$%*HFm1$` z^qff^%@@YL3FMo=zFFj(E&8A`H<6GsUNG)uX>=`c4+j>JHMv(zUi*gp2PUsG*{}5u z;@$<+yP(D1A|YcWnEaZ_m-Yv-Cxkqqqa`Fv8Cy);W@7(g9EZkGXzWW432`IM#5Y9T zbAKAdZ3C!nK>Kb63A4sqCgzwkaCi;h7)Lk8zx+s?c_!{KQ9NqLw_sbu_WANszOj427xhm`bM`L7s>E s4@^~=N>>v_Avl0&t^bLse`f0KqbQCnp~#XJr)m0;s()#e$*cZ<0QaFQl>h($ delta 2016 zcmaLXOH3O_7zglOyf!AVw&U0OfqB}_+F%n>LjY53%%+J5Nex@KRf9}u9t2umOH-sO z4eBk&qPe6}5ALa4Dn;UwBh4v2)pO~pNL8Xpk#gLsm3r+wvYJt4)q-z6fB)Z%cV(@g z<^Rce{*vW00(!mp?$y01|Dh)$YKP}C+K8|$4hXt)*|9`oOxR0z>Ftw_sz{6*nJ$*u z-IDjP5wCS{@XWHbYbeFLdMTByVGUPc!1?pWG^@I0iURBp5-QfbWX(H%~n0Y znIJMTmf4*i;!GIbT(_ODmE4@Ha4-TIq9WpZmZRJ_#>R1O+znpZI>C*5z|FR|hxBse zKG3ko{oHr}yle}5^g(Wv1bq%AmEtC8bOTv-;dV!EAa#y|DuTmy%vK6B9Lyq^XTa_# z9OB?Gg6Hj+trX@sm`Cse19nH@2nWxjZ>(U)Y^89NgBK7SXTa_#e20St1Sjm6trU)N za2&xY2JDW&gNq!TKyccQ*{V-+W(t{BPePG1)5x@X65i#^3^Frc6y0JWs3gS z%pz0%uMh;i!ksgROr?d@^%`T(ado4Cz(c8hPtG_nSeaKa0=UdnX z&Rj!gp@q55nfF1pU6HGFVyJ72nI)jQbH^L*kEP=s`B$+wbJ}@(V|8n7JAdE!e9v&8 z1C0bOPF!5zmbdJ~KWm1Jiw74kIQDM3!njpH+Gk!gQn*i}a_YOgZEUXWuYa{_++O)| z{m#Cj;w*y;e*TOhT<`;C9HJ=xA#{_!fF)_7x9xYy{iyrf zho(}1O5te{N|!B%20Hx%q6YH*XbhV_y98%PvtzJP`?3B*{pm19vIbWDF@zrYFUhKh~UHfYLQ9O0pQq1{#;lWDzEd%}NbQA6jt@H2s67$)>1lHCyhtuDedILNIjGJx=Rbkc4eP2%(kf0LB_2D?hzcWWbEE{NvbBhg z*$u_Ygw*K)(Z^$EJO|^sC)c4gXRXlvH)%L@R5NoWm@A!ZL8)e~((qGI4TlbH(KmOg zRF6_-whXi7ldrK{qv1UojywsOqZK$>d7gmMN7gzGH)vQo37NBXI9qStc?hKg>ob~e z(Ue?X0mci>Viih@);3Ld#00tho`%!xn}B^2&5{nK53F4p8$eXT?mhbE3(%{C<@@B# KH=PE4-u?xMZg(dD diff --git a/recruitment/__pycache__/utils.cpython-313.pyc b/recruitment/__pycache__/utils.cpython-313.pyc index 1dcaa90457f010b98406347b22411e7ef47a607c..4a8959a020b7ea23f61a6b03eccf5f18daf7841d 100644 GIT binary patch delta 3655 zcmZ`*drTY28J~T5{l+$6V?(eZtUyN;Lmyn?5 z%=Jkw)sbX%I@DGw>K|3|UnHc{^^aCfqN-I@RZys~He3(2r%F*(iG;LQC6(H5)(=Q7 zwdCJ^^UcgR-+SigJwFw!n-+@^!Ef%@e;xhgwVT$8zCXk0LnI)9>P0;irhJ5^d+8nq zGaAkGvX})P+spOnurANzF%LYZSC94D*MJSfhJiALGL~hAO)V5T1R-n|IBXGg*g9<4 zn`5D<9id_KfHyzf_7V8PJ~%}0LD(i30PTVi&>@%rO9eAvnP3+z9`p`{%LOZpok9tq zORxb}jAR(U;}H?-r&QfoDk>%65AhNB{$Ixbxdq;TL;Qy>xz zMq>wBTcWXOq@_h{YC05byAW+^J*)V<@Fc7S72^RL9Bw?!JMOgQvO&LY@7` zL%rQ+eaV#U1KsL<1EMrBCih@pM{@)+_6!&i*tw_Cqw3cQ1md zZF3o&q^i*nQ}E~@#HWSMjcAmlhq&SlUD&1c*HoBG(XncTlI+`M0|w4fwU z*g?*Unu|L4kO(=K8-QoYvx5){q6DFk;U@hioQ9kw>;nAVDg!C0JNMnAF-exwRZ-Xd*yl{?*)uUjQqVr z4AA$D8d{7;TA2GT_mM{Cej^8Tq?maifCQRk+vdM0&;L(L<*Q{iHMGDC5pQWf5`2Lb zxO#d>U*IX7Oj)KDZKaB!gZ$;|=>|&B*B5g|FtjmAs*z-KQIkPxz*-Qq^(|z$550O` zFt)#qQ1~n&^9F67@;OtUNz#L~U?RyQm^HL$XeF3rs?i`b01fQrpHL#$3bppidHdFU zlG!qQ!10nK#b)F(gG@ntqD|fQdUq6Nz%H19aw{uHfO2z^GT!DHc5Ahl)EL{zl3h7! zTeX$;O&PUL>5GIDiH51bs4S?;DpML zYQ-g~Y&0x~AN>}rf^DOzctn&i>0&CM--2skG{v7kfBqdQmBc(5F&P+6S+yeeVNpth zTB6^CNnlky7RM(Jlq9Q;FLz;;C9+g5Ix&`(RNJ-%Cr%#ieoHk;a#)^_LJ_!5 zR33}zL^vXy4Ffuvsd50nl*QAKR!x(3!fRPtELI zHC8Vft5;ZWmM>Z5E0_4nIcb@%&03waW7o#6r)HR}qx?qvX8pW<$+2_(_>!ak_LU_^ z+YAql%}~DX-0@K=b2Bv`ze_JWn^&D3OU{l@x_?!>?ChTT+UE}ENBeH=n>(}Y*frC& zVMFGU>7J(yGFTU_)hl{$)?}UT`S()`vbi@AWhh@W+i%p&9-BF~VbqpB->?ApnM4}w zeS2$H1$t0n>}q8m)S9}QnFq}r&=DFftaE8PK>UT<03e>J3zEtJ;&#I90FX$x(fqH4 zK)I@~;En-HT==0rO>-D7E2LDIPC4){pzv-0l?_kGqj-<rYWK~|79E0F}vpNd_|xY@~_2F~Qd56nkcGltHIa3z5%-L(=I9Wn?DjyYPxEiH9 zG%hmx{5&RyzseHL2$g{g&~Ly|vM5Gigg_qwOh8kQn5CKvijjKF<+lMrDdbHhNv>RX zxVbB^_=ANzj^8oxfCCA6TiS#rmB>0 zRC+Q}J(daU5*J!M0_d=F$@7Y^DG3 zmPI%NV*EOsCUP~;7j?v!3C{_Te|r#TgP(s{S*`mXaCvIsFO_dF-WOHE-yyr=0FQo8 zsEf*1ygPNod$>dy_11=pDM8*#a%42;Lee|69N*^0oK~uNA`Lzgqd8B#q~n*>`@7ez zyAINV)Pm7>i}vgwk~JMQ&Av;9GXyRwAJ-N&`>fWRAzpbQX;mG(=S8@WUKM5Tl|Iw- z;Xsgo(V9hW*E2McKrqM>3N4dK5H}HF*AL@XWO?8|tQY>t&%uwuqLYFiKm7td2)`jP^C}m6vwWeEYaY9E>coP delta 2585 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 delta 5199 zcmai2eQZDQlq{u>lE`H<6?BV-LYL%M%nyy5>Ys4D2#aHB8 zI@aiI5>4Kn1M3i$!dxv8=NAs$CXe&1BW? zWAZ*vg?w9CQdmn8SB|70ImDXFNnSVE-e*g%-^9{-lCGHS%1Ae}w1K4V$MtY;~2A4$)`&JWf^{pWs@ih`|^EDBU`qmO|_jxdVldqX@hp&auJK67B=WBIY z4l8}8Efz5*Pg$FiJ16tVrH&3|(MeyY%aYN#+1Ev@$3LUfouhLL&-Uc((3>-RtFMoC z*u^?+IXeBOj_kDMwl3+M!N0lEQo zfF3|E0Luk$*^jSv400hqea;VA@5%@ z)G!J4>lr(Ui9z|%lC>r&gQ-}&j{Y&qW>noDj74?z4&5IL=mFt_N*r($dUk=G4abq=0WqT)`DtCDlT@=4 zxZUnA3O@3<9IuGBFu8n0$!Gi{bx{qt9pIOLu4wF!;`2BFJ{u)|e=rizw4fS^`28Nm zJP2jF#XKbl)u7y3*=w>8M9AdX%81uwwea&!#rit|*W);eiKN_F)nM+Pq}>lIcgxFF ztCXSCr&X_5lbfNkjlg3QIq`+`MuPdWjzpZ8RNLmvMDGwom>ULc1&jbj0XqTP2@Gre z2>E&keR|5oIKQv?W8jloKs{D*IYDCpllO863IbwSDLBXc! z!s9SW)dBZo@0$^OaUqQBVlsn ztgKy0MV@+orOUn)d1({{g-W>>@=u)*Vfo&gP0Cr>)aa{1=si`2V@}m)Vt z+t`>yvKb|?WI9157Y)p*MnOVT1-`@sP(&jz^60#9JWH&Rr-${3Dzel%BC#3rg|pId z&__&Yre}7OZx~_N$a<4y?jFk)Gj7pzKjs%w-lk{r!slqgf@0XUNMgog<e7;=$v7Cwn#hAQzChMbuk`8@E` zX%Wvr3=0I?!nt^LT^a>yD6BI_&!*P458E8)p?;5ycQq?d%Llt=2C`PCFx3S>3L1IA zSR$&M`Nzi=nqe_dm7BV2ntur869gLihFJpH01;dMkg5m55phA@+5Hk-FFzixO$B=P z+m-Y3!oYe5NT$5{_rSh<#ATeW!M*FYE4$@G>-P0sAc@D7!z$MeI5O||Ph zjf$UQL-s!7IQ4b5<5f(-81W0h_0pz%(II~@5Q$6$g0>^#wbY|q;!4SnXblw?XTgZ9 zA70(bHDJpL6lY7?X){}BP1av|bl9a_l&6QgI3upyjxsQux;ht+P&4v}L&8sz`D#3$ zv<{$uDiMx^RM9@G&qb*7andd&C9}XTVFNpNAU-?YKny6Hke_a?uIz_Kaqd>)E!jM> z*StW^rM^7!8~gS*Nol@hZkU#M-=W8~YZS$W8d9gjQ8hG5 zZ=cJUcvC*SqhATj-|ZM~c@Bd22#liXuo?;Z^%HSbybtML0q+3*0C-mpd$)x-V0_e0 z5-wQH2y(D94}x(|H8p)L7B%uBu|P;O>{GGO3GTYr^1_C4&v_eXbOBBQI^~Dnb-kd? ztP7Ncrc_ulO)`^(f-U$9R)eG*VtDUALiQ)X?+J)sR%fRm5LXEHMwqPMj<)*voJP;&C+^GSB%NNv>;F)@6_%ED9Zx z?kOf)kn$Ov2z*8!5}`DtUDZtFmqHNlk5~lfUjffe!v;P34*H)BY{!DWwThG z8Z|pRUGBy5gS|he0xtH|%2)QCUzUgc@&VZ>SrmEi{uPHf0yvMzxsJVt<5(b)pc-Qm z|3ybq)VNb&jb^KhJIoUPUGB%015uryQT&t3D9sAhIl4OOFzzZYFuR<?Q>3XF{o}T`-6F;6j%D88b_$W0!vtOwdF4*U!$4NL4 z3D2waP+m?FQ72!WU1mDOdDsB8%Fy9JbSBo0tkc?O)F`bA3ODpu0#*TTATaV0dN`t) z|0%Hs(ndfNrt{FO6zBxueoba2*r|L=J!NfYiARq`x0!{)> z1I_{#0M7z00A2#T0C){>5%3n^6MzkL6$8ovRe%}*azXR}TA|peNOAEwe diff --git a/recruitment/__pycache__/views_integration.cpython-313.pyc b/recruitment/__pycache__/views_integration.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a6a195df7b8eb0049732b45aa3866396100ba3fd GIT binary patch literal 8310 zcmcIJTWlLwc6Uh5kQ85fQ55wcM-N+~ACxT1PNc+1>{w1rIkw9&6Fb&=N|PgrHbp9T zhJFyiHoIsG<)^Wm2DXzAdw~LR&>sQqhd-Sb-KNP$$z=f%v$1!91}*X@MQ#vufuiSJ za!87nw?Wapz}&g_&bjBFd(OG%o^xi?&u7 zMP00In{`J#)Wh2L+15xKZDZ}0+4e{W?SQsJa?W-}ywn@%qFoUm^|3M6Y)>8Q+w0iZQ$Csf$L*%9d&$CXB~?6 zFK{96w}|^>h|_J!xTa{SB_(9iEk{!cO*bFTT>p&Ioe?#gi7M)HHlr#4=t!t^URJIt zOUoK`JE`(cE=3i2QPY;?B}H4zCRG@3tu*DNlE~7ymZdOec{8P4g}3L;csd2^rLvhB zO4%oeM&lVZp1`qharYs!E=buNO(=RxBzy66R@G9O1>Jo-qbUo35a3Q^7XX=eCVIMI zP*UiXR6g`*ZgmU<{6-dlawH!WuTvRg2S#q z5VjcH1*)uh@9RoDtu2a)MI~{$MucuvbBTnas(H3tgKR~lHKmI2EAdo1ele}&`F-K> z@VMTRpbDt33fxn)#kY=I`m0KMUbh-M$9;;TespXxyQGYz)FoJaY?)>+DG5y-i^Fp{9?vbx*aF_sTuNI~ zGTPV`V6rL$R}Fm)FJITq3yP+9JuQtejC)}rbq7@Mk#9V`<*s1aH&ylp%7fumXJO#b zhMn(k*(7|cW1aAJ$0h)hM2*qVL7nr!vpEdaOB{#+-j#C393wxm5HLD1Zk)tSN4bL> zh=4l@+vR}~3Izj@QkUaeVliZ)U|flAiPHsD7cO6AZ@Vlf(s5OlWfkxML&W!}2n~u7 z*L%0wk%149sohZhfcxrv|LU(i!F!zr&-neWqG!Bd8>d6?af>?HVSuTV8$|LE2{h~^ z+NgE_hGnp38Z)LC7e)G-io{KyB_zI&ATAU$D^Z)#s=tv#QNv)=he_1c(5W^hlcCPW zJ7Yf81M}OThK-_hn~GroGUk{$>TCK4UCAcd4^;OX^*{Z+Hs2Dnj8;D9RuuAa7qBa)K-bRx1OY!Bj%FCu-Q0w4JUFy8jM+@V2PTG zG$aihTyzs@$9{9n30QVEUYY}q)(h5bE>;qUYkjM{@$xk+fNBboVKPq+nS*4W18frp z2AKH&}h})gXH}4Mz5x|t+CeV3&v>uEsdN%Wb|S+i@+?%s1%wqHBq^X zQMntn5~&2M;5qC8i^bmaoI!JFPk#9IcqW-v#M7swGtcx-@~!dZ<#Z~+JjJm~;KSzm zoHoDrd7W3#=ghYq&s;$_RE$WcPKw|%#6u?90|ZmFfT}m& z!L8AmKZ?yrXLR$#_&ClQ*KN@m>GY|S(u^)pMa`u(aFblnVh+BHjG!oa-2*-jx?$i6 z$`}|>JYFGloktYKOYz>yTfjvZbxzhfG4B$q{@Z*moxTpVBA z@*2FPY4Hs3z@}VFfbTS)&3n-Sq?veHRKc}ZsK~aQdV=x^QuGpZb0&LLcP6p`n9*eI z`m&;1P~tkD&c>6fZn>CEUe^T-MUsl{L4hA-ZAJq{=T*0$HUWVU`OpCzfVo$}EwWq! z$C#p9rCYNXna`&S^Qkn@r1SGBs%jK(d%cw@6FQz%zft$4vkQ&xU+sfKZg5u}a@}9m zU0PO)r)3q0%K+Wrf-a}y3DA<6YYAl;V;3#8Sz<{2t_h6*E7CC}8lNoX7Tx;J=t`s3+)3!kQckzU(5RoprCV5GG3 zNU8VLn@88(q&s-)-J9>O`9_Pr(UNc14a;x6{#$2np1r+z-&FGMUi0oNdiRyQ`&W)V z?(l!@8!88Ol!N;>c=L{SSg<#+VKsLL*NM3;xb7r@UH4xs^gX}wM%m*nd3II72M#|R z{-@CAp)cMj9eKNO_*$@)dxuIr`&MSl-FBSIJwSw~ z`)hyjZs1N}%^xoM!>gf!KV0y?xaL1n^dBktkKPm>xAkoWnPniatoe@>{l`lF*KY{_ zUf;^T?MQjzB)&cOkCnRiue=5e5A9kToGuPdKX|S*_-fG^TzUO*M{gOJ-8xom9b2_N z@D)yO^ob-SeIsRYtQ;6wcM;c~4Pte+{azZ^R<0k3}^9D@2wVIZ>0{N)IT?GT6U zT?q8$-j)*<^Ow&ZHqE|l{%VSY&Q~wJy-=zeiA%Or#^Ft-tN6-m?FGxcDIxo*Z>e!IXh#z$-6V4Wry zwbgq@3r2rrU}!j^nEMFx5CjPlqd&yw1Fy1Fg%}qz%2k$OsU=qMay*&u#;de)K_s`{ zq`WRbQbSH9bzVzpX{F+vRC6DY#n71NLc>6B$f~#1E+mC6vuj=NsNXv>t}4S9-Gg^8 z-mjQ30pxTM=2!0Q6tr}EO3gxAXeq9Rtd%>ZCT=Pt=pQ4L879*gp{3$&S~dNZ1VI$P z2evW!4cFiYIP=e-0?ThB!JTV?=Zb;n9#~3&Lq%KfitVw-SMKoL_TPB9;2Z>P?-^M0 zPZs@?t7l67>G$0$=EsiC1^^ozhqbOyu`5*ecHcU8^IW;hf9ulCOXZ&ayWu%`v{J^_bdeDrYB*V1b<&CB_>oGOah}3ftHm1Jz8oTS`i+5yycGP z@e=v*$K%bC|HvQaczi}eUV099)FR7E*<>!Q$g=K~<#%%NGzKux^T;yukkhG*lF7n= zRhE<41o)}wVN*1w6xqabh%|vM7gP{$Y1ySzf;rX{%s-)TV}*wROkZvpOdlE*${6Z} zj3nfEQwcDD8ckiyX$mAMbyqcCnNZScS%ydsb0jL>Jg-!w0l5r9v4Vh^sQ&`h4}VTJ zOeWs;oz2QSHrk2Mb>ni0AADls_pS5x$M>$k4Fhd!ysyan)=gf1pfL3O zI)V2?%R}jlp@(Nbce8FmI$JpRlfs2~Au(ULFkd{kP)MZ<=h8)KX%oQ_Z#P4*9&SL` zQ$+~xMua&0`w+r+hVXk6w3roRJEX^9cUw}$Xp8XK#Av=TUvHe@4EGF+MU1c?)5rM< z{FA`WCc9dyKMfjw7L2N?C6re!yC3?@RG|sQ4Q&>nSXizcGo7Nyq@#D>rPXj1R9t1Mim|*&nlC`coj5{s~m- zQyZ+Gdzo$UPXP)wk^UU3A*gioYcprIYz6OECb~svG>T5*&f@~CZ0G9Kv*c04(Nqk< zSAN$(zK9Q-8$>PSL)-qWVX}w29Rm5KIpDxB-3W!+W{Lsdv6AmJ?Lr$?~GOftuGh#l>!@^m$!mn-zp zzkoSqcvOs7L4O3|>@K&1-`#&}?l0#)ys+loTlDTNdB=Vm+*R%#0@vExvSGFQ1#r)u z)^%d#txPDP06mJ!qZsI0Q0W3B9Y7f>#){b~NAglUvyctrZ_!GIp|3i^bUj@AFRF&| zhbps!`ZGZo7CWxwt76G%skGXFg;`VG*|-LE;DoN~5d4N!!jM&||Fz?I`{d$DpwyhQK77+PWV zE>!Dgj^ln!9FIuP!l(ByYmVB zy5$Bo(mXMB-MDgNvBU+-oO8ou<^r2$Vsc?6u3MnH;r@{zxf=^505{n=E6xtG7Tdu8 E0JIBMrvLx| literal 0 HcmV?d00001 diff --git a/recruitment/admin.py b/recruitment/admin.py index 67c8f21..3bfdc84 100644 --- a/recruitment/admin.py +++ b/recruitment/admin.py @@ -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) diff --git a/recruitment/erp_integration_service.py b/recruitment/erp_integration_service.py new file mode 100644 index 0000000..5a5978f --- /dev/null +++ b/recruitment/erp_integration_service.py @@ -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') diff --git a/recruitment/migrations/0020_delete_job.py b/recruitment/migrations/0020_delete_job.py new file mode 100644 index 0000000..80b089f --- /dev/null +++ b/recruitment/migrations/0020_delete_job.py @@ -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', + ), + ] diff --git a/recruitment/migrations/0021_source_api_key_source_api_secret_source_description_and_more.py b/recruitment/migrations/0021_source_api_key_source_api_secret_source_description_and_more.py new file mode 100644 index 0000000..91a68ac --- /dev/null +++ b/recruitment/migrations/0021_source_api_key_source_api_secret_source_description_and_more.py @@ -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'], + }, + ), + ] diff --git a/recruitment/migrations/0022_alter_source_trusted_ips.py b/recruitment/migrations/0022_alter_source_trusted_ips.py new file mode 100644 index 0000000..facbfec --- /dev/null +++ b/recruitment/migrations/0022_alter_source_trusted_ips.py @@ -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'), + ), + ] diff --git a/recruitment/migrations/0023_alter_jobposting_application_url_and_more.py b/recruitment/migrations/0023_alter_jobposting_application_url_and_more.py new file mode 100644 index 0000000..a1d7ce3 --- /dev/null +++ b/recruitment/migrations/0023_alter_jobposting_application_url_and_more.py @@ -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), + ), + ] diff --git a/recruitment/migrations/0024_fieldresponse_created_at_fieldresponse_slug_and_more.py b/recruitment/migrations/0024_fieldresponse_created_at_fieldresponse_slug_and_more.py new file mode 100644 index 0000000..439ae6d --- /dev/null +++ b/recruitment/migrations/0024_fieldresponse_created_at_fieldresponse_slug_and_more.py @@ -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'), + ), + ] diff --git a/recruitment/migrations/__pycache__/0013_candidate_criteria_checklist_candidate_match_score_and_more.cpython-313.pyc b/recruitment/migrations/__pycache__/0013_candidate_criteria_checklist_candidate_match_score_and_more.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e12a7f8ef892bc25eac5fe5156292c656261f0c GIT binary patch literal 1253 zcmah}O>fgM7f<{XVaq_j#56#DoLn`1tj>^UeU^ zSUJTgoq_(k1RsD8eC-ivXjE(HRKEe{Kn?i%Jn)UCwq+Hh;e1c#Q-d09(=DAS=<33W zNt+^0(u4^sPD32AKy4Lc{0#KxHFancP|XKa_cdzxx~EaIS=y=;!NXZz#qiDM*w*xL ztw3_G)U%|DJprQsxNChPH{jp!eg9pQD0fPeFwHu8s~;(J27EQ)*WWOC`BO-TO`854p0)3 z5OFN30VNz$f?&|WK{q0dZ_BKuod_jeF&5$$$|CL>QbVO1Oi+x4ncGr};3yR1G2%f7 zvLL0{)v3}+24qnrY{n^0+PuSDZLsb<$EcfN#xVN?Hr+{4hIvK^g#)2HA&aoY6$qbY zC{m|LZwkT|p^Qf)!AYvNL)^njh?9U|Mpeg&@XROzcPaR-iJ}fUBloyV!){ZXKkE(Nr)!IR|wm0*I?^oBpjhVLdXVmpPs(w(d z@7@1*)Z$34{p#|$nXa7|b91(8y=l-|arsTx6n40al6JZr?&SLAstrC*ReKP7OCnKT q8+64wtxH$_9spaE=G4$M?WYAa_q(;AS?`_0>81VYr5`{#Ir|s#GCR8f literal 0 HcmV?d00001 diff --git a/recruitment/migrations/__pycache__/0014_source_jobposting_source.cpython-313.pyc b/recruitment/migrations/__pycache__/0014_source_jobposting_source.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..350f33476ffbc9b1b6871f46a11b8e17f56f2edb GIT binary patch literal 1859 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 E0lvY@4*&oF literal 0 HcmV?d00001 diff --git a/recruitment/migrations/__pycache__/0016_alter_source_options_hiringagency_address_and_more.cpython-313.pyc b/recruitment/migrations/__pycache__/0016_alter_source_options_hiringagency_address_and_more.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c7cd58eb5d1872e6e7a138db60b413397cb0bd7f GIT binary patch literal 2548 zcmah~J8auV7$zx+rXG$h+KwGNj#uG$*1MlF`6I1Sxr`zh8iiqr`?%Q2VkZQ4*!{5*o}$UZH6bKo8)9=KAR2Wx4J#L4?`kA*Cq)rlfd$8*zv>57cahj zNv~SOaw2*6GhMkp261)os+&UkpRIf|nfMJ>hdtMRTkCkl0 zxd&zg+XgEMHg}AfQ^YncY@6Xg*$o_@?Citn3E2o4rSyrKv8~(KsrXewhck?Nc2HRj zqX?F4jDr+xTE0=TAt(+F<-oD-2bc^ojxxcJ98)*^-NZ8AYlaV=rHsILb;o_EQ+GKy zsMAbx@F#rF!Ack00dBday6gEgU4uOKPR1cMbUImZyF+-PvT$BoDx&k+jlvym--M&x zBu~1w4wcA8!*~_jb=}9?KAEJNbikOE-K87bJ|>P~YrL8oB()B=mj%&kn=a8@YbTHn$9?;_AF#fFqPy2YF#?nYR$fr8_uK z#E>z1=SyZO94f#>xV7b~K{zhKjPw2A3WQp;YM6`30=a%4-{ElU zxcgxl({;~>k4zaF&L>tW@Uf@s#EIU^x?iMclUe18$o?!%4 z(DQOV^~xQ6wQ?~1i@HIxrL)pwy)%ISz7jg)r+0^cp}s?8u+9GHwc`CI3c~lofh@#} z;z2|jmB0PyAR)v@TFUI6GTTm{YG>wunRs%!v4NYTn=fLKc;Y}|=Tm#iRD1kfJ3ZM} zCt7M|Pt80UeZ10C^DhRXfZ{B@s7y=EHq~rPU2Lk0Pm0fs#;0F2)WwE+x39){TRq!S z=bGwVOTExkFSOLFP4(*2l?M8}VU!y8+yg=QM!X?4)T^)?S(SQOruUR-=G`>&Zf;MV zd%W`W>!wjbli#oHXoN14rZKt)~__>9db{hQb#NW7Z?b*AH&+az19yZnO zUJ9NDO2NSMd+PinAH4oRT$W&2lh*0-rBtR%Rk|L5<>Sa#krxn{5#da?#q+BC-5Ocy z7BH)Xya{PZ2gBAZ#5N`_4FNa=)~4Sy*kmUbxXp-BfNjYzvx(JeJDo z@_mq{{_U?h$V$T&57Bs(C2MWBZ$LPN7^(BQH02keNV7!e+$0 zLoU4RAxQLEP%aE;%4B9wJE8OhkC?Kdy6i7ZAzhh<)33hGzIbov7CCe;z3s}<31fdI5ONp_nMPsEVy@i*OIRF0uRaK3-!ot>uJG(SK`@r>?S(VEZ zHlCJygvb;or$uO*im=&(rVt|LA#n&BDe8B4m-(UW(lGK9!H-xgl|F@iOlhwJdD5S1 zm_HdsA4pyskvySoMDU2UmE=t50m%+6NDRjpG;mM}8w1OncRuY5TvVF7EG}LY7q9L= zySnRL)fO&m%NMofpD#`Z2yK}=CS32$V}13_ZR-}HxrTK+W0%~4<|>Br@pVqchIj(10b4$VV_+GE@z+5{{kKywoUiWX?9#8< Kr9TLiEc+iWP$s|t literal 0 HcmV?d00001 diff --git a/recruitment/migrations/__pycache__/0018_alter_jobposting_hiring_agency.cpython-313.pyc b/recruitment/migrations/__pycache__/0018_alter_jobposting_hiring_agency.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..be967798d58be5c758764532c63b1fc45e2f4195 GIT binary patch literal 1023 zcmah{Pfrs;6rcS|cWDKSL}DavfCGDB@nDq1ppB6j6b@Z99BL-h?$9o5ck9elfs=`e zXD=jv1pOd>0J%Ws%86S^HC}zQrD;7m$-MV_f9K76uU{&;2+r3Z7p;np(1j9)$GQgn zbpR(QM4|Q)ZE8&0)S2#~IkbR6{SFF^npU-kK_mXa7+_{JP2ajyg0D-r9XA;fah7sv zx3h>QyfKPFsCX^(BLF9;hL{#2riU6cLOsx!S+lA`pRqV7#R$#XM0I*hc@4p4g?2a* z7D6X<*Q{`I&45f!SY&Rqlwg7T;4=&p6FBz5%mQ^BCC8+{@aFX%c-$+y101;M{_-bTJ0cZbAVUF@D!2 ziSh<%3gf+)agoF+O*3VVXosc|v?!*Wsg00sMRjsRC1$2lOl^_$XeU!N*Gf0*(BY2E zV$22XO9K-jSX}QyZ!l)F5ZH%{Bnetsn+7p&lQ;=FEZe6I!2<&KfRJtr;&?FDw_vn@ z0Sxxhe+IkKIbv!la-sm=1@am7Y~(!Y8Tu{z6`=Q##Q;Nt@Y9 zRL?t?cWY3L`u@Pz10)NN2lH5G3m|H6`CTAAL({ZBHq!iGc2TpxxM$N#Kc|;|BM|cF Ezi_i7B>(^b literal 0 HcmV?d00001 diff --git a/recruitment/migrations/__pycache__/0019_merge_20251006_1224.cpython-313.pyc b/recruitment/migrations/__pycache__/0019_merge_20251006_1224.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..180c2e83e8299b9d078f9e5c650edea02ec2aa17 GIT binary patch literal 673 zcmYjPO>fgc5S_IXH!)OHg-U=p6zIj5Itdg3s`>*0^nfMAq0(p*Z=4ObH_Wb)dP73u z%%u{4fgi#T-~`%hPrXG7y|d1T!b&^)o_FSH{ATCd+ab91{paPl&j2oKvR=&R8}VxVTypdf2Tfo;E8>s$q{BJ%7F5<{w_DMV@Pai`OLf+I!6 zNQyjNAZ>UikVK(AiRFiCP)My^FAIviOtDZTB9FG5rhV*0o+HnVAe^YdRB0p249Ak{ z!-U{TtD9ShBT3rivJ|DNuTwDO^Xpm2 zc7rc{+Fj?KM!s#Q6JBQOFda}m_N7qW?=tPSL$-CibmoNi9Ca@Z^aB@WK4a`U0QT^2 U&|<;2@b}KaubqQyaAZ;a4ljA6n;@B8Hy99=w21djg?8Mi)jWSiev77Bek1+}@2ME)q_Wr7Ws4TJm_E3(y^~IFny5 zVGIm~z_buCJ=8qJjB)Q`^RA%9vIRXfPLa&m2z$&*O?N}sqprq{Dbrj{^cjg+omB-X zE3xPMgHv1(mJ%HJ!O6h)U*bU!91DF&C$dMQj0*A{&Rttr7%PIYa4;@tQs)Y9V*Ig= z^3~2gjNfOBS9w;FlB(^5%t@J$GR_ER$_CMYLvm7)5lu)g96Bc}55z9Uypn#zII1d^ zP3nqpj9Fib7HUK>@}{&PUdD?k%e^_HGZI(ai{$emit4GbddSYx!OMX MwS904k}TE#0fgzJr2qf` literal 0 HcmV?d00001 diff --git a/recruitment/migrations/__pycache__/0021_source_api_key_source_api_secret_source_description_and_more.cpython-313.pyc b/recruitment/migrations/__pycache__/0021_source_api_key_source_api_secret_source_description_and_more.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..36981d78e2c6a2afd5bbec23dff5fe9da67cdcf8 GIT binary patch literal 4812 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 literal 0 HcmV?d00001 diff --git a/recruitment/migrations/__pycache__/0023_alter_jobposting_application_url_and_more.cpython-313.pyc b/recruitment/migrations/__pycache__/0023_alter_jobposting_application_url_and_more.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5049dfb72a86436b610f0354e08c3b8b562eb6d3 GIT binary patch literal 1265 zcmaJ~HcZlxdT+-qqY+zXt#Y5sQI32xgps4At%yg`NSXI57 zcoG)&yex?L4fI?15fWKQs6BY_Vs0a&c-?<>&k!-P1y%Kb{{GKNv*{r?gMZ$R{xuPL ztC`uUO^L}JAfBQC1;!fU2K?rlxoUB1)#i2=okvS3uoh5YZ&>{jZ3a;N*RAU3wV~0g z9ct#6o~gVf9&#e%EEUR0vWPMfR)*C(H)SU8fOv{F%E;Ud3~mLcZ*Y5~)}Q~}p`vvz zkV(DY{;IAPI6-a0?Vmob?VtUM?80%`^{L1=g8GUbG*+zt$;5?B7LH>;mR>Ly%&(Z* z-#7V*pvhaq6TOylf1dT_unvyUsX0zVUc@q?sZ@(suU@-`36qp#krh0oSn@(h8sRt> z%G=5Yc_w6>4pocfIg7)x$GG5Z{qe&6{@w2HBg$zvBxw{!L{ib!W;;Dw)dq~D50yKj zEXR^=OJ%18W6JxDFm0M~(X(J~dR90k2?f1=Ynw4;k!rJS8Y|3-RPvq2&43h9Ou9EY z8N}o;s>-QHbdwZJ0utHAjHW|5dI>AhRt;RjVyrxjAxyz2(DN8SDhSh|fNNpA8FL|7 zoYFMY>WJnvjc6LiRB*j(%DbuCb4O>zZ7pa(-6gA>Xp5x7Oxa<^DQ{`zJSZQ`Dpc;R z5vg32m*sS}p=U4_5;_@TA|;Op1vCz0ehvU8aFJsEC`+gxi-g3?&v~{*Ln(X$_aPz0 z2>gWq*&zL+_4i?<-%m$+G(P;r^e_wzn!@RFzQY{|Sw4D2#B+H5L}LfJD`U$%?L4_V z_K+`<o|X`@^5Fk_8fJ# zYX7qC{_@dU@o&IJxl?5{D92ciuBueGxx)EQ8#aEdmg6Vh1J-~mG{D$03}fOTw4N`U_Wi7ifz literal 0 HcmV?d00001 diff --git a/recruitment/migrations/__pycache__/0024_fieldresponse_created_at_fieldresponse_slug_and_more.cpython-313.pyc b/recruitment/migrations/__pycache__/0024_fieldresponse_created_at_fieldresponse_slug_and_more.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..714a9d768b1e287bd8502740585690825e7b55e4 GIT binary patch literal 4902 zcmdT|OK;mo5GE;!q8P~!`kB~H95+=WG?CIr4hEFAegbjQ2SmGR8wU$YT+4JQQdyEo z>lST+0y+26|IprgY)^Ggy;?y537bO??Zr0-ZttBXEnBu^*MJNK6(Dk7GxP1Yvoj;@ zBoZ0+mZfk!Y@ATMiXVwvJ7wurCA;sge{#czM>tLu)#zkM zmDJeearWiqL~gWMI(NRs*u~~CH3AmSz%#BUCL?OsWayZ^b|1Z0wMXrp6pocwUNTpT z+BvQiwLPx-UNTpG9ptL7O|JT1GFSZ_`lkin6T+G;NfjCK&~Xq%oj)PkOGcAmEiYiRy0>PS#b)>nA z>+(#{8Q0uYh{iR~wUK2%Mw+hEsE$`O&va@05hi)t!N}4|cs><|B8?ShewT*XAdUDo zXipi;VDbo~x@_1nz2X+sY8(x#&`d>WByVcg11e$NaJ9UNsc2%W=&n-Hvy6uxCM=4j zLZlmBgjJ5Dmx;?>S>MIIE-wXb23w#~9kwq?V=Fc(1$g|X<7!1ryV$7XJwI7Y#T^HbzB#O|yzNjd-qM zI#hCDX5F@c5by%p8_Xj7nTsvPnnMLWPeq?xhj!<{$L!J!>?+Wf_`t%8Havo^plRMJ$T^vYv&!uMj>y1$c?*bYxCChb4jh_b?z(q%(&f*I2-f`v zyNXT{ehoe0wnZ+sz;B0ye({?-+cFpHt4br!q>73Er0xmi(eZp?0~ zA8%^;%?I{2$9>Ju37g7IX!XqtFQQ@4ax{YQMz*4S*t}|@4V)l*3OElU_`l=4+yK@3_>VC%xE*a9ub+8TQ%jn#(WBpO!DZhTo$78~z; zt5wY$1*;DF(NVe%a?rTS34O&Y;D9>)t*b0o=g95tmiBG7hK!_ng9R* literal 0 HcmV?d00001 diff --git a/recruitment/models.py b/recruitment/models.py index e1a6159..973898d 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -56,11 +56,10 @@ class JobPosting(Base): job_type = models.CharField(max_length=20, choices=JOB_TYPES, default='FULL_TIME') workplace_type = models.CharField(max_length=20, choices=WORKPLACE_TYPES, default='ON_SITE') - # Location location_city = models.CharField(max_length=100, blank=True) location_state = models.CharField(max_length=100, blank=True) - location_country = models.CharField(max_length=100, default='United States') + location_country = models.CharField(max_length=100, default='Saudia Arabia') # Job Details description = models.TextField(help_text="Full job description including responsibilities and requirements") @@ -70,7 +69,7 @@ class JobPosting(Base): # Application Information - application_url = models.URLField(validators=[URLValidator()], help_text="URL where candidates apply") + application_url = models.URLField(validators=[URLValidator()], help_text="URL where candidates apply",null=True, blank=True) application_deadline = models.DateField(null=True, blank=True) application_instructions = models.TextField(blank=True, help_text="Special instructions for applicants") @@ -108,8 +107,8 @@ class JobPosting(Base): 'Source', on_delete=models.SET_NULL, # Recommended: If a source is deleted, job's source is set to NULL related_name='job_postings', - null=True, - blank=True, + null=True, + blank=True, help_text="The system or channel from which this job posting originated or was first published." ) @@ -129,6 +128,8 @@ class JobPosting(Base): def __str__(self): return f"{self.title} - {self.get_status_display()}" + def get_source(self): + return self.source.name if self.source else 'System' def save(self, *args, **kwargs): # Generate unique internal job ID if not exists if not self.internal_job_id: @@ -212,7 +213,7 @@ class Candidate(Base): weaknesses = models.TextField(blank=True) criteria_checklist = models.JSONField(default=dict, blank=True) - + submitted_by_agency = models.ForeignKey( 'HiringAgency', on_delete=models.SET_NULL, @@ -278,9 +279,6 @@ class Candidate(Base): return self.full_name - - - class TrainingMaterial(Base): title = models.CharField(max_length=255, verbose_name=_('Title')) content = models.TextField(blank=True, verbose_name=_('Content')) @@ -316,15 +314,14 @@ class ZoomMeeting(Base): return self.topic -class FormTemplate(models.Model): +class FormTemplate(Base): """ Represents a complete form template with multiple stages """ + job = models.OneToOneField(JobPosting, on_delete=models.CASCADE, related_name='form_template') name = models.CharField(max_length=200, help_text="Name of the form template") description = models.TextField(blank=True, help_text="Description of the form template") created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='form_templates') - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) is_active = models.BooleanField(default=True, help_text="Whether this template is active") class Meta: @@ -342,7 +339,7 @@ class FormTemplate(models.Model): return sum(stage.fields.count() for stage in self.stages.all()) -class FormStage(models.Model): +class FormStage(Base): """ Represents a stage/section within a form template """ @@ -364,7 +361,7 @@ class FormStage(models.Model): raise ValidationError("Order must be a positive integer") -class FormField(models.Model): +class FormField(Base): """ Represents a single field within a form stage """ @@ -439,7 +436,7 @@ class FormField(models.Model): raise ValidationError("Order must be a positive integer") -class FormSubmission(models.Model): +class FormSubmission(Base): """ Represents a completed form submission by an applicant """ @@ -458,7 +455,7 @@ class FormSubmission(models.Model): return f"Submission for {self.template.name} - {self.submitted_at.strftime('%Y-%m-%d %H:%M')}" -class FieldResponse(models.Model): +class FieldResponse(Base): """ Represents a response to a specific field in a form submission """ @@ -492,14 +489,13 @@ class FieldResponse(models.Model): # Optional: Create a model for form templates that can be shared across organizations -class SharedFormTemplate(models.Model): +class SharedFormTemplate(Base): """ Represents a form template that can be shared across different organizations/users """ template = models.OneToOneField(FormTemplate, on_delete=models.CASCADE) is_public = models.BooleanField(default=False, help_text="Whether this template is publicly available") shared_with = models.ManyToManyField(User, blank=True, related_name='shared_templates') - created_at = models.DateTimeField(auto_now_add=True) class Meta: verbose_name = 'Shared Form Template' @@ -507,25 +503,180 @@ class SharedFormTemplate(models.Model): def __str__(self): return f"Shared: {self.template.name}" - -class Source(models.Model): + +class Source(Base): name = models.CharField( max_length=100, - unique=True, + unique=True, verbose_name=_('Source Name'), help_text=_("e.g., ATS, ERP ") ) + source_type = models.CharField( + max_length=100, + verbose_name=_('Source Type'), + help_text=_("e.g., ATS, ERP ") + ) + description = models.TextField( + blank=True, + verbose_name=_('Description'), + help_text=_("A description of the source") + ) + ip_address = models.GenericIPAddressField( + blank=True, + null=True, + verbose_name=_('IP Address'), + help_text=_("The IP address of the source") + ) created_at = models.DateTimeField(auto_now_add=True) + # Integration specific fields + api_key = models.CharField( + max_length=255, + blank=True, + null=True, + verbose_name=_('API Key'), + help_text=_("API key for authentication (will be encrypted)") + ) + api_secret = models.CharField( + max_length=255, + blank=True, + null=True, + verbose_name=_('API Secret'), + help_text=_("API secret for authentication (will be encrypted)") + ) + trusted_ips = models.TextField( + blank=True, + null=True, + verbose_name=_('Trusted IP Addresses'), + help_text=_("Comma-separated list of trusted IP addresses") + ) + is_active = models.BooleanField( + default=True, + verbose_name=_('Active'), + help_text=_("Whether this source is active for integration") + ) + integration_version = models.CharField( + max_length=50, + blank=True, + verbose_name=_('Integration Version'), + help_text=_("Version of the integration protocol") + ) + last_sync_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_('Last Sync At'), + help_text=_("Timestamp of the last successful synchronization") + ) + sync_status = models.CharField( + max_length=20, + blank=True, + choices=[ + ('IDLE', 'Idle'), + ('SYNCING', 'Syncing'), + ('ERROR', 'Error'), + ('DISABLED', 'Disabled') + ], + default='IDLE', + verbose_name=_('Sync Status') + ) + def __str__(self): return self.name + + class Meta: verbose_name = _('Source') verbose_name_plural = _('Sources') ordering = ['name'] +class IntegrationLog(Base): + """ + Log all integration requests and responses for audit and debugging purposes + """ + class ActionChoices(models.TextChoices): + REQUEST = 'REQUEST', _('Request') + RESPONSE = 'RESPONSE', _('Response') + ERROR = 'ERROR', _('Error') + SYNC = 'SYNC', _('Sync') + CREATE_JOB = 'CREATE_JOB', _('Create Job') + UPDATE_JOB = 'UPDATE_JOB', _('Update Job') + + source = models.ForeignKey( + Source, + on_delete=models.CASCADE, + related_name='integration_logs', + verbose_name=_('Source') + ) + action = models.CharField( + max_length=20, + choices=ActionChoices.choices, + verbose_name=_('Action') + ) + endpoint = models.CharField( + max_length=255, + blank=True, + verbose_name=_('Endpoint') + ) + method = models.CharField( + max_length=10, + blank=True, + verbose_name=_('HTTP Method') + ) + request_data = models.JSONField( + blank=True, + null=True, + verbose_name=_('Request Data') + ) + response_data = models.JSONField( + blank=True, + null=True, + verbose_name=_('Response Data') + ) + status_code = models.CharField( + max_length=10, + blank=True, + verbose_name=_('Status Code') + ) + error_message = models.TextField( + blank=True, + verbose_name=_('Error Message') + ) + ip_address = models.GenericIPAddressField( + verbose_name=_('IP Address') + ) + user_agent = models.CharField( + max_length=255, + blank=True, + verbose_name=_('User Agent') + ) + processing_time = models.FloatField( + null=True, + blank=True, + verbose_name=_('Processing Time (seconds)') + ) + + def __str__(self): + return f"{self.source.name} - {self.action} - {self.created_at}" + + class Meta: + ordering = ['-created_at'] + verbose_name = _('Integration Log') + verbose_name_plural = _('Integration Logs') + + @property + def is_successful(self): + """Check if the integration action was successful""" + if self.action == self.ActionChoices.ERROR: + return False + if self.action == self.ActionChoices.REQUEST: + return True # Requests are always logged, success depends on response + if self.status_code and self.status_code.startswith('2'): + return True + return False + + class HiringAgency(Base): name = models.CharField(max_length=200, unique=True, verbose_name=_('Agency Name')) contact_person = models.CharField(max_length=150, blank=True, verbose_name=_('Contact Person')) @@ -543,6 +694,3 @@ class HiringAgency(Base): verbose_name = _('Hiring Agency') verbose_name_plural = _('Hiring Agencies') ordering = ['name'] - - - \ No newline at end of file diff --git a/recruitment/urls.py b/recruitment/urls.py index 599e36f..4d7514d 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -1,6 +1,7 @@ from django.urls import path from . import views_frontend from . import views +from . import views_integration urlpatterns = [ path('dashboard/', views_frontend.dashboard_view, name='dashboard'), @@ -45,7 +46,11 @@ urlpatterns = [ path('api/create/', views.create_job, name='create_job_api'), path('api//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'), diff --git a/recruitment/views_integration.py b/recruitment/views_integration.py new file mode 100644 index 0000000..82b6aa6 --- /dev/null +++ b/recruitment/views_integration.py @@ -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' + } + }) diff --git a/templates/jobs/job_list.html b/templates/jobs/job_list.html index f540b62..da76cf0 100644 --- a/templates/jobs/job_list.html +++ b/templates/jobs/job_list.html @@ -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 @@
Filter & Search
-
- + +
-
@@ -152,7 +152,7 @@ - + {# Show Clear button if any filter/search is active #} {% if status_filter or search_query %} @@ -179,11 +179,12 @@
-

- {{ job.department|default:"No Department" }}
- {{ job.get_location_display }}
- {{ job.get_job_type_display }} -

+

+ {{ job.department|default:"No Department" }}
+ {{ job.get_location_display }}
+ {{ job.get_job_type_display }}
+ {{ job.get_source }} +

{% if job.posted_to_linkedin %} From 0483e3efc6ade4ce9d4540cc1e7fecf63b9191e1 Mon Sep 17 00:00:00 2001 From: ismail Date: Tue, 7 Oct 2025 13:44:11 +0300 Subject: [PATCH 2/3] update --- .gitignore | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ccf3159..488280b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +<<<<<<< HEAD # Byte-compiled / optimized / DLL files __pycache__/ *.pyc @@ -57,4 +58,56 @@ static/ # Deployment files *.tar.gz *.zip -db.sqlite3 \ No newline at end of file +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) From c5c7963df55c31b13324e5ac377850f4170b809a Mon Sep 17 00:00:00 2001 From: ismail Date: Tue, 7 Oct 2025 16:23:58 +0300 Subject: [PATCH 3/3] update the form builder --- .../__pycache__/settings.cpython-313.pyc | Bin 4955 -> 5152 bytes NorahUniversity/settings.py | 4 +- recruitment/__pycache__/admin.cpython-313.pyc | Bin 11317 -> 11317 bytes .../erp_integration_service.cpython-313.pyc | Bin 13176 -> 13176 bytes recruitment/__pycache__/forms.cpython-313.pyc | Bin 16944 -> 18777 bytes .../linkedin_service.cpython-313.pyc | Bin 11109 -> 11109 bytes .../__pycache__/models.cpython-313.pyc | Bin 36795 -> 37614 bytes .../__pycache__/signals.cpython-313.pyc | Bin 5567 -> 10965 bytes recruitment/__pycache__/urls.cpython-313.pyc | Bin 5954 -> 6071 bytes recruitment/__pycache__/views.cpython-313.pyc | Bin 27573 -> 28898 bytes .../views_frontend.cpython-313.pyc | Bin 19511 -> 19442 bytes .../views_integration.cpython-313.pyc | Bin 8310 -> 8310 bytes recruitment/forms.py | 45 +- recruitment/linkedin_service.py | 79 +- ...ield_max_files_formfield_multiple_files.py | 23 + .../0020_delete_job.cpython-313.pyc | Bin 658 -> 658 bytes ...ource_description_and_more.cpython-313.pyc | Bin 4812 -> 4812 bytes ...2_alter_source_trusted_ips.cpython-313.pyc | Bin 961 -> 961 bytes ...g_application_url_and_more.cpython-313.pyc | Bin 1265 -> 1265 bytes ...ieldresponse_slug_and_more.cpython-313.pyc | Bin 4902 -> 4902 bytes recruitment/models.py | 26 +- recruitment/signals.py | 304 ++++++- recruitment/urls.py | 1 + recruitment/views.py | 30 +- templates/forms/create_form_template.html | 208 +++++ templates/forms/form_builder.html | 800 +++++++++++------- templates/forms/form_templates_list.html | 149 +++- 27 files changed, 1249 insertions(+), 420 deletions(-) create mode 100644 recruitment/migrations/0025_formfield_max_files_formfield_multiple_files.py create mode 100644 templates/forms/create_form_template.html diff --git a/NorahUniversity/__pycache__/settings.cpython-313.pyc b/NorahUniversity/__pycache__/settings.cpython-313.pyc index 8662d9d2012d1de5b120753dc444a0f57d29341e..345c317d215461bcf974bcc9c6626617c4b4a710 100644 GIT binary patch delta 272 zcmcbuwm^gLGcPX}0}xyke44=|K9Ns?F=nHBAd^6_b&*A|O_61=ZIM+vlcwF~8m7}+ zDYy76%*?aOi!&=za}7-mOsW*a1AO8g0}S;7{X9%_BJ^$m^8^$dW}(8|KVz(7AMKdD$hCo?ZQH6=4oKRGccCn+>VK<9vnOvV zv-2$>A5TATR~JvecxN9^SHF;WPnTO_=wiXH&Oxprw?yIMK|mcruFfIxp+TOL*9io3 t6$1Un2*kxPo4Ex&nWP)|Kd>=~sa;?Yx*=(EfkEtsg!@GX(V~2y5&)qUP-OrB delta 74 zcmZ3Waa)b=GcPX}0}yl^dz=v}Hjz()F<_&5AQMYElcx3N4yMywOqv#(xAUelPmUK1 Z=E?=CWCY@3kIj1oJ(<`W`HQlFTmT8g6B7Uc diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index bc1e0ff..3a40e03 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -212,8 +212,6 @@ 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/' \ No newline at end of file diff --git a/recruitment/__pycache__/admin.cpython-313.pyc b/recruitment/__pycache__/admin.cpython-313.pyc index 149ef4d021f0e08e211a99cd955015875ba06858..87dcd283ed4b296bc5b5ea1bc57b7bf4928b4f1c 100644 GIT binary patch delta 20 acmdlQu{DDGGcPX}0}#Z%d$N&RO9uc*4hDVz delta 20 acmdlQu{DDGGcPX}0}$Lg`*AWmT6nHtn1d+UAwIvMcO7Yq)z;ZE_o^|lQvDA+Cdhkc4+E$zx!;G zg4^zie4Kmkxqs(;_nhk&9~UQ&iq2_=!zQq=L;UORH+qgZ%f!Ty1#$Pc;hRh3*-%vA1- zDz9p=#5b1`@ylW!y(YFfHT&uq85&TAh9h!9p`Pk(=>vv^29JkrlvDsJ0abu%fR~;z zjri)Ac(Y&^8|sfM5haxH<&j)NymUw2_r!*y<~3p|U28sTZpR!;(`A+ehR$*(4`}&B zk;AcQWIvhD+T)^T4aMWZP(+T$b5DsGs_8}RWAj>=sF_FA@Su_)3!%3H900hQw9--A zN^=Lq)#*2F#iAeUkBj79{FhG}(bEL*1DXL#0jmK^=zj}J<_<71E;8sQd?Gix+_b@7 zs~^Ul9u=8 zv;r0ZjDULpZGa^JOhmXxJNR+=)_2er=bEgaQ_ir#hplNAzAYu}b7;ol*f5bXwhw@_ zYKGy5S!nI9g>~GimeB5)8dBn99W1a5Uq0Cgc@tnW-R7#Bhk40v0QXTolO+9nRCm?t z^eI=+D0+{UyxiCiI~+nQ+$u-ZF!xU%Au*yKlJ6%BBQL#DQrfA&)U+q2Mtvr}vSy7c zqro_LsESUf9Erq6m9Vah$4yL$)nsl^~f zKIvq~W<5iSKN1Vck#Pg3hGt6aA65wVuNm4}#*MnxIHX2NJ;v&UDa>^S;)DSsOlfY$ zET$D6$(xfh7|0p(75aeZi7aFO=J}G?+=;&BfG)raKnj3Ek{-aLfP;Wlbga@n)XT(i z#|)#08GX2CA|MO{^|)p6o)g~=BIw}&bT zWs-hSmAR+MbZNEAP{zYlpob};CL&5EJhWT#nfXPnr!mRb%|+euML8W`g!w=9rY@!v z()`cyJEfkQA7}l3uI8ke%H{W`u+EI%zm4WGz;Onek;F#HDkdcz0ePPy^)rGIRgTX1zKOG63&VxRLf1C*bQ5dFvevTB}t5EJqZ2HER%D+IN*7e6lqV;DR} zE9x>K7)$rn^_#?}($v=|n)QRx-!}NGGL+|RM?u&-q7o&nXUpfQv(dkU-=UviKIBoK zesRbdC|&^kl!0bm8H*=kQSvi14E;UiMJ6O2TcT=02_qLLlz3*SmbXuij40$K41St^ zr*TIKkN8Qn`K3Dp>1AqfTGyD}-b`njbtYZ4Z7oeLEiIB?s%_^Id4)dKv{w8%{Xs4l>nQ-IDw zyaCYZ=*me)7Yt_TXhd@{fY*#?AU(@Kv-YwLt%(kgB*<5x%mcImZZ9c7r$$2x4@=S>kVnF* z)I;Q*s>}n#DoY5ny#?q4@QReLMp?4QxG;1r0A7v2#-cmu`1<1Ci0$-Xt2<5a9u>vY zw4iN^c^HHG>8`frZXZ#i z!wRP=RQ$;~BP{k2^jPU!w0XT~Yq0S(f13jAidK$}P+3)UpXi zopYU?9k&)F7G^tZEu&rvjUyzYS!d{i&FGbH#quABX3m&b>3d!Gu7f|dyii1uqog0Q zRSt)P3hFL85)-+;nV(vL{nn5ipifay_CR!AzVx zh$YbgR{?(kya&LXpHXR_L-%tu*xf7fM2|LTa_ci?r0H(jT3t*@UHwDEvPD=flKfi1 zXVHpgKWrhZDC9{AqQqXVQiwe~d6j03At{l00>?kG2;MaKrvEO`n)*trmNn381}FV@ zb&EM*Yc>W7=nKY*QVV;gTi65MaSvMpU%<)xg5HW$VW6l*3>0*T0avq;I0AEsbFkRw z)=IFE?86&!bH9?1)e$xWqi8vsL^DO@Aw5NDRy7`!LkV@Cq8Zo5b|xL!k{pRZd`2>jUXYvI0O;8&mHXK;2NpBwsuj(dcP>v{$XS|W6_(f; zdqhuJpOUCDT>B>*0sqBO9}fu=MU{v1QkrYoVe_Ah-D6v5`=q;MEOyCVKUqc8$C74PIuiJ#m znrFMd+r=y<%F4OXeV4pzm_;irDq-RA{;|C2VtLaMXW> z&7!~9aXCU6wlGl;?Ch_Q{Y^*GVue$r|CwhmE(lmU4BJc4jW1x|UP^1`m$Tn)E38#1 zjNsp(;455Day=eEqgjuK+amN4dRH+>7Oui&P^2uY$oJ9pbpX=s?b(*8v~uqq1P%sO zR%(O6Zz2XK=vCV$da~A%syo$n!M^AnyKAie)cOnd#gP0zV!EY}8qfQdU9fk)W1lnD z{`98v-j+*Mt>>2qF4zaA3(VEF!=`T)JXvr#-=n*2D_h!U{eN-}FK72N^KExL4X-mT z0U#s9JxuuMkT7X#_|QOjHHzsNMokOwkNJQx(Mz$lcB7TU$ zb)zVXSA?=FLIeMI*A=1dU7`E3(EY9uxGV&2m>NXeSl_V?N9fg_zRDZI0fSh1W2Jc^ Q(~cZV9ZCI5U?N}Ve@`CIt^fc4 delta 2897 zcmai0eQXp(6z^QG*SpeQdtQOIv}I{~z}glnVkuC*=?Bo3kMbo!j^%D?7nj{SyQOI% zKm)-T5)v382GsZ!V?hyF6%~ztAPI>^qXrXJ6GJrNFHvF)ME}tF&Gu-)Q!eSRZ{EE3 zX6E@i%9d)DOQ1$QIt`U(HSbq=*-zn*3_`U z!YErng8Acxehk;=-lH!P`aG@=-=i-U`h2c0xJO?i^bxLqXp}zW?~2NW>?N{?_*t2+ z-cfvmK7TRaAG_`^W`FysT*U`}j@fR~owb&#s|j1qqA;K(fC+$7Ksh^^^)iuJV_+E> z#|{Oq1ZF`!+gY2v*VkCY<+W~*s!Gx{^nO~w$3w&oB`hnR&=t!{9}^MESUBfE#dI#Z zfj%wSrP_1~^dUeV0EE&y=Hx66G(c=}{>q7xjaYw(&}RG#(uXlq4X6Rs0%il60QDT) zytucQgsSWDICt&jT8nU4NJZc%OrvajZk2bqC>x9wJ43m1NP9V-8W`K5_DPF+dUP$J z*qUibowm}YQcy^z0wx1w4jpn{+8yP?U>X}-5|uL{&ICLLzz&5g6}*c5i2Xmo^zgD2 z*%!k}A1P;2q_A)+9F_ubLf6;xB+pT6WNJ)MnVsl1wS;QX6|kt|AP4D6$g2RW+3`rM z0*9hq0Kr6%OOp57?-A1E{1Ay}@&y~mo+oYW*Rdr{2t~I75&(p7BMHTb+f*?u4Yb5< zlP00}?=Uq(_KS7hkfHX)EkRf}>ljx?8XSFGkZi(nWg_SYG>pGaM2i%{Eekl8yTt0lK z`o*2n%kPjPk%lo|8f?wh)fD}EBsl>3X^fqbL%GOUh zJhntdMcd7a8G&N`=_YKlI9rQ@t8hlV0n3gDtPF-6Af*5W=CU$v&^aeGJ8Ez!?s1V6kc0ra|Atz}LQrzQu)s zymf|Vt4TCQTeXH=-K<@T-mB8Ha6ZYdR&N`@Iar`E$h+#J49jYl!5Mc-qgX{-0W59bSj4*^~kM$%Sc1P0Rp=)lyw z^N>W7e;(2c9Nf@SzRhJuPp?g%fies5A8jEx{D{5oT%Y!xFVz@6dE^Y)cNuQjl$*7b zD%)-P0p!3MtzSuYiyRH)djn||HFs%7LKVpx$n8{mlbW=MD%&(gq>Vof+Ys#v;BLvf zLCxU(8h!~(h*OE@1SMMVuNuAm=p{1Cxj6GMAs1Oo{knh&uQv8dePwkIM9N|KH4yo5 zGx=-4TddM$rGiPc!TGJez~|NLB^H>SnBj%tzHX+j8uV*~667K%FR_(SpuR012Z@JZ zr)IxMX0bVQej#Vs)VT|auZ^-q6^>Z$pUb#qVdE`cNP&5crFR#j+1zf_I+=7O+cK|| zzgoNJy+#6}Bpeo7(9#0J+^mGIDh6$X98!|WxJs!>`L~qVorvnWAZjNE3u))QG}TOR zFlWKueQ2iqF+?KdaM~*C<5Qy^!mAj55BL%AGvEfG9U?^OPvFDL>Sdp|PV_f_o6*c}wyqrWN5)|Up delta 73 zcmaDF_B4#|GcPX}0}#wO`Z!~&#zww&en#KT)A-jjF=lOc6N(gItk}Fj=@1*^-_6OI ZT8xYull!zz@K-T2ihN=Kkwv0Fy#T2r7P0^U diff --git a/recruitment/__pycache__/models.cpython-313.pyc b/recruitment/__pycache__/models.cpython-313.pyc index 0a696c750c5e4485452fd1fd606795b684c46549..c673a1c5ee53944078f2cc258d731f6dc8cb9f43 100644 GIT binary patch delta 9663 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 diff --git a/recruitment/__pycache__/signals.cpython-313.pyc b/recruitment/__pycache__/signals.cpython-313.pyc index 6369bd07181631a1251473436dd13ace2d80ece7..df8b0cd633c1060f77bc5bdfd9539192876cd510 100644 GIT binary patch literal 10965 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 T#8-81_&oJAJZLwWj@ADkApHKy delta 599 zcmZ8dJ8KkC6uxI(J2TmteW)bt%3^|ICNbMZHlTtiTg+6+pja$~VP{B2_LXyYBLQKP zN(&nq#1vMs62T@vKrGzOB87+pTUn(`*j_wWw~3dT?|$ETeD`p^Kl;3EPb|x1bPm7% z+Aht$w&#XNq~0)}`QR}Z5UvR>d6FxmOkDOAPjOXL|Hf5ML#?50*uvxDSzrx)75>H0 zAP?0u(WHLOn{l(4buF}}9yZzynd+gTZal7Bv=PeBtVzCJVyn{>=C@SGI2Fo))aZ42 zf0GBh5eXfmW)$y6cq*QzfY$DYQHv)B@C5dg6w1&%{teG^H?x#SOF&LQo*=b*0rDu^ z0MsRLai==`TymPumrj3T=pOLM=!EjahyoSdgSGZ5pXc1cfbH#RBoh z!GmTm96Z#6k&B6jgB;P5CLU%^8%eln;&F_gj0b0D@ZiD8?wg(OdB1nQZ03FVZAAPb ziuD3rbHBgby!}*^ESEvX^YCw|KN3ntpha8On?61#KaJ zZ?Ra`3(b;6JFAzQGx2Ha0Q4J9b)Q1!4~YBfFI8wG0}stZYT@-Z(S0FjGg>W%y&* zA43BZ7)}z2u~xQr%xw-~e z@EBm@b?sUWWfUu;Xe^CkhKvJ7r%bxtwqrF~6x^bq$Pk7}GEHMvUFHvX>^hxe#`JLv zQsRa(iIqt-vw&fdWOz>YKX{(w9LE79#|(J_%M&Pd3&SOnr$;oqeN@9t$KAdycH9zg z@K<9-a1aLv(FHd3GLKu~RM|acgpxRvJlMc+maKB9aTs~+GWtev-^hnH4ChFR!!-_v z4qV1?4iD#0aTCKWa+lNll*Z|0kal8*H;TPcl$gSBnmpimoj12vtwv`*?(9cHDGX=G cLryC?@VIRoe6y){c--O^+dOK}F4K$p2k4LjtpET3 delta 711 zcmXAn%}*0S6u@`sl#kW|?RK}VU6uw5wAkG$E=diVf=TTXNrPf90!2;GDh9E{Z-JP2 z=*0xxnDizbj5S_RPVMEc}V1~ z!c{=muxgmGpi|{caDZ?z=u~+@`GIne@{lqt4iTHByg~U=>?1q7qAb%hF7fn`*!JLi zZAWWt06LFnNx*?POz4%Ab{aNs_DUhw0@<396vDD3FHlit`N!);Tg`%+ZLI(*;#)kG zw;fpxZ;jbP3WSt($^bNlmzi8)nTh(KJu(3!6RmY8^DdLC=08c?yvG(57MX2{_GAer zOV$#(<|bZa_5rhz+L#^HA*x&BGk`AQhs-`=R@rvju?)mAR(2N9W&D`gC(P2@=RhUD ii+J-XZ9cEN7V)Z diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index 0273c215d8111963a128551fe6536375e588b872..dc13da4d3a8a5fa258dc22706fb43fb0de11d90a 100644 GIT binary patch delta 7170 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 delta 1459 zcmZuwS!`QX5WVx3*nVD;##tQ4j-M?z&FYXkOWmw)k~#~8;G2hnEH$-=LsODh`W%Vn zgA|F6AVd{1s@fkuir}Z}qGAvdNR_G<(TX2bMF>&>p&}B6eo%^#_(0;`=R8G)TJo8> zcV^Da+?n^s8Tj-pcpkdl3Sqy<rorZv_st*m0&w-s33R2+;_Ys7yP`r{sEPQaFMz4$Qc+C% z@m#gn;gMM;T{6=IA%N{o-sL(g`Hu9GXXU)mM)HD}jM@k}u2JBTlfofZf|E69a zJ5skX`<|V%rD>AI-mrZ&TltvU$~BvZP_HI2tcc6SD)<<9R*S1BeO%}^UeH3hWnylP z_><3H&c-g%N?KLqGiz>ZtqfMj<^MDBV0PP#+1A>Gi~3?Ei*C)OxjO_}<0r@Lx;@is zCD7~<(z$)GI*PJfQ!c#dt4l{15gRa2e@t9-Ke7|GQ|?Z&7#U^5Q`uja=SY}jViviL zWDl~Q9Bg|76FyzhlysbTv#dKaR#HNPnv+k+%C0`TdUb4lLz!-iQgc{-+f!BfO6G+S zp#j~IbVRr19L$kEF+}E|G5lJ1G^@`p?nn7iin60GIIR_5{Pay|02;IJ>t2T!9T~X?`(hoac_$;_Yw|AI7_wPgBS9ZjaHz zu8rq7oX680-OZmdbpvw7O)lLz!#Hlt%;uJ|^9y<79CsUJkY^tnQSq;i=D=;Heao*d zv%3C1Nwy^acamcz{O0KILLuJyq43mNL#m&A-pSxvd$Ng%>$RR#Lb~2q&-5E*$!aFP zXh=3Q@nuDnV?yTGuZ(%boA#bDhj>c_rr&bNV;-#Ew*qS6^1gQks4paYRsp*4cJGVI zIxE89pkk`87uKLm$=;j=&w${Z#k` zygyh0V=Uw?_yJEvx|B`s$YBh>N)Es#Iu9&B+T>h2FkgC?n>nP}cb*U8k>N@V4ef%v zjPMnhHvMi4y`yY#KMvEF8E%6u{C;;$;e+9;Lg*K6xyPZFLyp6*9Dd_)AKyr|$Pbu0 xQ}`%F1^5I@#z#W@+0Aav9?bg8D$Eqd13We!gntSv<09Bb08YT4leji<Ue!P);8#4etf(A1H diff --git a/recruitment/migrations/__pycache__/0023_alter_jobposting_application_url_and_more.cpython-313.pyc b/recruitment/migrations/__pycache__/0023_alter_jobposting_application_url_and_more.cpython-313.pyc index 5049dfb72a86436b610f0354e08c3b8b562eb6d3..fbfeec0f848455d689aacf089b6e1d97a8b55a66 100644 GIT binary patch delta 20 acmey!`H_?RGcPX}0}#Z%d$N)H84CbMDhBKT delta 20 acmey!`H_?RGcPX}0}v!#dAyPP84CbLtOnKq diff --git a/recruitment/migrations/__pycache__/0024_fieldresponse_created_at_fieldresponse_slug_and_more.cpython-313.pyc b/recruitment/migrations/__pycache__/0024_fieldresponse_created_at_fieldresponse_slug_and_more.cpython-313.pyc index 714a9d768b1e287bd8502740585690825e7b55e4..f5967821110625cb9c7ce0b57ccdfad4582ba3fd 100644 GIT binary patch delta 19 ZcmZ3cwoHxdGcPX}0}#Z%+sGv&3;;F@1n&R< delta 19 ZcmZ3cwoHxdGcPX}0}v>@+{h&(3;;BQ1g!u7 diff --git a/recruitment/models.py b/recruitment/models.py index 973898d..2619a0b 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -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')) @@ -167,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): @@ -339,6 +346,7 @@ class FormTemplate(Base): return sum(stage.fields.count() for stage in self.stages.all()) + class FormStage(Base): """ Represents a stage/section within a form template @@ -402,15 +410,20 @@ class FormField(Base): 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']: @@ -427,11 +440,18 @@ class FormField(Base): 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") diff --git a/recruitment/signals.py b/recruitment/signals.py index 69f2b2d..912989d 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -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): - \ No newline at end of file + +@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 + ) \ No newline at end of file diff --git a/recruitment/urls.py b/recruitment/urls.py index 4d7514d..a1c2404 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -57,6 +57,7 @@ urlpatterns = [ path('forms/builder/', views.form_builder, name='form_builder'), path('forms/builder//', 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//', views.form_wizard_view, name='form_wizard'), path('forms/form//submit/', views.submit_form, name='submit_form'), diff --git a/recruitment/views.py b/recruitment/views.py index 2f26c1b..b671f81 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -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__) @@ -321,6 +322,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}") @@ -685,10 +687,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', '') @@ -703,13 +706,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""" diff --git a/templates/forms/create_form_template.html b/templates/forms/create_form_template.html new file mode 100644 index 0000000..03688e3 --- /dev/null +++ b/templates/forms/create_form_template.html @@ -0,0 +1,208 @@ +{% extends 'base.html' %} +{% load static i18n %} +{% load crispy_forms_tags %} + +{% block title %}Create Form Template - ATS{% endblock %} + +{% block customCSS %} + +{% endblock %} + +{% block content %} +
+ + +
+
+
+
+

New Form Template

+
+
+ + {% csrf_token %} + {{ form|crispy }} +
+ + Cancel + + +
+ +
+
+
+
+
+ +{% if messages %} + {% for message in messages %} + + {% endfor %} +{% endif %} +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/forms/form_builder.html b/templates/forms/form_builder.html index 842bc4e..a3217a6 100644 --- a/templates/forms/form_builder.html +++ b/templates/forms/form_builder.html @@ -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 @@ - +
+

File Settings

+
+ + +
+
+ + +
+
+
+ + +
+ Enable this to allow uploading multiple files for this field. +
+
+ + + Only applicable when multiple files are allowed. +
+
@@ -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 = '

Error loading template data.

'; + 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 = ` -
- - ${field.label || field.type.charAt(0).toUpperCase() + field.type.slice(1)} - ${field.required ? ' *' : ''} -
-
-
- -
- ${!field.predefined ? `
- -
` : ''} -
- `; - const fieldContent = document.createElement('div'); - fieldContent.className = 'field-content'; - fieldContent.innerHTML = ` - - `; - // 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 = ` -
- -
-
-

Drag & drop your resume here or click to browse

-
-
-

Supported formats: ${field.fileTypes || '.pdf, .doc, .docx'} (Max ${field.maxFileSize || 5}MB)

-
- - `; - if (field.uploadedFile) { - const uploadedFile = document.createElement('div'); - uploadedFile.className = 'uploaded-file'; - uploadedFile.innerHTML = ` -
- -
-
${field.uploadedFile.name}
-
${formatFileSize(field.uploadedFile.size)}
-
+ 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 = ` +
+ + ${field.label || field.type.charAt(0).toUpperCase() + field.type.slice(1)} + ${field.required ? ' *' : ''} +
+
+
+ +
+ ${!field.predefined ? `
+ +
` : ''} +
+ `; + + const fieldContent = document.createElement('div'); + fieldContent.className = 'field-content'; + fieldContent.innerHTML = ` + + `; + + // 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 = ` +
+ +
+
+

Drag & drop your ${field.label.toLowerCase()} here or click to browse

+
+
+

Supported formats: ${field.fileTypes || '.pdf, .doc, .docx'} (Max ${field.maxFileSize || 5}MB)

+ ${field.multipleFiles ? `

Multiple files allowed (Max ${field.maxFiles || 1} files)

` : ''} +
+ + `; + + // 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 = ` +
+ +
+
${file.name}
+
${formatFileSize(file.size)}
- - `; - 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 = ` - - - `; - 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); - } +
+ + `; + 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 = ` + + + `; + 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 = ` - - - `; - 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 = ` + + + `; + 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 = '

Loading form template...

'; + + // 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 diff --git a/templates/forms/form_templates_list.html b/templates/forms/form_templates_list.html index 3f0dbfc..0194a88 100644 --- a/templates/forms/form_templates_list.html +++ b/templates/forms/form_templates_list.html @@ -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 @@

{% trans "Form Templates" %}

- - {% trans "Create New Template" %} - +
{# Search/Filter Area - Matching Job List Structure #}
Search Templates
-
- + +
@@ -214,7 +215,7 @@ - + {# Show Clear button if search is active #} {% if query %} @@ -236,13 +237,14 @@

{{ template.name }}

-
+ {{ template.job }} +
{{ template.created_at|date:"M d, Y" }} {{ template.updated_at|timesince }} {% trans "ago" %}
- + {# Content area - includes stats and description #}
@@ -263,7 +265,7 @@ {% endif %}

- + {# Action area - visually separated with pt-2 border-top #}
@@ -336,7 +338,33 @@ {% endif %}
-{% include 'includes/delete_modal.html' %} +{% include 'includes/delete_modal.html' %} + + + {% 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'); + } + }); -{% endblock %} \ No newline at end of file +{% endblock %}