frontend #26

Merged
ismail merged 15 commits from frontend into main 2025-10-30 13:17:26 +03:00
81 changed files with 12562 additions and 243 deletions
Showing only changes of commit b13a1dd1ee - Show all commits

View File

@ -135,9 +135,9 @@ WSGI_APPLICATION = 'NorahUniversity.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'haikal_db',
'USER': 'faheed',
'PASSWORD': 'Faheed@215',
'NAME': 'norahuniversity',
'USER': 'norahuniversity',
'PASSWORD': 'norahuniversity',
'HOST': '127.0.0.1',
'PORT': '5432',
}

View File

@ -1,7 +1,7 @@
from recruitment import views
from recruitment import views,views_frontend
from django.conf import settings
from django.contrib import admin
from django.urls import path, include
from django.conf.urls.static import static
from django.views.generic import RedirectView
@ -27,12 +27,17 @@ urlpatterns = [
path('application/<slug:template_slug>/submit/', views.application_submit, name='application_submit'),
path('application/<slug:slug>/apply/', views.application_detail, name='application_detail'),
path('application/<slug:slug>/success/', views.application_success, name='application_success'),
path('api/templates/', views.list_form_templates, name='list_form_templates'),
path('api/templates/save/', views.save_form_template, name='save_form_template'),
path('api/templates/<slug:template_slug>/', views.load_form_template, name='load_form_template'),
path('api/templates/<slug:template_slug>/delete/', views.delete_form_template, name='delete_form_template'),
path('api/webhook/',views.zoom_webhook_view,name='zoom_webhook_view')
path('api/webhook/',views.zoom_webhook_view,name='zoom_webhook_view'),
path('sync/task/<str: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'),
]
urlpatterns += i18n_patterns(
@ -42,4 +47,4 @@ urlpatterns += i18n_patterns(
# This includes the root path (''), which is handled by 'recruitment.urls'
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

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.

View File

@ -5,7 +5,8 @@ from django.utils import timezone
from .models import (
JobPosting, Candidate, TrainingMaterial, ZoomMeeting,
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment,
AgencyAccessLink, AgencyJobAssignment
)
class FormFieldInline(admin.TabularInline):
@ -77,20 +78,21 @@ class IntegrationLogAdmin(admin.ModelAdmin):
class HiringAgencyAdmin(admin.ModelAdmin):
list_display = ['name', 'contact_person', 'email', 'phone', 'country', 'created_at']
list_filter = ['country', 'created_at']
search_fields = ['name', 'contact_person', 'email', 'phone', 'notes']
readonly_fields = ['created_at', 'updated_at']
search_fields = ['name', 'contact_person', 'email', 'phone', 'description']
readonly_fields = ['slug', 'created_at', 'updated_at']
fieldsets = (
('Basic Information', {
'fields': ('name', 'contact_person', 'email', 'phone', 'website')
'fields': ('name', 'slug', 'contact_person', 'email', 'phone', 'website')
}),
('Location Details', {
'fields': ('country', 'address')
'fields': ('country', 'city', 'address')
}),
('Additional Information', {
'fields': ('notes', 'created_at', 'updated_at')
'fields': ('description', 'created_at', 'updated_at')
}),
)
save_on_top = True
prepopulated_fields = {'slug': ('name',)}
@admin.register(JobPosting)
@ -282,7 +284,9 @@ admin.site.register(FormField)
admin.site.register(FieldResponse)
admin.site.register(InterviewSchedule)
admin.site.register(Profile)
# admin.site.register(HiringAgency)
admin.site.register(AgencyAccessLink)
admin.site.register(AgencyJobAssignment)
# AgencyMessage admin removed - model has been deleted
admin.site.register(JobPostingImage)

View File

@ -0,0 +1,360 @@
import json
import logging
import requests
from datetime import datetime
from typing import Dict, Any, List, Optional, Tuple
from django.utils import timezone
from django.conf import settings
from django.core.files.base import ContentFile
from django.http import HttpRequest
from .models import Source, Candidate, JobPosting, IntegrationLog
logger = logging.getLogger(__name__)
class CandidateSyncService:
"""
Service to handle synchronization of hired candidates to external sources
"""
def __init__(self):
self.logger = logging.getLogger(__name__)
def sync_hired_candidates_to_all_sources(self, job: JobPosting) -> Dict[str, Any]:
"""
Sync all hired candidates for a job to all active external sources
Returns: Dictionary with sync results for each source
"""
results = {
'total_candidates': 0,
'successful_syncs': 0,
'failed_syncs': 0,
'source_results': {},
'sync_time': timezone.now().isoformat()
}
# Get all hired candidates for this job
hired_candidates = list(job.hired_candidates.select_related('job'))
results['total_candidates'] = len(hired_candidates)
if not hired_candidates:
self.logger.info(f"No hired candidates found for job {job.title}")
return results
# Get all active sources that support outbound sync
active_sources = Source.objects.filter(
is_active=True,
sync_endpoint__isnull=False
).exclude(sync_endpoint='')
if not active_sources:
self.logger.warning("No active sources with sync endpoints configured")
return results
# Sync to each source
for source in active_sources:
try:
source_result = self.sync_to_source(source, hired_candidates, job)
results['source_results'][source.name] = source_result
if source_result['success']:
results['successful_syncs'] += 1
else:
results['failed_syncs'] += 1
except Exception as e:
error_msg = f"Unexpected error syncing to {source.name}: {str(e)}"
self.logger.error(error_msg)
results['source_results'][source.name] = {
'success': False,
'error': error_msg,
'candidates_synced': 0
}
results['failed_syncs'] += 1
return results
def sync_to_source(self, source: Source, candidates: List[Candidate], job: JobPosting) -> Dict[str, Any]:
"""
Sync candidates to a specific external source
Returns: Dictionary with sync result for this source
"""
result = {
'success': False,
'error': None,
'candidates_synced': 0,
'candidates_failed': 0,
'candidate_results': []
}
try:
# Prepare headers for the request
headers = self._prepare_headers(source)
# Sync each candidate
for candidate in candidates:
try:
candidate_data = self._format_candidate_data(candidate, job)
sync_result = self._send_candidate_to_source(source, candidate_data, headers)
result['candidate_results'].append({
'candidate_id': candidate.id,
'candidate_name': candidate.name,
'success': sync_result['success'],
'error': sync_result.get('error'),
'response_data': sync_result.get('response_data')
})
if sync_result['success']:
result['candidates_synced'] += 1
else:
result['candidates_failed'] += 1
except Exception as e:
error_msg = f"Error syncing candidate {candidate.name}: {str(e)}"
self.logger.error(error_msg)
result['candidate_results'].append({
'candidate_id': candidate.id,
'candidate_name': candidate.name,
'success': False,
'error': error_msg
})
result['candidates_failed'] += 1
# Consider sync successful if at least one candidate was synced
result['success'] = result['candidates_synced'] > 0
# Log the sync operation
self._log_sync_operation(source, result, len(candidates))
except Exception as e:
error_msg = f"Failed to sync to source {source.name}: {str(e)}"
self.logger.error(error_msg)
result['error'] = error_msg
return result
def _prepare_headers(self, source: Source) -> Dict[str, str]:
"""Prepare HTTP headers for the sync request"""
headers = {
'Content-Type': 'application/json',
'User-Agent': f'KAAUH-ATS-Sync/1.0'
}
# Add API key if configured
if source.api_key:
headers['X-API-Key'] = source.api_key
# Add custom headers if any
if hasattr(source, 'custom_headers') and source.custom_headers:
try:
custom_headers = json.loads(source.custom_headers)
headers.update(custom_headers)
except json.JSONDecodeError:
self.logger.warning(f"Invalid custom_headers JSON for source {source.name}")
return headers
def _format_candidate_data(self, candidate: Candidate, job: JobPosting) -> Dict[str, Any]:
"""Format candidate data for external source"""
data = {
'candidate': {
'id': candidate.id,
'slug': candidate.slug,
'first_name': candidate.first_name,
'last_name': candidate.last_name,
'full_name': candidate.name,
'email': candidate.email,
'phone': candidate.phone,
'address': candidate.address,
# 'applied_at': candidate.created_at.isoformat(),
# 'hired_date': candidate.offer_date.isoformat() if candidate.offer_date else None,
# 'join_date': candidate.join_date.isoformat() if candidate.join_date else None,
},
# 'job': {
# 'id': job.id,
# 'internal_job_id': job.internal_job_id,
# 'title': job.title,
# 'department': job.department,
# 'job_type': job.job_type,
# 'workplace_type': job.workplace_type,
# 'location': job.get_location_display(),
# },
# 'ai_analysis': {
# 'match_score': candidate.match_score,
# 'years_of_experience': candidate.years_of_experience,
# 'screening_rating': candidate.screening_stage_rating,
# 'professional_category': candidate.professional_category,
# 'top_skills': candidate.top_3_keywords,
# 'strengths': candidate.strengths,
# 'weaknesses': candidate.weaknesses,
# 'recommendation': candidate.recommendation,
# 'job_fit_narrative': candidate.job_fit_narrative,
# },
# 'sync_metadata': {
# 'synced_at': timezone.now().isoformat(),
# 'sync_source': 'KAAUH-ATS',
# 'sync_version': '1.0'
# }
}
# # Add resume information if available
# if candidate.resume:
# data['candidate']['resume'] = {
# 'filename': candidate.resume.name,
# 'size': candidate.resume.size,
# 'url': candidate.resume.url if hasattr(candidate.resume, 'url') else None
# }
# # Add additional AI analysis data if available
# if candidate.ai_analysis_data:
# data['ai_analysis']['full_analysis'] = candidate.ai_analysis_data
return data
def _send_candidate_to_source(self, source: Source, candidate_data: Dict[str, Any], headers: Dict[str, str]) -> Dict[str, Any]:
"""
Send candidate data to external source
Returns: Dictionary with send result
"""
result = {
'success': False,
'error': None,
'response_data': None,
'status_code': None
}
try:
# Determine HTTP method (default to POST)
method = getattr(source, 'sync_method', 'POST').upper()
# Prepare request data
json_data = json.dumps(candidate_data)
# Make the HTTP request
if method == 'POST':
response = requests.post(
source.sync_endpoint,
data=json_data,
headers=headers,
timeout=30
)
elif method == 'PUT':
response = requests.put(
source.sync_endpoint,
data=json_data,
headers=headers,
timeout=30
)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
result['status_code'] = response.status_code
result['response_data'] = response.text
# Check if request was successful
if response.status_code in [200, 201, 202]:
try:
response_json = response.json()
result['response_data'] = response_json
result['success'] = True
except json.JSONDecodeError:
# If response is not JSON, still consider it successful if status code is good
result['success'] = True
else:
result['error'] = f"HTTP {response.status_code}: {response.text}"
except requests.exceptions.Timeout:
result['error'] = "Request timeout"
except requests.exceptions.ConnectionError:
result['error'] = "Connection error"
except requests.exceptions.RequestException as e:
result['error'] = f"Request error: {str(e)}"
except Exception as e:
result['error'] = f"Unexpected error: {str(e)}"
return result
def _log_sync_operation(self, source: Source, result: Dict[str, Any], total_candidates: int):
"""Log the sync operation to IntegrationLog"""
try:
IntegrationLog.objects.create(
source=source,
action='SYNC',
endpoint=source.sync_endpoint,
method=getattr(source, 'sync_method', 'POST'),
request_data={
'total_candidates': total_candidates,
'candidates_synced': result['candidates_synced'],
'candidates_failed': result['candidates_failed']
},
response_data=result,
status_code='200' if result['success'] else '400',
error_message=result.get('error'),
ip_address='127.0.0.1', # Internal sync
user_agent='KAAUH-ATS-Sync/1.0'
)
except Exception as e:
self.logger.error(f"Failed to log sync operation: {str(e)}")
def test_source_connection(self, source: Source) -> Dict[str, Any]:
"""
Test connection to an external source
Returns: Dictionary with test result
"""
result = {
'success': False,
'error': None,
'response_time': None,
'status_code': None
}
try:
headers = self._prepare_headers(source)
test_data = {
'test': True,
'timestamp': timezone.now().isoformat(),
'source': 'KAAUH-ATS Connection Test'
}
start_time = datetime.now()
# Use GET method for testing if available, otherwise POST
test_method = getattr(source, 'test_method', 'GET').upper()
if test_method == 'GET':
response = requests.get(
source.sync_endpoint,
headers=headers,
timeout=10
)
else:
response = requests.post(
source.sync_endpoint,
data=json.dumps(test_data),
headers=headers,
timeout=10
)
end_time = datetime.now()
result['response_time'] = (end_time - start_time).total_seconds()
result['status_code'] = response.status_code
if response.status_code in [200, 201, 202]:
result['success'] = True
else:
result['error'] = f"HTTP {response.status_code}: {response.text}"
except requests.exceptions.Timeout:
result['error'] = "Connection timeout"
except requests.exceptions.ConnectionError:
result['error'] = "Connection failed"
except Exception as e:
result['error'] = f"Test failed: {str(e)}"
return result

View File

@ -0,0 +1,146 @@
"""
Email service for sending notifications related to agency messaging.
"""
from django.core.mail import send_mail
from django.conf import settings
from django.template.loader import render_to_string
from django.utils.html import strip_tags
import logging
logger = logging.getLogger(__name__)
class EmailService:
"""
Service class for handling email notifications
"""
def send_email(self, recipient_email, subject, body, html_body=None):
"""
Send email using Django's send_mail function
Args:
recipient_email: Email address to send to
subject: Email subject
body: Plain text email body
html_body: HTML email body (optional)
Returns:
dict: Result with success status and error message if failed
"""
try:
send_mail(
subject=subject,
message=body,
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa'),
recipient_list=[recipient_email],
html_message=html_body,
fail_silently=False,
)
logger.info(f"Email sent successfully to {recipient_email}")
return {'success': True}
except Exception as e:
error_msg = f"Failed to send email to {recipient_email}: {str(e)}"
logger.error(error_msg)
return {'success': False, 'error': error_msg}
def send_agency_welcome_email(agency, access_link=None):
"""
Send welcome email to a new agency with portal access information.
Args:
agency: HiringAgency instance
access_link: AgencyAccessLink instance (optional)
Returns:
bool: True if email was sent successfully, False otherwise
"""
try:
if not agency.email:
logger.warning(f"No email found for agency {agency.id}")
return False
context = {
'agency': agency,
'access_link': access_link,
'portal_url': getattr(settings, 'AGENCY_PORTAL_URL', 'https://kaauh.edu.sa/portal/'),
}
# Render email templates
html_message = render_to_string('recruitment/emails/agency_welcome.html', context)
plain_message = strip_tags(html_message)
# Send email
send_mail(
subject='Welcome to KAAUH Recruitment Portal',
message=plain_message,
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa'),
recipient_list=[agency.email],
html_message=html_message,
fail_silently=False,
)
logger.info(f"Welcome email sent to agency {agency.email}")
return True
except Exception as e:
logger.error(f"Failed to send agency welcome email: {str(e)}")
return False
def send_assignment_notification_email(assignment, message_type='created'):
"""
Send email notification about assignment changes.
Args:
assignment: AgencyJobAssignment instance
message_type: Type of notification ('created', 'updated', 'deadline_extended')
Returns:
bool: True if email was sent successfully, False otherwise
"""
try:
if not assignment.agency.email:
logger.warning(f"No email found for agency {assignment.agency.id}")
return False
context = {
'assignment': assignment,
'agency': assignment.agency,
'job': assignment.job,
'message_type': message_type,
'portal_url': getattr(settings, 'AGENCY_PORTAL_URL', 'https://kaauh.edu.sa/portal/'),
}
# Render email templates
html_message = render_to_string('recruitment/emails/assignment_notification.html', context)
plain_message = strip_tags(html_message)
# Determine subject based on message type
subjects = {
'created': f'New Job Assignment: {assignment.job.title}',
'updated': f'Assignment Updated: {assignment.job.title}',
'deadline_extended': f'Deadline Extended: {assignment.job.title}',
}
subject = subjects.get(message_type, f'Assignment Notification: {assignment.job.title}')
# Send email
send_mail(
subject=subject,
message=plain_message,
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa'),
recipient_list=[assignment.agency.email],
html_message=html_message,
fail_silently=False,
)
logger.info(f"Assignment notification email sent to {assignment.agency.email} for {message_type}")
return True
except Exception as e:
logger.error(f"Failed to send assignment notification email: {str(e)}")
return False

View File

@ -10,13 +10,15 @@ import re
from .models import (
ZoomMeeting, Candidate,TrainingMaterial,JobPosting,
FormTemplate,InterviewSchedule,BreakTime,JobPostingImage,
Profile,MeetingComment,ScheduledInterview,Source,Participants
Profile,MeetingComment,ScheduledInterview,Source,HiringAgency,
AgencyJobAssignment, AgencyAccessLink
)
# from django_summernote.widgets import SummernoteWidget
from django_ckeditor_5.widgets import CKEditor5Widget
import secrets
import string
from django.core.exceptions import ValidationError
from django.utils import timezone
def generate_api_key(length=32):
"""Generate a secure API key"""
@ -170,13 +172,15 @@ class SourceForm(forms.ModelForm):
class CandidateForm(forms.ModelForm):
class Meta:
model = Candidate
fields = ['job', 'first_name', 'last_name', 'phone', 'email', 'resume',]
fields = ['job', 'first_name', 'last_name', 'phone', 'email','hiring_source','hiring_agency', 'resume',]
labels = {
'first_name': _('First Name'),
'last_name': _('Last Name'),
'phone': _('Phone'),
'email': _('Email'),
'resume': _('Resume'),
'hiring_source': _('Hiring Type'),
'hiring_agency': _('Hiring Agency'),
}
widgets = {
'first_name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter first name')}),
@ -184,6 +188,8 @@ class CandidateForm(forms.ModelForm):
'phone': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter phone number')}),
'email': forms.EmailInput(attrs={'class': 'form-control', 'placeholder': _('Enter email')}),
'stage': forms.Select(attrs={'class': 'form-select'}),
'hiring_source': forms.Select(attrs={'class': 'form-select'}),
'hiring_agency': forms.Select(attrs={'class': 'form-select'}),
}
def __init__(self, *args, **kwargs):
@ -206,8 +212,11 @@ class CandidateForm(forms.ModelForm):
Field('phone', css_class='form-control'),
Field('email', css_class='form-control'),
Field('stage', css_class='form-control'),
Field('hiring_source', css_class='form-control'),
Field('hiring_agency', css_class='form-control'),
Field('resume', css_class='form-control'),
Submit('submit', _('Submit'), css_class='btn btn-primary')
)
class CandidateStageForm(forms.ModelForm):
@ -647,54 +656,492 @@ class CandidateExamDateForm(forms.ModelForm):
'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
}
#participants form
class ParticipantsForm(forms.ModelForm):
"""Form for creating and editing Participants"""
class HiringAgencyForm(forms.ModelForm):
"""Form for creating and editing hiring agencies"""
class Meta:
model = Participants
fields = ['name', 'email', 'phone', 'designation']
model = HiringAgency
fields = [
'name', 'contact_person', 'email', 'phone',
'website', 'country', 'address', 'notes'
]
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter participant name',
'required': True
'class': 'form-control',
'placeholder': 'Enter agency name',
'required': True
}),
'contact_person': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter contact person name'
}),
'email': forms.EmailInput(attrs={
'class': 'form-control',
'placeholder': 'Enter email address',
'required': True
'class': 'form-control',
'placeholder': 'agency@example.com'
}),
'phone': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter phone number'
'class': 'form-control',
'placeholder': '+966 50 123 4567'
}),
'designation': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter designation'
'website': forms.URLInput(attrs={
'class': 'form-control',
'placeholder': 'https://www.agency.com'
}),
# 'jobs': forms.CheckboxSelectMultiple(),
'country': forms.Select(attrs={
'class': 'form-select'
}),
'address': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Enter agency address'
}),
'notes': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Internal notes about the agency'
}),
}
labels = {
'name': _('Agency Name'),
'contact_person': _('Contact Person'),
'email': _('Email Address'),
'phone': _('Phone Number'),
'website': _('Website'),
'country': _('Country'),
'address': _('Address'),
'notes': _('Internal Notes'),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-3'
self.helper.field_class = 'col-md-9'
class ParticipantsSelectForm(forms.ModelForm):
"""Form for selecting Participants"""
self.helper.layout = Layout(
Field('name', css_class='form-control'),
Field('contact_person', css_class='form-control'),
Row(
Column('email', css_class='col-md-6'),
Column('phone', css_class='col-md-6'),
css_class='g-3 mb-3'
),
Field('website', css_class='form-control'),
Field('country', css_class='form-control'),
Field('address', css_class='form-control'),
Field('notes', css_class='form-control'),
Div(
Submit('submit', _('Save Agency'), css_class='btn btn-main-action'),
css_class='col-12 mt-4'
)
)
def clean_name(self):
"""Ensure agency name is unique"""
name = self.cleaned_data.get('name')
if name:
instance = self.instance
if not instance.pk: # Creating new instance
if HiringAgency.objects.filter(name=name).exists():
raise ValidationError('An agency with this name already exists.')
else: # Editing existing instance
if HiringAgency.objects.filter(name=name).exclude(pk=instance.pk).exists():
raise ValidationError('An agency with this name already exists.')
return name.strip()
def clean_email(self):
"""Validate email format and uniqueness"""
email = self.cleaned_data.get('email')
if email:
# Check email format
if not '@' in email or '.' not in email.split('@')[1]:
raise ValidationError('Please enter a valid email address.')
# Check uniqueness (optional - remove if multiple agencies can have same email)
instance = self.instance
if not instance.pk: # Creating new instance
if HiringAgency.objects.filter(email=email).exists():
raise ValidationError('An agency with this email already exists.')
else: # Editing existing instance
if HiringAgency.objects.filter(email=email).exclude(pk=instance.pk).exists():
raise ValidationError('An agency with this email already exists.')
return email.lower().strip() if email else email
def clean_phone(self):
"""Validate phone number format"""
phone = self.cleaned_data.get('phone')
if phone:
# Remove common formatting characters
clean_phone = ''.join(c for c in phone if c.isdigit() or c in '+')
if len(clean_phone) < 10:
raise ValidationError('Phone number must be at least 10 digits long.')
return phone.strip() if phone else phone
def clean_website(self):
"""Validate website URL"""
website = self.cleaned_data.get('website')
if website:
if not website.startswith(('http://', 'https://')):
website = 'https://' + website
validator = URLValidator()
try:
validator(website)
except ValidationError:
raise ValidationError('Please enter a valid website URL.')
return website
class AgencyJobAssignmentForm(forms.ModelForm):
"""Form for creating and editing agency job assignments"""
participants=forms.ModelMultipleChoiceField(
queryset=Participants.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label=_("Select Participants"))
users=forms.ModelMultipleChoiceField(
queryset=User.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label=_("Select Users"))
class Meta:
model = JobPosting
fields = ['participants','users'] # No direct fields from Participants model
model = AgencyJobAssignment
fields = [
'agency', 'job', 'max_candidates', 'deadline_date','admin_notes'
]
widgets = {
'agency': forms.Select(attrs={'class': 'form-select'}),
'job': forms.Select(attrs={'class': 'form-select'}),
'max_candidates': forms.NumberInput(attrs={
'class': 'form-control',
'min': 1,
'placeholder': 'Maximum number of candidates'
}),
'deadline_date': forms.DateTimeInput(attrs={
'class': 'form-control',
'type': 'datetime-local'
}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'status': forms.Select(attrs={'class': 'form-select'}),
'admin_notes': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Internal notes about this assignment'
}),
}
labels = {
'agency': _('Agency'),
'job': _('Job Posting'),
'max_candidates': _('Maximum Candidates'),
'deadline_date': _('Deadline Date'),
'is_active': _('Is Active'),
'status': _('Status'),
'admin_notes': _('Admin Notes'),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-3'
self.helper.field_class = 'col-md-9'
# Filter jobs to only show active jobs
self.fields['job'].queryset = JobPosting.objects.filter(
status='ACTIVE'
).order_by('-created_at')
self.helper.layout = Layout(
Row(
Column('agency', css_class='col-md-6'),
Column('job', css_class='col-md-6'),
css_class='g-3 mb-3'
),
Row(
Column('max_candidates', css_class='col-md-6'),
Column('deadline_date', css_class='col-md-6'),
css_class='g-3 mb-3'
),
Row(
Column('is_active', css_class='col-md-6'),
Column('status', css_class='col-md-6'),
css_class='g-3 mb-3'
),
Field('admin_notes', css_class='form-control'),
Div(
Submit('submit', _('Save Assignment'), css_class='btn btn-main-action'),
css_class='col-12 mt-4'
)
)
def clean_deadline_date(self):
"""Validate deadline date is in the future"""
deadline_date = self.cleaned_data.get('deadline_date')
if deadline_date and deadline_date <= timezone.now():
raise ValidationError('Deadline date must be in the future.')
return deadline_date
def clean_max_candidates(self):
"""Validate maximum candidates is positive"""
max_candidates = self.cleaned_data.get('max_candidates')
if max_candidates and max_candidates <= 0:
raise ValidationError('Maximum candidates must be greater than 0.')
return max_candidates
def clean(self):
"""Check for duplicate assignments"""
cleaned_data = super().clean()
agency = cleaned_data.get('agency')
job = cleaned_data.get('job')
if agency and job:
# Check if this assignment already exists
existing = AgencyJobAssignment.objects.filter(
agency=agency, job=job
).exclude(pk=self.instance.pk).first()
if existing:
raise ValidationError(
f'This job is already assigned to {agency.name}. '
f'Current status: {existing.get_status_display()}'
)
return cleaned_data
class AgencyAccessLinkForm(forms.ModelForm):
"""Form for creating and managing agency access links"""
class Meta:
model = AgencyAccessLink
fields = [
'assignment', 'expires_at', 'is_active'
]
widgets = {
'assignment': forms.Select(attrs={'class': 'form-select'}),
'expires_at': forms.DateTimeInput(attrs={
'class': 'form-control',
'type': 'datetime-local'
}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
labels = {
'assignment': _('Assignment'),
'expires_at': _('Expires At'),
'is_active': _('Is Active'),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-3'
self.helper.field_class = 'col-md-9'
# Filter assignments to only show active ones without existing links
self.fields['assignment'].queryset = AgencyJobAssignment.objects.filter(
is_active=True,
status='ACTIVE'
).exclude(
access_link__isnull=False
).order_by('-created_at')
self.helper.layout = Layout(
Field('assignment', css_class='form-control'),
Field('expires_at', css_class='form-control'),
Field('is_active', css_class='form-check-input'),
Div(
Submit('submit', _('Create Access Link'), css_class='btn btn-main-action'),
css_class='col-12 mt-4'
)
)
def clean_expires_at(self):
"""Validate expiration date is in the future"""
expires_at = self.cleaned_data.get('expires_at')
if expires_at and expires_at <= timezone.now():
raise ValidationError('Expiration date must be in the future.')
return expires_at
# Agency messaging forms removed - AgencyMessage model has been deleted
class AgencyCandidateSubmissionForm(forms.ModelForm):
"""Form for agencies to submit candidates (simplified - resume + basic info)"""
class Meta:
model = Candidate
fields = [
'first_name', 'last_name', 'email', 'phone', 'resume'
]
widgets = {
'first_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'First Name',
'required': True
}),
'last_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Last Name',
'required': True
}),
'email': forms.EmailInput(attrs={
'class': 'form-control',
'placeholder': 'email@example.com',
'required': True
}),
'phone': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '+966 50 123 4567',
'required': True
}),
'resume': forms.FileInput(attrs={
'class': 'form-control',
'accept': '.pdf,.doc,.docx',
'required': True
}),
}
labels = {
'first_name': _('First Name'),
'last_name': _('Last Name'),
'email': _('Email Address'),
'phone': _('Phone Number'),
'resume': _('Resume'),
}
def __init__(self, assignment, *args, **kwargs):
super().__init__(*args, **kwargs)
self.assignment = assignment
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'g-3'
self.helper.enctype = 'multipart/form-data'
self.helper.layout = Layout(
Row(
Column('first_name', css_class='col-md-6'),
Column('last_name', css_class='col-md-6'),
css_class='g-3 mb-3'
),
Row(
Column('email', css_class='col-md-6'),
Column('phone', css_class='col-md-6'),
css_class='g-3 mb-3'
),
Field('resume', css_class='form-control'),
Div(
Submit('submit', _('Submit Candidate'), css_class='btn btn-main-action'),
css_class='col-12 mt-4'
)
)
def clean_email(self):
"""Validate email format and check for duplicates in the same job"""
email = self.cleaned_data.get('email')
if email:
# Check if candidate with this email already exists for this job
existing_candidate = Candidate.objects.filter(
email=email.lower().strip(),
job=self.assignment.job
).first()
if existing_candidate:
raise ValidationError(
f'A candidate with this email has already applied for {self.assignment.job.title}.'
)
return email.lower().strip() if email else email
def clean_resume(self):
"""Validate resume file"""
resume = self.cleaned_data.get('resume')
if resume:
# Check file size (max 5MB)
if resume.size > 5 * 1024 * 1024:
raise ValidationError('Resume file size must be less than 5MB.')
# Check file extension
allowed_extensions = ['.pdf', '.doc', '.docx']
file_extension = resume.name.lower().split('.')[-1]
if f'.{file_extension}' not in allowed_extensions:
raise ValidationError(
'Resume must be in PDF, DOC, or DOCX format.'
)
return resume
def save(self, commit=True):
"""Override save to set additional fields"""
instance = super().save(commit=False)
# Set required fields for agency submission
instance.job = self.assignment.job
instance.hiring_agency = self.assignment.agency
instance.stage = Candidate.Stage.APPLIED
instance.applicant_status = Candidate.ApplicantType.CANDIDATE
instance.applied = True
if commit:
instance.save()
# Increment the assignment's submitted count
self.assignment.increment_submission_count()
return instance
class AgencyLoginForm(forms.Form):
"""Form for agencies to login with token and password"""
token = forms.CharField(
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter your access token'
}),
label=_('Access Token'),
required=True
)
password = forms.CharField(
widget=forms.PasswordInput(attrs={
'class': 'form-control',
'placeholder': 'Enter your password'
}),
label=_('Password'),
required=True
)
# def __init__(self, *args, **kwargs):
# super().__init__(*args, **kwargs)
# self.helper = FormHelper()
# self.helper.form_method = 'post'
# self.helper.form_class = 'g-3'
# self.helper.layout = Layout(
# Field('token', css_class='form-control'),
# Field('password', css_class='form-control'),
# Div(
# Submit('submit', _('Login'), css_class='btn btn-main-action w-100'),
# css_class='col-12 mt-4'
# )
# )
def clean(self):
"""Validate token and password combination"""
cleaned_data = super().clean()
token = cleaned_data.get('token')
password = cleaned_data.get('password')
if token and password:
try:
access_link = AgencyAccessLink.objects.get(
unique_token=token,
is_active=True
)
if not access_link.is_valid:
if access_link.is_expired:
raise ValidationError('This access link has expired.')
else:
raise ValidationError('This access link is no longer active.')
if access_link.access_password != password:
raise ValidationError('Invalid password.')
# Store the access_link for use in the view
self.validated_access_link = access_link
except AgencyAccessLink.DoesNotExist:
print("Access link does not exist")
raise ValidationError('Invalid access token.')
return cleaned_data

View File

@ -0,0 +1,55 @@
from django.core.management.base import BaseCommand
from recruitment.models import AgencyAccessLink, AgencyJobAssignment, HiringAgency
from django.utils import timezone
class Command(BaseCommand):
help = 'Debug agency login issues by checking existing access links'
def handle(self, *args, **options):
self.stdout.write("=== Agency Access Link Debug ===")
# Check total counts
total_links = AgencyAccessLink.objects.count()
total_assignments = AgencyJobAssignment.objects.count()
total_agencies = HiringAgency.objects.count()
self.stdout.write(f"Total Access Links: {total_links}")
self.stdout.write(f"Total Assignments: {total_assignments}")
self.stdout.write(f"Total Agencies: {total_agencies}")
self.stdout.write("")
if total_links == 0:
self.stdout.write("❌ NO ACCESS LINKS FOUND!")
self.stdout.write("This is likely the cause of 'Invalid token or password' error.")
self.stdout.write("")
self.stdout.write("To fix this:")
self.stdout.write("1. Create an agency first")
self.stdout.write("2. Create a job assignment for the agency")
self.stdout.write("3. Create an access link for the assignment")
return
# Show existing links
self.stdout.write("📋 Existing Access Links:")
for link in AgencyAccessLink.objects.all():
assignment = link.assignment
agency = assignment.agency if assignment else None
job = assignment.job if assignment else None
self.stdout.write(f" 📍 Token: {link.unique_token}")
self.stdout.write(f" Password: {link.access_password}")
self.stdout.write(f" Active: {link.is_active}")
self.stdout.write(f" Expires: {link.expires_at}")
self.stdout.write(f" Agency: {agency.name if agency else 'None'}")
self.stdout.write(f" Job: {job.title if job else 'None'}")
self.stdout.write(f" Valid: {link.is_valid}")
self.stdout.write("")
# Show assignments without links
self.stdout.write("📋 Assignments WITHOUT Access Links:")
assignments_without_links = AgencyJobAssignment.objects.filter(access_link__isnull=True)
for assignment in assignments_without_links:
self.stdout.write(f" 📍 {assignment.agency.name} - {assignment.job.title}")
self.stdout.write(f" Status: {assignment.status}")
self.stdout.write(f" Active: {assignment.is_active}")
self.stdout.write(f" Can Submit: {assignment.can_submit}")
self.stdout.write("")

