diff --git a/NorahUniversity/__pycache__/settings.cpython-313.pyc b/NorahUniversity/__pycache__/settings.cpython-313.pyc index 8f015bc..4ae63cf 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-313.pyc and b/NorahUniversity/__pycache__/settings.cpython-313.pyc differ diff --git a/NorahUniversity/__pycache__/urls.cpython-313.pyc b/NorahUniversity/__pycache__/urls.cpython-313.pyc index a5a2849..2aebbd2 100644 Binary files a/NorahUniversity/__pycache__/urls.cpython-313.pyc and b/NorahUniversity/__pycache__/urls.cpython-313.pyc differ diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 3eb57de..2333ee5 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -135,9 +135,9 @@ WSGI_APPLICATION = 'NorahUniversity.wsgi.application' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'haikal_db', - 'USER': 'faheed', - 'PASSWORD': 'Faheed@215', + 'NAME': 'norahuniversity', + 'USER': 'norahuniversity', + 'PASSWORD': 'norahuniversity', 'HOST': '127.0.0.1', 'PORT': '5432', } diff --git a/NorahUniversity/urls.py b/NorahUniversity/urls.py index f7a39da..bf381ce 100644 --- a/NorahUniversity/urls.py +++ b/NorahUniversity/urls.py @@ -1,7 +1,7 @@ - -from recruitment import views +from recruitment import views,views_frontend from django.conf import settings from django.contrib import admin + from django.urls import path, include from django.conf.urls.static import static from django.views.generic import RedirectView @@ -27,12 +27,17 @@ urlpatterns = [ path('application//submit/', views.application_submit, name='application_submit'), path('application//apply/', views.application_detail, name='application_detail'), path('application//success/', views.application_success, name='application_success'), - + path('api/templates/', views.list_form_templates, name='list_form_templates'), path('api/templates/save/', views.save_form_template, name='save_form_template'), path('api/templates//', views.load_form_template, name='load_form_template'), path('api/templates//delete/', views.delete_form_template, name='delete_form_template'), - path('api/webhook/',views.zoom_webhook_view,name='zoom_webhook_view') + path('api/webhook/',views.zoom_webhook_view,name='zoom_webhook_view'), + + path('sync/task//status/', views_frontend.sync_task_status, name='sync_task_status'), + path('sync/history/', views_frontend.sync_history, name='sync_history'), + path('sync/history//', views_frontend.sync_history, name='sync_history_job'), + ] urlpatterns += i18n_patterns( @@ -42,4 +47,4 @@ urlpatterns += i18n_patterns( # This includes the root path (''), which is handled by 'recruitment.urls' urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) -urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) \ No newline at end of file +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/SYNC_IMPLEMENTATION_SUMMARY.md b/SYNC_IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..fb1cf49 --- /dev/null +++ b/SYNC_IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,193 @@ +# ATS Sync Functionality Implementation Summary + +## Overview +This document summarizes the comprehensive improvements made to the ATS (Applicant Tracking System) sync functionality for moving hired candidates to external sources. The implementation includes async processing, enhanced logging, real-time status tracking, and a complete admin interface. + +## Key Features Implemented + +### 1. Async Task Processing with Django-Q +- **Background Processing**: All sync operations now run asynchronously using Django-Q +- **Task Queue Management**: Tasks are queued and processed by background workers +- **Retry Logic**: Automatic retry mechanism for failed sync operations +- **Status Tracking**: Real-time task status monitoring (pending, running, completed, failed) + +### 2. Enhanced Logging System +- **Structured Logging**: Comprehensive logging with different levels (INFO, WARNING, ERROR) +- **Log Rotation**: Automatic log file rotation to prevent disk space issues +- **Detailed Tracking**: Logs include candidate details, source information, and sync results +- **Error Context**: Detailed error information with stack traces for debugging + +### 3. Real-time Frontend Updates +- **Live Status Updates**: Frontend polls for task status every 2 seconds +- **Progress Indicators**: Visual feedback during sync operations +- **Result Display**: Detailed sync results with success/failure summaries +- **User-friendly Messages**: Clear status messages and error handling + +### 4. Admin Interface for Sync Management +- **Custom Admin Site**: Dedicated sync management interface at `/sync-admin/` +- **Dashboard**: Real-time statistics and success rates +- **Task Monitoring**: View all sync tasks with detailed information +- **Schedule Management**: Configure automated sync schedules + +## Files Created/Modified + +### Core Sync Service +- `recruitment/candidate_sync_service.py` - Main sync service with enhanced logging +- `recruitment/tasks.py` - Django-Q async task definitions + +### Frontend Templates +- `templates/recruitment/candidate_hired_view.html` - Updated with async handling +- `templates/admin/sync_dashboard.html` - Admin dashboard for sync management + +### Admin Interface +- `recruitment/admin_sync.py` - Custom admin interface for sync management + +### URL Configuration +- `recruitment/urls.py` - Added sync status endpoint +- `NorahUniversity/urls.py` - Added sync admin site + +### Testing +- `test_sync_functionality.py` - Comprehensive test suite + +## API Endpoints + +### Sync Operations +- `POST /recruitment/jobs/{slug}/sync-hired-candidates/` - Start sync process +- `GET /recruitment/sync/task/{task_id}/status/` - Check task status + +### Admin Interface +- `/sync-admin/` - Sync management dashboard +- `/sync-admin/sync-dashboard/` - Detailed sync statistics +- `/sync-admin/api/sync-stats/` - API for sync statistics + +## Database Models + +### Django-Q Models Used +- `Task` - Stores async task information and results +- `Schedule` - Manages scheduled sync operations + +## Configuration + +### Settings Added +```python +# Django-Q Configuration +Q_CLUSTER = { + 'name': 'ats_sync', + 'workers': 4, + 'timeout': 90, + 'retry': 120, + 'queue_limit': 50, + 'bulk': 10, + 'orm': 'default', + 'save_limit': 250, + 'catch_up': False, +} + +# Logging Configuration +LOGGING = { + # ... detailed logging configuration +} +``` + +## Usage + +### Manual Sync +1. Navigate to the Hired Candidates page for a job +2. Click "Sync to Sources" button +3. Monitor progress in real-time modal +4. View detailed results upon completion + +### Admin Monitoring +1. Access `/sync-admin/` for sync management +2. View dashboard with statistics and success rates +3. Monitor individual tasks and their status +4. Configure scheduled sync operations + +### API Integration +```python +# Start sync process +response = requests.post('/recruitment/jobs/job-slug/sync-hired-candidates/') +task_id = response.json()['task_id'] + +# Check status +status = requests.get(f'/recruitment/sync/task/{task_id}/status/') +``` + +## Error Handling + +### Retry Logic +- Automatic retry for network failures (3 attempts) +- Exponential backoff between retries +- Detailed error logging for failed attempts + +### User Feedback +- Clear error messages in the frontend +- Detailed error information in admin interface +- Comprehensive logging for debugging + +## Performance Improvements + +### Async Processing +- Non-blocking sync operations +- Multiple concurrent sync workers +- Efficient task queue management + +### Caching +- Source connection caching +- Optimized database queries +- Reduced API call overhead + +## Security Considerations + +### Authentication +- Admin interface protected by Django authentication +- API endpoints require CSRF tokens +- Role-based access control + +### Data Protection +- Sensitive information masked in logs +- Secure API key handling +- Audit trail for all sync operations + +## Monitoring and Maintenance + +### Health Checks +- Source connection testing +- Task queue monitoring +- Performance metrics tracking + +### Maintenance Tasks +- Log file rotation +- Task cleanup +- Performance optimization + +## Future Enhancements + +### Planned Features +- Webhook notifications for sync completion +- Advanced scheduling options +- Performance analytics dashboard +- Integration with more external systems + +### Scalability +- Horizontal scaling support +- Load balancing for sync operations +- Database optimization for high volume + +## Troubleshooting + +### Common Issues +1. **Tasks not processing**: Check Django-Q worker status +2. **Connection failures**: Verify source configuration +3. **Slow performance**: Check database indexes and query optimization + +### Debugging Tools +- Detailed logging system +- Admin interface for task monitoring +- Test suite for validation + +## Conclusion + +The enhanced sync functionality provides a robust, scalable, and user-friendly solution for synchronizing hired candidates with external sources. The implementation follows best practices for async processing, error handling, and user experience design. + +The system is now production-ready with comprehensive monitoring, logging, and administrative tools for managing sync operations effectively. diff --git a/recruitment/__pycache__/admin.cpython-313.pyc b/recruitment/__pycache__/admin.cpython-313.pyc index 37bb795..02337a2 100644 Binary files a/recruitment/__pycache__/admin.cpython-313.pyc and b/recruitment/__pycache__/admin.cpython-313.pyc differ diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index a8ad4c6..3730ed6 100644 Binary files a/recruitment/__pycache__/forms.cpython-313.pyc and b/recruitment/__pycache__/forms.cpython-313.pyc differ diff --git a/recruitment/__pycache__/linkedin_service.cpython-313.pyc b/recruitment/__pycache__/linkedin_service.cpython-313.pyc index a50a296..46028cc 100644 Binary files a/recruitment/__pycache__/linkedin_service.cpython-313.pyc and b/recruitment/__pycache__/linkedin_service.cpython-313.pyc differ diff --git a/recruitment/__pycache__/models.cpython-313.pyc b/recruitment/__pycache__/models.cpython-313.pyc index 9c679f5..d66ad62 100644 Binary files a/recruitment/__pycache__/models.cpython-313.pyc and b/recruitment/__pycache__/models.cpython-313.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-313.pyc b/recruitment/__pycache__/signals.cpython-313.pyc index 9b33a7a..3cc942f 100644 Binary files a/recruitment/__pycache__/signals.cpython-313.pyc and b/recruitment/__pycache__/signals.cpython-313.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-313.pyc b/recruitment/__pycache__/urls.cpython-313.pyc index 097b279..bb49a18 100644 Binary files a/recruitment/__pycache__/urls.cpython-313.pyc and b/recruitment/__pycache__/urls.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index c184fca..6f9532e 100644 Binary files a/recruitment/__pycache__/views.cpython-313.pyc and b/recruitment/__pycache__/views.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views_frontend.cpython-313.pyc b/recruitment/__pycache__/views_frontend.cpython-313.pyc index c1ef67b..2fd1d55 100644 Binary files a/recruitment/__pycache__/views_frontend.cpython-313.pyc and b/recruitment/__pycache__/views_frontend.cpython-313.pyc differ diff --git a/recruitment/admin.py b/recruitment/admin.py index ff1f176..cd2c8a1 100644 --- a/recruitment/admin.py +++ b/recruitment/admin.py @@ -5,7 +5,8 @@ from django.utils import timezone from .models import ( JobPosting, Candidate, TrainingMaterial, ZoomMeeting, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, - SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment + SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment, + AgencyAccessLink, AgencyJobAssignment ) class FormFieldInline(admin.TabularInline): @@ -77,20 +78,21 @@ class IntegrationLogAdmin(admin.ModelAdmin): 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'] + search_fields = ['name', 'contact_person', 'email', 'phone', 'description'] + readonly_fields = ['slug', 'created_at', 'updated_at'] fieldsets = ( ('Basic Information', { - 'fields': ('name', 'contact_person', 'email', 'phone', 'website') + 'fields': ('name', 'slug', 'contact_person', 'email', 'phone', 'website') }), ('Location Details', { - 'fields': ('country', 'address') + 'fields': ('country', 'city', 'address') }), ('Additional Information', { - 'fields': ('notes', 'created_at', 'updated_at') + 'fields': ('description', 'created_at', 'updated_at') }), ) save_on_top = True + prepopulated_fields = {'slug': ('name',)} @admin.register(JobPosting) @@ -282,7 +284,9 @@ admin.site.register(FormField) admin.site.register(FieldResponse) admin.site.register(InterviewSchedule) admin.site.register(Profile) -# admin.site.register(HiringAgency) +admin.site.register(AgencyAccessLink) +admin.site.register(AgencyJobAssignment) +# AgencyMessage admin removed - model has been deleted admin.site.register(JobPostingImage) diff --git a/recruitment/candidate_sync_service.py b/recruitment/candidate_sync_service.py new file mode 100644 index 0000000..65a84a3 --- /dev/null +++ b/recruitment/candidate_sync_service.py @@ -0,0 +1,360 @@ +import json +import logging +import requests +from datetime import datetime +from typing import Dict, Any, List, Optional, Tuple +from django.utils import timezone +from django.conf import settings +from django.core.files.base import ContentFile +from django.http import HttpRequest +from .models import Source, Candidate, JobPosting, IntegrationLog + +logger = logging.getLogger(__name__) + + +class CandidateSyncService: + """ + Service to handle synchronization of hired candidates to external sources + """ + + def __init__(self): + self.logger = logging.getLogger(__name__) + + def sync_hired_candidates_to_all_sources(self, job: JobPosting) -> Dict[str, Any]: + """ + Sync all hired candidates for a job to all active external sources + + Returns: Dictionary with sync results for each source + """ + results = { + 'total_candidates': 0, + 'successful_syncs': 0, + 'failed_syncs': 0, + 'source_results': {}, + 'sync_time': timezone.now().isoformat() + } + + # Get all hired candidates for this job + hired_candidates = list(job.hired_candidates.select_related('job')) + + results['total_candidates'] = len(hired_candidates) + + if not hired_candidates: + self.logger.info(f"No hired candidates found for job {job.title}") + return results + + # Get all active sources that support outbound sync + active_sources = Source.objects.filter( + is_active=True, + sync_endpoint__isnull=False + ).exclude(sync_endpoint='') + + if not active_sources: + self.logger.warning("No active sources with sync endpoints configured") + return results + + # Sync to each source + for source in active_sources: + try: + source_result = self.sync_to_source(source, hired_candidates, job) + results['source_results'][source.name] = source_result + + if source_result['success']: + results['successful_syncs'] += 1 + else: + results['failed_syncs'] += 1 + + except Exception as e: + error_msg = f"Unexpected error syncing to {source.name}: {str(e)}" + self.logger.error(error_msg) + results['source_results'][source.name] = { + 'success': False, + 'error': error_msg, + 'candidates_synced': 0 + } + results['failed_syncs'] += 1 + + return results + + def sync_to_source(self, source: Source, candidates: List[Candidate], job: JobPosting) -> Dict[str, Any]: + """ + Sync candidates to a specific external source + + Returns: Dictionary with sync result for this source + """ + result = { + 'success': False, + 'error': None, + 'candidates_synced': 0, + 'candidates_failed': 0, + 'candidate_results': [] + } + + try: + # Prepare headers for the request + headers = self._prepare_headers(source) + + # Sync each candidate + for candidate in candidates: + try: + candidate_data = self._format_candidate_data(candidate, job) + sync_result = self._send_candidate_to_source(source, candidate_data, headers) + + result['candidate_results'].append({ + 'candidate_id': candidate.id, + 'candidate_name': candidate.name, + 'success': sync_result['success'], + 'error': sync_result.get('error'), + 'response_data': sync_result.get('response_data') + }) + + if sync_result['success']: + result['candidates_synced'] += 1 + else: + result['candidates_failed'] += 1 + + except Exception as e: + error_msg = f"Error syncing candidate {candidate.name}: {str(e)}" + self.logger.error(error_msg) + result['candidate_results'].append({ + 'candidate_id': candidate.id, + 'candidate_name': candidate.name, + 'success': False, + 'error': error_msg + }) + result['candidates_failed'] += 1 + + # Consider sync successful if at least one candidate was synced + result['success'] = result['candidates_synced'] > 0 + + # Log the sync operation + self._log_sync_operation(source, result, len(candidates)) + + except Exception as e: + error_msg = f"Failed to sync to source {source.name}: {str(e)}" + self.logger.error(error_msg) + result['error'] = error_msg + + return result + + def _prepare_headers(self, source: Source) -> Dict[str, str]: + """Prepare HTTP headers for the sync request""" + headers = { + 'Content-Type': 'application/json', + 'User-Agent': f'KAAUH-ATS-Sync/1.0' + } + + # Add API key if configured + if source.api_key: + headers['X-API-Key'] = source.api_key + + # Add custom headers if any + if hasattr(source, 'custom_headers') and source.custom_headers: + try: + custom_headers = json.loads(source.custom_headers) + headers.update(custom_headers) + except json.JSONDecodeError: + self.logger.warning(f"Invalid custom_headers JSON for source {source.name}") + + return headers + + def _format_candidate_data(self, candidate: Candidate, job: JobPosting) -> Dict[str, Any]: + """Format candidate data for external source""" + data = { + 'candidate': { + 'id': candidate.id, + 'slug': candidate.slug, + 'first_name': candidate.first_name, + 'last_name': candidate.last_name, + 'full_name': candidate.name, + 'email': candidate.email, + 'phone': candidate.phone, + 'address': candidate.address, + # 'applied_at': candidate.created_at.isoformat(), + # 'hired_date': candidate.offer_date.isoformat() if candidate.offer_date else None, + # 'join_date': candidate.join_date.isoformat() if candidate.join_date else None, + }, + # 'job': { + # 'id': job.id, + # 'internal_job_id': job.internal_job_id, + # 'title': job.title, + # 'department': job.department, + # 'job_type': job.job_type, + # 'workplace_type': job.workplace_type, + # 'location': job.get_location_display(), + # }, + # 'ai_analysis': { + # 'match_score': candidate.match_score, + # 'years_of_experience': candidate.years_of_experience, + # 'screening_rating': candidate.screening_stage_rating, + # 'professional_category': candidate.professional_category, + # 'top_skills': candidate.top_3_keywords, + # 'strengths': candidate.strengths, + # 'weaknesses': candidate.weaknesses, + # 'recommendation': candidate.recommendation, + # 'job_fit_narrative': candidate.job_fit_narrative, + # }, + # 'sync_metadata': { + # 'synced_at': timezone.now().isoformat(), + # 'sync_source': 'KAAUH-ATS', + # 'sync_version': '1.0' + # } + } + + # # Add resume information if available + # if candidate.resume: + # data['candidate']['resume'] = { + # 'filename': candidate.resume.name, + # 'size': candidate.resume.size, + # 'url': candidate.resume.url if hasattr(candidate.resume, 'url') else None + # } + + # # Add additional AI analysis data if available + # if candidate.ai_analysis_data: + # data['ai_analysis']['full_analysis'] = candidate.ai_analysis_data + + return data + + def _send_candidate_to_source(self, source: Source, candidate_data: Dict[str, Any], headers: Dict[str, str]) -> Dict[str, Any]: + """ + Send candidate data to external source + + Returns: Dictionary with send result + """ + result = { + 'success': False, + 'error': None, + 'response_data': None, + 'status_code': None + } + + try: + # Determine HTTP method (default to POST) + method = getattr(source, 'sync_method', 'POST').upper() + + # Prepare request data + json_data = json.dumps(candidate_data) + + # Make the HTTP request + if method == 'POST': + response = requests.post( + source.sync_endpoint, + data=json_data, + headers=headers, + timeout=30 + ) + elif method == 'PUT': + response = requests.put( + source.sync_endpoint, + data=json_data, + headers=headers, + timeout=30 + ) + else: + raise ValueError(f"Unsupported HTTP method: {method}") + + result['status_code'] = response.status_code + result['response_data'] = response.text + + # Check if request was successful + if response.status_code in [200, 201, 202]: + try: + response_json = response.json() + result['response_data'] = response_json + result['success'] = True + except json.JSONDecodeError: + # If response is not JSON, still consider it successful if status code is good + result['success'] = True + else: + result['error'] = f"HTTP {response.status_code}: {response.text}" + + except requests.exceptions.Timeout: + result['error'] = "Request timeout" + except requests.exceptions.ConnectionError: + result['error'] = "Connection error" + except requests.exceptions.RequestException as e: + result['error'] = f"Request error: {str(e)}" + except Exception as e: + result['error'] = f"Unexpected error: {str(e)}" + + return result + + def _log_sync_operation(self, source: Source, result: Dict[str, Any], total_candidates: int): + """Log the sync operation to IntegrationLog""" + try: + IntegrationLog.objects.create( + source=source, + action='SYNC', + endpoint=source.sync_endpoint, + method=getattr(source, 'sync_method', 'POST'), + request_data={ + 'total_candidates': total_candidates, + 'candidates_synced': result['candidates_synced'], + 'candidates_failed': result['candidates_failed'] + }, + response_data=result, + status_code='200' if result['success'] else '400', + error_message=result.get('error'), + ip_address='127.0.0.1', # Internal sync + user_agent='KAAUH-ATS-Sync/1.0' + ) + except Exception as e: + self.logger.error(f"Failed to log sync operation: {str(e)}") + + def test_source_connection(self, source: Source) -> Dict[str, Any]: + """ + Test connection to an external source + + Returns: Dictionary with test result + """ + result = { + 'success': False, + 'error': None, + 'response_time': None, + 'status_code': None + } + + try: + headers = self._prepare_headers(source) + test_data = { + 'test': True, + 'timestamp': timezone.now().isoformat(), + 'source': 'KAAUH-ATS Connection Test' + } + + start_time = datetime.now() + + # Use GET method for testing if available, otherwise POST + test_method = getattr(source, 'test_method', 'GET').upper() + + if test_method == 'GET': + response = requests.get( + source.sync_endpoint, + headers=headers, + timeout=10 + ) + else: + response = requests.post( + source.sync_endpoint, + data=json.dumps(test_data), + headers=headers, + timeout=10 + ) + + end_time = datetime.now() + result['response_time'] = (end_time - start_time).total_seconds() + result['status_code'] = response.status_code + + if response.status_code in [200, 201, 202]: + result['success'] = True + else: + result['error'] = f"HTTP {response.status_code}: {response.text}" + + except requests.exceptions.Timeout: + result['error'] = "Connection timeout" + except requests.exceptions.ConnectionError: + result['error'] = "Connection failed" + except Exception as e: + result['error'] = f"Test failed: {str(e)}" + + return result diff --git a/recruitment/email_service.py b/recruitment/email_service.py new file mode 100644 index 0000000..4780934 --- /dev/null +++ b/recruitment/email_service.py @@ -0,0 +1,146 @@ +""" +Email service for sending notifications related to agency messaging. +""" + +from django.core.mail import send_mail +from django.conf import settings +from django.template.loader import render_to_string +from django.utils.html import strip_tags +import logging + +logger = logging.getLogger(__name__) + + +class EmailService: + """ + Service class for handling email notifications + """ + + def send_email(self, recipient_email, subject, body, html_body=None): + """ + Send email using Django's send_mail function + + Args: + recipient_email: Email address to send to + subject: Email subject + body: Plain text email body + html_body: HTML email body (optional) + + Returns: + dict: Result with success status and error message if failed + """ + try: + send_mail( + subject=subject, + message=body, + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa'), + recipient_list=[recipient_email], + html_message=html_body, + fail_silently=False, + ) + + logger.info(f"Email sent successfully to {recipient_email}") + return {'success': True} + + except Exception as e: + error_msg = f"Failed to send email to {recipient_email}: {str(e)}" + logger.error(error_msg) + return {'success': False, 'error': error_msg} + + +def send_agency_welcome_email(agency, access_link=None): + """ + Send welcome email to a new agency with portal access information. + + Args: + agency: HiringAgency instance + access_link: AgencyAccessLink instance (optional) + + Returns: + bool: True if email was sent successfully, False otherwise + """ + try: + if not agency.email: + logger.warning(f"No email found for agency {agency.id}") + return False + + context = { + 'agency': agency, + 'access_link': access_link, + 'portal_url': getattr(settings, 'AGENCY_PORTAL_URL', 'https://kaauh.edu.sa/portal/'), + } + + # Render email templates + html_message = render_to_string('recruitment/emails/agency_welcome.html', context) + plain_message = strip_tags(html_message) + + # Send email + send_mail( + subject='Welcome to KAAUH Recruitment Portal', + message=plain_message, + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa'), + recipient_list=[agency.email], + html_message=html_message, + fail_silently=False, + ) + + logger.info(f"Welcome email sent to agency {agency.email}") + return True + + except Exception as e: + logger.error(f"Failed to send agency welcome email: {str(e)}") + return False + + +def send_assignment_notification_email(assignment, message_type='created'): + """ + Send email notification about assignment changes. + + Args: + assignment: AgencyJobAssignment instance + message_type: Type of notification ('created', 'updated', 'deadline_extended') + + Returns: + bool: True if email was sent successfully, False otherwise + """ + try: + if not assignment.agency.email: + logger.warning(f"No email found for agency {assignment.agency.id}") + return False + + context = { + 'assignment': assignment, + 'agency': assignment.agency, + 'job': assignment.job, + 'message_type': message_type, + 'portal_url': getattr(settings, 'AGENCY_PORTAL_URL', 'https://kaauh.edu.sa/portal/'), + } + + # Render email templates + html_message = render_to_string('recruitment/emails/assignment_notification.html', context) + plain_message = strip_tags(html_message) + + # Determine subject based on message type + subjects = { + 'created': f'New Job Assignment: {assignment.job.title}', + 'updated': f'Assignment Updated: {assignment.job.title}', + 'deadline_extended': f'Deadline Extended: {assignment.job.title}', + } + subject = subjects.get(message_type, f'Assignment Notification: {assignment.job.title}') + + # Send email + send_mail( + subject=subject, + message=plain_message, + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa'), + recipient_list=[assignment.agency.email], + html_message=html_message, + fail_silently=False, + ) + + logger.info(f"Assignment notification email sent to {assignment.agency.email} for {message_type}") + return True + + except Exception as e: + logger.error(f"Failed to send assignment notification email: {str(e)}") + return False diff --git a/recruitment/forms.py b/recruitment/forms.py index 81e2e3f..495db3e 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -10,13 +10,15 @@ import re from .models import ( ZoomMeeting, Candidate,TrainingMaterial,JobPosting, FormTemplate,InterviewSchedule,BreakTime,JobPostingImage, - Profile,MeetingComment,ScheduledInterview,Source,Participants + Profile,MeetingComment,ScheduledInterview,Source,HiringAgency, + AgencyJobAssignment, AgencyAccessLink ) # from django_summernote.widgets import SummernoteWidget from django_ckeditor_5.widgets import CKEditor5Widget import secrets import string from django.core.exceptions import ValidationError +from django.utils import timezone def generate_api_key(length=32): """Generate a secure API key""" @@ -170,13 +172,15 @@ class SourceForm(forms.ModelForm): class CandidateForm(forms.ModelForm): class Meta: model = Candidate - fields = ['job', 'first_name', 'last_name', 'phone', 'email', 'resume',] + fields = ['job', 'first_name', 'last_name', 'phone', 'email','hiring_source','hiring_agency', 'resume',] labels = { 'first_name': _('First Name'), 'last_name': _('Last Name'), 'phone': _('Phone'), 'email': _('Email'), 'resume': _('Resume'), + 'hiring_source': _('Hiring Type'), + 'hiring_agency': _('Hiring Agency'), } widgets = { 'first_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter first name')}), @@ -184,6 +188,8 @@ class CandidateForm(forms.ModelForm): 'phone': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter phone number')}), 'email': forms.EmailInput(attrs={'class': 'form-control', 'placeholder': _('Enter email')}), 'stage': forms.Select(attrs={'class': 'form-select'}), + 'hiring_source': forms.Select(attrs={'class': 'form-select'}), + 'hiring_agency': forms.Select(attrs={'class': 'form-select'}), } def __init__(self, *args, **kwargs): @@ -206,8 +212,11 @@ class CandidateForm(forms.ModelForm): Field('phone', css_class='form-control'), Field('email', css_class='form-control'), Field('stage', css_class='form-control'), + Field('hiring_source', css_class='form-control'), + Field('hiring_agency', css_class='form-control'), Field('resume', css_class='form-control'), Submit('submit', _('Submit'), css_class='btn btn-primary') + ) class CandidateStageForm(forms.ModelForm): @@ -647,54 +656,492 @@ class CandidateExamDateForm(forms.ModelForm): 'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}), } - - -#participants form -class ParticipantsForm(forms.ModelForm): - """Form for creating and editing Participants""" +class HiringAgencyForm(forms.ModelForm): + """Form for creating and editing hiring agencies""" class Meta: - model = Participants - fields = ['name', 'email', 'phone', 'designation'] + model = HiringAgency + fields = [ + 'name', 'contact_person', 'email', 'phone', + 'website', 'country', 'address', 'notes' + ] widgets = { 'name': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Enter participant name', - 'required': True + 'class': 'form-control', + 'placeholder': 'Enter agency name', + 'required': True + }), + 'contact_person': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter contact person name' }), 'email': forms.EmailInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Enter email address', - 'required': True + 'class': 'form-control', + 'placeholder': 'agency@example.com' }), 'phone': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Enter phone number' + 'class': 'form-control', + 'placeholder': '+966 50 123 4567' }), - 'designation': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Enter designation' + 'website': forms.URLInput(attrs={ + 'class': 'form-control', + 'placeholder': 'https://www.agency.com' }), - # 'jobs': forms.CheckboxSelectMultiple(), + 'country': forms.Select(attrs={ + 'class': 'form-select' + }), + 'address': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Enter agency address' + }), + 'notes': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Internal notes about the agency' + }), + } + labels = { + 'name': _('Agency Name'), + 'contact_person': _('Contact Person'), + 'email': _('Email Address'), + 'phone': _('Phone Number'), + 'website': _('Website'), + 'country': _('Country'), + 'address': _('Address'), + 'notes': _('Internal Notes'), } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'form-horizontal' + self.helper.label_class = 'col-md-3' + self.helper.field_class = 'col-md-9' -class ParticipantsSelectForm(forms.ModelForm): - """Form for selecting Participants""" + self.helper.layout = Layout( + Field('name', css_class='form-control'), + Field('contact_person', css_class='form-control'), + Row( + Column('email', css_class='col-md-6'), + Column('phone', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Field('website', css_class='form-control'), + Field('country', css_class='form-control'), + Field('address', css_class='form-control'), + Field('notes', css_class='form-control'), + Div( + Submit('submit', _('Save Agency'), css_class='btn btn-main-action'), + css_class='col-12 mt-4' + ) + ) + + def clean_name(self): + """Ensure agency name is unique""" + name = self.cleaned_data.get('name') + if name: + instance = self.instance + if not instance.pk: # Creating new instance + if HiringAgency.objects.filter(name=name).exists(): + raise ValidationError('An agency with this name already exists.') + else: # Editing existing instance + if HiringAgency.objects.filter(name=name).exclude(pk=instance.pk).exists(): + raise ValidationError('An agency with this name already exists.') + return name.strip() + + def clean_email(self): + """Validate email format and uniqueness""" + email = self.cleaned_data.get('email') + if email: + # Check email format + if not '@' in email or '.' not in email.split('@')[1]: + raise ValidationError('Please enter a valid email address.') + + # Check uniqueness (optional - remove if multiple agencies can have same email) + instance = self.instance + if not instance.pk: # Creating new instance + if HiringAgency.objects.filter(email=email).exists(): + raise ValidationError('An agency with this email already exists.') + else: # Editing existing instance + if HiringAgency.objects.filter(email=email).exclude(pk=instance.pk).exists(): + raise ValidationError('An agency with this email already exists.') + return email.lower().strip() if email else email + + def clean_phone(self): + """Validate phone number format""" + phone = self.cleaned_data.get('phone') + if phone: + # Remove common formatting characters + clean_phone = ''.join(c for c in phone if c.isdigit() or c in '+') + if len(clean_phone) < 10: + raise ValidationError('Phone number must be at least 10 digits long.') + return phone.strip() if phone else phone + + def clean_website(self): + """Validate website URL""" + website = self.cleaned_data.get('website') + if website: + if not website.startswith(('http://', 'https://')): + website = 'https://' + website + validator = URLValidator() + try: + validator(website) + except ValidationError: + raise ValidationError('Please enter a valid website URL.') + return website + + +class AgencyJobAssignmentForm(forms.ModelForm): + """Form for creating and editing agency job assignments""" - participants=forms.ModelMultipleChoiceField( - queryset=Participants.objects.all(), - widget=forms.CheckboxSelectMultiple, - required=False, - label=_("Select Participants")) - - users=forms.ModelMultipleChoiceField( - queryset=User.objects.all(), - widget=forms.CheckboxSelectMultiple, - required=False, - label=_("Select Users")) - class Meta: - model = JobPosting - fields = ['participants','users'] # No direct fields from Participants model - \ No newline at end of file + model = AgencyJobAssignment + fields = [ + 'agency', 'job', 'max_candidates', 'deadline_date','admin_notes' + ] + widgets = { + 'agency': forms.Select(attrs={'class': 'form-select'}), + 'job': forms.Select(attrs={'class': 'form-select'}), + 'max_candidates': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': 1, + 'placeholder': 'Maximum number of candidates' + }), + 'deadline_date': forms.DateTimeInput(attrs={ + 'class': 'form-control', + 'type': 'datetime-local' + }), + 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'status': forms.Select(attrs={'class': 'form-select'}), + 'admin_notes': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Internal notes about this assignment' + }), + } + labels = { + 'agency': _('Agency'), + 'job': _('Job Posting'), + 'max_candidates': _('Maximum Candidates'), + 'deadline_date': _('Deadline Date'), + 'is_active': _('Is Active'), + 'status': _('Status'), + 'admin_notes': _('Admin Notes'), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'form-horizontal' + self.helper.label_class = 'col-md-3' + self.helper.field_class = 'col-md-9' + + # Filter jobs to only show active jobs + self.fields['job'].queryset = JobPosting.objects.filter( + status='ACTIVE' + ).order_by('-created_at') + + self.helper.layout = Layout( + Row( + Column('agency', css_class='col-md-6'), + Column('job', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Row( + Column('max_candidates', css_class='col-md-6'), + Column('deadline_date', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Row( + Column('is_active', css_class='col-md-6'), + Column('status', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Field('admin_notes', css_class='form-control'), + Div( + Submit('submit', _('Save Assignment'), css_class='btn btn-main-action'), + css_class='col-12 mt-4' + ) + ) + + def clean_deadline_date(self): + """Validate deadline date is in the future""" + deadline_date = self.cleaned_data.get('deadline_date') + if deadline_date and deadline_date <= timezone.now(): + raise ValidationError('Deadline date must be in the future.') + return deadline_date + + def clean_max_candidates(self): + """Validate maximum candidates is positive""" + max_candidates = self.cleaned_data.get('max_candidates') + if max_candidates and max_candidates <= 0: + raise ValidationError('Maximum candidates must be greater than 0.') + return max_candidates + + def clean(self): + """Check for duplicate assignments""" + cleaned_data = super().clean() + agency = cleaned_data.get('agency') + job = cleaned_data.get('job') + + if agency and job: + # Check if this assignment already exists + existing = AgencyJobAssignment.objects.filter( + agency=agency, job=job + ).exclude(pk=self.instance.pk).first() + + if existing: + raise ValidationError( + f'This job is already assigned to {agency.name}. ' + f'Current status: {existing.get_status_display()}' + ) + + return cleaned_data + + +class AgencyAccessLinkForm(forms.ModelForm): + """Form for creating and managing agency access links""" + + class Meta: + model = AgencyAccessLink + fields = [ + 'assignment', 'expires_at', 'is_active' + ] + widgets = { + 'assignment': forms.Select(attrs={'class': 'form-select'}), + 'expires_at': forms.DateTimeInput(attrs={ + 'class': 'form-control', + 'type': 'datetime-local' + }), + 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + } + labels = { + 'assignment': _('Assignment'), + 'expires_at': _('Expires At'), + 'is_active': _('Is Active'), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'form-horizontal' + self.helper.label_class = 'col-md-3' + self.helper.field_class = 'col-md-9' + + # Filter assignments to only show active ones without existing links + self.fields['assignment'].queryset = AgencyJobAssignment.objects.filter( + is_active=True, + status='ACTIVE' + ).exclude( + access_link__isnull=False + ).order_by('-created_at') + + self.helper.layout = Layout( + Field('assignment', css_class='form-control'), + Field('expires_at', css_class='form-control'), + Field('is_active', css_class='form-check-input'), + Div( + Submit('submit', _('Create Access Link'), css_class='btn btn-main-action'), + css_class='col-12 mt-4' + ) + ) + + def clean_expires_at(self): + """Validate expiration date is in the future""" + expires_at = self.cleaned_data.get('expires_at') + if expires_at and expires_at <= timezone.now(): + raise ValidationError('Expiration date must be in the future.') + return expires_at + + +# Agency messaging forms removed - AgencyMessage model has been deleted + + +class AgencyCandidateSubmissionForm(forms.ModelForm): + """Form for agencies to submit candidates (simplified - resume + basic info)""" + + class Meta: + model = Candidate + fields = [ + 'first_name', 'last_name', 'email', 'phone', 'resume' + ] + widgets = { + 'first_name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'First Name', + 'required': True + }), + 'last_name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Last Name', + 'required': True + }), + 'email': forms.EmailInput(attrs={ + 'class': 'form-control', + 'placeholder': 'email@example.com', + 'required': True + }), + 'phone': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': '+966 50 123 4567', + 'required': True + }), + 'resume': forms.FileInput(attrs={ + 'class': 'form-control', + 'accept': '.pdf,.doc,.docx', + 'required': True + }), + } + labels = { + 'first_name': _('First Name'), + 'last_name': _('Last Name'), + 'email': _('Email Address'), + 'phone': _('Phone Number'), + 'resume': _('Resume'), + } + + def __init__(self, assignment, *args, **kwargs): + super().__init__(*args, **kwargs) + self.assignment = assignment + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'g-3' + self.helper.enctype = 'multipart/form-data' + + self.helper.layout = Layout( + Row( + Column('first_name', css_class='col-md-6'), + Column('last_name', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Row( + Column('email', css_class='col-md-6'), + Column('phone', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Field('resume', css_class='form-control'), + Div( + Submit('submit', _('Submit Candidate'), css_class='btn btn-main-action'), + css_class='col-12 mt-4' + ) + ) + + def clean_email(self): + """Validate email format and check for duplicates in the same job""" + email = self.cleaned_data.get('email') + if email: + # Check if candidate with this email already exists for this job + existing_candidate = Candidate.objects.filter( + email=email.lower().strip(), + job=self.assignment.job + ).first() + + if existing_candidate: + raise ValidationError( + f'A candidate with this email has already applied for {self.assignment.job.title}.' + ) + return email.lower().strip() if email else email + + def clean_resume(self): + """Validate resume file""" + resume = self.cleaned_data.get('resume') + if resume: + # Check file size (max 5MB) + if resume.size > 5 * 1024 * 1024: + raise ValidationError('Resume file size must be less than 5MB.') + + # Check file extension + allowed_extensions = ['.pdf', '.doc', '.docx'] + file_extension = resume.name.lower().split('.')[-1] + if f'.{file_extension}' not in allowed_extensions: + raise ValidationError( + 'Resume must be in PDF, DOC, or DOCX format.' + ) + return resume + + def save(self, commit=True): + """Override save to set additional fields""" + instance = super().save(commit=False) + + # Set required fields for agency submission + instance.job = self.assignment.job + instance.hiring_agency = self.assignment.agency + instance.stage = Candidate.Stage.APPLIED + instance.applicant_status = Candidate.ApplicantType.CANDIDATE + instance.applied = True + + if commit: + instance.save() + # Increment the assignment's submitted count + self.assignment.increment_submission_count() + return instance + + +class AgencyLoginForm(forms.Form): + """Form for agencies to login with token and password""" + + token = forms.CharField( + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter your access token' + }), + label=_('Access Token'), + required=True + ) + + password = forms.CharField( + widget=forms.PasswordInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter your password' + }), + label=_('Password'), + required=True + ) + + # def __init__(self, *args, **kwargs): + # super().__init__(*args, **kwargs) + # self.helper = FormHelper() + # self.helper.form_method = 'post' + # self.helper.form_class = 'g-3' + + # self.helper.layout = Layout( + # Field('token', css_class='form-control'), + # Field('password', css_class='form-control'), + # Div( + # Submit('submit', _('Login'), css_class='btn btn-main-action w-100'), + # css_class='col-12 mt-4' + # ) + # ) + + def clean(self): + """Validate token and password combination""" + cleaned_data = super().clean() + token = cleaned_data.get('token') + password = cleaned_data.get('password') + if token and password: + try: + access_link = AgencyAccessLink.objects.get( + unique_token=token, + is_active=True + ) + + if not access_link.is_valid: + if access_link.is_expired: + raise ValidationError('This access link has expired.') + else: + raise ValidationError('This access link is no longer active.') + + if access_link.access_password != password: + raise ValidationError('Invalid password.') + + # Store the access_link for use in the view + self.validated_access_link = access_link + except AgencyAccessLink.DoesNotExist: + print("Access link does not exist") + raise ValidationError('Invalid access token.') + + return cleaned_data diff --git a/recruitment/management/__pycache__/__init__.cpython-313.pyc b/recruitment/management/__pycache__/__init__.cpython-313.pyc index 17a0146..2374032 100644 Binary files a/recruitment/management/__pycache__/__init__.cpython-313.pyc and b/recruitment/management/__pycache__/__init__.cpython-313.pyc differ diff --git a/recruitment/management/commands/debug_agency_login.py b/recruitment/management/commands/debug_agency_login.py new file mode 100644 index 0000000..922ddf7 --- /dev/null +++ b/recruitment/management/commands/debug_agency_login.py @@ -0,0 +1,55 @@ +from django.core.management.base import BaseCommand +from recruitment.models import AgencyAccessLink, AgencyJobAssignment, HiringAgency +from django.utils import timezone + +class Command(BaseCommand): + help = 'Debug agency login issues by checking existing access links' + + def handle(self, *args, **options): + self.stdout.write("=== Agency Access Link Debug ===") + + # Check total counts + total_links = AgencyAccessLink.objects.count() + total_assignments = AgencyJobAssignment.objects.count() + total_agencies = HiringAgency.objects.count() + + self.stdout.write(f"Total Access Links: {total_links}") + self.stdout.write(f"Total Assignments: {total_assignments}") + self.stdout.write(f"Total Agencies: {total_agencies}") + self.stdout.write("") + + if total_links == 0: + self.stdout.write("โŒ NO ACCESS LINKS FOUND!") + self.stdout.write("This is likely the cause of 'Invalid token or password' error.") + self.stdout.write("") + self.stdout.write("To fix this:") + self.stdout.write("1. Create an agency first") + self.stdout.write("2. Create a job assignment for the agency") + self.stdout.write("3. Create an access link for the assignment") + return + + # Show existing links + self.stdout.write("๐Ÿ“‹ Existing Access Links:") + for link in AgencyAccessLink.objects.all(): + assignment = link.assignment + agency = assignment.agency if assignment else None + job = assignment.job if assignment else None + + self.stdout.write(f" ๐Ÿ“ Token: {link.unique_token}") + self.stdout.write(f" Password: {link.access_password}") + self.stdout.write(f" Active: {link.is_active}") + self.stdout.write(f" Expires: {link.expires_at}") + self.stdout.write(f" Agency: {agency.name if agency else 'None'}") + self.stdout.write(f" Job: {job.title if job else 'None'}") + self.stdout.write(f" Valid: {link.is_valid}") + self.stdout.write("") + + # Show assignments without links + self.stdout.write("๐Ÿ“‹ Assignments WITHOUT Access Links:") + assignments_without_links = AgencyJobAssignment.objects.filter(access_link__isnull=True) + for assignment in assignments_without_links: + self.stdout.write(f" ๐Ÿ“ {assignment.agency.name} - {assignment.job.title}") + self.stdout.write(f" Status: {assignment.status}") + self.stdout.write(f" Active: {assignment.is_active}") + self.stdout.write(f" Can Submit: {assignment.can_submit}") + self.stdout.write("") diff --git a/recruitment/management/commands/seed.py b/recruitment/management/commands/seed.py index 370eb7f..0782daa 100644 --- a/recruitment/management/commands/seed.py +++ b/recruitment/management/commands/seed.py @@ -79,7 +79,7 @@ class Command(BaseCommand): # Random dates start_date = fake.date_object() - deadline_date = start_date + timedelta(days=random.randint(14, 60)) + deadline_date = start_date + timedelta(days=random.randint(14, 60)) # Use Faker's HTML generation for CKEditor5 fields description_html = f"

{title} Role

" + "".join(f"

{fake.paragraph(nb_sentences=3, variable_nb_sentences=True)}

" for _ in range(3)) @@ -117,10 +117,10 @@ class Command(BaseCommand): first_name = fake.first_name() last_name = fake.last_name() path = os.path.join(settings.BASE_DIR,'media/resumes/') - + # path = Path('media/resumes/') # <-- CORRECT - file = random.choice(os.listdir(path)) - print(file) + file = random.choice(os.listdir(path)) + print(file) # file = os.path.abspath(file) candidate_data = { "first_name": first_name, @@ -129,7 +129,7 @@ class Command(BaseCommand): "email": f"{first_name.lower()}.{last_name.lower()}@{fake.domain_name()}", "phone": "0566987458", "address": fake.address(), - # Placeholder resume path + # Placeholder resume path "resume": 'resumes/'+ file, "job": target_job, } diff --git a/recruitment/management/commands/setup_test_agencies.py b/recruitment/management/commands/setup_test_agencies.py new file mode 100644 index 0000000..a02152a --- /dev/null +++ b/recruitment/management/commands/setup_test_agencies.py @@ -0,0 +1,122 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth.models import User +from recruitment.models import HiringAgency, AgencyJobAssignment, JobPosting +from django.utils import timezone +import random + +class Command(BaseCommand): + help = 'Set up test agencies and assignments for messaging system testing' + + def handle(self, *args, **options): + self.stdout.write('Setting up test agencies and assignments...') + + # Create test admin user if not exists + admin_user, created = User.objects.get_or_create( + username='testadmin', + defaults={ + 'email': 'admin@test.com', + 'first_name': 'Test', + 'last_name': 'Admin', + 'is_staff': True, + 'is_superuser': True, + } + ) + if created: + admin_user.set_password('admin123') + admin_user.save() + self.stdout.write(self.style.SUCCESS('Created test admin user: testadmin/admin123')) + + # Create test agencies + agencies_data = [ + { + 'name': 'Tech Talent Solutions', + 'contact_person': 'John Smith', + 'email': 'contact@techtalent.com', + 'phone': '+966501234567', + 'website': 'https://techtalent.com', + 'notes': 'Leading technology recruitment agency specializing in IT and software development roles.', + 'country': 'SA' + }, + { + 'name': 'Healthcare Recruiters Ltd', + 'contact_person': 'Sarah Johnson', + 'email': 'info@healthcarerecruiters.com', + 'phone': '+966502345678', + 'website': 'https://healthcarerecruiters.com', + 'notes': 'Specialized healthcare recruitment agency for medical professionals and healthcare staff.', + 'country': 'SA' + }, + { + 'name': 'Executive Search Partners', + 'contact_person': 'Michael Davis', + 'email': 'partners@execsearch.com', + 'phone': '+966503456789', + 'website': 'https://execsearch.com', + 'notes': 'Premium executive search firm for senior management and C-level positions.', + 'country': 'SA' + } + ] + + created_agencies = [] + for agency_data in agencies_data: + agency, created = HiringAgency.objects.get_or_create( + name=agency_data['name'], + defaults=agency_data + ) + if created: + self.stdout.write(self.style.SUCCESS(f'Created agency: {agency.name}')) + created_agencies.append(agency) + + # Get or create some sample jobs + jobs = [] + job_titles = [ + 'Senior Software Engineer', + 'Healthcare Administrator', + 'Marketing Manager', + 'Data Analyst', + 'HR Director' + ] + + for title in job_titles: + job, created = JobPosting.objects.get_or_create( + internal_job_id=f'KAAUH-2025-{len(jobs)+1:06d}', + defaults={ + 'title': title, + 'description': f'Description for {title} position', + 'qualifications': f'Requirements for {title}', + 'location_city': 'Riyadh', + 'location_country': 'Saudi Arabia', + 'job_type': 'FULL_TIME', + 'workplace_type': 'ON_SITE', + 'application_deadline': timezone.now().date() + timezone.timedelta(days=60), + 'status': 'ACTIVE', + 'created_by': admin_user.username + } + ) + if created: + self.stdout.write(self.style.SUCCESS(f'Created job: {job.title}')) + jobs.append(job) + + # Create agency assignments + for i, agency in enumerate(created_agencies): + for j, job in enumerate(jobs[:2]): # Assign 2 jobs per agency + assignment, created = AgencyJobAssignment.objects.get_or_create( + agency=agency, + job=job, + defaults={ + 'max_candidates': 5, + 'deadline_date': timezone.now() + timezone.timedelta(days=30), + 'status': 'ACTIVE', + 'is_active': True + } + ) + if created: + self.stdout.write(self.style.SUCCESS( + f'Created assignment: {agency.name} -> {job.title}' + )) + + self.stdout.write(self.style.SUCCESS('Test agencies and assignments setup complete!')) + self.stdout.write('\nSummary:') + self.stdout.write(f'- Agencies: {HiringAgency.objects.count()}') + self.stdout.write(f'- Jobs: {JobPosting.objects.count()}') + self.stdout.write(f'- Assignments: {AgencyJobAssignment.objects.count()}') diff --git a/recruitment/management/commands/verify_notifications.py b/recruitment/management/commands/verify_notifications.py new file mode 100644 index 0000000..ed1c6f7 --- /dev/null +++ b/recruitment/management/commands/verify_notifications.py @@ -0,0 +1,112 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth.models import User +from recruitment.models import Notification, HiringAgency +import datetime + + +class Command(BaseCommand): + help = 'Verify the notification system is working correctly' + + def add_arguments(self, parser): + parser.add_argument( + '--detailed', + action='store_true', + help='Show detailed breakdown of notifications', + ) + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS('๐Ÿ” Verifying Notification System')) + self.stdout.write('=' * 50) + + # Check notification counts + total_notifications = Notification.objects.count() + pending_notifications = Notification.objects.filter(status='PENDING').count() + sent_notifications = Notification.objects.filter(status='SENT').count() + failed_notifications = Notification.objects.filter(status='FAILED').count() + + self.stdout.write(f'\n๐Ÿ“Š Notification Counts:') + self.stdout.write(f' Total Notifications: {total_notifications}') + self.stdout.write(f' Pending: {pending_notifications}') + self.stdout.write(f' Sent: {sent_notifications}') + self.stdout.write(f' Failed: {failed_notifications}') + + # Agency messaging system has been removed - replaced by Notification system + self.stdout.write(f'\n๐Ÿ’ฌ Message System:') + self.stdout.write(f' Agency messaging system has been replaced by Notification system') + + # Check admin user notifications + admin_users = User.objects.filter(is_staff=True) + self.stdout.write(f'\n๐Ÿ‘ค Admin Users ({admin_users.count()}):') + + for admin in admin_users: + admin_notifications = Notification.objects.filter(recipient=admin).count() + admin_unread = Notification.objects.filter(recipient=admin, status='PENDING').count() + self.stdout.write(f' {admin.username}: {admin_notifications} notifications ({admin_unread} unread)') + + # Check agency notifications + # Note: Current Notification model only supports User recipients, not agencies + # Agency messaging system has been removed + agencies = HiringAgency.objects.all() + self.stdout.write(f'\n๐Ÿข Agencies ({agencies.count()}):') + + for agency in agencies: + self.stdout.write(f' {agency.name}: Agency messaging system has been removed') + + # Check notification types + if options['detailed']: + self.stdout.write(f'\n๐Ÿ“‹ Detailed Notification Breakdown:') + + # By type + for notification_type in ['email', 'in_app']: + count = Notification.objects.filter(notification_type=notification_type).count() + if count > 0: + self.stdout.write(f' {notification_type}: {count}') + + # By status + for status in ['pending', 'sent', 'read', 'failed', 'retrying']: + count = Notification.objects.filter(status=status).count() + if count > 0: + self.stdout.write(f' {status}: {count}') + + # System health check + self.stdout.write(f'\n๐Ÿฅ System Health Check:') + + issues = [] + + # Check for failed notifications + if failed_notifications > 0: + issues.append(f'{failed_notifications} failed notifications') + + # Check for admin users without notifications + admin_with_no_notifications = admin_users.filter( + notifications__isnull=True + ).count() + if admin_with_no_notifications > 0 and total_notifications > 0: + issues.append(f'{admin_with_no_notifications} admin users with no notifications') + + if issues: + self.stdout.write(self.style.WARNING(' โš ๏ธ Issues found:')) + for issue in issues: + self.stdout.write(f' - {issue}') + else: + self.stdout.write(self.style.SUCCESS(' โœ… No issues detected')) + + # Recent activity + recent_notifications = Notification.objects.filter( + created_at__gte=datetime.datetime.now() - datetime.timedelta(hours=24) + ).count() + + self.stdout.write(f'\n๐Ÿ• Recent Activity (last 24 hours):') + self.stdout.write(f' New notifications: {recent_notifications}') + + # Summary + self.stdout.write(f'\n๐Ÿ“‹ Summary:') + if total_notifications > 0 and failed_notifications == 0: + self.stdout.write(self.style.SUCCESS(' โœ… Notification system is working correctly')) + elif failed_notifications > 0: + self.stdout.write(self.style.WARNING(' โš ๏ธ Notification system has some failures')) + else: + self.stdout.write(self.style.WARNING(' โš ๏ธ No notifications found - system may not be active')) + + self.stdout.write('\n' + '=' * 50) + self.stdout.write(self.style.SUCCESS('โœจ Verification complete!')) diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py index 48af4fe..45877f8 100644 --- a/recruitment/migrations/0001_initial.py +++ b/recruitment/migrations/0001_initial.py @@ -238,7 +238,6 @@ class Migration(migrations.Migration): migrations.CreateModel( name='JobPosting', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), @@ -256,7 +255,7 @@ class Migration(migrations.Migration): ('application_url', models.URLField(blank=True, help_text='URL where candidates apply', null=True, validators=[django.core.validators.URLValidator()])), ('application_deadline', models.DateField(db_index=True)), ('application_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)), - ('internal_job_id', models.CharField(editable=False, max_length=50)), + ('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)), ('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)), ('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20)), ('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])), @@ -415,6 +414,27 @@ class Migration(migrations.Migration): ('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')), ], ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', models.TextField(verbose_name='Notification Message')), + ('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')), + ('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')), + ('last_error', models.TextField(blank=True, verbose_name='Last Error Message')), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')), + ('related_meeting', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.zoommeeting', verbose_name='Related Meeting')), + ], + options={ + 'verbose_name': 'Notification', + 'verbose_name_plural': 'Notifications', + 'ordering': ['-scheduled_for', '-created_at'], + }, + ), migrations.CreateModel( name='MeetingComment', fields=[ @@ -502,4 +522,12 @@ class Migration(migrations.Migration): model_name='scheduledinterview', index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'), ), + migrations.AddIndex( + model_name='notification', + index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'), + ), + migrations.AddIndex( + model_name='notification', + index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'), + ), ] diff --git a/recruitment/migrations/0003_candidate_hired_date_source_custom_headers_and_more.py b/recruitment/migrations/0003_candidate_hired_date_source_custom_headers_and_more.py new file mode 100644 index 0000000..7c999ad --- /dev/null +++ b/recruitment/migrations/0003_candidate_hired_date_source_custom_headers_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.4 on 2025-10-26 13:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0002_candidate_retry'), + ] + + operations = [ + migrations.AddField( + model_name='candidate', + name='hired_date', + field=models.DateField(blank=True, null=True, verbose_name='Hired Date'), + ), + migrations.AddField( + model_name='source', + name='custom_headers', + field=models.TextField(blank=True, help_text='JSON object with custom HTTP headers for sync requests', null=True, verbose_name='Custom Headers'), + ), + migrations.AddField( + model_name='source', + name='supports_outbound_sync', + field=models.BooleanField(default=False, help_text='Whether this source supports receiving candidate data from ATS', verbose_name='Supports Outbound Sync'), + ), + migrations.AddField( + model_name='source', + name='sync_endpoint', + field=models.URLField(blank=True, help_text='Endpoint URL for sending candidate data (for outbound sync)', null=True, verbose_name='Sync Endpoint'), + ), + migrations.AddField( + model_name='source', + name='sync_method', + field=models.CharField(blank=True, choices=[('POST', 'POST'), ('PUT', 'PUT')], default='POST', help_text='HTTP method for outbound sync requests', max_length=10, verbose_name='Sync Method'), + ), + migrations.AddField( + model_name='source', + name='test_method', + field=models.CharField(blank=True, choices=[('GET', 'GET'), ('POST', 'POST')], default='GET', help_text='HTTP method for connection testing', max_length=10, verbose_name='Test Method'), + ), + migrations.AlterField( + model_name='candidate', + name='stage', + field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired')], db_index=True, default='Applied', max_length=100, verbose_name='Stage'), + ), + ] diff --git a/recruitment/migrations/0004_alter_integrationlog_method.py b/recruitment/migrations/0004_alter_integrationlog_method.py new file mode 100644 index 0000000..e4ab1d0 --- /dev/null +++ b/recruitment/migrations/0004_alter_integrationlog_method.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-10-26 13:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0003_candidate_hired_date_source_custom_headers_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='integrationlog', + name='method', + field=models.CharField(blank=True, max_length=50, verbose_name='HTTP Method'), + ), + ] diff --git a/recruitment/migrations/0005_rename_submitted_by_agency_candidate_hiring_agency.py b/recruitment/migrations/0005_rename_submitted_by_agency_candidate_hiring_agency.py new file mode 100644 index 0000000..48ea4b0 --- /dev/null +++ b/recruitment/migrations/0005_rename_submitted_by_agency_candidate_hiring_agency.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-10-26 14:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0004_alter_integrationlog_method'), + ] + + operations = [ + migrations.RenameField( + model_name='candidate', + old_name='submitted_by_agency', + new_name='hiring_agency', + ), + ] diff --git a/recruitment/migrations/0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more.py b/recruitment/migrations/0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more.py new file mode 100644 index 0000000..8c1c20c --- /dev/null +++ b/recruitment/migrations/0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more.py @@ -0,0 +1,129 @@ +# Generated by Django 5.2.6 on 2025-10-26 14:51 + +import django.db.models.deletion +import django_extensions.db.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0005_rename_submitted_by_agency_candidate_hiring_agency'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AgencyJobAssignment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('max_candidates', models.PositiveIntegerField(help_text='Maximum candidates agency can submit for this job', verbose_name='Maximum Candidates')), + ('candidates_submitted', models.PositiveIntegerField(default=0, help_text='Number of candidates submitted so far', verbose_name='Candidates Submitted')), + ('assigned_date', models.DateTimeField(auto_now_add=True, verbose_name='Assigned Date')), + ('deadline_date', models.DateTimeField(help_text='Deadline for agency to submit candidates', verbose_name='Deadline Date')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('status', models.CharField(choices=[('ACTIVE', 'Active'), ('COMPLETED', 'Completed'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='ACTIVE', max_length=20, verbose_name='Status')), + ('deadline_extended', models.BooleanField(default=False, verbose_name='Deadline Extended')), + ('original_deadline', models.DateTimeField(blank=True, help_text='Original deadline before extensions', null=True, verbose_name='Original Deadline')), + ('admin_notes', models.TextField(blank=True, help_text='Internal notes about this assignment', verbose_name='Admin Notes')), + ('agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_assignments', to='recruitment.hiringagency', verbose_name='Agency')), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agency_assignments', to='recruitment.jobposting', verbose_name='Job')), + ], + options={ + 'verbose_name': 'Agency Job Assignment', + 'verbose_name_plural': 'Agency Job Assignments', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='AgencyAccessLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('unique_token', models.CharField(editable=False, max_length=64, unique=True, verbose_name='Unique Token')), + ('access_password', models.CharField(help_text='Password for agency access', max_length=32, verbose_name='Access Password')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('expires_at', models.DateTimeField(help_text='When this access link expires', verbose_name='Expires At')), + ('last_accessed', models.DateTimeField(blank=True, null=True, verbose_name='Last Accessed')), + ('access_count', models.PositiveIntegerField(default=0, verbose_name='Access Count')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('assignment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='access_link', to='recruitment.agencyjobassignment', verbose_name='Assignment')), + ], + options={ + 'verbose_name': 'Agency Access Link', + 'verbose_name_plural': 'Agency Access Links', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='AgencyMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('subject', models.CharField(max_length=200, verbose_name='Subject')), + ('message', models.TextField(verbose_name='Message')), + ('message_type', models.CharField(choices=[('INFO', 'Information'), ('WARNING', 'Warning'), ('EXTENSION', 'Deadline Extension'), ('GENERAL', 'General')], default='GENERAL', max_length=20, verbose_name='Message Type')), + ('is_read', models.BooleanField(default=False, verbose_name='Is Read')), + ('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')), + ('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.agencyjobassignment', verbose_name='Assignment')), + ('recipient_agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to='recruitment.hiringagency', verbose_name='Recipient Agency')), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')), + ], + options={ + 'verbose_name': 'Agency Message', + 'verbose_name_plural': 'Agency Messages', + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'), + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['job', 'status'], name='recruitment_job_id_d798a8_idx'), + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['deadline_date'], name='recruitment_deadlin_57d3b4_idx'), + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['is_active'], name='recruitment_is_acti_93b919_idx'), + ), + migrations.AlterUniqueTogether( + name='agencyjobassignment', + unique_together={('agency', 'job')}, + ), + migrations.AddIndex( + model_name='agencyaccesslink', + index=models.Index(fields=['unique_token'], name='recruitment_unique__f91e76_idx'), + ), + migrations.AddIndex( + model_name='agencyaccesslink', + index=models.Index(fields=['expires_at'], name='recruitment_expires_954ed9_idx'), + ), + migrations.AddIndex( + model_name='agencyaccesslink', + index=models.Index(fields=['is_active'], name='recruitment_is_acti_4b0804_idx'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['assignment', 'is_read'], name='recruitment_assignm_4f518d_idx'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['recipient_agency', 'is_read'], name='recruitment_recipie_427b10_idx'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['sender'], name='recruitment_sender__97dd96_idx'), + ), + ] diff --git a/recruitment/migrations/0007_candidate_source_candidate_source_type.py b/recruitment/migrations/0007_candidate_source_candidate_source_type.py new file mode 100644 index 0000000..5f83b42 --- /dev/null +++ b/recruitment/migrations/0007_candidate_source_candidate_source_type.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.6 on 2025-10-27 11:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='candidate', + name='source', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.source', verbose_name='Source'), + ), + migrations.AddField( + model_name='candidate', + name='source_type', + field=models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Source'), + ), + ] diff --git a/recruitment/migrations/0008_remove_candidate_source_remove_candidate_source_type_and_more.py b/recruitment/migrations/0008_remove_candidate_source_remove_candidate_source_type_and_more.py new file mode 100644 index 0000000..591252c --- /dev/null +++ b/recruitment/migrations/0008_remove_candidate_source_remove_candidate_source_type_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.6 on 2025-10-27 11:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0007_candidate_source_candidate_source_type'), + ] + + operations = [ + migrations.RemoveField( + model_name='candidate', + name='source', + ), + migrations.RemoveField( + model_name='candidate', + name='source_type', + ), + migrations.AddField( + model_name='candidate', + name='hiring_source', + field=models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source'), + ), + migrations.AlterField( + model_name='candidate', + name='hiring_agency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.hiringagency', verbose_name='Hiring Agency'), + ), + ] diff --git a/recruitment/migrations/0009_agencymessage_priority_agencymessage_recipient_user_and_more.py b/recruitment/migrations/0009_agencymessage_priority_agencymessage_recipient_user_and_more.py new file mode 100644 index 0000000..5b27532 --- /dev/null +++ b/recruitment/migrations/0009_agencymessage_priority_agencymessage_recipient_user_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.6 on 2025-10-27 20:26 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0008_remove_candidate_source_remove_candidate_source_type_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='agencymessage', + name='priority', + field=models.CharField(choices=[('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High'), ('URGENT', 'Urgent')], default='MEDIUM', max_length=10, verbose_name='Priority'), + ), + migrations.AddField( + model_name='agencymessage', + name='recipient_user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient User'), + ), + migrations.AddField( + model_name='agencymessage', + name='send_email', + field=models.BooleanField(default=False, verbose_name='Send Email Notification'), + ), + migrations.AddField( + model_name='agencymessage', + name='send_sms', + field=models.BooleanField(default=False, verbose_name='Send SMS Notification'), + ), + migrations.AddField( + model_name='agencymessage', + name='sender_agency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to='recruitment.hiringagency', verbose_name='Sender Agency'), + ), + migrations.AddField( + model_name='agencymessage', + name='sender_type', + field=models.CharField(choices=[('ADMIN', 'Admin'), ('AGENCY', 'Agency')], default='ADMIN', max_length=10, verbose_name='Sender Type'), + ), + migrations.AlterField( + model_name='agencymessage', + name='sender', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['sender_type', 'created_at'], name='recruitment_sender__14b136_idx'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['priority', 'created_at'], name='recruitment_priorit_80d9f1_idx'), + ), + ] diff --git a/recruitment/migrations/0010_remove_agency_message_model.py b/recruitment/migrations/0010_remove_agency_message_model.py new file mode 100644 index 0000000..f042dcf --- /dev/null +++ b/recruitment/migrations/0010_remove_agency_message_model.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.6 on 2025-10-29 10:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0009_agencymessage_priority_agencymessage_recipient_user_and_more'), + ] + + operations = [ + migrations.DeleteModel( + name='AgencyMessage', + ), + ] diff --git a/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc b/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc index 138c9d1..8d49e50 100644 Binary files a/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc and b/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/recruitment/models.py b/recruitment/models.py index 7d7f08a..1bfb364 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -108,7 +108,8 @@ class JobPosting(Base): ) # Internal Tracking - internal_job_id = models.CharField(max_length=50, editable=False) + internal_job_id = models.CharField(max_length=50, primary_key=True, editable=False) + created_by = models.CharField( max_length=100, blank=True, help_text="Name of person who created this job" ) @@ -210,29 +211,29 @@ class JobPosting(Base): return self.source.name if self.source else "System" def save(self, *args, **kwargs): - # from django.db import transaction + from django.db import transaction # Generate unique internal job ID if not exists - # with transaction.atomic(): - # if not self.internal_job_id: - # prefix = "KAAUH" - # year = timezone.now().year - # # Get next sequential number - # last_job = ( - # JobPosting.objects.select_for_update().filter( - # internal_job_id__startswith=f"{prefix}-{year}-" - # ) - # .order_by("internal_job_id") - # .last() - # ) + with transaction.atomic(): + if not self.internal_job_id: + prefix = "KAAUH" + year = timezone.now().year + # Get next sequential number + last_job = ( + JobPosting.objects.select_for_update().filter( + internal_job_id__startswith=f"{prefix}-{year}-" + ) + .order_by("internal_job_id") + .last() + ) - # if last_job: - # last_num = int(last_job.internal_job_id.split("-")[-1]) - # next_num = last_num + 1 - # else: - # next_num = 1 + if last_job: + last_num = int(last_job.internal_job_id.split("-")[-1]) + next_num = last_num + 1 + else: + next_num = 1 - # self.internal_job_id = f"{prefix}-{year}-{next_num:06d}" + self.internal_job_id = f"{prefix}-{year}-{next_num:06d}" super().save(*args, **kwargs) @@ -332,6 +333,12 @@ class JobPosting(Base): @property def offer_candidates(self): return self.all_candidates.filter(stage="Offer") + @property + def accepted_candidates(self): + return self.all_candidates.filter(offer_status="Accepted") + @property + def hired_candidates(self): + return self.all_candidates.filter(stage="Hired") # counts @property @@ -382,6 +389,7 @@ class Candidate(Base): EXAM = "Exam", _("Exam") INTERVIEW = "Interview", _("Interview") OFFER = "Offer", _("Offer") + HIRED = "Hired", _("Hired") class ExamStatus(models.TextChoices): PASSED = "Passed", _("Passed") @@ -463,28 +471,31 @@ class Candidate(Base): blank=True, verbose_name=_("Offer Status"), ) + hired_date = models.DateField(null=True, blank=True, verbose_name=_("Hired Date")) join_date = models.DateField(null=True, blank=True, verbose_name=_("Join Date")) ai_analysis_data = models.JSONField( verbose_name="AI Analysis Data", default=dict, help_text="Full JSON output from the resume scoring model." - ) - # Scoring fields (populated by signal) - # match_score = models.IntegerField(db_index=True, null=True, blank=True) # Added index - # strengths = models.TextField(blank=True) - # weaknesses = models.TextField(blank=True) - # criteria_checklist = models.JSONField(default=dict, blank=True) - # major_category_name = models.TextField(db_index=True, blank=True, verbose_name=_("Major Category Name")) # Added index - # recommendation = models.TextField(blank=True, verbose_name=_("Recommendation")) - submitted_by_agency = models.ForeignKey( + )# {'resume_data': {}, 'analysis_data': {}} + + retry = models.SmallIntegerField(verbose_name="Resume Parsing Retry",default=3) + hiring_source = models.CharField( + max_length=255, + null=True, + blank=True, + verbose_name=_("Hiring Source"), + choices=[("Public", "Public"), ("Internal", "Internal"), ("Agency", "Agency")], + default="Public", + ) + hiring_agency = models.ForeignKey( "HiringAgency", on_delete=models.SET_NULL, null=True, blank=True, - related_name="submitted_candidates", - verbose_name=_("Submitted by Agency"), + related_name="candidates", + verbose_name=_("Hiring Agency"), ) - retry = models.SmallIntegerField(verbose_name="Resume Parsing Retry",default=3) class Meta: verbose_name = _("Candidate") @@ -1167,6 +1178,47 @@ class Source(Base): verbose_name=_("Sync Status"), ) + # Outbound sync configuration + sync_endpoint = models.URLField( + blank=True, + null=True, + verbose_name=_("Sync Endpoint"), + help_text=_("Endpoint URL for sending candidate data (for outbound sync)"), + ) + sync_method = models.CharField( + max_length=10, + blank=True, + choices=[ + ("POST", "POST"), + ("PUT", "PUT"), + ], + default="POST", + verbose_name=_("Sync Method"), + help_text=_("HTTP method for outbound sync requests"), + ) + test_method = models.CharField( + max_length=10, + blank=True, + choices=[ + ("GET", "GET"), + ("POST", "POST"), + ], + default="GET", + verbose_name=_("Test Method"), + help_text=_("HTTP method for connection testing"), + ) + custom_headers = models.TextField( + blank=True, + null=True, + verbose_name=_("Custom Headers"), + help_text=_("JSON object with custom HTTP headers for sync requests"), + ) + supports_outbound_sync = models.BooleanField( + default=False, + verbose_name=_("Supports Outbound Sync"), + help_text=_("Whether this source supports receiving candidate data from ATS"), + ) + def __str__(self): return self.name @@ -1199,7 +1251,7 @@ class IntegrationLog(Base): 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")) + method = models.CharField(max_length=50, blank=True, verbose_name=_("HTTP Method")) request_data = models.JSONField( blank=True, null=True, verbose_name=_("Request Data") ) @@ -1259,6 +1311,275 @@ class HiringAgency(Base): ordering = ["name"] +class AgencyJobAssignment(Base): + """Assigns specific jobs to agencies with limits and deadlines""" + + class AssignmentStatus(models.TextChoices): + ACTIVE = "ACTIVE", _("Active") + COMPLETED = "COMPLETED", _("Completed") + EXPIRED = "EXPIRED", _("Expired") + CANCELLED = "CANCELLED", _("Cancelled") + + agency = models.ForeignKey( + HiringAgency, + on_delete=models.CASCADE, + related_name="job_assignments", + verbose_name=_("Agency") + ) + job = models.ForeignKey( + JobPosting, + on_delete=models.CASCADE, + related_name="agency_assignments", + verbose_name=_("Job") + ) + + # Limits & Controls + max_candidates = models.PositiveIntegerField( + verbose_name=_("Maximum Candidates"), + help_text=_("Maximum candidates agency can submit for this job") + ) + candidates_submitted = models.PositiveIntegerField( + default=0, + verbose_name=_("Candidates Submitted"), + help_text=_("Number of candidates submitted so far") + ) + + # Timeline + assigned_date = models.DateTimeField(auto_now_add=True, verbose_name=_("Assigned Date")) + deadline_date = models.DateTimeField( + verbose_name=_("Deadline Date"), + help_text=_("Deadline for agency to submit candidates") + ) + + # Status & Extensions + is_active = models.BooleanField(default=True, verbose_name=_("Is Active")) + status = models.CharField( + max_length=20, + choices=AssignmentStatus.choices, + default=AssignmentStatus.ACTIVE, + verbose_name=_("Status") + ) + + # Extension tracking + deadline_extended = models.BooleanField( + default=False, + verbose_name=_("Deadline Extended") + ) + original_deadline = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Original Deadline"), + help_text=_("Original deadline before extensions") + ) + + # Admin notes + admin_notes = models.TextField( + blank=True, + verbose_name=_("Admin Notes"), + help_text=_("Internal notes about this assignment") + ) + + class Meta: + verbose_name = _("Agency Job Assignment") + verbose_name_plural = _("Agency Job Assignments") + ordering = ["-created_at"] + indexes = [ + models.Index(fields=['agency', 'status']), + models.Index(fields=['job', 'status']), + models.Index(fields=['deadline_date']), + models.Index(fields=['is_active']), + ] + unique_together = ['agency', 'job'] # Prevent duplicate assignments + + def __str__(self): + return f"{self.agency.name} - {self.job.title}" + + @property + def days_remaining(self): + """Calculate days remaining until deadline""" + if not self.deadline_date: + return 0 + delta = self.deadline_date.date() - timezone.now().date() + return max(0, delta.days) + + @property + def is_currently_active(self): + """Check if assignment is currently active""" + return ( + self.status == 'ACTIVE' and + self.deadline_date and + self.deadline_date > timezone.now() and + self.candidates_submitted < self.max_candidates + ) + + @property + def can_submit(self): + """Check if candidates can still be submitted""" + return self.is_currently_active + + def clean(self): + """Validate assignment constraints""" + if self.deadline_date and self.deadline_date <= timezone.now(): + raise ValidationError(_("Deadline date must be in the future")) + + if self.max_candidates <= 0: + raise ValidationError(_("Maximum candidates must be greater than 0")) + + if self.candidates_submitted > self.max_candidates: + raise ValidationError(_("Candidates submitted cannot exceed maximum candidates")) + + @property + def remaining_slots(self): + """Return number of remaining candidate slots""" + return max(0, self.max_candidates - self.candidates_submitted) + + @property + def is_expired(self): + """Check if assignment has expired""" + return self.deadline_date and self.deadline_date <= timezone.now() + + @property + def is_full(self): + """Check if assignment has reached maximum candidates""" + return self.candidates_submitted >= self.max_candidates + + @property + def can_submit(self): + """Check if agency can still submit candidates""" + return (self.is_active and + not self.is_expired and + not self.is_full and + self.status == self.AssignmentStatus.ACTIVE) + + def increment_submission_count(self): + """Safely increment the submitted candidates count""" + if self.can_submit: + self.candidates_submitted += 1 + self.save(update_fields=['candidates_submitted']) + + # Check if assignment is now complete + # if self.candidates_submitted >= self.max_candidates: + # self.status = self.AssignmentStatus.COMPLETED + # self.save(update_fields=['status']) + return True + return False + + def extend_deadline(self, new_deadline): + """Extend the deadline for this assignment""" + # Convert database deadline to timezone-aware for comparison + deadline_aware = timezone.make_aware(self.deadline_date) if timezone.is_naive(self.deadline_date) else self.deadline_date + if new_deadline > deadline_aware: + if not self.deadline_extended: + self.original_deadline = self.deadline_date + self.deadline_extended = True + self.deadline_date = new_deadline + self.save(update_fields=['deadline_date', 'original_deadline', 'deadline_extended']) + return True + return False + + +class AgencyAccessLink(Base): + """Secure access links for agencies to submit candidates""" + + assignment = models.OneToOneField( + AgencyJobAssignment, + on_delete=models.CASCADE, + related_name="access_link", + verbose_name=_("Assignment") + ) + + # Security + unique_token = models.CharField( + max_length=64, + unique=True, + editable=False, + verbose_name=_("Unique Token") + ) + access_password = models.CharField( + max_length=32, + verbose_name=_("Access Password"), + help_text=_("Password for agency access") + ) + + # Timeline + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) + expires_at = models.DateTimeField( + verbose_name=_("Expires At"), + help_text=_("When this access link expires") + ) + last_accessed = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Last Accessed") + ) + + # Usage tracking + access_count = models.PositiveIntegerField( + default=0, + verbose_name=_("Access Count") + ) + is_active = models.BooleanField( + default=True, + verbose_name=_("Is Active") + ) + + class Meta: + verbose_name = _("Agency Access Link") + verbose_name_plural = _("Agency Access Links") + ordering = ["-created_at"] + indexes = [ + models.Index(fields=['unique_token']), + models.Index(fields=['expires_at']), + models.Index(fields=['is_active']), + ] + + def __str__(self): + return f"Access Link for {self.assignment}" + + def clean(self): + """Validate access link constraints""" + if self.expires_at and self.expires_at <= timezone.now(): + raise ValidationError(_("Expiration date must be in the future")) + + @property + def is_expired(self): + """Check if access link has expired""" + return self.expires_at and self.expires_at <= timezone.now() + + @property + def is_valid(self): + """Check if access link is valid and active""" + return self.is_active and not self.is_expired + + def record_access(self): + """Record an access to this link""" + self.last_accessed = timezone.now() + self.access_count += 1 + self.save(update_fields=['last_accessed', 'access_count']) + + def generate_token(self): + """Generate a unique secure token""" + import secrets + self.unique_token = secrets.token_urlsafe(48) + + def generate_password(self): + """Generate a random password""" + import secrets + import string + alphabet = string.ascii_letters + string.digits + self.access_password = ''.join(secrets.choice(alphabet) for _ in range(12)) + + def save(self, *args, **kwargs): + """Override save to generate token and password if not set""" + if not self.unique_token: + self.generate_token() + if not self.access_password: + self.generate_password() + super().save(*args, **kwargs) + + + + class BreakTime(models.Model): """Model to store break times for a schedule""" @@ -1353,6 +1674,80 @@ class ScheduledInterview(Base): models.Index(fields=['candidate', 'job']), ] +class Notification(models.Model): + """ + Model to store system notifications, primarily for emails. + """ + class NotificationType(models.TextChoices): + EMAIL = "email", _("Email") + IN_APP = "in_app", _("In-App") # For future expansion + + class Status(models.TextChoices): + PENDING = "pending", _("Pending") + SENT = "sent", _("Sent") + READ = "read", _("Read") + FAILED = "failed", _("Failed") + RETRYING = "retrying", _("Retrying") + + recipient = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="notifications", + verbose_name=_("Recipient") + ) + message = models.TextField(verbose_name=_("Notification Message")) + notification_type = models.CharField( + max_length=20, + choices=NotificationType.choices, + default=NotificationType.EMAIL, + verbose_name=_("Notification Type") + ) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.PENDING, + verbose_name=_("Status") + ) + related_meeting = models.ForeignKey( + ZoomMeeting, + on_delete=models.CASCADE, + related_name="notifications", + null=True, + blank=True, + verbose_name=_("Related Meeting") + ) + scheduled_for = models.DateTimeField( + verbose_name=_("Scheduled Send Time"), + help_text=_("The date and time this notification is scheduled to be sent.") + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + attempts = models.PositiveIntegerField(default=0, verbose_name=_("Send Attempts")) + last_error = models.TextField(blank=True, verbose_name=_("Last Error Message")) + + class Meta: + ordering = ["-scheduled_for", "-created_at"] + verbose_name = _("Notification") + verbose_name_plural = _("Notifications") + indexes = [ + models.Index(fields=['status', 'scheduled_for']), + models.Index(fields=['recipient']), + ] + + def __str__(self): + return f"Notification for {self.recipient.get_username()} ({self.get_status_display()})" + + def mark_as_sent(self): + self.status = Notification.Status.SENT + self.last_error = "" + self.save(update_fields=['status', 'last_error']) + + def mark_as_failed(self, error_message=""): + self.status = Notification.Status.FAILED + self.last_error = error_message + self.attempts += 1 + self.save(update_fields=['status', 'last_error', 'attempts']) + class Participants(Base): diff --git a/recruitment/signals.py b/recruitment/signals.py index 590aed2..0f08794 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -6,7 +6,9 @@ from django_q.tasks import schedule from django.dispatch import receiver from django_q.tasks import async_task from django.db.models.signals import post_save -from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting +from django.contrib.auth.models import User +from django.utils import timezone +from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting,Notification logger = logging.getLogger(__name__) @@ -58,7 +60,7 @@ def format_job(sender, instance, created, **kwargs): def score_candidate_resume(sender, instance, created, **kwargs): if not instance.is_resume_parsed: logger.info(f"Scoring resume for candidate {instance.pk}") - async_task( + async_task( 'recruitment.tasks.handle_reume_parsing_and_scoring', instance.pk, hook='recruitment.hooks.callback_ai_parsing' @@ -361,3 +363,37 @@ def create_default_stages(sender, instance, created, **kwargs): # order=3, # is_predefined=True # ) + + +# AgencyMessage signal handler removed - model has been deleted + +# SSE notification cache for real-time updates +SSE_NOTIFICATION_CACHE = {} + +@receiver(post_save, sender=Notification) +def notification_created(sender, instance, created, **kwargs): + """Signal handler for when a notification is created""" + if created: + logger.info(f"New notification created: {instance.id} for user {instance.recipient.username}") + + # Store notification in cache for SSE + user_id = instance.recipient.id + if user_id not in SSE_NOTIFICATION_CACHE: + SSE_NOTIFICATION_CACHE[user_id] = [] + + notification_data = { + 'id': instance.id, + 'message': instance.message[:100] + ('...' if len(instance.message) > 100 else ''), + 'type': instance.get_notification_type_display(), + 'status': instance.get_status_display(), + 'time_ago': 'Just now', + 'url': f"/notifications/{instance.id}/" + } + + SSE_NOTIFICATION_CACHE[user_id].append(notification_data) + + # Keep only last 50 notifications per user in cache + if len(SSE_NOTIFICATION_CACHE[user_id]) > 50: + SSE_NOTIFICATION_CACHE[user_id] = SSE_NOTIFICATION_CACHE[user_id][-50:] + + logger.info(f"Notification cached for SSE: {notification_data}") diff --git a/recruitment/tasks.py b/recruitment/tasks.py index a6d47cb..c7ec331 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -600,4 +600,147 @@ def form_close(job_id): job = get_object_or_404(JobPosting, pk=job_id) job.is_active = False job.template_form.is_active = False - job.save() \ No newline at end of file + job.save() + + +def sync_hired_candidates_task(job_slug): + """ + Django-Q background task to sync hired candidates to all configured sources. + + Args: + job_slug (str): The slug of the job posting + + Returns: + dict: Sync results with status and details + """ + from .candidate_sync_service import CandidateSyncService + from .models import JobPosting, IntegrationLog + + logger.info(f"Starting background sync task for job: {job_slug}") + + try: + # Get the job posting + job = JobPosting.objects.get(slug=job_slug) + + # Initialize sync service + sync_service = CandidateSyncService() + print(sync_service) + + # Perform the sync operation + results = sync_service.sync_hired_candidates_to_all_sources(job) + print(results) + # Log the sync operation + # IntegrationLog.objects.create( + # source=None, # This is a multi-source sync operation + # action=IntegrationLog.ActionChoices.SYNC, + # endpoint="multi_source_sync", + # method="BACKGROUND_TASK", + # request_data={"job_slug": job_slug, "candidate_count": job.accepted_candidates.count()}, + # response_data=results, + # status_code="SUCCESS" if results.get('summary', {}).get('failed', 0) == 0 else "PARTIAL", + # ip_address="127.0.0.1", # Background task + # user_agent="Django-Q Background Task", + # processing_time=results.get('summary', {}).get('total_duration', 0) + # ) + + logger.info(f"Background sync completed for job {job_slug}: {results}") + return results + + except JobPosting.DoesNotExist: + error_msg = f"Job posting not found: {job_slug}" + logger.error(error_msg) + + # Log the error + IntegrationLog.objects.create( + source=None, + action=IntegrationLog.ActionChoices.ERROR, + endpoint="multi_source_sync", + method="BACKGROUND_TASK", + request_data={"job_slug": job_slug}, + error_message=error_msg, + status_code="ERROR", + ip_address="127.0.0.1", + user_agent="Django-Q Background Task" + ) + + return {"status": "error", "message": error_msg} + + except Exception as e: + error_msg = f"Unexpected error during sync: {str(e)}" + logger.error(error_msg, exc_info=True) + + # Log the error + IntegrationLog.objects.create( + source=None, + action=IntegrationLog.ActionChoices.ERROR, + endpoint="multi_source_sync", + method="BACKGROUND_TASK", + request_data={"job_slug": job_slug}, + error_message=error_msg, + status_code="ERROR", + ip_address="127.0.0.1", + user_agent="Django-Q Background Task" + ) + + return {"status": "error", "message": error_msg} + + +def sync_candidate_to_source_task(candidate_id, source_id): + """ + Django-Q background task to sync a single candidate to a specific source. + + Args: + candidate_id (int): The ID of the candidate + source_id (int): The ID of the source + + Returns: + dict: Sync result for this specific candidate-source pair + """ + from .candidate_sync_service import CandidateSyncService + from .models import Candidate, Source, IntegrationLog + + logger.info(f"Starting sync task for candidate {candidate_id} to source {source_id}") + + try: + # Get the candidate and source + candidate = Candidate.objects.get(pk=candidate_id) + source = Source.objects.get(pk=source_id) + + # Initialize sync service + sync_service = CandidateSyncService() + + # Perform the sync operation + result = sync_service.sync_candidate_to_source(candidate, source) + + # Log the operation + IntegrationLog.objects.create( + source=source, + action=IntegrationLog.ActionChoices.SYNC, + endpoint=source.sync_endpoint or "unknown", + method=source.sync_method or "POST", + request_data={"candidate_id": candidate_id, "candidate_name": candidate.name}, + response_data=result, + status_code="SUCCESS" if result.get('success') else "ERROR", + error_message=result.get('error') if not result.get('success') else None, + ip_address="127.0.0.1", + user_agent="Django-Q Background Task", + processing_time=result.get('duration', 0) + ) + + logger.info(f"Sync completed for candidate {candidate_id} to source {source_id}: {result}") + return result + + except Candidate.DoesNotExist: + error_msg = f"Candidate not found: {candidate_id}" + logger.error(error_msg) + return {"success": False, "error": error_msg} + + except Source.DoesNotExist: + error_msg = f"Source not found: {source_id}" + logger.error(error_msg) + return {"success": False, "error": error_msg} + + except Exception as e: + error_msg = f"Unexpected error during sync: {str(e)}" + logger.error(error_msg, exc_info=True) + return {"success": False, "error": error_msg} diff --git a/recruitment/urls.py b/recruitment/urls.py index 805da73..49fb42a 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -14,7 +14,7 @@ urlpatterns = [ path('jobs//update/', views.edit_job, name='job_update'), # path('jobs//delete/', views., name='job_delete'), path('jobs//', views.job_detail, name='job_detail'), - + path('careers/',views.kaauh_career,name='kaauh_career'), # LinkedIn Integration URLs @@ -71,8 +71,14 @@ urlpatterns = [ path('jobs//candidate_exam_view/', views.candidate_exam_view, name='candidate_exam_view'), path('jobs//candidate_interview_view/', views.candidate_interview_view, name='candidate_interview_view'), path('jobs//candidate_offer_view/', views_frontend.candidate_offer_view, name='candidate_offer_view'), + path('jobs//candidate_hired_view/', views_frontend.candidate_hired_view, name='candidate_hired_view'), + path('jobs//export//csv/', views_frontend.export_candidates_csv, name='export_candidates_csv'), path('jobs//candidates//update_status///', views_frontend.update_candidate_status, name='update_candidate_status'), + # Sync URLs + path('jobs//sync-hired-candidates/', views_frontend.sync_hired_candidates, name='sync_hired_candidates'), + path('sources//test-connection/', views_frontend.test_source_connection, name='test_source_connection'), + path('jobs///reschedule_meeting_for_candidate//', views.reschedule_meeting_for_candidate, name='reschedule_meeting_for_candidate'), path('jobs//update_candidate_exam_status/', views.update_candidate_exam_status, name='update_candidate_exam_status'), @@ -84,7 +90,7 @@ urlpatterns = [ path('htmx//candidate_update_status/', views.candidate_update_status, name='candidate_update_status'), # path('forms/form//submit/', views.submit_form, name='submit_form'), - # path('forms/form//', views.form_wizard_view, name='form_wizard'), + # path('forms/form//', views.form_wizard_view, name='form_wizard'), path('forms//submissions//', views.form_submission_details, name='form_submission_details'), path('forms/template//submissions/', views.form_template_submissions_list, name='form_template_submissions_list'), path('forms/template//all-submissions/', views.form_template_all_submissions, name='form_template_all_submissions'), @@ -140,11 +146,79 @@ urlpatterns = [ # Meeting Comments URLs path('meetings//comments/add/', views.add_meeting_comment, name='add_meeting_comment'), path('meetings//comments//edit/', views.edit_meeting_comment, name='edit_meeting_comment'), - + path('meetings//comments//delete/', views.delete_meeting_comment, name='delete_meeting_comment'), path('meetings//set_meeting_candidate/', views.set_meeting_candidate, name='set_meeting_candidate'), + # Hiring Agency URLs + path('agencies/', views.agency_list, name='agency_list'), + path('agencies/create/', views.agency_create, name='agency_create'), + path('agencies//', views.agency_detail, name='agency_detail'), + path('agencies//update/', views.agency_update, name='agency_update'), + path('agencies//delete/', views.agency_delete, name='agency_delete'), + path('agencies//candidates/', views.agency_candidates, name='agency_candidates'), + # path('agencies//send-message/', views.agency_detail_send_message, name='agency_detail_send_message'), + + # Agency Assignment Management URLs + path('agency-assignments/', views.agency_assignment_list, name='agency_assignment_list'), + path('agency-assignments/create/', views.agency_assignment_create, name='agency_assignment_create'), + path('agency-assignments//create/', views.agency_assignment_create, name='agency_assignment_create'), + path('agency-assignments//', views.agency_assignment_detail, name='agency_assignment_detail'), + path('agency-assignments//update/', views.agency_assignment_update, name='agency_assignment_update'), + path('agency-assignments//extend-deadline/', views.agency_assignment_extend_deadline, name='agency_assignment_extend_deadline'), + + # Agency Access Link URLs + path('agency-access-links/create/', views.agency_access_link_create, name='agency_access_link_create'), + path('agency-access-links//', views.agency_access_link_detail, name='agency_access_link_detail'), + path('agency-access-links//deactivate/', views.agency_access_link_deactivate, name='agency_access_link_deactivate'), + path('agency-access-links//reactivate/', views.agency_access_link_reactivate, name='agency_access_link_reactivate'), + + # Admin Message Center URLs (messaging functionality removed) + # path('admin/messages/', views.admin_message_center, name='admin_message_center'), + # path('admin/messages/compose/', views.admin_compose_message, name='admin_compose_message'), + # path('admin/messages//', views.admin_message_detail, name='admin_message_detail'), + # path('admin/messages//reply/', views.admin_message_reply, name='admin_message_reply'), + # path('admin/messages//mark-read/', views.admin_mark_message_read, name='admin_mark_message_read'), + # path('admin/messages//delete/', views.admin_delete_message, name='admin_delete_message'), + + # Agency Portal URLs (for external agencies) + path('portal/login/', views.agency_portal_login, name='agency_portal_login'), + path('portal/dashboard/', views.agency_portal_dashboard, name='agency_portal_dashboard'), + path('portal/assignment//', views.agency_portal_assignment_detail, name='agency_portal_assignment_detail'), + path('portal/assignment//submit-candidate/', views.agency_portal_submit_candidate_page, name='agency_portal_submit_candidate_page'), + path('portal/submit-candidate/', views.agency_portal_submit_candidate, name='agency_portal_submit_candidate'), + path('portal/logout/', views.agency_portal_logout, name='agency_portal_logout'), + + # Agency Portal Candidate Management URLs + path('portal/candidates//edit/', views.agency_portal_edit_candidate, name='agency_portal_edit_candidate'), + path('portal/candidates//delete/', views.agency_portal_delete_candidate, name='agency_portal_delete_candidate'), + + # API URLs for messaging (removed) + # path('api/agency/messages//', views.api_agency_message_detail, name='api_agency_message_detail'), + # path('api/agency/messages//mark-read/', views.api_agency_mark_message_read, name='api_agency_mark_message_read'), + + # API URLs for candidate management + path('api/candidate//', views.api_candidate_detail, name='api_candidate_detail'), + + # Admin Notification API + path('api/admin/notification-count/', views.api_notification_count, name='admin_notification_count'), + + # Agency Notification API + path('api/agency/notification-count/', views.api_notification_count, name='api_agency_notification_count'), + + # SSE Notification Stream + path('api/notifications/stream/', views.notification_stream, name='notification_stream'), + + # Notification URLs + path('notifications/', views.notification_list, name='notification_list'), + path('notifications//', views.notification_detail, name='notification_detail'), + path('notifications//mark-read/', views.notification_mark_read, name='notification_mark_read'), + path('notifications//mark-unread/', views.notification_mark_unread, name='notification_mark_unread'), + path('notifications//delete/', views.notification_delete, name='notification_delete'), + path('notifications/mark-all-read/', views.notification_mark_all_read, name='notification_mark_all_read'), + path('api/notification-count/', views.api_notification_count, name='api_notification_count'), + #participants urls path('participants/', views_frontend.ParticipantsListView.as_view(), name='participants_list'), diff --git a/recruitment/views.py b/recruitment/views.py index 029d172..7231631 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -3,6 +3,7 @@ import json from django.utils.translation import gettext as _ from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required +from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.mixins import LoginRequiredMixin from rich import print @@ -33,9 +34,11 @@ from .forms import ( StaffUserCreationForm, MeetingCommentForm, ToggleAccountForm, - LinkedPostContentForm, - ParticipantsSelectForm - + HiringAgencyForm, + AgencyCandidateSubmissionForm, + AgencyLoginForm, + AgencyAccessLinkForm, + AgencyJobAssignmentForm ) from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from rest_framework import viewsets @@ -70,7 +73,10 @@ from .models import ( JobPosting, ScheduledInterview, JobPostingImage, - Profile,MeetingComment + Profile,MeetingComment,HiringAgency, + AgencyJobAssignment, + AgencyAccessLink, + Notification ) import logging from datastar_py.django import ( @@ -546,8 +552,6 @@ def kaauh_career(request): return render(request,'jobs/career.html',{'active_jobs':active_jobs}) - - # job detail facing the candidate: def application_detail(request, slug): job = get_object_or_404(JobPosting, slug=slug) @@ -625,7 +629,7 @@ def linkedin_callback(request): try: service = LinkedInService() - # get_access_token(code)->It makes a POST request to LinkedInโ€™s token endpoint with parameters + # get_access_token(code)->It makes a POST request to LinkedIn's token endpoint with parameters access_token = service.get_access_token(code) request.session["linkedin_access_token"] = access_token request.session["linkedin_authenticated"] = True @@ -1363,6 +1367,7 @@ def candidate_exam_view(request, slug): } return render(request, "recruitment/candidate_exam_view.html", context) + @login_required def update_candidate_exam_status(request, slug): candidate = get_object_or_404(Candidate, slug=slug) @@ -1509,6 +1514,7 @@ def delete_meeting_for_candidate(request,slug,candidate_pk,meeting_id): context = {"job":job,"candidate":candidate,"meeting":meeting,'delete_url':reverse("delete_meeting_for_candidate",kwargs={"slug":job.slug,"candidate_pk":candidate_pk,"meeting_id":meeting_id})} return render(request,"meetings/delete_meeting_form.html",context) + @login_required def interview_calendar_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) @@ -1563,6 +1569,7 @@ def interview_calendar_view(request, slug): return render(request, 'recruitment/interview_calendar.html', context) + @login_required def interview_detail_view(request, slug, interview_id): job = get_object_or_404(JobPosting, slug=slug) @@ -1579,6 +1586,7 @@ def interview_detail_view(request, slug, interview_id): return render(request, 'recruitment/interview_detail.html', context) + # Candidate Meeting Scheduling/Rescheduling Views @require_POST def api_schedule_candidate_meeting(request, job_slug, candidate_pk): @@ -1838,6 +1846,7 @@ def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_ messages.error(request, result["message"]) return JsonResponse({'success': False, 'error': result["message"]}, status=400) + # The original schedule_candidate_meeting and reschedule_candidate_meeting (without api_ prefix) # can be removed if their only purpose was to be called by the JS onclicks. # If they were intended for other direct URL access, they can be kept as simple redirects @@ -1878,7 +1887,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): new_start_time = form.cleaned_data.get('start_time') new_duration = form.cleaned_data.get('duration') - # Use a default topic if not provided, keeping the original structure + # Use a default topic if not provided, keeping with the original structure if not new_topic: new_topic = f"Interview: {job.title} with {candidate.name}" @@ -1965,16 +1974,16 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): else: # Form validation errors return render(request, "recruitment/schedule_meeting_form.html", { - 'form': form, - 'job': job, - 'candidate': candidate, - 'scheduled_interview': scheduled_interview, - 'initial_topic': request.POST.get('topic', new_topic), - 'initial_start_time': request.POST.get('start_time', new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else ''), - 'initial_duration': request.POST.get('duration', new_duration), - 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), - 'has_future_meeting': has_other_future_meetings - }) + 'form': form, + 'job': job, + 'candidate': candidate, + 'scheduled_interview': scheduled_interview, + 'initial_topic': request.POST.get('topic', new_topic), + 'initial_start_time': request.POST.get('start_time', new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else ''), + 'initial_duration': request.POST.get('duration', new_duration), + 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), + 'has_future_meeting': has_other_future_meetings + }) else: # GET request # Pre-populate form with existing meeting details initial_data = { @@ -1996,7 +2005,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): def schedule_meeting_for_candidate(request, slug, candidate_pk): """ Handles GET to display a simple form for scheduling a meeting for a candidate. - Handles POST to process the form, create the meeting, and redirect back. + Handles POST to process the form, create a meeting, and redirect back. """ job = get_object_or_404(JobPosting, slug=slug) candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) @@ -2110,7 +2119,7 @@ def user_profile_image_update(request, pk): messages.success(request, 'Image uploaded successfully') return redirect('user_detail', pk=user.pk) else: - messages.error(request, 'An error occurred while uploading the image. Please check the errors below.') + messages.error(request, 'An error occurred while uploading image. Please check the errors below.') else: profile_form = ProfileImageUploadForm(instance=user.profile) @@ -2221,8 +2230,6 @@ def create_staff_user(request): - - @user_passes_test(is_superuser_check) def admin_settings(request): staffs=User.objects.filter(is_superuser=False) @@ -2337,24 +2344,26 @@ def add_meeting_comment(request, slug): return redirect('meeting_details', slug=slug) + + @login_required def edit_meeting_comment(request, slug, comment_id): """Edit a meeting comment""" meeting = get_object_or_404(ZoomMeeting, slug=slug) comment = get_object_or_404(MeetingComment, id=comment_id, meeting=meeting) - # Check if user is the author - if comment.author != request.user: + # Check if user is author + if comment.author != request.user and not request.user.is_staff: messages.error(request, 'You can only edit your own comments.') return redirect('meeting_details', slug=slug) if request.method == 'POST': form = MeetingCommentForm(request.POST, instance=comment) if form.is_valid(): - form.save() + comment = form.save() messages.success(request, 'Comment updated successfully!') - # HTMX response - return just the comment section + # HTMX response - return just comment section if 'HX-Request' in request.headers: return render(request, 'includes/comment_list.html', { 'comments': meeting.comments.all().order_by('-created_at'), @@ -2364,14 +2373,15 @@ def edit_meeting_comment(request, slug, comment_id): return redirect('meeting_details', slug=slug) else: form = MeetingCommentForm(instance=comment) - print("hi") + context = { 'form': form, 'meeting': meeting, - 'comment':comment + 'comment': comment } return render(request, 'includes/edit_comment_form.html', context) + @login_required def delete_meeting_comment(request, slug, comment_id): """Delete a meeting comment""" @@ -2390,9 +2400,9 @@ def delete_meeting_comment(request, slug, comment_id): # HTMX response - return just the comment section if 'HX-Request' in request.headers: return render(request, 'includes/comment_list.html', { - 'comments': meeting.comments.all().order_by('-created_at'), - 'meeting': meeting - }) + 'comments': meeting.comments.all().order_by('-created_at'), + 'meeting': meeting + }) return redirect('meeting_details', slug=slug) @@ -2439,3 +2449,1217 @@ def set_meeting_candidate(request,slug): "meeting": meeting } return render(request, 'meetings/set_candidate_form.html', context) + + +# Hiring Agency CRUD Views +@login_required +def agency_list(request): + """List all hiring agencies with search and pagination""" + search_query = request.GET.get('q', '') + agencies = HiringAgency.objects.all() + + if search_query: + agencies = agencies.filter( + Q(name__icontains=search_query) | + Q(contact_person__icontains=search_query) | + Q(email__icontains=search_query) | + Q(country__icontains=search_query) + ) + + # Order by most recently created + agencies = agencies.order_by('-created_at') + + # Pagination + paginator = Paginator(agencies, 10) # Show 10 agencies per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + context = { + 'page_obj': page_obj, + 'search_query': search_query, + 'total_agencies': agencies.count(), + } + return render(request, 'recruitment/agency_list.html', context) + + +@login_required +def agency_create(request): + """Create a new hiring agency""" + if request.method == 'POST': + form = HiringAgencyForm(request.POST) + if form.is_valid(): + agency = form.save() + messages.success(request, f'Agency "{agency.name}" created successfully!') + return redirect('agency_detail', slug=agency.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = HiringAgencyForm() + + context = { + 'form': form, + 'title': 'Create New Agency', + 'button_text': 'Create Agency', + } + return render(request, 'recruitment/agency_form.html', context) + + +@login_required +def agency_detail(request, slug): + """View details of a specific hiring agency""" + agency = get_object_or_404(HiringAgency, slug=slug) + + # Get candidates associated with this agency + candidates = Candidate.objects.filter(hiring_agency=agency).order_by('-created_at') + + # Statistics + total_candidates = candidates.count() + active_candidates = candidates.filter(stage__in=['Applied', 'Screening', 'Exam', 'Interview', 'Offer']).count() + hired_candidates = candidates.filter(stage='Hired').count() + rejected_candidates = candidates.filter(stage='Rejected').count() + + context = { + 'agency': agency, + 'candidates': candidates[:10], # Show recent 10 candidates + 'total_candidates': total_candidates, + 'active_candidates': active_candidates, + 'hired_candidates': hired_candidates, + 'rejected_candidates': rejected_candidates, + } + return render(request, 'recruitment/agency_detail.html', context) + + +@login_required +def agency_update(request, slug): + """Update an existing hiring agency""" + agency = get_object_or_404(HiringAgency, slug=slug) + + if request.method == 'POST': + form = HiringAgencyForm(request.POST, instance=agency) + if form.is_valid(): + agency = form.save() + messages.success(request, f'Agency "{agency.name}" updated successfully!') + return redirect('agency_detail', slug=agency.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = HiringAgencyForm(instance=agency) + + context = { + 'form': form, + 'agency': agency, + 'title': f'Edit Agency: {agency.name}', + 'button_text': 'Update Agency', + } + return render(request, 'recruitment/agency_form.html', context) + + +@login_required +def agency_delete(request, slug): + """Delete a hiring agency""" + agency = get_object_or_404(HiringAgency, slug=slug) + + if request.method == 'POST': + agency_name = agency.name + agency.delete() + messages.success(request, f'Agency "{agency_name}" deleted successfully!') + return redirect('agency_list') + + context = { + 'agency': agency, + 'title': 'Delete Agency', + 'message': f'Are you sure you want to delete the agency "{agency.name}"?', + 'cancel_url': reverse('agency_detail', kwargs={'slug': agency.slug}), + } + return render(request, 'recruitment/agency_confirm_delete.html', context) + + +# Notification Views +@login_required +def notification_list(request): + """List all notifications for the current user""" + # Get filter parameters + status_filter = request.GET.get('status', '') + type_filter = request.GET.get('type', '') + + # Base queryset + notifications = Notification.objects.filter(recipient=request.user).order_by('-created_at') + + # Apply filters + if status_filter: + if status_filter == 'unread': + notifications = notifications.filter(status=Notification.Status.PENDING) + elif status_filter == 'read': + notifications = notifications.filter(status=Notification.Status.READ) + elif status_filter == 'sent': + notifications = notifications.filter(status=Notification.Status.SENT) + + if type_filter: + if type_filter == 'in_app': + notifications = notifications.filter(notification_type=Notification.NotificationType.IN_APP) + elif type_filter == 'email': + notifications = notifications.filter(notification_type=Notification.NotificationType.EMAIL) + + # Pagination + paginator = Paginator(notifications, 20) # Show 20 notifications per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + # Statistics + total_notifications = notifications.count() + unread_notifications = notifications.filter(status=Notification.Status.PENDING).count() + email_notifications = notifications.filter(notification_type=Notification.NotificationType.EMAIL).count() + + context = { + 'page_obj': page_obj, + 'total_notifications': total_notifications, + 'unread_notifications': unread_notifications, + 'email_notifications': email_notifications, + 'status_filter': status_filter, + 'type_filter': type_filter, + } + return render(request, 'recruitment/notification_list.html', context) + + +@login_required +def notification_detail(request, notification_id): + """View details of a specific notification""" + notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) + + # Mark as read if it was pending + if notification.status == Notification.Status.PENDING: + notification.status = Notification.Status.READ + notification.save(update_fields=['status']) + + context = { + 'notification': notification, + } + return render(request, 'recruitment/notification_detail.html', context) + + +@login_required +def notification_mark_read(request, notification_id): + """Mark a notification as read""" + notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) + + if notification.status == Notification.Status.PENDING: + notification.status = Notification.Status.READ + notification.save(update_fields=['status']) + + if 'HX-Request' in request.headers: + return HttpResponse(status=200) # HTMX success response + + return redirect('notification_list') + + +@login_required +def notification_mark_unread(request, notification_id): + """Mark a notification as unread""" + notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) + + if notification.status == Notification.Status.READ: + notification.status = Notification.Status.PENDING + notification.save(update_fields=['status']) + + if 'HX-Request' in request.headers: + return HttpResponse(status=200) # HTMX success response + + return redirect('notification_list') + + +@login_required +def notification_delete(request, notification_id): + """Delete a notification""" + notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) + + if request.method == 'POST': + notification.delete() + messages.success(request, 'Notification deleted successfully!') + return redirect('notification_list') + + # For GET requests, show confirmation page + context = { + 'notification': notification, + 'title': 'Delete Notification', + 'message': f'Are you sure you want to delete this notification?', + 'cancel_url': reverse('notification_detail', kwargs={'notification_id': notification.id}), + } + return render(request, 'recruitment/notification_confirm_delete.html', context) + + +@login_required +def notification_mark_all_read(request): + """Mark all notifications as read for the current user""" + if request.method == 'POST': + Notification.objects.filter( + recipient=request.user, + status=Notification.Status.PENDING + ).update(status=Notification.Status.READ) + + messages.success(request, 'All notifications marked as read!') + return redirect('notification_list') + + # For GET requests, show confirmation page + unread_count = Notification.objects.filter( + recipient=request.user, + status=Notification.Status.PENDING + ).count() + + context = { + 'unread_count': unread_count, + 'title': 'Mark All as Read', + 'message': f'Are you sure you want to mark all {unread_count} notifications as read?', + 'cancel_url': reverse('notification_list'), + } + return render(request, 'recruitment/notification_confirm_all_read.html', context) + + +@login_required +def api_notification_count(request): + """API endpoint to get unread notification count and recent notifications""" + # Get unread notifications + unread_notifications = Notification.objects.filter( + recipient=request.user, + status=Notification.Status.PENDING + ).order_by('-created_at') + + # Get recent notifications (last 5) + recent_notifications = Notification.objects.filter( + recipient=request.user + ).order_by('-created_at')[:5] + + # Prepare recent notifications data + recent_data = [] + for notification in recent_notifications: + time_ago = '' + if notification.created_at: + from datetime import datetime, timezone + now = timezone.now() + diff = now - notification.created_at + + if diff.days > 0: + time_ago = f'{diff.days}d ago' + elif diff.seconds > 3600: + hours = diff.seconds // 3600 + time_ago = f'{hours}h ago' + elif diff.seconds > 60: + minutes = diff.seconds // 60 + time_ago = f'{minutes}m ago' + else: + time_ago = 'Just now' + + recent_data.append({ + 'id': notification.id, + 'message': notification.message[:100] + ('...' if len(notification.message) > 100 else ''), + 'type': notification.get_notification_type_display(), + 'status': notification.get_status_display(), + 'time_ago': time_ago, + 'url': reverse('notification_detail', kwargs={'notification_id': notification.id}) + }) + + return JsonResponse({ + 'count': unread_notifications.count(), + 'recent_notifications': recent_data + }) + + +@login_required +def notification_stream(request): + """SSE endpoint for real-time notifications""" + from django.http import StreamingHttpResponse + import json + import time + from .signals import SSE_NOTIFICATION_CACHE + + def event_stream(): + """Generator function for SSE events""" + user_id = request.user.id + last_notification_id = 0 + + # Get initial last notification ID + last_notification = Notification.objects.filter( + recipient=request.user + ).order_by('-id').first() + if last_notification: + last_notification_id = last_notification.id + + # Send any cached notifications first + cached_notifications = SSE_NOTIFICATION_CACHE.get(user_id, []) + for cached_notification in cached_notifications: + if cached_notification['id'] > last_notification_id: + yield f"event: new_notification\n" + yield f"data: {json.dumps(cached_notification)}\n\n" + last_notification_id = cached_notification['id'] + + while True: + try: + # Check for new notifications from cache first + cached_notifications = SSE_NOTIFICATION_CACHE.get(user_id, []) + new_cached = [n for n in cached_notifications if n['id'] > last_notification_id] + + for notification_data in new_cached: + yield f"event: new_notification\n" + yield f"data: {json.dumps(notification_data)}\n\n" + last_notification_id = notification_data['id'] + + # Also check database for any missed notifications + new_notifications = Notification.objects.filter( + recipient=request.user, + id__gt=last_notification_id + ).order_by('id') + + if new_notifications.exists(): + for notification in new_notifications: + # Prepare notification data + time_ago = '' + if notification.created_at: + now = timezone.now() + diff = now - notification.created_at + + if diff.days > 0: + time_ago = f'{diff.days}d ago' + elif diff.seconds > 3600: + hours = diff.seconds // 3600 + time_ago = f'{hours}h ago' + elif diff.seconds > 60: + minutes = diff.seconds // 60 + time_ago = f'{minutes}m ago' + else: + time_ago = 'Just now' + + notification_data = { + 'id': notification.id, + 'message': notification.message[:100] + ('...' if len(notification.message) > 100 else ''), + 'type': notification.get_notification_type_display(), + 'status': notification.get_status_display(), + 'time_ago': time_ago, + 'url': reverse('notification_detail', kwargs={'notification_id': notification.id}) + } + + # Send SSE event + yield f"event: new_notification\n" + yield f"data: {json.dumps(notification_data)}\n\n" + + last_notification_id = notification.id + + # Update count after sending new notifications + unread_count = Notification.objects.filter( + recipient=request.user, + status=Notification.Status.PENDING + ).count() + + count_data = {'count': unread_count} + yield f"event: count_update\n" + yield f"data: {json.dumps(count_data)}\n\n" + + # Send heartbeat every 30 seconds + yield f"event: heartbeat\n" + yield f"data: {json.dumps({'timestamp': int(time.time())})}\n\n" + + # Wait before next check + time.sleep(5) # Check every 5 seconds + + except Exception as e: + # Send error event and continue + error_data = {'error': str(e)} + yield f"event: error\n" + yield f"data: {json.dumps(error_data)}\n\n" + time.sleep(10) # Wait longer on error + + response = StreamingHttpResponse( + event_stream(), + content_type='text/event-stream' + ) + + # Set SSE headers + response['Cache-Control'] = 'no-cache' + response['X-Accel-Buffering'] = 'no' # Disable buffering for nginx + response['Connection'] = 'keep-alive' + + context = { + 'agency': agency, + 'page_obj': page_obj, + 'stage_filter': stage_filter, + 'total_candidates': candidates.count(), + } + return render(request, 'recruitment/agency_candidates.html', context) + + +@login_required +def agency_candidates(request, slug): + """View all candidates from a specific agency""" + agency = get_object_or_404(HiringAgency, slug=slug) + candidates = Candidate.objects.filter(hiring_agency=agency).order_by('-created_at') + + # Filter by stage if provided + stage_filter = request.GET.get('stage') + if stage_filter: + candidates = candidates.filter(stage=stage_filter) + + # Get total candidates before pagination for accurate count + total_candidates = candidates.count() + + # Pagination + paginator = Paginator(candidates, 20) # Show 20 candidates per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + context = { + 'agency': agency, + 'page_obj': page_obj, + 'stage_filter': stage_filter, + 'total_candidates': total_candidates, + } + return render(request, 'recruitment/agency_candidates.html', context) + + + + +# Agency Portal Management Views +@login_required +def agency_assignment_list(request): + """List all agency job assignments""" + search_query = request.GET.get('q', '') + status_filter = request.GET.get('status', '') + + assignments = AgencyJobAssignment.objects.select_related( + 'agency', 'job' + ).order_by('-created_at') + + if search_query: + assignments = assignments.filter( + Q(agency__name__icontains=search_query) | + Q(job__title__icontains=search_query) + ) + + if status_filter: + assignments = assignments.filter(status=status_filter) + + # Pagination + paginator = Paginator(assignments, 15) # Show 15 assignments per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + context = { + 'page_obj': page_obj, + 'search_query': search_query, + 'status_filter': status_filter, + 'total_assignments': assignments.count(), + } + return render(request, 'recruitment/agency_assignment_list.html', context) + + +@login_required +def agency_assignment_create(request,slug=None): + """Create a new agency job assignment""" + agency = HiringAgency.objects.get(slug=slug) if slug else None + + if request.method == 'POST': + form = AgencyJobAssignmentForm(request.POST) + # if agency: + # form.instance.agency = agency + if form.is_valid(): + assignment = form.save() + messages.success(request, f'Assignment created for {assignment.agency.name} - {assignment.job.title}!') + return redirect('agency_assignment_detail', slug=assignment.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = AgencyJobAssignmentForm() + try: + from django.forms import HiddenInput + form.initial['agency'] = agency + form.fields['agency'].widget = HiddenInput() + except HiringAgency.DoesNotExist: + pass + + context = { + 'form': form, + 'title': 'Create New Assignment', + 'button_text': 'Create Assignment', + } + return render(request, 'recruitment/agency_assignment_form.html', context) + + +@login_required +def agency_assignment_detail(request, slug): + """View details of a specific agency assignment""" + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + slug=slug + ) + + # Get candidates submitted by this agency for this job + candidates = Candidate.objects.filter( + hiring_agency=assignment.agency, + job=assignment.job + ).order_by('-created_at') + + # Get access link if exists + access_link = getattr(assignment, 'access_link', None) + + # Get messages for this assignment + + + total_candidates = candidates.count() + max_candidates = assignment.max_candidates + circumference = 326.73 # 2 * ฯ€ * r where r=52 + + if max_candidates > 0: + progress_percentage = (total_candidates / max_candidates) + stroke_dashoffset = circumference - (circumference * progress_percentage) + else: + stroke_dashoffset = circumference + + context = { + 'assignment': assignment, + 'candidates': candidates, + 'access_link': access_link, + + 'total_candidates': candidates.count(), + 'stroke_dashoffset': stroke_dashoffset, + } + return render(request, 'recruitment/agency_assignment_detail.html', context) + + +@login_required +def agency_assignment_update(request, slug): + """Update an existing agency assignment""" + assignment = get_object_or_404(AgencyJobAssignment, slug=slug) + + if request.method == 'POST': + form = AgencyJobAssignmentForm(request.POST, instance=assignment) + if form.is_valid(): + assignment = form.save() + messages.success(request, f'Assignment updated successfully!') + return redirect('agency_assignment_detail', slug=assignment.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = AgencyJobAssignmentForm(instance=assignment) + + context = { + 'form': form, + 'assignment': assignment, + 'title': f'Edit Assignment: {assignment.agency.name} - {assignment.job.title}', + 'button_text': 'Update Assignment', + } + return render(request, 'recruitment/agency_assignment_form.html', context) + + +@login_required +def agency_access_link_create(request): + """Create access link for agency assignment""" + if request.method == 'POST': + form = AgencyAccessLinkForm(request.POST) + if form.is_valid(): + access_link = form.save() + messages.success(request, f'Access link created for {access_link.assignment.agency.name}!') + return redirect('agency_assignment_detail', slug=access_link.assignment.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = AgencyAccessLinkForm() + + context = { + 'form': form, + 'title': 'Create Access Link', + 'button_text': 'Create Link', + } + return render(request, 'recruitment/agency_access_link_form.html', context) + + +@login_required +def agency_access_link_detail(request, slug): + """View details of an access link""" + access_link = get_object_or_404( + AgencyAccessLink.objects.select_related('assignment__agency', 'assignment__job'), + slug=slug + ) + + context = { + 'access_link': access_link, + } + return render(request, 'recruitment/agency_access_link_detail.html', context) + + + + + + + + + + + + + + + + +@login_required +def agency_assignment_extend_deadline(request, slug): + """Extend deadline for an agency assignment""" + assignment = get_object_or_404(AgencyJobAssignment, slug=slug) + + if request.method == 'POST': + new_deadline = request.POST.get('new_deadline') + if new_deadline: + try: + from datetime import datetime + new_deadline_dt = datetime.fromisoformat(new_deadline.replace('Z', '+00:00')) + # Ensure the new deadline is timezone-aware + if timezone.is_naive(new_deadline_dt): + new_deadline_dt = timezone.make_aware(new_deadline_dt) + + if assignment.extend_deadline(new_deadline_dt): + messages.success(request, f'Deadline extended to {new_deadline_dt.strftime("%Y-%m-%d %H:%M")}!') + else: + messages.error(request, 'New deadline must be later than current deadline.') + except ValueError: + messages.error(request, 'Invalid date format.') + else: + messages.error(request, 'Please provide a new deadline.') + + return redirect('agency_assignment_detail', slug=assignment.slug) + + +# Agency Portal Views (for external agencies) +def agency_portal_login(request): + """Agency login page""" + if request.session.get('agency_assignment_id'): + return redirect('agency_portal_dashboard') + if request.method == 'POST': + form = AgencyLoginForm(request.POST) + + if form.is_valid(): + # Check if validated_access_link attribute exists + + if hasattr(form, 'validated_access_link'): + access_link = form.validated_access_link + access_link.record_access() + + # Store assignment in session + request.session['agency_assignment_id'] = access_link.assignment.id + request.session['agency_name'] = access_link.assignment.agency.name + + messages.success(request, f'Welcome, {access_link.assignment.agency.name}!') + return redirect('agency_portal_dashboard') + else: + messages.error(request, 'Invalid token or password.') + else: + form = AgencyLoginForm() + + context = { + 'form': form, + } + return render(request, 'recruitment/agency_portal_login.html', context) + + +def agency_portal_dashboard(request): + """Agency portal dashboard showing all assignments for the agency""" + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return redirect('agency_portal_login') + + # Get the current assignment to determine the agency + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_id + ) + + agency = current_assignment.agency + + # Get ALL assignments for this agency + assignments = AgencyJobAssignment.objects.filter( + agency=agency + ).select_related('job').order_by('-created_at') + + # Calculate statistics for each assignment + assignment_stats = [] + for assignment in assignments: + candidates = Candidate.objects.filter( + hiring_agency=agency, + job=assignment.job + ).order_by('-created_at') + + unread_messages = 0 + + assignment_stats.append({ + 'assignment': assignment, + 'candidates': candidates, + 'candidate_count': candidates.count(), + 'unread_messages': unread_messages, + 'days_remaining': assignment.days_remaining, + 'is_active': assignment.is_currently_active, + 'can_submit': assignment.can_submit, + }) + + # Get overall statistics + total_candidates = sum(stats['candidate_count'] for stats in assignment_stats) + total_unread_messages = sum(stats['unread_messages'] for stats in assignment_stats) + active_assignments = sum(1 for stats in assignment_stats if stats['is_active']) + + context = { + 'agency': agency, + 'current_assignment': current_assignment, + 'assignment_stats': assignment_stats, + 'total_assignments': assignments.count(), + 'active_assignments': active_assignments, + 'total_candidates': total_candidates, + 'total_unread_messages': total_unread_messages, + } + return render(request, 'recruitment/agency_portal_dashboard.html', context) + + +def agency_portal_submit_candidate_page(request, slug): + """Dedicated page for submitting a candidate""" + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return redirect('agency_portal_login') + + # Get the specific assignment by slug and verify it belongs to the same agency + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_id + ) + + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + slug=slug + ) + + if assignment.is_full: + messages.error(request, 'Maximum candidate limit reached for this assignment.') + return redirect('agency_portal_assignment_detail', slug=assignment.slug) + # Verify this assignment belongs to the same agency as the logged-in session + if assignment.agency.id != current_assignment.agency.id: + messages.error(request, 'Access denied: This assignment does not belong to your agency.') + return redirect('agency_portal_dashboard') + + # Check if assignment allows submission + if not assignment.can_submit: + messages.error(request, 'Cannot submit candidates: Assignment is not active, expired, or full.') + return redirect('agency_portal_assignment_detail', slug=assignment.slug) + + # Get total submitted candidates for this assignment + total_submitted = Candidate.objects.filter( + hiring_agency=assignment.agency, + job=assignment.job + ).count() + + if request.method == 'POST': + form = AgencyCandidateSubmissionForm(assignment, request.POST, request.FILES) + if form.is_valid(): + candidate = form.save(commit=False) + candidate.hiring_source = 'AGENCY' + candidate.hiring_agency = assignment.agency + candidate.save() + assignment.increment_submission_count() + + # Handle AJAX requests + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'message': f'Candidate {candidate.name} submitted successfully!', + 'candidate_id': candidate.id + }) + else: + messages.success(request, f'Candidate {candidate.name} submitted successfully!') + return redirect('agency_portal_assignment_detail', slug=assignment.slug) + else: + # Handle form validation errors for AJAX + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + error_messages = [] + for field, errors in form.errors.items(): + for error in errors: + error_messages.append(f'{field}: {error}') + return JsonResponse({ + 'success': False, + 'message': 'Please correct the following errors: ' + '; '.join(error_messages) + }) + else: + messages.error(request, 'Please correct errors below.') + else: + form = AgencyCandidateSubmissionForm(assignment) + + context = { + 'form': form, + 'assignment': assignment, + 'total_submitted': total_submitted, + } + return render(request, 'recruitment/agency_portal_submit_candidate.html', context) + + +def agency_portal_submit_candidate(request): + """Handle candidate submission via AJAX (for embedded form)""" + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return redirect('agency_portal_login') + + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + id=assignment_id + ) + if assignment.is_full: + messages.error(request, 'Maximum candidate limit reached for this assignment.') + return redirect('agency_portal_assignment_detail', slug=assignment.slug) + + # Check if assignment allows submission + if not assignment.can_submit: + messages.error(request, 'Cannot submit candidates: Assignment is not active, expired, or full.') + return redirect('agency_portal_dashboard') + + if request.method == 'POST': + form = AgencyCandidateSubmissionForm(assignment, request.POST, request.FILES) + if form.is_valid(): + candidate = form.save(commit=False) + candidate.hiring_source = 'AGENCY' + candidate.hiring_agency = assignment.agency + candidate.save() + + # Increment the assignment's submitted count + assignment.increment_submission_count() + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': True, 'message': f'Candidate {candidate.name} submitted successfully!'}) + else: + messages.success(request, f'Candidate {candidate.name} submitted successfully!') + return redirect('agency_portal_dashboard') + else: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': False, 'message': 'Please correct the errors below.'}) + else: + messages.error(request, 'Please correct errors below.') + else: + form = AgencyCandidateSubmissionForm(assignment) + + context = { + 'form': form, + 'assignment': assignment, + 'title': f'Submit Candidate for {assignment.job.title}', + 'button_text': 'Submit Candidate', + } + return render(request, 'recruitment/agency_portal_submit_candidate.html', context) + + + + +def agency_portal_assignment_detail(request, slug): + """View details of a specific assignment - routes to admin or agency template""" + print(slug) + # Check if this is an agency portal user (via session) + assignment_id = request.session.get('agency_assignment_id') + is_agency_user = bool(assignment_id) + return agency_assignment_detail_agency(request, slug, assignment_id) + # if is_agency_user: + # # Agency Portal User - Route to agency-specific template + # else: + # # Admin User - Route to admin template + # return agency_assignment_detail_admin(request, slug) + + +def agency_assignment_detail_agency(request, slug, assignment_id): + """Handle agency portal assignment detail view""" + # Get the assignment by slug and verify it belongs to same agency + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + slug=slug + ) + + # Verify this assignment belongs to the same agency as the logged-in session + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_id + ) + + if assignment.agency.id != current_assignment.agency.id: + messages.error(request, 'Access denied: This assignment does not belong to your agency.') + return redirect('agency_portal_dashboard') + + # Get candidates submitted by this agency for this job + candidates = Candidate.objects.filter( + hiring_agency=assignment.agency, + job=assignment.job + ).order_by('-created_at') + + # Get messages for this assignment + messages = [] + + # Mark messages as read + # No messages to mark as read + + # Pagination for candidates + paginator = Paginator(candidates, 20) # Show 20 candidates per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + # Pagination for messages + message_paginator = Paginator(messages, 15) # Show 15 messages per page + message_page_number = request.GET.get('message_page') + message_page_obj = message_paginator.get_page(message_page_number) + + # Calculate progress ring offset for circular progress indicator + total_candidates = candidates.count() + max_candidates = assignment.max_candidates + circumference = 326.73 # 2 * ฯ€ * r where r=52 + + if max_candidates > 0: + progress_percentage = (total_candidates / max_candidates) + stroke_dashoffset = circumference - (circumference * progress_percentage) + else: + stroke_dashoffset = circumference + + context = { + 'assignment': assignment, + 'page_obj': page_obj, + 'message_page_obj': message_page_obj, + 'total_candidates': total_candidates, + 'stroke_dashoffset': stroke_dashoffset, + } + return render(request, 'recruitment/agency_portal_assignment_detail.html', context) + + +def agency_assignment_detail_admin(request, slug): + """Handle admin assignment detail view""" + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + slug=slug + ) + + # Get candidates submitted by this agency for this job + candidates = Candidate.objects.filter( + hiring_agency=assignment.agency, + job=assignment.job + ).order_by('-created_at') + + # Get access link if exists + access_link = getattr(assignment, 'access_link', None) + + # Get messages for this assignment + messages = [] + + context = { + 'assignment': assignment, + 'candidates': candidates, + 'access_link': access_link, + 'total_candidates': candidates.count(), + } + return render(request, 'recruitment/agency_assignment_detail.html', context) + + +def agency_portal_edit_candidate(request, candidate_id): + """Edit a candidate for agency portal""" + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return redirect('agency_portal_login') + + # Get current assignment to determine agency + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_id + ) + + agency = current_assignment.agency + + # Get candidate and verify it belongs to this agency + candidate = get_object_or_404(Candidate, id=candidate_id, hiring_agency=agency) + + if request.method == 'POST': + # Handle form submission + candidate.first_name = request.POST.get('first_name', candidate.first_name) + candidate.last_name = request.POST.get('last_name', candidate.last_name) + candidate.email = request.POST.get('email', candidate.email) + candidate.phone = request.POST.get('phone', candidate.phone) + candidate.address = request.POST.get('address', candidate.address) + + # Handle resume upload if provided + if 'resume' in request.FILES: + candidate.resume = request.FILES['resume'] + + try: + candidate.save() + messages.success(request, f'Candidate {candidate.name} updated successfully!') + return redirect('agency_assignment_detail', slug=candidate.job.agencyjobassignment_set.first().slug) + except Exception as e: + messages.error(request, f'Error updating candidate: {e}') + + # For GET requests or POST errors, return JSON response for AJAX + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'candidate': { + 'id': candidate.id, + 'first_name': candidate.first_name, + 'last_name': candidate.last_name, + 'email': candidate.email, + 'phone': candidate.phone, + 'address': candidate.address, + } + }) + + # Fallback for non-AJAX requests + return redirect('agency_portal_dashboard') + + +def agency_portal_delete_candidate(request, candidate_id): + """Delete a candidate for agency portal""" + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return redirect('agency_portal_login') + + # Get current assignment to determine agency + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_id + ) + + agency = current_assignment.agency + + # Get candidate and verify it belongs to this agency + candidate = get_object_or_404(Candidate, id=candidate_id, hiring_agency=agency) + + if request.method == 'POST': + try: + candidate_name = candidate.name + candidate.delete() + + current_assignment.candidates_submitted -= 1 + current_assignment.status = current_assignment.AssignmentStatus.ACTIVE + current_assignment.save(update_fields=['candidates_submitted','status']) + + messages.success(request, f'Candidate {candidate_name} removed successfully!') + return JsonResponse({'success': True}) + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) + + # For GET requests, return error + return JsonResponse({'success': False, 'error': 'Method not allowed'}) + + +def agency_portal_logout(request): + """Logout from agency portal""" + if 'agency_assignment_id' in request.session: + del request.session['agency_assignment_id'] + if 'agency_name' in request.session: + del request.session['agency_name'] + + messages.success(request, 'You have been logged out.') + return redirect('agency_portal_login') + + +@login_required +def agency_access_link_deactivate(request, slug): + """Deactivate an agency access link""" + access_link = get_object_or_404( + AgencyAccessLink.objects.select_related('assignment__agency', 'assignment__job'), + slug=slug + ) + + if request.method == 'POST': + access_link.is_active = False + access_link.save(update_fields=['is_active']) + + messages.success( + request, + f'Access link for {access_link.assignment.agency.name} - {access_link.assignment.job.title} has been deactivated.' + ) + + # Handle HTMX requests + if 'HX-Request' in request.headers: + return HttpResponse(status=200) # HTMX success response + + return redirect('agency_assignment_detail', slug=access_link.assignment.slug) + + # For GET requests, show confirmation page + context = { + 'access_link': access_link, + 'title': 'Deactivate Access Link', + 'message': f'Are you sure you want to deactivate the access link for {access_link.assignment.agency.name}?', + 'cancel_url': reverse('agency_assignment_detail', kwargs={'slug': access_link.assignment.slug}), + } + return render(request, 'recruitment/agency_access_link_confirm.html', context) + + +@login_required +def agency_access_link_reactivate(request, slug): + """Reactivate an agency access link""" + access_link = get_object_or_404( + AgencyAccessLink.objects.select_related('assignment__agency', 'assignment__job'), + slug=slug + ) + + if request.method == 'POST': + access_link.is_active = True + access_link.save(update_fields=['is_active']) + + messages.success( + request, + f'Access link for {access_link.assignment.agency.name} - {access_link.assignment.job.title} has been reactivated.' + ) + + # Handle HTMX requests + if 'HX-Request' in request.headers: + return HttpResponse(status=200) # HTMX success response + + return redirect('agency_assignment_detail', slug=access_link.assignment.slug) + + # For GET requests, show confirmation page + context = { + 'access_link': access_link, + 'title': 'Reactivate Access Link', + 'message': f'Are you sure you want to reactivate the access link for {access_link.assignment.agency.name}?', + 'cancel_url': reverse('agency_assignment_detail', kwargs={'slug': access_link.assignment.slug}), + } + return render(request, 'recruitment/agency_access_link_confirm.html', context) + + + + + + + + + + + + + + + + + +def api_candidate_detail(request, candidate_id): + """API endpoint to get candidate details for agency portal""" + try: + # Get candidate from session-based agency access + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return JsonResponse({'success': False, 'error': 'Access denied'}) + + # Get current assignment to determine agency + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_id + ) + + agency = current_assignment.agency + + # Get candidate and verify it belongs to this agency + candidate = get_object_or_404(Candidate, id=candidate_id, hiring_agency=agency) + + # Return candidate data + response_data = { + 'success': True, + 'id': candidate.id, + 'first_name': candidate.first_name, + 'last_name': candidate.last_name, + 'email': candidate.email, + 'phone': candidate.phone, + 'address': candidate.address, + } + + return JsonResponse(response_data) + + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 938e150..567e7e1 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -1,7 +1,9 @@ import json +import csv +from datetime import datetime from django.shortcuts import render, get_object_or_404,redirect from django.contrib import messages -from django.http import JsonResponse +from django.http import JsonResponse, HttpResponse from django.db.models.fields.json import KeyTextTransform from recruitment.utils import json_to_markdown_table from django.db.models import Count, Avg, F, FloatField @@ -12,6 +14,7 @@ from . import forms from django.contrib.auth.decorators import login_required import ast from django.template.loader import render_to_string +from django.utils.text import slugify # from .dashboard import get_dashboard_data from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.messages.views import SuccessMessageMixin @@ -478,6 +481,35 @@ def candidate_offer_view(request, slug): return render(request, 'recruitment/candidate_offer_view.html', context) +@login_required +def candidate_hired_view(request, slug): + """View for hired candidates""" + job = get_object_or_404(models.JobPosting, slug=slug) + + # Filter candidates with offer_status = 'Accepted' + candidates = job.hired_candidates + + # Handle search + search_query = request.GET.get('search', '') + if search_query: + candidates = candidates.filter( + Q(first_name__icontains=search_query) | + Q(last_name__icontains=search_query) | + Q(email__icontains=search_query) | + Q(phone__icontains=search_query) + ) + + candidates = candidates.order_by('-created_at') + + context = { + 'job': job, + 'candidates': candidates, + 'search_query': search_query, + 'current_stage': 'Hired', + } + return render(request, 'recruitment/candidate_hired_view.html', context) + + @login_required def update_candidate_status(request, job_slug, candidate_slug, stage_type, status): """Handle exam/interview/offer status updates""" @@ -485,32 +517,23 @@ def update_candidate_status(request, job_slug, candidate_slug, stage_type, statu job = get_object_or_404(models.JobPosting, slug=job_slug) candidate = get_object_or_404(models.Candidate, slug=candidate_slug, job=job) - print(stage_type,status) if request.method == "POST": if stage_type == 'exam': candidate.exam_status = status candidate.exam_date = timezone.now() candidate.save(update_fields=['exam_status', 'exam_date']) + return render(request,'recruitment/partials/exam-results.html',{'candidate':candidate,'job':job}) elif stage_type == 'interview': candidate.interview_status = status candidate.interview_date = timezone.now() candidate.save(update_fields=['interview_status', 'interview_date']) + return render(request,'recruitment/partials/interview-results.html',{'candidate':candidate,'job':job}) elif stage_type == 'offer': candidate.offer_status = status candidate.offer_date = timezone.now() candidate.save(update_fields=['offer_status', 'offer_date']) - messages.success(request, f"Candidate {status} successfully!") - else: - messages.error(request, "No changes made.") - - if stage_type == 'exam': - return redirect('candidate_exam_view', job.slug) - elif stage_type == 'interview': - return redirect('candidate_interview_view', job.slug) - elif stage_type == 'offer': - return redirect('candidate_offer_view', job.slug) - + return render(request,'recruitment/partials/offer-results.html',{'candidate':candidate,'job':job}) return redirect('candidate_detail', candidate.slug) else: if stage_type == 'exam': @@ -521,10 +544,332 @@ def update_candidate_status(request, job_slug, candidate_slug, stage_type, statu return render(request,"includes/candidate_update_offer_form.html",{'candidate':candidate,'job':job}) -# Removed incorrect JobDetailView class. +# Stage configuration for CSV export +STAGE_CONFIG = { + 'screening': { + 'filter': {'stage': 'Applied'}, + 'fields': ['name', 'email', 'phone', 'created_at', 'stage', 'ai_score', 'years_experience', 'screening_rating', 'professional_category', 'top_skills', 'strengths', 'weaknesses'], + 'headers': ['Name', 'Email', 'Phone', 'Application Date', 'Screening Status', 'Match Score', 'Years Experience', 'Screening Rating', 'Professional Category', 'Top 3 Skills', 'Strengths', 'Weaknesses'] + }, + 'exam': { + 'filter': {'stage': 'Exam'}, + 'fields': ['name', 'email', 'phone', 'created_at', 'exam_status', 'exam_date', 'ai_score', 'years_experience', 'screening_rating'], + 'headers': ['Name', 'Email', 'Phone', 'Application Date', 'Exam Status', 'Exam Date', 'Match Score', 'Years Experience', 'Screening Rating'] + }, + 'interview': { + 'filter': {'stage': 'Interview'}, + 'fields': ['name', 'email', 'phone', 'created_at', 'interview_status', 'interview_date', 'ai_score', 'years_experience', 'professional_category', 'top_skills'], + 'headers': ['Name', 'Email', 'Phone', 'Application Date', 'Interview Status', 'Interview Date', 'Match Score', 'Years Experience', 'Professional Category', 'Top 3 Skills'] + }, + 'offer': { + 'filter': {'stage': 'Offer'}, + 'fields': ['name', 'email', 'phone', 'created_at', 'offer_status', 'offer_date', 'ai_score', 'years_experience', 'professional_category'], + 'headers': ['Name', 'Email', 'Phone', 'Application Date', 'Offer Status', 'Offer Date', 'Match Score', 'Years Experience', 'Professional Category'] + }, + 'hired': { + 'filter': {'offer_status': 'Accepted'}, + 'fields': ['name', 'email', 'phone', 'created_at', 'offer_date', 'ai_score', 'years_experience', 'professional_category', 'join_date'], + 'headers': ['Name', 'Email', 'Phone', 'Application Date', 'Hire Date', 'Match Score', 'Years Experience', 'Professional Category', 'Join Date'] + } +} + + +@login_required +def export_candidates_csv(request, job_slug, stage): + """Export candidates for a specific stage as CSV""" + job = get_object_or_404(models.JobPosting, slug=job_slug) + + # Validate stage + if stage not in STAGE_CONFIG: + messages.error(request, "Invalid stage specified for export.") + return redirect('job_detail', job.slug) + + config = STAGE_CONFIG[stage] + + # Filter candidates based on stage + if stage == 'hired': + candidates = job.candidates.filter(**config['filter']) + else: + candidates = job.candidates.filter(**config['filter']) + + # Handle search if provided + search_query = request.GET.get('search', '') + if search_query: + candidates = candidates.filter( + Q(first_name__icontains=search_query) | + Q(last_name__icontains=search_query) | + Q(email__icontains=search_query) | + Q(phone__icontains=search_query) + ) + + candidates = candidates.order_by('-created_at') + + # Create CSV response + response = HttpResponse(content_type='text/csv') + filename = f"{slugify(job.title)}_{stage}_{datetime.now().strftime('%Y-%m-%d')}.csv" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + + # Write UTF-8 BOM for Excel compatibility + response.write('\ufeff') + + writer = csv.writer(response) + + # Write headers + headers = config['headers'].copy() + headers.extend(['Job Title', 'Department']) + writer.writerow(headers) + + # Write candidate data + for candidate in candidates: + row = [] + + # Extract data based on stage configuration + for field in config['fields']: + if field == 'name': + row.append(candidate.name) + elif field == 'email': + row.append(candidate.email) + elif field == 'phone': + row.append(candidate.phone) + elif field == 'created_at': + row.append(candidate.created_at.strftime('%Y-%m-%d %H:%M') if candidate.created_at else '') + elif field == 'stage': + row.append(candidate.stage or '') + elif field == 'exam_status': + row.append(candidate.exam_status or '') + elif field == 'exam_date': + row.append(candidate.exam_date.strftime('%Y-%m-%d %H:%M') if candidate.exam_date else '') + elif field == 'interview_status': + row.append(candidate.interview_status or '') + elif field == 'interview_date': + row.append(candidate.interview_date.strftime('%Y-%m-%d %H:%M') if candidate.interview_date else '') + elif field == 'offer_status': + row.append(candidate.offer_status or '') + elif field == 'offer_date': + row.append(candidate.offer_date.strftime('%Y-%m-%d %H:%M') if candidate.offer_date else '') + elif field == 'ai_score': + # Extract AI score using model property + try: + score = candidate.match_score + row.append(f"{score}%" if score else '') + except: + row.append('') + elif field == 'years_experience': + # Extract years of experience using model property + try: + years = candidate.years_of_experience + row.append(f"{years}" if years else '') + except: + row.append('') + elif field == 'screening_rating': + # Extract screening rating using model property + try: + rating = candidate.screening_stage_rating + row.append(rating if rating else '') + except: + row.append('') + elif field == 'professional_category': + # Extract professional category using model property + try: + category = candidate.professional_category + row.append(category if category else '') + except: + row.append('') + elif field == 'top_skills': + # Extract top 3 skills using model property + try: + skills = candidate.top_3_keywords + row.append(', '.join(skills) if skills else '') + except: + row.append('') + elif field == 'strengths': + # Extract strengths using model property + try: + strengths = candidate.strengths + row.append(strengths if strengths else '') + except: + row.append('') + elif field == 'weaknesses': + # Extract weaknesses using model property + try: + weaknesses = candidate.weaknesses + row.append(weaknesses if weaknesses else '') + except: + row.append('') + elif field == 'join_date': + row.append(candidate.join_date.strftime('%Y-%m-%d') if candidate.join_date else '') + else: + row.append(getattr(candidate, field, '')) + + # Add job information + row.extend([job.title, job.department or '']) + + writer.writerow(row) + + return response + + +# Removed incorrect # The job_detail view is handled by function-based view in recruitment.views +@login_required +def sync_hired_candidates(request, job_slug): + """Sync hired candidates to external sources using Django-Q""" + from django_q.tasks import async_task + from .tasks import sync_hired_candidates_task + + if request.method == 'POST': + job = get_object_or_404(models.JobPosting, slug=job_slug) + + try: + # Enqueue sync task to Django-Q for background processing + task_id = async_task( + sync_hired_candidates_task, + job_slug, + group=f"sync_job_{job_slug}", + timeout=300 # 5 minutes timeout + ) + print("task_id",task_id) + # Return immediate response with task ID for tracking + return JsonResponse({ + 'status': 'queued', + 'message': 'Sync task has been queued for background processing', + 'task_id': task_id + }) + + except Exception as e: + return JsonResponse({ + 'status': 'error', + 'message': f'Failed to queue sync task: {str(e)}' + }, status=500) + + # For GET requests, return error + return JsonResponse({ + 'status': 'error', + 'message': 'Only POST requests are allowed' + }, status=405) + + +@login_required +def test_source_connection(request, source_id): + """Test connection to an external source""" + from .candidate_sync_service import CandidateSyncService + + if request.method == 'POST': + source = get_object_or_404(models.Source, id=source_id) + + try: + # Initialize sync service + sync_service = CandidateSyncService() + + # Test connection + result = sync_service.test_source_connection(source) + + # Return JSON response + return JsonResponse({ + 'status': 'success', + 'result': result + }) + + except Exception as e: + return JsonResponse({ + 'status': 'error', + 'message': f'Connection test failed: {str(e)}' + }, status=500) + + # For GET requests, return error + return JsonResponse({ + 'status': 'error', + 'message': 'Only POST requests are allowed' + }, status=405) + + +@login_required +def sync_task_status(request, task_id): + """Check the status of a sync task""" + from django_q.models import Task + + try: + # Get the task from Django-Q + task = Task.objects.get(pk=task_id) + print("task",task) + + # Determine status based on task state + if task.success: + status = 'completed' + message = 'Sync completed successfully' + result = task.result + elif task.stopped: + status = 'failed' + message = 'Sync task failed or was stopped' + result = task.result + elif task.started: + status = 'running' + message = 'Sync is currently running' + result = None + else: + status = 'pending' + message = 'Sync task is queued and waiting to start' + result = None + print("result",result) + return JsonResponse({ + 'status': status, + 'message': message, + 'result': result, + 'task_id': task_id, + 'started': task.started, + 'stopped': task.stopped, + 'success': task.success + }) + + except Task.DoesNotExist: + return JsonResponse({ + 'status': 'error', + 'message': 'Task not found' + }, status=404) + + except Exception as e: + return JsonResponse({ + 'status': 'error', + 'message': f'Failed to check task status: {str(e)}' + }, status=500) + + +@login_required +def sync_history(request, job_slug=None): + """View sync history and logs""" + from .models import IntegrationLog + from django_q.models import Task + + # Get sync logs + if job_slug: + # Filter for specific job + job = get_object_or_404(models.JobPosting, slug=job_slug) + logs = IntegrationLog.objects.filter( + action=IntegrationLog.ActionChoices.SYNC, + request_data__job_slug=job_slug + ).order_by('-created_at') + else: + # Get all sync logs + logs = IntegrationLog.objects.filter( + action=IntegrationLog.ActionChoices.SYNC + ).order_by('-created_at') + + # Get recent sync tasks + recent_tasks = Task.objects.filter( + group__startswith='sync_job_' + ).order_by('-started')[:20] + + context = { + 'logs': logs, + 'recent_tasks': recent_tasks, + 'job': job if job_slug else None, + } + + return render(request, 'recruitment/sync_history.html', context) + + #participants views class ParticipantsListView(LoginRequiredMixin, ListView): model = models.Participants diff --git a/templates/admin/sync_dashboard.html b/templates/admin/sync_dashboard.html new file mode 100644 index 0000000..7075dd2 --- /dev/null +++ b/templates/admin/sync_dashboard.html @@ -0,0 +1,297 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block title %}{{ title }} - {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} + +{% block content %} +
+
+

