diff --git a/NorahUniversity/__pycache__/settings.cpython-313.pyc b/NorahUniversity/__pycache__/settings.cpython-313.pyc index 43dc4f6..4ae63cf 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-313.pyc and b/NorahUniversity/__pycache__/settings.cpython-313.pyc differ diff --git a/NorahUniversity/__pycache__/urls.cpython-313.pyc b/NorahUniversity/__pycache__/urls.cpython-313.pyc index 5d18bab..2aebbd2 100644 Binary files a/NorahUniversity/__pycache__/urls.cpython-313.pyc and b/NorahUniversity/__pycache__/urls.cpython-313.pyc differ diff --git a/NorahUniversity/urls.py b/NorahUniversity/urls.py index 5fe7bfa..bf381ce 100644 --- a/NorahUniversity/urls.py +++ b/NorahUniversity/urls.py @@ -1,8 +1,7 @@ - -from recruitment import views +from recruitment import views,views_frontend from django.conf import settings from django.contrib import admin -from recruitment.admin_sync import sync_admin_site + from django.urls import path, include from django.conf.urls.static import static from django.views.generic import RedirectView @@ -16,7 +15,6 @@ router.register(r'candidates', views.CandidateViewSet) # 1. URLs that DO NOT have a language prefix (admin, API, static files) urlpatterns = [ path('admin/', admin.site.urls), - path('sync-admin/', sync_admin_site.urls), path('api/', include(router.urls)), path('accounts/', include('allauth.urls')), @@ -34,7 +32,12 @@ urlpatterns = [ path('api/templates/save/', views.save_form_template, name='save_form_template'), path('api/templates//', views.load_form_template, name='load_form_template'), path('api/templates//delete/', views.delete_form_template, name='delete_form_template'), - path('api/webhook/',views.zoom_webhook_view,name='zoom_webhook_view') + path('api/webhook/',views.zoom_webhook_view,name='zoom_webhook_view'), + + path('sync/task//status/', views_frontend.sync_task_status, name='sync_task_status'), + path('sync/history/', views_frontend.sync_history, name='sync_history'), + path('sync/history//', views_frontend.sync_history, name='sync_history_job'), + ] urlpatterns += i18n_patterns( diff --git a/recruitment/__pycache__/admin.cpython-313.pyc b/recruitment/__pycache__/admin.cpython-313.pyc index 37bb795..02337a2 100644 Binary files a/recruitment/__pycache__/admin.cpython-313.pyc and b/recruitment/__pycache__/admin.cpython-313.pyc differ diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index a8ad4c6..3730ed6 100644 Binary files a/recruitment/__pycache__/forms.cpython-313.pyc and b/recruitment/__pycache__/forms.cpython-313.pyc differ diff --git a/recruitment/__pycache__/models.cpython-313.pyc b/recruitment/__pycache__/models.cpython-313.pyc index d7ea0e5..d66ad62 100644 Binary files a/recruitment/__pycache__/models.cpython-313.pyc and b/recruitment/__pycache__/models.cpython-313.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-313.pyc b/recruitment/__pycache__/signals.cpython-313.pyc index cfbef5c..3cc942f 100644 Binary files a/recruitment/__pycache__/signals.cpython-313.pyc and b/recruitment/__pycache__/signals.cpython-313.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-313.pyc b/recruitment/__pycache__/urls.cpython-313.pyc index b9ccd81..bb49a18 100644 Binary files a/recruitment/__pycache__/urls.cpython-313.pyc and b/recruitment/__pycache__/urls.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index e3412b7..6f9532e 100644 Binary files a/recruitment/__pycache__/views.cpython-313.pyc and b/recruitment/__pycache__/views.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views_frontend.cpython-313.pyc b/recruitment/__pycache__/views_frontend.cpython-313.pyc index 02a6e7b..2fd1d55 100644 Binary files a/recruitment/__pycache__/views_frontend.cpython-313.pyc and b/recruitment/__pycache__/views_frontend.cpython-313.pyc differ diff --git a/recruitment/admin.py b/recruitment/admin.py index ff1f176..cd2c8a1 100644 --- a/recruitment/admin.py +++ b/recruitment/admin.py @@ -5,7 +5,8 @@ from django.utils import timezone from .models import ( JobPosting, Candidate, TrainingMaterial, ZoomMeeting, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, - SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment + SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment, + AgencyAccessLink, AgencyJobAssignment ) class FormFieldInline(admin.TabularInline): @@ -77,20 +78,21 @@ class IntegrationLogAdmin(admin.ModelAdmin): class HiringAgencyAdmin(admin.ModelAdmin): list_display = ['name', 'contact_person', 'email', 'phone', 'country', 'created_at'] list_filter = ['country', 'created_at'] - search_fields = ['name', 'contact_person', 'email', 'phone', 'notes'] - readonly_fields = ['created_at', 'updated_at'] + search_fields = ['name', 'contact_person', 'email', 'phone', 'description'] + readonly_fields = ['slug', 'created_at', 'updated_at'] fieldsets = ( ('Basic Information', { - 'fields': ('name', 'contact_person', 'email', 'phone', 'website') + 'fields': ('name', 'slug', 'contact_person', 'email', 'phone', 'website') }), ('Location Details', { - 'fields': ('country', 'address') + 'fields': ('country', 'city', 'address') }), ('Additional Information', { - 'fields': ('notes', 'created_at', 'updated_at') + 'fields': ('description', 'created_at', 'updated_at') }), ) save_on_top = True + prepopulated_fields = {'slug': ('name',)} @admin.register(JobPosting) @@ -282,7 +284,9 @@ admin.site.register(FormField) admin.site.register(FieldResponse) admin.site.register(InterviewSchedule) admin.site.register(Profile) -# admin.site.register(HiringAgency) +admin.site.register(AgencyAccessLink) +admin.site.register(AgencyJobAssignment) +# AgencyMessage admin removed - model has been deleted admin.site.register(JobPostingImage) diff --git a/recruitment/admin_sync.py b/recruitment/admin_sync.py deleted file mode 100644 index 812766b..0000000 --- a/recruitment/admin_sync.py +++ /dev/null @@ -1,342 +0,0 @@ -""" -Admin interface for sync management -""" -from django.contrib import admin -from django_q.models import Task, Schedule -from django.utils.html import format_html -from django.urls import reverse -from django.utils.safestring import mark_safe -import json - - -class SyncTaskAdmin(admin.ModelAdmin): - """Admin interface for monitoring sync tasks""" - - list_display = [ - 'id', 'task_name', 'task_status', 'started_display', - 'stopped_display', 'result_display', 'actions_display' - ] - list_filter = ['success', 'stopped', 'group'] - search_fields = ['name', 'func', 'group'] - readonly_fields = [ - 'id', 'name', 'func', 'args', 'kwargs', 'started', 'stopped', - 'result', 'success', 'group', 'attempt_count', 'retries', - 'time_taken', 'stopped_early' - ] - - def task_name(self, obj): - """Display task name with group if available""" - if obj.group: - return f"{obj.name} ({obj.group})" - return obj.name - task_name.short_description = 'Task Name' - - def task_status(self, obj): - """Display task status with color coding""" - if obj.success: - color = 'green' - status = 'SUCCESS' - elif obj.stopped: - color = 'red' - status = 'FAILED' - else: - color = 'orange' - status = 'PENDING' - - return format_html( - '{}', - color, status - ) - task_status.short_description = 'Status' - - def started_display(self, obj): - """Format started time""" - if obj.started: - return obj.started.strftime('%Y-%m-%d %H:%M:%S') - return '--' - started_display.short_description = 'Started' - - def stopped_display(self, obj): - """Format stopped time""" - if obj.stopped: - return obj.stopped.strftime('%Y-%m-%d %H:%M:%S') - return '--' - stopped_display.short_description = 'Stopped' - - def result_display(self, obj): - """Display result summary""" - if not obj.result: - return '--' - - try: - result = json.loads(obj.result) if isinstance(obj.result, str) else obj.result - - if isinstance(result, dict): - if 'summary' in result: - summary = result['summary'] - return format_html( - "Sources: {}, Success: {}, Failed: {}", - summary.get('total_sources', 0), - summary.get('successful', 0), - summary.get('failed', 0) - ) - elif 'error' in result: - return format_html( - 'Error: {}', - result['error'][:100] - ) - - return str(result)[:100] + '...' if len(str(result)) > 100 else str(result) - - except (json.JSONDecodeError, TypeError): - return str(obj.result)[:100] + '...' if len(str(obj.result)) > 100 else str(obj.result) - - result_display.short_description = 'Result Summary' - - def actions_display(self, obj): - """Display action buttons""" - actions = [] - - if obj.group: - # Link to view all tasks in this group - url = reverse('admin:django_q_task_changelist') + f'?group__exact={obj.group}' - actions.append( - f'View Group' - ) - - return mark_safe(' '.join(actions)) - - actions_display.short_description = 'Actions' - - def has_add_permission(self, request): - """Disable adding tasks through admin""" - return False - - def has_change_permission(self, request, obj=None): - """Disable editing tasks through admin""" - return False - - def has_delete_permission(self, request, obj=None): - """Allow deleting tasks""" - return True - - -class SyncScheduleAdmin(admin.ModelAdmin): - """Admin interface for managing scheduled sync tasks""" - - list_display = [ - 'name', 'func', 'schedule_type', 'next_run_display', - 'repeats_display', 'enabled_display' - ] - list_filter = ['repeats', 'schedule_type', 'enabled'] - search_fields = ['name', 'func'] - readonly_fields = ['last_run', 'next_run'] - - fieldsets = ( - ('Basic Information', { - 'fields': ('name', 'func', 'enabled') - }), - ('Schedule Configuration', { - 'fields': ( - 'schedule_type', 'repeats', 'cron', 'next_run', - 'minutes', 'hours', 'days', 'weeks' - ) - }), - ('Task Arguments', { - 'fields': ('args', 'kwargs'), - 'classes': ('collapse',) - }), - ('Runtime Information', { - 'fields': ('last_run', 'next_run'), - 'classes': ('collapse',) - }) - ) - - def schedule_type_display(self, obj): - """Display schedule type with icon""" - icons = { - 'O': '๐Ÿ•', # Once - 'I': '๐Ÿ”„', # Interval - 'C': '๐Ÿ“…', # Cron - 'D': '๐Ÿ“†', # Daily - 'W': '๐Ÿ“‹', # Weekly - 'M': '๐Ÿ“Š', # Monthly - 'Y': '๐Ÿ“ˆ', # Yearly - 'H': 'โฐ', # Hourly - 'Q': '๐Ÿ“ˆ', # Quarterly - } - - icon = icons.get(obj.schedule_type, 'โ“') - type_names = { - 'O': 'Once', - 'I': 'Interval', - 'C': 'Cron', - 'D': 'Daily', - 'W': 'Weekly', - 'M': 'Monthly', - 'Y': 'Yearly', - 'H': 'Hourly', - 'Q': 'Quarterly', - } - - name = type_names.get(obj.schedule_type, obj.schedule_type) - return format_html('{} {}', icon, name) - - schedule_type_display.short_description = 'Schedule Type' - - def next_run_display(self, obj): - """Format next run time""" - if obj.next_run: - return obj.next_run.strftime('%Y-%m-%d %H:%M:%S') - return '--' - - next_run_display.short_description = 'Next Run' - - def repeats_display(self, obj): - """Display repeat count""" - if obj.repeats == -1: - return 'โˆž (Forever)' - return str(obj.repeats) - - repeats_display.short_description = 'Repeats' - - def enabled_display(self, obj): - """Display enabled status with color""" - if obj.enabled: - return format_html( - 'โœ“ Enabled' - ) - else: - return format_html( - 'โœ— Disabled' - ) - - enabled_display.short_description = 'Status' - - -# Custom admin site for sync management -class SyncAdminSite(admin.AdminSite): - """Custom admin site for sync management""" - site_header = 'ATS Sync Management' - site_title = 'Sync Management' - index_title = 'Sync Task Management' - - def get_urls(self): - """Add custom URLs for sync management""" - from django.urls import path - from django.shortcuts import render - from django.http import JsonResponse - from recruitment.candidate_sync_service import CandidateSyncService - - urls = super().get_urls() - - custom_urls = [ - path('sync-dashboard/', self.admin_view(self.sync_dashboard), name='sync_dashboard'), - path('api/sync-stats/', self.admin_view(self.sync_stats), name='sync_stats'), - ] - - return custom_urls + urls - - def sync_dashboard(self, request): - """Custom sync dashboard view""" - from django_q.models import Task - from django.db.models import Count, Q - from django.utils import timezone - from datetime import timedelta - - # Get sync statistics - now = timezone.now() - last_24h = now - timedelta(hours=24) - last_7d = now - timedelta(days=7) - - # Task counts - total_tasks = Task.objects.filter(func__contains='sync_hired_candidates').count() - successful_tasks = Task.objects.filter( - func__contains='sync_hired_candidates', - success=True - ).count() - failed_tasks = Task.objects.filter( - func__contains='sync_hired_candidates', - success=False, - stopped__isnull=False - ).count() - pending_tasks = Task.objects.filter( - func__contains='sync_hired_candidates', - success=False, - stopped__isnull=True - ).count() - - # Recent activity - recent_tasks = Task.objects.filter( - func__contains='sync_hired_candidates' - ).order_by('-started')[:10] - - # Success rate over time - last_24h_tasks = Task.objects.filter( - func__contains='sync_hired_candidates', - started__gte=last_24h - ) - last_24h_success = last_24h_tasks.filter(success=True).count() - - last_7d_tasks = Task.objects.filter( - func__contains='sync_hired_candidates', - started__gte=last_7d - ) - last_7d_success = last_7d_tasks.filter(success=True).count() - - context = { - **self.each_context(request), - 'title': 'Sync Dashboard', - 'total_tasks': total_tasks, - 'successful_tasks': successful_tasks, - 'failed_tasks': failed_tasks, - 'pending_tasks': pending_tasks, - 'success_rate': (successful_tasks / total_tasks * 100) if total_tasks > 0 else 0, - 'last_24h_success_rate': (last_24h_success / last_24h_tasks.count() * 100) if last_24h_tasks.count() > 0 else 0, - 'last_7d_success_rate': (last_7d_success / last_7d_tasks.count() * 100) if last_7d_tasks.count() > 0 else 0, - 'recent_tasks': recent_tasks, - } - - return render(request, 'admin/sync_dashboard.html', context) - - def sync_stats(self, request): - """API endpoint for sync statistics""" - from django_q.models import Task - from django.utils import timezone - from datetime import timedelta - - now = timezone.now() - last_24h = now - timedelta(hours=24) - - stats = { - 'total_tasks': Task.objects.filter(func__contains='sync_hired_candidates').count(), - 'successful_24h': Task.objects.filter( - func__contains='sync_hired_candidates', - success=True, - started__gte=last_24h - ).count(), - 'failed_24h': Task.objects.filter( - func__contains='sync_hired_candidates', - success=False, - stopped__gte=last_24h - ).count(), - 'pending_tasks': Task.objects.filter( - func__contains='sync_hired_candidates', - success=False, - stopped__isnull=True - ).count(), - } - - return JsonResponse(stats) - - -# Create custom admin site -sync_admin_site = SyncAdminSite(name='sync_admin') - -# Register models with custom admin site -sync_admin_site.register(Task, SyncTaskAdmin) -sync_admin_site.register(Schedule, SyncScheduleAdmin) - -# Also register with default admin site for access -admin.site.register(Task, SyncTaskAdmin) -admin.site.register(Schedule, SyncScheduleAdmin) diff --git a/recruitment/candidate_sync_service.py b/recruitment/candidate_sync_service.py index 1ce3e78..65a84a3 100644 --- a/recruitment/candidate_sync_service.py +++ b/recruitment/candidate_sync_service.py @@ -35,9 +35,7 @@ class CandidateSyncService: } # Get all hired candidates for this job - hired_candidates = list(job.candidates.filter( - offer_status='Accepted' - ).select_related('job')) + hired_candidates = list(job.hired_candidates.select_related('job')) results['total_candidates'] = len(hired_candidates) @@ -172,48 +170,48 @@ class CandidateSyncService: '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, + # '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' - } + # '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 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 + # # Add additional AI analysis data if available + # if candidate.ai_analysis_data: + # data['ai_analysis']['full_analysis'] = candidate.ai_analysis_data return data diff --git a/recruitment/email_service.py b/recruitment/email_service.py new file mode 100644 index 0000000..4780934 --- /dev/null +++ b/recruitment/email_service.py @@ -0,0 +1,146 @@ +""" +Email service for sending notifications related to agency messaging. +""" + +from django.core.mail import send_mail +from django.conf import settings +from django.template.loader import render_to_string +from django.utils.html import strip_tags +import logging + +logger = logging.getLogger(__name__) + + +class EmailService: + """ + Service class for handling email notifications + """ + + def send_email(self, recipient_email, subject, body, html_body=None): + """ + Send email using Django's send_mail function + + Args: + recipient_email: Email address to send to + subject: Email subject + body: Plain text email body + html_body: HTML email body (optional) + + Returns: + dict: Result with success status and error message if failed + """ + try: + send_mail( + subject=subject, + message=body, + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa'), + recipient_list=[recipient_email], + html_message=html_body, + fail_silently=False, + ) + + logger.info(f"Email sent successfully to {recipient_email}") + return {'success': True} + + except Exception as e: + error_msg = f"Failed to send email to {recipient_email}: {str(e)}" + logger.error(error_msg) + return {'success': False, 'error': error_msg} + + +def send_agency_welcome_email(agency, access_link=None): + """ + Send welcome email to a new agency with portal access information. + + Args: + agency: HiringAgency instance + access_link: AgencyAccessLink instance (optional) + + Returns: + bool: True if email was sent successfully, False otherwise + """ + try: + if not agency.email: + logger.warning(f"No email found for agency {agency.id}") + return False + + context = { + 'agency': agency, + 'access_link': access_link, + 'portal_url': getattr(settings, 'AGENCY_PORTAL_URL', 'https://kaauh.edu.sa/portal/'), + } + + # Render email templates + html_message = render_to_string('recruitment/emails/agency_welcome.html', context) + plain_message = strip_tags(html_message) + + # Send email + send_mail( + subject='Welcome to KAAUH Recruitment Portal', + message=plain_message, + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa'), + recipient_list=[agency.email], + html_message=html_message, + fail_silently=False, + ) + + logger.info(f"Welcome email sent to agency {agency.email}") + return True + + except Exception as e: + logger.error(f"Failed to send agency welcome email: {str(e)}") + return False + + +def send_assignment_notification_email(assignment, message_type='created'): + """ + Send email notification about assignment changes. + + Args: + assignment: AgencyJobAssignment instance + message_type: Type of notification ('created', 'updated', 'deadline_extended') + + Returns: + bool: True if email was sent successfully, False otherwise + """ + try: + if not assignment.agency.email: + logger.warning(f"No email found for agency {assignment.agency.id}") + return False + + context = { + 'assignment': assignment, + 'agency': assignment.agency, + 'job': assignment.job, + 'message_type': message_type, + 'portal_url': getattr(settings, 'AGENCY_PORTAL_URL', 'https://kaauh.edu.sa/portal/'), + } + + # Render email templates + html_message = render_to_string('recruitment/emails/assignment_notification.html', context) + plain_message = strip_tags(html_message) + + # Determine subject based on message type + subjects = { + 'created': f'New Job Assignment: {assignment.job.title}', + 'updated': f'Assignment Updated: {assignment.job.title}', + 'deadline_extended': f'Deadline Extended: {assignment.job.title}', + } + subject = subjects.get(message_type, f'Assignment Notification: {assignment.job.title}') + + # Send email + send_mail( + subject=subject, + message=plain_message, + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa'), + recipient_list=[assignment.agency.email], + html_message=html_message, + fail_silently=False, + ) + + logger.info(f"Assignment notification email sent to {assignment.agency.email} for {message_type}") + return True + + except Exception as e: + logger.error(f"Failed to send assignment notification email: {str(e)}") + return False diff --git a/recruitment/forms.py b/recruitment/forms.py index 6f4e28a..109e836 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -10,13 +10,15 @@ import re from .models import ( ZoomMeeting, Candidate,TrainingMaterial,JobPosting, FormTemplate,InterviewSchedule,BreakTime,JobPostingImage, - Profile,MeetingComment,ScheduledInterview,Source + 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): @@ -643,5 +652,492 @@ class CandidateExamDateForm(forms.ModelForm): 'exam_date': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}), } +class HiringAgencyForm(forms.ModelForm): + """Form for creating and editing hiring agencies""" + + class Meta: + model = HiringAgency + fields = [ + 'name', 'contact_person', 'email', 'phone', + 'website', 'country', 'address', 'notes' + ] + widgets = { + 'name': forms.TextInput(attrs={ + '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': 'agency@example.com' + }), + 'phone': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': '+966 50 123 4567' + }), + 'website': forms.URLInput(attrs={ + 'class': 'form-control', + 'placeholder': 'https://www.agency.com' + }), + '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' + + 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""" + + class Meta: + model = AgencyJobAssignment + fields = [ + 'agency', 'job', 'max_candidates', 'deadline_date','admin_notes' + ] + widgets = { + 'agency': forms.Select(attrs={'class': 'form-select'}), + 'job': forms.Select(attrs={'class': 'form-select'}), + 'max_candidates': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': 1, + 'placeholder': 'Maximum number of candidates' + }), + 'deadline_date': forms.DateTimeInput(attrs={ + 'class': 'form-control', + 'type': 'datetime-local' + }), + 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'status': forms.Select(attrs={'class': 'form-select'}), + 'admin_notes': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Internal notes about this assignment' + }), + } + labels = { + 'agency': _('Agency'), + 'job': _('Job Posting'), + 'max_candidates': _('Maximum Candidates'), + 'deadline_date': _('Deadline Date'), + 'is_active': _('Is Active'), + 'status': _('Status'), + 'admin_notes': _('Admin Notes'), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'form-horizontal' + self.helper.label_class = 'col-md-3' + self.helper.field_class = 'col-md-9' + + # Filter jobs to only show active jobs + self.fields['job'].queryset = JobPosting.objects.filter( + status='ACTIVE' + ).order_by('-created_at') + + self.helper.layout = Layout( + Row( + Column('agency', css_class='col-md-6'), + Column('job', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Row( + Column('max_candidates', css_class='col-md-6'), + Column('deadline_date', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Row( + Column('is_active', css_class='col-md-6'), + Column('status', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Field('admin_notes', css_class='form-control'), + Div( + Submit('submit', _('Save Assignment'), css_class='btn btn-main-action'), + css_class='col-12 mt-4' + ) + ) + + def clean_deadline_date(self): + """Validate deadline date is in the future""" + deadline_date = self.cleaned_data.get('deadline_date') + if deadline_date and deadline_date <= timezone.now(): + raise ValidationError('Deadline date must be in the future.') + return deadline_date + + def clean_max_candidates(self): + """Validate maximum candidates is positive""" + max_candidates = self.cleaned_data.get('max_candidates') + if max_candidates and max_candidates <= 0: + raise ValidationError('Maximum candidates must be greater than 0.') + return max_candidates + + def clean(self): + """Check for duplicate assignments""" + cleaned_data = super().clean() + agency = cleaned_data.get('agency') + job = cleaned_data.get('job') + + if agency and job: + # Check if this assignment already exists + existing = AgencyJobAssignment.objects.filter( + agency=agency, job=job + ).exclude(pk=self.instance.pk).first() + + if existing: + raise ValidationError( + f'This job is already assigned to {agency.name}. ' + f'Current status: {existing.get_status_display()}' + ) + + return cleaned_data + + +class AgencyAccessLinkForm(forms.ModelForm): + """Form for creating and managing agency access links""" + + class Meta: + model = AgencyAccessLink + fields = [ + 'assignment', 'expires_at', 'is_active' + ] + widgets = { + 'assignment': forms.Select(attrs={'class': 'form-select'}), + 'expires_at': forms.DateTimeInput(attrs={ + 'class': 'form-control', + 'type': 'datetime-local' + }), + 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + } + labels = { + 'assignment': _('Assignment'), + 'expires_at': _('Expires At'), + 'is_active': _('Is Active'), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'form-horizontal' + self.helper.label_class = 'col-md-3' + self.helper.field_class = 'col-md-9' + + # Filter assignments to only show active ones without existing links + self.fields['assignment'].queryset = AgencyJobAssignment.objects.filter( + is_active=True, + status='ACTIVE' + ).exclude( + access_link__isnull=False + ).order_by('-created_at') + + self.helper.layout = Layout( + Field('assignment', css_class='form-control'), + Field('expires_at', css_class='form-control'), + Field('is_active', css_class='form-check-input'), + Div( + Submit('submit', _('Create Access Link'), css_class='btn btn-main-action'), + css_class='col-12 mt-4' + ) + ) + + def clean_expires_at(self): + """Validate expiration date is in the future""" + expires_at = self.cleaned_data.get('expires_at') + if expires_at and expires_at <= timezone.now(): + raise ValidationError('Expiration date must be in the future.') + return expires_at + + +# Agency messaging forms removed - AgencyMessage model has been deleted + + +class AgencyCandidateSubmissionForm(forms.ModelForm): + """Form for agencies to submit candidates (simplified - resume + basic info)""" + + class Meta: + model = Candidate + fields = [ + 'first_name', 'last_name', 'email', 'phone', 'resume' + ] + widgets = { + 'first_name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'First Name', + 'required': True + }), + 'last_name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Last Name', + 'required': True + }), + 'email': forms.EmailInput(attrs={ + 'class': 'form-control', + 'placeholder': 'email@example.com', + 'required': True + }), + 'phone': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': '+966 50 123 4567', + 'required': True + }), + 'resume': forms.FileInput(attrs={ + 'class': 'form-control', + 'accept': '.pdf,.doc,.docx', + 'required': True + }), + } + labels = { + 'first_name': _('First Name'), + 'last_name': _('Last Name'), + 'email': _('Email Address'), + 'phone': _('Phone Number'), + 'resume': _('Resume'), + } + + def __init__(self, assignment, *args, **kwargs): + super().__init__(*args, **kwargs) + self.assignment = assignment + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'g-3' + self.helper.enctype = 'multipart/form-data' + + self.helper.layout = Layout( + Row( + Column('first_name', css_class='col-md-6'), + Column('last_name', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Row( + Column('email', css_class='col-md-6'), + Column('phone', css_class='col-md-6'), + css_class='g-3 mb-3' + ), + Field('resume', css_class='form-control'), + Div( + Submit('submit', _('Submit Candidate'), css_class='btn btn-main-action'), + css_class='col-12 mt-4' + ) + ) + + def clean_email(self): + """Validate email format and check for duplicates in the same job""" + email = self.cleaned_data.get('email') + if email: + # Check if candidate with this email already exists for this job + existing_candidate = Candidate.objects.filter( + email=email.lower().strip(), + job=self.assignment.job + ).first() + + if existing_candidate: + raise ValidationError( + f'A candidate with this email has already applied for {self.assignment.job.title}.' + ) + return email.lower().strip() if email else email + + def clean_resume(self): + """Validate resume file""" + resume = self.cleaned_data.get('resume') + if resume: + # Check file size (max 5MB) + if resume.size > 5 * 1024 * 1024: + raise ValidationError('Resume file size must be less than 5MB.') + + # Check file extension + allowed_extensions = ['.pdf', '.doc', '.docx'] + file_extension = resume.name.lower().split('.')[-1] + if f'.{file_extension}' not in allowed_extensions: + raise ValidationError( + 'Resume must be in PDF, DOC, or DOCX format.' + ) + return resume + + def save(self, commit=True): + """Override save to set additional fields""" + instance = super().save(commit=False) + + # Set required fields for agency submission + instance.job = self.assignment.job + instance.hiring_agency = self.assignment.agency + instance.stage = Candidate.Stage.APPLIED + instance.applicant_status = Candidate.ApplicantType.CANDIDATE + instance.applied = True + + if commit: + instance.save() + # Increment the assignment's submitted count + self.assignment.increment_submission_count() + return instance + + +class AgencyLoginForm(forms.Form): + """Form for agencies to login with token and password""" + + token = forms.CharField( + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter your access token' + }), + label=_('Access Token'), + required=True + ) + + password = forms.CharField( + widget=forms.PasswordInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter your password' + }), + label=_('Password'), + required=True + ) + + # def __init__(self, *args, **kwargs): + # super().__init__(*args, **kwargs) + # self.helper = FormHelper() + # self.helper.form_method = 'post' + # self.helper.form_class = 'g-3' + + # self.helper.layout = Layout( + # Field('token', css_class='form-control'), + # Field('password', css_class='form-control'), + # Div( + # Submit('submit', _('Login'), css_class='btn btn-main-action w-100'), + # css_class='col-12 mt-4' + # ) + # ) + + def clean(self): + """Validate token and password combination""" + cleaned_data = super().clean() + token = cleaned_data.get('token') + password = cleaned_data.get('password') + if token and password: + try: + access_link = AgencyAccessLink.objects.get( + unique_token=token, + is_active=True + ) + + if not access_link.is_valid: + if access_link.is_expired: + raise ValidationError('This access link has expired.') + else: + raise ValidationError('This access link is no longer active.') + + if access_link.access_password != password: + raise ValidationError('Invalid password.') + + # Store the access_link for use in the view + self.validated_access_link = access_link + except AgencyAccessLink.DoesNotExist: + print("Access link does not exist") + raise ValidationError('Invalid access token.') + + return cleaned_data diff --git a/recruitment/management/commands/debug_agency_login.py b/recruitment/management/commands/debug_agency_login.py new file mode 100644 index 0000000..922ddf7 --- /dev/null +++ b/recruitment/management/commands/debug_agency_login.py @@ -0,0 +1,55 @@ +from django.core.management.base import BaseCommand +from recruitment.models import AgencyAccessLink, AgencyJobAssignment, HiringAgency +from django.utils import timezone + +class Command(BaseCommand): + help = 'Debug agency login issues by checking existing access links' + + def handle(self, *args, **options): + self.stdout.write("=== Agency Access Link Debug ===") + + # Check total counts + total_links = AgencyAccessLink.objects.count() + total_assignments = AgencyJobAssignment.objects.count() + total_agencies = HiringAgency.objects.count() + + self.stdout.write(f"Total Access Links: {total_links}") + self.stdout.write(f"Total Assignments: {total_assignments}") + self.stdout.write(f"Total Agencies: {total_agencies}") + self.stdout.write("") + + if total_links == 0: + self.stdout.write("โŒ NO ACCESS LINKS FOUND!") + self.stdout.write("This is likely the cause of 'Invalid token or password' error.") + self.stdout.write("") + self.stdout.write("To fix this:") + self.stdout.write("1. Create an agency first") + self.stdout.write("2. Create a job assignment for the agency") + self.stdout.write("3. Create an access link for the assignment") + return + + # Show existing links + self.stdout.write("๐Ÿ“‹ Existing Access Links:") + for link in AgencyAccessLink.objects.all(): + assignment = link.assignment + agency = assignment.agency if assignment else None + job = assignment.job if assignment else None + + self.stdout.write(f" ๐Ÿ“ Token: {link.unique_token}") + self.stdout.write(f" Password: {link.access_password}") + self.stdout.write(f" Active: {link.is_active}") + self.stdout.write(f" Expires: {link.expires_at}") + self.stdout.write(f" Agency: {agency.name if agency else 'None'}") + self.stdout.write(f" Job: {job.title if job else 'None'}") + self.stdout.write(f" Valid: {link.is_valid}") + self.stdout.write("") + + # Show assignments without links + self.stdout.write("๐Ÿ“‹ Assignments WITHOUT Access Links:") + assignments_without_links = AgencyJobAssignment.objects.filter(access_link__isnull=True) + for assignment in assignments_without_links: + self.stdout.write(f" ๐Ÿ“ {assignment.agency.name} - {assignment.job.title}") + self.stdout.write(f" Status: {assignment.status}") + self.stdout.write(f" Active: {assignment.is_active}") + self.stdout.write(f" Can Submit: {assignment.can_submit}") + self.stdout.write("") diff --git a/recruitment/management/commands/setup_test_agencies.py b/recruitment/management/commands/setup_test_agencies.py new file mode 100644 index 0000000..a02152a --- /dev/null +++ b/recruitment/management/commands/setup_test_agencies.py @@ -0,0 +1,122 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth.models import User +from recruitment.models import HiringAgency, AgencyJobAssignment, JobPosting +from django.utils import timezone +import random + +class Command(BaseCommand): + help = 'Set up test agencies and assignments for messaging system testing' + + def handle(self, *args, **options): + self.stdout.write('Setting up test agencies and assignments...') + + # Create test admin user if not exists + admin_user, created = User.objects.get_or_create( + username='testadmin', + defaults={ + 'email': 'admin@test.com', + 'first_name': 'Test', + 'last_name': 'Admin', + 'is_staff': True, + 'is_superuser': True, + } + ) + if created: + admin_user.set_password('admin123') + admin_user.save() + self.stdout.write(self.style.SUCCESS('Created test admin user: testadmin/admin123')) + + # Create test agencies + agencies_data = [ + { + 'name': 'Tech Talent Solutions', + 'contact_person': 'John Smith', + 'email': 'contact@techtalent.com', + 'phone': '+966501234567', + 'website': 'https://techtalent.com', + 'notes': 'Leading technology recruitment agency specializing in IT and software development roles.', + 'country': 'SA' + }, + { + 'name': 'Healthcare Recruiters Ltd', + 'contact_person': 'Sarah Johnson', + 'email': 'info@healthcarerecruiters.com', + 'phone': '+966502345678', + 'website': 'https://healthcarerecruiters.com', + 'notes': 'Specialized healthcare recruitment agency for medical professionals and healthcare staff.', + 'country': 'SA' + }, + { + 'name': 'Executive Search Partners', + 'contact_person': 'Michael Davis', + 'email': 'partners@execsearch.com', + 'phone': '+966503456789', + 'website': 'https://execsearch.com', + 'notes': 'Premium executive search firm for senior management and C-level positions.', + 'country': 'SA' + } + ] + + created_agencies = [] + for agency_data in agencies_data: + agency, created = HiringAgency.objects.get_or_create( + name=agency_data['name'], + defaults=agency_data + ) + if created: + self.stdout.write(self.style.SUCCESS(f'Created agency: {agency.name}')) + created_agencies.append(agency) + + # Get or create some sample jobs + jobs = [] + job_titles = [ + 'Senior Software Engineer', + 'Healthcare Administrator', + 'Marketing Manager', + 'Data Analyst', + 'HR Director' + ] + + for title in job_titles: + job, created = JobPosting.objects.get_or_create( + internal_job_id=f'KAAUH-2025-{len(jobs)+1:06d}', + defaults={ + 'title': title, + 'description': f'Description for {title} position', + 'qualifications': f'Requirements for {title}', + 'location_city': 'Riyadh', + 'location_country': 'Saudi Arabia', + 'job_type': 'FULL_TIME', + 'workplace_type': 'ON_SITE', + 'application_deadline': timezone.now().date() + timezone.timedelta(days=60), + 'status': 'ACTIVE', + 'created_by': admin_user.username + } + ) + if created: + self.stdout.write(self.style.SUCCESS(f'Created job: {job.title}')) + jobs.append(job) + + # Create agency assignments + for i, agency in enumerate(created_agencies): + for j, job in enumerate(jobs[:2]): # Assign 2 jobs per agency + assignment, created = AgencyJobAssignment.objects.get_or_create( + agency=agency, + job=job, + defaults={ + 'max_candidates': 5, + 'deadline_date': timezone.now() + timezone.timedelta(days=30), + 'status': 'ACTIVE', + 'is_active': True + } + ) + if created: + self.stdout.write(self.style.SUCCESS( + f'Created assignment: {agency.name} -> {job.title}' + )) + + self.stdout.write(self.style.SUCCESS('Test agencies and assignments setup complete!')) + self.stdout.write('\nSummary:') + self.stdout.write(f'- Agencies: {HiringAgency.objects.count()}') + self.stdout.write(f'- Jobs: {JobPosting.objects.count()}') + self.stdout.write(f'- Assignments: {AgencyJobAssignment.objects.count()}') diff --git a/recruitment/management/commands/verify_notifications.py b/recruitment/management/commands/verify_notifications.py new file mode 100644 index 0000000..ed1c6f7 --- /dev/null +++ b/recruitment/management/commands/verify_notifications.py @@ -0,0 +1,112 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth.models import User +from recruitment.models import Notification, HiringAgency +import datetime + + +class Command(BaseCommand): + help = 'Verify the notification system is working correctly' + + def add_arguments(self, parser): + parser.add_argument( + '--detailed', + action='store_true', + help='Show detailed breakdown of notifications', + ) + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS('๐Ÿ” Verifying Notification System')) + self.stdout.write('=' * 50) + + # Check notification counts + total_notifications = Notification.objects.count() + pending_notifications = Notification.objects.filter(status='PENDING').count() + sent_notifications = Notification.objects.filter(status='SENT').count() + failed_notifications = Notification.objects.filter(status='FAILED').count() + + self.stdout.write(f'\n๐Ÿ“Š Notification Counts:') + self.stdout.write(f' Total Notifications: {total_notifications}') + self.stdout.write(f' Pending: {pending_notifications}') + self.stdout.write(f' Sent: {sent_notifications}') + self.stdout.write(f' Failed: {failed_notifications}') + + # Agency messaging system has been removed - replaced by Notification system + self.stdout.write(f'\n๐Ÿ’ฌ Message System:') + self.stdout.write(f' Agency messaging system has been replaced by Notification system') + + # Check admin user notifications + admin_users = User.objects.filter(is_staff=True) + self.stdout.write(f'\n๐Ÿ‘ค Admin Users ({admin_users.count()}):') + + for admin in admin_users: + admin_notifications = Notification.objects.filter(recipient=admin).count() + admin_unread = Notification.objects.filter(recipient=admin, status='PENDING').count() + self.stdout.write(f' {admin.username}: {admin_notifications} notifications ({admin_unread} unread)') + + # Check agency notifications + # Note: Current Notification model only supports User recipients, not agencies + # Agency messaging system has been removed + agencies = HiringAgency.objects.all() + self.stdout.write(f'\n๐Ÿข Agencies ({agencies.count()}):') + + for agency in agencies: + self.stdout.write(f' {agency.name}: Agency messaging system has been removed') + + # Check notification types + if options['detailed']: + self.stdout.write(f'\n๐Ÿ“‹ Detailed Notification Breakdown:') + + # By type + for notification_type in ['email', 'in_app']: + count = Notification.objects.filter(notification_type=notification_type).count() + if count > 0: + self.stdout.write(f' {notification_type}: {count}') + + # By status + for status in ['pending', 'sent', 'read', 'failed', 'retrying']: + count = Notification.objects.filter(status=status).count() + if count > 0: + self.stdout.write(f' {status}: {count}') + + # System health check + self.stdout.write(f'\n๐Ÿฅ System Health Check:') + + issues = [] + + # Check for failed notifications + if failed_notifications > 0: + issues.append(f'{failed_notifications} failed notifications') + + # Check for admin users without notifications + admin_with_no_notifications = admin_users.filter( + notifications__isnull=True + ).count() + if admin_with_no_notifications > 0 and total_notifications > 0: + issues.append(f'{admin_with_no_notifications} admin users with no notifications') + + if issues: + self.stdout.write(self.style.WARNING(' โš ๏ธ Issues found:')) + for issue in issues: + self.stdout.write(f' - {issue}') + else: + self.stdout.write(self.style.SUCCESS(' โœ… No issues detected')) + + # Recent activity + recent_notifications = Notification.objects.filter( + created_at__gte=datetime.datetime.now() - datetime.timedelta(hours=24) + ).count() + + self.stdout.write(f'\n๐Ÿ• Recent Activity (last 24 hours):') + self.stdout.write(f' New notifications: {recent_notifications}') + + # Summary + self.stdout.write(f'\n๐Ÿ“‹ Summary:') + if total_notifications > 0 and failed_notifications == 0: + self.stdout.write(self.style.SUCCESS(' โœ… Notification system is working correctly')) + elif failed_notifications > 0: + self.stdout.write(self.style.WARNING(' โš ๏ธ Notification system has some failures')) + else: + self.stdout.write(self.style.WARNING(' โš ๏ธ No notifications found - system may not be active')) + + self.stdout.write('\n' + '=' * 50) + self.stdout.write(self.style.SUCCESS('โœจ Verification complete!')) diff --git a/recruitment/migrations/0003_candidate_hired_date_source_custom_headers_and_more.py b/recruitment/migrations/0003_candidate_hired_date_source_custom_headers_and_more.py new file mode 100644 index 0000000..7c999ad --- /dev/null +++ b/recruitment/migrations/0003_candidate_hired_date_source_custom_headers_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.4 on 2025-10-26 13:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0002_candidate_retry'), + ] + + operations = [ + migrations.AddField( + model_name='candidate', + name='hired_date', + field=models.DateField(blank=True, null=True, verbose_name='Hired Date'), + ), + migrations.AddField( + model_name='source', + name='custom_headers', + field=models.TextField(blank=True, help_text='JSON object with custom HTTP headers for sync requests', null=True, verbose_name='Custom Headers'), + ), + migrations.AddField( + model_name='source', + name='supports_outbound_sync', + field=models.BooleanField(default=False, help_text='Whether this source supports receiving candidate data from ATS', verbose_name='Supports Outbound Sync'), + ), + migrations.AddField( + model_name='source', + name='sync_endpoint', + field=models.URLField(blank=True, help_text='Endpoint URL for sending candidate data (for outbound sync)', null=True, verbose_name='Sync Endpoint'), + ), + migrations.AddField( + model_name='source', + name='sync_method', + field=models.CharField(blank=True, choices=[('POST', 'POST'), ('PUT', 'PUT')], default='POST', help_text='HTTP method for outbound sync requests', max_length=10, verbose_name='Sync Method'), + ), + migrations.AddField( + model_name='source', + name='test_method', + field=models.CharField(blank=True, choices=[('GET', 'GET'), ('POST', 'POST')], default='GET', help_text='HTTP method for connection testing', max_length=10, verbose_name='Test Method'), + ), + migrations.AlterField( + model_name='candidate', + name='stage', + field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired')], db_index=True, default='Applied', max_length=100, verbose_name='Stage'), + ), + ] diff --git a/recruitment/migrations/0004_alter_integrationlog_method.py b/recruitment/migrations/0004_alter_integrationlog_method.py new file mode 100644 index 0000000..e4ab1d0 --- /dev/null +++ b/recruitment/migrations/0004_alter_integrationlog_method.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-10-26 13:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0003_candidate_hired_date_source_custom_headers_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='integrationlog', + name='method', + field=models.CharField(blank=True, max_length=50, verbose_name='HTTP Method'), + ), + ] diff --git a/recruitment/migrations/0005_rename_submitted_by_agency_candidate_hiring_agency.py b/recruitment/migrations/0005_rename_submitted_by_agency_candidate_hiring_agency.py new file mode 100644 index 0000000..48ea4b0 --- /dev/null +++ b/recruitment/migrations/0005_rename_submitted_by_agency_candidate_hiring_agency.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-10-26 14:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0004_alter_integrationlog_method'), + ] + + operations = [ + migrations.RenameField( + model_name='candidate', + old_name='submitted_by_agency', + new_name='hiring_agency', + ), + ] diff --git a/recruitment/migrations/0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more.py b/recruitment/migrations/0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more.py new file mode 100644 index 0000000..8c1c20c --- /dev/null +++ b/recruitment/migrations/0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more.py @@ -0,0 +1,129 @@ +# Generated by Django 5.2.6 on 2025-10-26 14:51 + +import django.db.models.deletion +import django_extensions.db.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0005_rename_submitted_by_agency_candidate_hiring_agency'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AgencyJobAssignment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('max_candidates', models.PositiveIntegerField(help_text='Maximum candidates agency can submit for this job', verbose_name='Maximum Candidates')), + ('candidates_submitted', models.PositiveIntegerField(default=0, help_text='Number of candidates submitted so far', verbose_name='Candidates Submitted')), + ('assigned_date', models.DateTimeField(auto_now_add=True, verbose_name='Assigned Date')), + ('deadline_date', models.DateTimeField(help_text='Deadline for agency to submit candidates', verbose_name='Deadline Date')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('status', models.CharField(choices=[('ACTIVE', 'Active'), ('COMPLETED', 'Completed'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='ACTIVE', max_length=20, verbose_name='Status')), + ('deadline_extended', models.BooleanField(default=False, verbose_name='Deadline Extended')), + ('original_deadline', models.DateTimeField(blank=True, help_text='Original deadline before extensions', null=True, verbose_name='Original Deadline')), + ('admin_notes', models.TextField(blank=True, help_text='Internal notes about this assignment', verbose_name='Admin Notes')), + ('agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_assignments', to='recruitment.hiringagency', verbose_name='Agency')), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agency_assignments', to='recruitment.jobposting', verbose_name='Job')), + ], + options={ + 'verbose_name': 'Agency Job Assignment', + 'verbose_name_plural': 'Agency Job Assignments', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='AgencyAccessLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('unique_token', models.CharField(editable=False, max_length=64, unique=True, verbose_name='Unique Token')), + ('access_password', models.CharField(help_text='Password for agency access', max_length=32, verbose_name='Access Password')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('expires_at', models.DateTimeField(help_text='When this access link expires', verbose_name='Expires At')), + ('last_accessed', models.DateTimeField(blank=True, null=True, verbose_name='Last Accessed')), + ('access_count', models.PositiveIntegerField(default=0, verbose_name='Access Count')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('assignment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='access_link', to='recruitment.agencyjobassignment', verbose_name='Assignment')), + ], + options={ + 'verbose_name': 'Agency Access Link', + 'verbose_name_plural': 'Agency Access Links', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='AgencyMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('subject', models.CharField(max_length=200, verbose_name='Subject')), + ('message', models.TextField(verbose_name='Message')), + ('message_type', models.CharField(choices=[('INFO', 'Information'), ('WARNING', 'Warning'), ('EXTENSION', 'Deadline Extension'), ('GENERAL', 'General')], default='GENERAL', max_length=20, verbose_name='Message Type')), + ('is_read', models.BooleanField(default=False, verbose_name='Is Read')), + ('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')), + ('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.agencyjobassignment', verbose_name='Assignment')), + ('recipient_agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to='recruitment.hiringagency', verbose_name='Recipient Agency')), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')), + ], + options={ + 'verbose_name': 'Agency Message', + 'verbose_name_plural': 'Agency Messages', + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'), + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['job', 'status'], name='recruitment_job_id_d798a8_idx'), + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['deadline_date'], name='recruitment_deadlin_57d3b4_idx'), + ), + migrations.AddIndex( + model_name='agencyjobassignment', + index=models.Index(fields=['is_active'], name='recruitment_is_acti_93b919_idx'), + ), + migrations.AlterUniqueTogether( + name='agencyjobassignment', + unique_together={('agency', 'job')}, + ), + migrations.AddIndex( + model_name='agencyaccesslink', + index=models.Index(fields=['unique_token'], name='recruitment_unique__f91e76_idx'), + ), + migrations.AddIndex( + model_name='agencyaccesslink', + index=models.Index(fields=['expires_at'], name='recruitment_expires_954ed9_idx'), + ), + migrations.AddIndex( + model_name='agencyaccesslink', + index=models.Index(fields=['is_active'], name='recruitment_is_acti_4b0804_idx'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['assignment', 'is_read'], name='recruitment_assignm_4f518d_idx'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['recipient_agency', 'is_read'], name='recruitment_recipie_427b10_idx'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['sender'], name='recruitment_sender__97dd96_idx'), + ), + ] diff --git a/recruitment/migrations/0007_candidate_source_candidate_source_type.py b/recruitment/migrations/0007_candidate_source_candidate_source_type.py new file mode 100644 index 0000000..5f83b42 --- /dev/null +++ b/recruitment/migrations/0007_candidate_source_candidate_source_type.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.6 on 2025-10-27 11:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='candidate', + name='source', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.source', verbose_name='Source'), + ), + migrations.AddField( + model_name='candidate', + name='source_type', + field=models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Source'), + ), + ] diff --git a/recruitment/migrations/0008_remove_candidate_source_remove_candidate_source_type_and_more.py b/recruitment/migrations/0008_remove_candidate_source_remove_candidate_source_type_and_more.py new file mode 100644 index 0000000..591252c --- /dev/null +++ b/recruitment/migrations/0008_remove_candidate_source_remove_candidate_source_type_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.6 on 2025-10-27 11:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0007_candidate_source_candidate_source_type'), + ] + + operations = [ + migrations.RemoveField( + model_name='candidate', + name='source', + ), + migrations.RemoveField( + model_name='candidate', + name='source_type', + ), + migrations.AddField( + model_name='candidate', + name='hiring_source', + field=models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source'), + ), + migrations.AlterField( + model_name='candidate', + name='hiring_agency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.hiringagency', verbose_name='Hiring Agency'), + ), + ] diff --git a/recruitment/migrations/0009_agencymessage_priority_agencymessage_recipient_user_and_more.py b/recruitment/migrations/0009_agencymessage_priority_agencymessage_recipient_user_and_more.py new file mode 100644 index 0000000..5b27532 --- /dev/null +++ b/recruitment/migrations/0009_agencymessage_priority_agencymessage_recipient_user_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.6 on 2025-10-27 20:26 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0008_remove_candidate_source_remove_candidate_source_type_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='agencymessage', + name='priority', + field=models.CharField(choices=[('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High'), ('URGENT', 'Urgent')], default='MEDIUM', max_length=10, verbose_name='Priority'), + ), + migrations.AddField( + model_name='agencymessage', + name='recipient_user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient User'), + ), + migrations.AddField( + model_name='agencymessage', + name='send_email', + field=models.BooleanField(default=False, verbose_name='Send Email Notification'), + ), + migrations.AddField( + model_name='agencymessage', + name='send_sms', + field=models.BooleanField(default=False, verbose_name='Send SMS Notification'), + ), + migrations.AddField( + model_name='agencymessage', + name='sender_agency', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to='recruitment.hiringagency', verbose_name='Sender Agency'), + ), + migrations.AddField( + model_name='agencymessage', + name='sender_type', + field=models.CharField(choices=[('ADMIN', 'Admin'), ('AGENCY', 'Agency')], default='ADMIN', max_length=10, verbose_name='Sender Type'), + ), + migrations.AlterField( + model_name='agencymessage', + name='sender', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['sender_type', 'created_at'], name='recruitment_sender__14b136_idx'), + ), + migrations.AddIndex( + model_name='agencymessage', + index=models.Index(fields=['priority', 'created_at'], name='recruitment_priorit_80d9f1_idx'), + ), + ] diff --git a/recruitment/migrations/0010_remove_agency_message_model.py b/recruitment/migrations/0010_remove_agency_message_model.py new file mode 100644 index 0000000..f042dcf --- /dev/null +++ b/recruitment/migrations/0010_remove_agency_message_model.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.6 on 2025-10-29 10:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0009_agencymessage_priority_agencymessage_recipient_user_and_more'), + ] + + operations = [ + migrations.DeleteModel( + name='AgencyMessage', + ), + ] diff --git a/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc b/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc index 46b6a0f..8d49e50 100644 Binary files a/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc and b/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/recruitment/models.py b/recruitment/models.py index 7958a96..a7674b5 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -319,6 +319,9 @@ class JobPosting(Base): @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 @@ -355,6 +358,7 @@ class Candidate(Base): EXAM = "Exam", _("Exam") INTERVIEW = "Interview", _("Interview") OFFER = "Offer", _("Offer") + HIRED = "Hired", _("Hired") class ExamStatus(models.TextChoices): PASSED = "Passed", _("Passed") @@ -436,6 +440,7 @@ 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", @@ -443,22 +448,23 @@ class Candidate(Base): help_text="Full JSON output from the resume scoring model." )# {'resume_data': {}, 'analysis_data': {}} - # Scoring fields (populated by signal) - # match_score = models.IntegerField(db_index=True, null=True, blank=True) # Added index - # strengths = models.TextField(blank=True) - # 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( + 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") @@ -1173,7 +1179,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") ) @@ -1233,6 +1239,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""" diff --git a/recruitment/signals.py b/recruitment/signals.py index 5f6b623..7f73baf 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -6,7 +6,9 @@ from django_q.tasks import schedule from django.dispatch import receiver from django_q.tasks import async_task from django.db.models.signals import post_save -from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting +from django.contrib.auth.models import User +from django.utils import timezone +from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting,Notification logger = logging.getLogger(__name__) @@ -49,7 +51,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' @@ -351,4 +353,38 @@ def create_default_stages(sender, instance, created, **kwargs): # required=False, # order=3, # is_predefined=True - # ) \ No newline at end of file + # ) + + +# AgencyMessage signal handler removed - model has been deleted + +# SSE notification cache for real-time updates +SSE_NOTIFICATION_CACHE = {} + +@receiver(post_save, sender=Notification) +def notification_created(sender, instance, created, **kwargs): + """Signal handler for when a notification is created""" + if created: + logger.info(f"New notification created: {instance.id} for user {instance.recipient.username}") + + # Store notification in cache for SSE + user_id = instance.recipient.id + if user_id not in SSE_NOTIFICATION_CACHE: + SSE_NOTIFICATION_CACHE[user_id] = [] + + notification_data = { + 'id': instance.id, + 'message': instance.message[:100] + ('...' if len(instance.message) > 100 else ''), + 'type': instance.get_notification_type_display(), + 'status': instance.get_status_display(), + 'time_ago': 'Just now', + 'url': f"/notifications/{instance.id}/" + } + + SSE_NOTIFICATION_CACHE[user_id].append(notification_data) + + # Keep only last 50 notifications per user in cache + if len(SSE_NOTIFICATION_CACHE[user_id]) > 50: + SSE_NOTIFICATION_CACHE[user_id] = SSE_NOTIFICATION_CACHE[user_id][-50:] + + logger.info(f"Notification cached for SSE: {notification_data}") diff --git a/recruitment/tasks.py b/recruitment/tasks.py index ab66b0b..ac2e6ad 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -583,23 +583,24 @@ def sync_hired_candidates_task(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) - ) + # 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 diff --git a/recruitment/urls.py b/recruitment/urls.py index dbc0b4e..5ea13e2 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -77,9 +77,6 @@ urlpatterns = [ # Sync URLs path('jobs//sync-hired-candidates/', views_frontend.sync_hired_candidates, name='sync_hired_candidates'), path('sources//test-connection/', views_frontend.test_source_connection, name='test_source_connection'), - path('sync/task//status/', views_frontend.sync_task_status, name='sync_task_status'), - path('sync/history/', views_frontend.sync_history, name='sync_history'), - path('sync/history//', views_frontend.sync_history, name='sync_history_job'), path('jobs///reschedule_meeting_for_candidate//', views.reschedule_meeting_for_candidate, name='reschedule_meeting_for_candidate'), @@ -152,4 +149,72 @@ urlpatterns = [ path('meetings//comments//delete/', views.delete_meeting_comment, name='delete_meeting_comment'), path('meetings//set_meeting_candidate/', views.set_meeting_candidate, name='set_meeting_candidate'), + + # Hiring Agency URLs + path('agencies/', views.agency_list, name='agency_list'), + path('agencies/create/', views.agency_create, name='agency_create'), + path('agencies//', views.agency_detail, name='agency_detail'), + path('agencies//update/', views.agency_update, name='agency_update'), + path('agencies//delete/', views.agency_delete, name='agency_delete'), + path('agencies//candidates/', views.agency_candidates, name='agency_candidates'), + # path('agencies//send-message/', views.agency_detail_send_message, name='agency_detail_send_message'), + + # Agency Assignment Management URLs + path('agency-assignments/', views.agency_assignment_list, name='agency_assignment_list'), + path('agency-assignments/create/', views.agency_assignment_create, name='agency_assignment_create'), + path('agency-assignments//create/', views.agency_assignment_create, name='agency_assignment_create'), + path('agency-assignments//', views.agency_assignment_detail, name='agency_assignment_detail'), + path('agency-assignments//update/', views.agency_assignment_update, name='agency_assignment_update'), + path('agency-assignments//extend-deadline/', views.agency_assignment_extend_deadline, name='agency_assignment_extend_deadline'), + + # Agency Access Link URLs + path('agency-access-links/create/', views.agency_access_link_create, name='agency_access_link_create'), + path('agency-access-links//', views.agency_access_link_detail, name='agency_access_link_detail'), + path('agency-access-links//deactivate/', views.agency_access_link_deactivate, name='agency_access_link_deactivate'), + path('agency-access-links//reactivate/', views.agency_access_link_reactivate, name='agency_access_link_reactivate'), + + # Admin Message Center URLs (messaging functionality removed) + # path('admin/messages/', views.admin_message_center, name='admin_message_center'), + # path('admin/messages/compose/', views.admin_compose_message, name='admin_compose_message'), + # path('admin/messages//', views.admin_message_detail, name='admin_message_detail'), + # path('admin/messages//reply/', views.admin_message_reply, name='admin_message_reply'), + # path('admin/messages//mark-read/', views.admin_mark_message_read, name='admin_mark_message_read'), + # path('admin/messages//delete/', views.admin_delete_message, name='admin_delete_message'), + + # Agency Portal URLs (for external agencies) + path('portal/login/', views.agency_portal_login, name='agency_portal_login'), + path('portal/dashboard/', views.agency_portal_dashboard, name='agency_portal_dashboard'), + path('portal/assignment//', views.agency_portal_assignment_detail, name='agency_portal_assignment_detail'), + path('portal/assignment//submit-candidate/', views.agency_portal_submit_candidate_page, name='agency_portal_submit_candidate_page'), + path('portal/submit-candidate/', views.agency_portal_submit_candidate, name='agency_portal_submit_candidate'), + path('portal/logout/', views.agency_portal_logout, name='agency_portal_logout'), + + # Agency Portal Candidate Management URLs + path('portal/candidates//edit/', views.agency_portal_edit_candidate, name='agency_portal_edit_candidate'), + path('portal/candidates//delete/', views.agency_portal_delete_candidate, name='agency_portal_delete_candidate'), + + # API URLs for messaging (removed) + # path('api/agency/messages//', views.api_agency_message_detail, name='api_agency_message_detail'), + # path('api/agency/messages//mark-read/', views.api_agency_mark_message_read, name='api_agency_mark_message_read'), + + # API URLs for candidate management + path('api/candidate//', views.api_candidate_detail, name='api_candidate_detail'), + + # Admin Notification API + path('api/admin/notification-count/', views.api_notification_count, name='admin_notification_count'), + + # Agency Notification API + path('api/agency/notification-count/', views.api_notification_count, name='api_agency_notification_count'), + + # SSE Notification Stream + path('api/notifications/stream/', views.notification_stream, name='notification_stream'), + + # Notification URLs + path('notifications/', views.notification_list, name='notification_list'), + path('notifications//', views.notification_detail, name='notification_detail'), + path('notifications//mark-read/', views.notification_mark_read, name='notification_mark_read'), + path('notifications//mark-unread/', views.notification_mark_unread, name='notification_mark_unread'), + path('notifications//delete/', views.notification_delete, name='notification_delete'), + path('notifications/mark-all-read/', views.notification_mark_all_read, name='notification_mark_all_read'), + path('api/notification-count/', views.api_notification_count, name='api_notification_count'), ] diff --git a/recruitment/views.py b/recruitment/views.py index 9d535fa..5b004e5 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -3,6 +3,7 @@ import json from django.utils.translation import gettext as _ from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required +from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.mixins import LoginRequiredMixin from rich import print @@ -33,7 +34,11 @@ from .forms import ( StaffUserCreationForm, MeetingCommentForm, ToggleAccountForm, - + HiringAgencyForm, + AgencyCandidateSubmissionForm, + AgencyLoginForm, + AgencyAccessLinkForm, + AgencyJobAssignmentForm ) from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from rest_framework import viewsets @@ -68,7 +73,10 @@ from .models import ( JobPosting, ScheduledInterview, JobPostingImage, - Profile,MeetingComment + Profile,MeetingComment,HiringAgency, + AgencyJobAssignment, + AgencyAccessLink, + Notification ) import logging from datastar_py.django import ( @@ -509,8 +517,6 @@ def kaauh_career(request): return render(request,'jobs/career.html',{'active_jobs':active_jobs}) - - # job detail facing the candidate: def application_detail(request, slug): job = get_object_or_404(JobPosting, slug=slug) @@ -588,7 +594,7 @@ def linkedin_callback(request): try: service = LinkedInService() - # get_access_token(code)->It makes a POST request to LinkedInโ€™s token endpoint with parameters + # get_access_token(code)->It makes a POST request to LinkedIn's token endpoint with parameters access_token = service.get_access_token(code) request.session["linkedin_access_token"] = access_token request.session["linkedin_authenticated"] = True @@ -1326,6 +1332,7 @@ def candidate_exam_view(request, slug): } return render(request, "recruitment/candidate_exam_view.html", context) + @login_required def update_candidate_exam_status(request, slug): candidate = get_object_or_404(Candidate, slug=slug) @@ -1384,7 +1391,7 @@ def candidate_update_status(request, slug): @login_required def candidate_interview_view(request,slug): - job = get_object_or_404(JobPosting,slug=slug) + job = get_object_or_404(JobPosting, slug=slug) context = {"job":job,"candidates":job.interview_candidates,'current_stage':'Interview'} return render(request,"recruitment/candidate_interview_view.html",context) @@ -1437,6 +1444,7 @@ def delete_meeting_for_candidate(request,slug,candidate_pk,meeting_id): context = {"job":job,"candidate":candidate,"meeting":meeting,'delete_url':reverse("delete_meeting_for_candidate",kwargs={"slug":job.slug,"candidate_pk":candidate_pk,"meeting_id":meeting_id})} return render(request,"meetings/delete_meeting_form.html",context) + @login_required def interview_calendar_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) @@ -1491,6 +1499,7 @@ def interview_calendar_view(request, slug): return render(request, 'recruitment/interview_calendar.html', context) + @login_required def interview_detail_view(request, slug, interview_id): job = get_object_or_404(JobPosting, slug=slug) @@ -1507,6 +1516,7 @@ def interview_detail_view(request, slug, interview_id): return render(request, 'recruitment/interview_detail.html', context) + # Candidate Meeting Scheduling/Rescheduling Views @require_POST def api_schedule_candidate_meeting(request, job_slug, candidate_pk): @@ -1766,6 +1776,7 @@ def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_ messages.error(request, result["message"]) return JsonResponse({'success': False, 'error': result["message"]}, status=400) + # The original schedule_candidate_meeting and reschedule_candidate_meeting (without api_ prefix) # can be removed if their only purpose was to be called by the JS onclicks. # If they were intended for other direct URL access, they can be kept as simple redirects @@ -1806,7 +1817,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): new_start_time = form.cleaned_data.get('start_time') new_duration = form.cleaned_data.get('duration') - # Use a default topic if not provided, keeping the original structure + # Use a default topic if not provided, keeping with the original structure if not new_topic: new_topic = f"Interview: {job.title} with {candidate.name}" @@ -1893,16 +1904,16 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): else: # Form validation errors return render(request, "recruitment/schedule_meeting_form.html", { - 'form': form, - 'job': job, - 'candidate': candidate, - 'scheduled_interview': scheduled_interview, - 'initial_topic': request.POST.get('topic', new_topic), - 'initial_start_time': request.POST.get('start_time', new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else ''), - 'initial_duration': request.POST.get('duration', new_duration), - 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), - 'has_future_meeting': has_other_future_meetings - }) + 'form': form, + 'job': job, + 'candidate': candidate, + 'scheduled_interview': scheduled_interview, + 'initial_topic': request.POST.get('topic', new_topic), + 'initial_start_time': request.POST.get('start_time', new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else ''), + 'initial_duration': request.POST.get('duration', new_duration), + 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), + 'has_future_meeting': has_other_future_meetings + }) else: # GET request # Pre-populate form with existing meeting details initial_data = { @@ -1924,7 +1935,7 @@ def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): def schedule_meeting_for_candidate(request, slug, candidate_pk): """ Handles GET to display a simple form for scheduling a meeting for a candidate. - Handles POST to process the form, create the meeting, and redirect back. + Handles POST to process the form, create a meeting, and redirect back. """ job = get_object_or_404(JobPosting, slug=slug) candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) @@ -2038,7 +2049,7 @@ def user_profile_image_update(request, pk): messages.success(request, 'Image uploaded successfully') return redirect('user_detail', pk=user.pk) else: - messages.error(request, 'An error occurred while uploading the image. Please check the errors below.') + messages.error(request, 'An error occurred while uploading image. Please check the errors below.') else: profile_form = ProfileImageUploadForm(instance=user.profile) @@ -2149,8 +2160,6 @@ def create_staff_user(request): - - @user_passes_test(is_superuser_check) def admin_settings(request): staffs=User.objects.filter(is_superuser=False) @@ -2265,24 +2274,26 @@ def add_meeting_comment(request, slug): return redirect('meeting_details', slug=slug) + + @login_required def edit_meeting_comment(request, slug, comment_id): """Edit a meeting comment""" meeting = get_object_or_404(ZoomMeeting, slug=slug) comment = get_object_or_404(MeetingComment, id=comment_id, meeting=meeting) - # Check if user is the author - if comment.author != request.user: + # Check if user is author + if comment.author != request.user and not request.user.is_staff: messages.error(request, 'You can only edit your own comments.') return redirect('meeting_details', slug=slug) if request.method == 'POST': form = MeetingCommentForm(request.POST, instance=comment) if form.is_valid(): - form.save() + comment = form.save() messages.success(request, 'Comment updated successfully!') - # HTMX response - return just the comment section + # HTMX response - return just comment section if 'HX-Request' in request.headers: return render(request, 'includes/comment_list.html', { 'comments': meeting.comments.all().order_by('-created_at'), @@ -2292,14 +2303,15 @@ def edit_meeting_comment(request, slug, comment_id): return redirect('meeting_details', slug=slug) else: form = MeetingCommentForm(instance=comment) - print("hi") + context = { 'form': form, 'meeting': meeting, - 'comment':comment + 'comment': comment } return render(request, 'includes/edit_comment_form.html', context) + @login_required def delete_meeting_comment(request, slug, comment_id): """Delete a meeting comment""" @@ -2318,9 +2330,9 @@ def delete_meeting_comment(request, slug, comment_id): # HTMX response - return just the comment section if 'HX-Request' in request.headers: return render(request, 'includes/comment_list.html', { - 'comments': meeting.comments.all().order_by('-created_at'), - 'meeting': meeting - }) + 'comments': meeting.comments.all().order_by('-created_at'), + 'meeting': meeting + }) return redirect('meeting_details', slug=slug) @@ -2367,3 +2379,1217 @@ def set_meeting_candidate(request,slug): "meeting": meeting } return render(request, 'meetings/set_candidate_form.html', context) + + +# Hiring Agency CRUD Views +@login_required +def agency_list(request): + """List all hiring agencies with search and pagination""" + search_query = request.GET.get('q', '') + agencies = HiringAgency.objects.all() + + if search_query: + agencies = agencies.filter( + Q(name__icontains=search_query) | + Q(contact_person__icontains=search_query) | + Q(email__icontains=search_query) | + Q(country__icontains=search_query) + ) + + # Order by most recently created + agencies = agencies.order_by('-created_at') + + # Pagination + paginator = Paginator(agencies, 10) # Show 10 agencies per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + context = { + 'page_obj': page_obj, + 'search_query': search_query, + 'total_agencies': agencies.count(), + } + return render(request, 'recruitment/agency_list.html', context) + + +@login_required +def agency_create(request): + """Create a new hiring agency""" + if request.method == 'POST': + form = HiringAgencyForm(request.POST) + if form.is_valid(): + agency = form.save() + messages.success(request, f'Agency "{agency.name}" created successfully!') + return redirect('agency_detail', slug=agency.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = HiringAgencyForm() + + context = { + 'form': form, + 'title': 'Create New Agency', + 'button_text': 'Create Agency', + } + return render(request, 'recruitment/agency_form.html', context) + + +@login_required +def agency_detail(request, slug): + """View details of a specific hiring agency""" + agency = get_object_or_404(HiringAgency, slug=slug) + + # Get candidates associated with this agency + candidates = Candidate.objects.filter(hiring_agency=agency).order_by('-created_at') + + # Statistics + total_candidates = candidates.count() + active_candidates = candidates.filter(stage__in=['Applied', 'Screening', 'Exam', 'Interview', 'Offer']).count() + hired_candidates = candidates.filter(stage='Hired').count() + rejected_candidates = candidates.filter(stage='Rejected').count() + + context = { + 'agency': agency, + 'candidates': candidates[:10], # Show recent 10 candidates + 'total_candidates': total_candidates, + 'active_candidates': active_candidates, + 'hired_candidates': hired_candidates, + 'rejected_candidates': rejected_candidates, + } + return render(request, 'recruitment/agency_detail.html', context) + + +@login_required +def agency_update(request, slug): + """Update an existing hiring agency""" + agency = get_object_or_404(HiringAgency, slug=slug) + + if request.method == 'POST': + form = HiringAgencyForm(request.POST, instance=agency) + if form.is_valid(): + agency = form.save() + messages.success(request, f'Agency "{agency.name}" updated successfully!') + return redirect('agency_detail', slug=agency.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = HiringAgencyForm(instance=agency) + + context = { + 'form': form, + 'agency': agency, + 'title': f'Edit Agency: {agency.name}', + 'button_text': 'Update Agency', + } + return render(request, 'recruitment/agency_form.html', context) + + +@login_required +def agency_delete(request, slug): + """Delete a hiring agency""" + agency = get_object_or_404(HiringAgency, slug=slug) + + if request.method == 'POST': + agency_name = agency.name + agency.delete() + messages.success(request, f'Agency "{agency_name}" deleted successfully!') + return redirect('agency_list') + + context = { + 'agency': agency, + 'title': 'Delete Agency', + 'message': f'Are you sure you want to delete the agency "{agency.name}"?', + 'cancel_url': reverse('agency_detail', kwargs={'slug': agency.slug}), + } + return render(request, 'recruitment/agency_confirm_delete.html', context) + + +# Notification Views +@login_required +def notification_list(request): + """List all notifications for the current user""" + # Get filter parameters + status_filter = request.GET.get('status', '') + type_filter = request.GET.get('type', '') + + # Base queryset + notifications = Notification.objects.filter(recipient=request.user).order_by('-created_at') + + # Apply filters + if status_filter: + if status_filter == 'unread': + notifications = notifications.filter(status=Notification.Status.PENDING) + elif status_filter == 'read': + notifications = notifications.filter(status=Notification.Status.READ) + elif status_filter == 'sent': + notifications = notifications.filter(status=Notification.Status.SENT) + + if type_filter: + if type_filter == 'in_app': + notifications = notifications.filter(notification_type=Notification.NotificationType.IN_APP) + elif type_filter == 'email': + notifications = notifications.filter(notification_type=Notification.NotificationType.EMAIL) + + # Pagination + paginator = Paginator(notifications, 20) # Show 20 notifications per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + # Statistics + total_notifications = notifications.count() + unread_notifications = notifications.filter(status=Notification.Status.PENDING).count() + email_notifications = notifications.filter(notification_type=Notification.NotificationType.EMAIL).count() + + context = { + 'page_obj': page_obj, + 'total_notifications': total_notifications, + 'unread_notifications': unread_notifications, + 'email_notifications': email_notifications, + 'status_filter': status_filter, + 'type_filter': type_filter, + } + return render(request, 'recruitment/notification_list.html', context) + + +@login_required +def notification_detail(request, notification_id): + """View details of a specific notification""" + notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) + + # Mark as read if it was pending + if notification.status == Notification.Status.PENDING: + notification.status = Notification.Status.READ + notification.save(update_fields=['status']) + + context = { + 'notification': notification, + } + return render(request, 'recruitment/notification_detail.html', context) + + +@login_required +def notification_mark_read(request, notification_id): + """Mark a notification as read""" + notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) + + if notification.status == Notification.Status.PENDING: + notification.status = Notification.Status.READ + notification.save(update_fields=['status']) + + if 'HX-Request' in request.headers: + return HttpResponse(status=200) # HTMX success response + + return redirect('notification_list') + + +@login_required +def notification_mark_unread(request, notification_id): + """Mark a notification as unread""" + notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) + + if notification.status == Notification.Status.READ: + notification.status = Notification.Status.PENDING + notification.save(update_fields=['status']) + + if 'HX-Request' in request.headers: + return HttpResponse(status=200) # HTMX success response + + return redirect('notification_list') + + +@login_required +def notification_delete(request, notification_id): + """Delete a notification""" + notification = get_object_or_404(Notification, id=notification_id, recipient=request.user) + + if request.method == 'POST': + notification.delete() + messages.success(request, 'Notification deleted successfully!') + return redirect('notification_list') + + # For GET requests, show confirmation page + context = { + 'notification': notification, + 'title': 'Delete Notification', + 'message': f'Are you sure you want to delete this notification?', + 'cancel_url': reverse('notification_detail', kwargs={'notification_id': notification.id}), + } + return render(request, 'recruitment/notification_confirm_delete.html', context) + + +@login_required +def notification_mark_all_read(request): + """Mark all notifications as read for the current user""" + if request.method == 'POST': + Notification.objects.filter( + recipient=request.user, + status=Notification.Status.PENDING + ).update(status=Notification.Status.READ) + + messages.success(request, 'All notifications marked as read!') + return redirect('notification_list') + + # For GET requests, show confirmation page + unread_count = Notification.objects.filter( + recipient=request.user, + status=Notification.Status.PENDING + ).count() + + context = { + 'unread_count': unread_count, + 'title': 'Mark All as Read', + 'message': f'Are you sure you want to mark all {unread_count} notifications as read?', + 'cancel_url': reverse('notification_list'), + } + return render(request, 'recruitment/notification_confirm_all_read.html', context) + + +@login_required +def api_notification_count(request): + """API endpoint to get unread notification count and recent notifications""" + # Get unread notifications + unread_notifications = Notification.objects.filter( + recipient=request.user, + status=Notification.Status.PENDING + ).order_by('-created_at') + + # Get recent notifications (last 5) + recent_notifications = Notification.objects.filter( + recipient=request.user + ).order_by('-created_at')[:5] + + # Prepare recent notifications data + recent_data = [] + for notification in recent_notifications: + time_ago = '' + if notification.created_at: + from datetime import datetime, timezone + now = timezone.now() + diff = now - notification.created_at + + if diff.days > 0: + time_ago = f'{diff.days}d ago' + elif diff.seconds > 3600: + hours = diff.seconds // 3600 + time_ago = f'{hours}h ago' + elif diff.seconds > 60: + minutes = diff.seconds // 60 + time_ago = f'{minutes}m ago' + else: + time_ago = 'Just now' + + recent_data.append({ + 'id': notification.id, + 'message': notification.message[:100] + ('...' if len(notification.message) > 100 else ''), + 'type': notification.get_notification_type_display(), + 'status': notification.get_status_display(), + 'time_ago': time_ago, + 'url': reverse('notification_detail', kwargs={'notification_id': notification.id}) + }) + + return JsonResponse({ + 'count': unread_notifications.count(), + 'recent_notifications': recent_data + }) + + +@login_required +def notification_stream(request): + """SSE endpoint for real-time notifications""" + from django.http import StreamingHttpResponse + import json + import time + from .signals import SSE_NOTIFICATION_CACHE + + def event_stream(): + """Generator function for SSE events""" + user_id = request.user.id + last_notification_id = 0 + + # Get initial last notification ID + last_notification = Notification.objects.filter( + recipient=request.user + ).order_by('-id').first() + if last_notification: + last_notification_id = last_notification.id + + # Send any cached notifications first + cached_notifications = SSE_NOTIFICATION_CACHE.get(user_id, []) + for cached_notification in cached_notifications: + if cached_notification['id'] > last_notification_id: + yield f"event: new_notification\n" + yield f"data: {json.dumps(cached_notification)}\n\n" + last_notification_id = cached_notification['id'] + + while True: + try: + # Check for new notifications from cache first + cached_notifications = SSE_NOTIFICATION_CACHE.get(user_id, []) + new_cached = [n for n in cached_notifications if n['id'] > last_notification_id] + + for notification_data in new_cached: + yield f"event: new_notification\n" + yield f"data: {json.dumps(notification_data)}\n\n" + last_notification_id = notification_data['id'] + + # Also check database for any missed notifications + new_notifications = Notification.objects.filter( + recipient=request.user, + id__gt=last_notification_id + ).order_by('id') + + if new_notifications.exists(): + for notification in new_notifications: + # Prepare notification data + time_ago = '' + if notification.created_at: + now = timezone.now() + diff = now - notification.created_at + + if diff.days > 0: + time_ago = f'{diff.days}d ago' + elif diff.seconds > 3600: + hours = diff.seconds // 3600 + time_ago = f'{hours}h ago' + elif diff.seconds > 60: + minutes = diff.seconds // 60 + time_ago = f'{minutes}m ago' + else: + time_ago = 'Just now' + + notification_data = { + 'id': notification.id, + 'message': notification.message[:100] + ('...' if len(notification.message) > 100 else ''), + 'type': notification.get_notification_type_display(), + 'status': notification.get_status_display(), + 'time_ago': time_ago, + 'url': reverse('notification_detail', kwargs={'notification_id': notification.id}) + } + + # Send SSE event + yield f"event: new_notification\n" + yield f"data: {json.dumps(notification_data)}\n\n" + + last_notification_id = notification.id + + # Update count after sending new notifications + unread_count = Notification.objects.filter( + recipient=request.user, + status=Notification.Status.PENDING + ).count() + + count_data = {'count': unread_count} + yield f"event: count_update\n" + yield f"data: {json.dumps(count_data)}\n\n" + + # Send heartbeat every 30 seconds + yield f"event: heartbeat\n" + yield f"data: {json.dumps({'timestamp': int(time.time())})}\n\n" + + # Wait before next check + time.sleep(5) # Check every 5 seconds + + except Exception as e: + # Send error event and continue + error_data = {'error': str(e)} + yield f"event: error\n" + yield f"data: {json.dumps(error_data)}\n\n" + time.sleep(10) # Wait longer on error + + response = StreamingHttpResponse( + event_stream(), + content_type='text/event-stream' + ) + + # Set SSE headers + response['Cache-Control'] = 'no-cache' + response['X-Accel-Buffering'] = 'no' # Disable buffering for nginx + response['Connection'] = 'keep-alive' + + context = { + 'agency': agency, + 'page_obj': page_obj, + 'stage_filter': stage_filter, + 'total_candidates': candidates.count(), + } + return render(request, 'recruitment/agency_candidates.html', context) + + +@login_required +def agency_candidates(request, slug): + """View all candidates from a specific agency""" + agency = get_object_or_404(HiringAgency, slug=slug) + candidates = Candidate.objects.filter(hiring_agency=agency).order_by('-created_at') + + # Filter by stage if provided + stage_filter = request.GET.get('stage') + if stage_filter: + candidates = candidates.filter(stage=stage_filter) + + # Get total candidates before pagination for accurate count + total_candidates = candidates.count() + + # Pagination + paginator = Paginator(candidates, 20) # Show 20 candidates per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + context = { + 'agency': agency, + 'page_obj': page_obj, + 'stage_filter': stage_filter, + 'total_candidates': total_candidates, + } + return render(request, 'recruitment/agency_candidates.html', context) + + + + +# Agency Portal Management Views +@login_required +def agency_assignment_list(request): + """List all agency job assignments""" + search_query = request.GET.get('q', '') + status_filter = request.GET.get('status', '') + + assignments = AgencyJobAssignment.objects.select_related( + 'agency', 'job' + ).order_by('-created_at') + + if search_query: + assignments = assignments.filter( + Q(agency__name__icontains=search_query) | + Q(job__title__icontains=search_query) + ) + + if status_filter: + assignments = assignments.filter(status=status_filter) + + # Pagination + paginator = Paginator(assignments, 15) # Show 15 assignments per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + context = { + 'page_obj': page_obj, + 'search_query': search_query, + 'status_filter': status_filter, + 'total_assignments': assignments.count(), + } + return render(request, 'recruitment/agency_assignment_list.html', context) + + +@login_required +def agency_assignment_create(request,slug=None): + """Create a new agency job assignment""" + agency = HiringAgency.objects.get(slug=slug) if slug else None + + if request.method == 'POST': + form = AgencyJobAssignmentForm(request.POST) + # if agency: + # form.instance.agency = agency + if form.is_valid(): + assignment = form.save() + messages.success(request, f'Assignment created for {assignment.agency.name} - {assignment.job.title}!') + return redirect('agency_assignment_detail', slug=assignment.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = AgencyJobAssignmentForm() + try: + from django.forms import HiddenInput + form.initial['agency'] = agency + form.fields['agency'].widget = HiddenInput() + except HiringAgency.DoesNotExist: + pass + + context = { + 'form': form, + 'title': 'Create New Assignment', + 'button_text': 'Create Assignment', + } + return render(request, 'recruitment/agency_assignment_form.html', context) + + +@login_required +def agency_assignment_detail(request, slug): + """View details of a specific agency assignment""" + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + slug=slug + ) + + # Get candidates submitted by this agency for this job + candidates = Candidate.objects.filter( + hiring_agency=assignment.agency, + job=assignment.job + ).order_by('-created_at') + + # Get access link if exists + access_link = getattr(assignment, 'access_link', None) + + # Get messages for this assignment + + + total_candidates = candidates.count() + max_candidates = assignment.max_candidates + circumference = 326.73 # 2 * ฯ€ * r where r=52 + + if max_candidates > 0: + progress_percentage = (total_candidates / max_candidates) + stroke_dashoffset = circumference - (circumference * progress_percentage) + else: + stroke_dashoffset = circumference + + context = { + 'assignment': assignment, + 'candidates': candidates, + 'access_link': access_link, + + 'total_candidates': candidates.count(), + 'stroke_dashoffset': stroke_dashoffset, + } + return render(request, 'recruitment/agency_assignment_detail.html', context) + + +@login_required +def agency_assignment_update(request, slug): + """Update an existing agency assignment""" + assignment = get_object_or_404(AgencyJobAssignment, slug=slug) + + if request.method == 'POST': + form = AgencyJobAssignmentForm(request.POST, instance=assignment) + if form.is_valid(): + assignment = form.save() + messages.success(request, f'Assignment updated successfully!') + return redirect('agency_assignment_detail', slug=assignment.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = AgencyJobAssignmentForm(instance=assignment) + + context = { + 'form': form, + 'assignment': assignment, + 'title': f'Edit Assignment: {assignment.agency.name} - {assignment.job.title}', + 'button_text': 'Update Assignment', + } + return render(request, 'recruitment/agency_assignment_form.html', context) + + +@login_required +def agency_access_link_create(request): + """Create access link for agency assignment""" + if request.method == 'POST': + form = AgencyAccessLinkForm(request.POST) + if form.is_valid(): + access_link = form.save() + messages.success(request, f'Access link created for {access_link.assignment.agency.name}!') + return redirect('agency_assignment_detail', slug=access_link.assignment.slug) + else: + messages.error(request, 'Please correct the errors below.') + else: + form = AgencyAccessLinkForm() + + context = { + 'form': form, + 'title': 'Create Access Link', + 'button_text': 'Create Link', + } + return render(request, 'recruitment/agency_access_link_form.html', context) + + +@login_required +def agency_access_link_detail(request, slug): + """View details of an access link""" + access_link = get_object_or_404( + AgencyAccessLink.objects.select_related('assignment__agency', 'assignment__job'), + slug=slug + ) + + context = { + 'access_link': access_link, + } + return render(request, 'recruitment/agency_access_link_detail.html', context) + + + + + + + + + + + + + + + + +@login_required +def agency_assignment_extend_deadline(request, slug): + """Extend deadline for an agency assignment""" + assignment = get_object_or_404(AgencyJobAssignment, slug=slug) + + if request.method == 'POST': + new_deadline = request.POST.get('new_deadline') + if new_deadline: + try: + from datetime import datetime + new_deadline_dt = datetime.fromisoformat(new_deadline.replace('Z', '+00:00')) + # Ensure the new deadline is timezone-aware + if timezone.is_naive(new_deadline_dt): + new_deadline_dt = timezone.make_aware(new_deadline_dt) + + if assignment.extend_deadline(new_deadline_dt): + messages.success(request, f'Deadline extended to {new_deadline_dt.strftime("%Y-%m-%d %H:%M")}!') + else: + messages.error(request, 'New deadline must be later than current deadline.') + except ValueError: + messages.error(request, 'Invalid date format.') + else: + messages.error(request, 'Please provide a new deadline.') + + return redirect('agency_assignment_detail', slug=assignment.slug) + + +# Agency Portal Views (for external agencies) +def agency_portal_login(request): + """Agency login page""" + if request.session.get('agency_assignment_id'): + return redirect('agency_portal_dashboard') + if request.method == 'POST': + form = AgencyLoginForm(request.POST) + + if form.is_valid(): + # Check if validated_access_link attribute exists + + if hasattr(form, 'validated_access_link'): + access_link = form.validated_access_link + access_link.record_access() + + # Store assignment in session + request.session['agency_assignment_id'] = access_link.assignment.id + request.session['agency_name'] = access_link.assignment.agency.name + + messages.success(request, f'Welcome, {access_link.assignment.agency.name}!') + return redirect('agency_portal_dashboard') + else: + messages.error(request, 'Invalid token or password.') + else: + form = AgencyLoginForm() + + context = { + 'form': form, + } + return render(request, 'recruitment/agency_portal_login.html', context) + + +def agency_portal_dashboard(request): + """Agency portal dashboard showing all assignments for the agency""" + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return redirect('agency_portal_login') + + # Get the current assignment to determine the agency + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_id + ) + + agency = current_assignment.agency + + # Get ALL assignments for this agency + assignments = AgencyJobAssignment.objects.filter( + agency=agency + ).select_related('job').order_by('-created_at') + + # Calculate statistics for each assignment + assignment_stats = [] + for assignment in assignments: + candidates = Candidate.objects.filter( + hiring_agency=agency, + job=assignment.job + ).order_by('-created_at') + + unread_messages = 0 + + assignment_stats.append({ + 'assignment': assignment, + 'candidates': candidates, + 'candidate_count': candidates.count(), + 'unread_messages': unread_messages, + 'days_remaining': assignment.days_remaining, + 'is_active': assignment.is_currently_active, + 'can_submit': assignment.can_submit, + }) + + # Get overall statistics + total_candidates = sum(stats['candidate_count'] for stats in assignment_stats) + total_unread_messages = sum(stats['unread_messages'] for stats in assignment_stats) + active_assignments = sum(1 for stats in assignment_stats if stats['is_active']) + + context = { + 'agency': agency, + 'current_assignment': current_assignment, + 'assignment_stats': assignment_stats, + 'total_assignments': assignments.count(), + 'active_assignments': active_assignments, + 'total_candidates': total_candidates, + 'total_unread_messages': total_unread_messages, + } + return render(request, 'recruitment/agency_portal_dashboard.html', context) + + +def agency_portal_submit_candidate_page(request, slug): + """Dedicated page for submitting a candidate""" + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return redirect('agency_portal_login') + + # Get the specific assignment by slug and verify it belongs to the same agency + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_id + ) + + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + slug=slug + ) + + if assignment.is_full: + messages.error(request, 'Maximum candidate limit reached for this assignment.') + return redirect('agency_portal_assignment_detail', slug=assignment.slug) + # Verify this assignment belongs to the same agency as the logged-in session + if assignment.agency.id != current_assignment.agency.id: + messages.error(request, 'Access denied: This assignment does not belong to your agency.') + return redirect('agency_portal_dashboard') + + # Check if assignment allows submission + if not assignment.can_submit: + messages.error(request, 'Cannot submit candidates: Assignment is not active, expired, or full.') + return redirect('agency_portal_assignment_detail', slug=assignment.slug) + + # Get total submitted candidates for this assignment + total_submitted = Candidate.objects.filter( + hiring_agency=assignment.agency, + job=assignment.job + ).count() + + if request.method == 'POST': + form = AgencyCandidateSubmissionForm(assignment, request.POST, request.FILES) + if form.is_valid(): + candidate = form.save(commit=False) + candidate.hiring_source = 'AGENCY' + candidate.hiring_agency = assignment.agency + candidate.save() + assignment.increment_submission_count() + + # Handle AJAX requests + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'message': f'Candidate {candidate.name} submitted successfully!', + 'candidate_id': candidate.id + }) + else: + messages.success(request, f'Candidate {candidate.name} submitted successfully!') + return redirect('agency_portal_assignment_detail', slug=assignment.slug) + else: + # Handle form validation errors for AJAX + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + error_messages = [] + for field, errors in form.errors.items(): + for error in errors: + error_messages.append(f'{field}: {error}') + return JsonResponse({ + 'success': False, + 'message': 'Please correct the following errors: ' + '; '.join(error_messages) + }) + else: + messages.error(request, 'Please correct errors below.') + else: + form = AgencyCandidateSubmissionForm(assignment) + + context = { + 'form': form, + 'assignment': assignment, + 'total_submitted': total_submitted, + } + return render(request, 'recruitment/agency_portal_submit_candidate.html', context) + + +def agency_portal_submit_candidate(request): + """Handle candidate submission via AJAX (for embedded form)""" + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return redirect('agency_portal_login') + + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + id=assignment_id + ) + if assignment.is_full: + messages.error(request, 'Maximum candidate limit reached for this assignment.') + return redirect('agency_portal_assignment_detail', slug=assignment.slug) + + # Check if assignment allows submission + if not assignment.can_submit: + messages.error(request, 'Cannot submit candidates: Assignment is not active, expired, or full.') + return redirect('agency_portal_dashboard') + + if request.method == 'POST': + form = AgencyCandidateSubmissionForm(assignment, request.POST, request.FILES) + if form.is_valid(): + candidate = form.save(commit=False) + candidate.hiring_source = 'AGENCY' + candidate.hiring_agency = assignment.agency + candidate.save() + + # Increment the assignment's submitted count + assignment.increment_submission_count() + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': True, 'message': f'Candidate {candidate.name} submitted successfully!'}) + else: + messages.success(request, f'Candidate {candidate.name} submitted successfully!') + return redirect('agency_portal_dashboard') + else: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': False, 'message': 'Please correct the errors below.'}) + else: + messages.error(request, 'Please correct errors below.') + else: + form = AgencyCandidateSubmissionForm(assignment) + + context = { + 'form': form, + 'assignment': assignment, + 'title': f'Submit Candidate for {assignment.job.title}', + 'button_text': 'Submit Candidate', + } + return render(request, 'recruitment/agency_portal_submit_candidate.html', context) + + + + +def agency_portal_assignment_detail(request, slug): + """View details of a specific assignment - routes to admin or agency template""" + print(slug) + # Check if this is an agency portal user (via session) + assignment_id = request.session.get('agency_assignment_id') + is_agency_user = bool(assignment_id) + return agency_assignment_detail_agency(request, slug, assignment_id) + # if is_agency_user: + # # Agency Portal User - Route to agency-specific template + # else: + # # Admin User - Route to admin template + # return agency_assignment_detail_admin(request, slug) + + +def agency_assignment_detail_agency(request, slug, assignment_id): + """Handle agency portal assignment detail view""" + # Get the assignment by slug and verify it belongs to same agency + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + slug=slug + ) + + # Verify this assignment belongs to the same agency as the logged-in session + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_id + ) + + if assignment.agency.id != current_assignment.agency.id: + messages.error(request, 'Access denied: This assignment does not belong to your agency.') + return redirect('agency_portal_dashboard') + + # Get candidates submitted by this agency for this job + candidates = Candidate.objects.filter( + hiring_agency=assignment.agency, + job=assignment.job + ).order_by('-created_at') + + # Get messages for this assignment + messages = [] + + # Mark messages as read + # No messages to mark as read + + # Pagination for candidates + paginator = Paginator(candidates, 20) # Show 20 candidates per page + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + # Pagination for messages + message_paginator = Paginator(messages, 15) # Show 15 messages per page + message_page_number = request.GET.get('message_page') + message_page_obj = message_paginator.get_page(message_page_number) + + # Calculate progress ring offset for circular progress indicator + total_candidates = candidates.count() + max_candidates = assignment.max_candidates + circumference = 326.73 # 2 * ฯ€ * r where r=52 + + if max_candidates > 0: + progress_percentage = (total_candidates / max_candidates) + stroke_dashoffset = circumference - (circumference * progress_percentage) + else: + stroke_dashoffset = circumference + + context = { + 'assignment': assignment, + 'page_obj': page_obj, + 'message_page_obj': message_page_obj, + 'total_candidates': total_candidates, + 'stroke_dashoffset': stroke_dashoffset, + } + return render(request, 'recruitment/agency_portal_assignment_detail.html', context) + + +def agency_assignment_detail_admin(request, slug): + """Handle admin assignment detail view""" + assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency', 'job'), + slug=slug + ) + + # Get candidates submitted by this agency for this job + candidates = Candidate.objects.filter( + hiring_agency=assignment.agency, + job=assignment.job + ).order_by('-created_at') + + # Get access link if exists + access_link = getattr(assignment, 'access_link', None) + + # Get messages for this assignment + messages = [] + + context = { + 'assignment': assignment, + 'candidates': candidates, + 'access_link': access_link, + 'total_candidates': candidates.count(), + } + return render(request, 'recruitment/agency_assignment_detail.html', context) + + +def agency_portal_edit_candidate(request, candidate_id): + """Edit a candidate for agency portal""" + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return redirect('agency_portal_login') + + # Get current assignment to determine agency + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_id + ) + + agency = current_assignment.agency + + # Get candidate and verify it belongs to this agency + candidate = get_object_or_404(Candidate, id=candidate_id, hiring_agency=agency) + + if request.method == 'POST': + # Handle form submission + candidate.first_name = request.POST.get('first_name', candidate.first_name) + candidate.last_name = request.POST.get('last_name', candidate.last_name) + candidate.email = request.POST.get('email', candidate.email) + candidate.phone = request.POST.get('phone', candidate.phone) + candidate.address = request.POST.get('address', candidate.address) + + # Handle resume upload if provided + if 'resume' in request.FILES: + candidate.resume = request.FILES['resume'] + + try: + candidate.save() + messages.success(request, f'Candidate {candidate.name} updated successfully!') + return redirect('agency_assignment_detail', slug=candidate.job.agencyjobassignment_set.first().slug) + except Exception as e: + messages.error(request, f'Error updating candidate: {e}') + + # For GET requests or POST errors, return JSON response for AJAX + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': True, + 'candidate': { + 'id': candidate.id, + 'first_name': candidate.first_name, + 'last_name': candidate.last_name, + 'email': candidate.email, + 'phone': candidate.phone, + 'address': candidate.address, + } + }) + + # Fallback for non-AJAX requests + return redirect('agency_portal_dashboard') + + +def agency_portal_delete_candidate(request, candidate_id): + """Delete a candidate for agency portal""" + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return redirect('agency_portal_login') + + # Get current assignment to determine agency + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_id + ) + + agency = current_assignment.agency + + # Get candidate and verify it belongs to this agency + candidate = get_object_or_404(Candidate, id=candidate_id, hiring_agency=agency) + + if request.method == 'POST': + try: + candidate_name = candidate.name + candidate.delete() + + current_assignment.candidates_submitted -= 1 + current_assignment.status = current_assignment.AssignmentStatus.ACTIVE + current_assignment.save(update_fields=['candidates_submitted','status']) + + messages.success(request, f'Candidate {candidate_name} removed successfully!') + return JsonResponse({'success': True}) + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) + + # For GET requests, return error + return JsonResponse({'success': False, 'error': 'Method not allowed'}) + + +def agency_portal_logout(request): + """Logout from agency portal""" + if 'agency_assignment_id' in request.session: + del request.session['agency_assignment_id'] + if 'agency_name' in request.session: + del request.session['agency_name'] + + messages.success(request, 'You have been logged out.') + return redirect('agency_portal_login') + + +@login_required +def agency_access_link_deactivate(request, slug): + """Deactivate an agency access link""" + access_link = get_object_or_404( + AgencyAccessLink.objects.select_related('assignment__agency', 'assignment__job'), + slug=slug + ) + + if request.method == 'POST': + access_link.is_active = False + access_link.save(update_fields=['is_active']) + + messages.success( + request, + f'Access link for {access_link.assignment.agency.name} - {access_link.assignment.job.title} has been deactivated.' + ) + + # Handle HTMX requests + if 'HX-Request' in request.headers: + return HttpResponse(status=200) # HTMX success response + + return redirect('agency_assignment_detail', slug=access_link.assignment.slug) + + # For GET requests, show confirmation page + context = { + 'access_link': access_link, + 'title': 'Deactivate Access Link', + 'message': f'Are you sure you want to deactivate the access link for {access_link.assignment.agency.name}?', + 'cancel_url': reverse('agency_assignment_detail', kwargs={'slug': access_link.assignment.slug}), + } + return render(request, 'recruitment/agency_access_link_confirm.html', context) + + +@login_required +def agency_access_link_reactivate(request, slug): + """Reactivate an agency access link""" + access_link = get_object_or_404( + AgencyAccessLink.objects.select_related('assignment__agency', 'assignment__job'), + slug=slug + ) + + if request.method == 'POST': + access_link.is_active = True + access_link.save(update_fields=['is_active']) + + messages.success( + request, + f'Access link for {access_link.assignment.agency.name} - {access_link.assignment.job.title} has been reactivated.' + ) + + # Handle HTMX requests + if 'HX-Request' in request.headers: + return HttpResponse(status=200) # HTMX success response + + return redirect('agency_assignment_detail', slug=access_link.assignment.slug) + + # For GET requests, show confirmation page + context = { + 'access_link': access_link, + 'title': 'Reactivate Access Link', + 'message': f'Are you sure you want to reactivate the access link for {access_link.assignment.agency.name}?', + 'cancel_url': reverse('agency_assignment_detail', kwargs={'slug': access_link.assignment.slug}), + } + return render(request, 'recruitment/agency_access_link_confirm.html', context) + + + + + + + + + + + + + + + + + +def api_candidate_detail(request, candidate_id): + """API endpoint to get candidate details for agency portal""" + try: + # Get candidate from session-based agency access + assignment_id = request.session.get('agency_assignment_id') + if not assignment_id: + return JsonResponse({'success': False, 'error': 'Access denied'}) + + # Get current assignment to determine agency + current_assignment = get_object_or_404( + AgencyJobAssignment.objects.select_related('agency'), + id=assignment_id + ) + + agency = current_assignment.agency + + # Get candidate and verify it belongs to this agency + candidate = get_object_or_404(Candidate, id=candidate_id, hiring_agency=agency) + + # Return candidate data + response_data = { + 'success': True, + 'id': candidate.id, + 'first_name': candidate.first_name, + 'last_name': candidate.last_name, + 'email': candidate.email, + 'phone': candidate.phone, + 'address': candidate.address, + } + + return JsonResponse(response_data) + + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 7c4abbd..ff1a119 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -478,7 +478,7 @@ def candidate_hired_view(request, slug): job = get_object_or_404(models.JobPosting, slug=slug) # Filter candidates with offer_status = 'Accepted' - candidates = job.candidates.filter(offer_status='Accepted') + candidates = job.hired_candidates # Handle search search_query = request.GET.get('search', '') @@ -721,7 +721,7 @@ def sync_hired_candidates(request, 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', @@ -783,18 +783,19 @@ def sync_task_status(request, task_id): try: # Get the task from Django-Q - task = Task.objects.get(id=task_id) + task = Task.objects.get(pk=task_id) + print("task",task) # Determine status based on task state - if task.success(): + if task.success: status = 'completed' message = 'Sync completed successfully' result = task.result - elif task.stopped(): + elif task.stopped: status = 'failed' message = 'Sync task failed or was stopped' result = task.result - elif task.started(): + elif task.started: status = 'running' message = 'Sync is currently running' result = None @@ -802,15 +803,15 @@ def sync_task_status(request, task_id): 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() + 'started': task.started, + 'stopped': task.stopped, + 'success': task.success }) except Task.DoesNotExist: diff --git a/templates/agency_base.html b/templates/agency_base.html new file mode 100644 index 0000000..e5f2451 --- /dev/null +++ b/templates/agency_base.html @@ -0,0 +1,203 @@ +{% load static i18n %} +{% get_current_language as LANGUAGE_CODE %} + + + + + + + + {% block title %}{% trans 'KAAUH Agency Portal' %}{% endblock %} + + {% comment %} Load correct Bootstrap CSS file for RTL/LTR {% endcomment %} + {% if LANGUAGE_CODE == 'ar' %} + + {% else %} + + {% endif %} + + + + + + {% block customCSS %}{% endblock %} + + + + {% comment %}
+
+
+
+
+
+
+
+ {% trans 'Saudi Vision 2030' %} + +
+
+
ุฌุงู…ุนุฉ ุงู„ุฃู…ูŠุฑุฉ ู†ูˆุฑุฉ ุจู†ุช ุนุจุฏุงู„ุฑุญู…ู† ุงู„ุฃูƒุงุฏูŠู…ูŠุฉ
+
ูˆู…ุณุชุดูู‰ ุงู„ู…ู„ูƒ ุนุจุฏุงู„ู„ู‡ ุจู† ุนุจุฏุงู„ุฑุญู…ู† ุงู„ุชุฎุตุตูŠ
+
Princess Nourah bint Abdulrahman University
+
King Abdullah bin Abdulaziz University Hospital
+
+
+ KAAUH Logo +
+
+
+
{% endcomment %} + + + + +
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% block content %} + {% endblock %} +
+ +
+ +
+ + {% include 'includes/delete_modal.html' %} + + + + + + + + {% block customJS %}{% endblock %} + + diff --git a/templates/base.html b/templates/base.html index 2b3ea67..59cc177 100644 --- a/templates/base.html +++ b/templates/base.html @@ -9,7 +9,7 @@ {% block title %}{% trans 'University ATS' %}{% endblock %} - {% comment %} Load the correct Bootstrap CSS file for RTL/LTR {% endcomment %} + {% comment %} Load correct Bootstrap CSS file for RTL/LTR {% endcomment %} {% if LANGUAGE_CODE == 'ar' %} {% else %} @@ -99,6 +99,30 @@