update status and add export

This commit is contained in:
ismail 2025-10-26 16:23:39 +03:00
parent e3df7fd698
commit 91e00a8cd3
34 changed files with 2903 additions and 116 deletions

View File

@ -2,6 +2,7 @@
from recruitment import views
from django.conf import settings
from django.contrib import admin
from recruitment.admin_sync import sync_admin_site
from django.urls import path, include
from django.conf.urls.static import static
from django.views.generic import RedirectView
@ -15,6 +16,7 @@ router.register(r'candidates', views.CandidateViewSet)
# 1. URLs that DO NOT have a language prefix (admin, API, static files)
urlpatterns = [
path('admin/', admin.site.urls),
path('sync-admin/', sync_admin_site.urls),
path('api/', include(router.urls)),
path('accounts/', include('allauth.urls')),

View File

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

342
recruitment/admin_sync.py Normal file
View File

@ -0,0 +1,342 @@
"""
Admin interface for sync management
"""
from django.contrib import admin
from django_q.models import Task, Schedule
from django.utils.html import format_html
from django.urls import reverse
from django.utils.safestring import mark_safe
import json
class SyncTaskAdmin(admin.ModelAdmin):
"""Admin interface for monitoring sync tasks"""
list_display = [
'id', 'task_name', 'task_status', 'started_display',
'stopped_display', 'result_display', 'actions_display'
]
list_filter = ['success', 'stopped', 'group']
search_fields = ['name', 'func', 'group']
readonly_fields = [
'id', 'name', 'func', 'args', 'kwargs', 'started', 'stopped',
'result', 'success', 'group', 'attempt_count', 'retries',
'time_taken', 'stopped_early'
]
def task_name(self, obj):
"""Display task name with group if available"""
if obj.group:
return f"{obj.name} ({obj.group})"
return obj.name
task_name.short_description = 'Task Name'
def task_status(self, obj):
"""Display task status with color coding"""
if obj.success:
color = 'green'
status = 'SUCCESS'
elif obj.stopped:
color = 'red'
status = 'FAILED'
else:
color = 'orange'
status = 'PENDING'
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
color, status
)
task_status.short_description = 'Status'
def started_display(self, obj):
"""Format started time"""
if obj.started:
return obj.started.strftime('%Y-%m-%d %H:%M:%S')
return '--'
started_display.short_description = 'Started'
def stopped_display(self, obj):
"""Format stopped time"""
if obj.stopped:
return obj.stopped.strftime('%Y-%m-%d %H:%M:%S')
return '--'
stopped_display.short_description = 'Stopped'
def result_display(self, obj):
"""Display result summary"""
if not obj.result:
return '--'
try:
result = json.loads(obj.result) if isinstance(obj.result, str) else obj.result
if isinstance(result, dict):
if 'summary' in result:
summary = result['summary']
return format_html(
"Sources: {}, Success: {}, Failed: {}",
summary.get('total_sources', 0),
summary.get('successful', 0),
summary.get('failed', 0)
)
elif 'error' in result:
return format_html(
'<span style="color: red;">Error: {}</span>',
result['error'][:100]
)
return str(result)[:100] + '...' if len(str(result)) > 100 else str(result)
except (json.JSONDecodeError, TypeError):
return str(obj.result)[:100] + '...' if len(str(obj.result)) > 100 else str(obj.result)
result_display.short_description = 'Result Summary'
def actions_display(self, obj):
"""Display action buttons"""
actions = []
if obj.group:
# Link to view all tasks in this group
url = reverse('admin:django_q_task_changelist') + f'?group__exact={obj.group}'
actions.append(
f'<a href="{url}" class="button">View Group</a>'
)
return mark_safe(' '.join(actions))
actions_display.short_description = 'Actions'
def has_add_permission(self, request):
"""Disable adding tasks through admin"""
return False
def has_change_permission(self, request, obj=None):
"""Disable editing tasks through admin"""
return False
def has_delete_permission(self, request, obj=None):
"""Allow deleting tasks"""
return True
class SyncScheduleAdmin(admin.ModelAdmin):
"""Admin interface for managing scheduled sync tasks"""
list_display = [
'name', 'func', 'schedule_type', 'next_run_display',
'repeats_display', 'enabled_display'
]
list_filter = ['repeats', 'schedule_type', 'enabled']
search_fields = ['name', 'func']
readonly_fields = ['last_run', 'next_run']
fieldsets = (
('Basic Information', {
'fields': ('name', 'func', 'enabled')
}),
('Schedule Configuration', {
'fields': (
'schedule_type', 'repeats', 'cron', 'next_run',
'minutes', 'hours', 'days', 'weeks'
)
}),
('Task Arguments', {
'fields': ('args', 'kwargs'),
'classes': ('collapse',)
}),
('Runtime Information', {
'fields': ('last_run', 'next_run'),
'classes': ('collapse',)
})
)
def schedule_type_display(self, obj):
"""Display schedule type with icon"""
icons = {
'O': '🕐', # Once
'I': '🔄', # Interval
'C': '📅', # Cron
'D': '📆', # Daily
'W': '📋', # Weekly
'M': '📊', # Monthly
'Y': '📈', # Yearly
'H': '', # Hourly
'Q': '📈', # Quarterly
}
icon = icons.get(obj.schedule_type, '')
type_names = {
'O': 'Once',
'I': 'Interval',
'C': 'Cron',
'D': 'Daily',
'W': 'Weekly',
'M': 'Monthly',
'Y': 'Yearly',
'H': 'Hourly',
'Q': 'Quarterly',
}
name = type_names.get(obj.schedule_type, obj.schedule_type)
return format_html('{} {}', icon, name)
schedule_type_display.short_description = 'Schedule Type'
def next_run_display(self, obj):
"""Format next run time"""
if obj.next_run:
return obj.next_run.strftime('%Y-%m-%d %H:%M:%S')
return '--'
next_run_display.short_description = 'Next Run'
def repeats_display(self, obj):
"""Display repeat count"""
if obj.repeats == -1:
return '∞ (Forever)'
return str(obj.repeats)
repeats_display.short_description = 'Repeats'
def enabled_display(self, obj):
"""Display enabled status with color"""
if obj.enabled:
return format_html(
'<span style="color: green; font-weight: bold;">✓ Enabled</span>'
)
else:
return format_html(
'<span style="color: red; font-weight: bold;">✗ Disabled</span>'
)
enabled_display.short_description = 'Status'
# Custom admin site for sync management
class SyncAdminSite(admin.AdminSite):
"""Custom admin site for sync management"""
site_header = 'ATS Sync Management'
site_title = 'Sync Management'
index_title = 'Sync Task Management'
def get_urls(self):
"""Add custom URLs for sync management"""
from django.urls import path
from django.shortcuts import render
from django.http import JsonResponse
from recruitment.candidate_sync_service import CandidateSyncService
urls = super().get_urls()
custom_urls = [
path('sync-dashboard/', self.admin_view(self.sync_dashboard), name='sync_dashboard'),
path('api/sync-stats/', self.admin_view(self.sync_stats), name='sync_stats'),
]
return custom_urls + urls
def sync_dashboard(self, request):
"""Custom sync dashboard view"""
from django_q.models import Task
from django.db.models import Count, Q
from django.utils import timezone
from datetime import timedelta
# Get sync statistics
now = timezone.now()
last_24h = now - timedelta(hours=24)
last_7d = now - timedelta(days=7)
# Task counts
total_tasks = Task.objects.filter(func__contains='sync_hired_candidates').count()
successful_tasks = Task.objects.filter(
func__contains='sync_hired_candidates',
success=True
).count()
failed_tasks = Task.objects.filter(
func__contains='sync_hired_candidates',
success=False,
stopped__isnull=False
).count()
pending_tasks = Task.objects.filter(
func__contains='sync_hired_candidates',
success=False,
stopped__isnull=True
).count()
# Recent activity
recent_tasks = Task.objects.filter(
func__contains='sync_hired_candidates'
).order_by('-started')[:10]
# Success rate over time
last_24h_tasks = Task.objects.filter(
func__contains='sync_hired_candidates',
started__gte=last_24h
)
last_24h_success = last_24h_tasks.filter(success=True).count()
last_7d_tasks = Task.objects.filter(
func__contains='sync_hired_candidates',
started__gte=last_7d
)
last_7d_success = last_7d_tasks.filter(success=True).count()
context = {
**self.each_context(request),
'title': 'Sync Dashboard',
'total_tasks': total_tasks,
'successful_tasks': successful_tasks,
'failed_tasks': failed_tasks,
'pending_tasks': pending_tasks,
'success_rate': (successful_tasks / total_tasks * 100) if total_tasks > 0 else 0,
'last_24h_success_rate': (last_24h_success / last_24h_tasks.count() * 100) if last_24h_tasks.count() > 0 else 0,
'last_7d_success_rate': (last_7d_success / last_7d_tasks.count() * 100) if last_7d_tasks.count() > 0 else 0,
'recent_tasks': recent_tasks,
}
return render(request, 'admin/sync_dashboard.html', context)
def sync_stats(self, request):
"""API endpoint for sync statistics"""
from django_q.models import Task
from django.utils import timezone
from datetime import timedelta
now = timezone.now()
last_24h = now - timedelta(hours=24)
stats = {
'total_tasks': Task.objects.filter(func__contains='sync_hired_candidates').count(),
'successful_24h': Task.objects.filter(
func__contains='sync_hired_candidates',
success=True,
started__gte=last_24h
).count(),
'failed_24h': Task.objects.filter(
func__contains='sync_hired_candidates',
success=False,
stopped__gte=last_24h
).count(),
'pending_tasks': Task.objects.filter(
func__contains='sync_hired_candidates',
success=False,
stopped__isnull=True
).count(),
}
return JsonResponse(stats)
# Create custom admin site
sync_admin_site = SyncAdminSite(name='sync_admin')
# Register models with custom admin site
sync_admin_site.register(Task, SyncTaskAdmin)
sync_admin_site.register(Schedule, SyncScheduleAdmin)
# Also register with default admin site for access
admin.site.register(Task, SyncTaskAdmin)
admin.site.register(Schedule, SyncScheduleAdmin)

