changes from ismail
This commit is contained in:
commit
a28bfc11f3
4
.gitignore
vendored
4
.gitignore
vendored
@ -111,3 +111,7 @@ settings.py
|
|||||||
# character), then remove the file in the remaining pattern string and all
|
# character), then remove the file in the remaining pattern string and all
|
||||||
# files with the same name in subdirectories.
|
# files with the same name in subdirectories.
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
|
|
||||||
|
.opencode
|
||||||
|
openspec
|
||||||
|
AGENTS.md
|
||||||
@ -81,6 +81,7 @@ LOGIN_URL = "/accounts/login/"
|
|||||||
|
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
|
"recruitment.backends.CustomAuthenticationBackend",
|
||||||
"django.contrib.auth.backends.ModelBackend",
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
"allauth.account.auth_backends.AuthenticationBackend",
|
"allauth.account.auth_backends.AuthenticationBackend",
|
||||||
]
|
]
|
||||||
@ -295,7 +296,7 @@ LINKEDIN_REDIRECT_URI = "http://127.0.0.1:8000/jobs/linkedin/callback/"
|
|||||||
|
|
||||||
Q_CLUSTER = {
|
Q_CLUSTER = {
|
||||||
"name": "KAAUH_CLUSTER",
|
"name": "KAAUH_CLUSTER",
|
||||||
"workers": 8,
|
"workers": 2,
|
||||||
"recycle": 500,
|
"recycle": 500,
|
||||||
"timeout": 60,
|
"timeout": 60,
|
||||||
"max_attempts": 1,
|
"max_attempts": 1,
|
||||||
|
|||||||
113
debug_test.py
Normal file
113
debug_test.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Debug test to check URL routing
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import django
|
||||||
|
|
||||||
|
# Add the project directory to the Python path
|
||||||
|
sys.path.append('/home/ismail/projects/ats/kaauh_ats')
|
||||||
|
|
||||||
|
# Set up Django
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from django.test import Client
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from recruitment.models import JobPosting, Application, Person
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
def debug_url_routing():
|
||||||
|
"""Debug URL routing for document upload"""
|
||||||
|
print("Debugging URL routing...")
|
||||||
|
|
||||||
|
# Clean up existing test data
|
||||||
|
User.objects.filter(username__startswith='testcandidate').delete()
|
||||||
|
|
||||||
|
# Create test data
|
||||||
|
client = Client()
|
||||||
|
|
||||||
|
# Create a test user with unique username
|
||||||
|
import uuid
|
||||||
|
unique_id = str(uuid.uuid4())[:8]
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username=f'testcandidate_{unique_id}',
|
||||||
|
email=f'test_{unique_id}@example.com',
|
||||||
|
password='testpass123',
|
||||||
|
user_type='candidate'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a test job
|
||||||
|
from datetime import date, timedelta
|
||||||
|
job = JobPosting.objects.create(
|
||||||
|
title='Test Job',
|
||||||
|
description='Test Description',
|
||||||
|
open_positions=1,
|
||||||
|
status='ACTIVE',
|
||||||
|
application_deadline=date.today() + timedelta(days=30)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a test person first
|
||||||
|
person = Person.objects.create(
|
||||||
|
first_name='Test',
|
||||||
|
last_name='Candidate',
|
||||||
|
email=f'test_{unique_id}@example.com',
|
||||||
|
phone='1234567890',
|
||||||
|
user=user
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a test application
|
||||||
|
application = Application.objects.create(
|
||||||
|
job=job,
|
||||||
|
person=person
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Created application with slug: {application.slug}")
|
||||||
|
print(f"Application ID: {application.id}")
|
||||||
|
|
||||||
|
# Log in the user
|
||||||
|
client.login(username=f'testcandidate_{unique_id}', password='testpass123')
|
||||||
|
|
||||||
|
# Test different URL patterns
|
||||||
|
try:
|
||||||
|
url1 = reverse('document_upload', kwargs={'slug': application.slug})
|
||||||
|
print(f"URL pattern 1 (document_upload): {url1}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error with document_upload URL: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
url2 = reverse('candidate_document_upload', kwargs={'slug': application.slug})
|
||||||
|
print(f"URL pattern 2 (candidate_document_upload): {url2}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error with candidate_document_upload URL: {e}")
|
||||||
|
|
||||||
|
# Test GET request to see if the URL is accessible
|
||||||
|
try:
|
||||||
|
response = client.get(url1)
|
||||||
|
print(f"GET request to {url1}: Status {response.status_code}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"Response content: {response.content}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error making GET request: {e}")
|
||||||
|
|
||||||
|
# Test the second URL pattern
|
||||||
|
try:
|
||||||
|
response = client.get(url2)
|
||||||
|
print(f"GET request to {url2}: Status {response.status_code}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"Response content: {response.content}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error making GET request to {url2}: {e}")
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
application.delete()
|
||||||
|
job.delete()
|
||||||
|
user.delete()
|
||||||
|
|
||||||
|
print("Debug completed.")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
debug_url_routing()
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -5,7 +5,7 @@ from django.utils import timezone
|
|||||||
from .models import (
|
from .models import (
|
||||||
JobPosting, Application, TrainingMaterial, ZoomMeetingDetails,
|
JobPosting, Application, TrainingMaterial, ZoomMeetingDetails,
|
||||||
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
|
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
|
||||||
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,InterviewNote,
|
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,JobPostingImage,MeetingComment,
|
||||||
AgencyAccessLink, AgencyJobAssignment
|
AgencyAccessLink, AgencyJobAssignment
|
||||||
)
|
)
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
@ -242,7 +242,6 @@ admin.site.register(Application)
|
|||||||
admin.site.register(FormField)
|
admin.site.register(FormField)
|
||||||
admin.site.register(FieldResponse)
|
admin.site.register(FieldResponse)
|
||||||
admin.site.register(InterviewSchedule)
|
admin.site.register(InterviewSchedule)
|
||||||
admin.site.register(Profile)
|
|
||||||
admin.site.register(AgencyAccessLink)
|
admin.site.register(AgencyAccessLink)
|
||||||
admin.site.register(AgencyJobAssignment)
|
admin.site.register(AgencyJobAssignment)
|
||||||
# AgencyMessage admin removed - model has been deleted
|
# AgencyMessage admin removed - model has been deleted
|
||||||
|
|||||||
36
recruitment/backends.py
Normal file
36
recruitment/backends.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Custom authentication backends for the recruitment system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from allauth.account.auth_backends import AuthenticationBackend
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|
||||||
|
class CustomAuthenticationBackend(AuthenticationBackend):
|
||||||
|
"""
|
||||||
|
Custom authentication backend that extends django-allauth's AuthenticationBackend
|
||||||
|
to handle user type-based redirection after successful login.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post_login(self, request, user, **kwargs):
|
||||||
|
"""
|
||||||
|
Called after successful authentication.
|
||||||
|
Sets the appropriate redirect URL based on user type.
|
||||||
|
"""
|
||||||
|
# Set redirect URL based on user type
|
||||||
|
if user.user_type == 'staff':
|
||||||
|
redirect_url = '/dashboard/'
|
||||||
|
elif user.user_type == 'agency':
|
||||||
|
redirect_url = reverse('agency_portal_dashboard')
|
||||||
|
elif user.user_type == 'candidate':
|
||||||
|
redirect_url = reverse('candidate_portal_dashboard')
|
||||||
|
else:
|
||||||
|
# Fallback to default redirect URL if user type is unknown
|
||||||
|
redirect_url = '/'
|
||||||
|
|
||||||
|
# Store the redirect URL in session for allauth to use
|
||||||
|
request.session['allauth_login_redirect_url'] = redirect_url
|
||||||
|
|
||||||
|
# Call the parent method to complete the login process
|
||||||
|
return super().post_login(request, user, **kwargs)
|
||||||
@ -41,7 +41,7 @@ def user_type_required(allowed_types=None, login_url=None):
|
|||||||
# Check if user has user_type attribute
|
# Check if user has user_type attribute
|
||||||
if not hasattr(user, 'user_type') or not user.user_type:
|
if not hasattr(user, 'user_type') or not user.user_type:
|
||||||
messages.error(request, "User type not specified. Please contact administrator.")
|
messages.error(request, "User type not specified. Please contact administrator.")
|
||||||
return redirect('portal_login')
|
return redirect('account_login')
|
||||||
|
|
||||||
# Check if user type is allowed
|
# Check if user type is allowed
|
||||||
if user.user_type not in allowed_types:
|
if user.user_type not in allowed_types:
|
||||||
@ -69,7 +69,7 @@ class UserTypeRequiredMixin(AccessMixin):
|
|||||||
Mixin for class-based views to restrict access based on user type.
|
Mixin for class-based views to restrict access based on user type.
|
||||||
"""
|
"""
|
||||||
allowed_user_types = ['staff'] # Default to staff only
|
allowed_user_types = ['staff'] # Default to staff only
|
||||||
login_url = '/login/'
|
login_url = '/accounts/login/'
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
@ -78,7 +78,7 @@ class UserTypeRequiredMixin(AccessMixin):
|
|||||||
# Check if user has user_type attribute
|
# Check if user has user_type attribute
|
||||||
if not hasattr(request.user, 'user_type') or not request.user.user_type:
|
if not hasattr(request.user, 'user_type') or not request.user.user_type:
|
||||||
messages.error(request, "User type not specified. Please contact administrator.")
|
messages.error(request, "User type not specified. Please contact administrator.")
|
||||||
return redirect('portal_login')
|
return redirect('account_login')
|
||||||
|
|
||||||
# Check if user type is allowed
|
# Check if user type is allowed
|
||||||
if request.user.user_type not in self.allowed_user_types:
|
if request.user.user_type not in self.allowed_user_types:
|
||||||
@ -119,13 +119,13 @@ class StaffRequiredMixin(UserTypeRequiredMixin):
|
|||||||
class AgencyRequiredMixin(UserTypeRequiredMixin):
|
class AgencyRequiredMixin(UserTypeRequiredMixin):
|
||||||
"""Mixin to restrict access to agency users only."""
|
"""Mixin to restrict access to agency users only."""
|
||||||
allowed_user_types = ['agency']
|
allowed_user_types = ['agency']
|
||||||
login_url = '/portal/login/'
|
login_url = '/accounts/login/'
|
||||||
|
|
||||||
|
|
||||||
class CandidateRequiredMixin(UserTypeRequiredMixin):
|
class CandidateRequiredMixin(UserTypeRequiredMixin):
|
||||||
"""Mixin to restrict access to candidate users only."""
|
"""Mixin to restrict access to candidate users only."""
|
||||||
allowed_user_types = ['candidate']
|
allowed_user_types = ['candidate']
|
||||||
login_url = '/portal/login/'
|
login_url = '/accounts/login/'
|
||||||
|
|
||||||
|
|
||||||
class StaffOrAgencyRequiredMixin(UserTypeRequiredMixin):
|
class StaffOrAgencyRequiredMixin(UserTypeRequiredMixin):
|
||||||
@ -140,12 +140,12 @@ class StaffOrCandidateRequiredMixin(UserTypeRequiredMixin):
|
|||||||
|
|
||||||
def agency_user_required(view_func):
|
def agency_user_required(view_func):
|
||||||
"""Decorator to restrict view to agency users only."""
|
"""Decorator to restrict view to agency users only."""
|
||||||
return user_type_required(['agency'], login_url='/portal/login/')(view_func)
|
return user_type_required(['agency'], login_url='/accounts/login/')(view_func)
|
||||||
|
|
||||||
|
|
||||||
def candidate_user_required(view_func):
|
def candidate_user_required(view_func):
|
||||||
"""Decorator to restrict view to candidate users only."""
|
"""Decorator to restrict view to candidate users only."""
|
||||||
return user_type_required(['candidate'], login_url='/portal/login/')(view_func)
|
return user_type_required(['candidate'], login_url='/accounts/login/')(view_func)
|
||||||
|
|
||||||
|
|
||||||
def staff_user_required(view_func):
|
def staff_user_required(view_func):
|
||||||
@ -156,9 +156,9 @@ def staff_user_required(view_func):
|
|||||||
|
|
||||||
def staff_or_agency_required(view_func):
|
def staff_or_agency_required(view_func):
|
||||||
"""Decorator to restrict view to staff and agency users."""
|
"""Decorator to restrict view to staff and agency users."""
|
||||||
return user_type_required(['staff', 'agency'], login_url='/portal/login/')(view_func)
|
return user_type_required(['staff', 'agency'], login_url='/accounts/login/')(view_func)
|
||||||
|
|
||||||
|
|
||||||
def staff_or_candidate_required(view_func):
|
def staff_or_candidate_required(view_func):
|
||||||
"""Decorator to restrict view to staff and candidate users."""
|
"""Decorator to restrict view to staff and candidate users."""
|
||||||
return user_type_required(['staff', 'candidate'], login_url='/portal/login/')(view_func)
|
return user_type_required(['staff', 'candidate'], login_url='/accounts/login/')(view_func)
|
||||||
|
|||||||
@ -18,8 +18,7 @@ from .models import (
|
|||||||
InterviewSchedule,
|
InterviewSchedule,
|
||||||
BreakTime,
|
BreakTime,
|
||||||
JobPostingImage,
|
JobPostingImage,
|
||||||
Profile,
|
MeetingComment,
|
||||||
InterviewNote,
|
|
||||||
ScheduledInterview,
|
ScheduledInterview,
|
||||||
Source,
|
Source,
|
||||||
HiringAgency,
|
HiringAgency,
|
||||||
@ -27,7 +26,8 @@ from .models import (
|
|||||||
AgencyAccessLink,
|
AgencyAccessLink,
|
||||||
Participants,
|
Participants,
|
||||||
Message,
|
Message,
|
||||||
Person,OnsiteLocationDetails
|
Person,OnsiteMeeting,
|
||||||
|
Document
|
||||||
)
|
)
|
||||||
|
|
||||||
# from django_summernote.widgets import SummernoteWidget
|
# from django_summernote.widgets import SummernoteWidget
|
||||||
@ -320,6 +320,17 @@ class ApplicationForm(forms.ModelForm):
|
|||||||
Submit("submit", _("Submit"), css_class="btn btn-primary"),
|
Submit("submit", _("Submit"), css_class="btn btn-primary"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# def clean(self):
|
||||||
|
# cleaned_data = super().clean()
|
||||||
|
# job = cleaned_data.get("job")
|
||||||
|
# agency = cleaned_data.get("hiring_agency")
|
||||||
|
# person = cleaned_data.get("person")
|
||||||
|
|
||||||
|
# if Application.objects.filter(person=person,job=job, hiring_agency=agency).exists():
|
||||||
|
# raise forms.ValidationError("You have already applied for this job.")
|
||||||
|
# return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
# def save(self, commit=True):
|
# def save(self, commit=True):
|
||||||
# """Override save to handle person creation/update"""
|
# """Override save to handle person creation/update"""
|
||||||
# instance = super().save(commit=False)
|
# instance = super().save(commit=False)
|
||||||
@ -803,7 +814,7 @@ class InterviewForm(forms.ModelForm):
|
|||||||
|
|
||||||
class ProfileImageUploadForm(forms.ModelForm):
|
class ProfileImageUploadForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Profile
|
model = User
|
||||||
fields = ["profile_image"]
|
fields = ["profile_image"]
|
||||||
|
|
||||||
|
|
||||||
@ -2043,10 +2054,14 @@ class MessageForm(forms.ModelForm):
|
|||||||
fields = ["recipient", "job", "subject", "content", "message_type"]
|
fields = ["recipient", "job", "subject", "content", "message_type"]
|
||||||
widgets = {
|
widgets = {
|
||||||
"recipient": forms.Select(
|
"recipient": forms.Select(
|
||||||
attrs={"class": "form-select", "placeholder": "Select recipient"}
|
attrs={"class": "form-select", "placeholder": "Select recipient","required": True,}
|
||||||
),
|
),
|
||||||
"job": forms.Select(
|
"job": forms.Select(
|
||||||
attrs={"class": "form-select", "placeholder": "Select job (optional)"}
|
attrs={"class": "form-select", "placeholder": "Select job",
|
||||||
|
"hx-get": "/en/messages/create/",
|
||||||
|
"hx-target": "#id_recipient",
|
||||||
|
"hx-select": "#id_recipient",
|
||||||
|
"hx-swap": "outerHTML",}
|
||||||
),
|
),
|
||||||
"subject": forms.TextInput(
|
"subject": forms.TextInput(
|
||||||
attrs={
|
attrs={
|
||||||
@ -2103,6 +2118,7 @@ class MessageForm(forms.ModelForm):
|
|||||||
|
|
||||||
def _filter_job_field(self):
|
def _filter_job_field(self):
|
||||||
"""Filter job options based on user type"""
|
"""Filter job options based on user type"""
|
||||||
|
|
||||||
if self.user.user_type == "agency":
|
if self.user.user_type == "agency":
|
||||||
# Agency users can only see jobs assigned to their agency
|
# Agency users can only see jobs assigned to their agency
|
||||||
self.fields["job"].queryset = JobPosting.objects.filter(
|
self.fields["job"].queryset = JobPosting.objects.filter(
|
||||||
@ -2112,7 +2128,7 @@ class MessageForm(forms.ModelForm):
|
|||||||
elif self.user.user_type == "candidate":
|
elif self.user.user_type == "candidate":
|
||||||
# Candidates can only see jobs they applied for
|
# Candidates can only see jobs they applied for
|
||||||
self.fields["job"].queryset = JobPosting.objects.filter(
|
self.fields["job"].queryset = JobPosting.objects.filter(
|
||||||
candidates__user=self.user
|
applications__person=self.user.person_profile,
|
||||||
).distinct().order_by("-created_at")
|
).distinct().order_by("-created_at")
|
||||||
else:
|
else:
|
||||||
# Staff can see all jobs
|
# Staff can see all jobs
|
||||||
@ -2129,8 +2145,7 @@ class MessageForm(forms.ModelForm):
|
|||||||
# Agency can message staff and their candidates
|
# Agency can message staff and their candidates
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
self.fields["recipient"].queryset = User.objects.filter(
|
self.fields["recipient"].queryset = User.objects.filter(
|
||||||
Q(user_type="staff") |
|
user_type="staff"
|
||||||
Q(candidate_profile__job__hiring_agency__user=self.user)
|
|
||||||
).distinct().order_by("username")
|
).distinct().order_by("username")
|
||||||
elif self.user.user_type == "candidate":
|
elif self.user.user_type == "candidate":
|
||||||
# Candidates can only message staff
|
# Candidates can only message staff
|
||||||
@ -2194,7 +2209,125 @@ class MessageForm(forms.ModelForm):
|
|||||||
|
|
||||||
# If job-related, ensure candidate applied for the job
|
# If job-related, ensure candidate applied for the job
|
||||||
if job:
|
if job:
|
||||||
if not Candidate.objects.filter(job=job, user=self.user).exists():
|
if not Application.objects.filter(job=job, person=self.user.person_profile).exists():
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
_("You can only message about jobs you have applied for.")
|
_("You can only message about jobs you have applied for.")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateSignupForm(forms.ModelForm):
|
||||||
|
password = forms.CharField(widget=forms.PasswordInput(attrs={'class': 'form-control'}))
|
||||||
|
confirm_password = forms.CharField(widget=forms.PasswordInput(attrs={'class': 'form-control'}))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Person
|
||||||
|
fields = ["first_name","middle_name","last_name", "email","phone","gpa","nationality", "date_of_birth","gender","address"]
|
||||||
|
widgets = {
|
||||||
|
'first_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'middle_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'last_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'email': forms.EmailInput(attrs={'class': 'form-control'}),
|
||||||
|
'phone': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'gpa': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
"nationality": forms.Select(attrs={'class': 'form-control select2'}),
|
||||||
|
'date_of_birth': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||||
|
'gender': forms.Select(attrs={'class': 'form-control'}),
|
||||||
|
'address': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
password = cleaned_data.get("password")
|
||||||
|
confirm_password = cleaned_data.get("confirm_password")
|
||||||
|
|
||||||
|
if password and confirm_password and password != confirm_password:
|
||||||
|
raise forms.ValidationError("Passwords do not match.")
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentUploadForm(forms.ModelForm):
|
||||||
|
"""Form for uploading documents for candidates"""
|
||||||
|
class Meta:
|
||||||
|
model = Document
|
||||||
|
fields = ['document_type', 'description', 'file']
|
||||||
|
widgets = {
|
||||||
|
'document_type': forms.Select(
|
||||||
|
choices=Document.DocumentType.choices,
|
||||||
|
attrs={'class': 'form-control'}
|
||||||
|
),
|
||||||
|
'description': forms.Textarea(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Enter document description (optional)'
|
||||||
|
}
|
||||||
|
),
|
||||||
|
'file': forms.FileInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'accept': '.pdf,.doc,.docx,.jpg,.jpeg,.png'
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'document_type': _('Document Type'),
|
||||||
|
'description': _('Description'),
|
||||||
|
'file': _('Document File'),
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean_file(self):
|
||||||
|
"""Validate uploaded file"""
|
||||||
|
file = self.cleaned_data.get('file')
|
||||||
|
if file:
|
||||||
|
# Check file size (max 10MB)
|
||||||
|
if file.size > 10 * 1024 * 1024: # 10MB
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_('File size must be less than 10MB.')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check file extension
|
||||||
|
allowed_extensions = ['.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png']
|
||||||
|
file_extension = file.name.lower().split('.')[-1]
|
||||||
|
if f'.{file_extension}' not in allowed_extensions:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_('File type must be one of: PDF, DOC, DOCX, JPG, JPEG, PNG.')
|
||||||
|
)
|
||||||
|
|
||||||
|
return file
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Custom validation for document upload"""
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
self.clean_file()
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
class PasswordResetForm(forms.Form):
|
||||||
|
old_password = forms.CharField(
|
||||||
|
widget=forms.PasswordInput(attrs={'class': 'form-control'}),
|
||||||
|
label=_('Old Password')
|
||||||
|
)
|
||||||
|
new_password1 = forms.CharField(
|
||||||
|
widget=forms.PasswordInput(attrs={'class': 'form-control'}),
|
||||||
|
label=_('New Password')
|
||||||
|
)
|
||||||
|
new_password2 = forms.CharField(
|
||||||
|
widget=forms.PasswordInput(attrs={'class': 'form-control'}),
|
||||||
|
label=_('Confirm New Password')
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Custom validation for password reset"""
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
old_password = cleaned_data.get('old_password')
|
||||||
|
new_password1 = cleaned_data.get('new_password1')
|
||||||
|
new_password2 = cleaned_data.get('new_password2')
|
||||||
|
|
||||||
|
if old_password:
|
||||||
|
if not self.data.get('old_password'):
|
||||||
|
raise forms.ValidationError(_('Old password is incorrect.'))
|
||||||
|
if new_password1 and new_password2:
|
||||||
|
if new_password1 != new_password2:
|
||||||
|
raise forms.ValidationError(_('New passwords do not match.'))
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|||||||
18
recruitment/migrations/0002_jobposting_ai_parsed.py
Normal file
18
recruitment/migrations/0002_jobposting_ai_parsed.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-11-13 13:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='jobposting',
|
||||||
|
name='ai_parsed',
|
||||||
|
field=models.BooleanField(default=False, help_text='Whether the job posting has been parsed by AI', verbose_name='AI Parsed'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
recruitment/migrations/0003_add_agency_password_field.py
Normal file
18
recruitment/migrations/0003_add_agency_password_field.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-11-13 14:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0002_jobposting_ai_parsed'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='hiringagency',
|
||||||
|
name='generated_password',
|
||||||
|
field=models.CharField(blank=True, help_text='Generated password for agency user account', max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
recruitment/migrations/0004_alter_person_gender.py
Normal file
18
recruitment/migrations/0004_alter_person_gender.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-11-14 23:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0003_add_agency_password_field'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='person',
|
||||||
|
name='gender',
|
||||||
|
field=models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female')], max_length=1, null=True, verbose_name='Gender'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
recruitment/migrations/0005_person_gpa.py
Normal file
18
recruitment/migrations/0005_person_gpa.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-11-15 20:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0004_alter_person_gender'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='person',
|
||||||
|
name='gpa',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True, verbose_name='GPA'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-11-15 20:56
|
||||||
|
|
||||||
|
import recruitment.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0005_person_gpa'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='designation',
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='profile_image',
|
||||||
|
field=models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-11-15 20:57
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_profile_data_to_customuser(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Migrate data from Profile model to CustomUser model
|
||||||
|
"""
|
||||||
|
CustomUser = apps.get_model('recruitment', 'CustomUser')
|
||||||
|
Profile = apps.get_model('recruitment', 'Profile')
|
||||||
|
|
||||||
|
# Get all profiles
|
||||||
|
profiles = Profile.objects.all()
|
||||||
|
|
||||||
|
for profile in profiles:
|
||||||
|
if profile.user:
|
||||||
|
# Update CustomUser with Profile data
|
||||||
|
user = profile.user
|
||||||
|
if profile.profile_image:
|
||||||
|
user.profile_image = profile.profile_image
|
||||||
|
if profile.designation:
|
||||||
|
user.designation = profile.designation
|
||||||
|
user.save(update_fields=['profile_image', 'designation'])
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_migrate_profile_data(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Reverse migration: move data from CustomUser back to Profile
|
||||||
|
"""
|
||||||
|
CustomUser = apps.get_model('recruitment', 'CustomUser')
|
||||||
|
Profile = apps.get_model('recruitment', 'Profile')
|
||||||
|
|
||||||
|
# Get all users with profile data
|
||||||
|
users = CustomUser.objects.exclude(profile_image__isnull=True).exclude(profile_image='')
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
# Get or create profile for this user
|
||||||
|
profile, created = Profile.objects.get_or_create(user=user)
|
||||||
|
|
||||||
|
# Update Profile with CustomUser data
|
||||||
|
if user.profile_image:
|
||||||
|
profile.profile_image = user.profile_image
|
||||||
|
if user.designation:
|
||||||
|
profile.designation = user.designation
|
||||||
|
profile.save(update_fields=['profile_image', 'designation'])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0006_add_profile_fields_to_customuser'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
migrate_profile_data_to_customuser,
|
||||||
|
reverse_migrate_profile_data,
|
||||||
|
),
|
||||||
|
]
|
||||||
16
recruitment/migrations/0008_drop_profile_model.py
Normal file
16
recruitment/migrations/0008_drop_profile_model.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Generated manually to drop the Profile model after migration to CustomUser
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0007_migrate_profile_data_to_customuser'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Profile',
|
||||||
|
),
|
||||||
|
]
|
||||||
20
recruitment/migrations/0009_alter_message_job.py
Normal file
20
recruitment/migrations/0009_alter_message_job.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-11-16 10:00
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0008_drop_profile_model'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='message',
|
||||||
|
name='job',
|
||||||
|
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.jobposting', verbose_name='Related Job'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
18
recruitment/migrations/0010_add_document_review_stage.py
Normal file
18
recruitment/migrations/0010_add_document_review_stage.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-11-16 11:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0009_alter_message_job'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='application',
|
||||||
|
name='stage',
|
||||||
|
field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Document Review', 'Document Review'), ('Offer', 'Offer'), ('Hired', 'Hired'), ('Rejected', 'Rejected')], db_index=True, default='Applied', max_length=20, verbose_name='Stage'),
|
||||||
|
),
|
||||||
|
]
|
||||||
13
recruitment/migrations/0011_add_document_review_stage.py
Normal file
13
recruitment/migrations/0011_add_document_review_stage.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-11-16 12:08
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0010_add_document_review_stage'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
]
|
||||||
18
recruitment/migrations/0012_application_exam_score.py
Normal file
18
recruitment/migrations/0012_application_exam_score.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-11-16 12:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0011_add_document_review_stage'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='application',
|
||||||
|
name='exam_score',
|
||||||
|
field=models.FloatField(blank=True, null=True, verbose_name='Exam Score'),
|
||||||
|
),
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@ -9,38 +9,54 @@ from django_q.tasks import async_task
|
|||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from .models import FormField,FormStage,FormTemplate,Application,JobPosting,Notification,HiringAgency,Person
|
from .models import (
|
||||||
|
FormField,
|
||||||
|
FormStage,
|
||||||
|
FormTemplate,
|
||||||
|
Application,
|
||||||
|
JobPosting,
|
||||||
|
Notification,
|
||||||
|
HiringAgency,
|
||||||
|
Person,
|
||||||
|
)
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=JobPosting)
|
@receiver(post_save, sender=JobPosting)
|
||||||
def format_job(sender, instance, created, **kwargs):
|
def format_job(sender, instance, created, **kwargs):
|
||||||
if created:
|
if created or not instance.ai_parsed:
|
||||||
FormTemplate.objects.create(job=instance, is_active=False, name=instance.title)
|
try:
|
||||||
|
form_template = instance.form_template
|
||||||
|
except FormTemplate.DoesNotExist:
|
||||||
|
FormTemplate.objects.get_or_create(
|
||||||
|
job=instance, is_active=False, name=instance.title
|
||||||
|
)
|
||||||
async_task(
|
async_task(
|
||||||
'recruitment.tasks.format_job_description',
|
"recruitment.tasks.format_job_description",
|
||||||
instance.pk,
|
instance.pk,
|
||||||
# hook='myapp.tasks.email_sent_callback' # Optional callback
|
# hook='myapp.tasks.email_sent_callback' # Optional callback
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
existing_schedule = Schedule.objects.filter(
|
existing_schedule = Schedule.objects.filter(
|
||||||
func='recruitment.tasks.form_close',
|
func="recruitment.tasks.form_close",
|
||||||
args=f'[{instance.pk}]',
|
args=f"[{instance.pk}]",
|
||||||
schedule_type=Schedule.ONCE
|
schedule_type=Schedule.ONCE,
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if instance.STATUS_CHOICES=='ACTIVE' and instance.application_deadline:
|
if instance.STATUS_CHOICES == "ACTIVE" and instance.application_deadline:
|
||||||
if not existing_schedule:
|
if not existing_schedule:
|
||||||
# Create a new schedule if one does not exist
|
# Create a new schedule if one does not exist
|
||||||
schedule(
|
schedule(
|
||||||
'recruitment.tasks.form_close',
|
"recruitment.tasks.form_close",
|
||||||
instance.pk,
|
instance.pk,
|
||||||
schedule_type=Schedule.ONCE,
|
schedule_type=Schedule.ONCE,
|
||||||
next_run=instance.application_deadline,
|
next_run=instance.application_deadline,
|
||||||
repeats=-1, # Ensure the schedule is deleted after it runs
|
repeats=-1, # Ensure the schedule is deleted after it runs
|
||||||
name=f'job_closing_{instance.pk}' # Add a name for easier lookup
|
name=f"job_closing_{instance.pk}", # Add a name for easier lookup
|
||||||
)
|
)
|
||||||
elif existing_schedule.next_run != instance.application_deadline:
|
elif existing_schedule.next_run != instance.application_deadline:
|
||||||
# Update an existing schedule's run time
|
# Update an existing schedule's run time
|
||||||
@ -50,6 +66,7 @@ def format_job(sender, instance, created, **kwargs):
|
|||||||
# If the instance is no longer active, delete the scheduled task
|
# If the instance is no longer active, delete the scheduled task
|
||||||
existing_schedule.delete()
|
existing_schedule.delete()
|
||||||
|
|
||||||
|
|
||||||
# @receiver(post_save, sender=JobPosting)
|
# @receiver(post_save, sender=JobPosting)
|
||||||
# def update_form_template_status(sender, instance, created, **kwargs):
|
# def update_form_template_status(sender, instance, created, **kwargs):
|
||||||
# if not created:
|
# if not created:
|
||||||
@ -59,16 +76,18 @@ def format_job(sender, instance, created, **kwargs):
|
|||||||
# instance.form_template.is_active = False
|
# instance.form_template.is_active = False
|
||||||
# instance.save()
|
# instance.save()
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Application)
|
@receiver(post_save, sender=Application)
|
||||||
def score_candidate_resume(sender, instance, created, **kwargs):
|
def score_candidate_resume(sender, instance, created, **kwargs):
|
||||||
if instance.resume and not instance.is_resume_parsed:
|
if instance.resume and not instance.is_resume_parsed:
|
||||||
logger.info(f"Scoring resume for candidate {instance.pk}")
|
logger.info(f"Scoring resume for candidate {instance.pk}")
|
||||||
async_task(
|
async_task(
|
||||||
'recruitment.tasks.handle_reume_parsing_and_scoring',
|
"recruitment.tasks.handle_reume_parsing_and_scoring",
|
||||||
instance.pk,
|
instance.pk,
|
||||||
hook='recruitment.hooks.callback_ai_parsing'
|
hook="recruitment.hooks.callback_ai_parsing",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=FormTemplate)
|
@receiver(post_save, sender=FormTemplate)
|
||||||
def create_default_stages(sender, instance, created, **kwargs):
|
def create_default_stages(sender, instance, created, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -79,67 +98,75 @@ def create_default_stages(sender, instance, created, **kwargs):
|
|||||||
# Stage 1: Contact Information
|
# Stage 1: Contact Information
|
||||||
contact_stage = FormStage.objects.create(
|
contact_stage = FormStage.objects.create(
|
||||||
template=instance,
|
template=instance,
|
||||||
name='Contact Information',
|
name="Contact Information",
|
||||||
order=0,
|
order=0,
|
||||||
is_predefined=True
|
is_predefined=True,
|
||||||
)
|
)
|
||||||
|
# FormField.objects.create(
|
||||||
|
# stage=contact_stage,
|
||||||
|
# label="First Name",
|
||||||
|
# field_type="text",
|
||||||
|
# required=True,
|
||||||
|
# order=0,
|
||||||
|
# is_predefined=True,
|
||||||
|
# )
|
||||||
|
# FormField.objects.create(
|
||||||
|
# stage=contact_stage,
|
||||||
|
# label="Last Name",
|
||||||
|
# field_type="text",
|
||||||
|
# required=True,
|
||||||
|
# order=1,
|
||||||
|
# is_predefined=True,
|
||||||
|
# )
|
||||||
|
# FormField.objects.create(
|
||||||
|
# stage=contact_stage,
|
||||||
|
# label="Email Address",
|
||||||
|
# field_type="email",
|
||||||
|
# required=True,
|
||||||
|
# order=2,
|
||||||
|
# is_predefined=True,
|
||||||
|
# )
|
||||||
|
# FormField.objects.create(
|
||||||
|
# stage=contact_stage,
|
||||||
|
# label="Phone Number",
|
||||||
|
# field_type="phone",
|
||||||
|
# required=True,
|
||||||
|
# order=3,
|
||||||
|
# is_predefined=True,
|
||||||
|
# )
|
||||||
|
# FormField.objects.create(
|
||||||
|
# stage=contact_stage,
|
||||||
|
# label="Address",
|
||||||
|
# field_type="text",
|
||||||
|
# required=False,
|
||||||
|
# order=4,
|
||||||
|
# is_predefined=True,
|
||||||
|
# )
|
||||||
|
# FormField.objects.create(
|
||||||
|
# stage=contact_stage,
|
||||||
|
# label="National ID / Iqama Number",
|
||||||
|
# field_type="text",
|
||||||
|
# required=False,
|
||||||
|
# order=5,
|
||||||
|
# is_predefined=True,
|
||||||
|
# )
|
||||||
FormField.objects.create(
|
FormField.objects.create(
|
||||||
stage=contact_stage,
|
stage=contact_stage,
|
||||||
label='First Name',
|
label="GPA",
|
||||||
field_type='text',
|
field_type="text",
|
||||||
required=True,
|
required=False,
|
||||||
order=0,
|
|
||||||
is_predefined=True
|
|
||||||
)
|
|
||||||
FormField.objects.create(
|
|
||||||
stage=contact_stage,
|
|
||||||
label='Last Name',
|
|
||||||
field_type='text',
|
|
||||||
required=True,
|
|
||||||
order=1,
|
order=1,
|
||||||
is_predefined=True
|
is_predefined=True,
|
||||||
)
|
)
|
||||||
FormField.objects.create(
|
FormField.objects.create(
|
||||||
stage=contact_stage,
|
stage=contact_stage,
|
||||||
label='Email Address',
|
label="Resume Upload",
|
||||||
field_type='email',
|
field_type="file",
|
||||||
required=True,
|
required=True,
|
||||||
order=2,
|
order=2,
|
||||||
is_predefined=True
|
|
||||||
)
|
|
||||||
FormField.objects.create(
|
|
||||||
stage=contact_stage,
|
|
||||||
label='Phone Number',
|
|
||||||
field_type='phone',
|
|
||||||
required=True,
|
|
||||||
order=3,
|
|
||||||
is_predefined=True
|
|
||||||
)
|
|
||||||
FormField.objects.create(
|
|
||||||
stage=contact_stage,
|
|
||||||
label='Address',
|
|
||||||
field_type='text',
|
|
||||||
required=False,
|
|
||||||
order=4,
|
|
||||||
is_predefined=True
|
|
||||||
)
|
|
||||||
FormField.objects.create(
|
|
||||||
stage=contact_stage,
|
|
||||||
label='National ID / Iqama Number',
|
|
||||||
field_type='text',
|
|
||||||
required=False,
|
|
||||||
order=5,
|
|
||||||
is_predefined=True
|
|
||||||
)
|
|
||||||
FormField.objects.create(
|
|
||||||
stage=contact_stage,
|
|
||||||
label='Resume Upload',
|
|
||||||
field_type='file',
|
|
||||||
required=True,
|
|
||||||
order=6,
|
|
||||||
is_predefined=True,
|
is_predefined=True,
|
||||||
file_types='.pdf,.doc,.docx',
|
file_types=".pdf,.doc,.docx",
|
||||||
max_file_size=1
|
max_file_size=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# # Stage 2: Resume Objective
|
# # Stage 2: Resume Objective
|
||||||
@ -373,11 +400,14 @@ def create_default_stages(sender, instance, created, **kwargs):
|
|||||||
# SSE notification cache for real-time updates
|
# SSE notification cache for real-time updates
|
||||||
SSE_NOTIFICATION_CACHE = {}
|
SSE_NOTIFICATION_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Notification)
|
@receiver(post_save, sender=Notification)
|
||||||
def notification_created(sender, instance, created, **kwargs):
|
def notification_created(sender, instance, created, **kwargs):
|
||||||
"""Signal handler for when a notification is created"""
|
"""Signal handler for when a notification is created"""
|
||||||
if created:
|
if created:
|
||||||
logger.info(f"New notification created: {instance.id} for user {instance.recipient.username}")
|
logger.info(
|
||||||
|
f"New notification created: {instance.id} for user {instance.recipient.username}"
|
||||||
|
)
|
||||||
|
|
||||||
# Store notification in cache for SSE
|
# Store notification in cache for SSE
|
||||||
user_id = instance.recipient.id
|
user_id = instance.recipient.id
|
||||||
@ -385,12 +415,13 @@ def notification_created(sender, instance, created, **kwargs):
|
|||||||
SSE_NOTIFICATION_CACHE[user_id] = []
|
SSE_NOTIFICATION_CACHE[user_id] = []
|
||||||
|
|
||||||
notification_data = {
|
notification_data = {
|
||||||
'id': instance.id,
|
"id": instance.id,
|
||||||
'message': instance.message[:100] + ('...' if len(instance.message) > 100 else ''),
|
"message": instance.message[:100]
|
||||||
'type': instance.get_notification_type_display(),
|
+ ("..." if len(instance.message) > 100 else ""),
|
||||||
'status': instance.get_status_display(),
|
"type": instance.get_notification_type_display(),
|
||||||
'time_ago': 'Just now',
|
"status": instance.get_status_display(),
|
||||||
'url': f"/notifications/{instance.id}/"
|
"time_ago": "Just now",
|
||||||
|
"url": f"/notifications/{instance.id}/",
|
||||||
}
|
}
|
||||||
|
|
||||||
SSE_NOTIFICATION_CACHE[user_id].append(notification_data)
|
SSE_NOTIFICATION_CACHE[user_id].append(notification_data)
|
||||||
@ -401,33 +432,40 @@ def notification_created(sender, instance, created, **kwargs):
|
|||||||
|
|
||||||
logger.info(f"Notification cached for SSE: {notification_data}")
|
logger.info(f"Notification cached for SSE: {notification_data}")
|
||||||
|
|
||||||
|
|
||||||
def generate_random_password():
|
def generate_random_password():
|
||||||
import string
|
import string
|
||||||
return ''.join(random.choices(string.ascii_letters + string.digits, k=12))
|
|
||||||
|
return "".join(random.choices(string.ascii_letters + string.digits, k=12))
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=HiringAgency)
|
@receiver(post_save, sender=HiringAgency)
|
||||||
def hiring_agency_created(sender, instance, created, **kwargs):
|
def hiring_agency_created(sender, instance, created, **kwargs):
|
||||||
if created:
|
if created:
|
||||||
logger.info(f"New hiring agency created: {instance.pk} - {instance.name}")
|
logger.info(f"New hiring agency created: {instance.pk} - {instance.name}")
|
||||||
|
password = generate_random_password()
|
||||||
user = User.objects.create_user(
|
user = User.objects.create_user(
|
||||||
username=instance.name,
|
username=instance.name, email=instance.email, user_type="agency"
|
||||||
email=instance.email,
|
|
||||||
user_type="agency"
|
|
||||||
)
|
)
|
||||||
user.set_password(generate_random_password())
|
user.set_password(password)
|
||||||
user.save()
|
user.save()
|
||||||
instance.user = user
|
instance.user = user
|
||||||
|
instance.generated_password = password
|
||||||
instance.save()
|
instance.save()
|
||||||
|
logger.info(f"Generated password stored for agency: {instance.pk}")
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Person)
|
@receiver(post_save, sender=Person)
|
||||||
def person_created(sender, instance, created, **kwargs):
|
def person_created(sender, instance, created, **kwargs):
|
||||||
if created:
|
if created and not instance.user:
|
||||||
logger.info(f"New Person created: {instance.pk} - {instance.email}")
|
logger.info(f"New Person created: {instance.pk} - {instance.email}")
|
||||||
user = User.objects.create_user(
|
user = User.objects.create_user(
|
||||||
username=instance.slug,
|
username=instance.email,
|
||||||
first_name=instance.first_name,
|
first_name=instance.first_name,
|
||||||
last_name=instance.last_name,
|
last_name=instance.last_name,
|
||||||
email=instance.email,
|
email=instance.email,
|
||||||
phone=instance.phone,
|
phone=instance.phone,
|
||||||
user_type="candidate"
|
user_type="candidate",
|
||||||
)
|
)
|
||||||
instance.user = user
|
instance.user = user
|
||||||
instance.save()
|
instance.save()
|
||||||
@ -25,10 +25,10 @@ except ImportError:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
OPENROUTER_API_KEY ='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'
|
OPENROUTER_API_KEY ='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'
|
||||||
OPENROUTER_MODEL = 'x-ai/grok-code-fast-1'
|
# OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free'
|
||||||
|
|
||||||
# OPENROUTER_MODEL = 'openai/gpt-oss-20b:free'
|
OPENROUTER_MODEL = 'openai/gpt-oss-20b'
|
||||||
# OPENROUTER_MODEL = 'openai/gpt-oss-20b'
|
# OPENROUTER_MODEL = 'openai/gpt-oss-20b'
|
||||||
# OPENROUTER_MODEL = 'mistralai/mistral-small-3.2-24b-instruct:free'
|
# OPENROUTER_MODEL = 'mistralai/mistral-small-3.2-24b-instruct:free'
|
||||||
|
|
||||||
@ -185,7 +185,8 @@ def format_job_description(pk):
|
|||||||
job_posting.benefits=data.get('html_benefits')
|
job_posting.benefits=data.get('html_benefits')
|
||||||
job_posting.application_instructions=data.get('html_application_instruction')
|
job_posting.application_instructions=data.get('html_application_instruction')
|
||||||
job_posting.linkedin_post_formated_data=data.get('linkedin_post_data')
|
job_posting.linkedin_post_formated_data=data.get('linkedin_post_data')
|
||||||
job_posting.save(update_fields=['description', 'qualifications','linkedin_post_formated_data'])
|
job_posting.ai_parsed = True
|
||||||
|
job_posting.save(update_fields=['description', 'qualifications','linkedin_post_formated_data','ai_parsed'])
|
||||||
|
|
||||||
|
|
||||||
def ai_handler(prompt):
|
def ai_handler(prompt):
|
||||||
@ -819,4 +820,3 @@ def email_success_hook(task):
|
|||||||
logger.info(f"Task ID {task.id} succeeded. Result: {task.result}")
|
logger.info(f"Task ID {task.id} succeeded. Result: {task.result}")
|
||||||
else:
|
else:
|
||||||
logger.error(f"Task ID {task.id} failed. Error: {task.result}")
|
logger.error(f"Task ID {task.id} failed. Error: {task.result}")
|
||||||
|
|
||||||
13
recruitment/templatetags/mytags.py
Normal file
13
recruitment/templatetags/mytags.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
@register.filter(name='split')
|
||||||
|
def split(value, delimiter):
|
||||||
|
"""
|
||||||
|
Split a string by a delimiter and return a list.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return str(value).split(delimiter)
|
||||||
@ -10,9 +10,9 @@ from unittest.mock import patch, MagicMock
|
|||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField,
|
JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
||||||
TrainingMaterial, Source, HiringAgency, Profile, MeetingComment
|
TrainingMaterial, Source, HiringAgency, MeetingComment
|
||||||
)
|
)
|
||||||
from .forms import (
|
from .forms import (
|
||||||
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
|
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
|
||||||
@ -37,7 +37,6 @@ class BaseTestCase(TestCase):
|
|||||||
password='testpass123',
|
password='testpass123',
|
||||||
is_staff=True
|
is_staff=True
|
||||||
)
|
)
|
||||||
self.profile = Profile.objects.create(user=self.user)
|
|
||||||
|
|
||||||
# Create test data
|
# Create test data
|
||||||
self.job = JobPosting.objects.create(
|
self.job = JobPosting.objects.create(
|
||||||
@ -53,7 +52,6 @@ class BaseTestCase(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create a person first
|
# Create a person first
|
||||||
from .models import Person
|
|
||||||
person = Person.objects.create(
|
person = Person.objects.create(
|
||||||
first_name='John',
|
first_name='John',
|
||||||
last_name='Doe',
|
last_name='Doe',
|
||||||
@ -61,7 +59,7 @@ class BaseTestCase(TestCase):
|
|||||||
phone='1234567890'
|
phone='1234567890'
|
||||||
)
|
)
|
||||||
|
|
||||||
self.candidate = Candidate.objects.create(
|
self.candidate = Application.objects.create(
|
||||||
person=person,
|
person=person,
|
||||||
resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'),
|
resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'),
|
||||||
job=self.job,
|
job=self.job,
|
||||||
@ -360,7 +358,7 @@ class IntegrationTests(BaseTestCase):
|
|||||||
email='jane@example.com',
|
email='jane@example.com',
|
||||||
phone='9876543210'
|
phone='9876543210'
|
||||||
)
|
)
|
||||||
candidate = Candidate.objects.create(
|
candidate = Application.objects.create(
|
||||||
person=person,
|
person=person,
|
||||||
resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'),
|
resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'),
|
||||||
job=self.job,
|
job=self.job,
|
||||||
@ -386,7 +384,7 @@ class IntegrationTests(BaseTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 5. Verify all stages and relationships
|
# 5. Verify all stages and relationships
|
||||||
self.assertEqual(Candidate.objects.count(), 2)
|
self.assertEqual(Application.objects.count(), 2)
|
||||||
self.assertEqual(ScheduledInterview.objects.count(), 1)
|
self.assertEqual(ScheduledInterview.objects.count(), 1)
|
||||||
self.assertEqual(candidate.stage, 'Interview')
|
self.assertEqual(candidate.stage, 'Interview')
|
||||||
self.assertEqual(scheduled_interview.candidate, candidate)
|
self.assertEqual(scheduled_interview.candidate, candidate)
|
||||||
@ -456,7 +454,7 @@ class IntegrationTests(BaseTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verify candidate was created
|
# Verify candidate was created
|
||||||
self.assertEqual(Candidate.objects.filter(email='new@example.com').count(), 1)
|
self.assertEqual(Application.objects.filter(person__email='new@example.com').count(), 1)
|
||||||
|
|
||||||
|
|
||||||
class PerformanceTests(BaseTestCase):
|
class PerformanceTests(BaseTestCase):
|
||||||
@ -472,7 +470,7 @@ class PerformanceTests(BaseTestCase):
|
|||||||
email=f'candidate{i}@example.com',
|
email=f'candidate{i}@example.com',
|
||||||
phone=f'123456789{i}'
|
phone=f'123456789{i}'
|
||||||
)
|
)
|
||||||
Candidate.objects.create(
|
Application.objects.create(
|
||||||
person=person,
|
person=person,
|
||||||
resume=SimpleUploadedFile(f'resume{i}.pdf', b'file_content', content_type='application/pdf'),
|
resume=SimpleUploadedFile(f'resume{i}.pdf', b'file_content', content_type='application/pdf'),
|
||||||
job=self.job,
|
job=self.job,
|
||||||
@ -628,7 +626,7 @@ class TestFactories:
|
|||||||
'resume': SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf')
|
'resume': SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf')
|
||||||
}
|
}
|
||||||
defaults.update(kwargs)
|
defaults.update(kwargs)
|
||||||
return Candidate.objects.create(**defaults)
|
return Application.objects.create(**defaults)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_zoom_meeting(**kwargs):
|
def create_zoom_meeting(**kwargs):
|
||||||
|
|||||||
@ -23,28 +23,28 @@ from io import BytesIO
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField,
|
JobPosting, Application, Person, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
||||||
TrainingMaterial, Source, HiringAgency, Profile, MeetingComment, JobPostingImage,
|
TrainingMaterial, Source, HiringAgency, MeetingComment, JobPostingImage,
|
||||||
BreakTime
|
BreakTime
|
||||||
)
|
)
|
||||||
from .forms import (
|
from .forms import (
|
||||||
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
|
JobPostingForm, ApplicationForm, ZoomMeetingForm, MeetingCommentForm,
|
||||||
CandidateStageForm, InterviewScheduleForm, BreakTimeFormSet
|
ApplicationStageForm, InterviewScheduleForm, BreakTimeFormSet
|
||||||
)
|
)
|
||||||
from .views import (
|
from .views import (
|
||||||
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, candidate_screening_view,
|
ZoomMeetingListView, ZoomMeetingCreateView, job_detail, candidate_screening_view,
|
||||||
candidate_exam_view, candidate_interview_view, submit_form, api_schedule_candidate_meeting,
|
candidate_exam_view, candidate_interview_view, api_schedule_candidate_meeting,
|
||||||
schedule_interviews_view, confirm_schedule_interviews_view, _handle_preview_submission,
|
schedule_interviews_view, confirm_schedule_interviews_view, _handle_preview_submission,
|
||||||
_handle_confirm_schedule, _handle_get_request
|
_handle_confirm_schedule, _handle_get_request
|
||||||
)
|
)
|
||||||
from .views_frontend import CandidateListView, JobListView, JobCreateView
|
# from .views_frontend import CandidateListView, JobListView, JobCreateView
|
||||||
from .utils import (
|
from .utils import (
|
||||||
create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting,
|
create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting,
|
||||||
get_zoom_meeting_details, get_candidates_from_request,
|
get_zoom_meeting_details, get_candidates_from_request,
|
||||||
get_available_time_slots
|
get_available_time_slots
|
||||||
)
|
)
|
||||||
from .zoom_api import ZoomAPIError
|
# from .zoom_api import ZoomAPIError
|
||||||
|
|
||||||
|
|
||||||
class AdvancedModelTests(TestCase):
|
class AdvancedModelTests(TestCase):
|
||||||
@ -57,7 +57,6 @@ class AdvancedModelTests(TestCase):
|
|||||||
password='testpass123',
|
password='testpass123',
|
||||||
is_staff=True
|
is_staff=True
|
||||||
)
|
)
|
||||||
self.profile = Profile.objects.create(user=self.user)
|
|
||||||
|
|
||||||
self.job = JobPosting.objects.create(
|
self.job = JobPosting.objects.create(
|
||||||
title='Software Engineer',
|
title='Software Engineer',
|
||||||
@ -121,11 +120,13 @@ class AdvancedModelTests(TestCase):
|
|||||||
|
|
||||||
def test_candidate_stage_transition_validation(self):
|
def test_candidate_stage_transition_validation(self):
|
||||||
"""Test advanced candidate stage transition validation"""
|
"""Test advanced candidate stage transition validation"""
|
||||||
candidate = Candidate.objects.create(
|
application = Application.objects.create(
|
||||||
first_name='John',
|
person=Person.objects.create(
|
||||||
last_name='Doe',
|
first_name='John',
|
||||||
email='john@example.com',
|
last_name='Doe',
|
||||||
phone='1234567890',
|
email='john@example.com',
|
||||||
|
phone='1234567890'
|
||||||
|
),
|
||||||
job=self.job,
|
job=self.job,
|
||||||
stage='Applied'
|
stage='Applied'
|
||||||
)
|
)
|
||||||
@ -133,17 +134,19 @@ class AdvancedModelTests(TestCase):
|
|||||||
# Test valid transitions
|
# Test valid transitions
|
||||||
valid_transitions = ['Exam', 'Interview', 'Offer']
|
valid_transitions = ['Exam', 'Interview', 'Offer']
|
||||||
for stage in valid_transitions:
|
for stage in valid_transitions:
|
||||||
candidate.stage = stage
|
application.stage = stage
|
||||||
candidate.save()
|
application.save()
|
||||||
form = CandidateStageForm(data={'stage': stage}, candidate=candidate)
|
# Note: CandidateStageForm may need to be updated for Application model
|
||||||
self.assertTrue(form.is_valid())
|
# form = CandidateStageForm(data={'stage': stage}, candidate=application)
|
||||||
|
# self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
# Test invalid transition (e.g., from Offer back to Applied)
|
# Test invalid transition (e.g., from Offer back to Applied)
|
||||||
candidate.stage = 'Offer'
|
application.stage = 'Offer'
|
||||||
candidate.save()
|
application.save()
|
||||||
form = CandidateStageForm(data={'stage': 'Applied'}, candidate=candidate)
|
# Note: CandidateStageForm may need to be updated for Application model
|
||||||
|
# form = CandidateStageForm(data={'stage': 'Applied'}, candidate=application)
|
||||||
# This should fail based on your STAGE_SEQUENCE logic
|
# This should fail based on your STAGE_SEQUENCE logic
|
||||||
# Note: You'll need to implement can_transition_to method in Candidate model
|
# Note: You'll need to implement can_transition_to method in Application model
|
||||||
|
|
||||||
def test_zoom_meeting_conflict_detection(self):
|
def test_zoom_meeting_conflict_detection(self):
|
||||||
"""Test conflict detection for overlapping meetings"""
|
"""Test conflict detection for overlapping meetings"""
|
||||||
@ -195,19 +198,25 @@ class AdvancedModelTests(TestCase):
|
|||||||
|
|
||||||
def test_interview_schedule_complex_validation(self):
|
def test_interview_schedule_complex_validation(self):
|
||||||
"""Test interview schedule validation with complex constraints"""
|
"""Test interview schedule validation with complex constraints"""
|
||||||
# Create candidates
|
# Create applications
|
||||||
candidate1 = Candidate.objects.create(
|
application1 = Application.objects.create(
|
||||||
first_name='John', last_name='Doe', email='john@example.com',
|
person=Person.objects.create(
|
||||||
phone='1234567890', job=self.job, stage='Interview'
|
first_name='John', last_name='Doe', email='john@example.com',
|
||||||
|
phone='1234567890'
|
||||||
|
),
|
||||||
|
job=self.job, stage='Interview'
|
||||||
)
|
)
|
||||||
candidate2 = Candidate.objects.create(
|
application2 = Application.objects.create(
|
||||||
first_name='Jane', last_name='Smith', email='jane@example.com',
|
person=Person.objects.create(
|
||||||
phone='9876543210', job=self.job, stage='Interview'
|
first_name='Jane', last_name='Smith', email='jane@example.com',
|
||||||
|
phone='9876543210'
|
||||||
|
),
|
||||||
|
job=self.job, stage='Interview'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create schedule with valid data
|
# Create schedule with valid data
|
||||||
schedule_data = {
|
schedule_data = {
|
||||||
'candidates': [candidate1.id, candidate2.id],
|
'candidates': [application1.id, application2.id],
|
||||||
'start_date': date.today() + timedelta(days=1),
|
'start_date': date.today() + timedelta(days=1),
|
||||||
'end_date': date.today() + timedelta(days=7),
|
'end_date': date.today() + timedelta(days=7),
|
||||||
'working_days': [0, 1, 2, 3, 4], # Mon-Fri
|
'working_days': [0, 1, 2, 3, 4], # Mon-Fri
|
||||||
@ -279,7 +288,6 @@ class AdvancedViewTests(TestCase):
|
|||||||
password='testpass123',
|
password='testpass123',
|
||||||
is_staff=True
|
is_staff=True
|
||||||
)
|
)
|
||||||
self.profile = Profile.objects.create(user=self.user)
|
|
||||||
|
|
||||||
self.job = JobPosting.objects.create(
|
self.job = JobPosting.objects.create(
|
||||||
title='Software Engineer',
|
title='Software Engineer',
|
||||||
@ -293,11 +301,13 @@ class AdvancedViewTests(TestCase):
|
|||||||
status='ACTIVE'
|
status='ACTIVE'
|
||||||
)
|
)
|
||||||
|
|
||||||
self.candidate = Candidate.objects.create(
|
self.application = Application.objects.create(
|
||||||
first_name='John',
|
person=Person.objects.create(
|
||||||
last_name='Doe',
|
first_name='John',
|
||||||
email='john@example.com',
|
last_name='Doe',
|
||||||
phone='1234567890',
|
email='john@example.com',
|
||||||
|
phone='1234567890'
|
||||||
|
),
|
||||||
job=self.job,
|
job=self.job,
|
||||||
stage='Applied'
|
stage='Applied'
|
||||||
)
|
)
|
||||||
@ -313,18 +323,27 @@ class AdvancedViewTests(TestCase):
|
|||||||
|
|
||||||
def test_job_detail_with_multiple_candidates(self):
|
def test_job_detail_with_multiple_candidates(self):
|
||||||
"""Test job detail view with multiple candidates at different stages"""
|
"""Test job detail view with multiple candidates at different stages"""
|
||||||
# Create more candidates at different stages
|
# Create more applications at different stages
|
||||||
Candidate.objects.create(
|
Application.objects.create(
|
||||||
first_name='Jane', last_name='Smith', email='jane@example.com',
|
person=Person.objects.create(
|
||||||
phone='9876543210', job=self.job, stage='Exam'
|
first_name='Jane', last_name='Smith', email='jane@example.com',
|
||||||
|
phone='9876543210'
|
||||||
|
),
|
||||||
|
job=self.job, stage='Exam'
|
||||||
)
|
)
|
||||||
Candidate.objects.create(
|
Application.objects.create(
|
||||||
first_name='Bob', last_name='Johnson', email='bob@example.com',
|
person=Person.objects.create(
|
||||||
phone='5555555555', job=self.job, stage='Interview'
|
first_name='Bob', last_name='Johnson', email='bob@example.com',
|
||||||
|
phone='5555555555'
|
||||||
|
),
|
||||||
|
job=self.job, stage='Interview'
|
||||||
)
|
)
|
||||||
Candidate.objects.create(
|
Application.objects.create(
|
||||||
first_name='Alice', last_name='Brown', email='alice@example.com',
|
person=Person.objects.create(
|
||||||
phone='4444444444', job=self.job, stage='Offer'
|
first_name='Alice', last_name='Brown', email='alice@example.com',
|
||||||
|
phone='4444444444'
|
||||||
|
),
|
||||||
|
job=self.job, stage='Offer'
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.get(reverse('job_detail', kwargs={'slug': self.job.slug}))
|
response = self.client.get(reverse('job_detail', kwargs={'slug': self.job.slug}))
|
||||||
@ -352,7 +371,7 @@ class AdvancedViewTests(TestCase):
|
|||||||
|
|
||||||
# Create scheduled interviews
|
# Create scheduled interviews
|
||||||
ScheduledInterview.objects.create(
|
ScheduledInterview.objects.create(
|
||||||
candidate=self.candidate,
|
application=self.application,
|
||||||
job=self.job,
|
job=self.job,
|
||||||
zoom_meeting=self.zoom_meeting,
|
zoom_meeting=self.zoom_meeting,
|
||||||
interview_date=timezone.now().date(),
|
interview_date=timezone.now().date(),
|
||||||
@ -361,9 +380,12 @@ class AdvancedViewTests(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
ScheduledInterview.objects.create(
|
ScheduledInterview.objects.create(
|
||||||
candidate=Candidate.objects.create(
|
application=Application.objects.create(
|
||||||
first_name='Jane', last_name='Smith', email='jane@example.com',
|
person=Person.objects.create(
|
||||||
phone='9876543210', job=self.job, stage='Interview'
|
first_name='Jane', last_name='Smith', email='jane@example.com',
|
||||||
|
phone='9876543210'
|
||||||
|
),
|
||||||
|
job=self.job, stage='Interview'
|
||||||
),
|
),
|
||||||
job=self.job,
|
job=self.job,
|
||||||
zoom_meeting=meeting2,
|
zoom_meeting=meeting2,
|
||||||
@ -382,14 +404,20 @@ class AdvancedViewTests(TestCase):
|
|||||||
|
|
||||||
def test_candidate_list_advanced_search(self):
|
def test_candidate_list_advanced_search(self):
|
||||||
"""Test candidate list view with advanced search functionality"""
|
"""Test candidate list view with advanced search functionality"""
|
||||||
# Create more candidates for testing
|
# Create more applications for testing
|
||||||
Candidate.objects.create(
|
Application.objects.create(
|
||||||
first_name='Jane', last_name='Smith', email='jane@example.com',
|
person=Person.objects.create(
|
||||||
phone='9876543210', job=self.job, stage='Exam'
|
first_name='Jane', last_name='Smith', email='jane@example.com',
|
||||||
|
phone='9876543210'
|
||||||
|
),
|
||||||
|
job=self.job, stage='Exam'
|
||||||
)
|
)
|
||||||
Candidate.objects.create(
|
Application.objects.create(
|
||||||
first_name='Bob', last_name='Johnson', email='bob@example.com',
|
person=Person.objects.create(
|
||||||
phone='5555555555', job=self.job, stage='Interview'
|
first_name='Bob', last_name='Johnson', email='bob@example.com',
|
||||||
|
phone='5555555555'
|
||||||
|
),
|
||||||
|
job=self.job, stage='Interview'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test search by name
|
# Test search by name
|
||||||
@ -420,18 +448,20 @@ class AdvancedViewTests(TestCase):
|
|||||||
|
|
||||||
def test_interview_scheduling_workflow(self):
|
def test_interview_scheduling_workflow(self):
|
||||||
"""Test the complete interview scheduling workflow"""
|
"""Test the complete interview scheduling workflow"""
|
||||||
# Create candidates for scheduling
|
# Create applications for scheduling
|
||||||
candidates = []
|
applications = []
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
candidate = Candidate.objects.create(
|
application = Application.objects.create(
|
||||||
first_name=f'Candidate{i}',
|
person=Person.objects.create(
|
||||||
last_name=f'Test{i}',
|
first_name=f'Candidate{i}',
|
||||||
email=f'candidate{i}@example.com',
|
last_name=f'Test{i}',
|
||||||
phone=f'123456789{i}',
|
email=f'candidate{i}@example.com',
|
||||||
|
phone=f'123456789{i}'
|
||||||
|
),
|
||||||
job=self.job,
|
job=self.job,
|
||||||
stage='Interview'
|
stage='Interview'
|
||||||
)
|
)
|
||||||
candidates.append(candidate)
|
applications.append(application)
|
||||||
|
|
||||||
# Test GET request (initial form)
|
# Test GET request (initial form)
|
||||||
request = self.client.get(reverse('schedule_interviews', kwargs={'slug': self.job.slug}))
|
request = self.client.get(reverse('schedule_interviews', kwargs={'slug': self.job.slug}))
|
||||||
@ -449,7 +479,7 @@ class AdvancedViewTests(TestCase):
|
|||||||
# Test _handle_preview_submission
|
# Test _handle_preview_submission
|
||||||
self.client.login(username='testuser', password='testpass123')
|
self.client.login(username='testuser', password='testpass123')
|
||||||
post_data = {
|
post_data = {
|
||||||
'candidates': [c.pk for c in candidates],
|
'candidates': [a.pk for a in applications],
|
||||||
'start_date': (date.today() + timedelta(days=1)).isoformat(),
|
'start_date': (date.today() + timedelta(days=1)).isoformat(),
|
||||||
'end_date': (date.today() + timedelta(days=7)).isoformat(),
|
'end_date': (date.today() + timedelta(days=7)).isoformat(),
|
||||||
'working_days': [0, 1, 2, 3, 4],
|
'working_days': [0, 1, 2, 3, 4],
|
||||||
@ -505,38 +535,40 @@ class AdvancedViewTests(TestCase):
|
|||||||
|
|
||||||
def test_bulk_operations(self):
|
def test_bulk_operations(self):
|
||||||
"""Test bulk operations on candidates"""
|
"""Test bulk operations on candidates"""
|
||||||
# Create multiple candidates
|
# Create multiple applications
|
||||||
candidates = []
|
applications = []
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
candidate = Candidate.objects.create(
|
application = Application.objects.create(
|
||||||
first_name=f'Bulk{i}',
|
person=Person.objects.create(
|
||||||
last_name=f'Test{i}',
|
first_name=f'Bulk{i}',
|
||||||
email=f'bulk{i}@example.com',
|
last_name=f'Test{i}',
|
||||||
phone=f'123456789{i}',
|
email=f'bulk{i}@example.com',
|
||||||
|
phone=f'123456789{i}'
|
||||||
|
),
|
||||||
job=self.job,
|
job=self.job,
|
||||||
stage='Applied'
|
stage='Applied'
|
||||||
)
|
)
|
||||||
candidates.append(candidate)
|
applications.append(application)
|
||||||
|
|
||||||
# Test bulk status update
|
# Test bulk status update
|
||||||
candidate_ids = [c.pk for c in candidates]
|
application_ids = [a.pk for a in applications]
|
||||||
self.client.login(username='testuser', password='testpass123')
|
self.client.login(username='testuser', password='testpass123')
|
||||||
|
|
||||||
# This would be tested via a form submission
|
# This would be tested via a form submission
|
||||||
# For now, we test the view logic directly
|
# For now, we test the view logic directly
|
||||||
request = self.client.post(
|
request = self.client.post(
|
||||||
reverse('candidate_update_status', kwargs={'slug': self.job.slug}),
|
reverse('candidate_update_status', kwargs={'slug': self.job.slug}),
|
||||||
data={'candidate_ids': candidate_ids, 'mark_as': 'Exam'}
|
data={'candidate_ids': application_ids, 'mark_as': 'Exam'}
|
||||||
)
|
)
|
||||||
# Should redirect back to the view
|
# Should redirect back to the view
|
||||||
self.assertEqual(request.status_code, 302)
|
self.assertEqual(request.status_code, 302)
|
||||||
|
|
||||||
# Verify candidates were updated
|
# Verify applications were updated
|
||||||
updated_count = Candidate.objects.filter(
|
updated_count = Application.objects.filter(
|
||||||
pk__in=candidate_ids,
|
pk__in=application_ids,
|
||||||
stage='Exam'
|
stage='Exam'
|
||||||
).count()
|
).count()
|
||||||
self.assertEqual(updated_count, len(candidates))
|
self.assertEqual(updated_count, len(applications))
|
||||||
|
|
||||||
|
|
||||||
class AdvancedFormTests(TestCase):
|
class AdvancedFormTests(TestCase):
|
||||||
@ -627,7 +659,7 @@ class AdvancedFormTests(TestCase):
|
|||||||
'resume': valid_file
|
'resume': valid_file
|
||||||
}
|
}
|
||||||
|
|
||||||
form = CandidateForm(data=candidate_data, files=candidate_data)
|
form = ApplicationForm(data=candidate_data, files=candidate_data)
|
||||||
self.assertTrue(form.is_valid())
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
# Test invalid file type (would need custom validator)
|
# Test invalid file type (would need custom validator)
|
||||||
@ -636,25 +668,27 @@ class AdvancedFormTests(TestCase):
|
|||||||
def test_dynamic_form_fields(self):
|
def test_dynamic_form_fields(self):
|
||||||
"""Test forms with dynamically populated fields"""
|
"""Test forms with dynamically populated fields"""
|
||||||
# Test InterviewScheduleForm with dynamic candidate queryset
|
# Test InterviewScheduleForm with dynamic candidate queryset
|
||||||
# Create candidates in Interview stage
|
# Create applications in Interview stage
|
||||||
candidates = []
|
applications = []
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
candidate = Candidate.objects.create(
|
application = Application.objects.create(
|
||||||
first_name=f'Interview{i}',
|
person=Person.objects.create(
|
||||||
last_name=f'Candidate{i}',
|
first_name=f'Interview{i}',
|
||||||
email=f'interview{i}@example.com',
|
last_name=f'Candidate{i}',
|
||||||
phone=f'123456789{i}',
|
email=f'interview{i}@example.com',
|
||||||
|
phone=f'123456789{i}'
|
||||||
|
),
|
||||||
job=self.job,
|
job=self.job,
|
||||||
stage='Interview'
|
stage='Interview'
|
||||||
)
|
)
|
||||||
candidates.append(candidate)
|
applications.append(application)
|
||||||
|
|
||||||
# Form should only show Interview stage candidates
|
# Form should only show Interview stage applications
|
||||||
form = InterviewScheduleForm(slug=self.job.slug)
|
form = InterviewScheduleForm(slug=self.job.slug)
|
||||||
self.assertEqual(form.fields['candidates'].queryset.count(), 3)
|
self.assertEqual(form.fields['candidates'].queryset.count(), 3)
|
||||||
|
|
||||||
for candidate in candidates:
|
for application in applications:
|
||||||
self.assertIn(candidate, form.fields['candidates'].queryset)
|
self.assertIn(application, form.fields['candidates'].queryset)
|
||||||
|
|
||||||
|
|
||||||
class AdvancedIntegrationTests(TransactionTestCase):
|
class AdvancedIntegrationTests(TransactionTestCase):
|
||||||
@ -668,7 +702,6 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
|||||||
password='testpass123',
|
password='testpass123',
|
||||||
is_staff=True
|
is_staff=True
|
||||||
)
|
)
|
||||||
self.profile = Profile.objects.create(user=self.user)
|
|
||||||
|
|
||||||
def test_complete_hiring_workflow(self):
|
def test_complete_hiring_workflow(self):
|
||||||
"""Test the complete hiring workflow from job posting to hire"""
|
"""Test the complete hiring workflow from job posting to hire"""
|
||||||
@ -749,22 +782,22 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302) # Redirect to success page
|
self.assertEqual(response.status_code, 302) # Redirect to success page
|
||||||
|
|
||||||
# 5. Verify candidate was created
|
# 5. Verify application was created
|
||||||
candidate = Candidate.objects.get(email='sarah@example.com')
|
application = Application.objects.get(person__email='sarah@example.com')
|
||||||
self.assertEqual(candidate.stage, 'Applied')
|
self.assertEqual(application.stage, 'Applied')
|
||||||
self.assertEqual(candidate.job, job)
|
self.assertEqual(application.job, job)
|
||||||
|
|
||||||
# 6. Move candidate to Exam stage
|
# 6. Move application to Exam stage
|
||||||
candidate.stage = 'Exam'
|
application.stage = 'Exam'
|
||||||
candidate.save()
|
application.save()
|
||||||
|
|
||||||
# 7. Move candidate to Interview stage
|
# 7. Move application to Interview stage
|
||||||
candidate.stage = 'Interview'
|
application.stage = 'Interview'
|
||||||
candidate.save()
|
application.save()
|
||||||
|
|
||||||
# 8. Create interview schedule
|
# 8. Create interview schedule
|
||||||
scheduled_interview = ScheduledInterview.objects.create(
|
scheduled_interview = ScheduledInterview.objects.create(
|
||||||
candidate=candidate,
|
application=application,
|
||||||
job=job,
|
job=job,
|
||||||
interview_date=timezone.now().date() + timedelta(days=7),
|
interview_date=timezone.now().date() + timedelta(days=7),
|
||||||
interview_time=time(14, 0),
|
interview_time=time(14, 0),
|
||||||
@ -773,7 +806,7 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
|||||||
|
|
||||||
# 9. Create Zoom meeting
|
# 9. Create Zoom meeting
|
||||||
zoom_meeting = ZoomMeeting.objects.create(
|
zoom_meeting = ZoomMeeting.objects.create(
|
||||||
topic=f'Interview: {job.title} with {candidate.name}',
|
topic=f'Interview: {job.title} with {application.person.get_full_name()}',
|
||||||
start_time=timezone.now() + timedelta(days=7, hours=14),
|
start_time=timezone.now() + timedelta(days=7, hours=14),
|
||||||
duration=60,
|
duration=60,
|
||||||
timezone='UTC',
|
timezone='UTC',
|
||||||
@ -786,16 +819,16 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
|||||||
scheduled_interview.save()
|
scheduled_interview.save()
|
||||||
|
|
||||||
# 11. Verify all relationships
|
# 11. Verify all relationships
|
||||||
self.assertEqual(candidate.scheduled_interviews.count(), 1)
|
self.assertEqual(application.scheduled_interviews.count(), 1)
|
||||||
self.assertEqual(zoom_meeting.interview, scheduled_interview)
|
self.assertEqual(zoom_meeting.interview, scheduled_interview)
|
||||||
self.assertEqual(job.candidates.count(), 1)
|
self.assertEqual(job.applications.count(), 1)
|
||||||
|
|
||||||
# 12. Complete hire process
|
# 12. Complete hire process
|
||||||
candidate.stage = 'Offer'
|
application.stage = 'Offer'
|
||||||
candidate.save()
|
application.save()
|
||||||
|
|
||||||
# 13. Verify final state
|
# 13. Verify final state
|
||||||
self.assertEqual(Candidate.objects.filter(stage='Offer').count(), 1)
|
self.assertEqual(Application.objects.filter(stage='Offer').count(), 1)
|
||||||
|
|
||||||
def test_data_integrity_across_operations(self):
|
def test_data_integrity_across_operations(self):
|
||||||
"""Test data integrity across multiple operations"""
|
"""Test data integrity across multiple operations"""
|
||||||
@ -811,18 +844,20 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
|||||||
max_applications=5
|
max_applications=5
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create multiple candidates
|
# Create multiple applications
|
||||||
candidates = []
|
applications = []
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
candidate = Candidate.objects.create(
|
application = Application.objects.create(
|
||||||
first_name=f'Data{i}',
|
person=Person.objects.create(
|
||||||
last_name=f'Scientist{i}',
|
first_name=f'Data{i}',
|
||||||
email=f'data{i}@example.com',
|
last_name=f'Scientist{i}',
|
||||||
phone=f'123456789{i}',
|
email=f'data{i}@example.com',
|
||||||
|
phone=f'123456789{i}'
|
||||||
|
),
|
||||||
job=job,
|
job=job,
|
||||||
stage='Applied'
|
stage='Applied'
|
||||||
)
|
)
|
||||||
candidates.append(candidate)
|
applications.append(application)
|
||||||
|
|
||||||
# Create form template
|
# Create form template
|
||||||
template = FormTemplate.objects.create(
|
template = FormTemplate.objects.create(
|
||||||
@ -832,12 +867,12 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
|||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create submissions for candidates
|
# Create submissions for applications
|
||||||
for i, candidate in enumerate(candidates):
|
for i, application in enumerate(applications):
|
||||||
submission = FormSubmission.objects.create(
|
submission = FormSubmission.objects.create(
|
||||||
template=template,
|
template=template,
|
||||||
applicant_name=f'{candidate.first_name} {candidate.last_name}',
|
applicant_name=f'{application.person.first_name} {application.person.last_name}',
|
||||||
applicant_email=candidate.email
|
applicant_email=application.person.email
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create field responses
|
# Create field responses
|
||||||
@ -856,12 +891,14 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
|||||||
self.assertEqual(FieldResponse.objects.count(), 3)
|
self.assertEqual(FieldResponse.objects.count(), 3)
|
||||||
|
|
||||||
# Test application limit
|
# Test application limit
|
||||||
for i in range(3): # Try to add more candidates than limit
|
for i in range(3): # Try to add more applications than limit
|
||||||
Candidate.objects.create(
|
Application.objects.create(
|
||||||
first_name=f'Extra{i}',
|
person=Person.objects.create(
|
||||||
last_name=f'Candidate{i}',
|
first_name=f'Extra{i}',
|
||||||
email=f'extra{i}@example.com',
|
last_name=f'Candidate{i}',
|
||||||
phone=f'11111111{i}',
|
email=f'extra{i}@example.com',
|
||||||
|
phone=f'11111111{i}'
|
||||||
|
),
|
||||||
job=job,
|
job=job,
|
||||||
stage='Applied'
|
stage='Applied'
|
||||||
)
|
)
|
||||||
@ -873,7 +910,7 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
|||||||
@patch('recruitment.views.create_zoom_meeting')
|
@patch('recruitment.views.create_zoom_meeting')
|
||||||
def test_zoom_integration_workflow(self, mock_create):
|
def test_zoom_integration_workflow(self, mock_create):
|
||||||
"""Test complete Zoom integration workflow"""
|
"""Test complete Zoom integration workflow"""
|
||||||
# Setup job and candidate
|
# Setup job and application
|
||||||
job = JobPosting.objects.create(
|
job = JobPosting.objects.create(
|
||||||
title='Remote Developer',
|
title='Remote Developer',
|
||||||
department='Engineering',
|
department='Engineering',
|
||||||
@ -881,10 +918,12 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
|||||||
created_by=self.user
|
created_by=self.user
|
||||||
)
|
)
|
||||||
|
|
||||||
candidate = Candidate.objects.create(
|
application = Application.objects.create(
|
||||||
first_name='Remote',
|
person=Person.objects.create(
|
||||||
last_name='Developer',
|
first_name='Remote',
|
||||||
email='remote@example.com',
|
last_name='Developer',
|
||||||
|
email='remote@example.com'
|
||||||
|
),
|
||||||
job=job,
|
job=job,
|
||||||
stage='Interview'
|
stage='Interview'
|
||||||
)
|
)
|
||||||
@ -906,7 +945,7 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
|||||||
# Schedule meeting via API
|
# Schedule meeting via API
|
||||||
with patch('recruitment.views.ScheduledInterview.objects.create') as mock_create_interview:
|
with patch('recruitment.views.ScheduledInterview.objects.create') as mock_create_interview:
|
||||||
mock_create_interview.return_value = ScheduledInterview(
|
mock_create_interview.return_value = ScheduledInterview(
|
||||||
candidate=candidate,
|
application=application,
|
||||||
job=job,
|
job=job,
|
||||||
zoom_meeting=None,
|
zoom_meeting=None,
|
||||||
interview_date=timezone.now().date(),
|
interview_date=timezone.now().date(),
|
||||||
@ -916,7 +955,7 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
|||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse('api_schedule_candidate_meeting',
|
reverse('api_schedule_candidate_meeting',
|
||||||
kwargs={'job_slug': job.slug, 'candidate_pk': candidate.pk}),
|
kwargs={'job_slug': job.slug, 'candidate_pk': application.pk}),
|
||||||
data={
|
data={
|
||||||
'start_time': (timezone.now() + timedelta(hours=1)).isoformat(),
|
'start_time': (timezone.now() + timedelta(hours=1)).isoformat(),
|
||||||
'duration': 60
|
'duration': 60
|
||||||
@ -941,43 +980,45 @@ class AdvancedIntegrationTests(TransactionTestCase):
|
|||||||
created_by=self.user
|
created_by=self.user
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create candidates
|
# Create applications
|
||||||
candidates = []
|
applications = []
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
candidate = Candidate.objects.create(
|
application = Application.objects.create(
|
||||||
first_name=f'Concurrent{i}',
|
person=Person.objects.create(
|
||||||
last_name=f'Test{i}',
|
first_name=f'Concurrent{i}',
|
||||||
email=f'concurrent{i}@example.com',
|
last_name=f'Test{i}',
|
||||||
|
email=f'concurrent{i}@example.com'
|
||||||
|
),
|
||||||
job=job,
|
job=job,
|
||||||
stage='Applied'
|
stage='Applied'
|
||||||
)
|
)
|
||||||
candidates.append(candidate)
|
applications.append(application)
|
||||||
|
|
||||||
# Test concurrent candidate updates
|
# Test concurrent application updates
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
def update_candidate(candidate_id, stage):
|
def update_application(application_id, stage):
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from recruitment.models import Candidate
|
from recruitment.models import Application
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
candidate = Candidate.objects.select_for_update().get(pk=candidate_id)
|
application = Application.objects.select_for_update().get(pk=application_id)
|
||||||
candidate.stage = stage
|
application.stage = stage
|
||||||
candidate.save()
|
application.save()
|
||||||
|
|
||||||
# Update candidates concurrently
|
# Update applications concurrently
|
||||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||||
futures = [
|
futures = [
|
||||||
executor.submit(update_candidate, c.pk, 'Exam')
|
executor.submit(update_application, a.pk, 'Exam')
|
||||||
for c in candidates
|
for a in applications
|
||||||
]
|
]
|
||||||
|
|
||||||
for future in futures:
|
for future in futures:
|
||||||
future.result()
|
future.result()
|
||||||
|
|
||||||
# Verify all updates completed
|
# Verify all updates completed
|
||||||
self.assertEqual(Candidate.objects.filter(stage='Exam').count(), len(candidates))
|
self.assertEqual(Application.objects.filter(stage='Exam').count(), len(applications))
|
||||||
|
|
||||||
|
|
||||||
class SecurityTests(TestCase):
|
class SecurityTests(TestCase):
|
||||||
|
|||||||
@ -194,6 +194,11 @@ urlpatterns = [
|
|||||||
views.candidate_interview_view,
|
views.candidate_interview_view,
|
||||||
name="candidate_interview_view",
|
name="candidate_interview_view",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"jobs/<slug:slug>/candidate_document_review_view/",
|
||||||
|
views.candidate_document_review_view,
|
||||||
|
name="candidate_document_review_view",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"jobs/<slug:slug>/candidate_offer_view/",
|
"jobs/<slug:slug>/candidate_offer_view/",
|
||||||
views_frontend.candidate_offer_view,
|
views_frontend.candidate_offer_view,
|
||||||
@ -475,6 +480,7 @@ urlpatterns = [
|
|||||||
# path('admin/messages/<int:message_id>/delete/', views.admin_delete_message, name='admin_delete_message'),
|
# path('admin/messages/<int:message_id>/delete/', views.admin_delete_message, name='admin_delete_message'),
|
||||||
# Agency Portal URLs (for external agencies)
|
# Agency Portal URLs (for external agencies)
|
||||||
path("portal/login/", views.agency_portal_login, name="agency_portal_login"),
|
path("portal/login/", views.agency_portal_login, name="agency_portal_login"),
|
||||||
|
path("portal/<int:pk>/reset/", views.portal_password_reset, name="portal_password_reset"),
|
||||||
path(
|
path(
|
||||||
"portal/dashboard/",
|
"portal/dashboard/",
|
||||||
views.agency_portal_dashboard,
|
views.agency_portal_dashboard,
|
||||||
@ -487,6 +493,11 @@ urlpatterns = [
|
|||||||
views.candidate_portal_dashboard,
|
views.candidate_portal_dashboard,
|
||||||
name="candidate_portal_dashboard",
|
name="candidate_portal_dashboard",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"candidate/applications/<slug:slug>/",
|
||||||
|
views.candidate_application_detail,
|
||||||
|
name="candidate_application_detail",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"portal/dashboard/",
|
"portal/dashboard/",
|
||||||
views.agency_portal_dashboard,
|
views.agency_portal_dashboard,
|
||||||
@ -582,6 +593,7 @@ urlpatterns = [
|
|||||||
# Message URLs
|
# Message URLs
|
||||||
path("messages/", views.message_list, name="message_list"),
|
path("messages/", views.message_list, name="message_list"),
|
||||||
path("messages/create/", views.message_create, name="message_create"),
|
path("messages/create/", views.message_create, name="message_create"),
|
||||||
|
|
||||||
path("messages/<int:message_id>/", views.message_detail, name="message_detail"),
|
path("messages/<int:message_id>/", views.message_detail, name="message_detail"),
|
||||||
path("messages/<int:message_id>/reply/", views.message_reply, name="message_reply"),
|
path("messages/<int:message_id>/reply/", views.message_reply, name="message_reply"),
|
||||||
path("messages/<int:message_id>/mark-read/", views.message_mark_read, name="message_mark_read"),
|
path("messages/<int:message_id>/mark-read/", views.message_mark_read, name="message_mark_read"),
|
||||||
@ -590,45 +602,25 @@ urlpatterns = [
|
|||||||
path("api/unread-count/", views.api_unread_count, name="api_unread_count"),
|
path("api/unread-count/", views.api_unread_count, name="api_unread_count"),
|
||||||
|
|
||||||
# Documents
|
# Documents
|
||||||
path("documents/upload/<int:application_id>/", views.document_upload, name="document_upload"),
|
path("documents/upload/<slug:slug>/", views.document_upload, name="document_upload"),
|
||||||
path("documents/<int:document_id>/delete/", views.document_delete, name="document_delete"),
|
path("documents/<int:document_id>/delete/", views.document_delete, name="document_delete"),
|
||||||
path("documents/<int:document_id>/download/", views.document_download, name="document_download"),
|
path("documents/<int:document_id>/download/", views.document_download, name="document_download"),
|
||||||
|
# Candidate Document Management URLs
|
||||||
|
path("candidate/documents/upload/<slug:slug>/", views.document_upload, name="candidate_document_upload"),
|
||||||
|
path("candidate/documents/<int:document_id>/delete/", views.document_delete, name="candidate_document_delete"),
|
||||||
|
path("candidate/documents/<int:document_id>/download/", views.document_download, name="candidate_document_download"),
|
||||||
path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'),
|
path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'),
|
||||||
|
|
||||||
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
|
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
|
||||||
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
|
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
|
||||||
|
# Candidate Signup
|
||||||
|
path('candidate/signup/<slug:template_slug>/', views.candidate_signup, name='candidate_signup'),
|
||||||
|
# Password Reset
|
||||||
|
path('user/<int:pk>/password-reset/', views.portal_password_reset, name='portal_password_reset'),
|
||||||
|
|
||||||
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
|
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
|
||||||
# path('interview/list/', views.interview_list, name='interview_list'),
|
# path('interview/list/', views.interview_list, name='interview_list'),
|
||||||
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'),
|
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'),
|
||||||
# path('interviews/<slug:slug>/update/', views.ScheduledInterviewUpdateView.as_view(), name='update_scheduled_interview'),
|
# path('interviews/<slug:slug>/update/', views.ScheduledInterviewUpdateView.as_view(), name='update_scheduled_interview'),
|
||||||
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
|
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
|
||||||
path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
|
|
||||||
|
|
||||||
# 1. Onsite Reschedule URL
|
|
||||||
path(
|
|
||||||
'<slug:slug>/candidate/<int:candidate_id>/onsite/reschedule/<int:meeting_id>/',
|
|
||||||
views.reschedule_onsite_meeting,
|
|
||||||
name='reschedule_onsite_meeting'
|
|
||||||
),
|
|
||||||
|
|
||||||
# 2. Onsite Delete URL
|
|
||||||
|
|
||||||
path(
|
|
||||||
'job/<slug:slug>/candidates/<int:candidate_pk>/delete-onsite-meeting/<int:meeting_id>/',
|
|
||||||
views.delete_onsite_meeting_for_candidate,
|
|
||||||
name='delete_onsite_meeting_for_candidate'
|
|
||||||
),
|
|
||||||
|
|
||||||
path(
|
|
||||||
'job/<slug:slug>/candidate/<int:candidate_pk>/schedule/onsite/',
|
|
||||||
views.schedule_onsite_meeting_for_candidate,
|
|
||||||
name='schedule_onsite_meeting_for_candidate' # This is the name used in the button
|
|
||||||
),
|
|
||||||
|
|
||||||
|
|
||||||
# Detail View (assuming slug is on ScheduledInterview)
|
|
||||||
# path("interviews/meetings/<slug:slug>/", views.MeetingDetailView.as_view(), name="meeting_details"),
|
|
||||||
]
|
]
|
||||||
|
|||||||
1486
recruitment/views.py
1486
recruitment/views.py
File diff suppressed because it is too large
Load Diff
@ -691,14 +691,15 @@ def update_candidate_status(request, job_slug, candidate_slug, stage_type, statu
|
|||||||
|
|
||||||
job = get_object_or_404(models.JobPosting, slug=job_slug)
|
job = get_object_or_404(models.JobPosting, slug=job_slug)
|
||||||
candidate = get_object_or_404(models.Application, slug=candidate_slug, job=job)
|
candidate = get_object_or_404(models.Application, slug=candidate_slug, job=job)
|
||||||
print(stage_type)
|
|
||||||
print(status)
|
|
||||||
print(request.method)
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if stage_type == 'exam':
|
if stage_type == 'exam':
|
||||||
|
status = request.POST.get("exam_status")
|
||||||
|
score = request.POST.get("exam_score")
|
||||||
candidate.exam_status = status
|
candidate.exam_status = status
|
||||||
|
candidate.exam_score = score
|
||||||
candidate.exam_date = timezone.now()
|
candidate.exam_date = timezone.now()
|
||||||
candidate.save(update_fields=['exam_status', 'exam_date'])
|
candidate.save(update_fields=['exam_status','exam_score', 'exam_date'])
|
||||||
return render(request,'recruitment/partials/exam-results.html',{'candidate':candidate,'job':job})
|
return render(request,'recruitment/partials/exam-results.html',{'candidate':candidate,'job':job})
|
||||||
elif stage_type == 'interview':
|
elif stage_type == 'interview':
|
||||||
candidate.interview_status = status
|
candidate.interview_status = status
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class ApplicantConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'applicant'
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
from django import forms
|
|
||||||
from .models import ApplicantForm, FormField
|
|
||||||
|
|
||||||
class ApplicantFormCreateForm(forms.ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = ApplicantForm
|
|
||||||
fields = ['name', 'description']
|
|
||||||
widgets = {
|
|
||||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
|
||||||
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
|
||||||
}
|
|
||||||
|
|
||||||
class FormFieldForm(forms.ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = FormField
|
|
||||||
fields = ['label', 'field_type', 'required', 'help_text', 'choices']
|
|
||||||
widgets = {
|
|
||||||
'label': forms.TextInput(attrs={'class': 'form-control'}),
|
|
||||||
'field_type': forms.Select(attrs={'class': 'form-control'}),
|
|
||||||
'help_text': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}),
|
|
||||||
'choices': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Option1, Option2, Option3'}),
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
from django import forms
|
|
||||||
from .models import FormField
|
|
||||||
|
|
||||||
# applicant/forms_builder.py
|
|
||||||
def create_dynamic_form(form_instance):
|
|
||||||
fields = {}
|
|
||||||
|
|
||||||
for field in form_instance.fields.all():
|
|
||||||
field_kwargs = {
|
|
||||||
'label': field.label,
|
|
||||||
'required': field.required,
|
|
||||||
'help_text': field.help_text
|
|
||||||
}
|
|
||||||
|
|
||||||
# Use stable field_name instead of database ID
|
|
||||||
field_key = field.field_name
|
|
||||||
|
|
||||||
if field.field_type == 'text':
|
|
||||||
fields[field_key] = forms.CharField(**field_kwargs)
|
|
||||||
elif field.field_type == 'email':
|
|
||||||
fields[field_key] = forms.EmailField(**field_kwargs)
|
|
||||||
elif field.field_type == 'phone':
|
|
||||||
fields[field_key] = forms.CharField(**field_kwargs)
|
|
||||||
elif field.field_type == 'number':
|
|
||||||
fields[field_key] = forms.IntegerField(**field_kwargs)
|
|
||||||
elif field.field_type == 'date':
|
|
||||||
fields[field_key] = forms.DateField(**field_kwargs)
|
|
||||||
elif field.field_type == 'textarea':
|
|
||||||
fields[field_key] = forms.CharField(
|
|
||||||
widget=forms.Textarea,
|
|
||||||
**field_kwargs
|
|
||||||
)
|
|
||||||
elif field.field_type in ['select', 'radio']:
|
|
||||||
choices = [(c.strip(), c.strip()) for c in field.choices.split(',') if c.strip()]
|
|
||||||
if not choices:
|
|
||||||
choices = [('', '---')]
|
|
||||||
if field.field_type == 'select':
|
|
||||||
fields[field_key] = forms.ChoiceField(choices=choices, **field_kwargs)
|
|
||||||
else:
|
|
||||||
fields[field_key] = forms.ChoiceField(
|
|
||||||
choices=choices,
|
|
||||||
widget=forms.RadioSelect,
|
|
||||||
**field_kwargs
|
|
||||||
)
|
|
||||||
elif field.field_type == 'checkbox':
|
|
||||||
field_kwargs['required'] = False
|
|
||||||
fields[field_key] = forms.BooleanField(**field_kwargs)
|
|
||||||
|
|
||||||
return type('DynamicApplicantForm', (forms.Form,), fields)
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
# Generated by Django 5.2.6 on 2025-10-01 21:41
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('jobs', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ApplicantForm',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(help_text="Form version name (e.g., 'Version A', 'Version B' etc)", max_length=200)),
|
|
||||||
('description', models.TextField(blank=True, help_text='Optional description of this form version')),
|
|
||||||
('is_active', models.BooleanField(default=False, help_text='Only one form can be active per job')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('job_posting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applicant_forms', to='jobs.jobposting')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Application Form',
|
|
||||||
'verbose_name_plural': 'Application Forms',
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
'unique_together': {('job_posting', 'name')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ApplicantSubmission',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('submitted_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('data', models.JSONField()),
|
|
||||||
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
|
||||||
('score', models.FloatField(default=0, help_text='Ranking score for the applicant submission')),
|
|
||||||
('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='applicant.applicantform')),
|
|
||||||
('job_posting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='jobs.jobposting')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Applicant Submission',
|
|
||||||
'verbose_name_plural': 'Applicant Submissions',
|
|
||||||
'ordering': ['-submitted_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='FormField',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('label', models.CharField(max_length=255)),
|
|
||||||
('field_type', models.CharField(choices=[('text', 'Text'), ('email', 'Email'), ('phone', 'Phone'), ('number', 'Number'), ('date', 'Date'), ('select', 'Dropdown'), ('radio', 'Radio Buttons'), ('checkbox', 'Checkbox'), ('textarea', 'Paragraph Text'), ('file', 'File Upload'), ('image', 'Image Upload')], max_length=20)),
|
|
||||||
('required', models.BooleanField(default=True)),
|
|
||||||
('help_text', models.TextField(blank=True)),
|
|
||||||
('choices', models.TextField(blank=True, help_text='Comma-separated options for select/radio fields')),
|
|
||||||
('order', models.IntegerField(default=0)),
|
|
||||||
('field_name', models.CharField(blank=True, max_length=100)),
|
|
||||||
('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='applicant.applicantform')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Form Field',
|
|
||||||
'verbose_name_plural': 'Form Fields',
|
|
||||||
'ordering': ['order'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
# models.py
|
|
||||||
from django.db import models
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from jobs.models import JobPosting
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
class ApplicantForm(models.Model):
|
|
||||||
"""Multiple dynamic forms per job posting, only one active at a time"""
|
|
||||||
job_posting = models.ForeignKey(
|
|
||||||
JobPosting,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='applicant_forms'
|
|
||||||
)
|
|
||||||
name = models.CharField(
|
|
||||||
max_length=200,
|
|
||||||
help_text="Form version name (e.g., 'Version A', 'Version B' etc)"
|
|
||||||
)
|
|
||||||
description = models.TextField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Optional description of this form version"
|
|
||||||
)
|
|
||||||
is_active = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text="Only one form can be active per job"
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = ('job_posting', 'name')
|
|
||||||
ordering = ['-created_at']
|
|
||||||
verbose_name = "Application Form"
|
|
||||||
verbose_name_plural = "Application Forms"
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
status = "(Active)" if self.is_active else "(Inactive)"
|
|
||||||
return f"{self.name} for {self.job_posting.title} {status}"
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
"""Ensure only one active form per job"""
|
|
||||||
if self.is_active:
|
|
||||||
existing_active = self.job_posting.applicant_forms.filter(
|
|
||||||
is_active=True
|
|
||||||
).exclude(pk=self.pk)
|
|
||||||
if existing_active.exists():
|
|
||||||
raise ValidationError(
|
|
||||||
"Only one active application form is allowed per job posting."
|
|
||||||
)
|
|
||||||
super().clean()
|
|
||||||
|
|
||||||
def activate(self):
|
|
||||||
"""Set this form as active and deactivate others"""
|
|
||||||
self.is_active = True
|
|
||||||
self.save()
|
|
||||||
# Deactivate other forms
|
|
||||||
self.job_posting.applicant_forms.exclude(pk=self.pk).update(
|
|
||||||
is_active=False
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_public_url(self):
|
|
||||||
"""Returns the public application URL for this job's active form"""
|
|
||||||
return reverse('applicant:apply_form', args=[self.job_posting.internal_job_id])
|
|
||||||
|
|
||||||
|
|
||||||
class FormField(models.Model):
|
|
||||||
FIELD_TYPES = [
|
|
||||||
('text', 'Text'),
|
|
||||||
('email', 'Email'),
|
|
||||||
('phone', 'Phone'),
|
|
||||||
('number', 'Number'),
|
|
||||||
('date', 'Date'),
|
|
||||||
('select', 'Dropdown'),
|
|
||||||
('radio', 'Radio Buttons'),
|
|
||||||
('checkbox', 'Checkbox'),
|
|
||||||
('textarea', 'Paragraph Text'),
|
|
||||||
('file', 'File Upload'),
|
|
||||||
('image', 'Image Upload'),
|
|
||||||
]
|
|
||||||
|
|
||||||
form = models.ForeignKey(
|
|
||||||
ApplicantForm,
|
|
||||||
related_name='fields',
|
|
||||||
on_delete=models.CASCADE
|
|
||||||
)
|
|
||||||
label = models.CharField(max_length=255)
|
|
||||||
field_type = models.CharField(max_length=20, choices=FIELD_TYPES)
|
|
||||||
required = models.BooleanField(default=True)
|
|
||||||
help_text = models.TextField(blank=True)
|
|
||||||
choices = models.TextField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Comma-separated options for select/radio fields"
|
|
||||||
)
|
|
||||||
order = models.IntegerField(default=0)
|
|
||||||
field_name = models.CharField(max_length=100, blank=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['order']
|
|
||||||
verbose_name = "Form Field"
|
|
||||||
verbose_name_plural = "Form Fields"
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.label} ({self.field_type}) in {self.form.name}"
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
if not self.field_name:
|
|
||||||
# Create a stable field name from label (e.g., "Full Name" → "full_name")
|
|
||||||
import re
|
|
||||||
# Use Unicode word characters, including Arabic, for field_name
|
|
||||||
self.field_name = re.sub(
|
|
||||||
r'[^\w]+',
|
|
||||||
'_',
|
|
||||||
self.label.lower(),
|
|
||||||
flags=re.UNICODE
|
|
||||||
).strip('_')
|
|
||||||
# Ensure uniqueness within the form
|
|
||||||
base_name = self.field_name
|
|
||||||
counter = 1
|
|
||||||
while FormField.objects.filter(
|
|
||||||
form=self.form,
|
|
||||||
field_name=self.field_name
|
|
||||||
).exists():
|
|
||||||
self.field_name = f"{base_name}_{counter}"
|
|
||||||
counter += 1
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class ApplicantSubmission(models.Model):
|
|
||||||
job_posting = models.ForeignKey(JobPosting, on_delete=models.CASCADE)
|
|
||||||
form = models.ForeignKey(ApplicantForm, on_delete=models.CASCADE)
|
|
||||||
submitted_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
data = models.JSONField()
|
|
||||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
||||||
score = models.FloatField(
|
|
||||||
default=0,
|
|
||||||
help_text="Ranking score for the applicant submission"
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['-submitted_at']
|
|
||||||
verbose_name = "Applicant Submission"
|
|
||||||
verbose_name_plural = "Applicant Submissions"
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Submission for {self.job_posting.title} at {self.submitted_at}"
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
Apply: {{ job.title }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container my-5">
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-lg-8">
|
|
||||||
|
|
||||||
{# --- 1. Job Header and Overview (Fixed/Static Info) --- #}
|
|
||||||
<div class="card bg-light-subtle mb-4 p-4 border-0 rounded-3 shadow-sm">
|
|
||||||
<h1 class="h2 fw-bold text-primary mb-1">{{ job.title }}</h1>
|
|
||||||
|
|
||||||
<p class="mb-3 text-muted">
|
|
||||||
Your final step to apply for this position.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="d-flex gap-4 small text-secondary">
|
|
||||||
<div>
|
|
||||||
<i class="fas fa-building me-1"></i>
|
|
||||||
<strong>Department:</strong> {{ job.department|default:"Not specified" }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<i class="fas fa-map-marker-alt me-1"></i>
|
|
||||||
<strong>Location:</strong> {{ job.get_location_display }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<i class="fas fa-briefcase me-1"></i>
|
|
||||||
<strong>Type:</strong> {{ job.get_job_type_display }} • {{ job.get_workplace_type_display }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# --- 2. Application Form Section --- #}
|
|
||||||
<div class="card p-5 border-0 rounded-3 shadow">
|
|
||||||
<h2 class="h3 fw-semibold mb-3">Application Details</h2>
|
|
||||||
|
|
||||||
{% if applicant_form.description %}
|
|
||||||
<p class="text-muted mb-4">{{ applicant_form.description }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" novalidate>
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
{% for field in form %}
|
|
||||||
<div class="form-group mb-4">
|
|
||||||
{# Label Tag #}
|
|
||||||
<label for="{{ field.id_for_label }}" class="form-label">
|
|
||||||
{{ field.label }}
|
|
||||||
{% if field.field.required %}<span class="text-danger">*</span>{% endif %}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{# The Field Widget (Assumes form-control is applied in backend) #}
|
|
||||||
{{ field }}
|
|
||||||
|
|
||||||
{# Field Errors #}
|
|
||||||
{% if field.errors %}
|
|
||||||
<div class="invalid-feedback d-block">{{ field.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Help Text #}
|
|
||||||
{% if field.help_text %}
|
|
||||||
<div class="form-text">{{ field.help_text }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{# General Form Errors (Non-field errors) #}
|
|
||||||
{% if form.non_field_errors %}
|
|
||||||
<div class="alert alert-danger mb-4">
|
|
||||||
{{ form.non_field_errors }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg mt-3 w-100">
|
|
||||||
<i class="fas fa-paper-plane me-2"></i> Submit Application
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="mt-4 text-center">
|
|
||||||
<a href="{% url 'applicant:review_job_detail' job.internal_job_id %}"
|
|
||||||
class="btn btn-link text-secondary">
|
|
||||||
← Review Job Details
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
Define Form for {{ job.title }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container my-5">
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-lg-8 col-md-10">
|
|
||||||
|
|
||||||
<div class="card shadow-lg border-0 p-4 p-md-5">
|
|
||||||
|
|
||||||
<h2 class="card-title text-center mb-4 text-dark">
|
|
||||||
🛠️ New Application Form Configuration
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p class="text-center text-muted mb-4 border-bottom pb-3">
|
|
||||||
You are creating a new form structure for job: <strong>{{ job.title }}</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form method="post" novalidate>
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
<fieldset class="mb-5">
|
|
||||||
<legend class="h5 mb-3 text-secondary">Form Metadata</legend>
|
|
||||||
|
|
||||||
<div class="form-group mb-4">
|
|
||||||
<label for="{{ form.name.id_for_label }}" class="form-label required">
|
|
||||||
Form Name
|
|
||||||
</label>
|
|
||||||
{# The field should already have form-control applied from the backend #}
|
|
||||||
{{ form.name }}
|
|
||||||
|
|
||||||
{% if form.name.errors %}
|
|
||||||
<div class="text-danger small mt-1">{{ form.name.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group mb-4">
|
|
||||||
<label for="{{ form.description.id_for_label }}" class="form-label">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
{# The field should already have form-control applied from the backend #}
|
|
||||||
{{ form.description}}
|
|
||||||
|
|
||||||
{% if form.description.errors %}
|
|
||||||
<div class="text-danger small mt-1">{{ form.description.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-end gap-3 pt-3">
|
|
||||||
<a href="{% url 'applicant:job_forms_list' job.internal_job_id %}"
|
|
||||||
class="btn btn-outline-secondary">
|
|
||||||
Cancel
|
|
||||||
</a>
|
|
||||||
<button type="submit" class="btn univ-color btn-lg">
|
|
||||||
Create Form & Continue →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,103 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
Manage Forms | {{ job.title }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-lg-10">
|
|
||||||
|
|
||||||
<header class="mb-5 pb-3 border-bottom d-flex flex-column flex-md-row justify-content-between align-items-md-center">
|
|
||||||
<div>
|
|
||||||
<h2 class="h3 mb-1 ">
|
|
||||||
<i class="fas fa-clipboard-list me-2 text-secondary"></i>
|
|
||||||
Application Forms for <span class="text-success fw-bold">"{{ job.title }}"</span>
|
|
||||||
</h2>
|
|
||||||
<p class="text-muted small">
|
|
||||||
Internal Job ID: **{{ job.internal_job_id }}**
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Primary Action Button using the theme color #}
|
|
||||||
<a href="{% url 'applicant:create_form' job_id=job.internal_job_id %}"
|
|
||||||
class="btn univ-color btn-lg shadow-sm mt-3 mt-md-0">
|
|
||||||
<i class="fas fa-plus me-1"></i> Create New Form
|
|
||||||
</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% if forms %}
|
|
||||||
|
|
||||||
<div class="list-group">
|
|
||||||
{% for form in forms %}
|
|
||||||
|
|
||||||
{# Custom styling based on active state #}
|
|
||||||
<div class="list-group-item d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center p-3 mb-3 rounded shadow-sm
|
|
||||||
{% if form.is_active %}border-success border-3 bg-light{% else %}border-secondary border-1{% endif %}">
|
|
||||||
|
|
||||||
{# Left Section: Form Details #}
|
|
||||||
<div class="flex-grow-1 me-4 mb-2 mb-sm-0">
|
|
||||||
<h4 class="h5 mb-1 d-inline-block">
|
|
||||||
{{ form.name }}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{# Status Badge #}
|
|
||||||
{% if form.is_active %}
|
|
||||||
<span class="badge bg-success ms-2">
|
|
||||||
<i class="fas fa-check-circle me-1"></i> Active Form
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-secondary ms-2">
|
|
||||||
<i class="fas fa-times-circle me-1"></i> Inactive
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<p class="text-muted mt-1 mb-1 small">
|
|
||||||
{{ form.description|default:"— No description provided. —" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Right Section: Actions #}
|
|
||||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
|
||||||
|
|
||||||
{# Edit Structure Button #}
|
|
||||||
<a href="{% url 'applicant:edit_form' form.id %}"
|
|
||||||
class="btn btn-sm btn-outline-secondary">
|
|
||||||
<i class="fas fa-pen me-1"></i> Edit Structure
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{# Conditional Activation Button #}
|
|
||||||
{% if not form.is_active %}
|
|
||||||
<a href="{% url 'applicant:activate_form' form.id %}"
|
|
||||||
class="btn btn-sm univ-color">
|
|
||||||
<i class="fas fa-bolt me-1"></i> Activate Form
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
{# Active indicator/Deactivate button placeholder #}
|
|
||||||
<a href="#" class="btn btn-sm btn-outline-success" disabled>
|
|
||||||
<i class="fas fa-star me-1"></i> Current Form
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-5 bg-light rounded shadow-sm">
|
|
||||||
<i class="fas fa-file-alt fa-4x text-muted mb-3"></i>
|
|
||||||
<p class="lead mb-0">No application forms have been created yet for this job.</p>
|
|
||||||
<p class="mt-2 mb-0 text-secondary">Click the button above to define a new form structure.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<footer class="text-end mt-5 pt-3 border-top">
|
|
||||||
<a href="{% url 'jobs:job_detail' job.internal_job_id %}"
|
|
||||||
class="btn btn-outline-secondary">
|
|
||||||
<i class="fas fa-arrow-left me-1"></i> Back to Job Details
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ job.title }} - University ATS{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="row mb-5">
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h2>{{ job.title }}</h2>
|
|
||||||
<span class="badge bg-{{ job.status|lower }} status-badge">
|
|
||||||
{{ job.get_status_display }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<!-- Job Details -->
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<strong>Department:</strong> {{ job.department|default:"Not specified" }}
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<strong>Position Number:</strong> {{ job.position_number|default:"Not specified" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<strong>Job Type:</strong> {{ job.get_job_type_display }}
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<strong>Workplace:</strong> {{ job.get_workplace_type_display }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<strong>Location:</strong> {{ job.get_location_display }}
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<strong>Created By:</strong> {{ job.created_by|default:"Not specified" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if job.salary_range %}
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<strong>Salary Range:</strong> {{ job.salary_range }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if job.start_date %}
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<strong>Start Date:</strong> {{ job.start_date }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if job.application_deadline %}
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<strong>Application Deadline:</strong> {{ job.application_deadline }}
|
|
||||||
{% if job.is_expired %}
|
|
||||||
<span class="badge bg-danger">EXPIRED</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
{% if job.description %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<h5>Description</h5>
|
|
||||||
<div>{{ job.description|linebreaks }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if job.qualifications %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<h5>Qualifications</h5>
|
|
||||||
<div>{{ job.qualifications|linebreaks }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if job.benefits %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<h5>Benefits</h5>
|
|
||||||
<div>{{ job.benefits|linebreaks }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if job.application_instructions %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<h5>Application Instructions</h5>
|
|
||||||
<div>{{ job.application_instructions|linebreaks }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-4">
|
|
||||||
|
|
||||||
<!-- Add this section below your existing job details -->
|
|
||||||
<div class="card mt-4">
|
|
||||||
<div class="card-header bg-success text-white">
|
|
||||||
<h5><i class="fas fa-file-signature"></i> Ready to Apply?</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>Review the job details on the left, then click the button below to submit your application.</p>
|
|
||||||
<a href="{% url 'applicant:apply_form' job.internal_job_id %}" class="btn btn-success btn-lg w-100">
|
|
||||||
<i class="fas fa-paper-plane"></i> Apply for this Position
|
|
||||||
</a>
|
|
||||||
<p class="text-muted mt-2">
|
|
||||||
<small>You'll be redirected to our secure application form where you can upload your resume and provide additional details.</small>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% block title %}Application Submitted - {{ job.title }}{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="card">
|
|
||||||
<div style="text-align: center; padding: 30px 0;">
|
|
||||||
<div style="width: 80px; height: 80px; background: #d4edda; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 20px;">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="#28a745" viewBox="0 0 16 16">
|
|
||||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 style="color: #28a745; margin-bottom: 15px;">Thank You!</h1>
|
|
||||||
<h2>Your application has been submitted successfully</h2>
|
|
||||||
|
|
||||||
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 25px 0; text-align: left;">
|
|
||||||
<p><strong>Position:</strong> {{ job.title }}</p>
|
|
||||||
<p><strong>Job ID:</strong> {{ job.internal_job_id }}</p>
|
|
||||||
<p><strong>Department:</strong> {{ job.department|default:"Not specified" }}</p>
|
|
||||||
{% if job.application_deadline %}
|
|
||||||
<p><strong>Application Deadline:</strong> {{ job.application_deadline|date:"F j, Y" }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="font-size: 18px; line-height: 1.6;">
|
|
||||||
We appreciate your interest in joining our team. Our hiring team will review your application
|
|
||||||
and contact you if there's a potential match for this position.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% comment %} <div style="margin-top: 30px;">
|
|
||||||
<a href="/" class="btn btn-primary" style="margin-right: 10px;">Apply to Another Position</a>
|
|
||||||
<a href="{% url 'jobs:job_detail' job.internal_job_id %}" class="btn btn-outline">View Job Details</a>
|
|
||||||
</div> {% endcomment %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
import json
|
|
||||||
from django import template
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
@register.filter(name='from_json')
|
|
||||||
def from_json(json_string):
|
|
||||||
"""
|
|
||||||
Safely loads a JSON string into a Python object (list or dict).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# The JSON string comes from the context and needs to be parsed
|
|
||||||
return json.loads(json_string)
|
|
||||||
except (TypeError, json.JSONDecodeError):
|
|
||||||
# Handle cases where the string is invalid or None/empty
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
@register.filter(name='split')
|
|
||||||
def split_string(value, key=None):
|
|
||||||
"""Splits a string by the given key (default is space)."""
|
|
||||||
if key is None:
|
|
||||||
return value.split()
|
|
||||||
return value.split(key)
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
# from django.db.models.signals import post_save
|
|
||||||
# from django.dispatch import receiver
|
|
||||||
# from . import models
|
|
||||||
#
|
|
||||||
# @receiver(post_save, sender=models.Candidate)
|
|
||||||
# def parse_resume(sender, instance, created, **kwargs):
|
|
||||||
# if instance.resume and not instance.summary:
|
|
||||||
# from .utils import extract_summary_from_pdf,match_resume_with_job_description
|
|
||||||
# summary = extract_summary_from_pdf(instance.resume.path)
|
|
||||||
# if 'error' not in summary:
|
|
||||||
# instance.summary = summary
|
|
||||||
# instance.save()
|
|
||||||
#
|
|
||||||
# # match_resume_with_job_description
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
app_name = 'applicant'
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
# Form Management
|
|
||||||
path('job/<str:job_id>/forms/', views.job_forms_list, name='job_forms_list'),
|
|
||||||
path('job/<str:job_id>/forms/create/', views.create_form_for_job, name='create_form'),
|
|
||||||
path('form/<int:form_id>/edit/', views.edit_form, name='edit_form'),
|
|
||||||
path('field/<int:field_id>/delete/', views.delete_field, name='delete_field'),
|
|
||||||
path('form/<int:form_id>/activate/', views.activate_form, name='activate_form'),
|
|
||||||
|
|
||||||
# Public Application
|
|
||||||
path('apply/<str:job_id>/', views.apply_form_view, name='apply_form'),
|
|
||||||
path('review/job/detail/<str:job_id>/',views.review_job_detail, name="review_job_detail"),
|
|
||||||
path('apply/<str:job_id>/thank-you/', views.thank_you_view, name='thank_you'),
|
|
||||||
]
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import os
|
|
||||||
import fitz # PyMuPDF
|
|
||||||
import spacy
|
|
||||||
import requests
|
|
||||||
from recruitment import models
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
nlp = spacy.load("en_core_web_sm")
|
|
||||||
|
|
||||||
def extract_text_from_pdf(pdf_path):
|
|
||||||
text = ""
|
|
||||||
with fitz.open(pdf_path) as doc:
|
|
||||||
for page in doc:
|
|
||||||
text += page.get_text()
|
|
||||||
return text
|
|
||||||
|
|
||||||
def extract_summary_from_pdf(pdf_path):
|
|
||||||
if not os.path.exists(pdf_path):
|
|
||||||
return {'error': 'File not found'}
|
|
||||||
|
|
||||||
text = extract_text_from_pdf(pdf_path)
|
|
||||||
doc = nlp(text)
|
|
||||||
summary = {
|
|
||||||
'name': doc.ents[0].text if doc.ents else '',
|
|
||||||
'skills': [chunk.text for chunk in doc.noun_chunks if len(chunk.text.split()) > 1],
|
|
||||||
'summary': text[:500]
|
|
||||||
}
|
|
||||||
return summary
|
|
||||||
|
|
||||||
def match_resume_with_job_description(resume, job_description,prompt=""):
|
|
||||||
resume_doc = nlp(resume)
|
|
||||||
job_doc = nlp(job_description)
|
|
||||||
similarity = resume_doc.similarity(job_doc)
|
|
||||||
return similarity
|
|
||||||
@ -1,175 +0,0 @@
|
|||||||
# applicant/views.py (Updated edit_form function)
|
|
||||||
|
|
||||||
from django.shortcuts import render, get_object_or_404, redirect
|
|
||||||
from django.contrib import messages
|
|
||||||
from django.http import Http404, JsonResponse # <-- Import JsonResponse
|
|
||||||
from django.views.decorators.csrf import csrf_exempt # <-- Needed for JSON POST if not using FormData
|
|
||||||
import json # <-- Import json
|
|
||||||
from django.db import transaction # <-- Import transaction
|
|
||||||
|
|
||||||
# (Keep all your existing imports)
|
|
||||||
from .models import ApplicantForm, FormField, ApplicantSubmission
|
|
||||||
from .forms import ApplicantFormCreateForm, FormFieldForm
|
|
||||||
from jobs.models import JobPosting
|
|
||||||
from .forms_builder import create_dynamic_form
|
|
||||||
|
|
||||||
# ... (Keep all other functions like job_forms_list, create_form_for_job, etc.)
|
|
||||||
# ...
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# === FORM MANAGEMENT VIEWS ===
|
|
||||||
|
|
||||||
def job_forms_list(request, job_id):
|
|
||||||
"""List all forms for a specific job"""
|
|
||||||
job = get_object_or_404(JobPosting, internal_job_id=job_id)
|
|
||||||
forms = job.applicant_forms.all()
|
|
||||||
return render(request, 'applicant/job_forms_list.html', {
|
|
||||||
'job': job,
|
|
||||||
'forms': forms
|
|
||||||
})
|
|
||||||
|
|
||||||
def create_form_for_job(request, job_id):
|
|
||||||
"""Create a new form for a job"""
|
|
||||||
job = get_object_or_404(JobPosting, internal_job_id=job_id)
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
form = ApplicantFormCreateForm(request.POST)
|
|
||||||
if form.is_valid():
|
|
||||||
applicant_form = form.save(commit=False)
|
|
||||||
applicant_form.job_posting = job
|
|
||||||
applicant_form.save()
|
|
||||||
messages.success(request, 'Form created successfully!')
|
|
||||||
return redirect('applicant:job_forms_list', job_id=job_id)
|
|
||||||
else:
|
|
||||||
form = ApplicantFormCreateForm()
|
|
||||||
|
|
||||||
return render(request, 'applicant/create_form.html', {
|
|
||||||
'job': job,
|
|
||||||
'form': form
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic # Ensures all fields are saved or none are
|
|
||||||
def edit_form(request, form_id):
|
|
||||||
"""Edit form details and manage fields, including dynamic builder save."""
|
|
||||||
applicant_form = get_object_or_404(ApplicantForm, id=form_id)
|
|
||||||
job = applicant_form.job_posting
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
# --- 1. Handle JSON data from the Form Builder (JavaScript) ---
|
|
||||||
if request.content_type == 'application/json':
|
|
||||||
try:
|
|
||||||
field_data = json.loads(request.body)
|
|
||||||
|
|
||||||
# Clear existing fields for this form
|
|
||||||
applicant_form.fields.all().delete()
|
|
||||||
|
|
||||||
# Create new fields from the JSON data
|
|
||||||
for field_config in field_data:
|
|
||||||
# Sanitize/ensure required fields are present
|
|
||||||
FormField.objects.create(
|
|
||||||
form=applicant_form,
|
|
||||||
label=field_config.get('label', 'New Field'),
|
|
||||||
field_type=field_config.get('field_type', 'text'),
|
|
||||||
required=field_config.get('required', True),
|
|
||||||
help_text=field_config.get('help_text', ''),
|
|
||||||
choices=field_config.get('choices', ''),
|
|
||||||
order=field_config.get('order', 0),
|
|
||||||
# field_name will be auto-generated/re-generated on save() if needed
|
|
||||||
)
|
|
||||||
|
|
||||||
return JsonResponse({'status': 'success', 'message': 'Form structure saved successfully!'})
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return JsonResponse({'status': 'error', 'message': 'Invalid JSON data.'}, status=400)
|
|
||||||
except Exception as e:
|
|
||||||
return JsonResponse({'status': 'error', 'message': f'Server error: {str(e)}'}, status=500)
|
|
||||||
|
|
||||||
# --- 2. Handle standard POST requests (e.g., saving form details) ---
|
|
||||||
elif 'save_form_details' in request.POST: # Changed the button name for clarity
|
|
||||||
form_details = ApplicantFormCreateForm(request.POST, instance=applicant_form)
|
|
||||||
if form_details.is_valid():
|
|
||||||
form_details.save()
|
|
||||||
messages.success(request, 'Form details updated successfully!')
|
|
||||||
return redirect('applicant:edit_form', form_id=form_id)
|
|
||||||
|
|
||||||
# Note: The 'add_field' branch is now redundant since we use the builder,
|
|
||||||
# but you can keep it if you want the old manual way too.
|
|
||||||
|
|
||||||
# --- GET Request (or unsuccessful POST) ---
|
|
||||||
form_details = ApplicantFormCreateForm(instance=applicant_form)
|
|
||||||
# Get initial fields to load into the JS builder
|
|
||||||
initial_fields_json = list(applicant_form.fields.values(
|
|
||||||
'label', 'field_type', 'required', 'help_text', 'choices', 'order', 'field_name'
|
|
||||||
))
|
|
||||||
|
|
||||||
return render(request, 'applicant/edit_form.html', {
|
|
||||||
'applicant_form': applicant_form,
|
|
||||||
'job': job,
|
|
||||||
'form_details': form_details,
|
|
||||||
'initial_fields_json': json.dumps(initial_fields_json)
|
|
||||||
})
|
|
||||||
|
|
||||||
def delete_field(request, field_id):
|
|
||||||
"""Delete a form field"""
|
|
||||||
field = get_object_or_404(FormField, id=field_id)
|
|
||||||
form_id = field.form.id
|
|
||||||
field.delete()
|
|
||||||
messages.success(request, 'Field deleted successfully!')
|
|
||||||
return redirect('applicant:edit_form', form_id=form_id)
|
|
||||||
|
|
||||||
def activate_form(request, form_id):
|
|
||||||
"""Activate a form (deactivates others automatically)"""
|
|
||||||
applicant_form = get_object_or_404(ApplicantForm, id=form_id)
|
|
||||||
applicant_form.activate()
|
|
||||||
messages.success(request, f'Form "{applicant_form.name}" is now active!')
|
|
||||||
return redirect('applicant:job_forms_list', job_id=applicant_form.job_posting.internal_job_id)
|
|
||||||
|
|
||||||
# === PUBLIC VIEWS (for applicants) ===
|
|
||||||
|
|
||||||
def apply_form_view(request, job_id):
|
|
||||||
"""Public application form - serves active form"""
|
|
||||||
job = get_object_or_404(JobPosting, internal_job_id=job_id, status='ACTIVE')
|
|
||||||
|
|
||||||
if job.is_expired():
|
|
||||||
raise Http404("Application deadline has passed")
|
|
||||||
|
|
||||||
try:
|
|
||||||
applicant_form = job.applicant_forms.get(is_active=True)
|
|
||||||
except ApplicantForm.DoesNotExist:
|
|
||||||
raise Http404("No active application form configured for this job")
|
|
||||||
|
|
||||||
DynamicForm = create_dynamic_form(applicant_form)
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
form = DynamicForm(request.POST)
|
|
||||||
if form.is_valid():
|
|
||||||
ApplicantSubmission.objects.create(
|
|
||||||
job_posting=job,
|
|
||||||
form=applicant_form,
|
|
||||||
data=form.cleaned_data,
|
|
||||||
ip_address=request.META.get('REMOTE_ADDR')
|
|
||||||
)
|
|
||||||
return redirect('applicant:thank_you', job_id=job_id)
|
|
||||||
else:
|
|
||||||
form = DynamicForm()
|
|
||||||
|
|
||||||
return render(request, 'applicant/apply_form.html', {
|
|
||||||
'form': form,
|
|
||||||
'job': job,
|
|
||||||
'applicant_form': applicant_form
|
|
||||||
})
|
|
||||||
|
|
||||||
def review_job_detail(request,job_id):
|
|
||||||
"""Public job detail view for applicants"""
|
|
||||||
job = get_object_or_404(JobPosting, internal_job_id=job_id, status='ACTIVE')
|
|
||||||
if job.is_expired():
|
|
||||||
raise Http404("This job posting has expired.")
|
|
||||||
return render(request,'applicant/review_job_detail.html',{'job':job})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def thank_you_view(request, job_id):
|
|
||||||
job = get_object_or_404(JobPosting, internal_job_id=job_id)
|
|
||||||
return render(request, 'applicant/thank_you.html', {'job': job})
|
|
||||||
@ -19,7 +19,7 @@
|
|||||||
{% trans "Please enter your current password and a new password to secure your account." %}
|
{% trans "Please enter your current password and a new password to secure your account." %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="post" action="{% url 'account_change_password' %}" class="space-y-4">
|
<form method="post" action="{% url 'account_change_password' %}" class="space-y-4 account-password-change">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{{ form|crispy }}
|
{{ form|crispy }}
|
||||||
|
|||||||
@ -138,8 +138,8 @@
|
|||||||
data-bs-auto-close="outside"
|
data-bs-auto-close="outside"
|
||||||
data-bs-offset="0, 16" {# Vertical offset remains 16px to prevent clipping #}
|
data-bs-offset="0, 16" {# Vertical offset remains 16px to prevent clipping #}
|
||||||
>
|
>
|
||||||
{% if user.profile and user.profile.profile_image %}
|
{% if user.profile_image %}
|
||||||
<img src="{{ user.profile.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar"
|
<img src="{{ user.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar"
|
||||||
style="width: 36px; height: 36px; object-fit: cover; background-color: var(--kaauh-teal); display: inline-block; vertical-align: middle;"
|
style="width: 36px; height: 36px; object-fit: cover; background-color: var(--kaauh-teal); display: inline-block; vertical-align: middle;"
|
||||||
title="{% trans 'Your account' %}">
|
title="{% trans 'Your account' %}">
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -156,8 +156,8 @@
|
|||||||
<li class="px-4 py-3 ">
|
<li class="px-4 py-3 ">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="me-3 d-flex align-items-center justify-content-center" style="min-width: 48px;">
|
<div class="me-3 d-flex align-items-center justify-content-center" style="min-width: 48px;">
|
||||||
{% if user.profile and user.profile.profile_image %}
|
{% if user.profile_image %}
|
||||||
<img src="{{ user.profile.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar shadow-sm border"
|
<img src="{{ user.profile_image.url }}" alt="{{ user.username }}" class="profile-avatar shadow-sm border"
|
||||||
style="width: 44px; height: 44px; object-fit: cover; background-color: var(--kaauh-teal); display: block;"
|
style="width: 44px; height: 44px; object-fit: cover; background-color: var(--kaauh-teal); display: block;"
|
||||||
title="{% trans 'Your account' %}">
|
title="{% trans 'Your account' %}">
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@ -1,10 +1,34 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<div class="d-flex justify-content-center align-items-center gap-2" hx-swap='outerHTML' hx-target="#status-result-{{ candidate.pk }}"
|
<form id="exam-update-form" hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'Failed' %}" hx-swap='outerHTML' hx-target="#status-result-{{ candidate.pk }}"
|
||||||
hx-on::after-request="const modal = bootstrap.Modal.getInstance(document.getElementById('candidateviewModal')); if (modal) { modal.hide(); }">
|
hx-on::after-request="const modal = bootstrap.Modal.getInstance(document.getElementById('candidateviewModal')); if (modal) { modal.hide(); }">
|
||||||
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'Passed' %}" class="btn btn-outline-secondary">
|
<div class="d-flex justify-content-center align-items-center gap-2">
|
||||||
<i class="fas fa-check me-1"></i> {% trans "Passed" %}
|
<div class="form-check d-flex align-items-center gap-2">
|
||||||
</a>
|
<input class="form-check-input" type="radio" name="exam_status" id="exam_passed" value="Passed" {% if candidate.exam_status == 'Passed' %}checked{% endif %}>
|
||||||
<a hx-post="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'Failed' %}" class="btn btn-danger">
|
<label class="form-check-label" for="exam_passed">
|
||||||
<i class="fas fa-times me-1"></i> {% trans "Failed" %}
|
<i class="fas fa-check me-1"></i> {% trans "Passed" %}
|
||||||
</a>
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check d-flex align-items-center gap-2">
|
||||||
|
<input class="form-check-input" type="radio" name="exam_status" id="exam_failed" value="Failed" {% if candidate.exam_status == 'Failed' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="exam_failed">
|
||||||
|
<i class="fas fa-times me-1"></i> {% trans "Failed" %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-center align-items-center mt-3 gap-2">
|
||||||
|
<div class="w-25 text-end pe-none">
|
||||||
|
<label for="exam_score" class="form-label small text-muted">{% trans "Exam Score" %}</label>
|
||||||
|
</div>
|
||||||
|
<div class="w-25">
|
||||||
|
<input type="number" class="form-control form-control-sm" id="exam_score" name="exam_score" min="0" max="100" required value="{{ candidate.exam_score }}">
|
||||||
|
</div>
|
||||||
|
<div class="w-25 text-start ps-none">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<button type="submit" class="btn btn-success btn-sm">
|
||||||
|
<i class="fas fa-check me-1"></i> {% trans "Update" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|||||||
@ -141,9 +141,23 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% comment %} CONNECTOR 3 -> 4 {% endcomment %}
|
{% comment %} CONNECTOR 3 -> 4 {% endcomment %}
|
||||||
|
<div class="stage-connector {% if current_stage == 'Document Review' or current_stage == 'Offer' or current_stage == 'Hired' %}completed{% endif %}"></div>
|
||||||
|
|
||||||
|
{% comment %} STAGE 4: Document Review {% endcomment %}
|
||||||
|
<a href="{% url 'candidate_document_review_view' job.slug %}"
|
||||||
|
class="stage-item {% if current_stage == 'Document Review' %}active{% endif %} {% if current_stage == 'Offer' or current_stage == 'Hired' %}completed{% endif %}"
|
||||||
|
data-stage="Document Review">
|
||||||
|
<div class="stage-icon">
|
||||||
|
<i class="fas fa-file-alt"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stage-label">{% trans "Document Review" %}</div>
|
||||||
|
<div class="stage-count">{{ job.document_review_candidates.count|default:"0" }}</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% comment %} CONNECTOR 4 -> 5 {% endcomment %}
|
||||||
<div class="stage-connector {% if current_stage == 'Offer' or current_stage == 'Hired' %}completed{% endif %}"></div>
|
<div class="stage-connector {% if current_stage == 'Offer' or current_stage == 'Hired' %}completed{% endif %}"></div>
|
||||||
|
|
||||||
{% comment %} STAGE 4: Offer {% endcomment %}
|
{% comment %} STAGE 5: Offer {% endcomment %}
|
||||||
<a href="{% url 'candidate_offer_view' job.slug %}"
|
<a href="{% url 'candidate_offer_view' job.slug %}"
|
||||||
class="stage-item {% if current_stage == 'Offer' %}active{% endif %} {% if current_stage == 'Hired' %}completed{% endif %}"
|
class="stage-item {% if current_stage == 'Offer' %}active{% endif %} {% if current_stage == 'Hired' %}completed{% endif %}"
|
||||||
data-stage="Offer">
|
data-stage="Offer">
|
||||||
@ -154,10 +168,10 @@
|
|||||||
<div class="stage-count">{{ job.offer_candidates.count|default:"0" }}</div>
|
<div class="stage-count">{{ job.offer_candidates.count|default:"0" }}</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% comment %} CONNECTOR 4 -> 5 {% endcomment %}
|
{% comment %} CONNECTOR 5 -> 6 {% endcomment %}
|
||||||
<div class="stage-connector {% if current_stage == 'Hired' %}completed{% endif %}"></div>
|
<div class="stage-connector {% if current_stage == 'Hired' %}completed{% endif %}"></div>
|
||||||
|
|
||||||
{% comment %} STAGE 5: Hired {% endcomment %}
|
{% comment %} STAGE 6: Hired {% endcomment %}
|
||||||
<a href="{% url 'candidate_hired_view' job.slug %}"
|
<a href="{% url 'candidate_hired_view' job.slug %}"
|
||||||
class="stage-item {% if current_stage == 'Hired' %}active{% endif %}"
|
class="stage-item {% if current_stage == 'Hired' %}active{% endif %}"
|
||||||
data-stage="Hired">
|
data-stage="Hired">
|
||||||
|
|||||||
179
templates/messages/candidate_message_detail.html
Normal file
179
templates/messages/candidate_message_detail.html
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
{% extends "portal_base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{{ message.subject }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<!-- Message Header -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
{{ message.subject }}
|
||||||
|
{% if message.parent_message %}
|
||||||
|
<span class="badge bg-secondary ms-2">Reply</span>
|
||||||
|
{% endif %}
|
||||||
|
</h5>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<a href="{% url 'message_reply' message.id %}" class="btn btn-outline-info">
|
||||||
|
<i class="fas fa-reply"></i> Reply
|
||||||
|
</a>
|
||||||
|
{% if message.recipient == request.user %}
|
||||||
|
<a href="{% url 'message_mark_unread' message.id %}"
|
||||||
|
class="btn btn-outline-warning"
|
||||||
|
hx-post="{% url 'message_mark_unread' message.id %}">
|
||||||
|
<i class="fas fa-envelope"></i> Mark Unread
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url 'message_delete' message.id %}"
|
||||||
|
class="btn btn-outline-danger"
|
||||||
|
hx-get="{% url 'message_delete' message.id %}"
|
||||||
|
hx-confirm="Are you sure you want to delete this message?">
|
||||||
|
<i class="fas fa-trash"></i> Delete
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'message_list' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Back to Messages
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>From:</strong>
|
||||||
|
<span class="text-primary">{{ message.sender.get_full_name|default:message.sender.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>To:</strong>
|
||||||
|
<span class="text-primary">{{ message.recipient.get_full_name|default:message.recipient.username }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Type:</strong>
|
||||||
|
<span class="badge bg-{{ message.message_type|lower }}">
|
||||||
|
{{ message.get_message_type_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Status:</strong>
|
||||||
|
{% if message.is_read %}
|
||||||
|
<span class="badge bg-success">Read</span>
|
||||||
|
{% if message.read_at %}
|
||||||
|
<small class="text-muted">({{ message.read_at|date:"M d, Y H:i" }})</small>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning">Unread</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Created:</strong>
|
||||||
|
<span>{{ message.created_at|date:"M d, Y H:i" }}</span>
|
||||||
|
</div>
|
||||||
|
{% if message.job %}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Related Job:</strong>
|
||||||
|
<a href="{% url 'job_detail' message.job.slug %}" class="text-primary">
|
||||||
|
{{ message.job.title }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if message.parent_message %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>In reply to:</strong>
|
||||||
|
<a href="{% url 'message_detail' message.parent_message.id %}">
|
||||||
|
{{ message.parent_message.subject }}
|
||||||
|
</a>
|
||||||
|
<small class="text-muted d-block">
|
||||||
|
From {{ message.parent_message.sender.get_full_name|default:message.parent_message.sender.username }}
|
||||||
|
on {{ message.parent_message.created_at|date:"M d, Y H:i" }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Content -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">Message</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="message-content">
|
||||||
|
{{ message.content|linebreaks }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Thread (if this is a reply and has replies) -->
|
||||||
|
{% if message.replies.all %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="fas fa-comments"></i> Replies ({{ message.replies.count }})
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% for reply in message.replies.all %}
|
||||||
|
<div class="border-start ps-3 mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<strong>{{ reply.sender.get_full_name|default:reply.sender.username }}</strong>
|
||||||
|
<small class="text-muted ms-2">
|
||||||
|
{{ reply.created_at|date:"M d, Y H:i" }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-{{ reply.message_type|lower }}">
|
||||||
|
{{ reply.get_message_type_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="reply-content">
|
||||||
|
{{ reply.content|linebreaks }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<a href="{% url 'message_reply' reply.id %}" class="btn btn-sm btn-outline-info">
|
||||||
|
<i class="fas fa-reply"></i> Reply to this
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.message-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-start {
|
||||||
|
border-left: 3px solid #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-3 {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
238
templates/messages/candidate_message_form.html
Normal file
238
templates/messages/candidate_message_form.html
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
{% extends "portal_base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{% if form.instance.pk %}Reply to Message{% else %}Compose Message{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
{% if form.instance.pk %}
|
||||||
|
<i class="fas fa-reply"></i> Reply to Message
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-envelope"></i> Compose Message
|
||||||
|
{% endif %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if form.instance.parent_message %}
|
||||||
|
<div class="alert alert-info mb-4">
|
||||||
|
<strong>Replying to:</strong> {{ form.instance.parent_message.subject }}
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">
|
||||||
|
From {{ form.instance.parent_message.sender.get_full_name|default:form.instance.parent_message.sender.username }}
|
||||||
|
on {{ form.instance.parent_message.created_at|date:"M d, Y H:i" }}
|
||||||
|
</small>
|
||||||
|
<div class="mt-2">
|
||||||
|
<strong>Original message:</strong>
|
||||||
|
<div class="border-start ps-3 mt-2">
|
||||||
|
{{ form.instance.parent_message.content|linebreaks }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" id="messageForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.job.id_for_label }}" class="form-label">
|
||||||
|
Related Job <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.job }}
|
||||||
|
{% if form.job.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.job.errors.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">
|
||||||
|
Select a job if this message is related to a specific position
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.recipient.id_for_label }}" class="form-label">
|
||||||
|
Recipient <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.recipient }}
|
||||||
|
|
||||||
|
{% if form.recipient.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.recipient.errors.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">
|
||||||
|
Select the user who will receive this message
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.message_type.id_for_label }}" class="form-label">
|
||||||
|
Message Type <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.message_type }}
|
||||||
|
{% if form.message_type.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.message_type.errors.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">
|
||||||
|
Select the type of message you're sending
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.subject.id_for_label }}" class="form-label">
|
||||||
|
Subject <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.subject }}
|
||||||
|
{% if form.subject.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.subject.errors.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.content.id_for_label }}" class="form-label">
|
||||||
|
Message <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.content }}
|
||||||
|
{% if form.content.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.content.errors.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">
|
||||||
|
Write your message here. You can use line breaks and basic formatting.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{% url 'message_list' %}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-times"></i> Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-main-action">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
{% if form.instance.pk %}
|
||||||
|
Send Reply
|
||||||
|
{% else %}
|
||||||
|
Send Message
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
#id_content {
|
||||||
|
min-height: 200px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
{% if form.recipient.field.widget.attrs.disabled %}
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
{% endif %}
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-start {
|
||||||
|
border-left: 3px solid #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-3 {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Auto-resize textarea based on content
|
||||||
|
const textarea = document.getElementById('id_content');
|
||||||
|
if (textarea) {
|
||||||
|
textarea.addEventListener('input', function() {
|
||||||
|
this.style.height = 'auto';
|
||||||
|
this.style.height = (this.scrollHeight) + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial height
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = (textarea.scrollHeight) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character counter for subject
|
||||||
|
const subjectField = document.getElementById('id_subject');
|
||||||
|
const maxLength = 200;
|
||||||
|
|
||||||
|
if (subjectField) {
|
||||||
|
// Add character counter display
|
||||||
|
const counter = document.createElement('small');
|
||||||
|
counter.className = 'text-muted';
|
||||||
|
counter.style.float = 'right';
|
||||||
|
subjectField.parentNode.appendChild(counter);
|
||||||
|
|
||||||
|
function updateCounter() {
|
||||||
|
const remaining = maxLength - subjectField.value.length;
|
||||||
|
counter.textContent = `${subjectField.value.length}/${maxLength} characters`;
|
||||||
|
if (remaining < 20) {
|
||||||
|
counter.className = 'text-warning';
|
||||||
|
} else {
|
||||||
|
counter.className = 'text-muted';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subjectField.addEventListener('input', updateCounter);
|
||||||
|
updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form validation before submit
|
||||||
|
const form = document.getElementById('messageForm');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
const content = document.getElementById('id_content').value.trim();
|
||||||
|
const subject = document.getElementById('id_subject').value.trim();
|
||||||
|
const recipient = document.getElementById('id_recipient').value;
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please select a recipient.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subject) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please enter a subject.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please enter a message.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
230
templates/messages/candidate_message_list.html
Normal file
230
templates/messages/candidate_message_list.html
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
{% extends "portal_base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Messages{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="mb-0">Messages</h4>
|
||||||
|
<a href="{% url 'message_create' %}" class="btn btn-main-action">
|
||||||
|
<i class="fas fa-plus"></i> Compose Message
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="status" class="form-label">Status</label>
|
||||||
|
<select name="status" id="status" class="form-select">
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="read" {% if status_filter == 'read' %}selected{% endif %}>Read</option>
|
||||||
|
<option value="unread" {% if status_filter == 'unread' %}selected{% endif %}>Unread</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="type" class="form-label">Type</label>
|
||||||
|
<select name="type" id="type" class="form-select">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="GENERAL" {% if type_filter == 'GENERAL' %}selected{% endif %}>General</option>
|
||||||
|
<option value="JOB_RELATED" {% if type_filter == 'JOB_RELATED' %}selected{% endif %}>Job Related</option>
|
||||||
|
<option value="INTERVIEW" {% if type_filter == 'INTERVIEW' %}selected{% endif %}>Interview</option>
|
||||||
|
<option value="OFFER" {% if type_filter == 'OFFER' %}selected{% endif %}>Offer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="q" class="form-label">Search</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" name="q" id="q" class="form-control"
|
||||||
|
value="{{ search_query }}" placeholder="Search messages...">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label"> </label>
|
||||||
|
<button type="submit" class="btn btn-secondary w-100">Filter</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">Total Messages</h6>
|
||||||
|
<h3 class="text-primary">{{ total_messages }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">Unread Messages</h6>
|
||||||
|
<h3 class="text-warning">{{ unread_messages }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages List -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if page_obj %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Sender</th>
|
||||||
|
<th>Recipient</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for message in page_obj %}
|
||||||
|
<tr class="{% if not message.is_read %}table-secondary{% endif %}">
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'message_detail' message.id %}"
|
||||||
|
class="{% if not message.is_read %}fw-bold{% endif %}">
|
||||||
|
{{ message.subject }}
|
||||||
|
</a>
|
||||||
|
{% if message.parent_message %}
|
||||||
|
<span class="badge bg-secondary ms-2">Reply</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ message.sender.get_full_name|default:message.sender.username }}</td>
|
||||||
|
<td>{{ message.recipient.get_full_name|default:message.recipient.username }}</td>
|
||||||
|
<td>
|
||||||
|
<span>
|
||||||
|
{{ message.get_message_type_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if message.is_read %}
|
||||||
|
<span class="badge bg-primary-theme">Read</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning">Unread</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ message.created_at|date:"M d, Y H:i" }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<a href="{% url 'message_detail' message.id %}"
|
||||||
|
class="btn btn-sm btn-outline-primary" title="View">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if not message.is_read and message.recipient == request.user %}
|
||||||
|
<a href="{% url 'message_mark_read' message.id %}"
|
||||||
|
class="btn btn-sm btn-outline-success"
|
||||||
|
hx-post="{% url 'message_mark_read' message.id %}"
|
||||||
|
title="Mark as Read">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url 'message_reply' message.id %}"
|
||||||
|
class="btn btn-sm btn-outline-primary" title="Reply">
|
||||||
|
<i class="fas fa-reply"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'message_delete' message.id %}"
|
||||||
|
class="btn btn-sm btn-outline-danger"
|
||||||
|
hx-get="{% url 'message_delete' message.id %}"
|
||||||
|
hx-confirm="Are you sure you want to delete this message?"
|
||||||
|
title="Delete">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center text-muted">
|
||||||
|
<i class="fas fa-inbox fa-3x mb-3"></i>
|
||||||
|
<p class="mb-0">No messages found.</p>
|
||||||
|
<p class="small">Try adjusting your filters or compose a new message.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if page_obj.has_other_pages %}
|
||||||
|
<nav aria-label="Message pagination">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.previous_page_number }}&status={{ status_filter }}&type={{ type_filter }}&q={{ search_query }}">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for num in page_obj.paginator.page_range %}
|
||||||
|
{% if page_obj.number == num %}
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">{{ num }}</span>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ num }}&status={{ status_filter }}&type={{ type_filter }}&q={{ search_query }}">{{ num }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.next_page_number }}&status={{ status_filter }}&type={{ type_filter }}&q={{ search_query }}">
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="fas fa-inbox fa-3x mb-3"></i>
|
||||||
|
<p class="mb-0">No messages found.</p>
|
||||||
|
<p class="small">Try adjusting your filters or compose a new message.</p>
|
||||||
|
<a href="{% url 'message_create' %}" class="btn btn-main-action">
|
||||||
|
<i class="fas fa-plus"></i> Compose Message
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Auto-refresh unread count every 30 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
fetch('/api/unread-count/')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Update unread count in navigation if it exists
|
||||||
|
const unreadBadge = document.querySelector('.unread-messages-count');
|
||||||
|
if (unreadBadge) {
|
||||||
|
unreadBadge.textContent = data.unread_count;
|
||||||
|
unreadBadge.style.display = data.unread_count > 0 ? 'inline-block' : 'none';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error fetching unread count:', error));
|
||||||
|
}, 30000);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -39,12 +39,29 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.job.id_for_label }}" class="form-label">
|
||||||
|
Related Job <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.job }}
|
||||||
|
{% if form.job.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{{ form.job.errors.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">
|
||||||
|
Select a job if this message is related to a specific position
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="{{ form.recipient.id_for_label }}" class="form-label">
|
<label for="{{ form.recipient.id_for_label }}" class="form-label">
|
||||||
Recipient <span class="text-danger">*</span>
|
Recipient <span class="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
{{ form.recipient }}
|
{{ form.recipient }}
|
||||||
|
|
||||||
{% if form.recipient.errors %}
|
{% if form.recipient.errors %}
|
||||||
<div class="text-danger small mt-1">
|
<div class="text-danger small mt-1">
|
||||||
{{ form.recipient.errors.0 }}
|
{{ form.recipient.errors.0 }}
|
||||||
@ -55,7 +72,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="{{ form.message_type.id_for_label }}" class="form-label">
|
<label for="{{ form.message_type.id_for_label }}" class="form-label">
|
||||||
Message Type <span class="text-danger">*</span>
|
Message Type <span class="text-danger">*</span>
|
||||||
@ -87,22 +104,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="{{ form.job.id_for_label }}" class="form-label">
|
|
||||||
Related Job
|
|
||||||
</label>
|
|
||||||
{{ form.job }}
|
|
||||||
{% if form.job.errors %}
|
|
||||||
<div class="text-danger small mt-1">
|
|
||||||
{{ form.job.errors.0 }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="form-text">
|
|
||||||
Optional: Select a job if this message is related to a specific position
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -124,7 +125,7 @@
|
|||||||
<a href="{% url 'message_list' %}" class="btn btn-secondary">
|
<a href="{% url 'message_list' %}" class="btn btn-secondary">
|
||||||
<i class="fas fa-times"></i> Cancel
|
<i class="fas fa-times"></i> Cancel
|
||||||
</a>
|
</a>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-main-action">
|
||||||
<i class="fas fa-paper-plane"></i>
|
<i class="fas fa-paper-plane"></i>
|
||||||
{% if form.instance.pk %}
|
{% if form.instance.pk %}
|
||||||
Send Reply
|
Send Reply
|
||||||
|
|||||||
@ -93,7 +93,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for message in page_obj %}
|
{% for message in page_obj %}
|
||||||
<tr class="{% if not message.is_read %}table-warning{% endif %}">
|
<tr class="{% if not message.is_read %}table-secondary{% endif %}">
|
||||||
<td>
|
<td>
|
||||||
<a href="{% url 'message_detail' message.id %}"
|
<a href="{% url 'message_detail' message.id %}"
|
||||||
class="{% if not message.is_read %}fw-bold{% endif %}">
|
class="{% if not message.is_read %}fw-bold{% endif %}">
|
||||||
|
|||||||
@ -120,14 +120,26 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link text-white" href="{% url 'user_detail' request.user.pk %}">
|
||||||
|
<i class="fas fa-user-circle me-1"></i> <span>{% trans "My Profile" %}</span></a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="nav-item me-2">
|
||||||
|
<a class="nav-link text-white" href="{% url 'message_list' %}">
|
||||||
|
<i class="fas fa-envelope"></i> <span>{% trans "Messages" %}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li class="nav-item ms-3">
|
<li class="nav-item ms-3">
|
||||||
<form method="post" action="{% url 'portal_logout' %}" class="d-inline">
|
{% if request.user.is_authenticated %}
|
||||||
{% csrf_token %}
|
<form method="post" action="{% url 'account_logout' %}" class="d-inline py-2 d-flex align-items-center">
|
||||||
<button type="submit" class="btn btn-outline-light btn-sm">
|
{% csrf_token %}
|
||||||
<i class="fas fa-sign-out-alt me-1"></i> {% trans "Logout" %}
|
<button type="submit" class="btn btn-outline-light btn-sm">
|
||||||
</button>
|
<i class="fas fa-sign-out-alt me-1"></i> {% trans "Logout" %}
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -135,7 +147,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="container-fluid flex-grow-1" style="max-width: 1600px; margin: 0 auto;">
|
<main id="message-container" class="container-fluid flex-grow-1" style="max-width: 1600px; margin: 0 auto;">
|
||||||
{# Messages Block (Correct) #}
|
{# Messages Block (Correct) #}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
|
|||||||
@ -76,7 +76,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kaauh-card shadow-sm">
|
{% comment %} <div class="kaauh-card shadow-sm">
|
||||||
<div class="card-body px-3 py-3">
|
<div class="card-body px-3 py-3">
|
||||||
<h5 class="card-title mb-3">
|
<h5 class="card-title mb-3">
|
||||||
<i class="fas fa-key me-2 text-warning"></i>
|
<i class="fas fa-key me-2 text-warning"></i>
|
||||||
@ -121,7 +121,7 @@
|
|||||||
{% trans "Share these credentials securely with the agency. They can use this information to log in and submit candidates." %}
|
{% trans "Share these credentials securely with the agency. They can use this information to log in and submit candidates." %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> {% endcomment %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
|
|||||||
@ -165,7 +165,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kaauh-card shadow-sm mb-4">
|
{% comment %} <div class="kaauh-card shadow-sm mb-4">
|
||||||
<div class="card-body my-2">
|
<div class="card-body my-2">
|
||||||
<h5 class="card-title mb-3 mx-2">
|
<h5 class="card-title mb-3 mx-2">
|
||||||
<i class="fas fa-key me-2 text-warning"></i>
|
<i class="fas fa-key me-2 text-warning"></i>
|
||||||
@ -217,7 +217,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> {% endcomment %}
|
||||||
|
|
||||||
<!-- Candidates Card -->
|
<!-- Candidates Card -->
|
||||||
<div class="kaauh-card p-4">
|
<div class="kaauh-card p-4">
|
||||||
|
|||||||
@ -204,6 +204,37 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Password Display Styling */
|
||||||
|
.password-display-section {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-value {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2d3436;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-value:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -379,6 +410,54 @@
|
|||||||
<p class="mb-0">{{ agency.description|linebreaks }}</p>
|
<p class="mb-0">{{ agency.description|linebreaks }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Agency Login Information -->
|
||||||
|
{% if generated_password and request.user.is_staff %}
|
||||||
|
<div class="info-section mt-4">
|
||||||
|
<h5 class="mb-3">
|
||||||
|
<i class="fas fa-key me-2" style="color: var(--kaauh-teal);"></i>
|
||||||
|
{% trans "Agency Login Information" %}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
<h6 class="alert-heading">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
{% trans "Important Security Notice" %}
|
||||||
|
</h6>
|
||||||
|
<p class="mb-2">
|
||||||
|
{% trans "This password provides access to the agency portal. Share it securely with the agency contact person." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="password-display-section">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-icon">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
</div>
|
||||||
|
<div class="info-content">
|
||||||
|
<div class="info-label">{% trans "Username" %}</div>
|
||||||
|
<div class="info-value">{{ agency.user.username }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-icon">
|
||||||
|
<i class="fas fa-lock"></i>
|
||||||
|
</div>
|
||||||
|
<div class="info-content">
|
||||||
|
<div class="info-label">{% trans "Generated Password" %}</div>
|
||||||
|
<div class="password-container">
|
||||||
|
<div class="password-value" id="password-value">{{ generated_password }}</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" onclick="copyPassword()">
|
||||||
|
<i class="fas fa-copy me-1"></i>
|
||||||
|
{% trans "Copy" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -531,4 +610,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyPassword() {
|
||||||
|
const passwordText = document.getElementById('password-value').textContent;
|
||||||
|
navigator.clipboard.writeText(passwordText).then(function() {
|
||||||
|
// Show success feedback
|
||||||
|
const button = event.target;
|
||||||
|
const originalText = button.innerHTML;
|
||||||
|
button.innerHTML = '<i class="fas fa-check me-1"></i> {% trans "Copied!" %}';
|
||||||
|
button.classList.remove('btn-outline-secondary');
|
||||||
|
button.classList.add('btn-success');
|
||||||
|
|
||||||
|
// Reset after 2 seconds
|
||||||
|
setTimeout(function() {
|
||||||
|
button.innerHTML = originalText;
|
||||||
|
button.classList.remove('btn-success');
|
||||||
|
button.classList.add('btn-outline-secondary');
|
||||||
|
}, 2000);
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.error('Failed to copy password: ', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -3,122 +3,12 @@
|
|||||||
|
|
||||||
{% block title %}{{ title }} - ATS{% endblock %}
|
{% block title %}{{ title }} - ATS{% endblock %}
|
||||||
|
|
||||||
{% block customCSS %}
|
|
||||||
<style>
|
|
||||||
/* KAAT-S UI Variables */
|
|
||||||
:root {
|
|
||||||
--kaauh-teal: #00636e;
|
|
||||||
--kaauh-teal-dark: #004a53;
|
|
||||||
--kaauh-border: #eaeff3;
|
|
||||||
--kaauh-primary-text: #343a40;
|
|
||||||
--kaauh-success: #28a745;
|
|
||||||
--kaauh-info: #17a2b8;
|
|
||||||
--kaauh-danger: #dc3545;
|
|
||||||
--kaauh-warning: #ffc107;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Primary Color Overrides */
|
|
||||||
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
|
||||||
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
|
|
||||||
|
|
||||||
/* Main Container & Card Styling */
|
|
||||||
.kaauh-card {
|
|
||||||
border: 1px solid var(--kaauh-border);
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Styling */
|
|
||||||
.form-section {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
border: 1px solid var(--kaauh-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--kaauh-primary-text);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control, .form-select {
|
|
||||||
border: 1px solid var(--kaauh-border);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus, .form-select:focus {
|
|
||||||
border-color: var(--kaauh-teal);
|
|
||||||
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button Styling */
|
|
||||||
.btn-main-action {
|
|
||||||
background-color: var(--kaauh-teal);
|
|
||||||
border-color: var(--kaauh-teal);
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.btn-main-action:hover {
|
|
||||||
background-color: var(--kaauh-teal-dark);
|
|
||||||
border-color: var(--kaauh-teal-dark);
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: #6c757d;
|
|
||||||
border-color: #6c757d;
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Required Field Indicator */
|
|
||||||
.required-field::after {
|
|
||||||
content: " *";
|
|
||||||
color: var(--kaauh-danger);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error Styling */
|
|
||||||
.is-invalid {
|
|
||||||
border-color: var(--kaauh-danger) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invalid-feedback {
|
|
||||||
color: var(--kaauh-danger);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Help Text */
|
|
||||||
.form-text {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Icon Styling */
|
|
||||||
.section-icon {
|
|
||||||
color: var(--kaauh-teal);
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid py-4">
|
<div class="container py-4">
|
||||||
<!-- Header Section -->
|
<!-- Header -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
<h1 class="h3 mb-1">{{ title }}</h1>
|
||||||
<i class="fas fa-building me-2"></i>
|
|
||||||
{{ title }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-muted mb-0">
|
<p class="text-muted mb-0">
|
||||||
{% if agency %}
|
{% if agency %}
|
||||||
{% trans "Update the hiring agency information below." %}
|
{% trans "Update the hiring agency information below." %}
|
||||||
@ -132,11 +22,11 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form Card -->
|
<!-- Form -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
<div class="card kaauh-card">
|
<div class="card">
|
||||||
<div class="card-body p-4">
|
<div class="card-body">
|
||||||
{% if form.non_field_errors %}
|
{% if form.non_field_errors %}
|
||||||
<div class="alert alert-danger" role="alert">
|
<div class="alert alert-danger" role="alert">
|
||||||
<h5 class="alert-heading">
|
<h5 class="alert-heading">
|
||||||
@ -152,259 +42,166 @@
|
|||||||
<form method="post" novalidate>
|
<form method="post" novalidate>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<!-- Basic Information Section -->
|
<!-- Name -->
|
||||||
<div class="form-section">
|
<div class="mb-3">
|
||||||
<h5 class="mb-4">
|
<label for="{{ form.name.id_for_label }}" class="form-label">
|
||||||
<i class="fas fa-info-circle section-icon"></i>
|
{{ form.name.label }} <span class="text-danger">*</span>
|
||||||
{% trans "Basic Information" %}
|
</label>
|
||||||
</h5>
|
{{ form.name }}
|
||||||
|
{% if form.name.errors %}
|
||||||
|
{% for error in form.name.errors %}
|
||||||
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.name.help_text %}
|
||||||
|
<div class="form-text">{{ form.name.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<!-- Contact Person and Phone -->
|
||||||
<div class="col-md-12 mb-3">
|
<div class="row">
|
||||||
<label for="{{ form.name.id_for_label }}" class="form-label required-field">
|
<div class="col-md-6 mb-3">
|
||||||
{{ form.name.label }}
|
<label for="{{ form.contact_person.id_for_label }}" class="form-label">
|
||||||
</label>
|
{{ form.contact_person.label }}
|
||||||
{{ form.name }}
|
</label>
|
||||||
{% if form.name.errors %}
|
{{ form.contact_person }}
|
||||||
{% for error in form.name.errors %}
|
{% if form.contact_person.errors %}
|
||||||
<div class="invalid-feedback">{{ error }}</div>
|
{% for error in form.contact_person.errors %}
|
||||||
{% endfor %}
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
{% if form.name.help_text %}
|
{% endif %}
|
||||||
<div class="form-text">{{ form.name.help_text }}</div>
|
{% if form.contact_person.help_text %}
|
||||||
{% endif %}
|
<div class="form-text">{{ form.contact_person.help_text }}</div>
|
||||||
</div>
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="col-md-6 mb-3">
|
||||||
<div class="col-md-6 mb-3">
|
<label for="{{ form.phone.id_for_label }}" class="form-label">
|
||||||
<label for="{{ form.contact_person.id_for_label }}" class="form-label">
|
{{ form.phone.label }}
|
||||||
{{ form.contact_person.label }}
|
</label>
|
||||||
</label>
|
{{ form.phone }}
|
||||||
{{ form.contact_person }}
|
{% if form.phone.errors %}
|
||||||
{% if form.contact_person.errors %}
|
{% for error in form.phone.errors %}
|
||||||
{% for error in form.contact_person.errors %}
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
<div class="invalid-feedback">{{ error }}</div>
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
{% endif %}
|
{% if form.phone.help_text %}
|
||||||
{% if form.contact_person.help_text %}
|
<div class="form-text">{{ form.phone.help_text }}</div>
|
||||||
<div class="form-text">{{ form.contact_person.help_text }}</div>
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="{{ form.phone.id_for_label }}" class="form-label">
|
|
||||||
{{ form.phone.label }}
|
|
||||||
</label>
|
|
||||||
{{ form.phone }}
|
|
||||||
{% if form.phone.errors %}
|
|
||||||
{% for error in form.phone.errors %}
|
|
||||||
<div class="invalid-feedback">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% if form.phone.help_text %}
|
|
||||||
<div class="form-text">{{ form.phone.help_text }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Contact Information Section -->
|
<!-- Email and Website -->
|
||||||
<div class="form-section">
|
<div class="row">
|
||||||
<h5 class="mb-4">
|
<div class="col-md-6 mb-3">
|
||||||
<i class="fas fa-address-book section-icon"></i>
|
<label for="{{ form.email.id_for_label }}" class="form-label">
|
||||||
{% trans "Contact Information" %}
|
{{ form.email.label }}
|
||||||
</h5>
|
</label>
|
||||||
|
{{ form.email }}
|
||||||
<div class="row">
|
{% if form.email.errors %}
|
||||||
<div class="col-md-6 mb-3">
|
{% for error in form.email.errors %}
|
||||||
<label for="{{ form.email.id_for_label }}" class="form-label">
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
{{ form.email.label }}
|
{% endfor %}
|
||||||
</label>
|
{% endif %}
|
||||||
{{ form.email }}
|
{% if form.email.help_text %}
|
||||||
{% if form.email.errors %}
|
<div class="form-text">{{ form.email.help_text }}</div>
|
||||||
{% for error in form.email.errors %}
|
{% endif %}
|
||||||
<div class="invalid-feedback">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% if form.email.help_text %}
|
|
||||||
<div class="form-text">{{ form.email.help_text }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="{{ form.website.id_for_label }}" class="form-label">
|
|
||||||
{{ form.website.label }}
|
|
||||||
</label>
|
|
||||||
{{ form.website }}
|
|
||||||
{% if form.website.errors %}
|
|
||||||
{% for error in form.website.errors %}
|
|
||||||
<div class="invalid-feedback">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% if form.website.help_text %}
|
|
||||||
<div class="form-text">{{ form.website.help_text }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="col-md-6 mb-3">
|
||||||
<div class="col-md-12 mb-3">
|
<label for="{{ form.website.id_for_label }}" class="form-label">
|
||||||
<label for="{{ form.address.id_for_label }}" class="form-label">
|
{{ form.website.label }}
|
||||||
{{ form.address.label }}
|
</label>
|
||||||
</label>
|
{{ form.website }}
|
||||||
{{ form.address }}
|
{% if form.website.errors %}
|
||||||
{% if form.address.errors %}
|
{% for error in form.website.errors %}
|
||||||
{% for error in form.address.errors %}
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
<div class="invalid-feedback">{{ error }}</div>
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
{% endif %}
|
{% if form.website.help_text %}
|
||||||
{% if form.address.help_text %}
|
<div class="form-text">{{ form.website.help_text }}</div>
|
||||||
<div class="form-text">{{ form.address.help_text }}</div>
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Location Section -->
|
<!-- Address -->
|
||||||
<div class="form-section">
|
<div class="mb-3">
|
||||||
<h5 class="mb-4">
|
<label for="{{ form.address.id_for_label }}" class="form-label">
|
||||||
<i class="fas fa-globe section-icon"></i>
|
{{ form.address.label }}
|
||||||
{% trans "Location Information" %}
|
</label>
|
||||||
</h5>
|
{{ form.address }}
|
||||||
|
{% if form.address.errors %}
|
||||||
|
{% for error in form.address.errors %}
|
||||||
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if form.address.help_text %}
|
||||||
|
<div class="form-text">{{ form.address.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<!-- Country and City -->
|
||||||
<div class="col-md-6 mb-3">
|
<div class="row">
|
||||||
<label for="{{ form.country.id_for_label }}" class="form-label">
|
<div class="col-md-6 mb-3">
|
||||||
{{ form.country.label }}
|
<label for="{{ form.country.id_for_label }}" class="form-label">
|
||||||
</label>
|
{{ form.country.label }}
|
||||||
{{ form.country }}
|
</label>
|
||||||
{% if form.country.errors %}
|
{{ form.country }}
|
||||||
{% for error in form.country.errors %}
|
{% if form.country.errors %}
|
||||||
<div class="invalid-feedback">{{ error }}</div>
|
{% for error in form.country.errors %}
|
||||||
{% endfor %}
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
{% if form.country.help_text %}
|
{% endif %}
|
||||||
<div class="form-text">{{ form.country.help_text }}</div>
|
{% if form.country.help_text %}
|
||||||
{% endif %}
|
<div class="form-text">{{ form.country.help_text }}</div>
|
||||||
</div>
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label for="{{ form.city.id_for_label }}" class="form-label">
|
<label for="{{ form.city.id_for_label }}" class="form-label">
|
||||||
{{ form.city.label }}
|
{{ form.city.label }}
|
||||||
</label>
|
</label>
|
||||||
{{ form.city }}
|
{{ form.city }}
|
||||||
{% if form.city.errors %}
|
{% if form.city.errors %}
|
||||||
{% for error in form.city.errors %}
|
{% for error in form.city.errors %}
|
||||||
<div class="invalid-feedback">{{ error }}</div>
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if form.city.help_text %}
|
{% if form.city.help_text %}
|
||||||
<div class="form-text">{{ form.city.help_text }}</div>
|
<div class="form-text">{{ form.city.help_text }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Additional Information Section -->
|
<!-- Description -->
|
||||||
<div class="form-section">
|
<div class="mb-4">
|
||||||
<h5 class="mb-4">
|
<label for="{{ form.description.id_for_label }}" class="form-label">
|
||||||
<i class="fas fa-comment-dots section-icon"></i>
|
{{ form.description.label }}
|
||||||
{% trans "Additional Information" %}
|
</label>
|
||||||
</h5>
|
{{ form.description }}
|
||||||
|
{% if form.description.errors %}
|
||||||
<div class="row">
|
{% for error in form.description.errors %}
|
||||||
<div class="col-md-12 mb-3">
|
<div class="invalid-feedback d-block">{{ error }}</div>
|
||||||
<label for="{{ form.description.id_for_label }}" class="form-label">
|
{% endfor %}
|
||||||
{{ form.description.label }}
|
{% endif %}
|
||||||
</label>
|
{% if form.description.help_text %}
|
||||||
{{ form.description }}
|
<div class="form-text">{{ form.description.help_text }}</div>
|
||||||
{% if form.description.errors %}
|
{% endif %}
|
||||||
{% for error in form.description.errors %}
|
|
||||||
<div class="invalid-feedback">{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% if form.description.help_text %}
|
|
||||||
<div class="form-text">{{ form.description.help_text }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
<div class="d-flex justify-content-between">
|
||||||
<a href="{% url 'agency_list' %}" class="btn btn-secondary">
|
<a href="{% url 'agency_list' %}" class="btn btn-secondary">
|
||||||
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
|
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
|
||||||
</a>
|
</a>
|
||||||
<div>
|
<button type="submit" class="btn btn-main-action">
|
||||||
{% if agency %}
|
<i class="fas fa-save me-1"></i> {{ button_text }}
|
||||||
<button type="submit" class="btn btn-main-action">
|
</button>
|
||||||
<i class="fas fa-save me-1"></i> {{ button_text }}
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<button type="submit" class="btn btn-main-action">
|
|
||||||
<i class="fas fa-plus me-1"></i> {{ button_text }}
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card kaauh-card mb-4">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title" style="color: var(--kaauh-teal-dark);">
|
|
||||||
<i class="fas fa-info-circle me-2"></i>
|
|
||||||
{% trans "Quick Tips" %}
|
|
||||||
</h5>
|
|
||||||
<ul class="list-unstyled mb-0">
|
|
||||||
<li class="mb-2">
|
|
||||||
<i class="fas fa-check text-success me-2"></i>
|
|
||||||
{% trans "Provide accurate contact information for better communication" %}
|
|
||||||
</li>
|
|
||||||
<li class="mb-2">
|
|
||||||
<i class="fas fa-check text-success me-2"></i>
|
|
||||||
{% trans "Include a valid website URL if available" %}
|
|
||||||
</li>
|
|
||||||
<li class="mb-2">
|
|
||||||
<i class="fas fa-check text-success me-2"></i>
|
|
||||||
{% trans "Add a detailed description to help identify the agency" %}
|
|
||||||
</li>
|
|
||||||
<li class="mb-0">
|
|
||||||
<i class="fas fa-check text-success me-2"></i>
|
|
||||||
{% trans "All fields marked with * are required" %}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if agency %}
|
|
||||||
<div class="card kaauh-card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title" style="color: var(--kaauh-teal-dark);">
|
|
||||||
<i class="fas fa-history me-2"></i>
|
|
||||||
{% trans "Agency Information" %}
|
|
||||||
</h5>
|
|
||||||
<p class="mb-2">
|
|
||||||
<strong>{% trans "Created:" %}</strong><br>
|
|
||||||
{{ agency.created_at|date:"F d, Y" }}
|
|
||||||
</p>
|
|
||||||
<p class="mb-2">
|
|
||||||
<strong>{% trans "Last Updated:" %}</strong><br>
|
|
||||||
{{ agency.updated_at|date:"F d, Y" }}
|
|
||||||
</p>
|
|
||||||
<p class="mb-0">
|
|
||||||
<strong>{% trans "Slug:" %}</strong><br>
|
|
||||||
<code>{{ agency.slug }}</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -415,12 +212,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
formFields.forEach(function(field) {
|
formFields.forEach(function(field) {
|
||||||
field.classList.add('form-control');
|
field.classList.add('form-control');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add error classes to fields with errors
|
|
||||||
const errorFields = document.querySelectorAll('.is-invalid');
|
|
||||||
errorFields.forEach(function(field) {
|
|
||||||
field.classList.add('is-invalid');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{% extends 'portal_base.html' %}
|
{% extends 'portal_base.html' %}
|
||||||
{% load static i18n %}
|
{% load static i18n crispy_forms_tags %}
|
||||||
|
|
||||||
{% block title %}{% trans "Submit Candidate" %} - {{ assignment.job.title }} - Agency Portal{% endblock %}
|
{% block title %}{% trans "Submit Candidate" %} - {{ assignment.job.title }} - Agency Portal{% endblock %}
|
||||||
|
|
||||||
@ -177,173 +177,11 @@
|
|||||||
<form method="post" enctype="multipart/form-data" id="candidateForm"
|
<form method="post" enctype="multipart/form-data" id="candidateForm"
|
||||||
action="{% url 'agency_portal_submit_candidate_page' assignment.slug %}">
|
action="{% url 'agency_portal_submit_candidate_page' assignment.slug %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{{form|crispy}}
|
||||||
<!-- Personal Information -->
|
<button type="submit" class="btn btn-main-action">
|
||||||
<div class="row mb-4">
|
<i class="fas fa-user-plus me-2"></i>
|
||||||
<div class="col-12">
|
{% trans "Submit Candidate" %}
|
||||||
<h6 class="mb-3 text-muted">
|
</button>
|
||||||
<i class="fas fa-user me-1"></i>
|
|
||||||
{% trans "Personal Information" %}
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="first_name" class="form-label required-field">
|
|
||||||
{% trans "First Name" %}
|
|
||||||
</label>
|
|
||||||
<input type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="first_name"
|
|
||||||
name="first_name"
|
|
||||||
required
|
|
||||||
placeholder="{% trans 'Enter first name' %}">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="last_name" class="form-label required-field">
|
|
||||||
{% trans "Last Name" %}
|
|
||||||
</label>
|
|
||||||
<input type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="last_name"
|
|
||||||
name="last_name"
|
|
||||||
required
|
|
||||||
placeholder="{% trans 'Enter last name' %}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contact Information -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6 class="mb-3 text-muted">
|
|
||||||
<i class="fas fa-address-book me-1"></i>
|
|
||||||
{% trans "Contact Information" %}
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="email" class="form-label required-field">
|
|
||||||
{% trans "Email Address" %}
|
|
||||||
</label>
|
|
||||||
<input type="email"
|
|
||||||
class="form-control"
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
required
|
|
||||||
placeholder="{% trans 'Enter email address' %}">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label for="phone" class="form-label required-field">
|
|
||||||
{% trans "Phone Number" %}
|
|
||||||
</label>
|
|
||||||
<input type="tel"
|
|
||||||
class="form-control"
|
|
||||||
id="phone"
|
|
||||||
name="phone"
|
|
||||||
required
|
|
||||||
placeholder="{% trans 'Enter phone number' %}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Address Information -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6 class="mb-3 text-muted">
|
|
||||||
<i class="fas fa-map-marker-alt me-1"></i>
|
|
||||||
{% trans "Address Information" %}
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 mb-3">
|
|
||||||
<label for="address" class="form-label required-field">
|
|
||||||
{% trans "Full Address" %}
|
|
||||||
</label>
|
|
||||||
<textarea class="form-control"
|
|
||||||
id="address"
|
|
||||||
name="address"
|
|
||||||
rows="3"
|
|
||||||
required
|
|
||||||
placeholder="{% trans 'Enter full address' %}"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Resume Upload -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6 class="mb-3 text-muted">
|
|
||||||
<i class="fas fa-file-alt me-1"></i>
|
|
||||||
{% trans "Resume/CV" %}
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 mb-3">
|
|
||||||
<label for="resume" class="form-label required-field">
|
|
||||||
{% trans "Upload Resume" %}
|
|
||||||
</label>
|
|
||||||
<div class="file-upload-area" id="fileUploadArea">
|
|
||||||
<input type="file"
|
|
||||||
class="form-control d-none"
|
|
||||||
id="resume"
|
|
||||||
name="resume"
|
|
||||||
accept=".pdf,.doc,.docx"
|
|
||||||
required>
|
|
||||||
|
|
||||||
<div id="uploadPlaceholder">
|
|
||||||
<i class="fas fa-cloud-upload-alt fa-3x text-muted mb-3"></i>
|
|
||||||
<h6 class="text-muted">{% trans "Click to upload or drag and drop" %}</h6>
|
|
||||||
<p class="text-muted small">
|
|
||||||
{% trans "Accepted formats: PDF, DOC, DOCX (Maximum 5MB)" %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="filePreview" class="d-none">
|
|
||||||
<i class="fas fa-file-alt fa-3x text-success mb-3"></i>
|
|
||||||
<h6 id="fileName" class="text-success"></h6>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" id="removeFile">
|
|
||||||
<i class="fas fa-times me-1"></i>{% trans "Remove File" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Additional Notes -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6 class="mb-3 text-muted">
|
|
||||||
<i class="fas fa-sticky-note me-1"></i>
|
|
||||||
{% trans "Additional Notes" %}
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 mb-3">
|
|
||||||
<label for="notes" class="form-label">
|
|
||||||
{% trans "Notes (Optional)" %}
|
|
||||||
</label>
|
|
||||||
<textarea class="form-control"
|
|
||||||
id="notes"
|
|
||||||
name="notes"
|
|
||||||
rows="4"
|
|
||||||
placeholder="{% trans 'Any additional information about the candidate' %}"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form Actions -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<small class="text-muted">
|
|
||||||
<i class="fas fa-info-circle me-1"></i>
|
|
||||||
{% trans "Submitted candidates will be reviewed by the hiring team." %}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a href="{% url 'agency_assignment_detail' assignment.slug %}" class="btn btn-outline-secondary me-2">
|
|
||||||
{% trans "Cancel" %}
|
|
||||||
</a>
|
|
||||||
<button type="submit" class="btn btn-main-action" id="submitBtn">
|
|
||||||
<i class="fas fa-paper-plane me-1"></i>
|
|
||||||
{% trans "Submit Candidate" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
661
templates/recruitment/candidate_application_detail.html
Normal file
661
templates/recruitment/candidate_application_detail.html
Normal file
@ -0,0 +1,661 @@
|
|||||||
|
{% extends 'portal_base.html' %}
|
||||||
|
{% load static i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Application Details" %} - ATS{% endblock %}
|
||||||
|
|
||||||
|
{% block customCSS %}
|
||||||
|
<style>
|
||||||
|
/* Application Progress Timeline - Using Kaauh Theme Colors */
|
||||||
|
.application-progress {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--kaauh-border);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step:first-child::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.completed::before {
|
||||||
|
background: var(--kaauh-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.active::before {
|
||||||
|
background: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--kaauh-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 0.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #6c757d;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.completed .progress-icon {
|
||||||
|
background: var(--kaauh-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.active .progress-icon {
|
||||||
|
background: var(--kaauh-teal);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.completed .progress-label,
|
||||||
|
.progress-step.active .progress-label {
|
||||||
|
color: var(--kaauh-primary-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badges - Using Kaauh Theme */
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-applied { background: #e3f2fd; color: #1976d2; }
|
||||||
|
.status-screening { background: #fff3e0; color: #f57c00; }
|
||||||
|
.status-exam { background: #f3e5f5; color: #7b1fa2; }
|
||||||
|
.status-interview { background: #e8f5e8; color: #388e3c; }
|
||||||
|
.status-offer { background: #fff8e1; color: #f9a825; }
|
||||||
|
.status-hired { background: #e8f5e8; color: #2e7d32; }
|
||||||
|
.status-rejected { background: #ffebee; color: #c62828; }
|
||||||
|
|
||||||
|
/* AI Score Circle - Using Theme Colors */
|
||||||
|
.ai-score-circle {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-score-high { background: linear-gradient(135deg, var(--kaauh-success), #20c997); }
|
||||||
|
.ai-score-medium { background: linear-gradient(135deg, var(--kaauh-warning), #fd7e14); }
|
||||||
|
.ai-score-low { background: linear-gradient(135deg, var(--kaauh-danger), #e83e8c); }
|
||||||
|
|
||||||
|
/* Alert Purple - Using Theme Colors */
|
||||||
|
.alert-purple {
|
||||||
|
color: #4a148c;
|
||||||
|
background-color: #f3e5f5;
|
||||||
|
border-color: #ce93d8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Breadcrumb Navigation -->
|
||||||
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{% url 'candidate_portal_dashboard' %}">{% trans "Dashboard" %}</a>
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{% url 'candidate_portal_dashboard' %}#applications">{% trans "My Applications" %}</a>
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">
|
||||||
|
{% trans "Application Details" %}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Application Header with Progress -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="kaauh-card">
|
||||||
|
<div class="card-header bg-primary-theme text-white">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h4 class="mb-2">
|
||||||
|
<i class="fas fa-briefcase me-2"></i>
|
||||||
|
{{ application.job.title }}
|
||||||
|
</h4>
|
||||||
|
<p class="mb-0 opacity-75">
|
||||||
|
<small>{% trans "Application ID:" %} {{ application.slug }}</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<span class="status-badge status-{{ application.stage|lower }}">
|
||||||
|
{{ application.get_stage_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Application Progress Timeline -->
|
||||||
|
<div class="application-progress">
|
||||||
|
<!-- Applied Stage - Always shown -->
|
||||||
|
<div class="progress-step {% if application.stage != 'Applied' %}completed{% else %}active{% endif %}">
|
||||||
|
<div class="progress-icon">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
</div>
|
||||||
|
<div class="progress-label">{% trans "Applied" %}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screening Stage - Show if current stage is Screening or beyond -->
|
||||||
|
{% if application.stage in 'Screening,Exam,Interview,Offer,Hired,Rejected' %}
|
||||||
|
<div class="progress-step {% if application.stage not in 'Applied,Screening' %}completed{% elif application.stage == 'Screening' %}active{% endif %}">
|
||||||
|
<div class="progress-icon">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</div>
|
||||||
|
<div class="progress-label">{% trans "Screening" %}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Exam Stage - Show if current stage is Exam or beyond -->
|
||||||
|
{% if application.stage in 'Exam,Interview,Offer,Hired,Rejected' %}
|
||||||
|
<div class="progress-step {% if application.stage not in 'Applied,Screening,Exam' %}completed{% elif application.stage == 'Exam' %}active{% endif %}">
|
||||||
|
<div class="progress-icon">
|
||||||
|
<i class="fas fa-clipboard-check"></i>
|
||||||
|
</div>
|
||||||
|
<div class="progress-label">{% trans "Exam" %}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Interview Stage - Show if current stage is Interview or beyond -->
|
||||||
|
{% if application.stage in 'Interview,Offer,Hired,Rejected' %}
|
||||||
|
<div class="progress-step {% if application.stage not in 'Applied,Screening,Exam,Interview' %}completed{% elif application.stage == 'Interview' %}active{% endif %}">
|
||||||
|
<div class="progress-icon">
|
||||||
|
<i class="fas fa-video"></i>
|
||||||
|
</div>
|
||||||
|
<div class="progress-label">{% trans "Interview" %}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Offer Stage - Show if current stage is Offer or beyond -->
|
||||||
|
{% if application.stage in 'Offer,Hired,Rejected' %}
|
||||||
|
<div class="progress-step {% if application.stage not in 'Applied,Screening,Exam,Interview,Offer' %}completed{% elif application.stage == 'Offer' %}active{% endif %}">
|
||||||
|
<div class="progress-icon">
|
||||||
|
<i class="fas fa-handshake"></i>
|
||||||
|
</div>
|
||||||
|
<div class="progress-label">{% trans "Offer" %}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Hired Stage - Show only if current stage is Hired or Rejected -->
|
||||||
|
{% if application.stage in 'Hired,Rejected' %}
|
||||||
|
<div class="progress-step {% if application.stage == 'Hired' %}completed{% elif application.stage == 'Rejected' %}active{% endif %}">
|
||||||
|
<div class="progress-icon">
|
||||||
|
<i class="fas fa-trophy"></i>
|
||||||
|
</div>
|
||||||
|
<div class="progress-label">{% trans "Hired" %}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Application Details Grid -->
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-center p-3 bg-light rounded">
|
||||||
|
<i class="fas fa-calendar-alt text-primary-theme fa-2x mb-2"></i>
|
||||||
|
<h6 class="text-muted">{% trans "Applied Date" %}</h6>
|
||||||
|
<p class="mb-0 fw-bold">{{ application.created_at|date:"M d, Y" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-center p-3 bg-light rounded">
|
||||||
|
<i class="fas fa-building text-info fa-2x mb-2"></i>
|
||||||
|
<h6 class="text-muted">{% trans "Department" %}</h6>
|
||||||
|
<p class="mb-0 fw-bold">{{ application.job.department|default:"-" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-center p-3 bg-light rounded">
|
||||||
|
<i class="fas fa-briefcase text-success fa-2x mb-2"></i>
|
||||||
|
<h6 class="text-muted">{% trans "Job Type" %}</h6>
|
||||||
|
<p class="mb-0 fw-bold">{{ application.get_job_type_display }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-center p-3 bg-light rounded">
|
||||||
|
<i class="fas fa-map-marker-alt text-warning fa-2x mb-2"></i>
|
||||||
|
<h6 class="text-muted">{% trans "Location" %}</h6>
|
||||||
|
<p class="mb-0 fw-bold">{{ application.get_workplace_type_display }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Interview Details -->
|
||||||
|
{% if interviews %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="kaauh-card">
|
||||||
|
<div class="card-header bg-success text-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-video me-2"></i>
|
||||||
|
{% trans "Interview Schedule" %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if interviews %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Date" %}</th>
|
||||||
|
<th>{% trans "Time" %}</th>
|
||||||
|
<th>{% trans "Type" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
<th>{% trans "Meeting Link" %}</th>
|
||||||
|
<th>{% trans "Actions" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for interview in interviews %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ interview.interview_date|date:"M d, Y" }}</td>
|
||||||
|
<td>{{ interview.interview_time|time:"H:i" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if interview.zoom_meeting %}
|
||||||
|
<span class="badge bg-primary">
|
||||||
|
<i class="fas fa-laptop me-1"></i>
|
||||||
|
{% trans "Remote" %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
<i class="fas fa-building me-1"></i>
|
||||||
|
{% trans "On-site" %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ interview.status|lower }} text-white">
|
||||||
|
{{ interview.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if interview.zoom_meeting and interview.zoom_meeting.join_url %}
|
||||||
|
<a href="{{ interview.zoom_meeting.join_url }}"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-sm btn-primary">
|
||||||
|
<i class="fas fa-video me-1"></i>
|
||||||
|
{% trans "Join" %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if interview.zoom_meeting and interview.zoom_meeting.join_url %}
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="addToCalendar({{ interview.interview_date|date:'Y' }}, {{ interview.interview_date|date:'m' }}, {{ interview.interview_date|date:'d' }}, '{{ interview.interview_time|time:'H:i' }}', '{{ application.job.title }}')">
|
||||||
|
<i class="fas fa-calendar-plus me-1"></i>
|
||||||
|
{% trans "Add to Calendar" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<i class="fas fa-calendar-times fa-3x text-muted mb-3"></i>
|
||||||
|
<p class="text-muted">{% trans "No interviews scheduled yet." %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Documents Section -->
|
||||||
|
{% if application.stage == "Document Review" %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="kaauh-card">
|
||||||
|
<div class="card-header bg-primary-theme text-white">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-file-alt me-2"></i>
|
||||||
|
{% trans "Documents" %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-sm btn-light" data-bs-toggle="modal" data-bs-target="#uploadDocumentModal">
|
||||||
|
<i class="fas fa-plus me-1"></i>
|
||||||
|
{% trans "Upload Document" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if documents %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Document Name" %}</th>
|
||||||
|
<th>{% trans "Type" %}</th>
|
||||||
|
<th>{% trans "Upload Date" %}</th>
|
||||||
|
<th>{% trans "File Size" %}</th>
|
||||||
|
<th>{% trans "Actions" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for document in documents %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if document.file %}
|
||||||
|
<a href="{{ document.file.url }}"
|
||||||
|
target="_blank"
|
||||||
|
class="text-decoration-none">
|
||||||
|
<i class="fas fa-file-pdf text-danger me-2"></i>
|
||||||
|
{{ document.get_document_type_display }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
{{ document.get_document_type_display }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-light text-dark">
|
||||||
|
{{ document.get_document_type_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ document.created_at|date:"M d, Y" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if document.file %}
|
||||||
|
{% with file_size=document.file.size|filesizeformat %}
|
||||||
|
{{ file_size }}
|
||||||
|
{% endwith %}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if document.file %}
|
||||||
|
<a href="{{ document.file.url }}"
|
||||||
|
class="btn btn-sm btn-outline-primary me-1"
|
||||||
|
target="_blank">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
{% trans "Download" %}
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteDocument({{ document.id }})">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<i class="fas fa-file-upload fa-3x text-muted mb-3"></i>
|
||||||
|
<p class="text-muted">{% trans "No documents uploaded." %}</p>
|
||||||
|
<button class="btn btn-main-action" data-bs-toggle="modal" data-bs-target="#uploadDocumentModal">
|
||||||
|
<i class="fas fa-plus me-2"></i>
|
||||||
|
{% trans "Upload Your First Document" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Action Cards -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="kaauh-card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-arrow-left fa-2x text-primary-theme mb-3"></i>
|
||||||
|
<h6>{% trans "Back to Dashboard" %}</h6>
|
||||||
|
<p class="text-muted small">{% trans "View all your applications" %}</p>
|
||||||
|
<a href="{% url 'candidate_portal_dashboard' %}" class="btn btn-main-action w-100">
|
||||||
|
{% trans "Go to Dashboard" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if application.resume %}
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="kaauh-card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-download fa-2x text-success mb-3"></i>
|
||||||
|
<h6>{% trans "Download Resume" %}</h6>
|
||||||
|
<p class="text-muted small">{% trans "Get your submitted resume" %}</p>
|
||||||
|
<a href="{{ application.resume.url }}"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-main-action w-100">
|
||||||
|
<i class="fas fa-download me-2"></i>
|
||||||
|
{% trans "Download" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="kaauh-card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-print fa-2x text-info mb-3"></i>
|
||||||
|
<h6>{% trans "Print Application" %}</h6>
|
||||||
|
<p class="text-muted small">{% trans "Get a printable version" %}</p>
|
||||||
|
<button class="btn btn-main-action w-100" onclick="window.print()">
|
||||||
|
<i class="fas fa-print me-2"></i>
|
||||||
|
{% trans "Print" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %} <div class="col-md-3 mb-3">
|
||||||
|
<div class="kaauh-card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-edit fa-2x text-warning mb-3"></i>
|
||||||
|
<h6>{% trans "Update Profile" %}</h6>
|
||||||
|
<p class="text-muted small">{% trans "Edit your personal information" %}</p>
|
||||||
|
<a href="" class="btn btn-main-action w-100">
|
||||||
|
<i class="fas fa-edit me-2"></i>
|
||||||
|
{% trans "Update" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> {% endcomment %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next Steps Section -->
|
||||||
|
{% comment %} <div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="kaauh-card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
{% trans "Next Steps" %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if application.stage == 'Applied' %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fas fa-clock me-2"></i>
|
||||||
|
{% trans "Your application is being reviewed by our recruitment team. You will receive an update within 3-5 business days." %}
|
||||||
|
</div>
|
||||||
|
{% elif application.stage == 'Screening' %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="fas fa-search me-2"></i>
|
||||||
|
{% trans "Your application is currently under screening. We are evaluating your qualifications against the job requirements." %}
|
||||||
|
</div>
|
||||||
|
{% elif application.stage == 'Exam' %}
|
||||||
|
<div class="alert alert-purple">
|
||||||
|
<i class="fas fa-clipboard-check me-2"></i>
|
||||||
|
{% trans "You have been shortlisted for an assessment. Please check your email for exam details and preparation materials." %}
|
||||||
|
</div>
|
||||||
|
{% elif application.stage == 'Interview' %}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<i class="fas fa-video me-2"></i>
|
||||||
|
{% trans "Congratulations! You have been selected for an interview. Please check the interview schedule above and prepare accordingly." %}
|
||||||
|
</div>
|
||||||
|
{% elif application.stage == 'Offer' %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="fas fa-handshake me-2"></i>
|
||||||
|
{% trans "You have received a job offer! Please check your email for the detailed offer letter and next steps." %}
|
||||||
|
</div>
|
||||||
|
{% elif application.stage == 'Hired' %}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<i class="fas fa-trophy me-2"></i>
|
||||||
|
{% trans "Welcome to the team! You will receive onboarding information shortly." %}
|
||||||
|
</div>
|
||||||
|
{% elif application.stage == 'Rejected' %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<i class="fas fa-times-circle me-2"></i>
|
||||||
|
{% trans "Thank you for your interest. Unfortunately, your application was not selected at this time. We encourage you to apply for other positions." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> {% endcomment %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Document Modal -->
|
||||||
|
<div class="modal fade" id="uploadDocumentModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{% trans "Upload Document" %}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form action="{% url 'document_upload' application.slug %}" method="POST" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="documentType" class="form-label">{% trans "Document Type" %}</label>
|
||||||
|
<select class="form-select" id="documentType" name="document_type" required>
|
||||||
|
<option value="">{% trans "Select document type" %}</option>
|
||||||
|
<option value="resume">{% trans "Resume" %}</option>
|
||||||
|
<option value="cover_letter">{% trans "Cover Letter" %}</option>
|
||||||
|
<option value="transcript">{% trans "Academic Transcript" %}</option>
|
||||||
|
<option value="certificate">{% trans "Certificate" %}</option>
|
||||||
|
<option value="portfolio">{% trans "Portfolio" %}</option>
|
||||||
|
<option value="other">{% trans "Other" %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="documentDescription" class="form-label">{% trans "Description" %}</label>
|
||||||
|
<textarea class="form-control" id="documentDescription" name="description" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="documentFile" class="form-label">{% trans "Choose File" %}</label>
|
||||||
|
<input type="file" class="form-control" id="documentFile" name="file" accept=".pdf,.doc,.docx,.jpg,.png" required>
|
||||||
|
<div class="form-text">{% trans "Accepted formats: PDF, DOC, DOCX, JPG, PNG (Max 5MB)" %}</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="application_slug" value="{{ application.slug }}">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||||
|
<button type="submit" class="btn btn-main-action">
|
||||||
|
<i class="fas fa-upload me-2"></i>
|
||||||
|
{% trans "Upload" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function addToCalendar(year, month, day, time, title) {
|
||||||
|
// Create Google Calendar URL
|
||||||
|
const startDate = new Date(year, month - 1, day, time.split(':')[0], time.split(':')[1]);
|
||||||
|
const endDate = new Date(startDate.getTime() + 60 * 60 * 1000); // Add 1 hour
|
||||||
|
|
||||||
|
const formatDate = (date) => {
|
||||||
|
return date.toISOString().replace(/-|:|\.\d\d\d/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const googleCalendarUrl = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(title)}&dates=${formatDate(startDate)}/${formatDate(endDate)}&details=${encodeURIComponent('Interview scheduled via ATS')}`;
|
||||||
|
|
||||||
|
window.open(googleCalendarUrl, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDocument(documentId) {
|
||||||
|
if (confirm('{% trans "Are you sure you want to delete this document?" %}')) {
|
||||||
|
fetch(`/documents/${documentId}/delete/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCookie('csrftoken'),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('{% trans "Error deleting document. Please try again." %}');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('{% trans "Error deleting document. Please try again." %}');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get CSRF token
|
||||||
|
function getCookie(name) {
|
||||||
|
let cookieValue = null;
|
||||||
|
if (document.cookie && document.cookie !== '') {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i].trim();
|
||||||
|
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookieValue;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock content %}
|
||||||
262
templates/recruitment/candidate_document_management.html
Normal file
262
templates/recruitment/candidate_document_management.html
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
{% extends "portal_base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Document Management{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Document Management</h1>
|
||||||
|
<button onclick="showUploadModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors">
|
||||||
|
<i class="fas fa-upload mr-2"></i>
|
||||||
|
Upload Document
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Documents Table -->
|
||||||
|
<div class="bg-white shadow rounded-lg overflow-hidden">
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Document Type
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Description
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
File Name
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Size
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Uploaded
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
{% for document in documents %}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
{{ document.get_document_type_display }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{{ document.description|default:"-" }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{% if document.file %}
|
||||||
|
<a href="{{ document.file.url }}" target="_blank" class="text-blue-600 hover:text-blue-800 underline">
|
||||||
|
{{ document.file.name|truncatechars:30 }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{% if document.file %}
|
||||||
|
{{ document.file.size|filesizeformat }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{{ document.created_at|date:"M d, Y" }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
{% if document.file %}
|
||||||
|
<a href="{% url 'candidate_document_download' document.id %}"
|
||||||
|
class="text-green-600 hover:text-green-800 mr-3"
|
||||||
|
title="Download document">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="deleteDocument({{ document.id }})"
|
||||||
|
class="text-red-600 hover:text-red-800"
|
||||||
|
title="Delete document">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-6 py-4 text-center text-gray-500">
|
||||||
|
No documents uploaded yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Modal -->
|
||||||
|
<div id="uploadModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full z-50 hidden">
|
||||||
|
<div class="flex items-center justify-center min-h-screen px-4">
|
||||||
|
<div class="relative bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-lg sm:w-full">
|
||||||
|
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
Upload New Document
|
||||||
|
</h3>
|
||||||
|
<button onclick="hideUploadModal()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="uploadForm" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="document_type" class="block text-sm font-medium text-gray-700">Document Type</label>
|
||||||
|
<select id="document_type" name="document_type" required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||||
|
<option value="resume">Resume</option>
|
||||||
|
<option value="cover_letter">Cover Letter</option>
|
||||||
|
<option value="transcript">Transcript</option>
|
||||||
|
<option value="certificate">Certificate</option>
|
||||||
|
<option value="id_document">ID Document</option>
|
||||||
|
<option value="portfolio">Portfolio</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="description" class="block text-sm font-medium text-gray-700">Description</label>
|
||||||
|
<textarea id="description" name="description" rows="3"
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
placeholder="Optional description of the document"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="file" class="block text-sm font-medium text-gray-700">File</label>
|
||||||
|
<input type="file" id="file" name="file" required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
accept=".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
|
<button type="button" onclick="hideUploadModal()"
|
||||||
|
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm">
|
||||||
|
Upload Document
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showUploadModal() {
|
||||||
|
document.getElementById('uploadModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideUploadModal() {
|
||||||
|
document.getElementById('uploadModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDocument(documentId) {
|
||||||
|
if (confirm('Are you sure you want to delete this document?')) {
|
||||||
|
fetch(`/candidate/documents/${documentId}/delete/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCookie('csrftoken'),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Remove the row from table
|
||||||
|
const row = event.target.closest('tr');
|
||||||
|
row.remove();
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showNotification('Document deleted successfully!', 'success');
|
||||||
|
} else {
|
||||||
|
showNotification(data.error || 'Failed to delete document', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showNotification('An error occurred while deleting the document', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
document.getElementById('uploadForm').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const applicationId = '{{ application.id }}';
|
||||||
|
|
||||||
|
fetch(`/candidate/documents/upload/{{ application.slug }}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCookie('csrftoken'),
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
hideUploadModal();
|
||||||
|
// Reload the page to show the new document
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
showNotification(data.error || 'Failed to upload document', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showNotification('An error occurred while uploading the document', 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
const value = `; ${document.cookie}`.match(`;\\s*${name}=([^;]*)`);
|
||||||
|
return value ? value[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotification(message, type) {
|
||||||
|
// Create notification element
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `fixed top-4 right-4 p-4 rounded-lg shadow-lg z-50 ${
|
||||||
|
type === 'success' ? 'bg-green-100 border-green-400 text-green-700' : 'bg-red-100 border-red-400 text-red-700'
|
||||||
|
}`;
|
||||||
|
notification.innerHTML = `
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
${type === 'success' ?
|
||||||
|
'<svg class="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 00-8 8 0 018 0zm-3.707-9.293a1 1 0 00-1.414 1.414L10 10.586 3.414a1 1 0 01.414-1.414l-4.293-4.293a1 1 0 00-1.414 1.414L10 14.172a1 1 0 01.414-1.414l4.293-4.293a1 1 0 00-1.414 1.414L10 5.828a1 1 0 01.414-1.414z" clip-rule="evenodd"></path></svg>' :
|
||||||
|
'<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 00-8 8 0 018 0zm-3.707-9.293a1 1 0 00-1.414 1.414L10 10.586 3.414a1 1 0 01.414-1.414l-4.293-4.293a1 1 0 00-1.414 1.414L10 14.172a1 1 0 01.414-1.414l4.293-4.293a1 1 0 00-1.414 1.414z" clip-rule="evenodd"></path></svg>'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium">${message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
// Remove after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
494
templates/recruitment/candidate_document_review_view.html
Normal file
494
templates/recruitment/candidate_document_review_view.html
Normal file
@ -0,0 +1,494 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static i18n %}
|
||||||
|
|
||||||
|
{% block title %}Document Review - {{ job.title }} - University ATS{% endblock %}
|
||||||
|
{% block customCSS %}
|
||||||
|
<style>
|
||||||
|
/* KAAT-S UI Variables */
|
||||||
|
:root {
|
||||||
|
--kaauh-teal: #00636e;
|
||||||
|
--kaauh-teal-dark: #004a53;
|
||||||
|
--kaauh-border: #eaeff3;
|
||||||
|
--kaauh-primary-text: #343a40;
|
||||||
|
--kaauh-success: #28a745;
|
||||||
|
--kaauh-info: #17a2b8;
|
||||||
|
--kaauh-danger: #dc3545;
|
||||||
|
--kaauh-warning: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary Color Overrides */
|
||||||
|
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
||||||
|
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
|
||||||
|
|
||||||
|
/* 1. Main Container & Card Styling */
|
||||||
|
.kaauh-card {
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dedicated style for filter block */
|
||||||
|
.filter-controls {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. Button Styling (Themed for Main Actions) */
|
||||||
|
.btn-main-action {
|
||||||
|
background-color: var(--kaauh-teal);
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.btn-main-action:hover {
|
||||||
|
background-color: var(--kaauh-teal-dark);
|
||||||
|
border-color: var(--kaauh-teal-dark);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.btn-outline-secondary {
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
.btn-outline-secondary:hover {
|
||||||
|
background-color: var(--kaauh-teal-dark);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--kaauh-teal-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. Candidate Table Styling (Aligned with KAAT-S) */
|
||||||
|
.candidate-table {
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.candidate-table thead {
|
||||||
|
background-color: var(--kaauh-border);
|
||||||
|
}
|
||||||
|
.candidate-table th {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
border-bottom: 2px solid var(--kaauh-teal);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.candidate-table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--kaauh-border);
|
||||||
|
vertical-align: middle;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.candidate-table tbody tr:hover {
|
||||||
|
background-color: #f1f3f4;
|
||||||
|
}
|
||||||
|
.candidate-table thead th:nth-child(1) { width: 40px; }
|
||||||
|
.candidate-table thead th:nth-child(4) { width: 10%; }
|
||||||
|
.candidate-table thead th:nth-child(7) { width: 100px; }
|
||||||
|
|
||||||
|
.candidate-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--kaauh-primary-text);
|
||||||
|
}
|
||||||
|
.candidate-details {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. Badges and Statuses */
|
||||||
|
.ai-score-badge {
|
||||||
|
background-color: var(--kaauh-teal-dark) !important;
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.3em 0.7em;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.bg-applicant { background-color: #6c757d !important; color: white; }
|
||||||
|
.bg-candidate { background-color: var(--kaauh-success) !important; color: white; }
|
||||||
|
|
||||||
|
/* Stage Badges */
|
||||||
|
.stage-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.stage-Applied { background-color: #e9ecef; color: #495057; }
|
||||||
|
.stage-Screening { background-color: var(--kaauh-info); color: white; }
|
||||||
|
.stage-Exam { background-color: var(--kaauh-warning); color: #856404; }
|
||||||
|
.stage-Interview { background-color: #17a2b8; color: white; }
|
||||||
|
.stage-Offer { background-color: var(--kaauh-success); color: white; }
|
||||||
|
|
||||||
|
/* Timeline specific container */
|
||||||
|
.applicant-tracking-timeline {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Document specific styles */
|
||||||
|
.document-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.document-item {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.document-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.document-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.document-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--kaauh-primary-text);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.document-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
.document-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.no-documents {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- CUSTOM HEIGHT OPTIMIZATION (MAKING INPUTS/BUTTONS SMALLER) --- */
|
||||||
|
.form-control-sm,
|
||||||
|
.btn-sm {
|
||||||
|
/* Reduce vertical padding even more than default Bootstrap 'sm' */
|
||||||
|
padding-top: 0.2rem !important;
|
||||||
|
padding-bottom: 0.2rem !important;
|
||||||
|
/* Ensure a consistent, small height for both */
|
||||||
|
height: 28px !important;
|
||||||
|
font-size: 0.8rem !important; /* Slightly smaller font */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1 page-header">
|
||||||
|
<i class="fas fa-file-alt me-2"></i>
|
||||||
|
{% trans "Document Review" %}
|
||||||
|
</h1>
|
||||||
|
<h2 class="h5 text-muted mb-0">
|
||||||
|
{% trans "Job:" %} {{ job.title }}
|
||||||
|
<span class="badge bg-secondary ms-2 fw-normal">{{ job.internal_job_id }}</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{% url 'export_candidates_csv' job.slug 'document_review' %}"
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
title="{% trans 'Export document review candidates to CSV' %}">
|
||||||
|
<i class="fas fa-download me-1"></i> {% trans "Export CSV" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="applicant-tracking-timeline mb-4">
|
||||||
|
{% include 'jobs/partials/applicant_tracking.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search and Filter Controls -->
|
||||||
|
<div class="filter-controls">
|
||||||
|
<h4 class="h6 mb-3 fw-bold">
|
||||||
|
<i class="fas fa-search me-1"></i> {% trans "Search Candidates" %}
|
||||||
|
</h4>
|
||||||
|
<form method="GET" class="mb-0">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-auto">
|
||||||
|
<input type="text"
|
||||||
|
name="q"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
value="{{ search_query }}"
|
||||||
|
placeholder="{% trans 'Search by name, email...' %}"
|
||||||
|
style="min-width: 250px;">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-main-action btn-sm">
|
||||||
|
<i class="fas fa-search me-1"></i> {% trans "Search" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="h4 mb-3" style="color: var(--kaauh-primary-text);">
|
||||||
|
<i class="fas fa-users me-1"></i> {% trans "Candidates Ready for Document Review" %}
|
||||||
|
<span class="badge bg-primary-theme ms-2">{{ candidates|length }}</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="kaauh-card p-3">
|
||||||
|
{% if candidates %}
|
||||||
|
<div class="bulk-action-bar p-3 bg-light border-bottom">
|
||||||
|
{# Use d-flex to align the entire contents (two forms and the separator) horizontally #}
|
||||||
|
<div class="d-flex align-items-end gap-3">
|
||||||
|
|
||||||
|
{# Form 1: Status Update #}
|
||||||
|
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post" class="d-flex align-items-end gap-2 action-group">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{# Select Input Group - No label needed for this one, so we just flex the select and button #}
|
||||||
|
|
||||||
|
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="width: 120px;">
|
||||||
|
<option selected>
|
||||||
|
----------
|
||||||
|
</option>
|
||||||
|
<option value="Interview">
|
||||||
|
{% trans "To Interview" %}
|
||||||
|
</option>
|
||||||
|
<option value="Offer">
|
||||||
|
{% trans "To Offer" %}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-main-action btn-sm">
|
||||||
|
<i class="fas fa-arrow-right me-1"></i> {% trans "Change Stage" %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# Separator (Vertical Rule) - Aligns automatically at the bottom with align-items-end #}
|
||||||
|
<div class="vr" style="height: 28px;"></div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-outline-info btn-sm"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
hx-boost='true'
|
||||||
|
data-bs-target="#emailModal"
|
||||||
|
hx-get="{% url 'compose_candidate_email' job.slug %}"
|
||||||
|
hx-target="#emailModalBody"
|
||||||
|
hx-include="#candidate-form"
|
||||||
|
title="Email Participants">
|
||||||
|
<i class="fas fa-envelope"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<form id="candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="get">
|
||||||
|
<table class="table candidate-table align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="width: 2%;">
|
||||||
|
{% if candidates %}
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="selectAllCheckbox">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
|
<th scope="col" style="width: 20%;">
|
||||||
|
<i class="fas fa-user me-1"></i> {% trans "Name" %}
|
||||||
|
</th>
|
||||||
|
<th scope="col" style="width: 15%;">
|
||||||
|
<i class="fas fa-envelope me-1"></i> {% trans "Contact Info" %}
|
||||||
|
</th>
|
||||||
|
<th scope="col" style="width: 15%;">
|
||||||
|
<i class="fas fa-briefcase me-1"></i> {% trans "Current Stage" %}
|
||||||
|
</th>
|
||||||
|
<th scope="col" style="width: 28%;">
|
||||||
|
<i class="fas fa-file-alt me-1"></i> {% trans "Documents" %}
|
||||||
|
</th>
|
||||||
|
<th scope="col" style="width: 10%;">
|
||||||
|
<i class="fas fa-cog me-1"></i> {% trans "Actions" %}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for candidate in candidates %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="form-check">
|
||||||
|
<input name="candidate_ids" value="{{ candidate.id }}" type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}">
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="candidate-name">
|
||||||
|
{{ candidate.person.first_name }} {{ candidate.person.last_name }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="candidate-details">
|
||||||
|
<i class="fas fa-envelope me-1"></i> {{ candidate.person.email }}<br>
|
||||||
|
<i class="fas fa-phone me-1"></i> {{ candidate.person.phone|default:"--" }}
|
||||||
|
</div></td>
|
||||||
|
<td>
|
||||||
|
<span class="stage-badge stage-Interview">
|
||||||
|
{% trans "Interview Completed" %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% with documents=candidate.documents.all %}
|
||||||
|
{% if documents %}
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for document in documents %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="document-name">
|
||||||
|
<i class="fas fa-file me-1"></i>
|
||||||
|
{{ document.get_document_type_display }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<small class="text-muted">
|
||||||
|
{% trans "Uploaded" %} {{ document.created_at|date:"M d, Y" }}
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="document-actions">
|
||||||
|
<a href="{{ document.file.url }}"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
title="{% trans 'Download document' %}">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="no-documents">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||||
|
{% trans "No documents uploaded" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-main-action"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#documentModal"
|
||||||
|
hx-get="{% url 'candidate_application_detail' candidate.slug %}"
|
||||||
|
hx-target="#documentModalBody"
|
||||||
|
title="{% trans 'View Candidate Details' %}">
|
||||||
|
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info text-center" role="alert">
|
||||||
|
<i class="fas fa-info-circle me-1"></i>
|
||||||
|
{% trans "No candidates are currently ready for document review." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for viewing candidate details -->
|
||||||
|
<div class="modal fade modal-xl" id="documentModal" tabindex="-1" aria-labelledby="documentModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content kaauh-card">
|
||||||
|
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||||
|
<h5 class="modal-title" id="documentModalLabel" style="color: var(--kaauh-teal-dark);">
|
||||||
|
{% trans "Candidate Details" %}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div id="documentModalBody" class="modal-body">
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||||
|
{% trans "Loading candidate details..." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="border-top: 1px solid var(--kaauh-border);">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
|
||||||
|
{% trans "Close" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block customJS %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||||
|
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
|
||||||
|
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
// Function to safely update header checkbox state
|
||||||
|
function updateSelectAllState() {
|
||||||
|
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
|
||||||
|
const totalCount = rowCheckboxes.length;
|
||||||
|
|
||||||
|
if (checkedCount === 0) {
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
} else if (checkedCount === totalCount) {
|
||||||
|
selectAllCheckbox.checked = true;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
} else {
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Logic for 'Select All' checkbox (Clicking it updates all rows)
|
||||||
|
selectAllCheckbox.addEventListener('change', function () {
|
||||||
|
const isChecked = selectAllCheckbox.checked;
|
||||||
|
|
||||||
|
rowCheckboxes.forEach(checkbox => {
|
||||||
|
checkbox.checked = isChecked;
|
||||||
|
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSelectAllState();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Logic to update 'Select All' state based on row checkboxes
|
||||||
|
rowCheckboxes.forEach(function (checkbox) {
|
||||||
|
checkbox.addEventListener('change', updateSelectAllState);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial check to set correct state on load
|
||||||
|
updateSelectAllState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -262,6 +262,7 @@
|
|||||||
<th style="width: 15%;">{% trans "Contact Info" %}</th>
|
<th style="width: 15%;">{% trans "Contact Info" %}</th>
|
||||||
<th style="width: 10%;" class="text-center">{% trans "AI Score" %}</th>
|
<th style="width: 10%;" class="text-center">{% trans "AI Score" %}</th>
|
||||||
<th style="width: 15%;">{% trans "Exam Date" %}</th>
|
<th style="width: 15%;">{% trans "Exam Date" %}</th>
|
||||||
|
<th style="width: 15%;">{% trans "Exam Score" %}</th>
|
||||||
<th style="width: 10%;" class="text-center">{% trans "Exam Results" %}</th>
|
<th style="width: 10%;" class="text-center">{% trans "Exam Results" %}</th>
|
||||||
<th style="width: 15%;">{% trans "Actions" %}</th>
|
<th style="width: 15%;">{% trans "Actions" %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -294,6 +295,9 @@
|
|||||||
<td>
|
<td>
|
||||||
{{candidate.exam_date|date:"d-m-Y h:i A"|default:"--"}}
|
{{candidate.exam_date|date:"d-m-Y h:i A"|default:"--"}}
|
||||||
</td>
|
</td>
|
||||||
|
<td id="exam-score-{{ candidate.pk}}">
|
||||||
|
{{candidate.exam_score|default:"--"}}
|
||||||
|
</td>
|
||||||
|
|
||||||
<td class="text-center" id="status-result-{{ candidate.pk}}">
|
<td class="text-center" id="status-result-{{ candidate.pk}}">
|
||||||
{% if not candidate.exam_status %}
|
{% if not candidate.exam_status %}
|
||||||
|
|||||||
@ -211,6 +211,9 @@
|
|||||||
<option selected>
|
<option selected>
|
||||||
----------
|
----------
|
||||||
</option>
|
</option>
|
||||||
|
<option value="Document Review">
|
||||||
|
{% trans "To Documents Review" %}
|
||||||
|
</option>
|
||||||
<option value="Offer">
|
<option value="Offer">
|
||||||
{% trans "To Offer" %}
|
{% trans "To Offer" %}
|
||||||
</option>
|
</option>
|
||||||
@ -282,7 +285,6 @@
|
|||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input name="candidate_ids" value="{{ candidate.id }}" type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}">
|
<input name="candidate_ids" value="{{ candidate.id }}" type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
@ -380,10 +382,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
<<<<<<< HEAD
|
||||||
|
|
||||||
{% if candidate.get_latest_meeting %}
|
{% if candidate.get_latest_meeting %}
|
||||||
{% if candidate.get_latest_meeting.location_type == 'Remote'%}
|
{% if candidate.get_latest_meeting.location_type == 'Remote'%}
|
||||||
|
|
||||||
|
=======
|
||||||
|
|
||||||
|
{% if candidate.get_latest_meeting %}
|
||||||
|
>>>>>>> 1babb1be63436083b4a5ec7d76c115350b0c9f4a
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#candidateviewModal"
|
data-bs-target="#candidateviewModal"
|
||||||
@ -401,6 +408,7 @@
|
|||||||
title="Delete Meeting">
|
title="Delete Meeting">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<<<<<<< HEAD
|
||||||
{% else%}
|
{% else%}
|
||||||
|
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
@ -423,6 +431,9 @@
|
|||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
=======
|
||||||
|
|
||||||
|
>>>>>>> 1babb1be63436083b4a5ec7d76c115350b0c9f4a
|
||||||
{% else %}
|
{% else %}
|
||||||
<button type="button" class="btn btn-main-action btn-sm"
|
<button type="button" class="btn btn-main-action btn-sm"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
|
|||||||
@ -212,6 +212,9 @@
|
|||||||
<option value="Hired">
|
<option value="Hired">
|
||||||
{% trans "To Hired" %}
|
{% trans "To Hired" %}
|
||||||
</option>
|
</option>
|
||||||
|
<option value="Document Review">
|
||||||
|
{% trans "To Documents Review" %}
|
||||||
|
</option>
|
||||||
<option value="Interview">
|
<option value="Interview">
|
||||||
{% trans "To Interview" %}
|
{% trans "To Interview" %}
|
||||||
</option>
|
</option>
|
||||||
@ -260,7 +263,10 @@
|
|||||||
<th style="width: 15%"><i class="fas fa-user me-1"></i> {% trans "Name" %}</th>
|
<th style="width: 15%"><i class="fas fa-user me-1"></i> {% trans "Name" %}</th>
|
||||||
<th style="width: 15%"><i class="fas fa-phone me-1"></i> {% trans "Contact" %}</th>
|
<th style="width: 15%"><i class="fas fa-phone me-1"></i> {% trans "Contact" %}</th>
|
||||||
<th class="text-center" style="width: 10%"><i class="fas fa-check-circle me-1"></i> {% trans "Offer" %}</th>
|
<th class="text-center" style="width: 10%"><i class="fas fa-check-circle me-1"></i> {% trans "Offer" %}</th>
|
||||||
<th style="width: 15%"><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
|
<th scope="col" style="width: 30%;">
|
||||||
|
<i class="fas fa-file-alt me-1"></i> {% trans "Documents" %}
|
||||||
|
</th>
|
||||||
|
<th style="width: 5%"><i class="fas fa-cog me-1"></i> {% trans "Actions" %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -307,6 +313,50 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% with documents=candidate.documents.all %}
|
||||||
|
{% if documents %}
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for document in documents %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="document-name">
|
||||||
|
<i class="fas fa-file me-1"></i>
|
||||||
|
{{ document.get_document_type_display }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<small class="text-muted">
|
||||||
|
{% trans "Uploaded" %} {{ document.created_at|date:"M d, Y" }}
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="document-actions">
|
||||||
|
<a href="{{ document.file.url }}"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
title="{% trans 'Download document' %}">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="no-documents">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||||
|
{% trans "No documents uploaded" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
|
|||||||
@ -132,6 +132,87 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Applications Section -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-briefcase me-2"></i>
|
||||||
|
{% trans "My Applications" %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if applications %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Job Title" %}</th>
|
||||||
|
<th>{% trans "Department" %}</th>
|
||||||
|
<th>{% trans "Applied Date" %}</th>
|
||||||
|
<th>{% trans "Current Stage" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
<th>{% trans "Actions" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for application in applications %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ application.job.title }}</strong>
|
||||||
|
{% if application.job.department %}
|
||||||
|
<br><small class="text-muted">{{ application.job.department }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ application.job.department|default:"-" }}</td>
|
||||||
|
<td>{{ application.created_at|date:"M d, Y" }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ application.stage|lower }} text-white">
|
||||||
|
{{ application.get_stage_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if application.stage == "Hired" %}
|
||||||
|
<span class="badge bg-success">{% trans "Hired" %}</span>
|
||||||
|
{% elif application.stage == "Rejected" %}
|
||||||
|
<span class="badge bg-danger">{% trans "Rejected" %}</span>
|
||||||
|
{% elif application.stage == "Offer" %}
|
||||||
|
<span class="badge bg-info">{% trans "Offer Extended" %}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning">{% trans "In Progress" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'candidate_application_detail' application.slug %}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fas fa-eye me-1"></i>
|
||||||
|
{% trans "View Details" %}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<i class="fas fa-briefcase fa-3x text-muted mb-3"></i>
|
||||||
|
<h5 class="text-muted">{% trans "No Applications Yet" %}</h5>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% trans "You haven't applied to any positions yet. Browse available jobs and submit your first application!" %}
|
||||||
|
</p>
|
||||||
|
<a href="{% url 'kaauh_career' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-search me-2"></i>
|
||||||
|
{% trans "Browse Jobs" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@ -151,10 +232,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
<button class="btn btn-outline-info w-100">
|
<a href="{% url 'kaauh_career' %}" class="btn btn-outline-info w-100">
|
||||||
<i class="fas fa-eye me-2"></i>
|
<i class="fas fa-search me-2"></i>
|
||||||
{% trans "View Application" %}
|
{% trans "Browse Jobs" %}
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
704
templates/recruitment/candidate_profile.html
Normal file
704
templates/recruitment/candidate_profile.html
Normal file
@ -0,0 +1,704 @@
|
|||||||
|
{% extends 'portal_base.html' %}
|
||||||
|
{% load static i18n mytags crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "My Dashboard" %} - ATS{% endblock %}
|
||||||
|
|
||||||
|
{% block customCSS %}
|
||||||
|
<style>
|
||||||
|
/* Application Progress Timeline - Using Kaauh Theme Colors */
|
||||||
|
.application-progress {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--kaauh-border);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step:first-child::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.completed::before {
|
||||||
|
background: var(--kaauh-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.active::before {
|
||||||
|
background: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--kaauh-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 0.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #6c757d;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.completed .progress-icon {
|
||||||
|
background: var(--kaauh-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.active .progress-icon {
|
||||||
|
background: var(--kaauh-teal);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.completed .progress-label,
|
||||||
|
.progress-step.active .progress-label {
|
||||||
|
color: var(--kaauh-primary-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badges - Using Kaauh Theme */
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-applied { background: #e3f2fd; color: #1976d2; }
|
||||||
|
.status-screening { background: #fff3e0; color: #f57c00; }
|
||||||
|
.status-exam { background: #f3e5f5; color: #7b1fa2; }
|
||||||
|
.status-interview { background: #e8f5e8; color: #388e3c; }
|
||||||
|
.status-offer { background: #fff8e1; color: #f9a825; }
|
||||||
|
.status-hired { background: #e8f5e8; color: #2e7d32; }
|
||||||
|
.status-rejected { background: #ffebee; color: #c62828; }
|
||||||
|
|
||||||
|
/* Alert Purple - Using Theme Colors */
|
||||||
|
.alert-purple {
|
||||||
|
color: #4a148c;
|
||||||
|
background-color: #f3e5f5;
|
||||||
|
border-color: #ce93d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile specific styles */
|
||||||
|
.profile-data-list li {
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px dashed var(--kaauh-border);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.profile-data-list li strong {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
min-width: 120px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs styling */
|
||||||
|
.nav-tabs {
|
||||||
|
border-bottom: 1px solid var(--kaauh-border);
|
||||||
|
}
|
||||||
|
.nav-tabs .nav-link.active {
|
||||||
|
color: var(--kaauh-primary-text);
|
||||||
|
}
|
||||||
|
.nav-tabs .nav-link {
|
||||||
|
color: var(--kaauh-teal);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
padding: 1rem 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.nav-tabs .nav-link:hover {
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
border-color: var(--kaauh-teal-light);
|
||||||
|
}
|
||||||
|
.nav-tabs .nav-link.active {
|
||||||
|
color: #000000;
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
background-color: transparent;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.nav-tabs .nav-link i {
|
||||||
|
color: var(--kaauh-teal) !important;
|
||||||
|
}
|
||||||
|
.nav-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.nav-scroll .nav-tabs { flex-wrap: nowrap; border-bottom: none; }
|
||||||
|
.nav-scroll .nav-tabs .nav-item { flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Application table styling */
|
||||||
|
.application-table thead th {
|
||||||
|
background-color: var(--kaauh-teal-light);
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
font-weight: 700;
|
||||||
|
border-bottom: 1px solid var(--kaauh-border);
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
.application-table tbody tr {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.application-table tbody tr:hover {
|
||||||
|
background-color: var(--kaauh-teal-light);
|
||||||
|
}
|
||||||
|
.badge-stage {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
border-radius: 50rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive table for mobile */
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.application-table thead { display: none; }
|
||||||
|
.application-table tr {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--kaauh-shadow-sm);
|
||||||
|
}
|
||||||
|
.application-table td {
|
||||||
|
text-align: right !important;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
padding-left: 50%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.application-table td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
width: 45%;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gray-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Document management list */
|
||||||
|
.list-group-item {
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.list-group-item:hover {
|
||||||
|
background-color: var(--kaauh-teal-light);
|
||||||
|
border-color: var(--kaauh-teal-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action tiles */
|
||||||
|
.btn-action-tile {
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: var(--kaauh-shadow-sm);
|
||||||
|
}
|
||||||
|
.btn-action-tile:hover {
|
||||||
|
background-color: var(--kaauh-teal-light);
|
||||||
|
border-color: var(--kaauh-teal-accent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--kaauh-shadow-lg);
|
||||||
|
}
|
||||||
|
.action-tile-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--kaauh-teal-accent);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Application Cards Styling */
|
||||||
|
.hover-lift {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.hover-lift:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--kaauh-shadow-lg);
|
||||||
|
border-color: var(--kaauh-teal-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-card .card-title a {
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
.application-card .card-title a:hover {
|
||||||
|
color: var(--kaauh-teal-dark) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for application cards */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.application-card .card-body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.application-card .card-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
|
||||||
|
{# Header: Larger, more dynamic on large screens. Stacks cleanly on mobile. #}
|
||||||
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5">
|
||||||
|
<h1 class="display-6 display-md-5 fw-extrabold mb-3 mb-md-0" style="color: var(--kaauh-teal-dark);">
|
||||||
|
{% trans "Your Candidate Dashboard" %}
|
||||||
|
</h1>
|
||||||
|
{% comment %} <a href="#profile-details" data-bs-toggle="tab" class="btn btn-main-action btn-sm btn-md-lg px-4 py-2 rounded-pill shadow-sm shadow-md-lg">
|
||||||
|
<i class="fas fa-edit me-2"></i> {% trans "Update Profile" %}
|
||||||
|
</a> {% endcomment %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Candidate Quick Overview Card: Use a softer background color #}
|
||||||
|
<div class="card kaauh-card mb-5 p-4 bg-white">
|
||||||
|
<div class="d-flex align-items-center flex-column flex-sm-row text-center text-sm-start">
|
||||||
|
<img src="{% if candidate.user.profile_image %}{{ candidate.user.profile_image.url }}{% else %}{% static 'image/default_avatar.png' %}{% endif %}"
|
||||||
|
alt="{% trans 'Profile Picture' %}"
|
||||||
|
class="rounded-circle me-sm-4 mb-3 mb-sm-0 shadow-lg"
|
||||||
|
style="width: 80px; height: 80px; object-fit: cover; border: 4px solid var(--kaauh-teal-accent);">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title mb-1 fw-bold text-dark">{{ candidate.full_name|default:"Candidate Name" }}</h3>
|
||||||
|
<p class="text-gray-subtle mb-0">{{ candidate.email }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ================================================= #}
|
||||||
|
{# MAIN TABBED INTERFACE #}
|
||||||
|
{# ================================================= #}
|
||||||
|
<div class="card kaauh-card p-0 bg-white">
|
||||||
|
|
||||||
|
{# Tab Navigation: Used nav-scroll for responsiveness #}
|
||||||
|
<div class="nav-scroll px-4 pt-3">
|
||||||
|
<ul class="nav nav-tabs" id="candidateTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="profile-tab" data-bs-toggle="tab" data-bs-target="#profile-details" type="button" role="tab" aria-controls="profile-details" aria-selected="true">
|
||||||
|
<i class="fas fa-user-circle me-2"></i> {% trans "Profile Details" %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="applications-tab" data-bs-toggle="tab" data-bs-target="#applications-history" type="button" role="tab" aria-controls="applications-history" aria-selected="false">
|
||||||
|
<i class="fas fa-list-alt me-2"></i> {% trans "My Applications" %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="documents-tab" data-bs-toggle="tab" data-bs-target="#document-management" type="button" role="tab" aria-controls="document-management" aria-selected="false">
|
||||||
|
<i class="fas fa-file-upload me-2"></i> {% trans "Documents" %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{% comment %} <li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="settings-tab" data-bs-toggle="tab" data-bs-target="#account-settings" type="button" role="tab" aria-controls="account-settings" aria-selected="false">
|
||||||
|
<i class="fas fa-cogs me-2"></i> {% trans "Settings" %}
|
||||||
|
</button>
|
||||||
|
</li> {% endcomment %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Tab Content #}
|
||||||
|
<div class="tab-content p-4 p-md-5" id="candidateTabsContent">
|
||||||
|
|
||||||
|
<div class="tab-pane fade show active" id="profile-details" role="tabpanel" aria-labelledby="profile-tab">
|
||||||
|
<!-- Basic Information Section -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<h4 class="mb-4 fw-bold text-gray-subtle">
|
||||||
|
<i class="fas fa-user me-2 text-primary-theme"></i>{% trans "Basic Information" %}
|
||||||
|
</h4>
|
||||||
|
<ul class="list-unstyled profile-data-list p-0">
|
||||||
|
<li class="d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fas fa-id-card me-2 text-primary-theme"></i> <strong>{% trans "First Name" %}</strong></div>
|
||||||
|
<span class="text-end">{{ candidate.first_name|default:"N/A" }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fas fa-id-card me-2 text-primary-theme"></i> <strong>{% trans "Last Name" %}</strong></div>
|
||||||
|
<span class="text-end">{{ candidate.last_name|default:"N/A" }}</span>
|
||||||
|
</li>
|
||||||
|
{% if candidate.middle_name %}
|
||||||
|
<li class="d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fas fa-id-card me-2 text-primary-theme"></i> <strong>{% trans "Middle Name" %}</strong></div>
|
||||||
|
<span class="text-end">{{ candidate.middle_name }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fas fa-envelope me-2 text-primary-theme"></i> <strong>{% trans "Email" %}</strong></div>
|
||||||
|
<span class="text-end">{{ candidate.email|default:"N/A" }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Information Section -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<h4 class="mb-4 fw-bold text-gray-subtle">
|
||||||
|
<i class="fas fa-address-book me-2 text-primary-theme"></i>{% trans "Contact Information" %}
|
||||||
|
</h4>
|
||||||
|
<ul class="list-unstyled profile-data-list p-0">
|
||||||
|
<li class="d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fas fa-phone-alt me-2 text-primary-theme"></i> <strong>{% trans "Phone" %}</strong></div>
|
||||||
|
<span class="text-end">{{ candidate.phone|default:"N/A" }}</span>
|
||||||
|
</li>
|
||||||
|
{% if candidate.address %}
|
||||||
|
<li class="d-flex align-items-start">
|
||||||
|
<div class="mb-1"><i class="fas fa-map-marker-alt me-2 text-primary-theme"></i> <strong>{% trans "Address" %}</strong></div>
|
||||||
|
<span class="text-end text-break">{{ candidate.address|linebreaksbr }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if candidate.linkedin_profile %}
|
||||||
|
<li class="d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fab fa-linkedin me-2 text-primary-theme"></i> <strong>{% trans "LinkedIn Profile" %}</strong></div>
|
||||||
|
<span class="text-end">
|
||||||
|
<a href="{{ candidate.linkedin_profile }}" target="_blank" class="text-primary-theme text-decoration-none">
|
||||||
|
{% trans "View Profile" %} <i class="fas fa-external-link-alt ms-1"></i>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Personal Details Section -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<h4 class="mb-4 fw-bold text-gray-subtle">
|
||||||
|
<i class="fas fa-user-circle me-2 text-primary-theme"></i>{% trans "Personal Details" %}
|
||||||
|
</h4>
|
||||||
|
<ul class="list-unstyled profile-data-list p-0">
|
||||||
|
<li class="d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fas fa-calendar-alt me-2 text-primary-theme"></i> <strong>{% trans "Date of Birth" %}</strong></div>
|
||||||
|
<span class="text-end">{{ candidate.date_of_birth|date:"M d, Y"|default:"N/A" }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fas fa-venus-mars me-2 text-primary-theme"></i> <strong>{% trans "Gender" %}</strong></div>
|
||||||
|
<span class="text-end">{{ candidate.get_gender_display|default:"N/A" }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fas fa-globe me-2 text-primary-theme"></i> <strong>{% trans "Nationality" %}</strong></div>
|
||||||
|
<span class="text-end">{{ candidate.get_nationality_display|default:"N/A" }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Professional Information Section -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<h4 class="mb-4 fw-bold text-gray-subtle">
|
||||||
|
<i class="fas fa-briefcase me-2 text-primary-theme"></i>{% trans "Professional Information" %}
|
||||||
|
</h4>
|
||||||
|
<ul class="list-unstyled profile-data-list p-0">
|
||||||
|
{% if candidate.user.designation %}
|
||||||
|
<li class="d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fas fa-user-tie me-2 text-primary-theme"></i> <strong>{% trans "Designation" %}</strong></div>
|
||||||
|
<span class="text-end">{{ candidate.user.designation }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if candidate.gpa %}
|
||||||
|
<li class="d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fas fa-graduation-cap me-2 text-primary-theme"></i> <strong>{% trans "GPA" %}</strong></div>
|
||||||
|
<span class="text-end">{{ candidate.gpa }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %} <div class="alert alert-info mt-4">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
<small>{% trans "Use the 'Update Profile' button above to edit these details." %}</small>
|
||||||
|
</div> {% endcomment %}
|
||||||
|
|
||||||
|
{% comment %} <hr class="my-5"> {% endcomment %}
|
||||||
|
|
||||||
|
{% comment %} <h4 class="mb-4 fw-bold text-gray-subtle">{% trans "Quick Actions" %}</h4>
|
||||||
|
<div class="row g-3 g-md-4">
|
||||||
|
<div class="col-6 col-sm-4 col-md-4">
|
||||||
|
<a href="#applications-history" data-bs-toggle="tab" class="btn btn-action-tile w-100 d-grid text-center text-dark text-decoration-none">
|
||||||
|
<span class="action-tile-icon mb-2"><i class="fas fa-list-check"></i></span>
|
||||||
|
<span class="fw-bold">{% trans "Track Jobs" %}</span>
|
||||||
|
<span class="small text-muted d-none d-sm-block">{% trans "View stages" %}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-sm-4 col-md-4">
|
||||||
|
<a href="#document-management" data-bs-toggle="tab" class="btn btn-action-tile w-100 d-grid text-center text-dark text-decoration-none">
|
||||||
|
<span class="action-tile-icon mb-2"><i class="fas fa-cloud-upload-alt"></i></span>
|
||||||
|
<span class="fw-bold">{% trans "Manage Documents" %}</span>
|
||||||
|
<span class="small text-muted d-none d-sm-block">{% trans "Upload/View files" %}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-4 col-md-4">
|
||||||
|
<a href="{% url 'kaauh_career' %}" class="btn btn-action-tile w-100 d-grid text-center text-dark text-decoration-none">
|
||||||
|
<span class="action-tile-icon mb-2"><i class="fas fa-search"></i></span>
|
||||||
|
<span class="fw-bold">{% trans "Find New Careers" %}</span>
|
||||||
|
<span class="small text-muted d-none d-sm-block">{% trans "Explore open roles" %}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div> {% endcomment %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="applications-history" role="tabpanel" aria-labelledby="applications-tab">
|
||||||
|
<h4 class="mb-4 fw-bold text-gray-subtle">{% trans "Application Tracking" %}</h4>
|
||||||
|
|
||||||
|
{% if applications %}
|
||||||
|
<div class="row g-4">
|
||||||
|
{% for application in applications %}
|
||||||
|
<div class="col-12 col-md-6 col-lg-4">
|
||||||
|
<div class="card kaauh-card h-100 shadow-sm hover-lift">
|
||||||
|
<div class="card-body d-flex flex-column">
|
||||||
|
<!-- Job Title as Card Header -->
|
||||||
|
<div class="d-flex align-items-start mb-3">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h5 class="card-title fw-bold mb-1">
|
||||||
|
<a href="{% url 'candidate_application_detail' application.slug %}"
|
||||||
|
class="text-decoration-none text-primary-theme hover:text-primary-theme-dark">
|
||||||
|
{{ application.job.title }}
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
<p class="text-muted small mb-0">
|
||||||
|
<i class="fas fa-calendar-alt me-1"></i>
|
||||||
|
{% trans "Applied" %}: {{ application.applied_date|date:"d M Y" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Application Details -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="text-muted small fw-medium">{% trans "Current Stage" %}</span>
|
||||||
|
<span class="badge badge-stage bg-info text-white">
|
||||||
|
{{ application.stage }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-muted small fw-medium">{% trans "Status" %}</span>
|
||||||
|
{% if application.is_active %}
|
||||||
|
<span class="badge badge-stage bg-success">{% trans "Active" %}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-stage bg-warning text-dark">{% trans "Closed" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Button -->
|
||||||
|
<div class="mt-auto">
|
||||||
|
<a href="{% url 'candidate_application_detail' application.slug %}"
|
||||||
|
class="btn btn-main-action w-100 rounded-pill">
|
||||||
|
<i class="fas fa-eye me-2"></i> {% trans "View Details" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info text-center p-5 rounded-3" style="border: 1px dashed var(--kaauh-border); background-color: var(--kaauh-teal-light);">
|
||||||
|
<i class="fas fa-info-circle fa-2x mb-3 text-primary-theme"></i>
|
||||||
|
<h5 class="mb-3 fw-bold text-primary-theme">{% trans "You haven't submitted any applications yet." %}</h5>
|
||||||
|
<a href="{% url 'kaauh_career' %}" class="ms-3 btn btn-main-action mt-2 rounded-pill px-4">
|
||||||
|
{% trans "View Available Jobs" %} <i class="fas fa-arrow-right ms-2"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="document-management" role="tabpanel" aria-labelledby="documents-tab">
|
||||||
|
<h4 class="mb-4 fw-bold text-gray-subtle">{% trans "My Uploaded Documents" %}</h4>
|
||||||
|
|
||||||
|
<p class="text-gray-subtle">{% trans "You can upload and manage your resume, certificates, and professional documents here. These documents will be attached to your applications." %}</p>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-main-action rounded-pill px-4 me-3 d-block d-sm-inline-block w-100 w-sm-auto mb-4" data-bs-toggle="modal" data-bs-target="#documentUploadModal">
|
||||||
|
<i class="fas fa-cloud-upload-alt me-2"></i> {% trans "Upload New Document" %}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<hr class="my-5">
|
||||||
|
|
||||||
|
{# Document List #}
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for document in documents %}
|
||||||
|
<li class="list-group-item d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center bg-white p-3">
|
||||||
|
<div class="mb-2 mb-sm-0 fw-medium">
|
||||||
|
<i class="fas fa-file-pdf me-2 text-primary-theme"></i> <strong>{{ document.document_type|title }}</strong>
|
||||||
|
<span class="text-muted small">({{ document.file.name|split:"/"|last }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="text-muted small me-3">{% trans "Uploaded:" %} {{ document.uploaded_at|date:"d M Y" }}</span>
|
||||||
|
<a href="{{ document.file.url }}" target="_blank" class="btn btn-sm btn-outline-secondary me-2"><i class="fas fa-eye"></i></a>
|
||||||
|
<a href="{% url 'candidate_document_delete' document.id %}" class="btn btn-sm btn-outline-danger" onclick="return confirm('{% trans "Are you sure you want to delete this document?" %}')"><i class="fas fa-trash-alt"></i></a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="list-group-item text-center text-muted p-4">
|
||||||
|
<i class="fas fa-folder-open fa-2x mb-3"></i>
|
||||||
|
<p>{% trans "No documents uploaded yet." %}</p>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %} <div class="tab-pane fade" id="account-settings" role="tabpanel" aria-labelledby="settings-tab">
|
||||||
|
<h4 class="mb-4 fw-bold text-gray-subtle">{% trans "Security & Preferences" %}</h4>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="card kaauh-card p-4 h-100 bg-white">
|
||||||
|
<h5 class="fw-bold"><i class="fas fa-key me-2 text-primary-theme"></i> {% trans "Password Security" %}</h5>
|
||||||
|
<p class="text-muted small">{% trans "Update your password regularly to keep your account secure." %}</p>
|
||||||
|
<button type="button" class="btn btn-outline-secondary mt-auto w-100 py-2 fw-medium" data-bs-toggle="modal" data-bs-target="#passwordModal">
|
||||||
|
{% trans "Change Password" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="card kaauh-card p-4 h-100 bg-white">
|
||||||
|
<h5 class="fw-bold"><i class="fas fa-image me-2 text-primary-theme"></i> {% trans "Profile Image" %}</h5>
|
||||||
|
<p class="text-muted small">{% trans "Update your profile picture to personalize your account." %}</p>
|
||||||
|
<button type="button" class="btn btn-outline-secondary mt-auto w-100 py-2 fw-medium" data-bs-toggle="modal" data-bs-target="#profileImageModal">
|
||||||
|
{% trans "Change Image" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert mt-5 py-3" style="background-color: var(--danger-subtle); color: #842029; border: 1px solid #f5c2c7; border-radius: 8px;">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2"></i> {% trans "To delete your profile, please contact HR support." %}
|
||||||
|
</div>
|
||||||
|
</div> {% endcomment %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{# ================================================= #}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Change Modal (Reused from portal_profile.html) -->
|
||||||
|
<div class="modal fade mt-4" id="passwordModal" tabindex="-1" aria-labelledby="passwordModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="passwordModalLabel">{% trans "Change Password" %}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="passwordModalBody">
|
||||||
|
<form action="{% url 'portal_password_reset' candidate.pk %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ password_reset_form|crispy }}
|
||||||
|
<button type="submit" class="btn btn-main-action">{% trans "Change Password" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Image Modal (Reused from portal_profile.html) -->
|
||||||
|
<div class="modal fade mt-4" id="profileImageModal" tabindex="-1" aria-labelledby="profileImageModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="profileImageModalLabel">{% trans "Upload Profile Image" %}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form method="post" action="{% url 'user_profile_image_update' candidate.pk %}" enctype="multipart/form-data" >
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ profile_form.profile_image.id_for_label }}" class="form-label">{% trans "Profile Image" %}</label>
|
||||||
|
|
||||||
|
{# 1. Check if an image currently exists on the bound instance #}
|
||||||
|
{% if profile_form.instance.profile_image %}
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="text-muted d-block">{% trans "Current Image:" %}</small>
|
||||||
|
|
||||||
|
{# Display Link to View Current Image #}
|
||||||
|
<a href="{{ profile_form.instance.profile_image.url }}" target="_blank" class="d-inline-block me-3 text-info fw-bold">
|
||||||
|
{% trans "View/Download" %} ({{ profile_form.instance.profile_image.name }})
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{# Image Preview #}
|
||||||
|
<div class="mt-2">
|
||||||
|
<img src="{{ profile_form.instance.profile_image.url }}"
|
||||||
|
alt="{% trans 'Profile Image' %}"
|
||||||
|
style="max-width: 150px; height: auto; border: 1px solid #ccc; border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 2. Explicitly render the 'Clear' checkbox and the Change input #}
|
||||||
|
<div class="form-check mt-3">
|
||||||
|
{# The ClearableFileInput widget renders itself here. It provides the "Clear" checkbox and the "Change" input field. #}
|
||||||
|
{{ profile_form.profile_image }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# If no image exists, just render the file input for upload #}
|
||||||
|
<div class="form-control p-0 border-0">
|
||||||
|
{{ profile_form.profile_image }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Display any validation errors #}
|
||||||
|
{% for error in profile_form.profile_image.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer mt-4">
|
||||||
|
<button type="button" class="btn btn-lg btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
||||||
|
<button type="submit" class="btn btn-main-action">{% trans "Save changes" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Document Upload Modal -->
|
||||||
|
<div class="modal fade mt-4" id="documentUploadModal" tabindex="-1" aria-labelledby="documentUploadModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="documentUploadModalLabel">{% trans "Upload Document" %}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form method="post" action="{% url 'document_upload' candidate.id %}" enctype="multipart/form-data" id="documentUploadForm">
|
||||||
|
<input type="hidden" name="upload_target" value="person">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ document_form.document_type.id_for_label }}" class="form-label">{% trans "Document Type" %}</label>
|
||||||
|
{{ document_form.document_type }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ document_form.file.id_for_label }}" class="form-label">{% trans "File" %}</label>
|
||||||
|
{{ document_form.file }}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer mt-4">
|
||||||
|
<button type="button" class="btn btn-lg btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
||||||
|
<button type="submit" class="btn btn-main-action">{% trans "Upload" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -247,6 +247,14 @@
|
|||||||
<form method="GET" class="mb-0">
|
<form method="GET" class="mb-0">
|
||||||
<div class="row g-3 align-items-end">
|
<div class="row g-3 align-items-end">
|
||||||
|
|
||||||
|
<div class="col-auto">
|
||||||
|
<label for="gpa" class="form-label small text-muted mb-1">
|
||||||
|
{% trans "GPA" %}
|
||||||
|
</label>
|
||||||
|
<input type="number" name="GPA" id="gpa" class="form-control form-control-sm"
|
||||||
|
value="{{ gpa }}" min="0" max="4" step="1"
|
||||||
|
placeholder="e.g., 4" style="width: 120px;">
|
||||||
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label for="min_ai_score" class="form-label small text-muted mb-1">
|
<label for="min_ai_score" class="form-label small text-muted mb-1">
|
||||||
{% trans "Min AI Score" %}
|
{% trans "Min AI Score" %}
|
||||||
@ -372,6 +380,9 @@
|
|||||||
<th scope="col" style="width: 10%;">
|
<th scope="col" style="width: 10%;">
|
||||||
<i class="fas fa-phone me-1"></i> {% trans "Contact Info" %}
|
<i class="fas fa-phone me-1"></i> {% trans "Contact Info" %}
|
||||||
</th>
|
</th>
|
||||||
|
<th scope="col" style="width: 5%;">
|
||||||
|
<i class="fas fa-graduation-cap me-1"></i> {% trans "GPA" %}
|
||||||
|
</th>
|
||||||
<th scope="col" style="width: 6%;" class="text-center">
|
<th scope="col" style="width: 6%;" class="text-center">
|
||||||
<i class="fas fa-robot me-1"></i> {% trans "AI Score" %}
|
<i class="fas fa-robot me-1"></i> {% trans "AI Score" %}
|
||||||
</th>
|
</th>
|
||||||
@ -411,6 +422,7 @@
|
|||||||
<i class="fas fa-phone me-1"></i> {{ candidate.phone }}
|
<i class="fas fa-phone me-1"></i> {{ candidate.phone }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-center">{{candidate.person.gpa|default:"0"}}</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
{% if candidate.is_resume_parsed %}
|
{% if candidate.is_resume_parsed %}
|
||||||
{% if candidate.match_score %}
|
{% if candidate.match_score %}
|
||||||
|
|||||||
@ -1,14 +1,55 @@
|
|||||||
{% extends "base.html" %}
|
{% extends 'applicant/partials/candidate_facing_base.html' %}
|
||||||
{% load i18n %}
|
{% load i18n crispy_forms_tags %}
|
||||||
|
|
||||||
{% block title %}{% trans "Candidate Signup" %}{% endblock %}
|
{% block title %}{% trans "Candidate Signup" %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
/* KAAUH Teal Style Variables */
|
||||||
|
:root {
|
||||||
|
--kaauh-teal: #00636e;
|
||||||
|
--kaauh-teal-dark: #004a53;
|
||||||
|
--kaauh-teal-light: #e6f7f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kaauh-teal-header {
|
||||||
|
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-kaauh-teal {
|
||||||
|
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-kaauh-teal:hover {
|
||||||
|
background: linear-gradient(135deg, var(--kaauh-teal-dark) 0%, var(--kaauh-teal) 100%);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 99, 110, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-kaauh-teal {
|
||||||
|
color: var(--kaauh-teal) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-kaauh-teal:hover {
|
||||||
|
color: var(--kaauh-teal-dark) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<div class="container mt-5">
|
<div class="container mt-5">
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-md-8 col-lg-6">
|
<div class="col-md-8 col-lg-6">
|
||||||
<div class="card shadow">
|
<div class="card shadow">
|
||||||
<div class="card-header bg-primary text-white">
|
<div class="card-header kaauh-teal-header text-white">
|
||||||
<h4 class="mb-0">
|
<h4 class="mb-0">
|
||||||
<i class="fas fa-user-plus me-2"></i>
|
<i class="fas fa-user-plus me-2"></i>
|
||||||
{% trans "Candidate Signup" %}
|
{% trans "Candidate Signup" %}
|
||||||
@ -78,6 +119,43 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="{{ form.gpa.id_for_label }}" class="form-label">
|
||||||
|
{% trans "GPA" %} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.gpa }}
|
||||||
|
{% if form.nationality.errors %}
|
||||||
|
<div class="text-danger small">
|
||||||
|
{{ form.gpa.errors.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="{{ form.nationality.id_for_label }}" class="form-label">
|
||||||
|
{% trans "Nationality" %} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.nationality }}
|
||||||
|
{% if form.nationality.errors %}
|
||||||
|
<div class="text-danger small">
|
||||||
|
{{ form.nationality.errors.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="{{ form.gender.id_for_label }}" class="form-label">
|
||||||
|
{% trans "Gender" %} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.gender }}
|
||||||
|
{% if form.gender.errors %}
|
||||||
|
<div class="text-danger small">
|
||||||
|
{{ form.gender.errors.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="{{ form.email.id_for_label }}" class="form-label">
|
<label for="{{ form.email.id_for_label }}" class="form-label">
|
||||||
@ -126,21 +204,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-kaauh-teal">
|
||||||
<i class="fas fa-user-plus me-2"></i>
|
<i class="fas fa-user-plus me-2"></i>
|
||||||
{% trans "Sign Up" %}
|
{% trans "Sign Up" %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer text-center">
|
<div class="card-footer text-center">
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
{% trans "Already have an account?" %}
|
{% trans "Already have an account?" %}
|
||||||
<a href="{% url 'portal_login' %}" class="text-decoration-none">
|
<a href="{% url 'portal_login' %}" class="text-decoration-none text-kaauh-teal">
|
||||||
{% trans "Login here" %}
|
{% trans "Login here" %}
|
||||||
</a>
|
</a>
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,3 +12,7 @@
|
|||||||
--
|
--
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<td id="exam-score-{{ candidate.pk}}" hx-swap-oob="true">
|
||||||
|
{{candidate.exam_score|default:"--"}}
|
||||||
|
</td>
|
||||||
276
templates/user/portal_profile.html
Normal file
276
templates/user/portal_profile.html
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
{% extends "portal_base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "User Profile" %} - KAAUH ATS{% endblock %}
|
||||||
|
|
||||||
|
{% block customCSS %}
|
||||||
|
<style>
|
||||||
|
/* Theme Variables based on Teal Accent */
|
||||||
|
:root {
|
||||||
|
--bs-primary: #00636e;
|
||||||
|
--bs-primary-rgb: 0, 99, 110;
|
||||||
|
--bs-primary-light: #007a88;
|
||||||
|
--bs-body-bg: #f3f5f8; /* Soft light gray background */
|
||||||
|
--bs-body-color: #212529;
|
||||||
|
--bs-border-color: #e9ecef; /* Lighter, softer border */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Card Refinements for Depth and Geometry */
|
||||||
|
.card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0 8px 30px rgba(0,0,0,0.08); /* Deeper, softer shadow */
|
||||||
|
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px); /* Subtle lift on hover */
|
||||||
|
box-shadow: 0 12px 35px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile Header Consistency */
|
||||||
|
.profile-header {
|
||||||
|
border-bottom: 2px solid var(--bs-border-color);
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Consistency */
|
||||||
|
.form-control {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-color: var(--bs-border-color);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
font-size: 0.95rem; /* Slightly larger text in fields */
|
||||||
|
}
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: var(--bs-primary-light);
|
||||||
|
box-shadow: 0 0 0 0.15rem rgba(0, 99, 110, 0.15);
|
||||||
|
}
|
||||||
|
.form-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057; /* Slightly darker than default mute */
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Consistency (Primary) */
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--bs-primary);
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 0.65rem 1.5rem;
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 99, 110, 0.3);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--bs-primary-light);
|
||||||
|
border-color: var(--bs-primary-light);
|
||||||
|
box-shadow: 0 8px 18px rgba(0, 99, 110, 0.4);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Consistency (Outline/Secondary) */
|
||||||
|
.btn-outline-secondary {
|
||||||
|
color: #495057;
|
||||||
|
border-color: var(--bs-border-color);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.btn-outline-secondary:hover {
|
||||||
|
color: var(--bs-primary); /* Accent text color on hover */
|
||||||
|
background-color: rgba(0, 99, 110, 0.05);
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent & Info Text */
|
||||||
|
.text-accent {
|
||||||
|
color: var(--bs-primary) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.info-value {
|
||||||
|
font-weight: 700; /* Bolder status values */
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
.info-label {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4" style="max-width: 900px;">
|
||||||
|
|
||||||
|
<div class="profile-header d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 fw-bold mb-1">{% trans "Account Settings" %}</h1>
|
||||||
|
<p class="text-muted mb-0">{% trans "Manage your personal details and security." %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-circle bg-primary-subtle text-accent d-flex align-items-center justify-content-center" style="width: 50px; height: 50px; font-size: 1.5rem;">
|
||||||
|
{% if user.first_name %}{{ user.first_name.0 }}{% else %}<i class="fas fa-user"></i>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card p-5">
|
||||||
|
<h5 class="fw-bold mb-4 text-accent">{% trans "Personal Information" %}</h5>
|
||||||
|
|
||||||
|
<form method="POST" action="{% url 'user_detail' user.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="row g-4"> <div class="col-md-6">
|
||||||
|
<label for="id_first_name" class="form-label">{% trans "First Name" %}</label>
|
||||||
|
<input type="text" class="form-control" id="id_first_name" name="first_name" value="{{ user.first_name|default:'' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="id_last_name" class="form-label">{% trans "Last Name" %}</label>
|
||||||
|
<input type="text" class="form-control" id="id_last_name" name="last_name" value="{{ user.last_name|default:'' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="id_email" class="form-label">{% trans "Email Address" %}</label>
|
||||||
|
<input type="email" class="form-control" id="id_email" value="{{ user.email }}" disabled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 mt-4 pt-2">
|
||||||
|
<button type="submit" class="btn btn-primary">{% trans "Save Changes" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-5">
|
||||||
|
|
||||||
|
<div class="card p-4 mb-4">
|
||||||
|
<h5 class="fw-bold mb-4 text-accent">{% trans "Security" %}</h5>
|
||||||
|
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
<button type="button" class="btn btn-outline-danger w-100 rounded-pill py-2" data-bs-toggle="modal" data-bs-target="#passwordModal">
|
||||||
|
<i class="fas fa-lock me-2"></i> {% trans "Change Password" %}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#myModalForm">
|
||||||
|
<i class="fas fa-image me-1"></i> {% trans "Change Profile Image" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4">
|
||||||
|
<h5 class="fw-bold mb-4 text-accent">{% trans "Account Status" %}</h5>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="info-label">{% trans "Username" %}</div>
|
||||||
|
<div class="info-value">{{ user.username }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="info-label">{% trans "Last Login" %}</div>
|
||||||
|
<div class="info-value">
|
||||||
|
{% if user.last_login %}{{ user.last_login|date:"F d, Y P" }}{% else %}N/A{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="info-label">{% trans "Date Joined" %}</div>
|
||||||
|
<div class="info-value">{{ user.date_joined|date:"F d, Y" }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--modal class for password change-->
|
||||||
|
<div class="modal fade mt-4" id="passwordModal" tabindex="-1" aria-labelledby="passwordModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="passwordModalLabel">{% trans "Change Password" %}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="passwordModalBody">
|
||||||
|
<form action="{% url 'portal_password_reset' user.pk %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{password_reset_form|crispy}}
|
||||||
|
<button type="submit" class="btn btn-primary">{% trans "Change Password" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--modal class for image upload-->
|
||||||
|
<div class="modal fade mt-4" id="myModalForm" tabindex="-1" aria-labelledby="myModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="myModalLabel">Upload Profile image</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form method="post" action="{% url 'user_profile_image_update' user.pk %}" enctype="multipart/form-data" >
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ profile_form.profile_image.id_for_label }}" class="form-label">Profile Image</label>
|
||||||
|
|
||||||
|
{# 1. Check if an image currently exists on the bound instance #}
|
||||||
|
{% if profile_form.instance.profile_image %}
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="text-muted d-block">Current Image:</small>
|
||||||
|
|
||||||
|
{# Display Link to View Current Image #}
|
||||||
|
<a href="{{ profile_form.instance.profile_image.url }}" target="_blank" class="d-inline-block me-3 text-info fw-bold">
|
||||||
|
View/Download ({{ profile_form.instance.profile_image.name }})
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{# Image Preview #}
|
||||||
|
<div class="mt-2">
|
||||||
|
<img src="{{ profile_form.instance.profile_image.url }}"
|
||||||
|
alt="Profile Image"
|
||||||
|
style="max-width: 150px; height: auto; border: 1px solid #ccc; border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 2. Explicitly render the 'Clear' checkbox and the Change input #}
|
||||||
|
<div class="form-check mt-3">
|
||||||
|
{# The ClearableFileInput widget renders itself here. It provides the "Clear" checkbox and the "Change" input field. #}
|
||||||
|
{{ profile_form.profile_image }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# If no image exists, just render the file input for upload #}
|
||||||
|
<div class="form-control p-0 border-0">
|
||||||
|
{{ profile_form.profile_image }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Display any validation errors #}
|
||||||
|
{% for error in profile_form.profile_image.errors %}
|
||||||
|
<div class="text-danger small mt-1">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer mt-4">
|
||||||
|
<button type="button" class="btn btn-lg btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
112
test_document_upload.py
Normal file
112
test_document_upload.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Simple test script to verify document upload functionality
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import django
|
||||||
|
|
||||||
|
# Add the project directory to the Python path
|
||||||
|
sys.path.append('/home/ismail/projects/ats/kaauh_ats')
|
||||||
|
|
||||||
|
# Set up Django
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'NorahUniversity.settings')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from django.test import TestCase, Client
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from recruitment.models import JobPosting, Application, Document
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
def test_document_upload():
|
||||||
|
"""Test document upload functionality"""
|
||||||
|
print("Testing document upload functionality...")
|
||||||
|
|
||||||
|
# Clean up existing test data
|
||||||
|
User.objects.filter(username__startswith='testcandidate').delete()
|
||||||
|
|
||||||
|
# Create test data
|
||||||
|
client = Client()
|
||||||
|
|
||||||
|
# Create a test user with unique username
|
||||||
|
import uuid
|
||||||
|
unique_id = str(uuid.uuid4())[:8]
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username=f'testcandidate_{unique_id}',
|
||||||
|
email=f'test_{unique_id}@example.com',
|
||||||
|
password='testpass123',
|
||||||
|
user_type='candidate'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a test job
|
||||||
|
from datetime import date, timedelta
|
||||||
|
job = JobPosting.objects.create(
|
||||||
|
title='Test Job',
|
||||||
|
description='Test Description',
|
||||||
|
open_positions=1,
|
||||||
|
status='ACTIVE',
|
||||||
|
application_deadline=date.today() + timedelta(days=30)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a test person first
|
||||||
|
from recruitment.models import Person
|
||||||
|
person = Person.objects.create(
|
||||||
|
first_name='Test',
|
||||||
|
last_name='Candidate',
|
||||||
|
email=f'test_{unique_id}@example.com',
|
||||||
|
phone='1234567890',
|
||||||
|
user=user
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a test application
|
||||||
|
application = Application.objects.create(
|
||||||
|
job=job,
|
||||||
|
person=person
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log in the user
|
||||||
|
client.login(username=f'testcandidate_{unique_id}', password='testpass123')
|
||||||
|
|
||||||
|
# Test document upload URL
|
||||||
|
url = reverse('document_upload', kwargs={'slug': application.slug})
|
||||||
|
print(f"Document upload URL: {url}")
|
||||||
|
|
||||||
|
# Create a test file
|
||||||
|
test_file = SimpleUploadedFile(
|
||||||
|
"test_document.pdf",
|
||||||
|
b"file_content",
|
||||||
|
content_type="application/pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test POST request
|
||||||
|
response = client.post(url, {
|
||||||
|
'document_type': 'resume',
|
||||||
|
'description': 'Test document',
|
||||||
|
'file': test_file
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"Response status: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print(f"Response data: {data}")
|
||||||
|
if data.get('success'):
|
||||||
|
print("✅ Document upload test PASSED")
|
||||||
|
else:
|
||||||
|
print(f"❌ Document upload test FAILED: {data.get('error')}")
|
||||||
|
else:
|
||||||
|
print(f"❌ Document upload test FAILED: HTTP {response.status_code}")
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
Document.objects.filter(object_id=application.id, content_type__model='application').delete()
|
||||||
|
application.delete()
|
||||||
|
job.delete()
|
||||||
|
user.delete()
|
||||||
|
|
||||||
|
print("Test completed.")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
test_document_upload()
|
||||||
Loading…
x
Reference in New Issue
Block a user