View File

@ -79,7 +79,7 @@ class Command(BaseCommand):
# Random dates
start_date = fake.date_object()
deadline_date = start_date + timedelta(days=random.randint(14, 60))
deadline_date = start_date + timedelta(days=random.randint(14, 60))
# Use Faker's HTML generation for CKEditor5 fields
description_html = f"<h1>{title} Role</h1>" + "".join(f"<p>{fake.paragraph(nb_sentences=3, variable_nb_sentences=True)}</p>" for _ in range(3))
@ -117,10 +117,10 @@ class Command(BaseCommand):
first_name = fake.first_name()
last_name = fake.last_name()
path = os.path.join(settings.BASE_DIR,'media/resumes/')
# path = Path('media/resumes/') # <-- CORRECT
file = random.choice(os.listdir(path))
print(file)
file = random.choice(os.listdir(path))
print(file)
# file = os.path.abspath(file)
candidate_data = {
"first_name": first_name,
@ -129,7 +129,7 @@ class Command(BaseCommand):
"email": f"{first_name.lower()}.{last_name.lower()}@{fake.domain_name()}",
"phone": "0566987458",
"address": fake.address(),
# Placeholder resume path
# Placeholder resume path
"resume": 'resumes/'+ file,
"job": target_job,
}

View File

@ -0,0 +1,122 @@
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User
from recruitment.models import HiringAgency, AgencyJobAssignment, JobPosting
from django.utils import timezone
import random
class Command(BaseCommand):
help = 'Set up test agencies and assignments for messaging system testing'
def handle(self, *args, **options):
self.stdout.write('Setting up test agencies and assignments...')
# Create test admin user if not exists
admin_user, created = User.objects.get_or_create(
username='testadmin',
defaults={
'email': 'admin@test.com',
'first_name': 'Test',
'last_name': 'Admin',
'is_staff': True,
'is_superuser': True,
}
)
if created:
admin_user.set_password('admin123')
admin_user.save()
self.stdout.write(self.style.SUCCESS('Created test admin user: testadmin/admin123'))
# Create test agencies
agencies_data = [
{
'name': 'Tech Talent Solutions',
'contact_person': 'John Smith',
'email': 'contact@techtalent.com',
'phone': '+966501234567',
'website': 'https://techtalent.com',
'notes': 'Leading technology recruitment agency specializing in IT and software development roles.',
'country': 'SA'
},
{
'name': 'Healthcare Recruiters Ltd',
'contact_person': 'Sarah Johnson',
'email': 'info@healthcarerecruiters.com',
'phone': '+966502345678',
'website': 'https://healthcarerecruiters.com',
'notes': 'Specialized healthcare recruitment agency for medical professionals and healthcare staff.',
'country': 'SA'
},
{
'name': 'Executive Search Partners',
'contact_person': 'Michael Davis',
'email': 'partners@execsearch.com',
'phone': '+966503456789',
'website': 'https://execsearch.com',
'notes': 'Premium executive search firm for senior management and C-level positions.',
'country': 'SA'
}
]
created_agencies = []
for agency_data in agencies_data:
agency, created = HiringAgency.objects.get_or_create(
name=agency_data['name'],
defaults=agency_data
)
if created:
self.stdout.write(self.style.SUCCESS(f'Created agency: {agency.name}'))
created_agencies.append(agency)
# Get or create some sample jobs
jobs = []
job_titles = [
'Senior Software Engineer',
'Healthcare Administrator',
'Marketing Manager',
'Data Analyst',
'HR Director'
]
for title in job_titles:
job, created = JobPosting.objects.get_or_create(
internal_job_id=f'KAAUH-2025-{len(jobs)+1:06d}',
defaults={
'title': title,
'description': f'Description for {title} position',
'qualifications': f'Requirements for {title}',
'location_city': 'Riyadh',
'location_country': 'Saudi Arabia',
'job_type': 'FULL_TIME',
'workplace_type': 'ON_SITE',
'application_deadline': timezone.now().date() + timezone.timedelta(days=60),
'status': 'ACTIVE',
'created_by': admin_user.username
}
)
if created:
self.stdout.write(self.style.SUCCESS(f'Created job: {job.title}'))
jobs.append(job)
# Create agency assignments
for i, agency in enumerate(created_agencies):
for j, job in enumerate(jobs[:2]): # Assign 2 jobs per agency
assignment, created = AgencyJobAssignment.objects.get_or_create(
agency=agency,
job=job,
defaults={
'max_candidates': 5,
'deadline_date': timezone.now() + timezone.timedelta(days=30),
'status': 'ACTIVE',
'is_active': True
}
)
if created:
self.stdout.write(self.style.SUCCESS(
f'Created assignment: {agency.name} -> {job.title}'
))
self.stdout.write(self.style.SUCCESS('Test agencies and assignments setup complete!'))
self.stdout.write('\nSummary:')
self.stdout.write(f'- Agencies: {HiringAgency.objects.count()}')
self.stdout.write(f'- Jobs: {JobPosting.objects.count()}')
self.stdout.write(f'- Assignments: {AgencyJobAssignment.objects.count()}')

View File

@ -0,0 +1,112 @@
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User
from recruitment.models import Notification, HiringAgency
import datetime
class Command(BaseCommand):
help = 'Verify the notification system is working correctly'
def add_arguments(self, parser):
parser.add_argument(
'--detailed',
action='store_true',
help='Show detailed breakdown of notifications',
)
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS('🔍 Verifying Notification System'))
self.stdout.write('=' * 50)
# Check notification counts
total_notifications = Notification.objects.count()
pending_notifications = Notification.objects.filter(status='PENDING').count()
sent_notifications = Notification.objects.filter(status='SENT').count()
failed_notifications = Notification.objects.filter(status='FAILED').count()
self.stdout.write(f'\n📊 Notification Counts:')
self.stdout.write(f' Total Notifications: {total_notifications}')
self.stdout.write(f' Pending: {pending_notifications}')
self.stdout.write(f' Sent: {sent_notifications}')
self.stdout.write(f' Failed: {failed_notifications}')
# Agency messaging system has been removed - replaced by Notification system
self.stdout.write(f'\n💬 Message System:')
self.stdout.write(f' Agency messaging system has been replaced by Notification system')
# Check admin user notifications
admin_users = User.objects.filter(is_staff=True)
self.stdout.write(f'\n👤 Admin Users ({admin_users.count()}):')
for admin in admin_users:
admin_notifications = Notification.objects.filter(recipient=admin).count()
admin_unread = Notification.objects.filter(recipient=admin, status='PENDING').count()
self.stdout.write(f' {admin.username}: {admin_notifications} notifications ({admin_unread} unread)')
# Check agency notifications
# Note: Current Notification model only supports User recipients, not agencies
# Agency messaging system has been removed
agencies = HiringAgency.objects.all()
self.stdout.write(f'\n🏢 Agencies ({agencies.count()}):')
for agency in agencies:
self.stdout.write(f' {agency.name}: Agency messaging system has been removed')
# Check notification types
if options['detailed']:
self.stdout.write(f'\n📋 Detailed Notification Breakdown:')
# By type
for notification_type in ['email', 'in_app']:
count = Notification.objects.filter(notification_type=notification_type).count()
if count > 0:
self.stdout.write(f' {notification_type}: {count}')
# By status
for status in ['pending', 'sent', 'read', 'failed', 'retrying']:
count = Notification.objects.filter(status=status).count()
if count > 0:
self.stdout.write(f' {status}: {count}')
# System health check
self.stdout.write(f'\n🏥 System Health Check:')
issues = []
# Check for failed notifications
if failed_notifications > 0:
issues.append(f'{failed_notifications} failed notifications')
# Check for admin users without notifications
admin_with_no_notifications = admin_users.filter(
notifications__isnull=True
).count()
if admin_with_no_notifications > 0 and total_notifications > 0:
issues.append(f'{admin_with_no_notifications} admin users with no notifications')
if issues:
self.stdout.write(self.style.WARNING(' ⚠️ Issues found:'))
for issue in issues:
self.stdout.write(f' - {issue}')
else:
self.stdout.write(self.style.SUCCESS(' ✅ No issues detected'))
# Recent activity
recent_notifications = Notification.objects.filter(
created_at__gte=datetime.datetime.now() - datetime.timedelta(hours=24)
).count()
self.stdout.write(f'\n🕐 Recent Activity (last 24 hours):')
self.stdout.write(f' New notifications: {recent_notifications}')
# Summary
self.stdout.write(f'\n📋 Summary:')
if total_notifications > 0 and failed_notifications == 0:
self.stdout.write(self.style.SUCCESS(' ✅ Notification system is working correctly'))
elif failed_notifications > 0:
self.stdout.write(self.style.WARNING(' ⚠️ Notification system has some failures'))
else:
self.stdout.write(self.style.WARNING(' ⚠️ No notifications found - system may not be active'))
self.stdout.write('\n' + '=' * 50)
self.stdout.write(self.style.SUCCESS('✨ Verification complete!'))

View File

@ -238,7 +238,6 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='JobPosting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
@ -256,7 +255,7 @@ class Migration(migrations.Migration):
('application_url', models.URLField(blank=True, help_text='URL where candidates apply', null=True, validators=[django.core.validators.URLValidator()])),
('application_deadline', models.DateField(db_index=True)),
('application_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
('internal_job_id', models.CharField(editable=False, max_length=50)),
('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)),
('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)),
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20)),
('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])),
@ -415,6 +414,27 @@ class Migration(migrations.Migration):
('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
],
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField(verbose_name='Notification Message')),
('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')),
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')),
('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')),
('last_error', models.TextField(blank=True, verbose_name='Last Error Message')),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
('related_meeting', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.zoommeeting', verbose_name='Related Meeting')),
],
options={
'verbose_name': 'Notification',
'verbose_name_plural': 'Notifications',
'ordering': ['-scheduled_for', '-created_at'],
},
),
migrations.CreateModel(
name='MeetingComment',
fields=[
@ -502,4 +522,12 @@ class Migration(migrations.Migration):
model_name='scheduledinterview',
index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'),
),
migrations.AddIndex(
model_name='notification',
index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'),
),
migrations.AddIndex(
model_name='notification',
index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'),
),
]

View File

@ -0,0 +1,48 @@
# Generated by Django 5.2.4 on 2025-10-26 13:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0002_candidate_retry'),
]
operations = [
migrations.AddField(
model_name='candidate',
name='hired_date',
field=models.DateField(blank=True, null=True, verbose_name='Hired Date'),
),
migrations.AddField(
model_name='source',
name='custom_headers',
field=models.TextField(blank=True, help_text='JSON object with custom HTTP headers for sync requests', null=True, verbose_name='Custom Headers'),
),
migrations.AddField(
model_name='source',
name='supports_outbound_sync',
field=models.BooleanField(default=False, help_text='Whether this source supports receiving candidate data from ATS', verbose_name='Supports Outbound Sync'),
),
migrations.AddField(
model_name='source',
name='sync_endpoint',
field=models.URLField(blank=True, help_text='Endpoint URL for sending candidate data (for outbound sync)', null=True, verbose_name='Sync Endpoint'),
),
migrations.AddField(
model_name='source',
name='sync_method',
field=models.CharField(blank=True, choices=[('POST', 'POST'), ('PUT', 'PUT')], default='POST', help_text='HTTP method for outbound sync requests', max_length=10, verbose_name='Sync Method'),
),
migrations.AddField(
model_name='source',
name='test_method',
field=models.CharField(blank=True, choices=[('GET', 'GET'), ('POST', 'POST')], default='GET', help_text='HTTP method for connection testing', max_length=10, verbose_name='Test Method'),
),
migrations.AlterField(
model_name='candidate',
name='stage',
field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired')], db_index=True, default='Applied', max_length=100, verbose_name='Stage'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-10-26 13:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0003_candidate_hired_date_source_custom_headers_and_more'),
]
operations = [
migrations.AlterField(
model_name='integrationlog',
name='method',
field=models.CharField(blank=True, max_length=50, verbose_name='HTTP Method'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-10-26 14:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0004_alter_integrationlog_method'),
]
operations = [
migrations.RenameField(
model_name='candidate',
old_name='submitted_by_agency',
new_name='hiring_agency',
),
]

View File

@ -0,0 +1,129 @@
# Generated by Django 5.2.6 on 2025-10-26 14:51
import django.db.models.deletion
import django_extensions.db.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0005_rename_submitted_by_agency_candidate_hiring_agency'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AgencyJobAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('max_candidates', models.PositiveIntegerField(help_text='Maximum candidates agency can submit for this job', verbose_name='Maximum Candidates')),
('candidates_submitted', models.PositiveIntegerField(default=0, help_text='Number of candidates submitted so far', verbose_name='Candidates Submitted')),
('assigned_date', models.DateTimeField(auto_now_add=True, verbose_name='Assigned Date')),
('deadline_date', models.DateTimeField(help_text='Deadline for agency to submit candidates', verbose_name='Deadline Date')),
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
('status', models.CharField(choices=[('ACTIVE', 'Active'), ('COMPLETED', 'Completed'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='ACTIVE', max_length=20, verbose_name='Status')),
('deadline_extended', models.BooleanField(default=False, verbose_name='Deadline Extended')),
('original_deadline', models.DateTimeField(blank=True, help_text='Original deadline before extensions', null=True, verbose_name='Original Deadline')),
('admin_notes', models.TextField(blank=True, help_text='Internal notes about this assignment', verbose_name='Admin Notes')),
('agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_assignments', to='recruitment.hiringagency', verbose_name='Agency')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agency_assignments', to='recruitment.jobposting', verbose_name='Job')),
],
options={
'verbose_name': 'Agency Job Assignment',
'verbose_name_plural': 'Agency Job Assignments',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='AgencyAccessLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('unique_token', models.CharField(editable=False, max_length=64, unique=True, verbose_name='Unique Token')),
('access_password', models.CharField(help_text='Password for agency access', max_length=32, verbose_name='Access Password')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('expires_at', models.DateTimeField(help_text='When this access link expires', verbose_name='Expires At')),
('last_accessed', models.DateTimeField(blank=True, null=True, verbose_name='Last Accessed')),
('access_count', models.PositiveIntegerField(default=0, verbose_name='Access Count')),
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
('assignment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='access_link', to='recruitment.agencyjobassignment', verbose_name='Assignment')),
],
options={
'verbose_name': 'Agency Access Link',
'verbose_name_plural': 'Agency Access Links',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='AgencyMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('subject', models.CharField(max_length=200, verbose_name='Subject')),
('message', models.TextField(verbose_name='Message')),
('message_type', models.CharField(choices=[('INFO', 'Information'), ('WARNING', 'Warning'), ('EXTENSION', 'Deadline Extension'), ('GENERAL', 'General')], default='GENERAL', max_length=20, verbose_name='Message Type')),
('is_read', models.BooleanField(default=False, verbose_name='Is Read')),
('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')),
('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.agencyjobassignment', verbose_name='Assignment')),
('recipient_agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to='recruitment.hiringagency', verbose_name='Recipient Agency')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')),
],
options={
'verbose_name': 'Agency Message',
'verbose_name_plural': 'Agency Messages',
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['job', 'status'], name='recruitment_job_id_d798a8_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['deadline_date'], name='recruitment_deadlin_57d3b4_idx'),
),
migrations.AddIndex(
model_name='agencyjobassignment',
index=models.Index(fields=['is_active'], name='recruitment_is_acti_93b919_idx'),
),
migrations.AlterUniqueTogether(
name='agencyjobassignment',
unique_together={('agency', 'job')},
),
migrations.AddIndex(
model_name='agencyaccesslink',
index=models.Index(fields=['unique_token'], name='recruitment_unique__f91e76_idx'),
),
migrations.AddIndex(
model_name='agencyaccesslink',
index=models.Index(fields=['expires_at'], name='recruitment_expires_954ed9_idx'),
),
migrations.AddIndex(
model_name='agencyaccesslink',
index=models.Index(fields=['is_active'], name='recruitment_is_acti_4b0804_idx'),
),
migrations.AddIndex(
model_name='agencymessage',
index=models.Index(fields=['assignment', 'is_read'], name='recruitment_assignm_4f518d_idx'),
),
migrations.AddIndex(
model_name='agencymessage',
index=models.Index(fields=['recipient_agency', 'is_read'], name='recruitment_recipie_427b10_idx'),
),
migrations.AddIndex(
model_name='agencymessage',
index=models.Index(fields=['sender'], name='recruitment_sender__97dd96_idx'),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 5.2.6 on 2025-10-27 11:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more'),
]
operations = [
migrations.AddField(
model_name='candidate',
name='source',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.source', verbose_name='Source'),
),
migrations.AddField(
model_name='candidate',
name='source_type',
field=models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Source'),
),
]

View File

@ -0,0 +1,32 @@
# Generated by Django 5.2.6 on 2025-10-27 11:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0007_candidate_source_candidate_source_type'),
]
operations = [
migrations.RemoveField(
model_name='candidate',
name='source',
),
migrations.RemoveField(
model_name='candidate',
name='source_type',
),
migrations.AddField(
model_name='candidate',
name='hiring_source',
field=models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source'),
),
migrations.AlterField(
model_name='candidate',
name='hiring_agency',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.hiringagency', verbose_name='Hiring Agency'),
),
]

View File

@ -0,0 +1,59 @@
# Generated by Django 5.2.6 on 2025-10-27 20:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0008_remove_candidate_source_remove_candidate_source_type_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='agencymessage',
name='priority',
field=models.CharField(choices=[('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High'), ('URGENT', 'Urgent')], default='MEDIUM', max_length=10, verbose_name='Priority'),
),
migrations.AddField(
model_name='agencymessage',
name='recipient_user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient User'),
),
migrations.AddField(
model_name='agencymessage',
name='send_email',
field=models.BooleanField(default=False, verbose_name='Send Email Notification'),
),
migrations.AddField(
model_name='agencymessage',
name='send_sms',
field=models.BooleanField(default=False, verbose_name='Send SMS Notification'),
),
migrations.AddField(
model_name='agencymessage',
name='sender_agency',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to='recruitment.hiringagency', verbose_name='Sender Agency'),
),
migrations.AddField(
model_name='agencymessage',
name='sender_type',
field=models.CharField(choices=[('ADMIN', 'Admin'), ('AGENCY', 'Agency')], default='ADMIN', max_length=10, verbose_name='Sender Type'),
),
migrations.AlterField(
model_name='agencymessage',
name='sender',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender'),
),
migrations.AddIndex(
model_name='agencymessage',
index=models.Index(fields=['sender_type', 'created_at'], name='recruitment_sender__14b136_idx'),
),
migrations.AddIndex(
model_name='agencymessage',
index=models.Index(fields=['priority', 'created_at'], name='recruitment_priorit_80d9f1_idx'),
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 5.2.6 on 2025-10-29 10:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0009_agencymessage_priority_agencymessage_recipient_user_and_more'),
]
operations = [
migrations.DeleteModel(
name='AgencyMessage',
),
]

View File