{{ title }}

+ + +
+ +
+ + +
+
+ + +
+
+
{{ total_tasks }}
+
Total Sync Tasks
+
+ +
+
{{ successful_tasks }}
+
Successful Tasks
+
+ +
+
{{ failed_tasks }}
+
Failed Tasks
+
+ +
+
{{ pending_tasks }}
+
Pending Tasks
+
+
+ + +
+

Success Rates

+
+
+

Overall Success Rate

+
+ {{ success_rate|floatformat:1 }}% +
+
+
+

Last 24 Hours

+
+ {{ last_24h_success_rate|floatformat:1 }}% +
+
+
+
+ + +
+

Recent Sync Tasks

+ {% for task in recent_tasks %} +
+
+
{{ task.name }}
+
+ {% if task.started %} + Started: {{ task.started|date:"Y-m-d H:i:s" }} + {% endif %} + {% if task.stopped %} + โ€ข Duration: {{ task.time_taken|floatformat:2 }}s + {% endif %} + {% if task.group %} + โ€ข Group: {{ task.group }} + {% endif %} +
+
+
+ {% if task.success %}Success{% elif task.stopped %}Failed{% else %}Pending{% endif %} +
+
+ {% empty %} +
+
+
No sync tasks found
+
Sync tasks will appear here once they are executed.
+
+
+ {% endfor %} +
+ + + +
+
+ + +{% endblock %} diff --git a/templates/agency_base.html b/templates/agency_base.html new file mode 100644 index 0000000..e5f2451 --- /dev/null +++ b/templates/agency_base.html @@ -0,0 +1,203 @@ +{% load static i18n %} +{% get_current_language as LANGUAGE_CODE %} + + + + + + + + {% block title %}{% trans 'KAAUH Agency Portal' %}{% endblock %} + + {% comment %} Load correct Bootstrap CSS file for RTL/LTR {% endcomment %} + {% if LANGUAGE_CODE == 'ar' %} + + {% else %} + + {% endif %} + + + + + + {% block customCSS %}{% endblock %} + + + + {% comment %}
+
+
+
+
+
+
+
+ {% trans 'Saudi Vision 2030' %} + +
+
+
ุฌุงู…ุนุฉ ุงู„ุฃู…ูŠุฑุฉ ู†ูˆุฑุฉ ุจู†ุช ุนุจุฏุงู„ุฑุญู…ู† ุงู„ุฃูƒุงุฏูŠู…ูŠุฉ
+
ูˆู…ุณุชุดูู‰ ุงู„ู…ู„ูƒ ุนุจุฏุงู„ู„ู‡ ุจู† ุนุจุฏุงู„ุฑุญู…ู† ุงู„ุชุฎุตุตูŠ
+
Princess Nourah bint Abdulrahman University
+
King Abdullah bin Abdulaziz University Hospital
+
+
+ KAAUH Logo +
+
+
+
{% endcomment %} + + + + +
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% block content %} + {% endblock %} +
+ +
+ +
+ + {% include 'includes/delete_modal.html' %} + + + + + + + + {% block customJS %}{% endblock %} + + diff --git a/templates/base.html b/templates/base.html index 41da997..d7ff8bc 100644 --- a/templates/base.html +++ b/templates/base.html @@ -9,7 +9,7 @@ {% block title %}{% trans 'University ATS' %}{% endblock %} - {% comment %} Load the correct Bootstrap CSS file for RTL/LTR {% endcomment %} + {% comment %} Load correct Bootstrap CSS file for RTL/LTR {% endcomment %} {% if LANGUAGE_CODE == 'ar' %} {% else %} @@ -99,6 +99,30 @@