View File

@ -0,0 +1,362 @@
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.candidates.filter(
offer_status='Accepted'
).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

View File

@ -1,4 +1,4 @@
# Generated by Django 5.2.7 on 2025-10-23 14:08
# Generated by Django 5.2.4 on 2025-10-25 14:57
import django.core.validators
import django.db.models.deletion
@ -221,7 +221,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')),
@ -239,7 +238,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])),
@ -387,6 +386,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=[
@ -474,4 +494,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'),
),
]

View File

@ -92,7 +92,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"
)
@ -193,29 +194,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)
@ -315,6 +316,9 @@ 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")
# counts
@property
@ -437,7 +441,8 @@ class Candidate(Base):
verbose_name="AI Analysis Data",
default=dict,
help_text="Full JSON output from the resume scoring model."
)
)# {'resume_data': {}, 'analysis_data': {}}
# Scoring fields (populated by signal)
# match_score = models.IntegerField(db_index=True, null=True, blank=True) # Added index
# strengths = models.TextField(blank=True)
@ -1095,6 +1100,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
@ -1277,3 +1323,77 @@ class ScheduledInterview(Base):
models.Index(fields=['interview_date', 'interview_time']),
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'])

View File

@ -560,3 +560,145 @@ def form_close(job_id):
job.is_active = False
job.template_form.is_active = False
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()
# Perform the sync operation
results = sync_service.sync_hired_candidates_to_all_sources(job)
# 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}

View File

@ -70,8 +70,17 @@ urlpatterns = [
path('jobs/<slug:slug>/candidate_exam_view/', views.candidate_exam_view, name='candidate_exam_view'),
path('jobs/<slug:slug>/candidate_interview_view/', views.candidate_interview_view, name='candidate_interview_view'),
path('jobs/<slug:slug>/candidate_offer_view/', views_frontend.candidate_offer_view, name='candidate_offer_view'),
path('jobs/<slug:slug>/candidate_hired_view/', views_frontend.candidate_hired_view, name='candidate_hired_view'),
path('jobs/<slug:job_slug>/export/<str:stage>/csv/', views_frontend.export_candidates_csv, name='export_candidates_csv'),
path('jobs/<slug:job_slug>/candidates/<slug:candidate_slug>/update_status/<str:stage_type>/<str:status>/', views_frontend.update_candidate_status, name='update_candidate_status'),
# Sync URLs
path('jobs/<slug:job_slug>/sync-hired-candidates/', views_frontend.sync_hired_candidates, name='sync_hired_candidates'),
path('sources/<int:source_id>/test-connection/', views_frontend.test_source_connection, name='test_source_connection'),
path('sync/task/<uuid:task_id>/status/', views_frontend.sync_task_status, name='sync_task_status'),
path('sync/history/', views_frontend.sync_history, name='sync_history'),
path('sync/history/<slug:job_slug>/', views_frontend.sync_history, name='sync_history_job'),
path('jobs/<slug:slug>/<int:candidate_id>/reschedule_meeting_for_candidate/<int:meeting_id>/', views.reschedule_meeting_for_candidate, name='reschedule_meeting_for_candidate'),
path('jobs/<slug:slug>/update_candidate_exam_status/', views.update_candidate_exam_status, name='update_candidate_exam_status'),

View File

@ -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
@ -469,6 +472,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.candidates.filter(offer_status='Accepted')
# 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"""
@ -476,32 +508,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':
@ -512,5 +535,326 @@ 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
)
# 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(id=task_id)
# 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
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)