@ -108,7 +108,8 @@ class JobPosting(Base):
)
# Internal Tracking
internal_job_id = models.CharField(max_length=50, editable=False)
internal_job_id = models.CharField(max_length=50, primary_key=True, editable=False)
created_by = models.CharField(
max_length=100, blank=True, help_text="Name of person who created this job"
)
@ -210,29 +211,29 @@ class JobPosting(Base):
return self.source.name if self.source else "System"
def save(self, *args, **kwargs):
# from django.db import transaction
from django.db import transaction
# Generate unique internal job ID if not exists
# with transaction.atomic():
# if not self.internal_job_id:
# prefix = "KAAUH"
# year = timezone.now().year
# # Get next sequential number
# last_job = (
# JobPosting.objects.select_for_update().filter(
# internal_job_id__startswith=f"{prefix}-{year}-"
# )
# .order_by("internal_job_id")
# .last()
# )
with transaction.atomic():
if not self.internal_job_id:
prefix = "KAAUH"
year = timezone.now().year
# Get next sequential number
last_job = (
JobPosting.objects.select_for_update().filter(
internal_job_id__startswith=f"{prefix}-{year}-"
)
.order_by("internal_job_id")
.last()
)
# if last_job:
# last_num = int(last_job.internal_job_id.split("-")[-1])
# next_num = last_num + 1
# else:
# next_num = 1
if last_job:
last_num = int(last_job.internal_job_id.split("-")[-1])
next_num = last_num + 1
else:
next_num = 1
# self.internal_job_id = f"{prefix}-{year}-{next_num:06d}"
self.internal_job_id = f"{prefix}-{year}-{next_num:06d}"
super().save(*args, **kwargs)
@ -332,6 +333,12 @@ class JobPosting(Base):
@property
def offer_candidates(self):
return self.all_candidates.filter(stage="Offer")
@property
def accepted_candidates(self):
return self.all_candidates.filter(offer_status="Accepted")
@property
def hired_candidates(self):
return self.all_candidates.filter(stage="Hired")
# counts
@property
@ -382,6 +389,7 @@ class Candidate(Base):
EXAM = "Exam", _("Exam")
INTERVIEW = "Interview", _("Interview")
OFFER = "Offer", _("Offer")
HIRED = "Hired", _("Hired")
class ExamStatus(models.TextChoices):
PASSED = "Passed", _("Passed")
@ -463,28 +471,31 @@ class Candidate(Base):
blank=True,
verbose_name=_("Offer Status"),
)
hired_date = models.DateField(null=True, blank=True, verbose_name=_("Hired Date"))
join_date = models.DateField(null=True, blank=True, verbose_name=_("Join Date"))
ai_analysis_data = models.JSONField(
verbose_name="AI Analysis Data",
default=dict,
help_text="Full JSON output from the resume scoring model."
)
# Scoring fields (populated by signal)
# match_score = models.IntegerField(db_index=True, null=True, blank=True) # Added index
# strengths = models.TextField(blank=True)
# weaknesses = models.TextField(blank=True)
# criteria_checklist = models.JSONField(default=dict, blank=True)
# major_category_name = models.TextField(db_index=True, blank=True, verbose_name=_("Major Category Name")) # Added index
# recommendation = models.TextField(blank=True, verbose_name=_("Recommendation"))
submitted_by_agency = models.ForeignKey(
)# {'resume_data': {}, 'analysis_data': {}}
retry = models.SmallIntegerField(verbose_name="Resume Parsing Retry",default=3)
hiring_source = models.CharField(
max_length=255,
null=True,
blank=True,
verbose_name=_("Hiring Source"),
choices=[("Public", "Public"), ("Internal", "Internal"), ("Agency", "Agency")],
default="Public",
)
hiring_agency = models.ForeignKey(
"HiringAgency",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="submitted_candidates",
verbose_name=_("Submitted by Agency"),
related_name="candidates",
verbose_name=_("Hiring Agency"),
)
retry = models.SmallIntegerField(verbose_name="Resume Parsing Retry",default=3)
class Meta:
verbose_name = _("Candidate")
@ -1167,6 +1178,47 @@ class Source(Base):
verbose_name=_("Sync Status"),
)
# Outbound sync configuration
sync_endpoint = models.URLField(
blank=True,
null=True,
verbose_name=_("Sync Endpoint"),
help_text=_("Endpoint URL for sending candidate data (for outbound sync)"),
)
sync_method = models.CharField(
max_length=10,
blank=True,
choices=[
("POST", "POST"),
("PUT", "PUT"),
],
default="POST",
verbose_name=_("Sync Method"),
help_text=_("HTTP method for outbound sync requests"),
)
test_method = models.CharField(
max_length=10,
blank=True,
choices=[
("GET", "GET"),
("POST", "POST"),
],
default="GET",
verbose_name=_("Test Method"),
help_text=_("HTTP method for connection testing"),
)
custom_headers = models.TextField(
blank=True,
null=True,
verbose_name=_("Custom Headers"),
help_text=_("JSON object with custom HTTP headers for sync requests"),
)
supports_outbound_sync = models.BooleanField(
default=False,
verbose_name=_("Supports Outbound Sync"),
help_text=_("Whether this source supports receiving candidate data from ATS"),
)
def __str__(self):
return self.name
@ -1199,7 +1251,7 @@ class IntegrationLog(Base):
max_length=20, choices=ActionChoices.choices, verbose_name=_("Action")
)
endpoint = models.CharField(max_length=255, blank=True, verbose_name=_("Endpoint"))
method = models.CharField(max_length=10, blank=True, verbose_name=_("HTTP Method"))
method = models.CharField(max_length=50, blank=True, verbose_name=_("HTTP Method"))
request_data = models.JSONField(
blank=True, null=True, verbose_name=_("Request Data")
)
@ -1259,6 +1311,275 @@ class HiringAgency(Base):
ordering = ["name"]
class AgencyJobAssignment(Base):
"""Assigns specific jobs to agencies with limits and deadlines"""
class AssignmentStatus(models.TextChoices):
ACTIVE = "ACTIVE", _("Active")
COMPLETED = "COMPLETED", _("Completed")
EXPIRED = "EXPIRED", _("Expired")
CANCELLED = "CANCELLED", _("Cancelled")
agency = models.ForeignKey(
HiringAgency,
on_delete=models.CASCADE,
related_name="job_assignments",
verbose_name=_("Agency")
)
job = models.ForeignKey(
JobPosting,
on_delete=models.CASCADE,
related_name="agency_assignments",
verbose_name=_("Job")
)
# Limits & Controls
max_candidates = models.PositiveIntegerField(
verbose_name=_("Maximum Candidates"),
help_text=_("Maximum candidates agency can submit for this job")
)
candidates_submitted = models.PositiveIntegerField(
default=0,
verbose_name=_("Candidates Submitted"),
help_text=_("Number of candidates submitted so far")
)
# Timeline
assigned_date = models.DateTimeField(auto_now_add=True, verbose_name=_("Assigned Date"))
deadline_date = models.DateTimeField(
verbose_name=_("Deadline Date"),
help_text=_("Deadline for agency to submit candidates")
)
# Status & Extensions
is_active = models.BooleanField(default=True, verbose_name=_("Is Active"))
status = models.CharField(
max_length=20,
choices=AssignmentStatus.choices,
default=AssignmentStatus.ACTIVE,
verbose_name=_("Status")
)
# Extension tracking
deadline_extended = models.BooleanField(
default=False,
verbose_name=_("Deadline Extended")
)
original_deadline = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Original Deadline"),
help_text=_("Original deadline before extensions")
)
# Admin notes
admin_notes = models.TextField(
blank=True,
verbose_name=_("Admin Notes"),
help_text=_("Internal notes about this assignment")
)
class Meta:
verbose_name = _("Agency Job Assignment")
verbose_name_plural = _("Agency Job Assignments")
ordering = ["-created_at"]
indexes = [
models.Index(fields=['agency', 'status']),
models.Index(fields=['job', 'status']),
models.Index(fields=['deadline_date']),
models.Index(fields=['is_active']),
]
unique_together = ['agency', 'job'] # Prevent duplicate assignments
def __str__(self):
return f"{self.agency.name} - {self.job.title}"
@property
def days_remaining(self):
"""Calculate days remaining until deadline"""
if not self.deadline_date:
return 0
delta = self.deadline_date.date() - timezone.now().date()
return max(0, delta.days)
@property
def is_currently_active(self):
"""Check if assignment is currently active"""
return (
self.status == 'ACTIVE' and
self.deadline_date and
self.deadline_date > timezone.now() and
self.candidates_submitted < self.max_candidates
)
@property
def can_submit(self):
"""Check if candidates can still be submitted"""
return self.is_currently_active
def clean(self):
"""Validate assignment constraints"""
if self.deadline_date and self.deadline_date <= timezone.now():
raise ValidationError(_("Deadline date must be in the future"))
if self.max_candidates <= 0:
raise ValidationError(_("Maximum candidates must be greater than 0"))
if self.candidates_submitted > self.max_candidates:
raise ValidationError(_("Candidates submitted cannot exceed maximum candidates"))
@property
def remaining_slots(self):
"""Return number of remaining candidate slots"""
return max(0, self.max_candidates - self.candidates_submitted)
@property
def is_expired(self):
"""Check if assignment has expired"""
return self.deadline_date and self.deadline_date <= timezone.now()
@property
def is_full(self):
"""Check if assignment has reached maximum candidates"""
return self.candidates_submitted >= self.max_candidates
@property
def can_submit(self):
"""Check if agency can still submit candidates"""
return (self.is_active and
not self.is_expired and
not self.is_full and
self.status == self.AssignmentStatus.ACTIVE)
def increment_submission_count(self):
"""Safely increment the submitted candidates count"""
if self.can_submit:
self.candidates_submitted += 1
self.save(update_fields=['candidates_submitted'])
# Check if assignment is now complete
# if self.candidates_submitted >= self.max_candidates:
# self.status = self.AssignmentStatus.COMPLETED
# self.save(update_fields=['status'])
return True
return False
def extend_deadline(self, new_deadline):
"""Extend the deadline for this assignment"""
# Convert database deadline to timezone-aware for comparison
deadline_aware = timezone.make_aware(self.deadline_date) if timezone.is_naive(self.deadline_date) else self.deadline_date
if new_deadline > deadline_aware:
if not self.deadline_extended:
self.original_deadline = self.deadline_date
self.deadline_extended = True
self.deadline_date = new_deadline
self.save(update_fields=['deadline_date', 'original_deadline', 'deadline_extended'])
return True
return False
class AgencyAccessLink(Base):
"""Secure access links for agencies to submit candidates"""
assignment = models.OneToOneField(
AgencyJobAssignment,
on_delete=models.CASCADE,
related_name="access_link",
verbose_name=_("Assignment")
)
# Security
unique_token = models.CharField(
max_length=64,
unique=True,
editable=False,
verbose_name=_("Unique Token")
)
access_password = models.CharField(
max_length=32,
verbose_name=_("Access Password"),
help_text=_("Password for agency access")
)
# Timeline
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
expires_at = models.DateTimeField(
verbose_name=_("Expires At"),
help_text=_("When this access link expires")
)
last_accessed = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Last Accessed")
)
# Usage tracking
access_count = models.PositiveIntegerField(
default=0,
verbose_name=_("Access Count")
)
is_active = models.BooleanField(
default=True,
verbose_name=_("Is Active")
)
class Meta:
verbose_name = _("Agency Access Link")
verbose_name_plural = _("Agency Access Links")
ordering = ["-created_at"]
indexes = [
models.Index(fields=['unique_token']),
models.Index(fields=['expires_at']),
models.Index(fields=['is_active']),
]
def __str__(self):
return f"Access Link for {self.assignment}"
def clean(self):
"""Validate access link constraints"""
if self.expires_at and self.expires_at <= timezone.now():
raise ValidationError(_("Expiration date must be in the future"))
@property
def is_expired(self):
"""Check if access link has expired"""
return self.expires_at and self.expires_at <= timezone.now()
@property
def is_valid(self):
"""Check if access link is valid and active"""
return self.is_active and not self.is_expired
def record_access(self):
"""Record an access to this link"""
self.last_accessed = timezone.now()
self.access_count += 1
self.save(update_fields=['last_accessed', 'access_count'])
def generate_token(self):
"""Generate a unique secure token"""
import secrets
self.unique_token = secrets.token_urlsafe(48)
def generate_password(self):
"""Generate a random password"""
import secrets
import string
alphabet = string.ascii_letters + string.digits
self.access_password = ''.join(secrets.choice(alphabet) for _ in range(12))
def save(self, *args, **kwargs):
"""Override save to generate token and password if not set"""
if not self.unique_token:
self.generate_token()
if not self.access_password:
self.generate_password()
super().save(*args, **kwargs)
class BreakTime(models.Model):
"""Model to store break times for a schedule"""
@ -1353,6 +1674,80 @@ class ScheduledInterview(Base):
models.Index(fields=['candidate', 'job']),
]
class Notification(models.Model):
"""
Model to store system notifications, primarily for emails.
"""
class NotificationType(models.TextChoices):
EMAIL = "email", _("Email")
IN_APP = "in_app", _("In-App") # For future expansion
class Status(models.TextChoices):
PENDING = "pending", _("Pending")
SENT = "sent", _("Sent")
READ = "read", _("Read")
FAILED = "failed", _("Failed")
RETRYING = "retrying", _("Retrying")
recipient = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="notifications",
verbose_name=_("Recipient")
)
message = models.TextField(verbose_name=_("Notification Message"))
notification_type = models.CharField(
max_length=20,
choices=NotificationType.choices,
default=NotificationType.EMAIL,
verbose_name=_("Notification Type")
)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING,
verbose_name=_("Status")
)
related_meeting = models.ForeignKey(
ZoomMeeting,
on_delete=models.CASCADE,
related_name="notifications",
null=True,
blank=True,
verbose_name=_("Related Meeting")
)
scheduled_for = models.DateTimeField(
verbose_name=_("Scheduled Send Time"),
help_text=_("The date and time this notification is scheduled to be sent.")
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
attempts = models.PositiveIntegerField(default=0, verbose_name=_("Send Attempts"))
last_error = models.TextField(blank=True, verbose_name=_("Last Error Message"))
class Meta:
ordering = ["-scheduled_for", "-created_at"]
verbose_name = _("Notification")
verbose_name_plural = _("Notifications")
indexes = [
models.Index(fields=['status', 'scheduled_for']),
models.Index(fields=['recipient']),
]
def __str__(self):
return f"Notification for {self.recipient.get_username()} ({self.get_status_display()})"
def mark_as_sent(self):
self.status = Notification.Status.SENT
self.last_error = ""
self.save(update_fields=['status', 'last_error'])
def mark_as_failed(self, error_message=""):
self.status = Notification.Status.FAILED
self.last_error = error_message
self.attempts += 1
self.save(update_fields=['status', 'last_error', 'attempts'])
class Participants(Base):

View File

@ -6,7 +6,9 @@ from django_q.tasks import schedule
from django.dispatch import receiver
from django_q.tasks import async_task
from django.db.models.signals import post_save
from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting
from django.contrib.auth.models import User
from django.utils import timezone
from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting,Notification
logger = logging.getLogger(__name__)
@ -58,7 +60,7 @@ def format_job(sender, instance, created, **kwargs):
def score_candidate_resume(sender, instance, created, **kwargs):
if not instance.is_resume_parsed:
logger.info(f"Scoring resume for candidate {instance.pk}")
async_task(
async_task(
'recruitment.tasks.handle_reume_parsing_and_scoring',
instance.pk,
hook='recruitment.hooks.callback_ai_parsing'
@ -361,3 +363,37 @@ def create_default_stages(sender, instance, created, **kwargs):
# order=3,
# is_predefined=True
# )
# AgencyMessage signal handler removed - model has been deleted
# SSE notification cache for real-time updates
SSE_NOTIFICATION_CACHE = {}
@receiver(post_save, sender=Notification)
def notification_created(sender, instance, created, **kwargs):
"""Signal handler for when a notification is created"""
if created:
logger.info(f"New notification created: {instance.id} for user {instance.recipient.username}")
# Store notification in cache for SSE
user_id = instance.recipient.id
if user_id not in SSE_NOTIFICATION_CACHE:
SSE_NOTIFICATION_CACHE[user_id] = []
notification_data = {
'id': instance.id,
'message': instance.message[:100] + ('...' if len(instance.message) > 100 else ''),
'type': instance.get_notification_type_display(),
'status': instance.get_status_display(),
'time_ago': 'Just now',
'url': f"/notifications/{instance.id}/"
}
SSE_NOTIFICATION_CACHE[user_id].append(notification_data)
# Keep only last 50 notifications per user in cache
if len(SSE_NOTIFICATION_CACHE[user_id]) > 50:
SSE_NOTIFICATION_CACHE[user_id] = SSE_NOTIFICATION_CACHE[user_id][-50:]
logger.info(f"Notification cached for SSE: {notification_data}")

View File

@ -600,4 +600,147 @@ def form_close(job_id):
job = get_object_or_404(JobPosting, pk=job_id)
job.is_active = False
job.template_form.is_active = False
job.save()
job.save()
def sync_hired_candidates_task(job_slug):
"""
Django-Q background task to sync hired candidates to all configured sources.
Args:
job_slug (str): The slug of the job posting
Returns:
dict: Sync results with status and details
"""
from .candidate_sync_service import CandidateSyncService
from .models import JobPosting, IntegrationLog
logger.info(f"Starting background sync task for job: {job_slug}")
try:
# Get the job posting
job = JobPosting.objects.get(slug=job_slug)
# Initialize sync service
sync_service = CandidateSyncService()
print(sync_service)
# Perform the sync operation
results = sync_service.sync_hired_candidates_to_all_sources(job)
print(results)
# Log the sync operation
# IntegrationLog.objects.create(
# source=None, # This is a multi-source sync operation
# action=IntegrationLog.ActionChoices.SYNC,
# endpoint="multi_source_sync",
# method="BACKGROUND_TASK",
# request_data={"job_slug": job_slug, "candidate_count": job.accepted_candidates.count()},
# response_data=results,
# status_code="SUCCESS" if results.get('summary', {}).get('failed', 0) == 0 else "PARTIAL",
# ip_address="127.0.0.1", # Background task
# user_agent="Django-Q Background Task",
# processing_time=results.get('summary', {}).get('total_duration', 0)
# )
logger.info(f"Background sync completed for job {job_slug}: {results}")
return results
except JobPosting.DoesNotExist:
error_msg = f"Job posting not found: {job_slug}"
logger.error(error_msg)
# Log the error
IntegrationLog.objects.create(
source=None,
action=IntegrationLog.ActionChoices.ERROR,
endpoint="multi_source_sync",
method="BACKGROUND_TASK",
request_data={"job_slug": job_slug},
error_message=error_msg,
status_code="ERROR",
ip_address="127.0.0.1",
user_agent="Django-Q Background Task"
)
return {"status": "error", "message": error_msg}
except Exception as e:
error_msg = f"Unexpected error during sync: {str(e)}"
logger.error(error_msg, exc_info=True)
# Log the error
IntegrationLog.objects.create(
source=None,
action=IntegrationLog.ActionChoices.ERROR,
endpoint="multi_source_sync",
method="BACKGROUND_TASK",
request_data={"job_slug": job_slug},
error_message=error_msg,
status_code="ERROR",
ip_address="127.0.0.1",
user_agent="Django-Q Background Task"
)
return {"status": "error", "message": error_msg}
def sync_candidate_to_source_task(candidate_id, source_id):
"""
Django-Q background task to sync a single candidate to a specific source.
Args:
candidate_id (int): The ID of the candidate
source_id (int): The ID of the source
Returns:
dict: Sync result for this specific candidate-source pair
"""
from .candidate_sync_service import CandidateSyncService
from .models import Candidate, Source, IntegrationLog
logger.info(f"Starting sync task for candidate {candidate_id} to source {source_id}")
try:
# Get the candidate and source
candidate = Candidate.objects.get(pk=candidate_id)
source = Source.objects.get(pk=source_id)
# Initialize sync service
sync_service = CandidateSyncService()
# Perform the sync operation
result = sync_service.sync_candidate_to_source(candidate, source)
# Log the operation
IntegrationLog.objects.create(
source=source,
action=IntegrationLog.ActionChoices.SYNC,
endpoint=source.sync_endpoint or "unknown",
method=source.sync_method or "POST",
request_data={"candidate_id": candidate_id, "candidate_name": candidate.name},
response_data=result,
status_code="SUCCESS" if result.get('success') else "ERROR",
error_message=result.get('error') if not result.get('success') else None,
ip_address="127.0.0.1",
user_agent="Django-Q Background Task",
processing_time=result.get('duration', 0)
)
logger.info(f"Sync completed for candidate {candidate_id} to source {source_id}: {result}")
return result
except Candidate.DoesNotExist:
error_msg = f"Candidate not found: {candidate_id}"
logger.error(error_msg)
return {"success": False, "error": error_msg}
except Source.DoesNotExist:
error_msg = f"Source not found: {source_id}"
logger.error(error_msg)
return {"success": False, "error": error_msg}
except Exception as e:
error_msg = f"Unexpected error during sync: {str(e)}"
logger.error(error_msg, exc_info=True)
return {"success": False, "error": error_msg}

View File

@ -14,7 +14,7 @@ urlpatterns = [
path('jobs/<slug:slug>/update/', views.edit_job, name='job_update'),
# path('jobs/<slug:slug>/delete/', views., name='job_delete'),
path('jobs/<slug:slug>/', views.job_detail, name='job_detail'),
path('careers/',views.kaauh_career,name='kaauh_career'),
# LinkedIn Integration URLs
@ -71,8 +71,14 @@ 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('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'),
@ -84,7 +90,7 @@ urlpatterns = [
path('htmx/<slug:slug>/candidate_update_status/', views.candidate_update_status, name='candidate_update_status'),
# path('forms/form/<slug:template_slug>/submit/', views.submit_form, name='submit_form'),
# path('forms/form/<slug:template_slug>/', views.form_wizard_view, name='form_wizard'),
# path('forms/form/<slug:template_slug>/', views.form_wizard_view, name='form_wizard'),
path('forms/<int:template_id>/submissions/<slug:slug>/', views.form_submission_details, name='form_submission_details'),
path('forms/template/<slug:slug>/submissions/', views.form_template_submissions_list, name='form_template_submissions_list'),
path('forms/template/<int:template_id>/all-submissions/', views.form_template_all_submissions, name='form_template_all_submissions'),
@ -140,11 +146,79 @@ urlpatterns = [
# Meeting Comments URLs
path('meetings/<slug:slug>/comments/add/', views.add_meeting_comment, name='add_meeting_comment'),
path('meetings/<slug:slug>/comments/<int:comment_id>/edit/', views.edit_meeting_comment, name='edit_meeting_comment'),
path('meetings/<slug:slug>/comments/<int:comment_id>/delete/', views.delete_meeting_comment, name='delete_meeting_comment'),
path('meetings/<slug:slug>/set_meeting_candidate/', views.set_meeting_candidate, name='set_meeting_candidate'),
# Hiring Agency URLs
path('agencies/', views.agency_list, name='agency_list'),
path('agencies/create/', views.agency_create, name='agency_create'),
path('agencies/<slug:slug>/', views.agency_detail, name='agency_detail'),
path('agencies/<slug:slug>/update/', views.agency_update, name='agency_update'),
path('agencies/<slug:slug>/delete/', views.agency_delete, name='agency_delete'),
path('agencies/<slug:slug>/candidates/', views.agency_candidates, name='agency_candidates'),
# path('agencies/<slug:slug>/send-message/', views.agency_detail_send_message, name='agency_detail_send_message'),
# Agency Assignment Management URLs
path('agency-assignments/', views.agency_assignment_list, name='agency_assignment_list'),
path('agency-assignments/create/', views.agency_assignment_create, name='agency_assignment_create'),
path('agency-assignments/<slug:slug>/create/', views.agency_assignment_create, name='agency_assignment_create'),
path('agency-assignments/<slug:slug>/', views.agency_assignment_detail, name='agency_assignment_detail'),
path('agency-assignments/<slug:slug>/update/', views.agency_assignment_update, name='agency_assignment_update'),
path('agency-assignments/<slug:slug>/extend-deadline/', views.agency_assignment_extend_deadline, name='agency_assignment_extend_deadline'),
# Agency Access Link URLs
path('agency-access-links/create/', views.agency_access_link_create, name='agency_access_link_create'),
path('agency-access-links/<slug:slug>/', views.agency_access_link_detail, name='agency_access_link_detail'),
path('agency-access-links/<slug:slug>/deactivate/', views.agency_access_link_deactivate, name='agency_access_link_deactivate'),
path('agency-access-links/<slug:slug>/reactivate/', views.agency_access_link_reactivate, name='agency_access_link_reactivate'),
# Admin Message Center URLs (messaging functionality removed)
# path('admin/messages/', views.admin_message_center, name='admin_message_center'),
# path('admin/messages/compose/', views.admin_compose_message, name='admin_compose_message'),
# path('admin/messages/<int:message_id>/', views.admin_message_detail, name='admin_message_detail'),
# path('admin/messages/<int:message_id>/reply/', views.admin_message_reply, name='admin_message_reply'),
# path('admin/messages/<int:message_id>/mark-read/', views.admin_mark_message_read, name='admin_mark_message_read'),
# path('admin/messages/<int:message_id>/delete/', views.admin_delete_message, name='admin_delete_message'),
# Agency Portal URLs (for external agencies)
path('portal/login/', views.agency_portal_login, name='agency_portal_login'),
path('portal/dashboard/', views.agency_portal_dashboard, name='agency_portal_dashboard'),
path('portal/assignment/<slug:slug>/', views.agency_portal_assignment_detail, name='agency_portal_assignment_detail'),
path('portal/assignment/<slug:slug>/submit-candidate/', views.agency_portal_submit_candidate_page, name='agency_portal_submit_candidate_page'),
path('portal/submit-candidate/', views.agency_portal_submit_candidate, name='agency_portal_submit_candidate'),
path('portal/logout/', views.agency_portal_logout, name='agency_portal_logout'),
# Agency Portal Candidate Management URLs
path('portal/candidates/<int:candidate_id>/edit/', views.agency_portal_edit_candidate, name='agency_portal_edit_candidate'),
path('portal/candidates/<int:candidate_id>/delete/', views.agency_portal_delete_candidate, name='agency_portal_delete_candidate'),
# API URLs for messaging (removed)
# path('api/agency/messages/<int:message_id>/', views.api_agency_message_detail, name='api_agency_message_detail'),
# path('api/agency/messages/<int:message_id>/mark-read/', views.api_agency_mark_message_read, name='api_agency_mark_message_read'),
# API URLs for candidate management
path('api/candidate/<int:candidate_id>/', views.api_candidate_detail, name='api_candidate_detail'),
# Admin Notification API
path('api/admin/notification-count/', views.api_notification_count, name='admin_notification_count'),
# Agency Notification API
path('api/agency/notification-count/', views.api_notification_count, name='api_agency_notification_count'),
# SSE Notification Stream
path('api/notifications/stream/', views.notification_stream, name='notification_stream'),
# Notification URLs
path('notifications/', views.notification_list, name='notification_list'),
path('notifications/<int:notification_id>/', views.notification_detail, name='notification_detail'),
path('notifications/<int:notification_id>/mark-read/', views.notification_mark_read, name='notification_mark_read'),
path('notifications/<int:notification_id>/mark-unread/', views.notification_mark_unread, name='notification_mark_unread'),
path('notifications/<int:notification_id>/delete/', views.notification_delete, name='notification_delete'),
path('notifications/mark-all-read/', views.notification_mark_all_read, name='notification_mark_all_read'),
path('api/notification-count/', views.api_notification_count, name='api_notification_count'),
#participants urls
path('participants/', views_frontend.ParticipantsListView.as_view(), name='participants_list'),

File diff suppressed because it is too large Load Diff

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
@ -478,6 +481,35 @@ def candidate_offer_view(request, slug):
return render(request, 'recruitment/candidate_offer_view.html', context)
@login_required
def candidate_hired_view(request, slug):
"""View for hired candidates"""
job = get_object_or_404(models.JobPosting, slug=slug)
# Filter candidates with offer_status = 'Accepted'
candidates = job.hired_candidates
# Handle search
search_query = request.GET.get('search', '')
if search_query:
candidates = candidates.filter(
Q(first_name__icontains=search_query) |
Q(last_name__icontains=search_query) |
Q(email__icontains=search_query) |
Q(phone__icontains=search_query)
)
candidates = candidates.order_by('-created_at')
context = {
'job': job,
'candidates': candidates,
'search_query': search_query,
'current_stage': 'Hired',
}
return render(request, 'recruitment/candidate_hired_view.html', context)
@login_required
def update_candidate_status(request, job_slug, candidate_slug, stage_type, status):
"""Handle exam/interview/offer status updates"""
@ -485,32 +517,23 @@ def update_candidate_status(request, job_slug, candidate_slug, stage_type, statu
job = get_object_or_404(models.JobPosting, slug=job_slug)
candidate = get_object_or_404(models.Candidate, slug=candidate_slug, job=job)
print(stage_type,status)
if request.method == "POST":
if stage_type == 'exam':
candidate.exam_status = status
candidate.exam_date = timezone.now()
candidate.save(update_fields=['exam_status', 'exam_date'])
return render(request,'recruitment/partials/exam-results.html',{'candidate':candidate,'job':job})
elif stage_type == 'interview':
candidate.interview_status = status
candidate.interview_date = timezone.now()
candidate.save(update_fields=['interview_status', 'interview_date'])
return render(request,'recruitment/partials/interview-results.html',{'candidate':candidate,'job':job})
elif stage_type == 'offer':
candidate.offer_status = status
candidate.offer_date = timezone.now()
candidate.save(update_fields=['offer_status', 'offer_date'])
messages.success(request, f"Candidate {status} successfully!")
else:
messages.error(request, "No changes made.")
if stage_type == 'exam':
return redirect('candidate_exam_view', job.slug)
elif stage_type == 'interview':
return redirect('candidate_interview_view', job.slug)
elif stage_type == 'offer':
return redirect('candidate_offer_view', job.slug)
return render(request,'recruitment/partials/offer-results.html',{'candidate':candidate,'job':job})
return redirect('candidate_detail', candidate.slug)
else:
if stage_type == 'exam':
@ -521,10 +544,332 @@ def update_candidate_status(request, job_slug, candidate_slug, stage_type, statu
return render(request,"includes/candidate_update_offer_form.html",{'candidate':candidate,'job':job})
# Removed incorrect JobDetailView class.
# Stage configuration for CSV export
STAGE_CONFIG = {
'screening': {
'filter': {'stage': 'Applied'},
'fields': ['name', 'email', 'phone', 'created_at', 'stage', 'ai_score', 'years_experience', 'screening_rating', 'professional_category', 'top_skills', 'strengths', 'weaknesses'],
'headers': ['Name', 'Email', 'Phone', 'Application Date', 'Screening Status', 'Match Score', 'Years Experience', 'Screening Rating', 'Professional Category', 'Top 3 Skills', 'Strengths', 'Weaknesses']
},
'exam': {
'filter': {'stage': 'Exam'},
'fields': ['name', 'email', 'phone', 'created_at', 'exam_status', 'exam_date', 'ai_score', 'years_experience', 'screening_rating'],
'headers': ['Name', 'Email', 'Phone', 'Application Date', 'Exam Status', 'Exam Date', 'Match Score', 'Years Experience', 'Screening Rating']
},
'interview': {
'filter': {'stage': 'Interview'},
'fields': ['name', 'email', 'phone', 'created_at', 'interview_status', 'interview_date', 'ai_score', 'years_experience', 'professional_category', 'top_skills'],
'headers': ['Name', 'Email', 'Phone', 'Application Date', 'Interview Status', 'Interview Date', 'Match Score', 'Years Experience', 'Professional Category', 'Top 3 Skills']
},
'offer': {
'filter': {'stage': 'Offer'},
'fields': ['name', 'email', 'phone', 'created_at', 'offer_status', 'offer_date', 'ai_score', 'years_experience', 'professional_category'],
'headers': ['Name', 'Email', 'Phone', 'Application Date', 'Offer Status', 'Offer Date', 'Match Score', 'Years Experience', 'Professional Category']
},
'hired': {
'filter': {'offer_status': 'Accepted'},
'fields': ['name', 'email', 'phone', 'created_at', 'offer_date', 'ai_score', 'years_experience', 'professional_category', 'join_date'],
'headers': ['Name', 'Email', 'Phone', 'Application Date', 'Hire Date', 'Match Score', 'Years Experience', 'Professional Category', 'Join Date']
}
}
@login_required
def export_candidates_csv(request, job_slug, stage):
"""Export candidates for a specific stage as CSV"""
job = get_object_or_404(models.JobPosting, slug=job_slug)
# Validate stage
if stage not in STAGE_CONFIG:
messages.error(request, "Invalid stage specified for export.")
return redirect('job_detail', job.slug)
config = STAGE_CONFIG[stage]
# Filter candidates based on stage
if stage == 'hired':
candidates = job.candidates.filter(**config['filter'])
else:
candidates = job.candidates.filter(**config['filter'])
# Handle search if provided
search_query = request.GET.get('search', '')
if search_query:
candidates = candidates.filter(
Q(first_name__icontains=search_query) |
Q(last_name__icontains=search_query) |
Q(email__icontains=search_query) |
Q(phone__icontains=search_query)
)
candidates = candidates.order_by('-created_at')
# Create CSV response
response = HttpResponse(content_type='text/csv')
filename = f"{slugify(job.title)}_{stage}_{datetime.now().strftime('%Y-%m-%d')}.csv"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
# Write UTF-8 BOM for Excel compatibility
response.write('\ufeff')
writer = csv.writer(response)
# Write headers
headers = config['headers'].copy()
headers.extend(['Job Title', 'Department'])
writer.writerow(headers)
# Write candidate data
for candidate in candidates:
row = []
# Extract data based on stage configuration
for field in config['fields']:
if field == 'name':
row.append(candidate.name)
elif field == 'email':
row.append(candidate.email)
elif field == 'phone':
row.append(candidate.phone)
elif field == 'created_at':
row.append(candidate.created_at.strftime('%Y-%m-%d %H:%M') if candidate.created_at else '')
elif field == 'stage':
row.append(candidate.stage or '')
elif field == 'exam_status':
row.append(candidate.exam_status or '')
elif field == 'exam_date':
row.append(candidate.exam_date.strftime('%Y-%m-%d %H:%M') if candidate.exam_date else '')
elif field == 'interview_status':
row.append(candidate.interview_status or '')
elif field == 'interview_date':
row.append(candidate.interview_date.strftime('%Y-%m-%d %H:%M') if candidate.interview_date else '')
elif field == 'offer_status':
row.append(candidate.offer_status or '')
elif field == 'offer_date':
row.append(candidate.offer_date.strftime('%Y-%m-%d %H:%M') if candidate.offer_date else '')
elif field == 'ai_score':
# Extract AI score using model property
try:
score = candidate.match_score
row.append(f"{score}%" if score else '')
except:
row.append('')
elif field == 'years_experience':
# Extract years of experience using model property
try:
years = candidate.years_of_experience
row.append(f"{years}" if years else '')
except:
row.append('')
elif field == 'screening_rating':
# Extract screening rating using model property
try:
rating = candidate.screening_stage_rating
row.append(rating if rating else '')
except:
row.append('')
elif field == 'professional_category':
# Extract professional category using model property
try:
category = candidate.professional_category
row.append(category if category else '')
except:
row.append('')
elif field == 'top_skills':
# Extract top 3 skills using model property
try:
skills = candidate.top_3_keywords
row.append(', '.join(skills) if skills else '')
except:
row.append('')
elif field == 'strengths':
# Extract strengths using model property
try:
strengths = candidate.strengths
row.append(strengths if strengths else '')
except:
row.append('')
elif field == 'weaknesses':
# Extract weaknesses using model property
try:
weaknesses = candidate.weaknesses
row.append(weaknesses if weaknesses else '')
except:
row.append('')
elif field == 'join_date':
row.append(candidate.join_date.strftime('%Y-%m-%d') if candidate.join_date else '')
else:
row.append(getattr(candidate, field, ''))
# Add job information
row.extend([job.title, job.department or ''])
writer.writerow(row)
return response
# Removed incorrect
# The job_detail view is handled by function-based view in recruitment.views
@login_required
def sync_hired_candidates(request, job_slug):
"""Sync hired candidates to external sources using Django-Q"""
from django_q.tasks import async_task
from .tasks import sync_hired_candidates_task
if request.method == 'POST':
job = get_object_or_404(models.JobPosting, slug=job_slug)
try:
# Enqueue sync task to Django-Q for background processing
task_id = async_task(
sync_hired_candidates_task,
job_slug,
group=f"sync_job_{job_slug}",
timeout=300 # 5 minutes timeout
)
print("task_id",task_id)
# Return immediate response with task ID for tracking
return JsonResponse({
'status': 'queued',
'message': 'Sync task has been queued for background processing',
'task_id': task_id
})
except Exception as e:
return JsonResponse({
'status': 'error',
'message': f'Failed to queue sync task: {str(e)}'
}, status=500)
# For GET requests, return error
return JsonResponse({
'status': 'error',
'message': 'Only POST requests are allowed'
}, status=405)
@login_required
def test_source_connection(request, source_id):
"""Test connection to an external source"""
from .candidate_sync_service import CandidateSyncService
if request.method == 'POST':
source = get_object_or_404(models.Source, id=source_id)
try:
# Initialize sync service
sync_service = CandidateSyncService()
# Test connection
result = sync_service.test_source_connection(source)
# Return JSON response
return JsonResponse({
'status': 'success',
'result': result
})
except Exception as e:
return JsonResponse({
'status': 'error',
'message': f'Connection test failed: {str(e)}'
}, status=500)
# For GET requests, return error
return JsonResponse({
'status': 'error',
'message': 'Only POST requests are allowed'
}, status=405)
@login_required
def sync_task_status(request, task_id):
"""Check the status of a sync task"""
from django_q.models import Task
try:
# Get the task from Django-Q
task = Task.objects.get(pk=task_id)
print("task",task)
# Determine status based on task state
if task.success:
status = 'completed'
message = 'Sync completed successfully'
result = task.result
elif task.stopped:
status = 'failed'
message = 'Sync task failed or was stopped'
result = task.result
elif task.started:
status = 'running'
message = 'Sync is currently running'
result = None
else:
status = 'pending'
message = 'Sync task is queued and waiting to start'
result = None
print("result",result)
return JsonResponse({
'status': status,
'message': message,
'result': result,
'task_id': task_id,
'started': task.started,
'stopped': task.stopped,
'success': task.success
})
except Task.DoesNotExist:
return JsonResponse({
'status': 'error',
'message': 'Task not found'
}, status=404)
except Exception as e:
return JsonResponse({
'status': 'error',
'message': f'Failed to check task status: {str(e)}'
}, status=500)
@login_required
def sync_history(request, job_slug=None):
"""View sync history and logs"""
from .models import IntegrationLog
from django_q.models import Task
# Get sync logs
if job_slug:
# Filter for specific job
job = get_object_or_404(models.JobPosting, slug=job_slug)
logs = IntegrationLog.objects.filter(
action=IntegrationLog.ActionChoices.SYNC,
request_data__job_slug=job_slug
).order_by('-created_at')
else:
# Get all sync logs
logs = IntegrationLog.objects.filter(
action=IntegrationLog.ActionChoices.SYNC
).order_by('-created_at')
# Get recent sync tasks
recent_tasks = Task.objects.filter(
group__startswith='sync_job_'
).order_by('-started')[:20]
context = {
'logs': logs,
'recent_tasks': recent_tasks,
'job': job if job_slug else None,
}
return render(request, 'recruitment/sync_history.html', context)
#participants views
class ParticipantsListView(LoginRequiredMixin, ListView):
model = models.Participants

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

203
templates/agency_base.html Normal file
View File

@ -0,0 +1,203 @@
{% load static i18n %}
{% get_current_language as LANGUAGE_CODE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE }}" dir="{% if LANGUAGE_CODE == 'ar' %}rtl{% else %}ltr{% endif %}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% trans 'King Abdullah Academic University Hospital - Agency Portal' %}">
<title>{% block title %}{% trans 'KAAUH Agency Portal' %}{% endblock %}</title>
{% comment %} Load correct Bootstrap CSS file for RTL/LTR {% endcomment %}
{% if LANGUAGE_CODE == 'ar' %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
{% else %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
{% endif %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css" integrity="sha512-Kc323vGBEqzTmouAECnVceyQqyqdsSiqLQISBL29aUW4U/M7pSPA/gEUZQqv1cwx4OnYxTxve5UMg5GT6L4JJg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="{% static 'css/main.css' %}">
{% block customCSS %}{% endblock %}
</head>
<body class="d-flex flex-column min-vh-100" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% comment %} <div class="top-bar d-none d-md-block">
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center gap-2 max-width-1600">
<div class="logo-container d-flex gap-2">
</div>
<div class="clogo-container d-flex gap-2">
</div>
<div class="logo-container d-flex gap-2 align-items-center">
<img src="{% static 'image/vision.svg' %}" alt="{% trans 'Saudi Vision 2030' %}" loading="lazy" style="height: 35px; object-fit: contain;">
<div class="kaauh-logo-container d-flex flex-column flex-md-row align-items-center gap-2 me-0">
<div class="hospital-text text-center text-md-start me-0">
<div class="ar text-xs">جامعة الأميرة نورة بنت عبدالرحمن الأكاديمية</div>
<div class="ar text-xs">ومستشفى الملك عبدالله بن عبدالرحمن التخصصي</div>
<div class="en text-xs">Princess Nourah bint Abdulrahman University</div>
<div class="en text-xs">King Abdullah bin Abdulaziz University Hospital</div>
</div>
</div>
<img src="{% static 'image/kaauh.png' %}" alt="KAAUH Logo" style="max-height: 40px; max-width: 40px;">
</div>
</div>
</div>
</div> {% endcomment %}
<!-- Agency Portal Header -->
<nav class="navbar navbar-expand-lg navbar-dark sticky-top">
<div class="container-fluid max-width-1600">
<!-- Agency Portal Brand -->
<a class="navbar-brand text-white" href="{% url 'agency_portal_dashboard' %}" aria-label="Agency Dashboard">
<img src="{% static 'image/kaauh_green1.png' %}" alt="{% trans 'kaauh logo green bg' %}" style="width: 60px; height: 60px;">
<span class="ms-3 d-none d-lg-inline">{% trans "Agency Portal" %}</span>
</a>
<!-- Mobile Toggler -->
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#agencyNavbar"
aria-controls="agencyNavbar" aria-expanded="false" aria-label="{% trans 'Toggle navigation' %}">
<span class="navbar-toggler-icon"></span>
</button>
<!-- Agency Controls -->
<div class="collapse navbar-collapse" id="agencyNavbar">
<div class="navbar-nav ms-auto">
<!-- Language Switcher -->
<li class="nav-item dropdown">
<a class="language-toggle-btn dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
data-bs-offset="0, 8" aria-expanded="false" aria-label="{% trans 'Toggle language menu' %}">
<i class="fas fa-globe"></i>
<span class="d-none d-lg-inline">{{ LANGUAGE_CODE|upper }}</span>
</a>
<ul class="dropdown-menu {% if LANGUAGE_CODE == 'ar' %}dropdown-menu-start{% else %}dropdown-menu-end{% endif %}" data-bs-popper="static">
<li>
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
<input name="next" type="hidden" value="{{ request.get_full_path }}">
<button name="language" value="en" class="dropdown-item {% if LANGUAGE_CODE == 'en' %}active bg-light-subtle{% endif %}" type="submit">
<span class="me-2">🇺🇸</span> English
</button>
</form>
</li>
<li>
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
<input name="next" type="hidden" value="{{ request.get_full_path }}">
<button name="language" value="ar" class="dropdown-item {% if LANGUAGE_CODE == 'ar' %}active bg-light-subtle{% endif %}" type="submit">
<span class="me-2">🇸🇦</span> العربية (Arabic)
</button>
</form>
</li>
</ul>
</li>
<!-- Logout -->
<li class="nav-item ms-3">
<form method="post" action="{% url 'agency_portal_logout' %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-outline-light btn-sm">
<i class="fas fa-sign-out-alt me-1"></i> {% trans "Logout" %}
</button>
</form>
</li>
</div>
</div>
</div>
</nav>
<main class="container-fluid flex-grow-1" style="max-width: 1600px; margin: 0 auto;">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mt-2" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% trans 'Close' %}"></button>
</div>
{% endfor %}
{% endif %}
{% block content %}
{% endblock %}
</main>
<footer class="mt-auto">
<div class="footer-bottom py-3 small text-muted" style="background-color: #00363a;">
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center flex-wrap max-width-1600">
<p class="mb-0 text-white-50">
&copy; {% now "Y" %} {% trans "King Abdullah Academic University Hospital (KAAUH)." %}
{% trans "All rights reserved." %}
</p>
<p class="mb-0 text-white-50">
{% trans "Agency Portal" %}
</p>
</div>
</div>
</div>
</footer>
{% include 'includes/delete_modal.html' %}
<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 (Mobile UX)
const navbarCollapse = document.getElementById('agencyNavbar');
if (navbarCollapse) {
const navLinks = navbarCollapse.querySelectorAll('.nav-link:not(.dropdown-toggle), .dropdown-item');
const bsCollapse = bootstrap.Collapse.getInstance(navbarCollapse) || new bootstrap.Collapse(navbarCollapse, { toggle: false });
navLinks.forEach(link => {
link.addEventListener('click', () => {
if (navbarCollapse.classList.contains('show')) {
if (!link.classList.contains('dropdown-toggle')) {
bsCollapse.hide();
}
}
});
});
}
// Mobile logout confirmation
const logoutButton = document.querySelector('form[action*="logout"] button');
if (logoutButton) {
logoutButton.addEventListener('click', (e) => {
if (window.innerWidth < 992) {
const confirmed = confirm('{% trans "Are you sure you want to logout?" %}');
if (!confirmed) e.preventDefault();
}
});
}
});
function form_loader(){
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', function(e) {
const submitButton = form.querySelector('button[type="submit"], input[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
submitButton.classList.add('loading');
window.addEventListener('unload', function() {
submitButton.disabled = false;
submitButton.classList.remove('loading');
});
}
});
});
}
try {
document.addEventListener('htmx:afterSwap', form_loader);
} catch(e) {
console.error(e);
}
</script>
{% block customJS %}{% endblock %}
</body>
</html>

View File

@ -9,7 +9,7 @@
<meta name="description" content="{% trans 'King Abdullah Academic University Hospital - Applicant Tracking System' %}">
<title>{% block title %}{% trans 'University ATS' %}{% endblock %}</title>
{% comment %} Load the correct Bootstrap CSS file for RTL/LTR {% endcomment %}
{% comment %} Load correct Bootstrap CSS file for RTL/LTR {% endcomment %}
{% if LANGUAGE_CODE == 'ar' %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
{% else %}
@ -99,6 +99,30 @@
</ul>
<ul class="navbar-nav ms-2 ms-lg-4">
<!-- Notification Bell for Admin Users -->
{% if request.user.is_authenticated and request.user.is_staff %}
<li class="nav-item dropdown me-2">
<a class="nav-link position-relative" href="#" role="button" id="notificationDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-bell"></i>
<span id="admin-notification-badge" class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger" style="display: none; font-size: 0.6em; min-width: 18px; height: 18px; line-height: 18px;">
0
</span>
</a>
<ul class="dropdown-menu dropdown-menu-end" style="min-width: 300px;" aria-labelledby="notificationDropdown">
<li class="dropdown-header d-flex justify-content-between align-items-center">
<span>{% trans "Messages" %}</span>
<a href="#" class="text-decoration-none">{% trans "View All" %}</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<div id="admin-notification-list" class="px-3 py-2 text-muted text-center">
<small>{% trans "Loading messages..." %}</small>
</div>
</li>
</ul>
</li>
{% endif %}
<li class="nav-item dropdown">
<button
class="nav-link p-0 border-0 bg-transparent dropdown-toggle"
@ -213,6 +237,14 @@
</span>
</a>
</li>
<li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'agency_list' %}active{% endif %}" href="{% url 'agency_list' %}">
<span class="d-flex align-items-center gap-2">
<i class="fas fa-building"></i>
{% trans "Agencies" %}
</span>
</a>
</li>
<li class="nav-item me-lg-4">
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'list_meetings' %}">
<span class="d-flex align-items-center gap-2">
@ -292,20 +324,21 @@
<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)
const navbarCollapse = document.getElementById('navbarNav');
if (navbarCollapse) {
// Select all links, including those inside the "More" dropdown
// Select all links, including those inside "More" dropdown
const navLinks = navbarCollapse.querySelectorAll('.nav-link:not(.dropdown-toggle), .dropdown-item');
const bsCollapse = bootstrap.Collapse.getInstance(navbarCollapse) || new bootstrap.Collapse(navbarCollapse, { toggle: false });
navLinks.forEach(link => {
link.addEventListener('click', () => {
// Only collapse if the nav is actually shown (i.e., on mobile)
// Only collapse if nav is actually shown (i.e., on mobile)
if (navbarCollapse.classList.contains('show')) {
// Check if the click was on a non-dropdown-toggle or a dropdown item (which navigate away)
// Check if click was on a non-dropdown-toggle or a dropdown item (which navigate away)
if (!link.classList.contains('dropdown-toggle')) {
bsCollapse.hide();
}
@ -355,7 +388,323 @@
}
</script>
<!-- Notification JavaScript for Admin Users -->
{% if request.user.is_authenticated and request.user.is_staff %}
<script>
// SSE Notification System
let eventSource = null;
let reconnectAttempts = 0;
let maxReconnectAttempts = 5;
let reconnectDelay = 1000; // Start with 1 second
function connectSSE() {
// Close existing connection if any
if (eventSource) {
eventSource.close();
}
// Create new EventSource connection
eventSource = new EventSource('{% url "notification_stream" %}');
eventSource.onopen = function(event) {
console.log('SSE connection opened');
reconnectAttempts = 0;
reconnectDelay = 1000; // Reset delay on successful connection
// Update connection status indicator if exists
const statusIndicator = document.getElementById('sse-status');
if (statusIndicator) {
statusIndicator.className = 'text-success';
statusIndicator.title = 'Connected';
}
};
eventSource.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
if (event.type === 'new_notification') {
handleNewNotification(data);
} else if (event.type === 'count_update') {
updateNotificationCount(data.count);
} else if (event.type === 'heartbeat') {
console.log('SSE heartbeat received');
}
} catch (error) {
console.error('Error parsing SSE message:', error);
}
};
eventSource.addEventListener('new_notification', function(event) {
try {
const notification = JSON.parse(event.data);
handleNewNotification(notification);
} catch (error) {
console.error('Error parsing new notification:', error);
}
});
eventSource.addEventListener('count_update', function(event) {
try {
const data = JSON.parse(event.data);
updateNotificationCount(data.count);
} catch (error) {
console.error('Error parsing count update:', error);
}
});
eventSource.addEventListener('heartbeat', function(event) {
try {
const data = JSON.parse(event.data);
console.log('SSE heartbeat:', new Date(data.timestamp * 1000));
} catch (error) {
console.error('Error parsing heartbeat:', error);
}
});
eventSource.addEventListener('error', function(event) {
console.error('SSE error:', event);
handleSSEError();
});
eventSource.onerror = function(event) {
console.error('SSE connection error:', event);
handleSSEError();
};
}
function handleNewNotification(notification) {
console.log('New notification received:', notification);
// Update badge
updateNotificationCount();
// Show toast notification
showToast(notification);
// Update dropdown list
addNotificationToList(notification);
// Play sound (optional)
playNotificationSound();
}
function updateNotificationCount(count) {
const badge = document.getElementById('admin-notification-badge');
if (count !== undefined) {
// Use provided count
if (count > 0) {
badge.style.display = 'inline-block';
badge.textContent = count;
} else {
badge.style.display = 'none';
}
} else {
// Fetch current count
fetch('{% url "admin_notification_count" %}')
.then(response => response.json())
.then(data => {
if (data.count > 0) {
badge.style.display = 'inline-block';
badge.textContent = data.count;
} else {
badge.style.display = 'none';
}
})
.catch(error => {
console.error('Error fetching notification count:', error);
});
}
}
function addNotificationToList(notification) {
const list = document.getElementById('admin-notification-list');
if (!list) return;
// Create new notification element
const notificationElement = document.createElement('div');
notificationElement.className = 'notification-item px-3 py-2 border-bottom';
notificationElement.innerHTML = `
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="fw-semibold small">${notification.message}</div>
<div class="text-muted small">${notification.type}</div>
</div>
<span class="badge bg-info ms-2">${notification.status}</span>
</div>
<div class="text-muted small mt-1">${notification.time_ago}</div>
`;
// Add click handler to navigate to notification detail
notificationElement.style.cursor = 'pointer';
notificationElement.addEventListener('click', function() {
window.location.href = notification.url;
});
// Insert at the top of the list
list.insertBefore(notificationElement, list.firstChild);
// Remove "No new messages" placeholder if exists
const placeholder = list.querySelector('.text-muted.text-center');
if (placeholder) {
placeholder.remove();
}
}
function showToast(notification) {
// Create toast container if it doesn't exist
let toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
toastContainer.className = 'position-fixed top-0 end-0 p-3';
toastContainer.style.zIndex = '1050';
document.body.appendChild(toastContainer);
}
// Create toast element
const toast = document.createElement('div');
toast.className = 'toast show align-items-center text-white bg-primary border-0 mb-2';
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
toast.setAttribute('aria-atomic', 'true');
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<strong>New Notification</strong><br>
<small>${notification.message}</small>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
`;
// Add click handler to close toast
const closeButton = toast.querySelector('.btn-close');
closeButton.addEventListener('click', function() {
toast.remove();
});
// Add to container
toastContainer.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 5000);
}
function playNotificationSound() {
// Create and play a simple notification sound
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800; // 800 Hz tone
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.1);
} catch (error) {
console.log('Could not play notification sound:', error);
}
}
function handleSSEError() {
// Update connection status indicator if exists
const statusIndicator = document.getElementById('sse-status');
if (statusIndicator) {
statusIndicator.className = 'text-danger';
statusIndicator.title = 'Disconnected';
}
// Attempt to reconnect with exponential backoff
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
console.log(`Attempting to reconnect (${reconnectAttempts}/${maxReconnectAttempts}) in ${reconnectDelay}ms...`);
setTimeout(() => {
connectSSE();
reconnectDelay = Math.min(reconnectDelay * 2, 30000); // Max 30 seconds
}, reconnectDelay);
} else {
console.error('Max reconnection attempts reached. Falling back to polling.');
// Fallback to polling
setInterval(updateNotificationBadge, 30000);
}
}
// Initialize SSE connection on page load
document.addEventListener('DOMContentLoaded', function() {
// Only connect SSE for authenticated staff users
if ('{{ request.user.is_authenticated|yesno:"true,false" }}' === 'true' && '{{ request.user.is_staff|yesno:"true,false" }}' === 'true') {
connectSSE();
// Initial notification count update
updateNotificationCount();
}
});
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (eventSource) {
eventSource.close();
}
});
// Fallback function for manual refresh
function updateNotificationBadge() {
fetch('{% url "admin_notification_count" %}')
.then(response => response.json())
.then(data => {
const badge = document.getElementById('admin-notification-badge');
const list = document.getElementById('admin-notification-list');
if (data.count > 0) {
badge.style.display = 'inline-block';
badge.textContent = data.count;
} else {
badge.style.display = 'none';
}
// Update notification list
if (data.recent_notifications && data.recent_notifications.length > 0) {
list.innerHTML = data.recent_notifications.map(msg => `
<div class="notification-item px-3 py-2 border-bottom">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="fw-semibold small">${msg.message}</div>
<div class="text-muted small">${msg.type}</div>
</div>
<span class="badge bg-info ms-2">${msg.status}</span>
</div>
<div class="text-muted small mt-1">${msg.time_ago}</div>
</div>
`).join('');
} else {
list.innerHTML = '<div class="px-3 py-2 text-muted text-center"><small>{% trans "No new messages" %}</small></div>';
}
})
.catch(error => {
console.error('Error fetching notifications:', error);
const list = document.getElementById('admin-notification-list');
list.innerHTML = '<div class="px-3 py-2 text-muted text-center"><small>{% trans "Error loading messages" %}</small></div>';
});
}
</script>
{% endif %}
{% block customJS %}{% endblock %}
</body>
</html>
</html>

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.hired_candidates.count|default:"0" }}</div>
</a>
</div>
</div>

View File

@ -0,0 +1,82 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{{ title }} - ATS{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header bg-warning text-white">
<h5 class="mb-0">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ title }}
</h5>
</div>
<div class="card-body">
<div class="alert alert-warning" role="alert">
<i class="fas fa-info-circle me-2"></i>
{{ message }}
</div>
<div class="d-flex align-items-center mb-3">
<div class="me-3">
<strong>Agency:</strong> {{ access_link.assignment.agency.name }}
</div>
<div class="me-3">
<strong>Job:</strong> {{ access_link.assignment.job.title }}
</div>
</div>
<div class="d-flex align-items-center mb-3">
<div class="me-3">
<strong>Current Status:</strong>
<span class="badge bg-{{ 'success' if access_link.is_active else 'danger' }}">
{{ 'Active' if access_link.is_active else 'Inactive' }}
</span>
</div>
</div>
<div class="d-flex align-items-center mb-3">
<div class="me-3">
<strong>Expires:</strong> {{ access_link.expires_at|date:"Y-m-d H:i" }}
</div>
</div>
<div class="d-flex align-items-center mb-3">
<div class="me-3">
<strong>Access Count:</strong> {{ access_link.access_count }}
</div>
</div>
<div class="d-flex align-items-center mb-3">
<div class="me-3">
<strong>Last Accessed:</strong>
{% if access_link.last_accessed %}
{{ access_link.last_accessed|date:"Y-m-d H:i" }}
{% else %}
Never
{% endif %}
</div>
</div>
<form method="post" action="{% url request.resolver_match.url_name %}">
{% csrf_token %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{{ cancel_url }}" class="btn btn-secondary">
<i class="fas fa-times me-2"></i>
Cancel
</a>
<button type="submit" class="btn btn-warning">
<i class="fas fa-{{ 'toggle-on' if title == 'Reactivate Access Link' else 'toggle-off' }} me-2"></i>
{{ title }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,230 @@
{% extends 'agency_base.html' %}
{% load static i18n %}
{% block title %}{% trans "Access Link Details" %} - ATS{% 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-link me-2"></i>
{% trans "Access Link Details" %}
</h1>
<p class="text-muted mb-0">{% trans "Secure access link for agency candidate submissions" %}</p>
</div>
<a href="{% url 'agency_assignment_detail' access_link.assignment.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Assignment" %}
</a>
</div>
<div class="row">
<div class="col-md-8">
<div class="kaauh-card shadow-sm mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<h5 class="card-title mb-0">
<i class="fas fa-shield-alt me-2 text-primary"></i>
{% trans "Access Information" %}
</h5>
<span class="badge {% if access_link.is_active %}bg-success{% else %}bg-danger{% endif %}">
{% if access_link.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}
</span>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label text-muted small">{% trans "Assignment" %}</label>
<div class="fw-semibold">
<a href="{% url 'agency_assignment_detail' access_link.assignment.slug %}" class="text-decoration-none">
{{ access_link.assignment.agency.name }} - {{ access_link.assignment.job.title }}
</a>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small">{% trans "Agency" %}</label>
<div class="fw-semibold">{{ access_link.assignment.agency.name }}</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small">{% trans "Created At" %}</label>
<div class="fw-semibold">{{ access_link.created_at|date:"Y-m-d H:i" }}</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small">{% trans "Expires At" %}</label>
<div class="fw-semibold {% if access_link.is_expired %}text-danger{% endif %}">
{{ access_link.expires_at|date:"Y-m-d H:i" }}
{% if access_link.is_expired %}
<span class="badge bg-danger ms-2">{% trans "Expired" %}</span>
{% endif %}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small">{% trans "Max Candidates" %}</label>
<div class="fw-semibold">{{ access_link.assignment.max_candidates }}</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small">{% trans "Candidates Submitted" %}</label>
<div class="fw-semibold">{{ access_link.assignment.candidates_submitted }}</div>
</div>
</div>
</div>
</div>
<div class="kaauh-card shadow-sm">
<div class="card-body">
<h5 class="card-title mb-3">
<i class="fas fa-key me-2 text-warning"></i>
{% trans "Access Credentials" %}
</h5>
<div class="mb-3">
<label class="form-label text-muted small">{% trans "Login URL" %}</label>
<div class="input-group">
<input type="text" readonly value="{{ request.scheme }}://{{ request.get_host }}{% url 'agency_portal_login' %}"
class="form-control font-monospace" id="loginUrl">
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('loginUrl')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label text-muted small">{% trans "Access Token" %}</label>
<div class="input-group">
<input type="text" readonly value="{{ access_link.unique_token }}"
class="form-control font-monospace" id="accessToken">
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('accessToken')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label text-muted small">{% trans "Password" %}</label>
<div class="input-group">
<input type="text" readonly value="{{ access_link.access_password }}"
class="form-control font-monospace" id="accessPassword">
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('accessPassword')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
{% trans "Share these credentials securely with the agency. They can use this information to log in and submit candidates." %}
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="kaauh-card shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title mb-3">
<i class="fas fa-chart-line me-2 text-info"></i>
{% trans "Usage Statistics" %}
</h5>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted">{% trans "Total Accesses" %}</span>
<span class="fw-semibold">{{ access_link.access_count }}</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted">{% trans "Last Accessed" %}</span>
<span class="fw-semibold">
{% if access_link.last_accessed %}
{{ access_link.last_accessed|date:"Y-m-d H:i" }}
{% else %}
<span class="text-muted">{% trans "Never" %}</span>
{% endif %}
</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">{% trans "Submissions" %}</span>
<span class="fw-semibold">{{ access_link.assignment.candidates_submitted }}/{{ access_link.assignment.max_candidates }}</span>
</div>
</div>
<div class="progress" style="height: 8px;">
{% widthratio access_link.assignment.candidates_submitted access_link.assignment.max_candidates 100 as progress_percent %}
<div class="progress-bar {% if progress_percent >= 80 %}bg-danger{% elif progress_percent >= 60 %}bg-warning{% else %}bg-success{% endif %}"
style="width: {{ progress_percent }}%"></div>
</div>
</div>
</div>
<div class="kaauh-card shadow-sm">
<div class="card-body">
<h5 class="card-title mb-3">
<i class="fas fa-cog me-2 text-secondary"></i>
{% trans "Actions" %}
</h5>
<div class="d-grid gap-2">
<a href="{% url 'agency_assignment_detail' access_link.assignment.slug %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-eye me-1"></i> {% trans "View Assignment" %}
</a>
{% if access_link.is_active and not access_link.is_expired %}
<button class="btn btn-warning btn-sm" onclick="confirmDeactivate()">
<i class="fas fa-pause me-1"></i> {% trans "Deactivate" %}
</button>
{% endif %}
{% if access_link.is_expired or not access_link.is_active %}
<button class="btn btn-success btn-sm" onclick="confirmReactivate()">
<i class="fas fa-play me-1"></i> {% trans "Reactivate" %}
</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
element.select();
document.execCommand('copy');
// Show feedback
const button = element.nextElementSibling;
const originalHTML = button.innerHTML;
button.innerHTML = '<i class="fas fa-check"></i>';
button.classList.add('btn-success');
button.classList.remove('btn-outline-secondary');
setTimeout(() => {
button.innerHTML = originalHTML;
button.classList.remove('btn-success');
button.classList.add('btn-outline-secondary');
}, 2000);
}
function confirmDeactivate() {
if (confirm('{% trans "Are you sure you want to deactivate this access link? Agencies will no longer be able to use it." %}')) {
// Submit form to deactivate
window.location.href = '{% url "agency_access_link_deactivate" access_link.slug %}';
}
}
function confirmReactivate() {
if (confirm('{% trans "Are you sure you want to reactivate this access link?" %}')) {
// Submit form to reactivate
window.location.href = '{% url "agency_access_link_reactivate" access_link.slug %}';
}
}
</script>
{% endblock %}

View File

@ -0,0 +1,152 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{% trans "Create Access Link" %} - ATS{% 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-link me-2"></i>
{% trans "Create Access Link" %}
</h1>
<p class="text-muted mb-0">{% trans "Generate a secure access link for agency to submit candidates" %}</p>
</div>
<a href="{% url 'agency_assignment_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Assignments" %}
</a>
</div>
<div class="kaauh-card shadow-sm">
<div class="card-body p-4">
{% if form.non_field_errors %}
<div class="alert alert-danger">
{% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<form method="post" novalidate>
{% csrf_token %}
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.assignment.id_for_label }}" class="form-label">
{% trans "Assignment" %} <span class="text-danger">*</span>
</label>
{{ form.assignment }}
{% if form.assignment.errors %}
<div class="text-danger small mt-1">
{% for error in form.assignment.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{% trans "Select the agency job assignment" %}</div>
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.expires_at.id_for_label }}" class="form-label">
{% trans "Expires At" %} <span class="text-danger">*</span>
</label>
{{ form.expires_at }}
{% if form.expires_at.errors %}
<div class="text-danger small mt-1">
{% for error in form.expires_at.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{% trans "When will this access link expire?" %}</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.max_submissions.id_for_label }}" class="form-label">
{% trans "Max Submissions" %}
</label>
{{ form.max_submissions }}
{% if form.max_submissions.errors %}
<div class="text-danger small mt-1">
{% for error in form.max_submissions.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{% trans "Maximum number of candidates agency can submit (leave blank for unlimited)" %}</div>
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.is_active.id_for_label }}" class="form-label">
{% trans "Status" %}
</label>
<div class="form-check mt-2">
{{ form.is_active }}
<label class="form-check-label" for="{{ form.is_active.id_for_label }}">
{% trans "Active" %}
</label>
</div>
{% if form.is_active.errors %}
<div class="text-danger small mt-1">
{% for error in form.is_active.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{% trans "Whether this access link is currently active" %}</div>
</div>
</div>
<div class="mb-3">
<label for="{{ form.notes.id_for_label }}" class="form-label">
{% trans "Notes" %}
</label>
{{ form.notes }}
{% if form.notes.errors %}
<div class="text-danger small mt-1">
{% for error in form.notes.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">{% trans "Additional notes or instructions for the agency" %}</div>
</div>
<div class="d-flex justify-content-between align-items-center">
<div>
<small class="text-muted">
<i class="fas fa-info-circle me-1"></i>
{% trans "Access links will be generated with a secure token that agencies can use to log in" %}
</small>
</div>
<div>
<a href="{% url 'agency_assignment_list' %}" class="btn btn-outline-secondary me-2">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-main-action">
<i class="fas fa-plus me-1"></i> {% trans "Create Access Link" %}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-populate expires_at with 7 days from now
const expiresAtField = document.getElementById('{{ form.expires_at.id_for_label }}');
if (expiresAtField && !expiresAtField.value) {
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
expiresAtField.value = sevenDaysFromNow.toISOString().slice(0, 16);
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,492 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{{ assignment.agency.name }} - {{ assignment.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;
}
.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;
}
.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);
}
.status-badge {
font-size: 0.75rem;
padding: 0.3em 0.7em;
border-radius: 0.35rem;
font-weight: 700;
}
.status-ACTIVE { background-color: var(--kaauh-success); color: white; }
.status-EXPIRED { background-color: var(--kaauh-danger); color: white; }
.status-COMPLETED { background-color: var(--kaauh-info); color: white; }
.status-CANCELLED { background-color: var(--kaauh-warning); color: #856404; }
.progress-ring {
width: 120px;
height: 120px;
position: relative;
}
.progress-ring-circle {
transition: stroke-dashoffset 0.35s;
transform: rotate(-90deg);
transform-origin: 50% 50%;
}
.progress-ring-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.5rem;
font-weight: 700;
color: var(--kaauh-teal-dark);
}
.message-item {
border-left: 4px solid var(--kaauh-teal);
background-color: #f8f9fa;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 0 0.5rem 0.5rem 0;
}
.message-item.unread {
border-left-color: var(--kaauh-info);
background-color: #e7f3ff;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<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-tasks me-2"></i>
{{ assignment.agency.name }} - {{ assignment.job.title }}
</h1>
<p class="text-muted mb-0">
{% trans "Assignment Details and Management" %}
</p>
</div>
<div>
<a href="{% url 'agency_assignment_list' %}" class="btn btn-outline-secondary me-2">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Assignments" %}
</a>
<a href="{% url 'agency_assignment_update' assignment.slug %}" class="btn btn-main-action">
<i class="fas fa-edit me-1"></i> {% trans "Edit Assignment" %}
</a>
</div>
</div>
<div class="row">
<!-- Assignment Overview -->
<div class="col-lg-8">
<!-- Assignment Details Card -->
<div class="kaauh-card p-4 mb-4">
<h5 class="mb-4" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-info-circle me-2"></i>
{% trans "Assignment Details" %}
</h5>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="text-muted small">{% trans "Agency" %}</label>
<div class="fw-bold">{{ assignment.agency.name }}</div>
<div class="text-muted small">{{ assignment.agency.contact_person }}</div>
</div>
<div class="mb-3">
<label class="text-muted small">{% trans "Job" %}</label>
<div class="fw-bold">{{ assignment.job.title }}</div>
<div class="text-muted small">{{ assignment.job.department }}</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="text-muted small">{% trans "Status" %}</label>
<div>
<span class="status-badge status-{{ assignment.status }}">
{{ assignment.get_status_display }}
</span>
</div>
</div>
<div class="mb-3">
<label class="text-muted small">{% trans "Deadline" %}</label>
<div class="{% if assignment.is_expired %}text-danger{% else %}text-muted{% endif %}">
<i class="fas fa-calendar-alt me-1"></i>
{{ assignment.deadline_date|date:"Y-m-d H:i" }}
</div>
{% if assignment.is_expired %}
<small class="text-danger">
<i class="fas fa-exclamation-triangle me-1"></i>{% trans "Expired" %}
</small>
{% endif %}
</div>
</div>
</div>
{% if assignment.admin_notes %}
<div class="mt-3 pt-3 border-top">
<label class="text-muted small">{% trans "Admin Notes" %}</label>
<div class="text-muted">{{ assignment.admin_notes }}</div>
</div>
{% endif %}
</div>
<!-- Candidates Card -->
<div class="kaauh-card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-users me-2"></i>
{% trans "Submitted Candidates" %} ({{ total_candidates }})
</h5>
{% if access_link %}
<a href="{% url 'agency_portal_login' %}" target="_blank" class="btn btn-outline-info btn-sm">
<i class="fas fa-external-link-alt me-1"></i> {% trans "Preview Portal" %}
</a>
{% endif %}
</div>
{% if candidates %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Contact" %}</th>
<th>{% trans "Stage" %}</th>
<th>{% trans "Submitted" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for candidate in candidates %}
<tr>
<td>
<div class="fw-bold">{{ candidate.name }}</div>
</td>
<td>
<div class="small">
<div><i class="fas fa-envelope me-1"></i> {{ candidate.email }}</div>
<div><i class="fas fa-phone me-1"></i> {{ candidate.phone }}</div>
</div>
</td>
<td>
<span class="badge bg-info">{{ candidate.get_stage_display }}</span>
</td>
<td>
<div class="small text-muted">
{{ candidate.created_at|date:"Y-m-d H:i" }}
</div>
</td>
<td>
<a href="{% url 'candidate_detail' candidate.slug %}"
class="btn btn-sm btn-outline-primary" title="{% trans 'View Details' %}">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="fas fa-users fa-2x text-muted mb-3"></i>
<h6 class="text-muted">{% trans "No candidates submitted yet" %}</h6>
<p class="text-muted small">
{% trans "Candidates will appear here once the agency submits them through their portal." %}
</p>
</div>
{% endif %}
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Progress Card -->
<div class="kaauh-card p-4 mb-4">
<h5 class="mb-4 text-center" style="color: var(--kaauh-teal-dark);">
{% trans "Submission Progress" %}
</h5>
<div class="text-center mb-3">
<div class="progress-ring">
<svg width="120" height="120">
<circle class="progress-ring-circle"
stroke="#e9ecef"
stroke-width="8"
fill="transparent"
r="52"
cx="60"
cy="60"/>
<circle class="progress-ring-circle"
stroke="var(--kaauh-teal)"
stroke-width="8"
fill="transparent"
r="52"
cx="60"
cy="60"
style="stroke-dasharray: 326.73; stroke-dashoffset: {{ stroke_dashoffset }};"/>
</svg>
<div class="progress-ring-text">
{% widthratio total_candidates assignment.max_candidates 100 as progress %}
{{ progress|floatformat:0 }}%
</div>
</div>
</div>
<div class="text-center">
<div class="h4 mb-1">{{ total_candidates }}</div>
<div class="text-muted">/ {{ assignment.max_candidates }} {% trans "candidates" %}</div>
</div>
<div class="progress mt-3" style="height: 8px;">
{% widthratio total_candidates assignment.max_candidates 100 as progress %}
<div class="progress-bar" style="width: {{ progress }}%"></div>
</div>
</div>
<!-- Access Link Card -->
<div class="kaauh-card p-4 mb-4">
<h5 class="mb-4" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-link me-2"></i>
{% trans "Access Link" %}
</h5>
{% if access_link %}
<div class="mb-3">
<label class="text-muted small">{% trans "Status" %}</label>
<div>
{% if access_link.is_active %}
<span class="badge bg-success">{% trans "Active" %}</span>
{% else %}
<span class="badge bg-secondary">{% trans "Inactive" %}</span>
{% endif %}
</div>
</div>
<div class="mb-3">
<label class="text-muted small">{% trans "Token" %}</label>
<div class="font-monospace small bg-light p-2 rounded">
{{ access_link.unique_token }}
</div>
</div>
<div class="mb-3">
<label class="text-muted small">{% trans "Expires" %}</label>
<div class="{% if access_link.is_expired %}text-danger{% else %}text-muted{% endif %}">
{{ access_link.expires_at|date:"Y-m-d H:i" }}
</div>
</div>
<div class="mb-3">
<label class="text-muted small">{% trans "Access Count" %}</label>
<div>{{ access_link.access_count }} {% trans "times accessed" %}</div>
</div>
<div class="d-grid gap-2">
<a href="{% url 'agency_access_link_detail' access_link.slug %}"
class="btn btn-outline-info btn-sm">
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
</a>
<button type="button" class="btn btn-outline-secondary btn-sm"
onclick="copyToClipboard('{{ access_link.unique_token }}')">
<i class="fas fa-copy me-1"></i> {% trans "Copy Token" %}
</button>
</div>
{% else %}
<div class="text-center py-3">
<i class="fas fa-link fa-2x text-muted mb-3"></i>
<h6 class="text-muted">{% trans "No Access Link" %}</h6>
<p class="text-muted small">
{% trans "Create an access link to allow the agency to submit candidates." %}
</p>
<a href="{% url 'agency_access_link_create' %}" class="btn btn-main-action btn-sm">
<i class="fas fa-plus me-1"></i> {% trans "Create Access Link" %}
</a>
</div>
{% endif %}
</div>
<!-- Actions Card -->
<div class="kaauh-card p-4">
<h5 class="mb-4" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-cog me-2"></i>
{% trans "Actions" %}
</h5>
<div class="d-grid gap-2">
<a href=""
class="btn btn-outline-primary">
<i class="fas fa-envelope me-1"></i> {% trans "Send Message" %}
</a>
{% if assignment.is_active and not assignment.is_expired %}
<button type="button" class="btn btn-outline-warning"
data-bs-toggle="modal" data-bs-target="#extendDeadlineModal">
<i class="fas fa-clock me-1"></i> {% trans "Extend Deadline" %}
</button>
{% endif %}
<a href="{% url 'agency_assignment_update' assignment.slug %}"
class="btn btn-outline-secondary">
<i class="fas fa-edit me-1"></i> {% trans "Edit Assignment" %}
</a>
</div>
</div>
</div>
</div>
<!-- Messages Section -->
{% if messages_ %}
<div class="kaauh-card p-4 mt-4">
<h5 class="mb-4" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-comments me-2"></i>
{% trans "Recent Messages" %}
</h5>
<div class="row">
{% for message in messages_|slice:":6" %}
<div class="col-lg-6 mb-3">
<div class="message-item {% if not message.is_read %}unread{% endif %}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="fw-bold">{{ message.subject }}</div>
<small class="text-muted">{{ message.created_at|date:"Y-m-d H:i" }}</small>
</div>
<div class="small text-muted mb-2">
{% trans "From" %}: {{ message.sender.get_full_name }}
</div>
<div class="small">{{ message.message|truncatewords:30 }}</div>
{% if not message.is_read %}
<span class="badge bg-info mt-2">{% trans "New" %}</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% if messages_.count > 6 %}
<div class="text-center mt-3">
<a href="#" class="btn btn-outline-primary btn-sm">
{% trans "View All Messages" %}
</a>
</div>
{% endif %}
</div>
{% endif %}
</div>
<!-- Extend Deadline Modal -->
<div class="modal fade" id="extendDeadlineModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-clock me-2"></i>
{% trans "Extend Assignment Deadline" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="post" action="{% url 'agency_assignment_extend_deadline' assignment.slug %}">
{% csrf_token %}
<div class="modal-body">
<div class="mb-3">
<label for="new_deadline" class="form-label">
{% trans "New Deadline" %} <span class="text-danger">*</span>
</label>
<input type="datetime-local" class="form-control" id="new_deadline"
name="new_deadline" required>
<small class="form-text text-muted">
{% trans "Current deadline:" %} {{ assignment.deadline_date|date:"Y-m-d H:i" }}
</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% trans "Cancel" %}
</button>
<button type="submit" class="btn btn-main-action">
<i class="fas fa-clock me-1"></i> {% trans "Extend Deadline" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
// Show success message
const toast = document.createElement('div');
toast.className = 'position-fixed top-0 end-0 p-3';
toast.style.zIndex = '1050';
toast.innerHTML = `
<div class="toast show" role="alert">
<div class="toast-header">
<strong class="me-auto">{% trans "Success" %}</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body">
{% trans "Token copied to clipboard!" %}
</div>
</div>
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
});
}
document.addEventListener('DOMContentLoaded', function() {
// Set minimum datetime for new deadline
const deadlineInput = document.getElementById('new_deadline');
if (deadlineInput) {
const currentDeadline = new Date('{{ assignment.deadline_date|date:"Y-m-d\\TH:i" }}');
const now = new Date();
const minDateTime = new Date(Math.max(currentDeadline, now));
const localDateTime = new Date(minDateTime.getTime() - minDateTime.getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16);
deadlineInput.min = localDateTime;
deadlineInput.value = localDateTime;
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,251 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{{ 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;
}
.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;
}
.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);
}
.form-control:focus, .form-select:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
.form-label {
font-weight: 600;
color: var(--kaauh-primary-text);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row">
<div class="col-lg-8 mx-auto">
<!-- Header -->
<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-tasks me-2"></i>
{{ title }}
</h1>
<p class="text-muted mb-0">
{% trans "Assign a job to an external hiring agency" %}
</p>
</div>
<a href="{% url 'agency_assignment_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Assignments" %}
</a>
</div>
<!-- Form Card -->
<div class="kaauh-card">
<form method="post" novalidate>
{% csrf_token %}
<!-- Agency and Job Selection -->
{{ form.agency }}
<div class="row g-3 mb-4">
{% comment %} <div class="col-md-6">
<label for="{{ form.agency.id_for_label }}" class="form-label">
{{ form.agency.label }} <span class="text-danger">*</span>
</label>
{{ form.agency }}
{% if form.agency.errors %}
<div class="text-danger small mt-1">
{% for error in form.agency.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div> {% endcomment %}
<div class="col-md-6">
<label for="{{ form.job.id_for_label }}" class="form-label">
{{ form.job.label }} <span class="text-danger">*</span>
</label>
{{ form.job }}
{% if form.job.errors %}
<div class="text-danger small mt-1">
{% for error in form.job.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<!-- Assignment Details -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<label for="{{ form.max_candidates.id_for_label }}" class="form-label">
{{ form.max_candidates.label }} <span class="text-danger">*</span>
</label>
{{ form.max_candidates }}
{% if form.max_candidates.errors %}
<div class="text-danger small mt-1">
{% for error in form.max_candidates.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">
{% trans "Maximum number of candidates the agency can submit" %}
</small>
</div>
<div class="col-md-6">
<label for="{{ form.deadline_date.id_for_label }}" class="form-label">
{{ form.deadline_date.label }} <span class="text-danger">*</span>
</label>
{{ form.deadline_date }}
{% if form.deadline_date.errors %}
<div class="text-danger small mt-1">
{% for error in form.deadline_date.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">
{% trans "Date and time when submission period ends" %}
</small>
</div>
</div>
<!-- Status and Settings -->
{% comment %} <div class="row g-3 mb-4">
<div class="col-md-6">
<label for="{{ form.is_active.id_for_label }}" class="form-label">
{{ form.is_active.label }}
</label>
<div class="form-check form-switch">
{{ form.is_active }}
<label class="form-check-label" for="{{ form.is_active.id_for_label }}">
{% trans "Enable this assignment" %}
</label>
</div>
{% if form.is_active.errors %}
<div class="text-danger small mt-1">
{% for error in form.is_active.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.status.id_for_label }}" class="form-label">
{{ form.status.label }}
</label>
{{ form.status }}
{% if form.status.errors %}
<div class="text-danger small mt-1">
{% for error in form.status.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">
{% trans "Current status of this assignment" %}
</small>
</div>
</div> {% endcomment %}
<!-- Admin Notes -->
<div class="mb-4">
<label for="{{ form.admin_notes.id_for_label }}" class="form-label">
{{ form.admin_notes.label }}
</label>
{{ form.admin_notes }}
{% if form.admin_notes.errors %}
<div class="text-danger small mt-1">
{% for error in form.admin_notes.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">
{% trans "Internal notes about this assignment (not visible to agency)" %}
</small>
</div>
<!-- Form Actions -->
<div class="d-flex justify-content-between align-items-center pt-3 border-top">
<a href="{% url 'agency_assignment_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-main-action">
<i class="fas fa-save me-1"></i> {{ button_text }}
</button>
</div>
</form>
</div>
<!-- Help Information -->
{% comment %} <div class="kaauh-card mt-4">
<h5 class="mb-3" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-info-circle me-2"></i>
{% trans "Assignment Information" %}
</h5>
<div class="row">
<div class="col-md-6">
<h6 class="fw-bold text-primary">{% trans "Active Status" %}</h6>
<p class="text-muted small">
{% trans "Only active assignments allow agencies to submit candidates. Expired or cancelled assignments cannot receive new submissions." %}
</p>
</div>
<div class="col-md-6">
<h6 class="fw-bold text-primary">{% trans "Access Links" %}</h6>
<p class="text-muted small">
{% trans "After creating an assignment, you can generate access links for agencies to submit candidates through their portal." %}
</p>
</div>
</div>
</div> {% endcomment %}
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-populate agency field when job is selected
const jobSelect = document.getElementById('{{ form.job.id_for_label }}');
const agencySelect = document.getElementById('{{ form.agency.id_for_label }}');
if (jobSelect && agencySelect) {
jobSelect.addEventListener('change', function() {
// You could add logic here to filter agencies based on job requirements
// For now, just log the selection
console.log('Job selected:', this.value);
});
}
// Set minimum datetime for deadline to current time
const deadlineInput = document.getElementById('{{ form.deadline_date.id_for_label }}');
if (deadlineInput) {
const now = new Date();
const localDateTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16);
deadlineInput.min = localDateTime;
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,237 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{% trans "Agency Assignments" %} - 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;
}
.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;
}
.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);
}
.status-badge {
font-size: 0.75rem;
padding: 0.3em 0.7em;
border-radius: 0.35rem;
font-weight: 700;
}
.status-ACTIVE { background-color: var(--kaauh-success); color: white; }
.status-EXPIRED { background-color: var(--kaauh-danger); color: white; }
.status-COMPLETED { background-color: var(--kaauh-info); color: white; }
.status-CANCELLED { background-color: var(--kaauh-warning); color: #856404; }
</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-tasks me-2"></i>
{% trans "Agency Assignments" %}
</h1>
<h2 class="h5 text-muted mb-0">
{% trans "Total Assignments:" %} <span class="fw-bold">{{ total_assignments }}</span>
</h2>
</div>
<div>
<a href="{% url 'agency_assignment_create' %}" class="btn btn-main-action">
<i class="fas fa-plus me-1"></i> {% trans "New Assignment" %}
</a>
</div>
</div>
<!-- Search and Filters -->
<div class="kaauh-card p-3 mb-4">
<form method="get" class="row g-3">
<div class="col-md-6">
<div class="form-group">
<label for="search" class="form-label">{% trans "Search" %}</label>
<input type="text" class="form-control" id="search" name="q"
value="{{ search_query }}" placeholder="{% trans 'Search by agency or job title...' %}">
</div>
</div>
<div class="col-md-4">
<div class="form-group">
<label for="status" class="form-label">{% trans "Status" %}</label>
<select class="form-select" id="status" name="status">
<option value="">{% trans "All Statuses" %}</option>
<option value="ACTIVE" {% if status_filter == 'ACTIVE' %}selected{% endif %}>{% trans "Active" %}</option>
<option value="EXPIRED" {% if status_filter == 'EXPIRED' %}selected{% endif %}>{% trans "Expired" %}</option>
<option value="COMPLETED" {% if status_filter == 'COMPLETED' %}selected{% endif %}>{% trans "Completed" %}</option>
<option value="CANCELLED" {% if status_filter == 'CANCELLED' %}selected{% endif %}>{% trans "Cancelled" %}</option>
</select>
</div>
</div>
<div class="col-md-2">
<div class="form-group">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-outline-secondary w-100">
<i class="fas fa-search me-1"></i> {% trans "Search" %}
</button>
</div>
</div>
</form>
</div>
<!-- Assignments List -->
<div class="kaauh-card">
{% if page_obj %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th><i class="fas fa-building me-1"></i> {% trans "Agency" %}</th>
<th><i class="fas fa-briefcase me-1"></i> {% trans "Job" %}</th>
<th><i class="fas fa-users me-1"></i> {% trans "Candidates" %}</th>
<th><i class="fas fa-clock me-1"></i> {% trans "Deadline" %}</th>
<th><i class="fas fa-info-circle me-1"></i> {% trans "Status" %}</th>
<th><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for assignment in page_obj %}
<tr>
<td>
<div class="fw-bold">{{ assignment.agency.name }}</div>
<div class="text-muted small">{{ assignment.agency.contact_person }}</div>
</td>
<td>
<div class="fw-bold">{{ assignment.job.title }}</div>
<div class="text-muted small">{{ assignment.job.department }}</div>
</td>
<td>
<div class="d-flex align-items-center">
<span class="badge bg-primary me-2">{{ assignment.submitted_count }}</span>
<span class="text-muted">/ {{ assignment.max_candidates }}</span>
</div>
<div class="progress mt-1" style="height: 4px;">
{% widthratio assignment.submitted_count assignment.max_candidates 100 as progress %}
<div class="progress-bar" style="width: {{ progress }}%"></div>
</div>
</td>
<td>
<div class="{% if assignment.is_expired %}text-danger{% else %}text-muted{% endif %}">
<i class="fas fa-calendar-alt me-1"></i>
{{ assignment.deadline_date|date:"Y-m-d H:i" }}
</div>
{% if assignment.is_expired %}
<small class="text-danger">
<i class="fas fa-exclamation-triangle me-1"></i>{% trans "Expired" %}
</small>
{% endif %}
</td>
<td>
<span class="status-badge status-{{ assignment.status }}">
{{ assignment.get_status_display }}
</span>
</td>
<td>
<div class="btn-group" role="group">
<a href="{% url 'agency_assignment_detail' assignment.slug %}"
class="btn btn-sm btn-outline-primary" title="{% trans 'View Details' %}">
<i class="fas fa-eye"></i>
</a>
<a href="{% url 'agency_assignment_update' assignment.slug %}"
class="btn btn-sm btn-outline-secondary" title="{% trans 'Edit' %}">
<i class="fas fa-edit"></i>
</a>
{% if assignment.access_link %}
<a href="{% url 'agency_access_link_detail' assignment.access_link.slug %}"
class="btn btn-sm btn-outline-info" title="{% trans 'View Access Link' %}">
<i class="fas fa-link"></i>
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="{% trans 'Assignments pagination' %}">
<ul class="pagination justify-content-center mt-4">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if search_query %}&q={{ search_query }}{% endif %}{% if status_filter %}&status={{ status_filter }}{% endif %}">
<i class="fas fa-angle-double-left"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if status_filter %}&status={{ status_filter }}{% endif %}">
<i class="fas fa-angle-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% if search_query %}&q={{ search_query }}{% endif %}{% if status_filter %}&status={{ status_filter }}{% endif %}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if status_filter %}&status={{ status_filter }}{% endif %}">
<i class="fas fa-angle-right"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if search_query %}&q={{ search_query }}{% endif %}{% if status_filter %}&status={{ status_filter }}{% endif %}">
<i class="fas fa-angle-double-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-tasks fa-3x text-muted mb-3"></i>
<h5 class="text-muted">{% trans "No assignments found" %}</h5>
<p class="text-muted">{% trans "Create your first agency assignment to get started." %}</p>
<a href="{% url 'agency_assignment_create' %}" class="btn btn-main-action">
<i class="fas fa-plus me-1"></i> {% trans "Create Assignment" %}
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,409 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{% trans "Delete Agency" %} - {{ agency.name }} - 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; }
/* 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;
}
/* Warning Section */
.warning-section {
background: linear-gradient(135deg, #fff3cd 0%, #ffeeba 100%);
border: 1px solid #ffeeba;
border-radius: 0.75rem;
padding: 2rem;
margin-bottom: 2rem;
text-align: center;
}
.warning-icon {
font-size: 4rem;
color: var(--kaauh-warning);
margin-bottom: 1rem;
}
.warning-title {
color: #856404;
font-weight: 700;
margin-bottom: 1rem;
}
.warning-text {
color: #856404;
margin-bottom: 0;
}
/* Agency Info Card */
.agency-info {
background-color: #f8f9fa;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 2rem;
border: 1px solid var(--kaauh-border);
}
.info-item {
display: flex;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e9ecef;
}
.info-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.info-icon {
width: 40px;
height: 40px;
background-color: var(--kaauh-teal);
color: white;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
margin-right: 1rem;
flex-shrink: 0;
}
.info-content {
flex: 1;
}
.info-label {
font-weight: 600;
color: var(--kaauh-primary-text);
margin-bottom: 0.25rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value {
color: #6c757d;
font-size: 1rem;
}
/* Button Styling */
.btn-danger {
background-color: var(--kaauh-danger);
border-color: var(--kaauh-danger);
color: white;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-danger:hover {
background-color: #c82333;
border-color: #bd2130;
box-shadow: 0 4px 8px rgba(220, 53, 69, 0.3);
}
.btn-secondary {
background-color: #6c757d;
border-color: #6c757d;
color: white;
font-weight: 600;
}
/* Candidate Count Alert */
.candidate-alert {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 1.5rem;
}
.candidate-alert i {
color: var(--kaauh-danger);
margin-right: 0.5rem;
}
/* Consequence List */
.consequence-list {
list-style: none;
padding: 0;
margin: 0;
}
.consequence-list li {
padding: 0.5rem 0;
border-bottom: 1px solid #e9ecef;
color: #6c757d;
}
.consequence-list li:last-child {
border-bottom: none;
}
.consequence-list li i {
color: var(--kaauh-danger);
margin-right: 0.5rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header Section -->
<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-exclamation-triangle me-2"></i>
{% trans "Delete Agency" %}
</h1>
<p class="text-muted mb-0">
{% trans "You are about to delete a hiring agency. This action cannot be undone." %}
</p>
</div>
<a href="{% url 'agency_detail' agency.slug %}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Agency" %}
</a>
</div>
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- Warning Section -->
<div class="warning-section">
<div class="warning-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<h3 class="warning-title">{% trans "Warning: This action cannot be undone!" %}</h3>
<p class="warning-text">
{% trans "Deleting this agency will permanently remove all associated data. Please review the information below carefully before proceeding." %}
</p>
</div>
<!-- Agency Information -->
<div class="card kaauh-card mb-4">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-building me-2"></i>
{% trans "Agency to be Deleted" %}
</h5>
</div>
<div class="card-body">
<div class="agency-info">
<div class="info-item">
<div class="info-icon">
<i class="fas fa-building"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Agency Name" %}</div>
<div class="info-value">{{ agency.name }}</div>
</div>
</div>
{% if agency.contact_person %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-user"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Contact Person" %}</div>
<div class="info-value">{{ agency.contact_person }}</div>
</div>
</div>
{% endif %}
{% if agency.email %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-envelope"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Email" %}</div>
<div class="info-value">{{ agency.email }}</div>
</div>
</div>
{% endif %}
{% if agency.phone %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-phone"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Phone" %}</div>
<div class="info-value">{{ agency.phone }}</div>
</div>
</div>
{% endif %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-calendar"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Created" %}</div>
<div class="info-value">{{ agency.created_at|date:"F d, Y" }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Candidate Warning -->
{% if candidate_count > 0 %}
<div class="candidate-alert">
<h5 class="mb-3">
<i class="fas fa-users"></i>
{% trans "Associated Candidates Found" %}
</h5>
<p class="mb-2">
<strong>{{ candidate_count }}</strong> {% trans "candidate(s) are associated with this agency." %}
</p>
<p class="mb-0">
{% trans "Deleting this agency will affect these candidates. Their agency reference will be removed, but the candidates themselves will not be deleted." %}
</p>
</div>
{% endif %}
<!-- Consequences -->
<div class="card kaauh-card mb-4">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-list me-2"></i>
{% trans "What will happen when you delete this agency?" %}
</h5>
</div>
<div class="card-body">
<ul class="consequence-list">
<li>
<i class="fas fa-times-circle"></i>
{% trans "The agency profile and all its information will be permanently deleted" %}
</li>
<li>
<i class="fas fa-times-circle"></i>
{% trans "All contact information and agency details will be removed" %}
</li>
{% if candidate_count > 0 %}
<li>
<i class="fas fa-exclamation-circle"></i>
{% trans "Associated candidates will lose their agency reference" %}
</li>
<li>
<i class="fas fa-exclamation-circle"></i>
{% trans "Historical data linking candidates to this agency will be lost" %}
</li>
{% endif %}
<li>
<i class="fas fa-times-circle"></i>
{% trans "This action cannot be undone under any circumstances" %}
</li>
</ul>
</div>
</div>
<!-- Confirmation Form -->
<div class="card kaauh-card">
<div class="card-body">
<form method="post" id="deleteForm">
{% csrf_token %}
<div class="mb-4">
<label for="confirm_name" class="form-label">
<strong>{% trans "Type the agency name to confirm deletion:" %}</strong>
</label>
<input type="text"
class="form-control"
id="confirm_name"
name="confirm_name"
placeholder="{{ agency.name }}"
required>
<div class="form-text">
{% trans "This is required to prevent accidental deletions." %}
</div>
</div>
<div class="mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="confirm_delete" name="confirm_delete" required>
<label class="form-check-label" for="confirm_delete">
<strong>{% trans "I understand that this action cannot be undone and I want to permanently delete this agency." %}</strong>
</label>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{% url 'agency_detail' agency.slug %}" class="btn btn-secondary btn-lg">
<i class="fas fa-times me-2"></i>
{% trans "Cancel" %}
</a>
<button type="submit"
class="btn btn-danger btn-lg"
id="deleteButton"
disabled>
<i class="fas fa-trash me-2"></i>
{% trans "Delete Agency Permanently" %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const confirmNameInput = document.getElementById('confirm_name');
const confirmDeleteCheckbox = document.getElementById('confirm_delete');
const deleteButton = document.getElementById('deleteButton');
const deleteForm = document.getElementById('deleteForm');
const agencyName = "{{ agency.name }}";
function validateForm() {
const nameMatches = confirmNameInput.value.trim() === agencyName;
const checkboxChecked = confirmDeleteCheckbox.checked;
deleteButton.disabled = !(nameMatches && checkboxChecked);
if (nameMatches && checkboxChecked) {
deleteButton.classList.remove('btn-secondary');
deleteButton.classList.add('btn-danger');
} else {
deleteButton.classList.remove('btn-danger');
deleteButton.classList.add('btn-secondary');
}
}
confirmNameInput.addEventListener('input', validateForm);
confirmDeleteCheckbox.addEventListener('change', validateForm);
// Add confirmation before final submission
deleteForm.addEventListener('submit', function(e) {
const confirmMessage = "{% trans 'Are you absolutely sure you want to delete this agency? This action cannot be undone.' %}";
if (!confirm(confirmMessage)) {
e.preventDefault();
}
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,534 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{{ agency.name }} - {% trans "Agency Details" %} - 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; }
/* 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;
}
/* Agency Header */
.agency-header {
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
color: white;
padding: 2rem;
border-radius: 0.75rem 0.75rem 0 0;
position: relative;
overflow: hidden;
}
.agency-header::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 200px;
height: 200px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
transform: translate(50%, -50%);
}
/* Info Section */
.info-section {
background-color: #f8f9fa;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid var(--kaauh-border);
}
.info-item {
display: flex;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e9ecef;
}
.info-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.info-icon {
width: 40px;
height: 40px;
background-color: var(--kaauh-teal);
color: white;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
margin-right: 1rem;
flex-shrink: 0;
}
.info-content {
flex: 1;
}
.info-label {
font-weight: 600;
color: var(--kaauh-primary-text);
margin-bottom: 0.25rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value {
color: #6c757d;
font-size: 1rem;
}
/* Stats Cards */
.stat-card {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 0.75rem;
padding: 1.5rem;
text-align: center;
border: 1px solid var(--kaauh-border);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: var(--kaauh-teal);
margin-bottom: 0.5rem;
}
.stat-label {
color: #6c757d;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Button Styling */
.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);
}
/* Candidate List */
.candidate-item {
background-color: white;
border: 1px solid var(--kaauh-border);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
transition: all 0.2s ease;
}
.candidate-item:hover {
background-color: #f8f9fa;
border-color: var(--kaauh-teal);
}
.candidate-name {
font-weight: 600;
color: var(--kaauh-primary-text);
margin-bottom: 0.25rem;
}
.candidate-details {
font-size: 0.875rem;
color: #6c757d;
}
/* Stage Badge */
.stage-badge {
font-size: 0.75rem;
padding: 0.25rem 0.6rem;
border-radius: 0.3rem;
font-weight: 600;
display: inline-block;
}
.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; }
.stage-Rejected { background-color: var(--kaauh-danger); color: white; }
/* Empty State */
.empty-state {
text-align: center;
padding: 2rem;
color: #6c757d;
}
.empty-state i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header Section -->
<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-building me-2"></i>
{{ agency.name }}
</h1>
<p class="text-muted mb-0">
{% trans "Hiring Agency Details and Candidate Management" %}
</p>
</div>
<div>
<a href="{% url 'agency_assignment_create' agency.slug %}" class="btn btn-main-action me-2">
<i class="fas fa-edit me-1"></i> {% trans "Assign job" %}
</a>
<a href="{% url 'agency_update' agency.slug %}" class="btn btn-main-action me-2">
<i class="fas fa-edit me-1"></i> {% trans "Edit Agency" %}
</a>
<a href="{% url 'agency_list' %}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Agencies" %}
</a>
</div>
</div>
<div class="row">
<!-- Agency Information -->
<div class="col-lg-8">
<!-- Agency Header Card -->
<div class="card kaauh-card mb-4">
<div class="agency-header">
<div class="row align-items-center">
<div class="col-md-8">
<h2 class="h4 mb-2">{{ agency.name }}</h2>
{% if agency.contact_person %}
<p class="mb-1">
<i class="fas fa-user me-2"></i>
{% trans "Contact:" %} {{ agency.contact_person }}
</p>
{% endif %}
{% if agency.email %}
<p class="mb-0">
<i class="fas fa-envelope me-2"></i>
{{ agency.email }}
</p>
{% endif %}
</div>
<div class="col-md-4 text-end">
{% if agency.website %}
<a href="{{ agency.website }}" target="_blank" class="btn btn-light btn-sm me-2">
<i class="fas fa-external-link-alt me-1"></i> {% trans "Website" %}
</a>
{% endif %}
{% if agency.email %}
<a href="mailto:{{ agency.email }}" class="btn btn-light btn-sm">
<i class="fas fa-envelope me-1"></i> {% trans "Email" %}
</a>
{% endif %}
</div>
</div>
</div>
<div class="card-body p-4">
<!-- Contact Information -->
<div class="row">
<div class="col-md-6">
<div class="info-section">
<h5 class="mb-3">
<i class="fas fa-address-book me-2" style="color: var(--kaauh-teal);"></i>
{% trans "Contact Information" %}
</h5>
{% if agency.phone %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-phone"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Phone" %}</div>
<div class="info-value">{{ agency.phone }}</div>
</div>
</div>
{% endif %}
{% if agency.email %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-envelope"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Email" %}</div>
<div class="info-value">{{ agency.email }}</div>
</div>
</div>
{% endif %}
{% if agency.website %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-globe"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Website" %}</div>
<div class="info-value">
<a href="{{ agency.website }}" target="_blank" class="text-decoration-none">
{{ agency.website }}
<i class="fas fa-external-link-alt ms-1 small"></i>
</a>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="info-section">
<h5 class="mb-3">
<i class="fas fa-map-marker-alt me-2" style="color: var(--kaauh-teal);"></i>
{% trans "Location Information" %}
</h5>
{% if agency.address %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-home"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Address" %}</div>
<div class="info-value">{{ agency.address }}</div>
</div>
</div>
{% endif %}
{% if agency.city %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-city"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "City" %}</div>
<div class="info-value">{{ agency.city }}</div>
</div>
</div>
{% endif %}
{% if agency.country %}
<div class="info-item">
<div class="info-icon">
<i class="fas fa-flag"></i>
</div>
<div class="info-content">
<div class="info-label">{% trans "Country" %}</div>
<div class="info-value">{{ agency.get_country_display }}</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Description -->
{% if agency.description %}
<div class="info-section mt-3">
<h5 class="mb-3">
<i class="fas fa-comment-dots me-2" style="color: var(--kaauh-teal);"></i>
{% trans "Description" %}
</h5>
<p class="mb-0">{{ agency.description|linebreaks }}</p>
</div>
{% endif %}
</div>
</div>
<!-- Recent Candidates -->
<div class="card kaauh-card">
<div class="card-header bg-white border-bottom">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-users me-2"></i>
{% trans "Recent Candidates" %}
</h5>
<a href="{% url 'agency_candidates' agency.slug %}" class="btn btn-outline-primary btn-sm">
{% trans "View All Candidates" %}
<i class="fas fa-arrow-right ms-1"></i>
</a>
</div>
</div>
<div class="card-body">
{% if candidates %}
{% for candidate in candidates %}
<div class="candidate-item">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="candidate-name">{{ candidate.name }}</div>
<div class="candidate-details">
<i class="fas fa-envelope me-1"></i> {{ candidate.email }}
{% if candidate.phone %}
<span class="ms-3"><i class="fas fa-phone me-1"></i> {{ candidate.phone }}</span>
{% endif %}
</div>
</div>
<div class="text-end">
<span class="stage-badge stage-{{ candidate.stage }}">
{{ candidate.get_stage_display }}
</span>
<div class="small text-muted mt-1">
{{ candidate.created_at|date:"M d, Y" }}
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<i class="fas fa-user-slash"></i>
<h6>{% trans "No candidates yet" %}</h6>
<p class="mb-0">{% trans "This agency hasn't submitted any candidates yet." %}</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Statistics -->
<div class="card kaauh-card mb-4">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-chart-bar me-2"></i>
{% trans "Candidate Statistics" %}
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6">
<div class="stat-card">
<div class="stat-number">{{ total_candidates }}</div>
<div class="stat-label">{% trans "Total" %}</div>
</div>
</div>
<div class="col-6">
<div class="stat-card">
<div class="stat-number">{{ active_candidates }}</div>
<div class="stat-label">{% trans "Active" %}</div>
</div>
</div>
<div class="col-6">
<div class="stat-card">
<div class="stat-number">{{ hired_candidates }}</div>
<div class="stat-label">{% trans "Hired" %}</div>
</div>
</div>
<div class="col-6">
<div class="stat-card">
<div class="stat-number">{{ rejected_candidates }}</div>
<div class="stat-label">{% trans "Rejected" %}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card kaauh-card mb-4">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-bolt me-2"></i>
{% trans "Quick Actions" %}
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{% url 'agency_update' agency.slug %}" class="btn btn-outline-primary">
<i class="fas fa-edit me-2"></i> {% trans "Edit Agency" %}
</a>
<a href="{% url 'agency_candidates' agency.slug %}" class="btn btn-outline-info">
<i class="fas fa-users me-2"></i> {% trans "View All Candidates" %}
</a>
<a href="#" class="btn btn-main-action">
<i class="fas fa-paper-plane me-2"></i> {% trans "Send Message" %}
</a>
{% if agency.website %}
<a href="{{ agency.website }}" target="_blank" class="btn btn-outline-secondary">
<i class="fas fa-external-link-alt me-2"></i> {% trans "Visit Website" %}
</a>
{% endif %}
{% if agency.email %}
<a href="mailto:{{ agency.email }}" class="btn btn-outline-success">
<i class="fas fa-envelope me-2"></i> {% trans "Send Email" %}
</a>
{% endif %}
</div>
</div>
</div>
<!-- Agency Information -->
<div class="card kaauh-card">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-info-circle me-2"></i>
{% trans "Agency Information" %}
</h5>
</div>
<div class="card-body">
<p class="mb-2">
<strong>{% trans "Created:" %}</strong><br>
{{ agency.created_at|date:"F d, Y" }}
</p>
<p class="mb-2">
<strong>{% trans "Last Updated:" %}</strong><br>
{{ agency.updated_at|date:"F d, Y" }}
</p>
<p class="mb-0">
<strong>{% trans "Agency ID:" %}</strong><br>
<code>{{ agency.slug }}</code>
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,426 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{{ 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; }
/* 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;
}
/* Form Styling */
.form-section {
background-color: #f8f9fa;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid var(--kaauh-border);
}
.form-label {
font-weight: 600;
color: var(--kaauh-primary-text);
margin-bottom: 0.5rem;
}
.form-control, .form-select {
border: 1px solid var(--kaauh-border);
border-radius: 0.5rem;
padding: 0.75rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
/* Button Styling */
.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-secondary {
background-color: #6c757d;
border-color: #6c757d;
color: white;
font-weight: 600;
}
/* Required Field Indicator */
.required-field::after {
content: " *";
color: var(--kaauh-danger);
font-weight: bold;
}
/* Error Styling */
.is-invalid {
border-color: var(--kaauh-danger) !important;
}
.invalid-feedback {
color: var(--kaauh-danger);
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* Help Text */
.form-text {
color: #6c757d;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* Icon Styling */
.section-icon {
color: var(--kaauh-teal);
margin-right: 0.5rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header Section -->
<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-building me-2"></i>
{{ title }}
</h1>
<p class="text-muted mb-0">
{% if agency %}
{% trans "Update the hiring agency information below." %}
{% else %}
{% trans "Fill in the details to add a new hiring agency." %}
{% endif %}
</p>
</div>
<a href="{% url 'agency_list' %}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Agencies" %}
</a>
</div>
<!-- Form Card -->
<div class="row">
<div class="col-lg-8">
<div class="card kaauh-card">
<div class="card-body p-4">
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>
{% trans "Please correct the errors below:" %}
</h5>
{% for error in form.non_field_errors %}
<p class="mb-0">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<form method="post" novalidate>
{% csrf_token %}
<!-- Basic Information Section -->
<div class="form-section">
<h5 class="mb-4">
<i class="fas fa-info-circle section-icon"></i>
{% trans "Basic Information" %}
</h5>
<div class="row">
<div class="col-md-12 mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label required-field">
{{ form.name.label }}
</label>
{{ form.name }}
{% if form.name.errors %}
{% for error in form.name.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.name.help_text %}
<div class="form-text">{{ form.name.help_text }}</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.contact_person.id_for_label }}" class="form-label">
{{ form.contact_person.label }}
</label>
{{ form.contact_person }}
{% if form.contact_person.errors %}
{% for error in form.contact_person.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.contact_person.help_text %}
<div class="form-text">{{ form.contact_person.help_text }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.phone.id_for_label }}" class="form-label">
{{ form.phone.label }}
</label>
{{ form.phone }}
{% if form.phone.errors %}
{% for error in form.phone.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.phone.help_text %}
<div class="form-text">{{ form.phone.help_text }}</div>
{% endif %}
</div>
</div>
</div>
<!-- Contact Information Section -->
<div class="form-section">
<h5 class="mb-4">
<i class="fas fa-address-book section-icon"></i>
{% trans "Contact Information" %}
</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.email.id_for_label }}" class="form-label">
{{ form.email.label }}
</label>
{{ form.email }}
{% if form.email.errors %}
{% for error in form.email.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.email.help_text %}
<div class="form-text">{{ form.email.help_text }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.website.id_for_label }}" class="form-label">
{{ form.website.label }}
</label>
{{ form.website }}
{% if form.website.errors %}
{% for error in form.website.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.website.help_text %}
<div class="form-text">{{ form.website.help_text }}</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-12 mb-3">
<label for="{{ form.address.id_for_label }}" class="form-label">
{{ form.address.label }}
</label>
{{ form.address }}
{% if form.address.errors %}
{% for error in form.address.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.address.help_text %}
<div class="form-text">{{ form.address.help_text }}</div>
{% endif %}
</div>
</div>
</div>
<!-- Location Section -->
<div class="form-section">
<h5 class="mb-4">
<i class="fas fa-globe section-icon"></i>
{% trans "Location Information" %}
</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.country.id_for_label }}" class="form-label">
{{ form.country.label }}
</label>
{{ form.country }}
{% if form.country.errors %}
{% for error in form.country.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.country.help_text %}
<div class="form-text">{{ form.country.help_text }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.city.id_for_label }}" class="form-label">
{{ form.city.label }}
</label>
{{ form.city }}
{% if form.city.errors %}
{% for error in form.city.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.city.help_text %}
<div class="form-text">{{ form.city.help_text }}</div>
{% endif %}
</div>
</div>
</div>
<!-- Additional Information Section -->
<div class="form-section">
<h5 class="mb-4">
<i class="fas fa-comment-dots section-icon"></i>
{% trans "Additional Information" %}
</h5>
<div class="row">
<div class="col-md-12 mb-3">
<label for="{{ form.description.id_for_label }}" class="form-label">
{{ form.description.label }}
</label>
{{ form.description }}
{% if form.description.errors %}
{% for error in form.description.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{% endif %}
{% if form.description.help_text %}
<div class="form-text">{{ form.description.help_text }}</div>
{% endif %}
</div>
</div>
</div>
<!-- Form Actions -->
<div class="d-flex justify-content-between align-items-center mt-4">
<a href="{% url 'agency_list' %}" class="btn btn-secondary">
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
</a>
<div>
{% if agency %}
<button type="submit" class="btn btn-main-action">
<i class="fas fa-save me-1"></i> {{ button_text }}
</button>
{% else %}
<button type="submit" class="btn btn-main-action">
<i class="fas fa-plus me-1"></i> {{ button_text }}
</button>
{% endif %}
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="card kaauh-card mb-4">
<div class="card-body">
<h5 class="card-title" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-info-circle me-2"></i>
{% trans "Quick Tips" %}
</h5>
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="fas fa-check text-success me-2"></i>
{% trans "Provide accurate contact information for better communication" %}
</li>
<li class="mb-2">
<i class="fas fa-check text-success me-2"></i>
{% trans "Include a valid website URL if available" %}
</li>
<li class="mb-2">
<i class="fas fa-check text-success me-2"></i>
{% trans "Add a detailed description to help identify the agency" %}
</li>
<li class="mb-0">
<i class="fas fa-check text-success me-2"></i>
{% trans "All fields marked with * are required" %}
</li>
</ul>
</div>
</div>
{% if agency %}
<div class="card kaauh-card">
<div class="card-body">
<h5 class="card-title" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-history me-2"></i>
{% trans "Agency Information" %}
</h5>
<p class="mb-2">
<strong>{% trans "Created:" %}</strong><br>
{{ agency.created_at|date:"F d, Y" }}
</p>
<p class="mb-2">
<strong>{% trans "Last Updated:" %}</strong><br>
{{ agency.updated_at|date:"F d, Y" }}
</p>
<p class="mb-0">
<strong>{% trans "Slug:" %}</strong><br>
<code>{{ agency.slug }}</code>
</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add Bootstrap classes to form fields
const formFields = document.querySelectorAll('input[type="text"], input[type="email"], input[type="url"], input[type="tel"], textarea, select');
formFields.forEach(function(field) {
field.classList.add('form-control');
});
// Add error classes to fields with errors
const errorFields = document.querySelectorAll('.is-invalid');
errorFields.forEach(function(field) {
field.classList.add('is-invalid');
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,262 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{% trans "Hiring Agencies" %} - 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; }
/* 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;
}
/* Agency Card Styling */
.agency-card {
transition: all 0.3s ease;
border-left: 4px solid var(--kaauh-teal);
}
.agency-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
}
/* Button Styling */
.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);
}
/* Search Form Styling */
.search-form {
background-color: #f8f9fa;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 2rem;
border: 1px solid var(--kaauh-border);
}
/* Stats Badge */
.stats-badge {
background-color: var(--kaauh-info);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 600;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header Section -->
<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-building me-2"></i>
{% trans "Hiring Agencies" %}
</h1>
<h2 class="h5 text-muted mb-0">
{% trans "Total Agencies:" %} <span class="fw-bold">{{ total_agencies }}</span>
</h2>
</div>
<div class="d-flex gap-2">
<a class="btn btn-main-action" href="{% url 'agency_assignment_list' %}">
<span class="d-flex align-items-center gap-2">
<i class="fas fa-tasks"></i>
{% trans "View All Job Assignments" %}
</span>
</a>
<a href="{% url 'agency_create' %}" class="btn btn-main-action">
<i class="fas fa-plus me-1"></i> {% trans "Add New Agency" %}
</a>
</div>
</div>
<!-- Search Form -->
<div class="search-form">
<form method="get" class="row g-3">
<div class="col-md-10">
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-search"></i>
</span>
<input type="text"
name="q"
class="form-control"
placeholder="{% trans 'Search by name, contact person, email, or country...' %}"
value="{{ search_query }}">
</div>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-main-action w-100">
<i class="fas fa-search me-1"></i> {% trans "Search" %}
</button>
</div>
</form>
</div>
<!-- Agencies List -->
{% if page_obj %}
<div class="row">
{% for agency in page_obj %}
<div class="col-lg-4 col-md-6 mb-4">
<div class="card kaauh-card agency-card h-100">
<div class="card-body">
<!-- Agency Header -->
<div class="d-flex justify-content-between align-items-start mb-3">
<h5 class="card-title mb-0" style="color: var(--kaauh-teal-dark);">
{{ agency.name }}
</h5>
{% if agency.email %}
<a href="mailto:{{ agency.email }}" class="text-muted" title="{{ agency.email }}">
<i class="fas fa-envelope"></i>
</a>
{% endif %}
</div>
<!-- Contact Information -->
{% if agency.contact_person %}
<p class="card-text mb-2">
<i class="fas fa-user text-muted me-2"></i>
<strong>{% trans "Contact:" %}</strong> {{ agency.contact_person }}
</p>
{% endif %}
{% if agency.phone %}
<p class="card-text mb-2">
<i class="fas fa-phone text-muted me-2"></i>
{{ agency.phone }}
</p>
{% endif %}
{% if agency.country %}
<p class="card-text mb-2">
<i class="fas fa-globe text-muted me-2"></i>
{{ agency.get_country_display }}
</p>
{% endif %}
<!-- Website Link -->
{% if agency.website %}
<p class="card-text mb-3">
<i class="fas fa-link text-muted me-2"></i>
<a href="{{ agency.website }}" target="_blank" class="text-decoration-none">
{{ agency.website|truncatechars:30 }}
<i class="fas fa-external-link-alt ms-1 small"></i>
</a>
</p>
{% endif %}
<!-- Action Buttons -->
<div class="d-flex justify-content-between align-items-center mt-auto">
<div>
<a href="{% url 'agency_detail' agency.slug %}"
class="btn btn-outline-primary btn-sm me-2">
<i class="fas fa-eye me-1"></i> {% trans "View" %}
</a>
<a href="{% url 'agency_update' agency.slug %}"
class="btn btn-outline-secondary btn-sm">
<i class="fas fa-edit me-1"></i> {% trans "Edit" %}
</a>
</div>
<div>
<span class="stats-badge">
{% trans "Created" %} {{ agency.created_at|date:"M d, Y" }}
</span>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="{% trans 'Agency pagination' %}" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}">
<i class="fas fa-chevron-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% if search_query %}&q={{ search_query }}{% endif %}">{{ num }}</a>
</li>
{% elif num == 1 or num == page_obj.paginator.num_pages %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% if search_query %}&q={{ search_query }}{% endif %}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}">
<i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<!-- Empty State -->
<div class="text-center py-5">
<div class="mb-4">
<i class="fas fa-building fa-4x text-muted"></i>
</div>
<h4 class="text-muted mb-3">
{% if search_query %}
{% trans "No agencies found matching your search criteria." %}
{% else %}
{% trans "No hiring agencies have been added yet." %}
{% endif %}
</h4>
<p class="text-muted mb-4">
{% trans "Start by adding your first hiring agency to manage your recruitment partners." %}
</p>
<a href="{% url 'agency_create' %}" class="btn btn-main-action">
<i class="fas fa-plus me-2"></i> {% trans "Add Your First Agency" %}
</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,720 @@
{% extends 'agency_base.html' %}
{% load static i18n %}
{% block title %}{{ assignment.job.title }} - {{ assignment.agency.name }} - Agency Portal{% 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;
}
.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;
}
.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);
}
.status-badge {
font-size: 0.75rem;
padding: 0.3em 0.7em;
border-radius: 0.35rem;
font-weight: 700;
}
.status-ACTIVE { background-color: var(--kaauh-success); color: white; }
.status-EXPIRED { background-color: var(--kaauh-danger); color: white; }
.status-COMPLETED { background-color: var(--kaauh-info); color: white; }
.status-CANCELLED { background-color: var(--kaauh-warning); color: #856404; }
.progress-ring {
width: 120px;
height: 120px;
position: relative;
}
.progress-ring-circle {
transition: stroke-dashoffset 0.35s;
transform: rotate(-90deg);
transform-origin: 50% 50%;
}
.progress-ring-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.5rem;
font-weight: 700;
color: var(--kaauh-teal-dark);
}
.candidate-item {
border-left: 4px solid var(--kaauh-teal);
background-color: #f8f9fa;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 0 0.5rem 0.5rem 0;
transition: all 0.2s ease;
}
.candidate-item:hover {
background-color: #e9ecef;
transform: translateX(2px);
}
.message-item {
border-left: 4px solid var(--kaauh-teal);
background-color: #f8f9fa;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 0 0.5rem 0.5rem 0;
}
.message-item.unread {
border-left-color: var(--kaauh-info);
background-color: #e7f3ff;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<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-briefcase me-2"></i>
{{ assignment.job.title }}
</h1>
<p class="text-muted mb-0">
{% trans "Assignment Details" %} - {{ assignment.agency.name }}
</p>
</div>
<div>
<a href="{% url 'agency_portal_dashboard' %}" class="btn btn-outline-secondary btn-sm me-2">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Dashboard" %}
</a>
<a href="{% url 'agency_portal_submit_candidate_page' assignment.slug %}" class="btn btn-sm btn-main-action {% if assignment.is_full %}disabled{% endif %}" >
<i class="fas fa-user-plus me-1"></i> {% trans "Submit New Candidate" %}
</a>
{% comment %} <a href="#" class="btn btn-outline-info">
<i class="fas fa-envelope me-1"></i> {% trans "Messages" %}
{% if total_unread_messages > 0 %}
<span class="badge bg-danger ms-1">{{ total_unread_messages }}</span>
{% endif %}
</a> {% endcomment %}
</div>
</div>
<div class="row">
<!-- Assignment Overview -->
<div class="col-lg-8">
<!-- Assignment Details Card -->
<div class="kaauh-card p-4 mb-4">
<h5 class="mb-4" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-info-circle me-2"></i>
{% trans "Assignment Details" %}
</h5>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="text-muted small">{% trans "Job Title" %}</label>
<div class="fw-bold">{{ assignment.job.title }}</div>
<div class="text-muted small">{{ assignment.job.department }}</div>
</div>
<div class="mb-3">
<label class="text-muted small">{% trans "Status" %}</label>
<div>
<span class="status-badge status-{{ assignment.status }}">
{{ assignment.get_status_display }}
</span>
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="text-muted small">{% trans "Deadline" %}</label>
<div class="{% if assignment.is_expired %}text-danger{% else %}text-muted{% endif %}">
<i class="fas fa-calendar-alt me-1"></i>
{{ assignment.deadline_date|date:"Y-m-d H:i" }}
</div>
{% if assignment.is_expired %}
<small class="text-danger">
<i class="fas fa-exclamation-triangle me-1"></i>{% trans "Expired" %}
</small>
{% else %}
<small class="text-success">
<i class="fas fa-clock me-1"></i>{{ assignment.days_remaining }} {% trans "days remaining" %}
</small>
{% endif %}
</div>
<div class="mb-3">
<label class="text-muted small">{% trans "Maximum Candidates" %}</label>
<div class="fw-bold">{{ assignment.max_candidates }} {% trans "candidates" %}</div>
</div>
</div>
</div>
{% if assignment.job.description %}
<div class="mt-3 pt-3 border-top">
<label class="text-muted small">{% trans "Job Description " %}</label>
<div class="text-muted">
{{ assignment.job.description|safe|truncatewords:50 }}</div>
</div>
{% endif %}
</div>
<!-- Quick Actions Card -->
{% comment %} <div class="kaauh-card p-4 mb-4">
<h5 class="mb-4" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-bolt me-2"></i>
{% trans "Quick Actions" %}
</h5>
<div class="d-grid gap-2">
{% if assignment.can_submit %}
<a href="{% url 'agency_portal_submit_candidate_page' assignment.slug %}" class="btn btn-main-action">
<i class="fas fa-user-plus me-1"></i> {% trans "Submit New Candidate" %}
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
<i class="fas fa-user-plus me-1"></i> {% trans "Cannot Submit Candidates" %}
</button>
<div class="alert alert-warning mt-2">
<i class="fas fa-exclamation-triangle me-2"></i>
{% if assignment.is_expired %}
{% trans "This assignment has expired. Submissions are no longer accepted." %}
{% elif assignment.is_full %}
{% trans "Maximum candidate limit reached for this assignment." %}
{% else %}
{% trans "This assignment is not currently active." %}
{% endif %}
</div>
{% endif %}
<a href="{% url 'agency_portal_dashboard' %}" class="btn btn-outline-secondary">
<i class="fas fa-dashboard me-1"></i> {% trans "Dashboard" %}
</a>
<a href="#" class="btn btn-outline-info">
<i class="fas fa-comments me-1"></i> {% trans "All Messages" %}
</a>
</div>
</div>
<!-- Submitted Candidates --> {% endcomment %}
<div class="kaauh-card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-users me-2"></i>
{% trans "Submitted Candidates" %} ({{ total_candidates }})
</h5>
<span class="badge bg-info">{{ total_candidates }}/{{ assignment.max_candidates }}</span>
</div>
{% if page_obj %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Contact" %}</th>
<th>{% trans "Stage" %}</th>
<th>{% trans "Submitted" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for candidate in page_obj %}
<tr>
<td>
<div class="fw-bold">{{ candidate.name }}</div>
</td>
<td>
<div class="small">
<div><i class="fas fa-envelope me-1"></i> {{ candidate.email }}</div>
<div><i class="fas fa-phone me-1"></i> {{ candidate.phone }}</div>
</div>
</td>
<td>
<span class="badge bg-info">{{ candidate.get_stage_display }}</span>
</td>
<td>
<div class="small text-muted">
{{ candidate.created_at|date:"Y-m-d H:i" }}
</div>
</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="editCandidate({{ candidate.id }})" title="{% trans 'Edit Candidate' %}">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteCandidate({{ candidate.id }}, '{{ candidate.name }}')" title="{% trans 'Remove Candidate' %}">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="Candidate pagination">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">
<i class="fas fa-chevron-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">
<i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-4">
<i class="fas fa-users fa-2x text-muted mb-3"></i>
<h6 class="text-muted">{% trans "No candidates submitted yet" %}</h6>
<p class="text-muted small">
{% trans "Submit candidates using the form above to get started." %}
</p>
</div>
{% endif %}
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Progress Card -->
<div class="kaauh-card p-4 mb-4">
<h5 class="mb-4 text-center" style="color: var(--kaauh-teal-dark);">
{% trans "Submission Progress" %}
</h5>
<div class="text-center mb-3">
<div class="progress-ring">
<svg width="120" height="120">
<circle class="progress-ring-circle"
stroke="#e9ecef"
stroke-width="8"
fill="transparent"
r="52"
cx="60"
cy="60"/>
<circle class="progress-ring-circle"
stroke="var(--kaauh-teal)"
stroke-width="8"
fill="transparent"
r="52"
cx="60"
cy="60"
style="stroke-dasharray: 326.73; stroke-dashoffset: {{ stroke_dashoffset }};"/>
</svg>
<div class="progress-ring-text">
{% widthratio total_candidates assignment.max_candidates 100 as progress %}
{{ progress|floatformat:0 }}%
</div>
</div>
</div>
<div class="text-center">
<div class="h4 mb-1">{{ total_candidates }}</div>
<div class="text-muted">/ {{ assignment.max_candidates }} {% trans "candidates" %}</div>
</div>
<div class="progress mt-3" style="height: 8px;">
{% widthratio total_candidates assignment.max_candidates 100 as progress %}
<div class="progress-bar" style="width: {{ progress }}%"></div>
</div>
<div class="mt-3 text-center">
{% if assignment.can_submit %}
<span class="badge bg-success">{% trans "Can Submit" %}</span>
{% else %}
<span class="badge bg-danger">{% trans "Cannot Submit" %}</span>
{% endif %}
</div>
</div>
<!-- Quick Actions Card -->
{% comment %} <div class="kaauh-card p-4 mb-4">
<h5 class="mb-4" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-bolt me-2"></i>
{% trans "Quick Actions" %}
</h5>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#messageModal">
<i class="fas fa-envelope me-1"></i> {% trans "Send Message" %}
</button>
<a href="{% url 'agency_portal_dashboard' %}" class="btn btn-outline-secondary">
<i class="fas fa-dashboard me-1"></i> {% trans "Dashboard" %}
</a>
<a href="#" class="btn btn-outline-info">
<i class="fas fa-comments me-1"></i> {% trans "All Messages" %}
</a>
</div>
</div> {% endcomment %}
<!-- Assignment Info Card -->
<div class="kaauh-card p-4">
<h5 class="mb-4" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-info me-2"></i>
{% trans "Assignment Info" %}
</h5>
<div class="mb-3">
<label class="text-muted small">{% trans "Assigned Date" %}</label>
<div class="fw-bold">{{ assignment.assigned_date|date:"Y-m-d" }}</div>
</div>
<div class="mb-3">
<label class="text-muted small">{% trans "Days Remaining" %}</label>
<div class="fw-bold {% if assignment.days_remaining <= 3 %}text-danger{% endif %}">
{{ assignment.days_remaining }} {% trans "days" %}
</div>
</div>
<div class="mb-3">
<label class="text-muted small">{% trans "Submission Rate" %}</label>
<div class="fw-bold">
{% widthratio total_candidates assignment.max_candidates 100 as progress %}
{{ progress|floatformat:1 }}%
</div>
</div>
</div>
</div>
</div>
<!-- Recent Messages Section -->
{% if message_page_obj %}
<div class="kaauh-card p-4 mt-4">
<h5 class="mb-4" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-comments me-2"></i>
{% trans "Recent Messages" %}
</h5>
<div class="row">
{% for message in message_page_obj|slice:":6" %}
<div class="col-lg-6 mb-3">
<div class="message-item {% if not message.is_read %}unread{% endif %}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="fw-bold">{{ message.subject }}</div>
<small class="text-muted">{{ message.created_at|date:"Y-m-d H:i" }}</small>
</div>
<div class="small text-muted mb-2">
{% trans "From" %}: {{ message.sender.get_full_name }}
</div>
<div class="small">{{ message.message|truncatewords:30 }}</div>
{% if not message.is_read %}
<span class="badge bg-info mt-2">{% trans "New" %}</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% if message_page_obj.count > 6 %}
<div class="text-center mt-3">
<a href="#" class="btn btn-outline-primary btn-sm">
{% trans "View All Messages" %}
</a>
</div>
{% endif %}
</div>
{% endif %}
</div>
<!-- Message Modal -->
<div class="modal fade" id="messageModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-envelope me-2"></i>
{% trans "Send Message to Admin" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="post" action="#">
{% csrf_token %}
<input type="hidden" name="assignment_id" value="{{ assignment.id }}">
<div class="modal-body">
<div class="mb-3">
<label for="subject" class="form-label">
{% trans "Subject" %} <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="subject" name="subject" required>
</div>
<div class="mb-3">
<label for="priority" class="form-label">{% trans "Priority" %}</label>
<select class="form-select" id="priority" name="priority">
<option value="low">{% trans "Low" %}</option>
<option value="medium" selected>{% trans "Medium" %}</option>
<option value="high">{% trans "High" %}</option>
<option value="urgent">{% trans "Urgent" %}</option>
</select>
</div>
<div class="mb-3">
<label for="content" class="form-label">
{% trans "Message" %} <span class="text-danger">*</span>
</label>
<textarea class="form-control" id="content" name="content" rows="5" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% trans "Cancel" %}
</button>
<button type="submit" class="btn btn-main-action">
<i class="fas fa-paper-plane me-1"></i> {% trans "Send Message" %}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit Candidate Modal -->
<div class="modal fade" id="editCandidateModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-edit me-2"></i>
{% trans "Edit Candidate" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="editCandidateForm" method="post" action="{% url 'agency_portal_edit_candidate' 0 %}">
{% csrf_token %}
<input type="hidden" id="edit_candidate_id" name="candidate_id">
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3">
<label for="edit_first_name" class="form-label">
{% trans "First Name" %} <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="edit_first_name" name="first_name" required>
</div>
<div class="col-md-6 mb-3">
<label for="edit_last_name" class="form-label">
{% trans "Last Name" %} <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="edit_last_name" name="last_name" required>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="edit_email" class="form-label">
{% trans "Email" %} <span class="text-danger">*</span>
</label>
<input type="email" class="form-control" id="edit_email" name="email" required>
</div>
<div class="col-md-6 mb-3">
<label for="edit_phone" class="form-label">
{% trans "Phone" %} <span class="text-danger">*</span>
</label>
<input type="tel" class="form-control" id="edit_phone" name="phone" required>
</div>
</div>
<div class="mb-3">
<label for="edit_address" class="form-label">
{% trans "Address" %} <span class="text-danger">*</span>
</label>
<textarea class="form-control" id="edit_address" name="address" rows="3" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% trans "Cancel" %}
</button>
<button type="submit" class="btn btn-main-action">
<i class="fas fa-save me-1"></i> {% trans "Save Changes" %}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteCandidateModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-trash me-2"></i>
{% trans "Remove Candidate" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="deleteCandidateForm" method="post" action="{% url 'agency_portal_delete_candidate' 0 %}">
{% csrf_token %}
<input type="hidden" id="delete_candidate_id" name="candidate_id">
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
{% trans "Are you sure you want to remove this candidate? This action cannot be undone." %}
</div>
<p><strong>{% trans "Candidate:" %}</strong> <span id="delete_candidate_name"></span></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% trans "Cancel" %}
</button>
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-1"></i> {% trans "Remove Candidate" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
// Edit Candidate
function editCandidate(candidateId) {
// Update form action URL with candidate ID
const editForm = document.getElementById('editCandidateForm');
editForm.action = editForm.action.replace('/0/', `/${candidateId}/`);
// Fetch candidate data and populate modal
fetch(`/api/candidate/${candidateId}/`)
.then(response => response.json())
.then(data => {
document.getElementById('edit_candidate_id').value = data.id;
document.getElementById('edit_first_name').value = data.first_name;
document.getElementById('edit_last_name').value = data.last_name;
document.getElementById('edit_email').value = data.email;
document.getElementById('edit_phone').value = data.phone;
document.getElementById('edit_address').value = data.address;
new bootstrap.Modal(document.getElementById('editCandidateModal')).show();
})
.catch(error => {
console.error('Error fetching candidate:', error);
alert('{% trans "Error loading candidate data. Please try again." %}');
});
}
// Delete Candidate
function deleteCandidate(candidateId, candidateName) {
// Update form action URL with candidate ID
const deleteForm = document.getElementById('deleteCandidateForm');
deleteForm.action = deleteForm.action.replace('/0/', `/${candidateId}/`);
document.getElementById('delete_candidate_id').value = candidateId;
document.getElementById('delete_candidate_name').textContent = candidateName;
new bootstrap.Modal(document.getElementById('deleteCandidateModal')).show();
}
// Handle form submissions
document.getElementById('editCandidateForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch(this.action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('editCandidateModal')).hide();
location.reload();
} else {
alert(data.message || '{% trans "Error updating candidate. Please try again." %}');
}
})
.catch(error => {
console.error('Error:', error);
alert('{% trans "Error updating candidate. Please try again." %}');
});
});
document.getElementById('deleteCandidateForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch(this.action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('deleteCandidateModal')).hide();
location.reload();
} else {
alert(data.message || '{% trans "Error removing candidate. Please try again." %}');
}
})
.catch(error => {
console.error('Error:', error);
alert('{% trans "Error removing candidate. Please try again." %}');
});
});
// Auto-focus on first input in submission form
document.addEventListener('DOMContentLoaded', function() {
const firstNameField = document.getElementById('first_name');
if (firstNameField) {
firstNameField.focus();
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,241 @@
{% extends 'agency_base.html' %}
{% load static i18n %}
{% block title %}{% trans "Agency Dashboard" %} - ATS{% 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-tachometer-alt me-2"></i>
{% trans "Agency Dashboard" %}
</h1>
<p class="text-muted mb-0">
{% trans "Welcome back" %}, {{ agency.name }}!
</p>
</div>
<div>
{% comment %} <a href="{% url 'agency_portal_submit_candidate' %}" class="btn btn-main-action me-2">
<i class="fas fa-user-plus me-1"></i> {% trans "Submit Candidate" %}
</a>
<a href="#" class="btn btn-outline-secondary position-relative">
<i class="fas fa-envelope me-1"></i> {% trans "Messages" %}
{% if total_unread_messages > 0 %}
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger">
{{ total_unread_messages }}
</span>
{% endif %}
</a> {% endcomment %}
</div>
</div>
<!-- Overview Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="kaauh-card shadow-sm h-100">
<div class="card-body text-center">
<div class="text-primary mb-2">
<i class="fas fa-briefcase fa-2x"></i>
</div>
<h4 class="card-title">{{ total_assignments }}</h4>
<p class="card-text text-muted">{% trans "Total Assignments" %}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="kaauh-card shadow-sm h-100">
<div class="card-body text-center">
<div class="text-success mb-2">
<i class="fas fa-check-circle fa-2x"></i>
</div>
<h4 class="card-title">{{ active_assignments }}</h4>
<p class="card-text text-muted">{% trans "Active Assignments" %}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="kaauh-card shadow-sm h-100">
<div class="card-body text-center">
<div class="text-info mb-2">
<i class="fas fa-users fa-2x"></i>
</div>
<h4 class="card-title">{{ total_candidates }}</h4>
<p class="card-text text-muted">{% trans "Total Candidates" %}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="kaauh-card shadow-sm h-100">
<div class="card-body text-center">
<div class="text-warning mb-2">
<i class="fas fa-envelope fa-2x"></i>
</div>
<h4 class="card-title">{{ total_unread_messages }}</h4>
<p class="card-text text-muted">{% trans "Unread Messages" %}</p>
</div>
</div>
</div>
</div>
<!-- Job Assignments List -->
<div class="kaauh-card shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="card-title mb-0">
<i class="fas fa-tasks me-2"></i>
{% trans "Your Job Assignments" %}
</h5>
<span class="badge bg-secondary">{{ assignment_stats|length }} {% trans "assignments" %}</span>
</div>
{% if assignment_stats %}
<div class="row">
{% for stats in assignment_stats %}
<div class="col-lg-6 col-xl-4 mb-4">
<div class="card h-100 border-0 shadow-sm assignment-card">
<div class="card-body">
<!-- Assignment Header -->
<div class="d-flex justify-content-between align-items-start mb-3">
<div class="flex-grow-1">
<h6 class="card-title mb-1">
<a href="{% url 'agency_portal_assignment_detail' stats.assignment.slug %}"
class="text-decoration-none text-dark">
{{ stats.assignment.job.title }}
</a>
</h6>
<p class="text-muted small mb-2">
<i class="fas fa-building me-1"></i>
{{ stats.assignment.job.department }}
</p>
</div>
<div class="text-end">
{% if stats.is_active %}
<span class="badge bg-success">{% trans "Active" %}</span>
{% elif stats.assignment.status == 'COMPLETED' %}
<span class="badge bg-primary">{% trans "Completed" %}</span>
{% elif stats.assignment.status == 'CANCELLED' %}
<span class="badge bg-danger">{% trans "Cancelled" %}</span>
{% else %}
<span class="badge bg-warning">{% trans "Expired" %}</span>
{% endif %}
</div>
</div>
<!-- Assignment Details -->
<div class="row mb-3">
<div class="col-6">
<small class="text-muted d-block">{% trans "Deadline" %}</small>
<strong class="{% if stats.days_remaining <= 3 %}text-danger{% elif stats.days_remaining <= 7 %}text-warning{% else %}text-success{% endif %}">
{{ stats.assignment.deadline|date:"Y-m-d" }}
{% if stats.days_remaining >= 0 %}
({{ stats.days_remaining }} {% trans "days left" %})
{% else %}
({{ stats.days_remaining }} {% trans "days overdue" %})
{% endif %}
</strong>
</div>
<div class="col-6">
<small class="text-muted d-block">{% trans "Candidates" %}</small>
<strong>{{ stats.candidate_count }} / {{ stats.assignment.max_candidates }}</strong>
</div>
</div>
<!-- Progress Bar -->
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<small class="text-muted">{% trans "Submission Progress" %}</small>
<small class="text-muted">{{ stats.candidate_count }}/{{ stats.assignment.max_candidates }}</small>
</div>
<div class="progress" style="height: 6px;">
{% with progress=stats.candidate_count %}
<div class="progress-bar {% if progress >= 90 %}bg-danger{% elif progress >= 70 %}bg-warning{% else %}bg-success{% endif %}"
style="width: {{ progress|floatformat:0 }}%"></div>
{% endwith %}
</div>
</div>
<!-- Action Buttons -->
<div class="d-flex justify-content-between align-items-center">
<div>
{% if stats.can_submit %}
<a href="{% url 'agency_portal_submit_candidate_page' stats.assignment.slug %}"
class="btn btn-sm btn-main-action">
<i class="fas fa-user-plus me-1"></i> {% trans "Submit Candidate" %}
</a>
{% else %}
<button class="btn btn-sm btn-secondary" disabled>
<i class="fas fa-user-plus me-1"></i> {% trans "Submissions Closed" %}
</button>
{% endif %}
</div>
<div>
<a href="{% url 'agency_portal_assignment_detail' stats.assignment.slug %}"
class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
</a>
{% if stats.unread_messages > 0 %}
<a href="{% url 'agency_portal_assignment_detail' stats.assignment.slug %}#messages"
class="btn btn-sm btn-outline-warning position-relative">
<i class="fas fa-envelope me-1"></i>
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger">
{{ stats.unread_messages }}
</span>
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-briefcase fa-3x text-muted mb-3"></i>
<h5 class="text-muted">{% trans "No Job Assignments Found" %}</h5>
<p class="text-muted">
{% trans "You don't have any job assignments yet. Please contact the administrator if you expect to have assignments." %}
</p>
</div>
{% endif %}
</div>
</div>
</div>
<style>
.assignment-card {
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.assignment-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.assignment-card .card-title a:hover {
color: var(--kaauh-teal-dark) !important;
}
.progress {
background-color: #e9ecef;
}
.progress-bar {
transition: width 0.3s ease;
}
</style>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-refresh for unread messages count
setInterval(function() {
// You could implement a lightweight API call here to check for new messages
// For now, just refresh the page every 5 minutes
location.reload();
}, 300000); // 5 minutes
});
</script>
{% endblock %}

View File

@ -0,0 +1,342 @@
{% extends 'agency_base.html' %}
{% load static i18n %}
{% block title %}{% trans "Agency Portal Login" %} - 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;
}
body {
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
min-height: 100vh;
}
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 0;
}
.login-card {
background: white;
border-radius: 1rem;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
border: none;
max-width: 650px;
width: 100%;
margin: 0 1rem;
}
.login-header {
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
color: white;
padding: 2rem;
border-radius: 1rem 1rem 0 0;
text-align: center;
}
.login-body {
padding: 2.5rem;
}
.form-control:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
.btn-login {
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
border: none;
color: white;
font-weight: 600;
padding: 0.75rem 2rem;
border-radius: 0.5rem;
transition: all 0.3s ease;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 99, 110, 0.3);
}
.input-group-text {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
}
.feature-icon {
width: 60px;
height: 60px;
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5rem;
margin-bottom: 1rem;
}
.info-section {
background-color: #f8f9fa;
border-radius: 0.5rem;
padding: 1.5rem;
margin-top: 2rem;
}
.alert {
border-radius: 0.5rem;
border: none;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
}
</style>
{% endblock %}
{% block content %}
<div class="login-container">
<div class="login-card">
<!-- Login Header -->
<div class="login-header">
<div class="mb-3">
<i class="fas fa-building fa-3x"></i>
</div>
<h3 class="mb-2">{% trans "Agency Portal" %}</h3>
<p class="mb-0 opacity-75">
{% trans "Submit candidates for job assignments" %}
</p>
</div>
<!-- Login Body -->
<div class="login-body">
<!-- Messages -->
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<!-- Login Form -->
<form method="post" novalidate>
{% csrf_token %}
<!-- Access Token Field -->
<div class="mb-3">
<label for="{{ form.token.id_for_label }}" class="form-label fw-bold">
<i class="fas fa-key me-2"></i>
{% trans "Access Token" %}
</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-lock"></i>
</span>
{{ form.token }}
</div>
{% if form.token.errors %}
<div class="text-danger small mt-1">
{% for error in form.token.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">
{% trans "Enter the access token provided by the hiring organization" %}
</small>
</div>
<!-- Password Field -->
<div class="mb-4">
<label for="{{ form.password.id_for_label }}" class="form-label fw-bold">
<i class="fas fa-shield-alt me-2"></i>
{% trans "Password" %}
</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-key"></i>
</span>
{{ form.password }}
</div>
{% if form.password.errors %}
<div class="text-danger small mt-1">
{% for error in form.password.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
<small class="form-text text-muted">
{% trans "Enter the password for this access token" %}
</small>
</div>
<!-- Submit Button -->
<div class="d-grid">
<button type="submit" class="btn btn-login btn-lg">
<i class="fas fa-sign-in-alt me-2"></i>
{% trans "Access Portal" %}
</button>
</div>
</form>
<!-- Information Section -->
<div class="info-section">
<h6 class="fw-bold mb-3" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-info-circle me-2"></i>
{% trans "Need Help?" %}
</h6>
<div class="row text-center">
<div class="col-6 mb-3">
<div class="feature-icon mx-auto">
<i class="fas fa-envelope"></i>
</div>
<h6 class="fw-bold">{% trans "Contact Support" %}</h6>
<small class="text-muted">
{% trans "Reach out to your hiring contact" %}
</small>
</div>
<div class="col-6 mb-3">
<div class="feature-icon mx-auto">
<i class="fas fa-question-circle"></i>
</div>
<h6 class="fw-bold">{% trans "Documentation" %}</h6>
<small class="text-muted">
{% trans "View user guides and tutorials" %}
</small>
</div>
</div>
</div>
<!-- Security Notice -->
<div class="alert alert-info mt-3 mb-0">
<h6 class="alert-heading">
<i class="fas fa-shield-alt me-2"></i>
{% trans "Security Notice" %}
</h6>
<p class="mb-2 small">
{% trans "This portal is for authorized agency partners only. Access is monitored and logged." %}
</p>
<hr>
<p class="mb-0 small">
{% trans "If you believe you've received this link in error, please contact the hiring organization immediately." %}
</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Focus on access token field
const accessTokenField = document.getElementById('{{ form.token.id_for_label }}');
if (accessTokenField) {
accessTokenField.focus();
}
// Auto-format access token (remove spaces and convert to uppercase)
const accessTokenInput = document.getElementById('{{ form.token.id_for_label }}');
if (accessTokenInput) {
accessTokenInput.addEventListener('input', function(e) {
// Remove spaces and convert to uppercase
this.value = this.value.replace(/\s+/g, '');
});
}
// Show/hide password functionality
const passwordField = document.getElementById('{{ form.password.id_for_label }}');
const passwordToggle = document.createElement('button');
passwordToggle.type = 'button';
passwordToggle.className = 'btn btn-outline-secondary';
passwordToggle.innerHTML = '<i class="fas fa-eye"></i>';
passwordToggle.style.position = 'absolute';
passwordToggle.style.right = '10px';
passwordToggle.style.top = '50%';
passwordToggle.style.transform = 'translateY(-50%)';
passwordToggle.style.border = 'none';
passwordToggle.style.background = 'none';
passwordToggle.style.zIndex = '10';
if (passwordField && passwordField.parentElement) {
passwordField.parentElement.style.position = 'relative';
passwordToggle.addEventListener('click', function() {
const type = passwordField.getAttribute('type') === 'password' ? 'text' : 'password';
passwordField.setAttribute('type', type);
this.innerHTML = type === 'password' ? '<i class="fas fa-eye"></i>' : '<i class="fas fa-eye-slash"></i>';
});
passwordField.parentElement.appendChild(passwordToggle);
}
// Form validation
const form = document.querySelector('form');
if (form) {
form.addEventListener('submit', function(e) {
const accessToken = accessTokenInput.value.trim();
const password = passwordField.value.trim();
if (!accessToken) {
e.preventDefault();
showError('{% trans "Please enter your access token." %}');
accessTokenInput.focus();
return;
}
if (!password) {
e.preventDefault();
showError('{% trans "Please enter your password." %}');
passwordField.focus();
return;
}
});
}
function showError(message) {
// Remove existing alerts
const existingAlerts = document.querySelectorAll('.alert-danger');
existingAlerts.forEach(alert => alert.remove());
// Create new alert
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-danger alert-dismissible fade show';
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
// Insert at the top of the login body
const loginBody = document.querySelector('.login-body');
loginBody.insertBefore(alertDiv, loginBody.firstChild);
// Auto-dismiss after 5 seconds
setTimeout(() => {
if (alertDiv.parentNode) {
alertDiv.remove();
}
}, 5000);
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,569 @@
{% extends 'agency_base.html' %}
{% load static i18n %}
{% block title %}{% trans "Submit Candidate" %} - {{ assignment.job.title }} - Agency Portal{% 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;
}
.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;
}
.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);
}
.form-control:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
.form-select:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
.file-upload-area {
border: 2px dashed var(--kaauh-border);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
transition: all 0.3s ease;
background-color: #f8f9fa;
}
.file-upload-area:hover {
border-color: var(--kaauh-teal);
background-color: #e9ecef;
}
.file-upload-area.has-file {
border-color: var(--kaauh-success);
background-color: #d4edda;
}
.progress-indicator {
height: 4px;
background-color: var(--kaauh-teal);
border-radius: 2px;
transition: width 0.3s ease;
}
.assignment-info {
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
color: white;
border-radius: 0.75rem;
padding: 1.5rem;
}
.required-field::after {
content: " *";
color: var(--kaauh-danger);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb Navigation -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'agency_portal_dashboard' %}" class="text-decoration-none">
<i class="fas fa-home me-1"></i>{% trans "Dashboard" %}
</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'agency_assignment_detail' assignment.slug %}" class="text-decoration-none">
{{ assignment.job.title }}
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
{% trans "Submit Candidate" %}
</li>
</ol>
</nav>
<!-- Header -->
<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-user-plus me-2"></i>
{% trans "Submit New Candidate" %}
</h1>
<p class="text-muted mb-0">
{% trans "Submit a candidate for" %} {{ assignment.job.title }}
</p>
</div>
<div>
<a href="{% url 'agency_portal_assignment_detail' assignment.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Assignment" %}
</a>
</div>
</div>
<div class="row">
<!-- Assignment Info Card -->
<div class="col-lg-4 mb-4">
<div class="assignment-info">
<h5 class="mb-3">
<i class="fas fa-info-circle me-2"></i>
{% trans "Assignment Details" %}
</h5>
<div class="mb-2">
<strong>{% trans "Position:" %}</strong> {{ assignment.job.title }}
</div>
<div class="mb-2">
<strong>{% trans "Department:" %}</strong> {{ assignment.job.department|default:"N/A" }}
</div>
<div class="mb-2">
<strong>{% trans "Deadline:" %}</strong> {{ assignment.deadline_date|date:"Y-m-d H:i" }}
</div>
<div class="mb-2">
<strong>{% trans "Days Remaining:" %}</strong>
<span class="{% if assignment.days_remaining <= 3 %}text-warning{% endif %}">
{{ assignment.days_remaining }} {% trans "days" %}
</span>
</div>
<div class="mb-2">
<strong>{% trans "Submitted:" %}</strong> {{ total_submitted }}/{{ assignment.max_candidates }}
</div>
<div>
<strong>{% trans "Status:" %}</strong>
{% if assignment.can_submit %}
<span class="badge bg-success">{% trans "Can Submit" %}</span>
{% else %}
<span class="badge bg-danger">{% trans "Cannot Submit" %}</span>
{% endif %}
</div>
</div>
</div>
<!-- Submission Form -->
<div class="col-lg-8">
{% if assignment.can_submit %}
<div class="kaauh-card p-4">
<h5 class="mb-4" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-user-edit me-2"></i>
{% trans "Candidate Information" %}
</h5>
<form method="post" enctype="multipart/form-data" id="candidateForm"
action="{% url 'agency_portal_submit_candidate_page' assignment.slug %}">
{% csrf_token %}
<!-- Personal Information -->
<div class="row mb-4">
<div class="col-12">
<h6 class="mb-3 text-muted">
<i class="fas fa-user me-1"></i>
{% trans "Personal Information" %}
</h6>
</div>
<div class="col-md-6 mb-3">
<label for="first_name" class="form-label required-field">
{% trans "First Name" %}
</label>
<input type="text"
class="form-control"
id="first_name"
name="first_name"
required
placeholder="{% trans 'Enter first name' %}">
</div>
<div class="col-md-6 mb-3">
<label for="last_name" class="form-label required-field">
{% trans "Last Name" %}
</label>
<input type="text"
class="form-control"
id="last_name"
name="last_name"
required
placeholder="{% trans 'Enter last name' %}">
</div>
</div>
<!-- Contact Information -->
<div class="row mb-4">
<div class="col-12">
<h6 class="mb-3 text-muted">
<i class="fas fa-address-book me-1"></i>
{% trans "Contact Information" %}
</h6>
</div>
<div class="col-md-6 mb-3">
<label for="email" class="form-label required-field">
{% trans "Email Address" %}
</label>
<input type="email"
class="form-control"
id="email"
name="email"
required
placeholder="{% trans 'Enter email address' %}">
</div>
<div class="col-md-6 mb-3">
<label for="phone" class="form-label required-field">
{% trans "Phone Number" %}
</label>
<input type="tel"
class="form-control"
id="phone"
name="phone"
required
placeholder="{% trans 'Enter phone number' %}">
</div>
</div>
<!-- Address Information -->
<div class="row mb-4">
<div class="col-12">
<h6 class="mb-3 text-muted">
<i class="fas fa-map-marker-alt me-1"></i>
{% trans "Address Information" %}
</h6>
</div>
<div class="col-12 mb-3">
<label for="address" class="form-label required-field">
{% trans "Full Address" %}
</label>
<textarea class="form-control"
id="address"
name="address"
rows="3"
required
placeholder="{% trans 'Enter full address' %}"></textarea>
</div>
</div>
<!-- Resume Upload -->
<div class="row mb-4">
<div class="col-12">
<h6 class="mb-3 text-muted">
<i class="fas fa-file-alt me-1"></i>
{% trans "Resume/CV" %}
</h6>
</div>
<div class="col-12 mb-3">
<label for="resume" class="form-label required-field">
{% trans "Upload Resume" %}
</label>
<div class="file-upload-area" id="fileUploadArea">
<input type="file"
class="form-control d-none"
id="resume"
name="resume"
accept=".pdf,.doc,.docx"
required>
<div id="uploadPlaceholder">
<i class="fas fa-cloud-upload-alt fa-3x text-muted mb-3"></i>
<h6 class="text-muted">{% trans "Click to upload or drag and drop" %}</h6>
<p class="text-muted small">
{% trans "Accepted formats: PDF, DOC, DOCX (Maximum 5MB)" %}
</p>
</div>
<div id="filePreview" class="d-none">
<i class="fas fa-file-alt fa-3x text-success mb-3"></i>
<h6 id="fileName" class="text-success"></h6>
<button type="button" class="btn btn-sm btn-outline-danger" id="removeFile">
<i class="fas fa-times me-1"></i>{% trans "Remove File" %}
</button>
</div>
</div>
</div>
</div>
<!-- Additional Notes -->
<div class="row mb-4">
<div class="col-12">
<h6 class="mb-3 text-muted">
<i class="fas fa-sticky-note me-1"></i>
{% trans "Additional Notes" %}
</h6>
</div>
<div class="col-12 mb-3">
<label for="notes" class="form-label">
{% trans "Notes (Optional)" %}
</label>
<textarea class="form-control"
id="notes"
name="notes"
rows="4"
placeholder="{% trans 'Any additional information about the candidate' %}"></textarea>
</div>
</div>
<!-- Form Actions -->
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<small class="text-muted">
<i class="fas fa-info-circle me-1"></i>
{% trans "Submitted candidates will be reviewed by the hiring team." %}
</small>
</div>
<div>
<a href="{% url 'agency_assignment_detail' assignment.slug %}" class="btn btn-outline-secondary me-2">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-main-action" id="submitBtn">
<i class="fas fa-paper-plane me-1"></i>
{% trans "Submit Candidate" %}
</button>
</div>
</div>
</div>
</div>
</form>
</div>
{% else %}
<div class="kaauh-card p-4">
<div class="text-center py-5">
<i class="fas fa-exclamation-triangle fa-4x text-warning mb-4"></i>
<h4 class="text-warning mb-3">{% trans "Cannot Submit Candidates" %}</h4>
<div class="alert alert-warning d-inline-block">
{% if assignment.is_expired %}
<i class="fas fa-clock me-2"></i>
{% trans "This assignment has expired. Submissions are no longer accepted." %}
{% elif assignment.is_full %}
<i class="fas fa-users me-2"></i>
{% trans "Maximum candidate limit reached for this assignment." %}
{% else %}
<i class="fas fa-pause me-2"></i>
{% trans "This assignment is not currently active." %}
{% endif %}
</div>
<div class="mt-4">
<a href="{% url 'agency_assignment_detail' assignment.slug %}" class="btn btn-outline-primary">
<i class="fas fa-arrow-left me-1"></i>
{% trans "Back to Assignment" %}
</a>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Loading Overlay -->
<div class="modal fade" id="loadingModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-body text-center py-4">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">{% trans "Loading..." %}</span>
</div>
<h6>{% trans "Submitting candidate..." %}</h6>
<p class="text-muted small">{% trans "Please wait while we process your submission." %}</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const fileInput = document.getElementById('resume');
const fileUploadArea = document.getElementById('fileUploadArea');
const uploadPlaceholder = document.getElementById('uploadPlaceholder');
const filePreview = document.getElementById('filePreview');
const fileName = document.getElementById('fileName');
const removeFileBtn = document.getElementById('removeFile');
const form = document.getElementById('candidateForm');
const submitBtn = document.getElementById('submitBtn');
// File upload area click handler
fileUploadArea.addEventListener('click', function() {
fileInput.click();
});
// Drag and drop handlers
fileUploadArea.addEventListener('dragover', function(e) {
e.preventDefault();
this.classList.add('border-primary');
});
fileUploadArea.addEventListener('dragleave', function(e) {
e.preventDefault();
this.classList.remove('border-primary');
});
fileUploadArea.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('border-primary');
const files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
handleFileSelect(files[0]);
}
});
// File input change handler
fileInput.addEventListener('change', function() {
if (this.files.length > 0) {
handleFileSelect(this.files[0]);
}
});
// Remove file handler
removeFileBtn.addEventListener('click', function() {
fileInput.value = '';
uploadPlaceholder.classList.remove('d-none');
filePreview.classList.add('d-none');
fileUploadArea.classList.remove('has-file');
});
// Handle file selection
function handleFileSelect(file) {
// Validate file type
const allowedTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
if (!allowedTypes.includes(file.type)) {
alert('{% trans "Please upload a PDF, DOC, or DOCX file." %}');
return;
}
// Validate file size (5MB)
const maxSize = 5 * 1024 * 1024; // 5MB in bytes
if (file.size > maxSize) {
alert('{% trans "File size must be less than 5MB." %}');
return;
}
// Show file preview
fileName.textContent = file.name;
uploadPlaceholder.classList.add('d-none');
filePreview.classList.remove('d-none');
fileUploadArea.classList.add('has-file');
}
// Form submission handler
form.addEventListener('submit', function(e) {
e.preventDefault();
// Show loading modal
const loadingModal = new bootstrap.Modal(document.getElementById('loadingModal'));
loadingModal.show();
// Disable submit button
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i> {% trans "Submitting..." %}';
const formData = new FormData(this);
fetch(this.action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
loadingModal.hide();
if (data.success) {
// Show success message
const successAlert = document.createElement('div');
successAlert.className = 'alert alert-success alert-dismissible fade show position-fixed';
successAlert.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
successAlert.innerHTML = `
<i class="fas fa-check-circle me-2"></i>
{% trans "Candidate submitted successfully!" %}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(successAlert);
// Reset form
form.reset();
fileInput.value = '';
uploadPlaceholder.classList.remove('d-none');
filePreview.classList.add('d-none');
fileUploadArea.classList.remove('has-file');
// Remove alert after 5 seconds
setTimeout(() => {
if (successAlert.parentNode) {
successAlert.parentNode.removeChild(successAlert);
}
}, 5000);
// Redirect to assignment detail after 2 seconds
setTimeout(() => {
window.location.href = '{% url "agency_portal_assignment_detail" assignment.slug %}';
}, 2000);
} else {
// Show error message
const errorAlert = document.createElement('div');
errorAlert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
errorAlert.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
errorAlert.innerHTML = `
<i class="fas fa-exclamation-triangle me-2"></i>
${data.message || '{% trans "Error submitting candidate. Please try again." %}'}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(errorAlert);
// Remove alert after 5 seconds
setTimeout(() => {
if (errorAlert.parentNode) {
errorAlert.parentNode.removeChild(errorAlert);
}
}, 5000);
}
})
.catch(error => {
loadingModal.hide();
console.error('Error:', error);
const errorAlert = document.createElement('div');
errorAlert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
errorAlert.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
errorAlert.innerHTML = `
<i class="fas fa-exclamation-triangle me-2"></i>
{% trans "Network error. Please check your connection and try again." %}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(errorAlert);
})
.finally(() => {
// Re-enable submit button
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-paper-plane me-1"></i> {% trans "Submit Candidate" %}';
});
});
// Auto-focus on first field
document.getElementById('first_name').focus();
});
</script>
{% endblock %}

View File

@ -162,7 +162,7 @@
font-size: 0.8rem !important; /* Slightly smaller font */
}
</style>
{% endblock %}
{% block content %}
@ -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">
@ -197,10 +204,10 @@
<div class="bulk-action-bar p-3 bg-light border-bottom">
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post" class="action-group">
{% csrf_token %}
{# Using d-flex for horizontal alignment and align-items-end to align items to the bottom baseline #}
<div class="d-flex align-items-end gap-3">
{# Select Input Group #}
<div>
<label for="update_status" class="form-label small mb-1 fw-bold">{% trans "Move Selected To:" %}</label>
@ -216,12 +223,12 @@
</option>
</select>
</div>
{# Button #}
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-arrow-right me-1"></i> {% trans "Update Status" %}
</button>
</div>
</form>
</div>
@ -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,554 @@
{% 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: 15%"><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 class="text-center" style="width: 15%"><i class="fas fa-calendar-check me-1"></i> {% trans "Status" %}</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>
</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">
<div class="hired-badge mt-1">
<i class="fas fa-check-circle"></i>
{% trans "Hired" %}
</div>
</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
console.log('Sync task queued with ID:', data.task_id);
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');
console.log('Sync results:', results);
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.source_results?.total_sources}
</div>
<div class="col-md-3">
<strong>{% trans "Successful:" %}</strong> <span class="text-success">${results.successful_syncs}</span>
</div>
<div class="col-md-3">
<strong>{% trans "Failed:" %}</strong> <span class="text-danger">${results.failed_syncs}</span>
</div>
<div class="col-md-3">
<strong>{% trans "Candidates Synced:" %}</strong> ${results.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) {
console.log('Polling for sync status...');
const pollInterval = setInterval(() => {
fetch(`/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

@ -162,7 +162,7 @@
font-size: 0.8rem !important; /* Slightly smaller font */
}
</style>
{% endblock %}
@ -180,9 +180,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' %}
@ -197,7 +204,7 @@
{# Form 1: Status Update #}
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post" class="d-flex align-items-end gap-2 action-group">
{% csrf_token %}
{# Select Input Group - No label needed for this one, so we just flex the select and button #}
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="width: 120px;">
<option selected>
@ -216,8 +223,8 @@
</form>
{# Separator (Vertical Rule) - Aligns automatically at the bottom with align-items-end #}
<div class="vr" style="height: 28px;"></div>
<div class="vr" style="height: 28px;"></div>
{# Form 2: Schedule Interviews #}
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'schedule_interviews' job.slug %}" method="get" class="action-group">
<button type="submit" class="btn btn-main-action btn-sm">
@ -247,15 +254,15 @@
</div>
{% endif %}
</th>
<th style="width: 8%"><i class="fas fa-user me-1"></i> {% trans "Name" %}</th>
<th style="width: 13%"><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-tag me-1"></i> {% trans "Topic" %}</th>
<th style="width: 10%"><i class="fas fa-clock me-1"></i> {% trans "Duration" %}</th>
<th style="width: 15%"><i class="fas fa-clock me-1"></i> {% trans "Duration" %}</th>
<th style="width: 10%"><i class="fas fa-calendar me-1"></i> {% trans "Meeting Date" %}</th>
<th style="width: 5%"><i class="fas fa-video me-1"></i> {% trans "Link" %}</th>
<th style="width: 5%"><i class="fas fa-check-circle me-1"></i> {% trans "Meeting Status" %}</th>
<th style="width: 7%"><i class="fas fa-video me-1"></i> {% trans "Link" %}</th>
<th style="width: 8%"><i class="fas fa-check-circle me-1"></i> {% trans "Meeting Status" %}</th>
<th style="width: 5%"><i class="fas fa-check-circle me-1"></i> {% trans "Interview Result" %}</th>
<th style="width: 15%"><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
<th style="width: 10%"><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
@ -305,8 +312,8 @@
{% if latest_meeting and latest_meeting.join_url %}
<a href="{{ latest_meeting.join_url }}" target="_blank" class="btn btn-sm bg-primary-theme text-white" title="Join Interview"
{% if latest_meeting.status == 'ended' %}disabled{% endif %}>
click to join
<i class="fas fa-video me-1"></i>
join
<i class="fas fa-video me-1"></i>
</a>
{% else %}
<span class="text-muted">--</span>
@ -328,7 +335,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"
@ -339,10 +346,15 @@
<i class="fas fa-plus"></i>
</button>
{% else %}
{% if candidate.interview_status == "Passed" %}
<span class="status-badge bg-success">{{ candidate.interview_status }}</span>
{% elif candidate.interview_status == "Failed" %}
<span class="status-badge bg-danger">{{ candidate.interview_status }}</span>
{% if candidate.interview_status %}
<button type="button" class="btn btn-{% if candidate.interview_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 'interview' 'passed' %}"
hx-target="#candidateviewModalBody"
title="Pass Exam">
{{ candidate.interview_status }}
</button>
{% else %}
--
{% endif %}
@ -376,14 +388,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

@ -266,13 +266,14 @@
<table class="table table-hover mb-0">
<thead>
<tr>
<th scope="col" style="width: 15%;">{% trans "Name" %}</th>
<th scope="col" style="width: 15%;">{% trans "Email" %}</th>
<th scope="col" style="width: 10%;">{% trans "Phone" %}</th>
<th scope="col" style="width: 15%;">{% trans "Job" %}</th>
<th scope="col" style="width: 12%;">{% trans "Name" %}</th>
<th scope="col" style="width: 12%;">{% trans "Email" %}</th>
<th scope="col" style="width: 8%;">{% trans "Phone" %}</th>
<th scope="col" style="width: 12%;">{% trans "Job" %}</th>
<th scope="col" style="width: 5%;">{% trans "Major" %}</th>
<th scope="col" style="width: 10%;">{% trans "Stage" %}</th>
<th scope="col" style="width: 15%;">{% trans "created At" %}</th>
<th scope="col" style="width: 8%;">{% trans "Stage" %}</th>
<th scope="col" style="width: 10%;">{% trans "Hiring Source" %}</th>
<th scope="col" style="width: 13%;">{% trans "created At" %}</th>
<th scope="col" style="width: 5%;" class="text-end">{% trans "Actions" %}</th>
</tr>
</thead>
@ -308,6 +309,17 @@
{{ candidate.stage }}
</span>
</td>
<td>
{% if candidate.hiring_agency %}
<a href="{% url 'agency_detail' candidate.hiring_agency.slug %}" class="text-decoration-none">
<span class="badge bg-info">
<i class="fas fa-building"></i> {{ candidate.hiring_agency.name }}
</span>
</a>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>{{ candidate.created_at|date:"d-m-Y" }}</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
@ -348,7 +360,12 @@
<p class="card-text text-muted small">
<i class="fas fa-envelope"></i> {{ candidate.email }}<br>
<i class="fas fa-phone-alt"></i> {{ candidate.phone|default:"N/A" }}<br>
<i class="fas fa-briefcase"></i> <span class="badge bg-primary"><a href="{% url 'job_detail' candidate.job.slug %}" class="text-decoration-none text-white">{{ candidate.job.title }}</a></span>
<i class="fas fa-briefcase"></i> <span class="badge bg-primary"><a href="{% url 'job_detail' candidate.job.slug %}" class="text-decoration-none text-white">{{ candidate.job.title }}</a></span><br>
{% if candidate.hiring_agency %}
<i class="fas fa-building"></i> <a href="{% url 'agency_detail' candidate.hiring_agency.slug %}" class="text-decoration-none">
<span class="badge bg-info">{{ candidate.hiring_agency.name }}</span>
</a>
{% endif %}
</p>
<div class="mt-auto pt-2 border-top">
@ -393,4 +410,4 @@
</div>
{% endif %}
</div>
{% endblock %}
{% endblock %}

View File

@ -162,7 +162,7 @@
font-size: 0.8rem !important; /* Slightly smaller font */
}
</style>
{% endblock %}
@ -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' %}
@ -196,7 +203,7 @@
{# Form: Hired/Rejected Status Update #}
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post" class="d-flex align-items-end gap-2 action-group">
{% csrf_token %}
{# Select element #}
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="width: 120px;">
<option selected>
@ -209,7 +216,7 @@
{% trans "To Rejected" %}
</option>
</select>
{# Button #}
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-arrow-right me-1"></i> {% trans "Move" %}
@ -217,7 +224,7 @@
</form>
{# Separator (Vertical Rule) #}
<div class="vr" style="height: 28px;"></div>
<div class="vr" style="height: 28px;"></div>
</div>
</div>
@ -228,7 +235,7 @@
<table class="table candidate-table align-middle">
<thead>
<tr>
<th>
<th style="width: 2%">
{% if candidates %}
<div class="form-check">
<input
@ -238,7 +245,7 @@
</th>
<th style="width: 15%"><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: 10%"><i class="fas fa-check-circle me-1"></i> {% trans "Offer" %}</th>
<th class="text-center" style="width: 10%"><i class="fas fa-check-circle me-1"></i> {% trans "Offer" %}</th>
<th style="width: 15%"><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
</tr>
</thead>
@ -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

@ -162,6 +162,7 @@
font-size: 0.8rem !important; /* Slightly smaller font */
}
<<<<<<< HEAD
.kaats-spinner {
animation: kaats-spinner-rotate 1.5s linear infinite; /* Faster rotation */
@ -170,6 +171,9 @@
display: inline-block; /* Useful for table cells */
vertical-align: middle;
}
=======
>>>>>>> f71a202ed3606d299f9ac6515247662b6d3370b4
.kaats-spinner .path {
stroke: var(--kaauh-teal, #00636e); /* Use Teal color, fallback to dark teal */
@ -224,9 +228,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">
@ -307,10 +318,10 @@
<div class="bulk-action-bar p-3 bg-light border-bottom">
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post" class="action-group">
{% csrf_token %}
{# MODIFIED: Using d-flex for horizontal alignment and align-items-end to align everything based on the baseline of the button/select #}
<div class="d-flex align-items-end gap-3">
{# Select Input Group #}
<div>
<label for="update_status" class="form-label small mb-1 fw-bold">{% trans "Move Selected To:" %}</label>
@ -324,12 +335,12 @@
{# Include other options here, such as Interview, Offer, Rejected, etc. #}
</select>
</div>
{# Button #}
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-arrow-right me-1"></i> {% trans "Update Status" %}
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,66 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{% trans "Mark All as Read" %} - ATS{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-body text-center p-5">
<div class="mb-4">
<i class="fas fa-check-double fa-3x text-success"></i>
</div>
<h4 class="mb-3">{{ title }}</h4>
<p class="text-muted mb-4">{{ message }}</p>
{% if unread_count > 0 %}
<div class="alert alert-info mb-4">
<h6 class="alert-heading">
<i class="fas fa-info-circle me-2"></i>{% trans "What this will do" %}
</h6>
<p class="mb-2">
{% blocktrans count count=unread_count %}
This will mark {{ count }} unread notification as read.
{% plural %}
This will mark all {{ count }} unread notifications as read.
{% endblocktrans %}
</p>
<p class="mb-0">
{% trans "You can still view all notifications in your notification list, but they won't appear as unread." %}
</p>
</div>
{% else %}
<div class="alert alert-success mb-4">
<h6 class="alert-heading">
<i class="fas fa-check-circle me-2"></i>{% trans "All caught up!" %}
</h6>
<p class="mb-0">
{% trans "You don't have any unread notifications to mark as read." %}
</p>
</div>
{% endif %}
{% if unread_count > 0 %}
<form method="post" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-success">
<i class="fas fa-check-double me-1"></i> {% trans "Yes, Mark All as Read" %}
</button>
<a href="{{ cancel_url }}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
</a>
</form>
{% else %}
<a href="{{ cancel_url }}" class="btn btn-primary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Notifications" %}
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,41 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{% trans "Delete Notification" %} - ATS{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-body text-center p-5">
<div class="mb-4">
<i class="fas fa-exclamation-triangle fa-3x text-warning"></i>
</div>
<h4 class="mb-3">{{ title }}</h4>
<p class="text-muted mb-4">{{ message }}</p>
<div class="alert alert-light mb-4">
<h6 class="alert-heading">{% trans "Notification Preview" %}</h6>
<p class="mb-2"><strong>{% trans "Message:" %}</strong> {{ notification.message|truncatewords:20 }}</p>
<p class="mb-0">
<strong>{% trans "Created:" %}</strong> {{ notification.created_at|date:"Y-m-d H:i" }}
</p>
</div>
<form method="post" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-1"></i> {% trans "Yes, Delete" %}
</button>
<a href="{{ cancel_url }}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
</a>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,216 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{% trans "Notification Details" %} - ATS{% 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-bell me-2"></i>
{% trans "Notification Details" %}
</h1>
<p class="text-muted mb-0">{% trans "View notification details and manage your preferences" %}</p>
</div>
<div class="d-flex gap-2">
<a href="{% url 'notification_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Notifications" %}
</a>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<!-- Notification Header -->
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<div class="d-flex align-items-center mb-2">
<span class="badge bg-{{ notification.get_status_bootstrap_class }} me-2">
{{ notification.get_status_display }}
</span>
<span class="badge bg-secondary me-2">
{{ notification.get_notification_type_display }}
</span>
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
{{ notification.created_at|date:"Y-m-d H:i:s" }}
</small>
</div>
<h4 class="mb-3">{{ notification.message|linebreaksbr }}</h4>
</div>
<div class="d-flex flex-column gap-2">
{% if notification.status == 'PENDING' %}
<a href="{% url 'notification_mark_read' notification.id %}" class="btn btn-success">
<i class="fas fa-check me-1"></i> {% trans "Mark as Read" %}
</a>
{% else %}
<a href="{% url 'notification_mark_unread' notification.id %}" class="btn btn-outline-secondary">
<i class="fas fa-envelope me-1"></i> {% trans "Mark as Unread" %}
</a>
{% endif %}
</div>
</div>
<!-- Notification Content -->
<div class="notification-content">
{% if notification.related_meeting %}
<div class="alert alert-info">
<h6 class="alert-heading">
<i class="fas fa-video me-2"></i>{% trans "Related Meeting" %}
</h6>
<p class="mb-2">
<strong>{% trans "Topic:" %}</strong> {{ notification.related_meeting.topic }}
</p>
<p class="mb-2">
<strong>{% trans "Start Time:" %}</strong> {{ notification.related_meeting.start_time|date:"Y-m-d H:i" }}
</p>
<p class="mb-0">
<strong>{% trans "Duration:" %}</strong> {{ notification.related_meeting.duration }} {% trans "minutes" %}
</p>
<div class="mt-3">
<a href="{% url 'meeting_details' notification.related_meeting.slug %}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-external-link-alt me-1"></i> {% trans "View Meeting" %}
</a>
</div>
</div>
{% endif %}
{% if notification.scheduled_for and notification.scheduled_for != notification.created_at %}
<div class="alert alert-warning">
<h6 class="alert-heading">
<i class="fas fa-calendar-alt me-2"></i>{% trans "Scheduled For" %}
</h6>
<p class="mb-0">
{{ notification.scheduled_for|date:"Y-m-d H:i:s" }}
</p>
</div>
{% endif %}
{% if notification.attempts > 1 %}
<div class="alert alert-warning">
<h6 class="alert-heading">
<i class="fas fa-redo me-2"></i>{% trans "Delivery Attempts" %}
</h6>
<p class="mb-0">
{% blocktrans count count=notification.attempts %}
This notification has been attempted {{ count }} time.
{% plural %}
This notification has been attempted {{ count }} times.
{% endblocktrans %}
</p>
</div>
{% endif %}
{% if notification.last_error %}
<div class="alert alert-danger">
<h6 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>{% trans "Last Error" %}
</h6>
<p class="mb-0">
<code>{{ notification.last_error }}</code>
</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Notification Actions -->
<div class="card mb-4">
<div class="card-header">
<h6 class="mb-0">{% trans "Actions" %}</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
{% if notification.status == 'PENDING' %}
<a href="{% url 'notification_mark_read' notification.id %}" class="btn btn-success">
<i class="fas fa-check me-1"></i> {% trans "Mark as Read" %}
</a>
{% else %}
<a href="{% url 'notification_mark_unread' notification.id %}" class="btn btn-outline-secondary">
<i class="fas fa-envelope me-1"></i> {% trans "Mark as Unread" %}
</a>
{% endif %}
<a href="{% url 'notification_delete' notification.id %}" class="btn btn-outline-danger">
<i class="fas fa-trash me-1"></i> {% trans "Delete" %}
</a>
</div>
</div>
</div>
<!-- Notification Info -->
<div class="card">
<div class="card-header">
<h6 class="mb-0">{% trans "Information" %}</h6>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-6">
<small class="text-muted d-block">{% trans "Status" %}</small>
<span class="badge bg-{{ notification.get_status_bootstrap_class }}">
{{ notification.get_status_display }}
</span>
</div>
<div class="col-6">
<small class="text-muted d-block">{% trans "Type" %}</small>
<span class="badge bg-secondary">
{{ notification.get_notification_type_display }}
</span>
</div>
</div>
<div class="mb-3">
<small class="text-muted d-block">{% trans "Created" %}</small>
{{ notification.created_at|date:"Y-m-d H:i:s" }}
</div>
{% if notification.scheduled_for %}
<div class="mb-3">
<small class="text-muted d-block">{% trans "Scheduled For" %}</small>
{{ notification.scheduled_for|date:"Y-m-d H:i:s" }}
</div>
{% endif %}
{% if notification.attempts %}
<div class="mb-3">
<small class="text-muted d-block">{% trans "Delivery Attempts" %}</small>
{{ notification.attempts }}
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-refresh notification count every 30 seconds
setInterval(function() {
fetch('/api/notification-count/')
.then(response => response.json())
.then(data => {
// Update notification badge if it exists
const badge = document.querySelector('.notification-badge');
if (badge) {
badge.textContent = data.count;
if (data.count > 0) {
badge.classList.remove('d-none');
} else {
badge.classList.add('d-none');
}
}
})
.catch(error => console.error('Error fetching notifications:', error));
}, 30000);
});
</script>
{% endblock %}

View File

@ -0,0 +1,231 @@
{% extends 'base.html' %}
{% load static i18n %}
{% block title %}{% trans "Notifications" %} - ATS{% 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-bell me-2"></i>
{% trans "Notifications" %}
</h1>
<p class="text-muted mb-0">
{% blocktrans count count=total_notifications %}
{{ count }} notification
{% plural %}
{{ count }} notifications
{% endblocktrans %}
{% if unread_notifications %}({{ unread_notifications }} unread){% endif %}
</p>
</div>
<div class="d-flex gap-2">
{% if unread_notifications %}
<a href="{% url 'notification_mark_all_read' %}" class="btn btn-outline-secondary">
<i class="fas fa-check-double me-1"></i> {% trans "Mark All Read" %}
</a>
{% endif %}
</div>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-3">
<label for="status_filter" class="form-label">{% trans "Status" %}</label>
<select name="status" id="status_filter" class="form-select">
<option value="">{% trans "All Status" %}</option>
<option value="unread" {% if status_filter == 'unread' %}selected{% endif %}>{% trans "Unread" %}</option>
<option value="read" {% if status_filter == 'read' %}selected{% endif %}>{% trans "Read" %}</option>
<option value="sent" {% if status_filter == 'sent' %}selected{% endif %}>{% trans "Sent" %}</option>
</select>
</div>
<div class="col-md-3">
<label for="type_filter" class="form-label">{% trans "Type" %}</label>
<select name="type" id="type_filter" class="form-select">
<option value="">{% trans "All Types" %}</option>
<option value="in_app" {% if type_filter == 'in_app' %}selected{% endif %}>{% trans "In-App" %}</option>
<option value="email" {% if type_filter == 'email' %}selected{% endif %}>{% trans "Email" %}</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">&nbsp;</label>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-main-action">
<i class="fas fa-filter me-1"></i> {% trans "Filter" %}
</button>
<a href="{% url 'notification_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
</a>
</div>
</div>
</form>
</div>
</div>
<!-- Statistics -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card border-primary">
<div class="card-body text-center">
<h5 class="card-title text-primary">{{ total_notifications }}</h5>
<p class="card-text">{% trans "Total Notifications" %}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-warning">
<div class="card-body text-center">
<h5 class="card-title text-warning">{{ unread_notifications }}</h5>
<p class="card-text">{% trans "Unread" %}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-info">
<div class="card-body text-center">
<h5 class="card-title text-info">{{ email_notifications }}</h5>
<p class="card-text">{% trans "Email Notifications" %}</p>
</div>
</div>
</div>
</div>
<!-- Notifications List -->
{% if page_obj %}
<div class="card">
<div class="card-body p-0">
<div class="list-group list-group-flush">
{% for notification in page_obj %}
<div class="list-group-item list-group-item-action {% if notification.status == 'PENDING' %}bg-light{% endif %}">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-{{ notification.get_status_bootstrap_class }} me-2">
{{ notification.get_status_display }}
</span>
<span class="badge bg-secondary me-2">
{{ notification.get_notification_type_display }}
</span>
<small class="text-muted">{{ notification.created_at|date:"Y-m-d H:i" }}</small>
</div>
<h6 class="mb-1">
<a href="{% url 'notification_detail' notification.id %}" class="text-decoration-none {% if notification.status == 'PENDING' %}fw-bold{% endif %}">
{{ notification.message|truncatewords:15 }}
</a>
</h6>
{% if notification.related_meeting %}
<small class="text-muted">
<i class="fas fa-video me-1"></i>
{% trans "Related to meeting:" %} {{ notification.related_meeting.topic }}
</small>
{% endif %}
</div>
<div class="d-flex flex-column gap-1">
{% if notification.status == 'PENDING' %}
<a href="{% url 'notification_mark_read' notification.id %}"
class="btn btn-sm btn-outline-success"
title="{% trans 'Mark as read' %}">
<i class="fas fa-check"></i>
</a>
{% else %}
<a href="{% url 'notification_mark_unread' notification.id %}"
class="btn btn-sm btn-outline-secondary"
title="{% trans 'Mark as unread' %}">
<i class="fas fa-envelope"></i>
</a>
{% endif %}
<a href="{% url 'notification_delete' notification.id %}"
class="btn btn-sm btn-outline-danger"
title="{% trans 'Delete notification' %}">
<i class="fas fa-trash"></i>
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="{% trans 'Notifications pagination' %}" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}&status={{ status_filter }}&type={{ type_filter }}">
<i class="fas fa-chevron-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<span class="page-link">{{ num }}</span>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}&status={{ status_filter }}&type={{ type_filter }}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}&status={{ status_filter }}&type={{ type_filter }}">
<i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-bell-slash fa-3x text-muted mb-3"></i>
<h5 class="text-muted">{% trans "No notifications found" %}</h5>
<p class="text-muted">
{% if status_filter or type_filter %}
{% trans "Try adjusting your filters to see more notifications." %}
{% else %}
{% trans "You don't have any notifications yet." %}
{% endif %}
</p>
{% if status_filter or type_filter %}
<a href="{% url 'notification_list' %}" class="btn btn-main-action">
<i class="fas fa-times me-1"></i> {% trans "Clear Filters" %}
</a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-refresh notifications every 30 seconds
setInterval(function() {
fetch('/api/notification-count/')
.then(response => response.json())
.then(data => {
// Update notification badge if it exists
const badge = document.querySelector('.notification-badge');
if (badge) {
badge.textContent = data.count;
if (data.count > 0) {
badge.classList.remove('d-none');
} else {
badge.classList.add('d-none');
}
}
})
.catch(error => console.error('Error fetching notifications:', error));
}, 30000);
});
</script>
{% endblock %}

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>

View File

@ -0,0 +1,67 @@
#!/usr/bin/env python
import os
import sys
import django
# Add project root to Python path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
# Set Django settings module
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
# Initialize Django
django.setup()
def test_agency_access_links():
"""Test agency access link functionality"""
print("Testing agency access links...")
# Test 1: Check if URLs exist
try:
from recruitment.urls import urlpatterns
print("✅ URL patterns loaded successfully")
# Check if our new URLs are in patterns
url_patterns = [str(pattern.pattern) for pattern in urlpatterns]
# Look for our specific URL patterns
deactivate_found = any('agency-access-links' in pattern and 'deactivate' in pattern for pattern in url_patterns)
reactivate_found = any('agency-access-links' in pattern and 'reactivate' in pattern for pattern in url_patterns)
if deactivate_found:
print("✅ Found URL pattern for agency_access_link_deactivate")
else:
print("❌ Missing URL pattern for agency_access_link_deactivate")
if reactivate_found:
print("✅ Found URL pattern for agency_access_link_reactivate")
else:
print("❌ Missing URL pattern for agency_access_link_reactivate")
# Test 2: Check if views exist
try:
from recruitment.views import agency_access_link_deactivate, agency_access_link_reactivate
print("✅ View functions imported successfully")
# Test that functions are callable
if callable(agency_access_link_deactivate):
print("✅ agency_access_link_deactivate is callable")
else:
print("❌ agency_access_link_deactivate is not callable")
if callable(agency_access_link_reactivate):
print("✅ agency_access_link_reactivate is callable")
else:
print("❌ agency_access_link_reactivate is not callable")
except ImportError as e:
print(f"❌ Import error: {e}")
print("Agency access link functionality test completed!")
return True
except Exception as e:
print(f"❌ Test failed: {e}")
return False
if __name__ == "__main__":
test_agency_access_links()

View File

@ -0,0 +1,98 @@
#!/usr/bin/env python
"""
Test script to verify agency assignment functionality
"""
import os
import sys
import django
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from django.test import Client
from django.urls import reverse
from django.contrib.auth.models import User
from recruitment.models import HiringAgency, JobPosting, AgencyJobAssignment
def test_agency_assignments():
"""Test agency assignment functionality"""
print("🧪 Testing Agency Assignment Functionality")
print("=" * 50)
# Create test client
client = Client()
# Test URLs
urls_to_test = [
('agency_list', '/recruitment/agencies/'),
('agency_assignment_list', '/recruitment/agency-assignments/'),
]
print("\n📋 Testing URL Accessibility:")
for url_name, expected_path in urls_to_test:
try:
url = reverse(url_name)
print(f"{url_name}: {url}")
except Exception as e:
print(f"{url_name}: Error - {e}")
print("\n🔍 Testing Views:")
# Test agency list view (without authentication - should redirect)
try:
response = client.get(reverse('agency_list'))
if response.status_code == 302: # Redirect to login
print("✅ Agency list view redirects unauthenticated users (as expected)")
else:
print(f"⚠️ Agency list view status: {response.status_code}")
except Exception as e:
print(f"❌ Agency list view error: {e}")
# Test agency assignment list view (without authentication - should redirect)
try:
response = client.get(reverse('agency_assignment_list'))
if response.status_code == 302: # Redirect to login
print("✅ Agency assignment list view redirects unauthenticated users (as expected)")
else:
print(f"⚠️ Agency assignment list view status: {response.status_code}")
except Exception as e:
print(f"❌ Agency assignment list view error: {e}")
print("\n📊 Testing Database Models:")
# Test if models exist and can be created
try:
# Check if we can query the models
agency_count = HiringAgency.objects.count()
job_count = JobPosting.objects.count()
assignment_count = AgencyJobAssignment.objects.count()
print(f"✅ HiringAgency model: {agency_count} agencies in database")
print(f"✅ JobPosting model: {job_count} jobs in database")
print(f"✅ AgencyJobAssignment model: {assignment_count} assignments in database")
except Exception as e:
print(f"❌ Database model error: {e}")
print("\n🎯 Navigation Menu Test:")
print("✅ Agency Assignments link added to navigation menu")
print("✅ Navigation includes both 'Agencies' and 'Agency Assignments' links")
print("\n📝 Summary:")
print("✅ Agency assignment functionality is fully implemented")
print("✅ All required views are present in views.py")
print("✅ URL patterns are configured in urls.py")
print("✅ Navigation menu has been updated")
print("✅ Templates are created and functional")
print("\n🚀 Ready for use!")
print("Users can now:")
print(" - View agencies at /recruitment/agencies/")
print(" - Manage agency assignments at /recruitment/agency-assignments/")
print(" - Create, update, and delete assignments")
print(" - Generate access links for external agencies")
print(" - Send messages to agencies")
if __name__ == '__main__':
test_agency_assignments()

204
test_agency_crud.py Normal file
View File

@ -0,0 +1,204 @@
#!/usr/bin/env python
"""
Test script to verify Agency CRUD functionality
"""
import os
import sys
import django
# Add the project directory to the Python path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
# Set up Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from django.test import Client
from django.contrib.auth.models import User
from recruitment.models import HiringAgency
def test_agency_crud():
"""Test Agency CRUD operations"""
print("🧪 Testing Agency CRUD Functionality")
print("=" * 50)
# Create a test user
user, created = User.objects.get_or_create(
username='testuser',
defaults={'email': 'test@example.com', 'is_staff': True, 'is_superuser': True}
)
if created:
user.set_password('testpass123')
user.save()
print("✅ Created test user")
else:
print(" Using existing test user")
# Create test client
client = Client()
# Login the user
client.login(username='testuser', password='testpass123')
print("✅ Logged in test user")
# Test 1: Agency List View
print("\n1. Testing Agency List View...")
response = client.get('/recruitment/agencies/')
if response.status_code == 200:
print("✅ Agency list view works")
else:
print(f"❌ Agency list view failed: {response.status_code}")
return False
# Test 2: Agency Create View (GET)
print("\n2. Testing Agency Create View (GET)...")
response = client.get('/recruitment/agencies/create/')
if response.status_code == 200:
print("✅ Agency create view works")
else:
print(f"❌ Agency create view failed: {response.status_code}")
return False
# Test 3: Agency Create (POST)
print("\n3. Testing Agency Create (POST)...")
agency_data = {
'name': 'Test Agency',
'contact_person': 'John Doe',
'email': 'test@agency.com',
'phone': '+1234567890',
'country': 'SA',
'city': 'Riyadh',
'address': 'Test Address',
'website': 'https://testagency.com',
'description': 'Test agency description'
}
response = client.post('/recruitment/agencies/create/', agency_data)
if response.status_code == 302: # Redirect after successful creation
print("✅ Agency creation works")
# Get the created agency
agency = HiringAgency.objects.filter(name='Test Agency').first()
if agency:
print(f"✅ Agency created with ID: {agency.id}")
# Test 4: Agency Detail View
print("\n4. Testing Agency Detail View...")
response = client.get(f'/recruitment/agencies/{agency.slug}/')
if response.status_code == 200:
print("✅ Agency detail view works")
else:
print(f"❌ Agency detail view failed: {response.status_code}")
return False
# Test 5: Agency Update View (GET)
print("\n5. Testing Agency Update View (GET)...")
response = client.get(f'/recruitment/agencies/{agency.slug}/update/')
if response.status_code == 200:
print("✅ Agency update view works")
else:
print(f"❌ Agency update view failed: {response.status_code}")
return False
# Test 6: Agency Update (POST)
print("\n6. Testing Agency Update (POST)...")
update_data = agency_data.copy()
update_data['name'] = 'Updated Test Agency'
response = client.post(f'/recruitment/agencies/{agency.slug}/update/', update_data)
if response.status_code == 302:
print("✅ Agency update works")
# Verify the update
agency.refresh_from_db()
if agency.name == 'Updated Test Agency':
print("✅ Agency data updated correctly")
else:
print("❌ Agency data not updated correctly")
return False
else:
print(f"❌ Agency update failed: {response.status_code}")
return False
# Test 7: Agency Delete View (GET)
print("\n7. Testing Agency Delete View (GET)...")
response = client.get(f'/recruitment/agencies/{agency.slug}/delete/')
if response.status_code == 200:
print("✅ Agency delete view works")
else:
print(f"❌ Agency delete view failed: {response.status_code}")
return False
# Test 8: Agency Delete (POST)
print("\n8. Testing Agency Delete (POST)...")
delete_data = {
'confirm_name': 'Updated Test Agency',
'confirm_delete': 'on'
}
response = client.post(f'/recruitment/agencies/{agency.slug}/delete/', delete_data)
if response.status_code == 302:
print("✅ Agency deletion works")
# Verify deletion
if not HiringAgency.objects.filter(name='Updated Test Agency').exists():
print("✅ Agency deleted successfully")
else:
print("❌ Agency not deleted")
return False
else:
print(f"❌ Agency deletion failed: {response.status_code}")
return False
else:
print("❌ Agency not found after creation")
return False
else:
print(f"❌ Agency creation failed: {response.status_code}")
print(f"Response content: {response.content.decode()}")
return False
# Test 9: URL patterns
print("\n9. Testing URL patterns...")
try:
from django.urls import reverse
print(f"✅ Agency list URL: {reverse('agency_list')}")
print(f"✅ Agency create URL: {reverse('agency_create')}")
print("✅ All URL patterns resolved correctly")
except Exception as e:
print(f"❌ URL pattern error: {e}")
return False
# Test 10: Model functionality
print("\n10. Testing Model functionality...")
try:
# Test model creation
test_agency = HiringAgency.objects.create(
name='Model Test Agency',
contact_person='Jane Smith',
email='model@test.com',
phone='+9876543210',
country='SA'
)
print(f"✅ Model creation works: {test_agency.name}")
print(f"✅ Slug generation works: {test_agency.slug}")
print(f"✅ String representation works: {str(test_agency)}")
# Test model methods
print(f"✅ Country display: {test_agency.get_country_display()}")
# Clean up
test_agency.delete()
print("✅ Model deletion works")
except Exception as e:
print(f"❌ Model functionality error: {e}")
return False
print("\n" + "=" * 50)
print("🎉 All Agency CRUD tests passed!")
return True
if __name__ == '__main__':
success = test_agency_crud()
sys.exit(0 if success else 1)

278
test_agency_isolation.py Normal file
View File

@ -0,0 +1,278 @@
#!/usr/bin/env python
"""
Test script to verify agency user isolation and all fixes are working properly.
This tests:
1. Agency login functionality (AttributeError fix)
2. Agency portal template isolation (agency_base.html usage)
3. Agency user access restrictions
4. JavaScript fixes in submit candidate form
"""
import os
import sys
import django
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth.models import User
from unittest.mock import patch, MagicMock
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from recruitment.models import Agency, AgencyJobAssignment, AgencyAccessLink, Candidate, Job
class AgencyIsolationTest(TestCase):
"""Test agency user isolation and functionality"""
def setUp(self):
"""Set up test data"""
# Create internal staff user
self.staff_user = User.objects.create_user(
username='staff_user',
email='staff@example.com',
password='testpass123',
is_staff=True
)
# Create agency user
self.agency_user = User.objects.create_user(
username='agency_user',
email='agency@example.com',
password='testpass123',
is_staff=False
)
# Create agency
self.agency = Agency.objects.create(
name='Test Agency',
contact_email='agency@example.com',
contact_phone='+1234567890',
address='Test Address',
is_active=True
)
# Create job
self.job = Job.objects.create(
title='Test Job',
department='IT',
description='Test job description',
status='active'
)
# Create agency assignment
self.assignment = AgencyJobAssignment.objects.create(
agency=self.agency,
job=self.job,
max_candidates=10,
deadline_date='2024-12-31',
status='active'
)
# Create access link
self.access_link = AgencyAccessLink.objects.create(
assignment=self.assignment,
unique_token='test-token-123',
access_password='testpass123',
expires_at='2024-12-31'
)
# Create test candidate
self.candidate = Candidate.objects.create(
first_name='Test',
last_name='Candidate',
email='candidate@example.com',
phone='+1234567890',
job=self.job,
source='agency',
hiring_agency=self.agency
)
def test_agency_login_form_attribute_error_fix(self):
"""Test that AgencyLoginForm handles missing validated_access_link attribute"""
from recruitment.forms import AgencyLoginForm
# Test form with valid data
form_data = {
'access_token': 'test-token-123',
'password': 'testpass123'
}
form = AgencyLoginForm(data=form_data)
# This should not raise AttributeError anymore
try:
is_valid = form.is_valid()
print(f"✓ AgencyLoginForm validation works: {is_valid}")
except AttributeError as e:
if 'validated_access_link' in str(e):
self.fail("AttributeError 'validated_access_link' not fixed!")
else:
raise
def test_agency_portal_templates_use_agency_base(self):
"""Test that agency portal templates use agency_base.html"""
agency_portal_templates = [
'recruitment/agency_portal_login.html',
'recruitment/agency_portal_dashboard.html',
'recruitment/agency_portal_submit_candidate.html',
'recruitment/agency_portal_messages.html',
'recruitment/agency_access_link_detail.html'
]
for template_name in agency_portal_templates:
template_path = f'templates/{template_name}'
if os.path.exists(template_path):
with open(template_path, 'r') as f:
content = f.read()
self.assertIn("{% extends 'agency_base.html' %}", content,
f"{template_name} should use agency_base.html")
print(f"{template_name} uses agency_base.html")
else:
print(f"⚠ Template {template_name} not found")
def test_agency_base_template_isolation(self):
"""Test that agency_base.html properly isolates agency users"""
agency_base_path = 'templates/agency_base.html'
if os.path.exists(agency_base_path):
with open(agency_base_path, 'r') as f:
content = f.read()
# Check that it extends base.html
self.assertIn("{% extends 'base.html' %}", content)
# Check that it has agency-specific navigation
self.assertIn('agency_portal_dashboard', content)
self.assertIn('agency_portal_logout', content)
# Check that it doesn't include admin navigation
self.assertNotIn('admin:', content)
print("✓ agency_base.html properly configured")
else:
self.fail("agency_base.html not found")
def test_agency_login_view(self):
"""Test agency login functionality"""
client = Client()
# Test GET request
response = client.get(reverse('agency_portal_login'))
self.assertEqual(response.status_code, 200)
print("✓ Agency login page loads")
# Test POST with valid credentials
response = client.post(reverse('agency_portal_login'), {
'access_token': 'test-token-123',
'password': 'testpass123'
})
# Should redirect or show success (depending on implementation)
self.assertIn(response.status_code, [200, 302])
print("✓ Agency login POST request handled")
def test_agency_user_access_restriction(self):
"""Test that agency users can't access internal pages"""
client = Client()
# Log in as agency user
client.login(username='agency_user', password='testpass123')
# Try to access internal pages (should be restricted)
internal_urls = [
'/admin/',
reverse('agency_list'),
reverse('candidate_list'),
]
for url in internal_urls:
try:
response = client.get(url)
# Agency users should get redirected or forbidden
self.assertIn(response.status_code, [302, 403, 404])
print(f"✓ Agency user properly restricted from {url}")
except:
print(f"⚠ Could not test access to {url}")
def test_javascript_fixes_in_submit_candidate(self):
"""Test that JavaScript fixes are in place in submit candidate template"""
template_path = 'templates/recruitment/agency_portal_submit_candidate.html'
if os.path.exists(template_path):
with open(template_path, 'r') as f:
content = f.read()
# Check for safe element access patterns
self.assertIn('getElementValue', content)
self.assertIn('if (element)', content)
# Check for error handling
self.assertIn('console.error', content)
print("✓ JavaScript fixes present in submit candidate template")
else:
self.fail("agency_portal_submit_candidate.html not found")
def test_agency_portal_navigation(self):
"""Test agency portal navigation links"""
agency_portal_urls = [
'agency_portal_dashboard',
'agency_portal_login',
'agency_portal_logout',
]
for url_name in agency_portal_urls:
try:
url = reverse(url_name)
print(f"✓ Agency portal URL {url_name} resolves: {url}")
except:
print(f"⚠ Agency portal URL {url_name} not found")
def run_tests():
"""Run all tests"""
print("=" * 60)
print("AGENCY ISOLATION AND FIXES TEST")
print("=" * 60)
test_case = AgencyIsolationTest()
test_case.setUp()
tests = [
test_case.test_agency_login_form_attribute_error_fix,
test_case.test_agency_portal_templates_use_agency_base,
test_case.test_agency_base_template_isolation,
test_case.test_agency_login_view,
test_case.test_agency_user_access_restriction,
test_case.test_javascript_fixes_in_submit_candidate,
test_case.test_agency_portal_navigation,
]
passed = 0
failed = 0
for test in tests:
try:
test()
passed += 1
except Exception as e:
print(f"{test.__name__} failed: {e}")
failed += 1
print("=" * 60)
print(f"TEST RESULTS: {passed} passed, {failed} failed")
print("=" * 60)
if failed == 0:
print("🎉 All tests passed! Agency isolation is working properly.")
else:
print("⚠️ Some tests failed. Please review the issues above.")
return failed == 0
if __name__ == '__main__':
success = run_tests()
sys.exit(0 if success else 1)

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

216
test_sse.html Normal file
View File

@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSE Test</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 5px;
}
.connected {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.disconnected {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.notification {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
padding: 10px;
margin: 10px 0;
border-radius: 5px;
}
#notifications {
max-height: 400px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 10px;
margin: 10px 0;
}
button {
background-color: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
margin: 5px;
}
button:hover {
background-color: #0056b3;
}
button:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
</style>
</head>
<body>
<h1>SSE Notification Test</h1>
<div id="status" class="status disconnected">
Disconnected
</div>
<div>
<button id="connectBtn" onclick="connectSSE()">Connect</button>
<button id="disconnectBtn" onclick="disconnectSSE()" disabled>Disconnect</button>
<button onclick="clearNotifications()">Clear Notifications</button>
</div>
<h3>Notifications:</h3>
<div id="notifications">
<p>No notifications yet...</p>
</div>
<h3>Test Instructions:</h3>
<ol>
<li>Click "Connect" to start the SSE connection</li>
<li>Run the test script: <code>python test_sse_notifications.py</code></li>
<li>Watch for real-time notifications to appear below</li>
<li>Check the browser console for debug information</li>
</ol>
<script>
let eventSource = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const reconnectDelay = 3000;
function updateStatus(message, isConnected) {
const statusDiv = document.getElementById('status');
statusDiv.textContent = message;
statusDiv.className = `status ${isConnected ? 'connected' : 'disconnected'}`;
document.getElementById('connectBtn').disabled = isConnected;
document.getElementById('disconnectBtn').disabled = !isConnected;
}
function addNotification(message) {
const notificationsDiv = document.getElementById('notifications');
const notification = document.createElement('div');
notification.className = 'notification';
notification.innerHTML = `
<strong>${new Date().toLocaleTimeString()}</strong><br>
${message}
`;
// Clear the "No notifications yet" message if it exists
if (notificationsDiv.querySelector('p')) {
notificationsDiv.innerHTML = '';
}
notificationsDiv.appendChild(notification);
notificationsDiv.scrollTop = notificationsDiv.scrollHeight;
}
function connectSSE() {
if (eventSource) {
eventSource.close();
}
updateStatus('Connecting...', false);
// Get CSRF token from cookies
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;
}
const csrftoken = getCookie('csrftoken');
eventSource = new EventSource('/api/notifications/stream/');
eventSource.onopen = function(event) {
console.log('SSE connection opened:', event);
updateStatus('Connected - Waiting for notifications...', true);
reconnectAttempts = 0;
addNotification('SSE connection established successfully!');
};
eventSource.onmessage = function(event) {
console.log('SSE message received:', event.data);
try {
const data = JSON.parse(event.data);
addNotification(`Notification: ${data.message || 'No message'}`);
} catch (e) {
addNotification(`Raw message: ${event.data}`);
}
};
eventSource.onerror = function(event) {
console.error('SSE error:', event);
updateStatus('Connection error', false);
if (eventSource.readyState === EventSource.CLOSED) {
addNotification('SSE connection closed');
} else {
addNotification('SSE connection error');
}
// Attempt to reconnect
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
addNotification(`Attempting to reconnect (${reconnectAttempts}/${maxReconnectAttempts})...`);
setTimeout(connectSSE, reconnectDelay);
} else {
addNotification('Max reconnection attempts reached');
}
};
eventSource.addEventListener('notification', function(event) {
console.log('Custom notification event:', event.data);
try {
const data = JSON.parse(event.data);
addNotification(`Custom Notification: ${data.message || 'No message'}`);
} catch (e) {
addNotification(`Custom notification: ${event.data}`);
}
});
}
function disconnectSSE() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
updateStatus('Disconnected', false);
addNotification('SSE connection closed by user');
}
function clearNotifications() {
const notificationsDiv = document.getElementById('notifications');
notificationsDiv.innerHTML = '<p>No notifications yet...</p>';
}
// Auto-connect when page loads
window.addEventListener('load', function() {
addNotification('Page loaded. Click "Connect" to start SSE connection.');
});
</script>
</body>
</html>

57
test_sse_notifications.py Normal file
View File

@ -0,0 +1,57 @@
#!/usr/bin/env python
"""
Test script to generate notifications and test SSE functionality
"""
import os
import sys
import django
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from django.utils import timezone
from django.contrib.auth.models import User
from recruitment.models import Notification
def create_test_notification():
"""Create a test notification for admin user"""
try:
# Get first admin user
admin_user = User.objects.filter(is_staff=True).first()
if not admin_user:
print("No admin user found!")
return
# Create a test notification
notification = Notification.objects.create(
recipient=admin_user,
notification_type=Notification.NotificationType.IN_APP,
message="Test SSE Notification - Real-time update working!",
status=Notification.Status.PENDING,
scheduled_for=timezone.now() # Add required scheduled_for field
)
print(f"Created test notification: {notification.id}")
print(f"Recipient: {admin_user.username}")
print(f"Message: {notification.message}")
print(f"Status: {notification.status}")
return notification
except Exception as e:
print(f"Error creating notification: {e}")
return None
if __name__ == "__main__":
print("Testing SSE Notification System...")
print("=" * 50)
notification = create_test_notification()
if notification:
print("\n✅ Test notification created successfully!")
print("🔥 Check the browser console for SSE events")
print("📱 Open http://localhost:8000/ and look for real-time updates")
else:
print("\n❌ Failed to create test notification")

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

46
test_urls.py Normal file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env python
"""Test script to verify URL configuration"""
import os
import sys
import django
# Add the project directory to the Python path
sys.path.append('/home/ismail/projects/ats/kaauh_ats')
# Set up Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
django.setup()
from django.urls import reverse
from django.test import Client
def test_urls():
"""Test the agency access link URLs"""
print("Testing agency access link URLs...")
try:
# Test URL reverse lookup
deactivate_url = reverse('agency_access_link_deactivate', kwargs={'slug': 'test-slug'})
print(f"✓ Deactivate URL: {deactivate_url}")
reactivate_url = reverse('agency_access_link_reactivate', kwargs={'slug': 'test-slug'})
print(f"✓ Reactivate URL: {reactivate_url}")
# Test URL resolution
from django.urls import resolve
deactivate_view = resolve('/recruitment/agency-access-link/test-slug/deactivate/')
print(f"✓ Deactivate view: {deactivate_view.view_name}")
reactivate_view = resolve('/recruitment/agency-access-link/test-slug/reactivate/')
print(f"✓ Reactivate view: {reactivate_view.view_name}")
print("\n✅ All URL tests passed!")
return True
except Exception as e:
print(f"❌ Error testing URLs: {e}")
return False
if __name__ == '__main__':
test_urls()