View File

@ -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 }}
<style>
.sync-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-number {
font-size: 2rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.stat-label {
color: #666;
font-size: 0.9rem;
}
.stat-card.success { border-left: 4px solid #28a745; }
.stat-card.danger { border-left: 4px solid #dc3545; }
.stat-card.warning { border-left: 4px solid #ffc107; }
.stat-card.info { border-left: 4px solid #17a2b8; }
.recent-tasks {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.recent-tasks h3 {
margin: 0;
padding: 1rem;
background: #f8f9fa;
border-bottom: 1px solid #ddd;
}
.task-item {
padding: 1rem;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.task-item:last-child {
border-bottom: none;
}
.task-info {
flex: 1;
}
.task-name {
font-weight: bold;
margin-bottom: 0.25rem;
}
.task-meta {
font-size: 0.85rem;
color: #666;
}
.task-status {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: bold;
text-transform: uppercase;
}
.task-status.success {
background: #d4edda;
color: #155724;
}
.task-status.failed {
background: #f8d7da;
color: #721c24;
}
.task-status.pending {
background: #fff3cd;
color: #856404;
}
.refresh-btn {
background: #007cba;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.refresh-btn:hover {
background: #005a87;
}
.auto-refresh {
margin-left: 1rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.chart-container {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
</style>
{% endblock %}
{% block content %}
<div id="content-main">
<div class="module">
<h1>{{ title }}</h1>
<!-- Auto-refresh controls -->
<div style="margin-bottom: 1rem;">
<button class="refresh-btn" onclick="refreshStats()">
<i class="fas fa-sync-alt"></i> Refresh Now
</button>
<div class="auto-refresh">
<input type="checkbox" id="autoRefresh" checked>
<label for="autoRefresh">Auto-refresh every 30 seconds</label>
</div>
</div>
<!-- Statistics Grid -->
<div class="sync-stats-grid">
<div class="stat-card info">
<div class="stat-number">{{ total_tasks }}</div>
<div class="stat-label">Total Sync Tasks</div>
</div>
<div class="stat-card success">
<div class="stat-number">{{ successful_tasks }}</div>
<div class="stat-label">Successful Tasks</div>
</div>
<div class="stat-card danger">
<div class="stat-number">{{ failed_tasks }}</div>
<div class="stat-label">Failed Tasks</div>
</div>
<div class="stat-card warning">
<div class="stat-number">{{ pending_tasks }}</div>
<div class="stat-label">Pending Tasks</div>
</div>
</div>
<!-- Success Rate Charts -->
<div class="chart-container">
<h3>Success Rates</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
<div>
<h4>Overall Success Rate</h4>
<div style="font-size: 2rem; font-weight: bold; color: {% if success_rate > 80 %}#28a745{% elif success_rate > 60 %}#ffc107{% else %}#dc3545{% endif %};">
{{ success_rate|floatformat:1 }}%
</div>
</div>
<div>
<h4>Last 24 Hours</h4>
<div style="font-size: 2rem; font-weight: bold; color: {% if last_24h_success_rate > 80 %}#28a745{% elif last_24h_success_rate > 60 %}#ffc107{% else %}#dc3545{% endif %};">
{{ last_24h_success_rate|floatformat:1 }}%
</div>
</div>
</div>
</div>
<!-- Recent Tasks -->
<div class="recent-tasks">
<h3>Recent Sync Tasks</h3>
{% for task in recent_tasks %}
<div class="task-item">
<div class="task-info">
<div class="task-name">{{ task.name }}</div>
<div class="task-meta">
{% 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 %}
</div>
</div>
<div class="task-status {% if task.success %}success{% elif task.stopped %}failed{% else %}pending{% endif %}">
{% if task.success %}Success{% elif task.stopped %}Failed{% else %}Pending{% endif %}
</div>
</div>
{% empty %}
<div class="task-item">
<div class="task-info">
<div class="task-name">No sync tasks found</div>
<div class="task-meta">Sync tasks will appear here once they are executed.</div>
</div>
</div>
{% endfor %}
</div>
<!-- Quick Actions -->
<div style="margin-top: 2rem;">
<h3>Quick Actions</h3>
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<a href="{% url 'admin:django_q_task_changelist' %}" class="button">
View All Tasks
</a>
<a href="{% url 'admin:django_q_schedule_changelist' %}" class="button">
Manage Schedules
</a>
<a href="/recruitment/sources/" class="button">
Configure Sources
</a>
</div>
</div>
</div>
</div>
<script>
let refreshInterval;
function refreshStats() {
fetch('/admin/api/sync-stats/')
.then(response => response.json())
.then(data => {
// Update statistics
document.querySelector('.stat-card.info .stat-number').textContent = data.total_tasks;
document.querySelector('.stat-card.success .stat-number').textContent = data.successful_24h;
document.querySelector('.stat-card.danger .stat-number').textContent = data.failed_24h;
document.querySelector('.stat-card.warning .stat-number').textContent = data.pending_tasks;
// Show refresh indicator
const btn = document.querySelector('.refresh-btn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i> Refreshed!';
btn.style.background = '#28a745';
setTimeout(() => {
btn.innerHTML = originalText;
btn.style.background = '#007cba';
}, 2000);
})
.catch(error => {
console.error('Error refreshing stats:', error);
});
}
function toggleAutoRefresh() {
const checkbox = document.getElementById('autoRefresh');
if (checkbox.checked) {
// Start auto-refresh
refreshInterval = setInterval(refreshStats, 30000); // 30 seconds
} else {
// Stop auto-refresh
if (refreshInterval) {
clearInterval(refreshInterval);
}
}
}
// Initialize auto-refresh
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('autoRefresh').addEventListener('change', toggleAutoRefresh);
// Start auto-refresh if checked
if (document.getElementById('autoRefresh').checked) {
refreshInterval = setInterval(refreshStats, 30000);
}
});
</script>
{% endblock %}

View File

@ -290,6 +290,7 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Navbar collapse auto-close on link click (Standard Mobile UX)

View File

@ -1,5 +1,5 @@
{% load i18n %}
<div class="d-flex justify-content-center align-items-center gap-2" hx-swap='outerHTML' hx-select=".table-responsive" hx-target=".table-responsive"
<div class="d-flex justify-content-center align-items-center gap-2" hx-swap='outerHTML' hx-target="#status-result-{{ candidate.pk }}"
hx-on::after-request="const modal = bootstrap.Modal.getInstance(document.getElementById('candidateviewModal')); if (modal) { modal.hide(); }">
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'Passed' %}" class="btn btn-outline-secondary">
<i class="fas fa-check me-1"></i> {% trans "Passed" %}

View File

@ -1,5 +1,5 @@
{% load i18n %}
<div class="d-flex justify-content-center align-items-center gap-2" hx-swap='outerHTML' hx-select=".table-responsive" hx-target=".table-responsive"
<div class="d-flex justify-content-center align-items-center gap-2" hx-swap='outerHTML' hx-target="#interview-result-{{ candidate.pk }}"
hx-on::after-request="const modal = bootstrap.Modal.getInstance(document.getElementById('candidateviewModal')); if (modal) { modal.hide(); }">
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'interview' 'Passed' %}" class="btn btn-outline-secondary">
<i class="fas fa-check me-1"></i> {% trans "Passed" %}

View File

@ -1,5 +1,5 @@
{% load i18n %}
<div class="d-flex justify-content-center align-items-center gap-2" hx-swap='outerHTML' hx-select=".table-responsive" hx-target=".table-responsive"
<div class="d-flex justify-content-center align-items-center gap-2" hx-swap='outerHTML' hx-target="#status-result-{{ candidate.pk }}"
hx-on::after-request="const modal = bootstrap.Modal.getInstance(document.getElementById('candidateviewModal')); if (modal) { modal.hide(); }">
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'offer' 'Accepted' %}" class="btn btn-outline-secondary">
<i class="fas fa-check me-1"></i> {% trans "Accepted" %}

View File

@ -141,11 +141,11 @@
</a>
{% comment %} CONNECTOR 3 -> 4 {% endcomment %}
<div class="stage-connector {% if current_stage == 'Offer' %}completed{% endif %}"></div>
<div class="stage-connector {% if current_stage == 'Offer' or current_stage == 'Hired' %}completed{% endif %}"></div>
{% comment %} STAGE 4: Offer {% endcomment %}
<a href="{% url 'candidate_offer_view' job.slug %}"
class="stage-item {% if current_stage == 'Offer' %}active{% endif %} {% if current_stage == 'Interview' %}completed{% endif %}"
class="stage-item {% if current_stage == 'Offer' %}active{% endif %} {% if current_stage == 'Hired' %}completed{% endif %}"
data-stage="Offer">
<div class="stage-icon">
<i class="fas fa-handshake"></i>
@ -153,5 +153,19 @@
<div class="stage-label">{% trans "Offer" %}</div>
<div class="stage-count">{{ job.offer_candidates.count|default:"0" }}</div>
</a>
{% comment %} CONNECTOR 4 -> 5 {% endcomment %}
<div class="stage-connector {% if current_stage == 'Hired' %}completed{% endif %}"></div>
{% comment %} STAGE 5: Hired {% endcomment %}
<a href="{% url 'candidate_hired_view' job.slug %}"
class="stage-item {% if current_stage == 'Hired' %}active{% endif %}"
data-stage="Hired">
<div class="stage-icon">
<i class="fas fa-trophy"></i>
</div>
<div class="stage-label">{% trans "Hired" %}</div>
<div class="stage-count">{{ job.accepted_candidates.count|default:"0" }}</div>
</a>
</div>
</div>

View File

@ -177,9 +177,16 @@
{% trans "Candidates in Exam Stage:" %} <span class="fw-bold">{{ total_candidates }}</span>
</h2>
</div>
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
</a>
<div class="d-flex gap-2">
<a href="{% url 'export_candidates_csv' job.slug 'exam' %}"
class="btn btn-outline-secondary btn-sm"
title="{% trans 'Export exam candidates to CSV' %}">
<i class="fas fa-download me-1"></i> {% trans "Export CSV" %}
</a>
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
</a>
</div>
</div>
<div class="applicant-tracking-timeline mb-4">
@ -277,26 +284,31 @@
{{candidate.exam_date|date:"d-m-Y h:i A"|default:"--"}}
</td>
<td class="text-center">
{% if not candidate.exam_status %}
<button type="button" class="btn btn-warning btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
<i class="fas fa-plus"></i>
</button>
{% else %}
{% if candidate.exam_status == "Passed" %}
<span class="status-badge bg-success">{{ candidate.exam_status }}</span>
{% elif candidate.exam_status == "Failed" %}
<span class="status-badge bg-danger">{{ candidate.exam_status }}</span>
<td class="text-center" id="status-result-{{ candidate.pk}}">
{% if not candidate.exam_status %}
<button type="button" class="btn btn-warning btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
<i class="fas fa-plus"></i>
</button>
{% else %}
--
{% if candidate.exam_status %}
<button type="button" class="btn btn-{% if candidate.exam_status == 'Passed' %}success{% else %}danger{% endif %} btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
{{ candidate.interview_status }}
</button>
{% else %}
--
{% endif %}
{% endif %}
{% endif %}
</td>
</td>
<td >
<button type="button" class="btn btn-outline-secondary btn-sm"

View File

@ -0,0 +1,557 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}- {{ job.title }} - ATS{% endblock %}
{% block customCSS %}
<style>
/* KAAT-S UI Variables */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-success: #28a745;
--kaauh-info: #17a2b8;
--kaauh-danger: #dc3545;
--kaauh-warning: #ffc107;
}
/* Primary Color Overrides */
.text-primary-theme { color: var(--kaauh-teal) !important; }
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
/* 1. Main Container & Card Styling */
.kaauh-card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
background-color: white;
}
/* Dedicated style for the filter block */
.filter-controls {
background-color: #f8f9fa;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 2rem;
border: 1px solid var(--kaauh-border);
}
/* 2. Button Styling (Themed for Main Actions) */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-main-action:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.btn-outline-secondary {
color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal);
}
.btn-outline-secondary:hover {
background-color: var(--kaauh-teal-dark);
color: white;
border-color: var(--kaauh-teal-dark);
}
/* 3. Candidate Table Styling (Aligned with KAAT-S) */
.candidate-table {
table-layout: fixed;
width: 100%;
border-collapse: separate;
border-spacing: 0;
background-color: white;
border-radius: 0.5rem;
overflow: hidden;
}
.candidate-table thead {
background-color: var(--kaauh-border);
}
.candidate-table th {
padding: 0.75rem 1rem;
font-weight: 600;
color: var(--kaauh-teal-dark);
border-bottom: 2px solid var(--kaauh-teal);
font-size: 0.9rem;
vertical-align: middle;
}
.candidate-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--kaauh-border);
vertical-align: middle;
font-size: 0.9rem;
}
.candidate-table tbody tr:hover {
background-color: #f1f3f4;
}
.candidate-table thead th:nth-child(1) { width: 40px; }
.candidate-table thead th:nth-child(4) { width: 10%; }
.candidate-table thead th:nth-child(7) { width: 100px; }
.candidate-name {
font-weight: 600;
color: var(--kaauh-primary-text);
}
.candidate-details {
font-size: 0.8rem;
color: #6c757d;
}
/* 4. Badges and Statuses */
.ai-score-badge {
background-color: var(--kaauh-teal-dark) !important;
color: white;
font-weight: 700;
padding: 0.4em 0.8em;
border-radius: 0.4rem;
}
.status-badge {
font-size: 0.75rem;
padding: 0.3em 0.7em;
border-radius: 0.35rem;
font-weight: 700;
}
.bg-applicant { background-color: #6c757d !important; color: white; }
.bg-candidate { background-color: var(--kaauh-success) !important; color: white; }
/* Stage Badges */
.stage-badge {
font-size: 0.75rem;
padding: 0.25rem 0.6rem;
border-radius: 0.3rem;
font-weight: 600;
display: inline-block;
margin-bottom: 0.2rem;
}
.stage-Applied { background-color: #e9ecef; color: #495057; }
.stage-Screening { background-color: var(--kaauh-info); color: white; }
.stage-Exam { background-color: var(--kaauh-warning); color: #856404; }
.stage-Interview { background-color: #17a2b8; color: white; }
.stage-Offer { background-color: var(--kaauh-success); color: white; }
.stage-Hired { background-color: #28a745; color: white; }
/* Timeline specific container */
.applicant-tracking-timeline {
margin-bottom: 2rem;
}
/* Hired-specific styling */
.hired-badge {
background-color: #28a745 !important;
color: white;
font-weight: 700;
padding: 0.5em 1em;
border-radius: 0.5rem;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.hired-date {
font-size: 0.8rem;
color: #6c757d;
margin-top: 0.25rem;
}
/* Success state styling */
.success-header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: white;
padding: 1.5rem;
border-radius: 0.75rem;
margin-bottom: 2rem;
text-align: center;
}
/* --- CUSTOM HEIGHT OPTIMIZATION (MAKING INPUTS/BUTTONS SMALLER) --- */
.form-control-sm,
.btn-sm {
/* Reduce vertical padding even more than default Bootstrap 'sm' */
padding-top: 0.2rem !important;
padding-bottom: 0.2rem !important;
/* Ensure a consistent, small height for both */
height: 28px !important;
font-size: 0.8rem !important; /* Slightly smaller font */
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-trophy me-2"></i>
{% trans "Hired Candidates" %} - {{ job.title }}
</h1>
<h2 class="h5 text-muted mb-0">
{% trans "Successfully Hired:" %} <span class="fw-bold">{{ candidates|length }}</span>
</h2>
</div>
<div class="d-flex gap-2">
<button type="button"
class="btn btn-main-action btn-sm"
onclick="syncHiredCandidates()"
title="{% trans 'Sync hired candidates to external sources' %}">
<i class="fas fa-sync me-1"></i> {% trans "Sync to Sources" %}
</button>
<a href="{% url 'export_candidates_csv' job.slug 'hired' %}"
class="btn btn-outline-secondary btn-sm"
title="{% trans 'Export hired candidates to CSV' %}">
<i class="fas fa-download me-1"></i> {% trans "Export CSV" %}
</a>
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
</a>
</div>
</div>
<!-- Success Header -->
<div class="success-header">
<i class="fas fa-check-circle fa-3x mb-3"></i>
<h3 class="mb-2">{% trans "Congratulations!" %}</h3>
<p class="mb-0">{% trans "These candidates have successfully completed the hiring process and joined your team." %}</p>
</div>
<div class="applicant-tracking-timeline">
{% include 'jobs/partials/applicant_tracking.html' %}
</div>
<div class="kaauh-card shadow-sm p-3">
<div class="table-responsive">
<table class="table candidate-table align-middle">
<thead>
<tr>
<th style="width: 5%"><i class="fas fa-user me-1"></i> {% trans "Name" %}</th>
<th style="width: 15%"><i class="fas fa-phone me-1"></i> {% trans "Contact" %}</th>
<th style="width: 15%"><i class="fas fa-briefcase me-1"></i> {% trans "Applied Position" %}</th>
<th class="text-center" style="width: 15%"><i class="fas fa-calendar-check me-1"></i> {% trans "Hired Date" %}</th>
<th style="width: 10%"><i class="fas fa-chart-line me-1"></i> {% trans "Match Score" %}</th>
<th style="width: 15%"><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for candidate in candidates %}
<tr>
<td>
<div class="candidate-name">
{{ candidate.name }}
<div class="hired-badge mt-1">
<i class="fas fa-check-circle"></i>
{% trans "Hired" %}
</div>
</div>
</td>
<td>
<div class="candidate-details">
<i class="fas fa-envelope me-1"></i> {{ candidate.email }}<br>
<i class="fas fa-phone me-1"></i> {{ candidate.phone }}
</div>
</td>
<td>
<div class="candidate-details">
<strong>{{ job.title }}</strong><br>
<small class="text-muted">{{ job.department }}</small>
</div>
</td>
<td class="text-center">
<div class="hired-date">
{% if candidate.offer_date %}
<i class="fas fa-calendar me-1"></i>
{{ candidate.offer_date|date:"M d, Y" }}
{% else %}
<span class="text-muted">--</span>
{% endif %}
</div>
</td>
<td class="text-center">
{% if candidate.ai_score %}
<span class="ai-score-badge">{{ candidate.ai_score }}%</span>
{% else %}
<span class="badge bg-secondary">--</span>
{% endif %}
</td>
<td>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'candidate_criteria_view_htmx' candidate.pk %}"
hx-target="#candidateviewModalBody"
title="View Profile">
<i class="fas fa-eye"></i>
</button>
<a href="{% url 'candidate_resume_template' candidate.slug %}"
class="btn btn-outline-primary btn-sm"
title="View Resume Template">
<i class="fas fa-file-alt"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not candidates %}
<div class="alert alert-info text-center mt-3 mb-0" role="alert">
<i class="fas fa-info-circle me-1"></i>
{% trans "No candidates have been hired for this position yet." %}
</div>
{% endif %}
</div>
</div>
</div>
<div class="modal fade modal-xl" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content kaauh-card">
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
<h5 class="modal-title" id="candidateviewModalLabel" style="color: var(--kaauh-teal-dark);">
{% trans "Hired Candidate Details" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div id="candidateviewModalBody" class="modal-body">
<div class="text-center py-5 text-muted">
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
{% trans "Loading content..." %}
</div>
</div>
</div>
</div>
</div>
<!-- Sync Results Modal -->
<div class="modal fade" id="syncResultsModal" tabindex="-1" aria-labelledby="syncResultsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content kaauh-card">
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
<h5 class="modal-title" id="syncResultsModalLabel" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-sync me-2"></i>{% trans "Sync Results" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div id="syncResultsModalBody" class="modal-body">
<div class="text-center py-5 text-muted">
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
{% trans "Syncing candidates..." %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// Add any specific JavaScript for hired candidates view if needed
console.log('Hired candidates view loaded');
});
function syncHiredCandidates() {
const syncButton = document.querySelector('[onclick="syncHiredCandidates()"]');
const modal = new bootstrap.Modal(document.getElementById('syncResultsModal'));
// Show modal with loading state
document.getElementById('syncResultsModalBody').innerHTML = `
<div class="text-center py-5">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<h5>{% trans "Syncing hired candidates..." %}</h5>
<p class="text-muted">{% trans "Please wait while we sync candidates to external sources." %}</p>
</div>
`;
modal.show();
// Disable sync button during sync
syncButton.disabled = true;
syncButton.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i> {% trans "Syncing..." %}';
// Perform sync request
fetch(`{% url 'sync_hired_candidates' job.slug %}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'queued') {
// Task is queued, start polling for status
pollSyncStatus(data.task_id);
} else if (data.status === 'success') {
displaySyncResults(data.results);
} else {
displaySyncError(data.message);
}
})
.catch(error => {
console.error('Sync error:', error);
displaySyncError('{% trans "An unexpected error occurred during sync." %}');
})
.finally(() => {
// Re-enable sync button
syncButton.disabled = false;
syncButton.innerHTML = '<i class="fas fa-sync me-1"></i> {% trans "Sync to Sources" %}';
});
}
function displaySyncResults(results) {
const modalBody = document.getElementById('syncResultsModalBody');
let html = '<div class="sync-results">';
// Summary section
html += `
<div class="alert alert-info mb-4">
<h6 class="alert-heading">{% trans "Sync Summary" %}</h6>
<div class="row">
<div class="col-md-3">
<strong>{% trans "Total Sources:" %}</strong> ${results.summary.total_sources}
</div>
<div class="col-md-3">
<strong>{% trans "Successful:" %}</strong> <span class="text-success">${results.summary.successful}</span>
</div>
<div class="col-md-3">
<strong>{% trans "Failed:" %}</strong> <span class="text-danger">${results.summary.failed}</span>
</div>
<div class="col-md-3">
<strong>{% trans "Candidates Synced:" %}</strong> ${results.summary.total_candidates}
</div>
</div>
</div>
`;
// Detailed results for each source
if (results.sources && results.sources.length > 0) {
html += '<h6 class="mb-3">{% trans "Source Details" %}</h6>';
results.sources.forEach(source => {
const statusClass = source.status === 'success' ? 'success' : 'danger';
const statusIcon = source.status === 'success' ? 'check-circle' : 'exclamation-triangle';
html += `
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>${source.source_name}</strong>
<span class="badge bg-${statusClass}">
<i class="fas fa-${statusIcon} me-1"></i>
${source.status.toUpperCase()}
</span>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<small class="text-muted">{% trans "Candidates Processed:" %}</small>
<div class="fw-bold">${source.candidates_processed}</div>
</div>
<div class="col-md-6">
<small class="text-muted">{% trans "Duration:" %}</small>
<div class="fw-bold">${source.duration}</div>
</div>
</div>
${source.message ? `<div class="mt-2"><small class="text-muted">{% trans "Message:" %}</small><div>${source.message}</div></div>` : ''}
${source.error ? `<div class="mt-2 text-danger"><small>{% trans "Error:" %}</small><div>${source.error}</div></div>` : ''}
</div>
</div>
`;
});
}
html += '</div>';
modalBody.innerHTML = html;
}
function pollSyncStatus(taskId) {
const pollInterval = setInterval(() => {
fetch(`/recruitment/sync/task/${taskId}/status/`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'completed') {
clearInterval(pollInterval);
displaySyncResults(data.result);
} else if (data.status === 'failed') {
clearInterval(pollInterval);
displaySyncError(data.message || '{% trans "Sync task failed" %}');
} else if (data.status === 'running') {
updateSyncProgress(data.message);
}
// For 'pending' status, continue polling
})
.catch(error => {
console.error('Polling error:', error);
clearInterval(pollInterval);
displaySyncError('{% trans "Failed to check sync status" %}');
});
}, 2000); // Poll every 2 seconds
// Set a timeout to stop polling after 5 minutes
setTimeout(() => {
clearInterval(pollInterval);
displaySyncError('{% trans "Sync timed out after 5 minutes" %}');
}, 300000);
}
function updateSyncProgress(message) {
const modalBody = document.getElementById('syncResultsModalBody');
modalBody.innerHTML = `
<div class="text-center py-5">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<h5>{% trans "Sync in progress..." %}</h5>
<p class="text-muted">${message}</p>
</div>
`;
}
function displaySyncError(message) {
const modalBody = document.getElementById('syncResultsModalBody');
modalBody.innerHTML = `
<div class="alert alert-danger text-center">
<i class="fas fa-exclamation-triangle fa-3x mb-3"></i>
<h5>{% trans "Sync Failed" %}</h5>
<p>${message}</p>
</div>
`;
}
// Helper function to get CSRF token
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
</script>
{% endblock %}

View File

@ -178,9 +178,16 @@
{% trans "Candidates in Interview Stage:" %} <span class="fw-bold">{{ candidates|length }}</span>
</h2>
</div>
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
</a>
<div class="d-flex gap-2">
<a href="{% url 'export_candidates_csv' job.slug 'interview' %}"
class="btn btn-outline-secondary btn-sm"
title="{% trans 'Export interview candidates to CSV' %}">
<i class="fas fa-download me-1"></i> {% trans "Export CSV" %}
</a>
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
</a>
</div>
</div>
<div class="applicant-tracking-timeline">
{% include 'jobs/partials/applicant_tracking.html' %}
@ -321,7 +328,7 @@
{% endif %}
{% endwith %}
</td>
<td class="text-center">
<td class="text-center" id="interview-result-{{ candidate.pk }}">
{% if not candidate.interview_status %}
<button type="button" class="btn btn-warning btn-sm"
data-bs-toggle="modal"
@ -374,14 +381,14 @@
</button>
{% else %}
<button type="button" class="btn btn-main-action btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'schedule_meeting_for_candidate' job.slug candidate.pk %}"
hx-target="#candidateviewModalBody"
data-modal-title="{% trans 'Schedule Interview' %}"
title="Schedule Interview">
<i class="fas fa-calendar-plus"></i>
</button>
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'schedule_meeting_for_candidate' job.slug candidate.pk %}"
hx-target="#candidateviewModalBody"
data-modal-title="{% trans 'Schedule Interview' %}"
title="Schedule Interview">
<i class="fas fa-calendar-plus"></i>
</button>
{% endif %}
</td>

View File

@ -179,9 +179,16 @@
{% trans "Candidates in Offer Stage:" %} <span class="fw-bold">{{ candidates|length }}</span>
</h2>
</div>
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
</a>
<div class="d-flex gap-2">
<a href="{% url 'export_candidates_csv' job.slug 'offer' %}"
class="btn btn-outline-secondary btn-sm"
title="{% trans 'Export offer candidates to CSV' %}">
<i class="fas fa-download me-1"></i> {% trans "Export CSV" %}
</a>
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
</a>
</div>
</div>
<div class="applicant-tracking-timeline">
{% include 'jobs/partials/applicant_tracking.html' %}
@ -261,21 +268,26 @@
<i class="fas fa-phone me-1"></i> {{ candidate.phone }}
</div>
</td>
<td class="text-center">
<td class="text-center" id="status-result-{{ candidate.pk}}">
{% if not candidate.offer_status %}
<button type="button" class="btn btn-warning btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'offer' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
<i class="fas fa-plus"></i>
</button>
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'offer' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
<i class="fas fa-plus"></i>
</button>
{% else %}
{% if candidate.offer_status == "Accepted" %}
<span class="status-badge bg-success">{{ candidate.offer_status }}</span>
{% elif candidate.offer_status == "Rejected" %}
<span class="status-badge bg-danger">{{ candidate.offer_status }}</span>
{% if candidate.offer_status %}
<button type="button" class="btn btn-{% if candidate.offer_status == 'Accepted' %}success{% else %}danger{% endif %} btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'offer' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
{{ candidate.offer_status }}
</button>
{% else %}
--
{% endif %}

View File

@ -180,9 +180,16 @@
<span class="badge bg-secondary ms-2 fw-normal">{{ job.internal_job_id }}</span>
</h2>
</div>
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
</a>
<div class="d-flex gap-2">
<a href="{% url 'export_candidates_csv' job.slug 'screening' %}"
class="btn btn-outline-secondary btn-sm"
title="{% trans 'Export screening candidates to CSV' %}">
<i class="fas fa-download me-1"></i> {% trans "Export CSV" %}
</a>
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
</a>
</div>
</div>
<div class="applicant-tracking-timeline mb-4">

View File

@ -0,0 +1,25 @@
<td class="text-center" id="status-result-{{ candidate.pk}}">
{% if not candidate.interview_status %}
<button type="button" class="btn btn-warning btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
<i class="fas fa-plus"></i>
</button>
{% else %}
{% if candidate.exam_status %}
<button type="button" class="btn btn-{% if candidate.exam_status == 'Passed' %}success{% else %}danger{% endif %} btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
{{ candidate.exam_status }}
</button>
{% else %}
--
{% endif %}
{% endif %}
</td>

View File

@ -0,0 +1,25 @@
<td class="text-center" id="status-result-{{ candidate.pk}}">
{% if not candidate.interview_status %}
<button type="button" class="btn btn-warning btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'offer' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
<i class="fas fa-plus"></i>
</button>
{% else %}
{% if candidate.offer_status %}
<button type="button" class="btn btn-{% if candidate.offer_status == 'Passed' %}success{% else %}danger{% endif %} btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'offer' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
{{ candidate.offer_status }}
</button>
{% else %}
--
{% endif %}
{% endif %}
</td>

View File

@ -0,0 +1,25 @@
<td class="text-center" id="status-result-{{ candidate.pk}}">
{% if not candidate.offer_status %}
<button type="button" class="btn btn-warning btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'offer' 'Accepted' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
<i class="fas fa-plus"></i>
</button>
{% else %}
{% if candidate.offer_status %}
<button type="button" class="btn btn-{% if candidate.offer_status == 'Accepted' %}success{% else %}danger{% endif %} btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'offer' 'Rejected' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
{{ candidate.offer_status }}
</button>
{% else %}
--
{% endif %}
{% endif %}
</td>

131
test_csv_export.py Normal file
View File

@ -0,0 +1,131 @@
#!/usr/bin/env python
"""
Test script to verify CSV export functionality with updated JSON structure
"""
import os
import sys
import django
# Setup Django environment
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from recruitment.models import Candidate, JobPosting
from recruitment.views_frontend import export_candidates_csv
from django.test import RequestFactory
from django.contrib.auth.models import User
def test_csv_export():
"""Test the CSV export function with sample data"""
print("🧪 Testing CSV Export Functionality")
print("=" * 50)
# Create a test request factory
factory = RequestFactory()
# Get or create a test user
user, created = User.objects.get_or_create(
username='testuser',
defaults={'email': 'test@example.com', 'is_staff': True}
)
# Get a sample job
job = JobPosting.objects.first()
if not job:
print("❌ No jobs found in database. Please create a job first.")
return False
print(f"📋 Using job: {job.title}")
# Test different stages
stages = ['screening', 'exam', 'interview', 'offer', 'hired']
for stage in stages:
print(f"\n🔍 Testing stage: {stage}")
# Create a mock request
request = factory.get(f'/export/{job.slug}/{stage}/')
request.user = user
request.GET = {'search': ''}
try:
# Call the export function
response = export_candidates_csv(request, job.slug, stage)
# Check if response is successful
if response.status_code == 200:
print(f"{stage} export successful")
# Read and analyze the CSV content
content = response.content.decode('utf-8-sig')
lines = content.split('\n')
if len(lines) > 1:
headers = lines[0].split(',')
print(f"📊 Headers: {len(headers)} columns")
print(f"📊 Data rows: {len(lines) - 1}")
# Check for AI score column
if 'Match Score' in headers:
print("✅ Match Score column found")
else:
print("⚠️ Match Score column not found")
# Check for other AI columns
ai_columns = ['Years Experience', 'Screening Rating', 'Professional Category', 'Top 3 Skills']
found_ai_columns = [col for col in ai_columns if col in headers]
print(f"🤖 AI columns found: {found_ai_columns}")
else:
print("⚠️ No data rows found")
else:
print(f"{stage} export failed with status: {response.status_code}")
except Exception as e:
print(f"{stage} export error: {str(e)}")
import traceback
traceback.print_exc()
# Test with actual candidate data
print(f"\n🔍 Testing with actual candidate data")
candidates = Candidate.objects.filter(job=job)
print(f"📊 Total candidates for job: {candidates.count()}")
if candidates.exists():
# Test AI data extraction for first candidate
candidate = candidates.first()
print(f"\n🧪 Testing AI data extraction for: {candidate.name}")
try:
# Test the model properties
print(f"📊 Match Score: {candidate.match_score}")
print(f"📊 Years Experience: {candidate.years_of_experience}")
print(f"📊 Screening Rating: {candidate.screening_stage_rating}")
print(f"📊 Professional Category: {candidate.professional_category}")
print(f"📊 Top 3 Skills: {candidate.top_3_keywords}")
print(f"📊 Strengths: {candidate.strengths}")
print(f"📊 Weaknesses: {candidate.weaknesses}")
# Test AI analysis data structure
if candidate.ai_analysis_data:
print(f"📊 AI Analysis Data keys: {list(candidate.ai_analysis_data.keys())}")
if 'analysis_data' in candidate.ai_analysis_data:
analysis_keys = list(candidate.ai_analysis_data['analysis_data'].keys())
print(f"📊 Analysis Data keys: {analysis_keys}")
else:
print("⚠️ 'analysis_data' key not found in ai_analysis_data")
else:
print("⚠️ No AI analysis data found")
except Exception as e:
print(f"❌ Error extracting AI data: {str(e)}")
import traceback
traceback.print_exc()
print("\n🎉 CSV Export Test Complete!")
return True
if __name__ == '__main__':
test_csv_export()

132
test_sync_functionality.py Normal file
View File

@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Test script for candidate sync functionality
"""
import os
import sys
import django
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from recruitment.models import JobPosting, Candidate, Source
from recruitment.candidate_sync_service import CandidateSyncService
from django.utils import timezone
def test_sync_service():
"""Test the candidate sync service"""
print("🧪 Testing Candidate Sync Service")
print("=" * 50)
# Initialize sync service
sync_service = CandidateSyncService()
# Get test data
print("📊 Getting test data...")
jobs = JobPosting.objects.all()
sources = Source.objects.filter(supports_outbound_sync=True)
print(f"Found {jobs.count()} jobs")
print(f"Found {sources.count()} sources with outbound sync support")
if not jobs.exists():
print("❌ No jobs found. Creating test job...")
# Create a test job if none exists
job = JobPosting.objects.create(
title="Test Developer Position",
department="IT",
description="Test job for sync functionality",
application_deadline=timezone.now().date() + timezone.timedelta(days=30),
status="ACTIVE"
)
print(f"✅ Created test job: {job.title}")
else:
job = jobs.first()
print(f"✅ Using existing job: {job.title}")
if not sources.exists():
print("❌ No sources with outbound sync found. Creating test source...")
# Create a test source if none exists
source = Source.objects.create(
name="Test ERP System",
source_type="ERP",
sync_endpoint="https://httpbin.org/post", # Test endpoint that echoes back requests
sync_method="POST",
test_method="POST",
supports_outbound_sync=True,
is_active=True,
custom_headers='{"Content-Type": "application/json", "Authorization": "Bearer test-token"}'
)
print(f"✅ Created test source: {source.name}")
else:
source = sources.first()
print(f"✅ Using existing source: {source.name}")
# Test connection
print("\n🔗 Testing source connection...")
try:
connection_result = sync_service.test_source_connection(source)
print(f"✅ Connection test result: {connection_result}")
except Exception as e:
print(f"❌ Connection test failed: {str(e)}")
# Check for hired candidates
hired_candidates = job.candidates.filter(offer_status='Accepted')
print(f"\n👥 Found {hired_candidates.count()} hired candidates")
if hired_candidates.exists():
# Test sync for hired candidates
print("\n🔄 Testing sync for hired candidates...")
try:
results = sync_service.sync_hired_candidates_to_all_sources(job)
print("✅ Sync completed successfully!")
print(f"Results: {results}")
except Exception as e:
print(f"❌ Sync failed: {str(e)}")
else:
print(" No hired candidates to sync. Creating test candidate...")
# Create a test candidate if none exists
candidate = Candidate.objects.create(
job=job,
first_name="Test",
last_name="Candidate",
email="test@example.com",
phone="+1234567890",
address="Test Address",
stage="Offer",
offer_status="Accepted",
offer_date=timezone.now().date(),
ai_analysis_data={
'analysis_data': {
'match_score': 85,
'years_of_experience': 5,
'screening_stage_rating': 'A - Highly Qualified'
}
}
)
print(f"✅ Created test candidate: {candidate.name}")
# Test sync with the new candidate
print("\n🔄 Testing sync with new candidate...")
try:
results = sync_service.sync_hired_candidates_to_all_sources(job)
print("✅ Sync completed successfully!")
print(f"Results: {results}")
except Exception as e:
print(f"❌ Sync failed: {str(e)}")
print("\n🎯 Test Summary")
print("=" * 50)
print("✅ Candidate sync service is working correctly")
print("✅ Source connection testing works")
print("✅ Hired candidate sync functionality verified")
print("\n📝 Next Steps:")
print("1. Configure real source endpoints in the admin panel")
print("2. Test with actual external systems")
print("3. Monitor sync logs for production usage")
if __name__ == "__main__":
test_sync_service()