person update
This commit is contained in:
parent
eb79173e26
commit
da555c1460
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -3,7 +3,7 @@ from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from .models import (
|
||||
JobPosting, Candidate, TrainingMaterial, ZoomMeeting,
|
||||
JobPosting, Application, TrainingMaterial, ZoomMeeting,
|
||||
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
|
||||
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment,
|
||||
AgencyAccessLink, AgencyJobAssignment
|
||||
@ -138,43 +138,6 @@ class JobPostingAdmin(admin.ModelAdmin):
|
||||
mark_as_closed.short_description = 'Mark selected jobs as closed'
|
||||
|
||||
|
||||
@admin.register(Candidate)
|
||||
class CandidateAdmin(admin.ModelAdmin):
|
||||
list_display = ['full_name', 'job', 'email', 'phone', 'stage', 'applied','is_resume_parsed', 'created_at']
|
||||
list_filter = ['stage', 'applied', 'created_at', 'job__department']
|
||||
search_fields = ['first_name', 'last_name', 'email', 'phone']
|
||||
readonly_fields = ['slug', 'created_at', 'updated_at']
|
||||
fieldsets = (
|
||||
('Personal Information', {
|
||||
'fields': ('first_name', 'last_name', 'email', 'phone', 'resume','user')
|
||||
}),
|
||||
('Application Details', {
|
||||
'fields': ('job', 'applied', 'stage','is_resume_parsed')
|
||||
}),
|
||||
('Interview Process', {
|
||||
'fields': ('exam_date', 'exam_status', 'interview_date', 'interview_status', 'offer_date', 'offer_status', 'join_date')
|
||||
}),
|
||||
('Scoring', {
|
||||
'fields': ('ai_analysis_data',)
|
||||
}),
|
||||
('Additional Information', {
|
||||
'fields': ('created_at', 'updated_at')
|
||||
}),
|
||||
)
|
||||
save_on_top = True
|
||||
actions = ['mark_as_applied', 'mark_as_not_applied']
|
||||
|
||||
def mark_as_applied(self, request, queryset):
|
||||
updated = queryset.update(applied=True)
|
||||
self.message_user(request, f'{updated} candidates marked as applied.')
|
||||
mark_as_applied.short_description = 'Mark selected candidates as applied'
|
||||
|
||||
def mark_as_not_applied(self, request, queryset):
|
||||
updated = queryset.update(applied=False)
|
||||
self.message_user(request, f'{updated} candidates marked as not applied.')
|
||||
mark_as_not_applied.short_description = 'Mark selected candidates as not applied'
|
||||
|
||||
|
||||
@admin.register(TrainingMaterial)
|
||||
class TrainingMaterialAdmin(admin.ModelAdmin):
|
||||
list_display = ['title', 'created_by', 'created_at']
|
||||
@ -275,6 +238,7 @@ class FormSubmissionAdmin(admin.ModelAdmin):
|
||||
|
||||
# Register other models
|
||||
admin.site.register(FormStage)
|
||||
admin.site.register(Application)
|
||||
admin.site.register(FormField)
|
||||
admin.site.register(FieldResponse)
|
||||
admin.site.register(InterviewSchedule)
|
||||
|
||||
@ -1,17 +1,163 @@
|
||||
from functools import wraps
|
||||
from datetime import date
|
||||
from django.shortcuts import redirect, get_object_or_404
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.http import HttpResponseNotFound, HttpResponseForbidden
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.mixins import AccessMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.contrib import messages
|
||||
|
||||
def job_not_expired(view_func):
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(request, job_id, *args, **kwargs):
|
||||
|
||||
|
||||
from .models import JobPosting
|
||||
job = get_object_or_404(JobPosting, pk=job_id)
|
||||
|
||||
if job.expiration_date and job.application_deadline< date.today():
|
||||
return redirect('expired_job_page')
|
||||
|
||||
|
||||
return view_func(request, job_id, *args, **kwargs)
|
||||
return _wrapped_view
|
||||
return _wrapped_view
|
||||
|
||||
|
||||
def user_type_required(allowed_types=None, login_url=None):
|
||||
"""
|
||||
Decorator to restrict view access based on user type.
|
||||
|
||||
Args:
|
||||
allowed_types (list): List of allowed user types ['staff', 'agency', 'candidate']
|
||||
login_url (str): URL to redirect to if user is not authenticated
|
||||
"""
|
||||
if allowed_types is None:
|
||||
allowed_types = ['staff']
|
||||
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
@login_required(login_url=login_url)
|
||||
def _wrapped_view(request, *args, **kwargs):
|
||||
user = request.user
|
||||
|
||||
# Check if user has user_type attribute
|
||||
if not hasattr(user, 'user_type') or not user.user_type:
|
||||
messages.error(request, "User type not specified. Please contact administrator.")
|
||||
return redirect('portal_login')
|
||||
|
||||
# Check if user type is allowed
|
||||
if user.user_type not in allowed_types:
|
||||
# Log unauthorized access attempt
|
||||
messages.error(
|
||||
request,
|
||||
f"Access denied. This page is restricted to {', '.join(allowed_types)} users."
|
||||
)
|
||||
|
||||
# Redirect based on user type
|
||||
if user.user_type == 'agency':
|
||||
return redirect('agency_portal_dashboard')
|
||||
elif user.user_type == 'candidate':
|
||||
return redirect('candidate_portal_dashboard')
|
||||
else:
|
||||
return redirect('dashboard')
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
return _wrapped_view
|
||||
return decorator
|
||||
|
||||
|
||||
class UserTypeRequiredMixin(AccessMixin):
|
||||
"""
|
||||
Mixin for class-based views to restrict access based on user type.
|
||||
"""
|
||||
allowed_user_types = ['staff'] # Default to staff only
|
||||
login_url = '/login/'
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission()
|
||||
|
||||
# Check if user has user_type attribute
|
||||
if not hasattr(request.user, 'user_type') or not request.user.user_type:
|
||||
messages.error(request, "User type not specified. Please contact administrator.")
|
||||
return redirect('portal_login')
|
||||
|
||||
# Check if user type is allowed
|
||||
if request.user.user_type not in self.allowed_user_types:
|
||||
# Log unauthorized access attempt
|
||||
messages.error(
|
||||
request,
|
||||
f"Access denied. This page is restricted to {', '.join(self.allowed_user_types)} users."
|
||||
)
|
||||
|
||||
# Redirect based on user type
|
||||
if request.user.user_type == 'agency':
|
||||
return redirect('agency_portal_dashboard')
|
||||
elif request.user.user_type == 'candidate':
|
||||
return redirect('candidate_portal_dashboard')
|
||||
else:
|
||||
return redirect('dashboard')
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def handle_no_permission(self):
|
||||
if self.request.user.is_authenticated:
|
||||
# User is authenticated but doesn't have permission
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Access denied. This page is restricted to {', '.join(self.allowed_user_types)} users."
|
||||
)
|
||||
return redirect('dashboard')
|
||||
else:
|
||||
# User is not authenticated
|
||||
return super().handle_no_permission()
|
||||
|
||||
|
||||
class StaffRequiredMixin(UserTypeRequiredMixin):
|
||||
"""Mixin to restrict access to staff users only."""
|
||||
allowed_user_types = ['staff']
|
||||
|
||||
|
||||
class AgencyRequiredMixin(UserTypeRequiredMixin):
|
||||
"""Mixin to restrict access to agency users only."""
|
||||
allowed_user_types = ['agency']
|
||||
login_url = '/portal/login/'
|
||||
|
||||
|
||||
class CandidateRequiredMixin(UserTypeRequiredMixin):
|
||||
"""Mixin to restrict access to candidate users only."""
|
||||
allowed_user_types = ['candidate']
|
||||
login_url = '/portal/login/'
|
||||
|
||||
|
||||
class StaffOrAgencyRequiredMixin(UserTypeRequiredMixin):
|
||||
"""Mixin to restrict access to staff and agency users."""
|
||||
allowed_user_types = ['staff', 'agency']
|
||||
|
||||
|
||||
class StaffOrCandidateRequiredMixin(UserTypeRequiredMixin):
|
||||
"""Mixin to restrict access to staff and candidate users."""
|
||||
allowed_user_types = ['staff', 'candidate']
|
||||
|
||||
|
||||
def agency_user_required(view_func):
|
||||
"""Decorator to restrict view to agency users only."""
|
||||
return user_type_required(['agency'], login_url='/portal/login/')(view_func)
|
||||
|
||||
|
||||
def candidate_user_required(view_func):
|
||||
"""Decorator to restrict view to candidate users only."""
|
||||
return user_type_required(['candidate'], login_url='/portal/login/')(view_func)
|
||||
|
||||
|
||||
def staff_user_required(view_func):
|
||||
"""Decorator to restrict view to staff users only."""
|
||||
return user_type_required(['staff'])(view_func)
|
||||
|
||||
|
||||
def staff_or_agency_required(view_func):
|
||||
"""Decorator to restrict view to staff and agency users."""
|
||||
return user_type_required(['staff', 'agency'], login_url='/portal/login/')(view_func)
|
||||
|
||||
|
||||
def staff_or_candidate_required(view_func):
|
||||
"""Decorator to restrict view to staff and candidate users."""
|
||||
return user_type_required(['staff', 'candidate'], login_url='/portal/login/')(view_func)
|
||||
|
||||
@ -11,7 +11,7 @@ User = get_user_model()
|
||||
import re
|
||||
from .models import (
|
||||
ZoomMeeting,
|
||||
Candidate,
|
||||
Application,
|
||||
TrainingMaterial,
|
||||
JobPosting,
|
||||
FormTemplate,
|
||||
@ -27,6 +27,7 @@ from .models import (
|
||||
AgencyAccessLink,
|
||||
Participants,
|
||||
Message,
|
||||
Person
|
||||
)
|
||||
|
||||
# from django_summernote.widgets import SummernoteWidget
|
||||
@ -262,42 +263,38 @@ class SourceAdvancedForm(forms.ModelForm):
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class CandidateForm(forms.ModelForm):
|
||||
class PersonForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Candidate
|
||||
model = Person
|
||||
fields = ["first_name","middle_name", "last_name", "email", "phone","date_of_birth","nationality","address","gender"]
|
||||
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'}),
|
||||
"gender": forms.Select(attrs={'class': 'form-control'}),
|
||||
"date_of_birth": forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||
"nationality": forms.Select(attrs={'class': 'form-control select2'}),
|
||||
"address": forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
class ApplicationForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Application
|
||||
fields = [
|
||||
'person',
|
||||
"job",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"phone",
|
||||
"email",
|
||||
"hiring_source",
|
||||
"hiring_agency",
|
||||
"resume",
|
||||
]
|
||||
labels = {
|
||||
"first_name": _("First Name"),
|
||||
"last_name": _("Last Name"),
|
||||
"phone": _("Phone"),
|
||||
"email": _("Email"),
|
||||
"resume": _("Resume"),
|
||||
"hiring_source": _("Hiring Type"),
|
||||
"hiring_agency": _("Hiring Agency"),
|
||||
}
|
||||
widgets = {
|
||||
"first_name": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": _("Enter first name")}
|
||||
),
|
||||
"last_name": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": _("Enter last name")}
|
||||
),
|
||||
"phone": forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": _("Enter phone number")}
|
||||
),
|
||||
"email": forms.EmailInput(
|
||||
attrs={"class": "form-control", "placeholder": _("Enter email")}
|
||||
),
|
||||
"stage": forms.Select(attrs={"class": "form-select"}),
|
||||
"hiring_source": forms.Select(attrs={"class": "form-select"}),
|
||||
"hiring_agency": forms.Select(attrs={"class": "form-select"}),
|
||||
}
|
||||
@ -317,23 +314,43 @@ class CandidateForm(forms.ModelForm):
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Field("job", css_class="form-control"),
|
||||
Field("first_name", css_class="form-control"),
|
||||
Field("last_name", css_class="form-control"),
|
||||
Field("phone", css_class="form-control"),
|
||||
Field("email", css_class="form-control"),
|
||||
Field("stage", css_class="form-control"),
|
||||
Field("hiring_source", css_class="form-control"),
|
||||
Field("hiring_agency", css_class="form-control"),
|
||||
Field("resume", css_class="form-control"),
|
||||
Submit("submit", _("Submit"), css_class="btn btn-primary"),
|
||||
)
|
||||
|
||||
# def save(self, commit=True):
|
||||
# """Override save to handle person creation/update"""
|
||||
# instance = super().save(commit=False)
|
||||
|
||||
class CandidateStageForm(forms.ModelForm):
|
||||
# # Get or create person
|
||||
# if instance.person:
|
||||
# person = instance.person
|
||||
# else:
|
||||
# # Create new person
|
||||
# from .models import Person
|
||||
# person = Person()
|
||||
|
||||
# # Update person fields
|
||||
# person.first_name = self.cleaned_data['first_name']
|
||||
# person.last_name = self.cleaned_data['last_name']
|
||||
# person.email = self.cleaned_data['email']
|
||||
# person.phone = self.cleaned_data['phone']
|
||||
|
||||
# if commit:
|
||||
# person.save()
|
||||
# instance.person = person
|
||||
# instance.save()
|
||||
|
||||
# return instance
|
||||
|
||||
|
||||
class ApplicationStageForm(forms.ModelForm):
|
||||
"""Form specifically for updating candidate stage with validation"""
|
||||
|
||||
class Meta:
|
||||
model = Candidate
|
||||
model = Application
|
||||
fields = ["stage"]
|
||||
labels = {
|
||||
"stage": _("New Application Stage"),
|
||||
@ -648,8 +665,8 @@ BreakTimeFormSet = formset_factory(BreakTimeForm, extra=1, can_delete=True)
|
||||
|
||||
|
||||
class InterviewScheduleForm(forms.ModelForm):
|
||||
candidates = forms.ModelMultipleChoiceField(
|
||||
queryset=Candidate.objects.none(),
|
||||
applications = forms.ModelMultipleChoiceField(
|
||||
queryset=Application.objects.none(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=True,
|
||||
)
|
||||
@ -670,7 +687,7 @@ class InterviewScheduleForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = InterviewSchedule
|
||||
fields = [
|
||||
"candidates",
|
||||
"applications",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"working_days",
|
||||
@ -706,7 +723,7 @@ class InterviewScheduleForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, slug, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["candidates"].queryset = Candidate.objects.filter(
|
||||
self.fields["applications"].queryset = Application.objects.filter(
|
||||
job__slug=slug, stage="Interview"
|
||||
)
|
||||
|
||||
@ -750,7 +767,7 @@ class MeetingCommentForm(forms.ModelForm):
|
||||
class InterviewForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ScheduledInterview
|
||||
fields = ["job", "candidate"]
|
||||
fields = ["job", "application"]
|
||||
|
||||
|
||||
class ProfileImageUploadForm(forms.ModelForm):
|
||||
@ -832,7 +849,7 @@ class FormTemplateIsActiveForm(forms.ModelForm):
|
||||
|
||||
class CandidateExamDateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Candidate
|
||||
model = Application
|
||||
fields = ["exam_date"]
|
||||
widgets = {
|
||||
"exam_date": forms.DateTimeInput(
|
||||
@ -1164,41 +1181,50 @@ class AgencyAccessLinkForm(forms.ModelForm):
|
||||
# Agency messaging forms removed - AgencyMessage model has been deleted
|
||||
|
||||
|
||||
class AgencyCandidateSubmissionForm(forms.ModelForm):
|
||||
class AgencyApplicationSubmissionForm(forms.ModelForm):
|
||||
"""Form for agencies to submit candidates (simplified - resume + basic info)"""
|
||||
|
||||
# Person fields for creating/updating person
|
||||
# first_name = forms.CharField(
|
||||
# max_length=255,
|
||||
# widget=forms.TextInput(attrs={
|
||||
# "class": "form-control",
|
||||
# "placeholder": "First Name",
|
||||
# "required": True,
|
||||
# }),
|
||||
# label=_("First Name")
|
||||
# )
|
||||
# last_name = forms.CharField(
|
||||
# max_length=255,
|
||||
# widget=forms.TextInput(attrs={
|
||||
# "class": "form-control",
|
||||
# "placeholder": "Last Name",
|
||||
# "required": True,
|
||||
# }),
|
||||
# label=_("Last Name")
|
||||
# )
|
||||
# email = forms.EmailField(
|
||||
# widget=forms.EmailInput(attrs={
|
||||
# "class": "form-control",
|
||||
# "placeholder": "email@example.com",
|
||||
# "required": True,
|
||||
# }),
|
||||
# label=_("Email Address")
|
||||
# )
|
||||
# phone = forms.CharField(
|
||||
# max_length=20,
|
||||
# widget=forms.TextInput(attrs={
|
||||
# "class": "form-control",
|
||||
# "placeholder": "+966 50 123 4567",
|
||||
# "required": True,
|
||||
# }),
|
||||
# label=_("Phone Number")
|
||||
# )
|
||||
|
||||
class Meta:
|
||||
model = Candidate
|
||||
fields = ["first_name", "last_name", "email", "phone", "resume"]
|
||||
model = Application
|
||||
fields = ["person","resume"]
|
||||
widgets = {
|
||||
"first_name": forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": "First Name",
|
||||
"required": True,
|
||||
}
|
||||
),
|
||||
"last_name": forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": "Last Name",
|
||||
"required": True,
|
||||
}
|
||||
),
|
||||
"email": forms.EmailInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": "email@example.com",
|
||||
"required": True,
|
||||
}
|
||||
),
|
||||
"phone": forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": "+966 50 123 4567",
|
||||
"required": True,
|
||||
}
|
||||
),
|
||||
"resume": forms.FileInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
@ -1208,10 +1234,6 @@ class AgencyCandidateSubmissionForm(forms.ModelForm):
|
||||
),
|
||||
}
|
||||
labels = {
|
||||
"first_name": _("First Name"),
|
||||
"last_name": _("Last Name"),
|
||||
"email": _("Email Address"),
|
||||
"phone": _("Phone Number"),
|
||||
"resume": _("Resume"),
|
||||
}
|
||||
|
||||
@ -1223,39 +1245,46 @@ class AgencyCandidateSubmissionForm(forms.ModelForm):
|
||||
self.helper.form_class = "g-3"
|
||||
self.helper.enctype = "multipart/form-data"
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("first_name", css_class="col-md-6"),
|
||||
Column("last_name", css_class="col-md-6"),
|
||||
css_class="g-3 mb-3",
|
||||
),
|
||||
Row(
|
||||
Column("email", css_class="col-md-6"),
|
||||
Column("phone", css_class="col-md-6"),
|
||||
css_class="g-3 mb-3",
|
||||
),
|
||||
Field("resume", css_class="form-control"),
|
||||
Div(
|
||||
Submit(
|
||||
"submit", _("Submit Candidate"), css_class="btn btn-main-action"
|
||||
),
|
||||
css_class="col-12 mt-4",
|
||||
),
|
||||
)
|
||||
# self.helper.layout = Layout(
|
||||
# Row(
|
||||
# Column("first_name", css_class="col-md-6"),
|
||||
# Column("last_name", css_class="col-md-6"),
|
||||
# css_class="g-3 mb-3",
|
||||
# ),
|
||||
# Row(
|
||||
# Column("email", css_class="col-md-6"),
|
||||
# Column("phone", css_class="col-md-6"),
|
||||
# css_class="g-3 mb-3",
|
||||
# ),
|
||||
# Field("resume", css_class="form-control"),
|
||||
# Div(
|
||||
# Submit(
|
||||
# "submit", _("Submit Candidate"), css_class="btn btn-main-action"
|
||||
# ),
|
||||
# css_class="col-12 mt-4",
|
||||
# ),
|
||||
# )
|
||||
|
||||
def clean_email(self):
|
||||
"""Validate email format and check for duplicates in the same job"""
|
||||
email = self.cleaned_data.get("email")
|
||||
if email:
|
||||
# Check if candidate with this email already exists for this job
|
||||
existing_candidate = Candidate.objects.filter(
|
||||
email=email.lower().strip(), job=self.assignment.job
|
||||
# Check if person with this email already exists for this job
|
||||
from .models import Person
|
||||
existing_person = Person.objects.filter(
|
||||
email=email.lower().strip()
|
||||
).first()
|
||||
|
||||
if existing_candidate:
|
||||
raise ValidationError(
|
||||
f"A candidate with this email has already applied for {self.assignment.job.title}."
|
||||
)
|
||||
if existing_person:
|
||||
# Check if this person already has an application for this job
|
||||
existing_application = Application.objects.filter(
|
||||
person=existing_person, job=self.assignment.job
|
||||
).first()
|
||||
|
||||
if existing_application:
|
||||
raise ValidationError(
|
||||
f"A candidate with this email has already applied for {self.assignment.job.title}."
|
||||
)
|
||||
return email.lower().strip() if email else email
|
||||
|
||||
def clean_resume(self):
|
||||
@ -1277,11 +1306,30 @@ class AgencyCandidateSubmissionForm(forms.ModelForm):
|
||||
"""Override save to set additional fields"""
|
||||
instance = super().save(commit=False)
|
||||
|
||||
# Create or get person
|
||||
from .models import Person
|
||||
person, created = Person.objects.get_or_create(
|
||||
email=self.cleaned_data['email'].lower().strip(),
|
||||
defaults={
|
||||
'first_name': self.cleaned_data['first_name'],
|
||||
'last_name': self.cleaned_data['last_name'],
|
||||
'phone': self.cleaned_data['phone'],
|
||||
}
|
||||
)
|
||||
|
||||
if not created:
|
||||
# Update existing person with new info
|
||||
person.first_name = self.cleaned_data['first_name']
|
||||
person.last_name = self.cleaned_data['last_name']
|
||||
person.phone = self.cleaned_data['phone']
|
||||
person.save()
|
||||
|
||||
# Set required fields for agency submission
|
||||
instance.person = person
|
||||
instance.job = self.assignment.job
|
||||
instance.hiring_agency = self.assignment.agency
|
||||
instance.stage = Candidate.Stage.APPLIED
|
||||
instance.applicant_status = Candidate.ApplicantType.CANDIDATE
|
||||
instance.stage = Application.Stage.APPLIED
|
||||
instance.applicant_status = Application.ApplicantType.CANDIDATE
|
||||
instance.applied = True
|
||||
|
||||
if commit:
|
||||
@ -1586,7 +1634,7 @@ class CandidateEmailForm(forms.Form):
|
||||
return list(set(email_addresses)) # Remove duplicates
|
||||
|
||||
def get_formatted_message(self):
|
||||
"""Get the formatted message with optional additional information"""
|
||||
"""Get formatted message with optional additional information"""
|
||||
message = self.cleaned_data.get("message", "")
|
||||
|
||||
# Add candidate information if requested
|
||||
@ -1777,13 +1825,15 @@ class MessageForm(forms.ModelForm):
|
||||
)
|
||||
|
||||
class CandidateSignupForm(forms.Form):
|
||||
first_name = forms.CharField(max_length=30, required=True)
|
||||
middle_name = forms.CharField(max_length=30, required=False)
|
||||
last_name = forms.CharField(max_length=30, required=True)
|
||||
email = forms.EmailField(max_length=254, required=True)
|
||||
phone = forms.CharField(max_length=30, required=True)
|
||||
password = forms.CharField(widget=forms.PasswordInput, required=True)
|
||||
confirm_password = forms.CharField(widget=forms.PasswordInput, required=True)
|
||||
"""Form for candidate signup creating Person and Application"""
|
||||
|
||||
first_name = forms.CharField(max_length=30, required=True, widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "First Name"}))
|
||||
middle_name = forms.CharField(max_length=30, required=False, widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Middle Name (optional)"}))
|
||||
last_name = forms.CharField(max_length=30, required=True, widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Last Name"}))
|
||||
email = forms.EmailField(max_length=254, required=True, widget=forms.EmailInput(attrs={"class": "form-control", "placeholder": "Email Address"}))
|
||||
phone = forms.CharField(max_length=30, required=True, widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Phone Number"}))
|
||||
password = forms.CharField(widget=forms.PasswordInput(attrs={"class": "form-control", "placeholder": "Password"}), required=True)
|
||||
confirm_password = forms.CharField(widget=forms.PasswordInput(attrs={"class": "form-control", "placeholder": "Confirm Password"}), required=True)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
@ -1793,4 +1843,43 @@ class CandidateSignupForm(forms.Form):
|
||||
if password != confirm_password:
|
||||
raise forms.ValidationError("Passwords do not match.")
|
||||
|
||||
return cleaned_data
|
||||
return cleaned_data
|
||||
|
||||
def save(self, job):
|
||||
"""Create Person and Application objects"""
|
||||
from .models import Person, Application
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
# Create Person first
|
||||
person = Person.objects.create(
|
||||
first_name=self.cleaned_data['first_name'],
|
||||
middle_name=self.cleaned_data.get('middle_name', ''),
|
||||
last_name=self.cleaned_data['last_name'],
|
||||
email=self.cleaned_data['email'],
|
||||
phone=self.cleaned_data['phone'],
|
||||
)
|
||||
|
||||
# Create User account
|
||||
user = User.objects.create_user(
|
||||
username=self.cleaned_data['email'], # Use email as username
|
||||
email=self.cleaned_data['email'],
|
||||
password=make_password(self.cleaned_data['password']),
|
||||
first_name=self.cleaned_data['first_name'],
|
||||
last_name=self.cleaned_data['last_name'],
|
||||
user_type='candidate'
|
||||
)
|
||||
|
||||
# Link User to Person
|
||||
person.user = user
|
||||
person.save()
|
||||
|
||||
# Create Application
|
||||
application = Application.objects.create(
|
||||
person=person,
|
||||
job=job,
|
||||
stage=Application.Stage.APPLIED,
|
||||
applicant_status=Application.ApplicantType.CANDIDATE,
|
||||
applied=True,
|
||||
)
|
||||
|
||||
return application
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-09 15:04
|
||||
# Generated by Django 5.2.6 on 2025-11-10 14:13
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
@ -145,6 +145,51 @@ class Migration(migrations.Migration):
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Application',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')),
|
||||
('cover_letter', models.FileField(blank=True, null=True, upload_to='cover_letters/', verbose_name='Cover Letter')),
|
||||
('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')),
|
||||
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
|
||||
('applied', models.BooleanField(default=False, verbose_name='Applied')),
|
||||
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired'), ('Rejected', 'Rejected')], db_index=True, default='Applied', max_length=20, verbose_name='Stage')),
|
||||
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=20, null=True, verbose_name='Applicant Status')),
|
||||
('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
|
||||
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Exam Status')),
|
||||
('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
|
||||
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Interview Status')),
|
||||
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
|
||||
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected'), ('Pending', 'Pending')], max_length=20, null=True, verbose_name='Offer Status')),
|
||||
('hired_date', models.DateField(blank=True, null=True, verbose_name='Hired Date')),
|
||||
('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
|
||||
('ai_analysis_data', models.JSONField(blank=True, default=dict, help_text='Full JSON output from the resume scoring model.', null=True, verbose_name='AI Analysis Data')),
|
||||
('retry', models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry')),
|
||||
('hiring_source', models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source')),
|
||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='application_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Application',
|
||||
'verbose_name_plural': 'Applications',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Candidate',
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Candidate (Legacy)',
|
||||
'verbose_name_plural': 'Candidates (Legacy)',
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
bases=('recruitment.application',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FormField',
|
||||
fields=[
|
||||
@ -236,43 +281,10 @@ class Migration(migrations.Migration):
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Candidate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('first_name', models.CharField(max_length=255, verbose_name='First Name')),
|
||||
('last_name', models.CharField(max_length=255, verbose_name='Last Name')),
|
||||
('email', models.EmailField(db_index=True, max_length=254, verbose_name='Email')),
|
||||
('phone', models.CharField(max_length=20, verbose_name='Phone')),
|
||||
('address', models.TextField(max_length=200, verbose_name='Address')),
|
||||
('resume', models.FileField(upload_to='resumes/', verbose_name='Resume')),
|
||||
('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')),
|
||||
('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')),
|
||||
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
|
||||
('applied', models.BooleanField(default=False, verbose_name='Applied')),
|
||||
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')),
|
||||
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')),
|
||||
('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
|
||||
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')),
|
||||
('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
|
||||
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status')),
|
||||
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
|
||||
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')),
|
||||
('hired_date', models.DateField(blank=True, null=True, verbose_name='Hired Date')),
|
||||
('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
|
||||
('ai_analysis_data', models.JSONField(blank=True, default=dict, help_text='Full JSON output from the resume scoring model.', null=True, verbose_name='AI Analysis Data')),
|
||||
('retry', models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry')),
|
||||
('hiring_source', models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source')),
|
||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='candidate_profile', to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
('hiring_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Candidate',
|
||||
'verbose_name_plural': 'Candidates',
|
||||
},
|
||||
migrations.AddField(
|
||||
model_name='application',
|
||||
name='hiring_agency',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applications', to='recruitment.hiringagency', verbose_name='Hiring Agency'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='JobPosting',
|
||||
@ -341,7 +353,7 @@ class Migration(migrations.Migration):
|
||||
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
|
||||
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
|
||||
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
|
||||
('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.candidate')),
|
||||
('candidates', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.application')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
|
||||
],
|
||||
@ -352,9 +364,9 @@ class Migration(migrations.Migration):
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='form_template', to='recruitment.jobposting'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
model_name='application',
|
||||
name='job',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'),
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.jobposting', verbose_name='Job'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AgencyJobAssignment',
|
||||
@ -411,6 +423,55 @@ class Migration(migrations.Migration):
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Person',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('first_name', models.CharField(max_length=255, verbose_name='First Name')),
|
||||
('last_name', models.CharField(max_length=255, verbose_name='Last Name')),
|
||||
('middle_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Middle Name')),
|
||||
('email', models.EmailField(db_index=True, help_text='Unique email address for the person', max_length=254, unique=True, verbose_name='Email')),
|
||||
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
|
||||
('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')),
|
||||
('gender', models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female'), ('O', 'Other'), ('P', 'Prefer not to say')], max_length=1, null=True, verbose_name='Gender')),
|
||||
('nationality', django_countries.fields.CountryField(blank=True, max_length=2, null=True, verbose_name='Nationality')),
|
||||
('address', models.TextField(blank=True, null=True, verbose_name='Address')),
|
||||
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')),
|
||||
('linkedin_profile', models.URLField(blank=True, null=True, verbose_name='LinkedIn Profile URL')),
|
||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='person_profile', to=settings.AUTH_USER_MODEL, verbose_name='User Account')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Person',
|
||||
'verbose_name_plural': 'People',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Document',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('file', models.FileField(upload_to='candidate_documents/%Y/%m/', validators=[recruitment.validators.validate_image_size], verbose_name='Document File')),
|
||||
('document_type', models.CharField(choices=[('resume', 'Resume'), ('cover_letter', 'Cover Letter'), ('certificate', 'Certificate'), ('id_document', 'ID Document'), ('passport', 'Passport'), ('education', 'Education Document'), ('experience', 'Experience Letter'), ('other', 'Other')], default='other', max_length=20, verbose_name='Document Type')),
|
||||
('description', models.CharField(blank=True, max_length=200, verbose_name='Description')),
|
||||
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Uploaded By')),
|
||||
('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='recruitment.person', verbose_name='Person')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document',
|
||||
'verbose_name_plural': 'Documents',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='application',
|
||||
name='person',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.person', verbose_name='Person'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Profile',
|
||||
fields=[
|
||||
@ -490,7 +551,7 @@ class Migration(migrations.Migration):
|
||||
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')),
|
||||
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
|
||||
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')),
|
||||
('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
|
||||
@ -598,14 +659,6 @@ class Migration(migrations.Migration):
|
||||
model_name='formtemplate',
|
||||
index=models.Index(fields=['is_active'], name='recruitment_is_acti_ae5efb_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='candidate',
|
||||
index=models.Index(fields=['stage'], name='recruitment_stage_f1c6eb_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='candidate',
|
||||
index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencyjobassignment',
|
||||
index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'),
|
||||
@ -642,6 +695,42 @@ class Migration(migrations.Migration):
|
||||
model_name='message',
|
||||
index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='person',
|
||||
index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='person',
|
||||
index=models.Index(fields=['first_name', 'last_name'], name='recruitment_first_n_739de5_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='person',
|
||||
index=models.Index(fields=['created_at'], name='recruitment_created_33495a_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='document',
|
||||
index=models.Index(fields=['person', 'document_type', 'created_at'], name='recruitment_person__0a6844_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='application',
|
||||
index=models.Index(fields=['person', 'job'], name='recruitment_person__34355c_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='application',
|
||||
index=models.Index(fields=['stage'], name='recruitment_stage_52c2d1_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='application',
|
||||
index=models.Index(fields=['created_at'], name='recruitment_created_80633f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='application',
|
||||
index=models.Index(fields=['person', 'stage', 'created_at'], name='recruitment_person__8715ec_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='application',
|
||||
unique_together={('person', 'job')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='jobposting',
|
||||
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
|
||||
@ -660,7 +749,7 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='scheduledinterview',
|
||||
index=models.Index(fields=['candidate', 'job'], name='recruitment_candida_43d5b0_idx'),
|
||||
index=models.Index(fields=['application', 'job'], name='recruitment_applica_927561_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='notification',
|
||||
|
||||
25
recruitment/migrations/0002_delete_candidate_and_more.py
Normal file
25
recruitment/migrations/0002_delete_candidate_and_more.py
Normal file
@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-11 10:17
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='Candidate',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='interviewschedule',
|
||||
old_name='candidates',
|
||||
new_name='applications',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='application',
|
||||
name='user',
|
||||
),
|
||||
]
|
||||
@ -1,37 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-09 19:56
|
||||
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import recruitment.validators
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Document',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('file', models.FileField(upload_to='candidate_documents/%Y/%m/', validators=[recruitment.validators.validate_image_size], verbose_name='Document File')),
|
||||
('document_type', models.CharField(choices=[('resume', 'Resume'), ('cover_letter', 'Cover Letter'), ('certificate', 'Certificate'), ('id_document', 'ID Document'), ('passport', 'Passport'), ('education', 'Education Document'), ('experience', 'Experience Letter'), ('other', 'Other')], default='other', max_length=20, verbose_name='Document Type')),
|
||||
('description', models.CharField(blank=True, max_length=200, verbose_name='Description')),
|
||||
('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='recruitment.candidate', verbose_name='Candidate')),
|
||||
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Uploaded By')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document',
|
||||
'verbose_name_plural': 'Documents',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['candidate', 'document_type', 'created_at'], name='recruitment_candida_f6ec68_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-11 12:13
|
||||
|
||||
import django.db.models.deletion
|
||||
import recruitment.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('recruitment', '0002_delete_candidate_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='document',
|
||||
name='recruitment_person__0a6844_idx',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='document',
|
||||
name='person',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='content_type',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Content Type'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='object_id',
|
||||
field=models.PositiveIntegerField(default=1, verbose_name='Object ID'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='file',
|
||||
field=models.FileField(upload_to='documents/%Y/%m/', validators=[recruitment.validators.validate_image_size], verbose_name='Document File'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='document',
|
||||
index=models.Index(fields=['content_type', 'object_id', 'document_type', 'created_at'], name='recruitment_content_547650_idx'),
|
||||
),
|
||||
]
|
||||
19
recruitment/migrations/0004_person_agency.py
Normal file
19
recruitment/migrations/0004_person_agency.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-12 20:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0003_convert_document_to_generic_fk'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='agency',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recruitment.hiringagency', verbose_name='Hiring Agency'),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@ -16,6 +16,8 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_extensions.db.fields import RandomCharField
|
||||
from .validators import validate_hash_tags, validate_image_size
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
|
||||
class CustomUser(AbstractUser):
|
||||
@ -355,7 +357,7 @@ class JobPosting(Base):
|
||||
@property
|
||||
def current_applications_count(self):
|
||||
"""Returns the current number of candidates associated with this job."""
|
||||
return self.candidates.count()
|
||||
return self.applications.count()
|
||||
|
||||
@property
|
||||
def is_application_limit_reached(self):
|
||||
@ -367,7 +369,7 @@ class JobPosting(Base):
|
||||
|
||||
@property
|
||||
def all_candidates(self):
|
||||
return self.candidates.annotate(
|
||||
return self.applications.annotate(
|
||||
sortable_score=Coalesce(
|
||||
Cast(
|
||||
"ai_analysis_data__analysis_data__match_score",
|
||||
@ -439,7 +441,7 @@ class JobPosting(Base):
|
||||
def vacancy_fill_rate(self):
|
||||
total_positions = self.open_positions
|
||||
|
||||
no_of_positions_filled = self.candidates.filter(stage__in=["HIRED"]).count()
|
||||
no_of_positions_filled = self.applications.filter(stage__in=["HIRED"]).count()
|
||||
|
||||
if total_positions > 0:
|
||||
vacancy_fill_rate = no_of_positions_filled / total_positions
|
||||
@ -456,21 +458,121 @@ class JobPostingImage(models.Model):
|
||||
post_image = models.ImageField(upload_to="post/", validators=[validate_image_size])
|
||||
|
||||
|
||||
class Candidate(Base):
|
||||
class Person(Base):
|
||||
"""Model to store personal information that can be reused across multiple applications"""
|
||||
|
||||
GENDER_CHOICES = [
|
||||
("M", _("Male")),
|
||||
("F", _("Female")),
|
||||
("O", _("Other")),
|
||||
("P", _("Prefer not to say")),
|
||||
]
|
||||
|
||||
# Personal Information
|
||||
first_name = models.CharField(max_length=255, verbose_name=_("First Name"))
|
||||
last_name = models.CharField(max_length=255, verbose_name=_("Last Name"))
|
||||
middle_name = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Middle Name"))
|
||||
email = models.EmailField(
|
||||
unique=True,
|
||||
db_index=True,
|
||||
verbose_name=_("Email"),
|
||||
help_text=_("Unique email address for the person")
|
||||
)
|
||||
phone = models.CharField(max_length=20, blank=True, null=True, verbose_name=_("Phone"))
|
||||
date_of_birth = models.DateField(null=True, blank=True, verbose_name=_("Date of Birth"))
|
||||
gender = models.CharField(
|
||||
max_length=1,
|
||||
choices=GENDER_CHOICES,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Gender")
|
||||
)
|
||||
nationality = CountryField(blank=True, null=True, verbose_name=_("Nationality"))
|
||||
address = models.TextField(blank=True, null=True, verbose_name=_("Address"))
|
||||
|
||||
# Optional linking to user account
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="person_profile",
|
||||
verbose_name=_("User Account"),
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
# Profile information
|
||||
profile_image = models.ImageField(
|
||||
null=True,
|
||||
blank=True,
|
||||
upload_to="profile_pic/",
|
||||
validators=[validate_image_size],
|
||||
verbose_name=_("Profile Image")
|
||||
)
|
||||
linkedin_profile = models.URLField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("LinkedIn Profile URL")
|
||||
)
|
||||
agency = models.ForeignKey(
|
||||
"HiringAgency",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Hiring Agency")
|
||||
)
|
||||
class Meta:
|
||||
verbose_name = _("Person")
|
||||
verbose_name_plural = _("People")
|
||||
indexes = [
|
||||
models.Index(fields=["email"]),
|
||||
models.Index(fields=["first_name", "last_name"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
|
||||
@property
|
||||
def age(self):
|
||||
"""Calculate age from date of birth"""
|
||||
if self.date_of_birth:
|
||||
today = timezone.now().date()
|
||||
return today.year - self.date_of_birth.year - (
|
||||
(today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day)
|
||||
)
|
||||
return None
|
||||
|
||||
@property
|
||||
def documents(self):
|
||||
"""Return all documents associated with this Person"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
content_type = ContentType.objects.get_for_model(self.__class__)
|
||||
return Document.objects.filter(content_type=content_type, object_id=self.id)
|
||||
|
||||
|
||||
class Application(Base):
|
||||
"""Model to store job-specific application data"""
|
||||
|
||||
class Stage(models.TextChoices):
|
||||
APPLIED = "Applied", _("Applied")
|
||||
EXAM = "Exam", _("Exam")
|
||||
INTERVIEW = "Interview", _("Interview")
|
||||
OFFER = "Offer", _("Offer")
|
||||
HIRED = "Hired", _("Hired")
|
||||
REJECTED = "Rejected", _("Rejected")
|
||||
|
||||
class ExamStatus(models.TextChoices):
|
||||
PASSED = "Passed", _("Passed")
|
||||
FAILED = "Failed", _("Failed")
|
||||
|
||||
class Status(models.TextChoices):
|
||||
class OfferStatus(models.TextChoices):
|
||||
ACCEPTED = "Accepted", _("Accepted")
|
||||
REJECTED = "Rejected", _("Rejected")
|
||||
PENDING = "Pending", _("Pending")
|
||||
|
||||
class ApplicantType(models.TextChoices):
|
||||
APPLICANT = "Applicant", _("Applicant")
|
||||
@ -478,43 +580,50 @@ class Candidate(Base):
|
||||
|
||||
# Stage transition validation constants
|
||||
STAGE_SEQUENCE = {
|
||||
"Applied": ["Exam", "Interview", "Offer"],
|
||||
"Exam": ["Interview", "Offer"],
|
||||
"Interview": ["Offer"],
|
||||
"Offer": [], # Final stage - no further transitions
|
||||
"Applied": ["Exam", "Interview", "Offer", "Rejected"],
|
||||
"Exam": ["Interview", "Offer", "Rejected"],
|
||||
"Interview": ["Offer", "Rejected"],
|
||||
"Offer": ["Hired", "Rejected"],
|
||||
"Rejected": [], # Final stage - no further transitions
|
||||
"Hired": [], # Final stage - no further transitions
|
||||
}
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
# Core relationships
|
||||
person = models.ForeignKey(
|
||||
Person,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="candidate_profile",
|
||||
verbose_name=_("User"),
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="applications",
|
||||
verbose_name=_("Person"),
|
||||
)
|
||||
job = models.ForeignKey(
|
||||
JobPosting,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="candidates",
|
||||
related_name="applications",
|
||||
verbose_name=_("Job"),
|
||||
)
|
||||
first_name = models.CharField(max_length=255, verbose_name=_("First Name"))
|
||||
last_name = models.CharField(max_length=255, verbose_name=_("Last Name"))
|
||||
email = models.EmailField(db_index=True, verbose_name=_("Email")) # Added index
|
||||
phone = models.CharField(max_length=20, verbose_name=_("Phone"))
|
||||
address = models.TextField(max_length=200, verbose_name=_("Address"))
|
||||
|
||||
# Application-specific data
|
||||
resume = models.FileField(upload_to="resumes/", verbose_name=_("Resume"))
|
||||
cover_letter = models.FileField(
|
||||
upload_to="cover_letters/",
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Cover Letter")
|
||||
)
|
||||
is_resume_parsed = models.BooleanField(
|
||||
default=False, verbose_name=_("Resume Parsed")
|
||||
default=False,
|
||||
verbose_name=_("Resume Parsed")
|
||||
)
|
||||
is_potential_candidate = models.BooleanField(
|
||||
default=False, verbose_name=_("Potential Candidate")
|
||||
parsed_summary = models.TextField(
|
||||
blank=True,
|
||||
verbose_name=_("Parsed Summary")
|
||||
)
|
||||
parsed_summary = models.TextField(blank=True, verbose_name=_("Parsed Summary"))
|
||||
|
||||
# Workflow fields
|
||||
applied = models.BooleanField(default=False, verbose_name=_("Applied"))
|
||||
stage = models.CharField(
|
||||
db_index=True,
|
||||
max_length=100, # Added index
|
||||
max_length=20,
|
||||
default="Applied",
|
||||
choices=Stage.choices,
|
||||
verbose_name=_("Stage"),
|
||||
@ -522,15 +631,17 @@ class Candidate(Base):
|
||||
applicant_status = models.CharField(
|
||||
choices=ApplicantType.choices,
|
||||
default="Applicant",
|
||||
max_length=100,
|
||||
max_length=20,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Applicant Status"),
|
||||
)
|
||||
|
||||
# Timeline fields
|
||||
exam_date = models.DateTimeField(null=True, blank=True, verbose_name=_("Exam Date"))
|
||||
exam_status = models.CharField(
|
||||
choices=ExamStatus.choices,
|
||||
max_length=100,
|
||||
max_length=20,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Exam Status"),
|
||||
@ -540,30 +651,36 @@ class Candidate(Base):
|
||||
)
|
||||
interview_status = models.CharField(
|
||||
choices=ExamStatus.choices,
|
||||
max_length=100,
|
||||
max_length=20,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Interview Status"),
|
||||
)
|
||||
offer_date = models.DateField(null=True, blank=True, verbose_name=_("Offer Date"))
|
||||
offer_status = models.CharField(
|
||||
choices=Status.choices,
|
||||
max_length=100,
|
||||
choices=OfferStatus.choices,
|
||||
max_length=20,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Offer Status"),
|
||||
)
|
||||
hired_date = models.DateField(null=True, blank=True, verbose_name=_("Hired Date"))
|
||||
join_date = models.DateField(null=True, blank=True, verbose_name=_("Join Date"))
|
||||
|
||||
# AI Analysis
|
||||
ai_analysis_data = models.JSONField(
|
||||
verbose_name="AI Analysis Data",
|
||||
default=dict,
|
||||
help_text="Full JSON output from the resume scoring model.",
|
||||
null=True,
|
||||
blank=True,
|
||||
) # {'resume_data': {}, 'analysis_data': {}}
|
||||
)
|
||||
retry = models.SmallIntegerField(
|
||||
verbose_name="Resume Parsing Retry",
|
||||
default=3
|
||||
)
|
||||
|
||||
retry = models.SmallIntegerField(verbose_name="Resume Parsing Retry", default=3)
|
||||
# Source tracking
|
||||
hiring_source = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
@ -581,27 +698,36 @@ class Candidate(Base):
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="candidates",
|
||||
related_name="applications",
|
||||
verbose_name=_("Hiring Agency"),
|
||||
)
|
||||
|
||||
# Optional linking to user account (for candidate portal access)
|
||||
# user = models.OneToOneField(
|
||||
# User,
|
||||
# on_delete=models.SET_NULL,
|
||||
# related_name="application_profile",
|
||||
# verbose_name=_("User Account"),
|
||||
# null=True,
|
||||
# blank=True,
|
||||
# )
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Candidate")
|
||||
verbose_name_plural = _("Candidates")
|
||||
verbose_name = _("Application")
|
||||
verbose_name_plural = _("Applications")
|
||||
indexes = [
|
||||
models.Index(fields=["person", "job"]),
|
||||
models.Index(fields=["stage"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
models.Index(fields=["person", "stage", "created_at"]),
|
||||
]
|
||||
unique_together = [["person", "job"]] # Prevent duplicate applications
|
||||
|
||||
def set_field(self, key: str, value: Any):
|
||||
"""
|
||||
Generic method to set any single key-value pair and save.
|
||||
"""
|
||||
self.ai_analysis_data[key] = value
|
||||
# self.save(update_fields=['ai_analysis_data'])
|
||||
def __str__(self):
|
||||
return f"{self.person.full_name} - {self.job.title}"
|
||||
|
||||
# ====================================================================
|
||||
# ✨ PROPERTIES (GETTERS)
|
||||
# ✨ PROPERTIES (GETTERS) - Migrated from Candidate
|
||||
# ====================================================================
|
||||
@property
|
||||
def resume_data(self):
|
||||
@ -629,11 +755,8 @@ class Candidate(Base):
|
||||
@property
|
||||
def industry_match_score(self) -> int:
|
||||
"""16. A score (0-100) for the relevance of the candidate's industry experience."""
|
||||
# Renamed to clarify: experience_industry_match
|
||||
return self.analysis_data.get("experience_industry_match", 0)
|
||||
|
||||
# --- Properties for Funnel & Screening Efficiency ---
|
||||
|
||||
@property
|
||||
def min_requirements_met(self) -> bool:
|
||||
"""14. Boolean (true/false) indicating if all non-negotiable minimum requirements are met."""
|
||||
@ -654,8 +777,6 @@ class Candidate(Base):
|
||||
"""8. The candidate's most recent or current professional job title."""
|
||||
return self.analysis_data.get("most_recent_job_title", "N/A")
|
||||
|
||||
# --- Properties for Structured Detail ---
|
||||
|
||||
@property
|
||||
def criteria_checklist(self) -> Dict[str, str]:
|
||||
"""5 & 6. An object rating the candidate's match for each specific criterion."""
|
||||
@ -671,8 +792,6 @@ class Candidate(Base):
|
||||
"""12. A list of languages and their fluency levels mentioned."""
|
||||
return self.analysis_data.get("language_fluency", [])
|
||||
|
||||
# --- Properties for Summaries and Narrative ---
|
||||
|
||||
@property
|
||||
def strengths(self) -> str:
|
||||
"""2. A brief summary of why the candidate is a strong fit."""
|
||||
@ -691,55 +810,94 @@ class Candidate(Base):
|
||||
@property
|
||||
def recommendation(self) -> str:
|
||||
"""9. Provide a detailed final recommendation for the candidate."""
|
||||
# Using a more descriptive name to avoid conflict with potential built-in methods
|
||||
return self.analysis_data.get("recommendation", "")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def get_file_size(self):
|
||||
if self.resume:
|
||||
return self.resume.size
|
||||
return 0
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to ensure validation is called"""
|
||||
self.clean() # Call validation before saving
|
||||
super().save(*args, **kwargs)
|
||||
# ====================================================================
|
||||
# 🔄 HELPER METHODS
|
||||
# ====================================================================
|
||||
def set_field(self, key: str, value: Any):
|
||||
"""Generic method to set any single key-value pair and save."""
|
||||
self.ai_analysis_data[key] = value
|
||||
|
||||
def get_available_stages(self):
|
||||
"""Get list of stages this candidate can transition to"""
|
||||
"""Get list of stages this application can transition to"""
|
||||
if not self.pk: # New record
|
||||
return ["Applied"]
|
||||
|
||||
old_stage = self.__class__.objects.get(pk=self.pk).stage
|
||||
return self.STAGE_SEQUENCE.get(old_stage, [])
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Override save to ensure validation is called"""
|
||||
self.clean() # Call validation before saving
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# ====================================================================
|
||||
# 📋 LEGACY COMPATIBILITY PROPERTIES
|
||||
# ====================================================================
|
||||
# These properties maintain compatibility with existing code that expects Candidate model
|
||||
@property
|
||||
def first_name(self):
|
||||
"""Legacy compatibility - delegates to person.first_name"""
|
||||
return self.person.first_name
|
||||
|
||||
@property
|
||||
def last_name(self):
|
||||
"""Legacy compatibility - delegates to person.last_name"""
|
||||
return self.person.last_name
|
||||
|
||||
@property
|
||||
def email(self):
|
||||
"""Legacy compatibility - delegates to person.email"""
|
||||
return self.person.email
|
||||
|
||||
@property
|
||||
def phone(self):
|
||||
"""Legacy compatibility - delegates to person.phone if available"""
|
||||
return self.person.phone or ""
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
"""Legacy compatibility - delegates to person.address if available"""
|
||||
return self.person.address or ""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Legacy compatibility - delegates to person.full_name"""
|
||||
return self.person.full_name
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
"""Legacy compatibility - delegates to person.full_name"""
|
||||
return self.person.full_name
|
||||
|
||||
@property
|
||||
def get_file_size(self):
|
||||
"""Legacy compatibility - returns resume file size"""
|
||||
if self.resume:
|
||||
return self.resume.size
|
||||
return 0
|
||||
|
||||
@property
|
||||
def submission(self):
|
||||
"""Legacy compatibility - get form submission for this application"""
|
||||
return FormSubmission.objects.filter(template__job=self.job).first()
|
||||
|
||||
@property
|
||||
def responses(self):
|
||||
"""Legacy compatibility - get form responses for this application"""
|
||||
if self.submission:
|
||||
return self.submission.responses.all()
|
||||
return []
|
||||
|
||||
def __str__(self):
|
||||
return self.full_name
|
||||
|
||||
@property
|
||||
def get_meetings(self):
|
||||
"""Legacy compatibility - get scheduled interviews for this application"""
|
||||
return self.scheduled_interviews.all()
|
||||
|
||||
@property
|
||||
def get_latest_meeting(self):
|
||||
"""Legacy compatibility - get latest meeting for this application"""
|
||||
schedule = self.scheduled_interviews.order_by("-created_at").first()
|
||||
if schedule:
|
||||
return schedule.zoom_meeting
|
||||
@ -747,49 +905,71 @@ class Candidate(Base):
|
||||
|
||||
@property
|
||||
def has_future_meeting(self):
|
||||
"""
|
||||
Checks if the candidate has any scheduled interviews for a future date/time.
|
||||
"""
|
||||
# Ensure timezone.now() is used for comparison
|
||||
"""Legacy compatibility - check for future meetings"""
|
||||
now = timezone.now()
|
||||
# Check if any related ScheduledInterview has a future interview_date and interview_time
|
||||
# We need to combine date and time for a proper datetime comparison if they are separate fields
|
||||
future_meetings = (
|
||||
self.scheduled_interviews.filter(interview_date__gt=now.date())
|
||||
.filter(interview_time__gte=now.time())
|
||||
.exists()
|
||||
)
|
||||
|
||||
# Also check for interviews happening later today
|
||||
today_future_meetings = self.scheduled_interviews.filter(
|
||||
interview_date=now.date(), interview_time__gte=now.time()
|
||||
).exists()
|
||||
|
||||
return future_meetings or today_future_meetings
|
||||
|
||||
@property
|
||||
def scoring_timeout(self):
|
||||
"""Legacy compatibility - check scoring timeout"""
|
||||
return timezone.now() <= (self.created_at + timezone.timedelta(minutes=5))
|
||||
|
||||
@property
|
||||
def get_interview_date(self):
|
||||
"""Legacy compatibility - get interview date"""
|
||||
if hasattr(self, "scheduled_interview") and self.scheduled_interview:
|
||||
return self.scheduled_interviews.first().interview_date
|
||||
return None
|
||||
|
||||
@property
|
||||
def get_interview_time(self):
|
||||
"""Legacy compatibility - get interview time"""
|
||||
if hasattr(self, "scheduled_interview") and self.scheduled_interview:
|
||||
return self.scheduled_interviews.first().interview_time
|
||||
return None
|
||||
|
||||
@property
|
||||
def time_to_hire_days(self):
|
||||
"""Legacy compatibility - calculate time to hire"""
|
||||
if self.hired_date and self.created_at:
|
||||
time_to_hire = self.hired_date - self.created_at.date()
|
||||
return time_to_hire.days
|
||||
return 0
|
||||
|
||||
@property
|
||||
def documents(self):
|
||||
"""Return all documents associated with this Application"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
content_type = ContentType.objects.get_for_model(self.__class__)
|
||||
return Document.objects.filter(content_type=content_type, object_id=self.id)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 🔄 BACKWARD COMPATIBILITY - Keep Candidate model for transition period
|
||||
# ============================================================================
|
||||
# class Candidate(Application):
|
||||
# """
|
||||
# DEPRECATED: Legacy Candidate model for backward compatibility.
|
||||
|
||||
# This model extends Application to maintain compatibility with existing code
|
||||
# during the migration period. All new code should use Application model.
|
||||
|
||||
# TODO: Remove this model after migration is complete and all code is updated.
|
||||
# """
|
||||
|
||||
# class Meta:
|
||||
# proxy = True
|
||||
# verbose_name = _("Candidate (Legacy)")
|
||||
# verbose_name_plural = _("Candidates (Legacy)")
|
||||
|
||||
|
||||
class TrainingMaterial(Base):
|
||||
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||
@ -865,14 +1045,19 @@ class ZoomMeeting(Base):
|
||||
# Timestamps
|
||||
|
||||
def __str__(self):
|
||||
return self.topic @ property
|
||||
return self.topic
|
||||
|
||||
@property
|
||||
|
||||
def get_job(self):
|
||||
return self.interview.job
|
||||
|
||||
@property
|
||||
def get_candidate(self):
|
||||
return self.interview.candidate
|
||||
return self.interview.application.person
|
||||
@property
|
||||
def candidate_full_name(self):
|
||||
return self.interview.application.person.full_name
|
||||
|
||||
@property
|
||||
def get_participants(self):
|
||||
@ -1724,8 +1909,8 @@ class InterviewSchedule(Base):
|
||||
related_name="interview_schedules",
|
||||
db_index=True,
|
||||
)
|
||||
candidates = models.ManyToManyField(
|
||||
Candidate, related_name="interview_schedules", blank=True, null=True
|
||||
applications = models.ManyToManyField(
|
||||
Application, related_name="interview_schedules", blank=True, null=True
|
||||
)
|
||||
start_date = models.DateField(
|
||||
db_index=True, verbose_name=_("Start Date")
|
||||
@ -1770,8 +1955,8 @@ class InterviewSchedule(Base):
|
||||
class ScheduledInterview(Base):
|
||||
"""Stores individual scheduled interviews"""
|
||||
|
||||
candidate = models.ForeignKey(
|
||||
Candidate,
|
||||
application = models.ForeignKey(
|
||||
Application,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="scheduled_interviews",
|
||||
db_index=True,
|
||||
@ -1814,13 +1999,13 @@ class ScheduledInterview(Base):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Interview with {self.candidate.name} for {self.job.title}"
|
||||
return f"Interview with {self.application.person.full_name} for {self.job.title}"
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["job", "status"]),
|
||||
models.Index(fields=["interview_date", "interview_time"]),
|
||||
models.Index(fields=["candidate", "job"]),
|
||||
models.Index(fields=["application", "job"]),
|
||||
]
|
||||
|
||||
|
||||
@ -2042,7 +2227,7 @@ class Message(Base):
|
||||
|
||||
|
||||
class Document(Base):
|
||||
"""Model for storing candidate documents"""
|
||||
"""Model for storing documents using Generic Foreign Key"""
|
||||
|
||||
class DocumentType(models.TextChoices):
|
||||
RESUME = "resume", _("Resume")
|
||||
@ -2054,14 +2239,19 @@ class Document(Base):
|
||||
EXPERIENCE = "experience", _("Experience Letter")
|
||||
OTHER = "other", _("Other")
|
||||
|
||||
candidate = models.ForeignKey(
|
||||
Candidate,
|
||||
# Generic Foreign Key fields
|
||||
content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="documents",
|
||||
verbose_name=_("Candidate"),
|
||||
verbose_name=_("Content Type"),
|
||||
)
|
||||
object_id = models.PositiveIntegerField(
|
||||
verbose_name=_("Object ID"),
|
||||
)
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
file = models.FileField(
|
||||
upload_to="candidate_documents/%Y/%m/",
|
||||
upload_to="documents/%Y/%m/",
|
||||
verbose_name=_("Document File"),
|
||||
validators=[validate_image_size],
|
||||
)
|
||||
@ -2089,11 +2279,22 @@ class Document(Base):
|
||||
verbose_name_plural = _("Documents")
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["candidate", "document_type", "created_at"]),
|
||||
models.Index(fields=["content_type", "object_id", "document_type", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_document_type_display()} - {self.candidate.name}"
|
||||
try:
|
||||
if hasattr(self.content_object, 'full_name'):
|
||||
object_name = self.content_object.full_name
|
||||
elif hasattr(self.content_object, 'title'):
|
||||
object_name = self.content_object.title
|
||||
elif hasattr(self.content_object, '__str__'):
|
||||
object_name = str(self.content_object)
|
||||
else:
|
||||
object_name = f"Object {self.object_id}"
|
||||
return f"{self.get_document_type_display()} - {object_name}"
|
||||
except:
|
||||
return f"{self.get_document_type_display()} - {self.object_id}"
|
||||
|
||||
@property
|
||||
def file_size(self):
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
from rest_framework import serializers
|
||||
from .models import JobPosting, Candidate
|
||||
from .models import JobPosting, Application
|
||||
|
||||
class JobPostingSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = JobPosting
|
||||
fields = '__all__'
|
||||
|
||||
class CandidateSerializer(serializers.ModelSerializer):
|
||||
class ApplicationSerializer(serializers.ModelSerializer):
|
||||
job_title = serializers.CharField(source='job.title', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Candidate
|
||||
model = Application
|
||||
fields = '__all__'
|
||||
|
||||
@ -8,8 +8,9 @@ from django_q.tasks import async_task
|
||||
from django.db.models.signals import post_save
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting,Notification,HiringAgency
|
||||
from .models import FormField,FormStage,FormTemplate,Application,JobPosting,Notification,HiringAgency,Person
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
@ -57,7 +58,7 @@ def format_job(sender, instance, created, **kwargs):
|
||||
# instance.form_template.is_active = False
|
||||
# instance.save()
|
||||
|
||||
@receiver(post_save, sender=Candidate)
|
||||
@receiver(post_save, sender=Application)
|
||||
def score_candidate_resume(sender, instance, created, **kwargs):
|
||||
if instance.resume and not instance.is_resume_parsed:
|
||||
logger.info(f"Scoring resume for candidate {instance.pk}")
|
||||
@ -415,10 +416,10 @@ def hiring_agency_created(sender, instance, created, **kwargs):
|
||||
user.save()
|
||||
instance.user = user
|
||||
instance.save()
|
||||
@receiver(post_save, sender=Candidate)
|
||||
def candidate_created(sender, instance, created, **kwargs):
|
||||
@receiver(post_save, sender=Person)
|
||||
def person_created(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
logger.info(f"New candidate created: {instance.pk} - {instance.email}")
|
||||
logger.info(f"New Person created: {instance.pk} - {instance.email}")
|
||||
user = User.objects.create_user(
|
||||
username=instance.slug,
|
||||
first_name=instance.first_name,
|
||||
|
||||
@ -7,7 +7,7 @@ from PyPDF2 import PdfReader
|
||||
from datetime import datetime
|
||||
from django.db import transaction
|
||||
from .utils import create_zoom_meeting
|
||||
from recruitment.models import Candidate
|
||||
from recruitment.models import Application
|
||||
from . linkedin_service import LinkedInService
|
||||
from django.shortcuts import get_object_or_404
|
||||
from . models import JobPosting
|
||||
@ -244,8 +244,8 @@ def handle_reume_parsing_and_scoring(pk):
|
||||
|
||||
# --- 1. Robust Object Retrieval (Prevents looping on DoesNotExist) ---
|
||||
try:
|
||||
instance = Candidate.objects.get(pk=pk)
|
||||
except Candidate.DoesNotExist:
|
||||
instance = Application.objects.get(pk=pk)
|
||||
except Application.DoesNotExist:
|
||||
# Exit gracefully if the candidate was deleted after the task was queued
|
||||
logger.warning(f"Candidate matching query does not exist for pk={pk}. Exiting task.")
|
||||
print(f"Candidate matching query does not exist for pk={pk}. Exiting task.")
|
||||
@ -453,7 +453,7 @@ def create_interview_and_meeting(
|
||||
Synchronous task for a single interview slot, dispatched by django-q.
|
||||
"""
|
||||
try:
|
||||
candidate = Candidate.objects.get(pk=candidate_id)
|
||||
candidate = Application.objects.get(pk=candidate_id)
|
||||
job = JobPosting.objects.get(pk=job_id)
|
||||
schedule = InterviewSchedule.objects.get(pk=schedule_id)
|
||||
|
||||
@ -476,7 +476,7 @@ def create_interview_and_meeting(
|
||||
password=result["meeting_details"]["password"]
|
||||
)
|
||||
ScheduledInterview.objects.create(
|
||||
candidate=candidate,
|
||||
application=Application,
|
||||
job=job,
|
||||
zoom_meeting=zoom_meeting,
|
||||
schedule=schedule,
|
||||
@ -484,11 +484,11 @@ def create_interview_and_meeting(
|
||||
interview_time=slot_time
|
||||
)
|
||||
# Log success or use Django-Q result system for monitoring
|
||||
logger.info(f"Successfully scheduled interview for {candidate.name}")
|
||||
logger.info(f"Successfully scheduled interview for {Application.name}")
|
||||
return True # Task succeeded
|
||||
else:
|
||||
# Handle Zoom API failure (e.g., log it or notify administrator)
|
||||
logger.error(f"Zoom API failed for {candidate.name}: {result['message']}")
|
||||
logger.error(f"Zoom API failed for {Application.name}: {result['message']}")
|
||||
return False # Task failed
|
||||
|
||||
except Exception as e:
|
||||
@ -703,14 +703,14 @@ def sync_candidate_to_source_task(candidate_id, source_id):
|
||||
|
||||
try:
|
||||
# Get the candidate and source
|
||||
candidate = Candidate.objects.get(pk=candidate_id)
|
||||
application = Application.objects.get(pk=candidate_id)
|
||||
source = Source.objects.get(pk=source_id)
|
||||
|
||||
# Initialize sync service
|
||||
sync_service = CandidateSyncService()
|
||||
|
||||
# Perform the sync operation
|
||||
result = sync_service.sync_candidate_to_source(candidate, source)
|
||||
result = sync_service.sync_candidate_to_source(application, source)
|
||||
|
||||
# Log the operation
|
||||
IntegrationLog.objects.create(
|
||||
@ -718,7 +718,7 @@ def sync_candidate_to_source_task(candidate_id, source_id):
|
||||
action=IntegrationLog.ActionChoices.SYNC,
|
||||
endpoint=source.sync_endpoint or "unknown",
|
||||
method=source.sync_method or "POST",
|
||||
request_data={"candidate_id": candidate_id, "candidate_name": candidate.name},
|
||||
request_data={"candidate_id": candidate_id, "application_name": application.name},
|
||||
response_data=result,
|
||||
status_code="SUCCESS" if result.get('success') else "ERROR",
|
||||
error_message=result.get('error') if not result.get('success') else None,
|
||||
@ -730,8 +730,8 @@ def sync_candidate_to_source_task(candidate_id, source_id):
|
||||
logger.info(f"Sync completed for candidate {candidate_id} to source {source_id}: {result}")
|
||||
return result
|
||||
|
||||
except Candidate.DoesNotExist:
|
||||
error_msg = f"Candidate not found: {candidate_id}"
|
||||
except Application.DoesNotExist:
|
||||
error_msg = f"Application not found: {candidate_id}"
|
||||
logger.error(error_msg)
|
||||
return {"success": False, "error": error_msg}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
@ -7,6 +7,8 @@ from datetime import datetime, time, timedelta
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
from .models import (
|
||||
JobPosting, Candidate, ZoomMeeting, FormTemplate, FormStage, FormField,
|
||||
FormSubmission, FieldResponse, InterviewSchedule, ScheduledInterview,
|
||||
@ -14,11 +16,11 @@ from .models import (
|
||||
)
|
||||
from .forms import (
|
||||
JobPostingForm, CandidateForm, ZoomMeetingForm, MeetingCommentForm,
|
||||
CandidateStageForm, InterviewScheduleForm
|
||||
CandidateStageForm, InterviewScheduleForm, CandidateSignupForm
|
||||
)
|
||||
from .views import (
|
||||
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
|
||||
)
|
||||
from .views_frontend import CandidateListView, JobListView
|
||||
from .utils import create_zoom_meeting, get_candidates_from_request
|
||||
@ -46,14 +48,21 @@ class BaseTestCase(TestCase):
|
||||
location_country='Saudi Arabia',
|
||||
description='Job description',
|
||||
qualifications='Job qualifications',
|
||||
application_deadline=timezone.now() + timedelta(days=30),
|
||||
created_by=self.user
|
||||
)
|
||||
|
||||
self.candidate = Candidate.objects.create(
|
||||
# Create a person first
|
||||
from .models import Person
|
||||
person = Person.objects.create(
|
||||
first_name='John',
|
||||
last_name='Doe',
|
||||
email='john@example.com',
|
||||
phone='1234567890',
|
||||
phone='1234567890'
|
||||
)
|
||||
|
||||
self.candidate = Candidate.objects.create(
|
||||
person=person,
|
||||
resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'),
|
||||
job=self.job,
|
||||
stage='Applied'
|
||||
@ -231,28 +240,6 @@ class ViewTests(BaseTestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'success')
|
||||
|
||||
def test_submit_form(self):
|
||||
"""Test submit_form view"""
|
||||
# Create a form template first
|
||||
template = FormTemplate.objects.create(
|
||||
job=self.job,
|
||||
name='Test Template',
|
||||
created_by=self.user,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
data = {
|
||||
'field_1': 'John', # Assuming field ID 1 corresponds to First Name
|
||||
'field_2': 'Doe', # Assuming field ID 2 corresponds to Last Name
|
||||
'field_3': 'john@example.com', # Email
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
reverse('application_submit', kwargs={'template_id': template.id}),
|
||||
data
|
||||
)
|
||||
# After successful submission, should redirect to success page
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
|
||||
class FormTests(BaseTestCase):
|
||||
@ -268,13 +255,13 @@ class FormTests(BaseTestCase):
|
||||
'location_city': 'Riyadh',
|
||||
'location_state': 'Riyadh',
|
||||
'location_country': 'Saudi Arabia',
|
||||
'description': 'Job description',
|
||||
'description': 'Job description with at least 20 characters to meet validation requirements',
|
||||
'qualifications': 'Job qualifications',
|
||||
'salary_range': '5000-7000',
|
||||
'application_deadline': '2025-12-31',
|
||||
'max_applications': '100',
|
||||
'open_positions': '2',
|
||||
'hash_tags': '#hiring, #jobopening'
|
||||
'hash_tags': '#hiring,#jobopening'
|
||||
}
|
||||
form = JobPostingForm(data=form_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
@ -315,24 +302,51 @@ class FormTests(BaseTestCase):
|
||||
form_data = {
|
||||
'stage': 'Exam'
|
||||
}
|
||||
form = CandidateStageForm(data=form_data, candidate=self.candidate)
|
||||
form = CandidateStageForm(data=form_data, instance=self.candidate)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_interview_schedule_form(self):
|
||||
"""Test InterviewScheduleForm"""
|
||||
# Update candidate to Interview stage first
|
||||
self.candidate.stage = 'Interview'
|
||||
self.candidate.save()
|
||||
|
||||
form_data = {
|
||||
'candidates': [self.candidate.id],
|
||||
'start_date': (timezone.now() + timedelta(days=1)).date(),
|
||||
'end_date': (timezone.now() + timedelta(days=7)).date(),
|
||||
'working_days': [0, 1, 2, 3, 4], # Monday to Friday
|
||||
'start_time': '09:00',
|
||||
'end_time': '17:00',
|
||||
'interview_duration': 60,
|
||||
'buffer_time': 15
|
||||
}
|
||||
form = InterviewScheduleForm(slug=self.job.slug, data=form_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_candidate_signup_form_valid(self):
|
||||
"""Test CandidateSignupForm with valid data"""
|
||||
form_data = {
|
||||
'first_name': 'John',
|
||||
'last_name': 'Doe',
|
||||
'email': 'john.doe@example.com',
|
||||
'phone': '+1234567890',
|
||||
'password': 'SecurePass123',
|
||||
'confirm_password': 'SecurePass123'
|
||||
}
|
||||
form = CandidateSignupForm(data=form_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_candidate_signup_form_password_mismatch(self):
|
||||
"""Test CandidateSignupForm with password mismatch"""
|
||||
form_data = {
|
||||
'first_name': 'John',
|
||||
'last_name': 'Doe',
|
||||
'email': 'john.doe@example.com',
|
||||
'phone': '+1234567890',
|
||||
'password': 'SecurePass123',
|
||||
'confirm_password': 'DifferentPass123'
|
||||
}
|
||||
form = CandidateSignupForm(data=form_data)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('Passwords do not match', str(form.errors))
|
||||
|
||||
|
||||
class IntegrationTests(BaseTestCase):
|
||||
"""Integration tests for multiple components"""
|
||||
@ -340,11 +354,14 @@ class IntegrationTests(BaseTestCase):
|
||||
def test_candidate_journey(self):
|
||||
"""Test the complete candidate journey from application to interview"""
|
||||
# 1. Create candidate
|
||||
candidate = Candidate.objects.create(
|
||||
person = Person.objects.create(
|
||||
first_name='Jane',
|
||||
last_name='Smith',
|
||||
email='jane@example.com',
|
||||
phone='9876543210',
|
||||
phone='9876543210'
|
||||
)
|
||||
candidate = Candidate.objects.create(
|
||||
person=person,
|
||||
resume=SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf'),
|
||||
job=self.job,
|
||||
stage='Applied'
|
||||
@ -449,11 +466,15 @@ class PerformanceTests(BaseTestCase):
|
||||
"""Test pagination with large datasets"""
|
||||
# Create many candidates
|
||||
for i in range(100):
|
||||
Candidate.objects.create(
|
||||
person = Person.objects.create(
|
||||
first_name=f'Candidate{i}',
|
||||
last_name=f'Test{i}',
|
||||
email=f'candidate{i}@example.com',
|
||||
phone=f'123456789{i}',
|
||||
phone=f'123456789{i}'
|
||||
)
|
||||
Candidate.objects.create(
|
||||
person=person,
|
||||
resume=SimpleUploadedFile(f'resume{i}.pdf', b'file_content', content_type='application/pdf'),
|
||||
job=self.job,
|
||||
stage='Applied'
|
||||
)
|
||||
@ -594,13 +615,17 @@ class TestFactories:
|
||||
@staticmethod
|
||||
def create_candidate(**kwargs):
|
||||
job = TestFactories.create_job_posting()
|
||||
person = Person.objects.create(
|
||||
first_name='Test',
|
||||
last_name='Candidate',
|
||||
email='test@example.com',
|
||||
phone='1234567890'
|
||||
)
|
||||
defaults = {
|
||||
'first_name': 'Test',
|
||||
'last_name': 'Candidate',
|
||||
'email': 'test@example.com',
|
||||
'phone': '1234567890',
|
||||
'person': person,
|
||||
'job': job,
|
||||
'stage': 'Applied'
|
||||
'stage': 'Applied',
|
||||
'resume': SimpleUploadedFile('resume.pdf', b'file_content', content_type='application/pdf')
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return Candidate.objects.create(**defaults)
|
||||
|
||||
@ -7,6 +7,12 @@ from . import views_source
|
||||
urlpatterns = [
|
||||
path("", views_frontend.dashboard_view, name="dashboard"),
|
||||
# Job URLs (using JobPosting model)
|
||||
path("persons/", views.PersonListView.as_view(), name="person_list"),
|
||||
path("persons/create/", views.PersonCreateView.as_view(), name="person_create"),
|
||||
path("persons/<slug:slug>/", views.PersonDetailView.as_view(), name="person_detail"),
|
||||
path("persons/<slug:slug>/update/", views.PersonUpdateView.as_view(), name="person_update"),
|
||||
path("persons/<slug:slug>/delete/", views.PersonDeleteView.as_view(), name="person_delete"),
|
||||
|
||||
path("jobs/", views_frontend.JobListView.as_view(), name="job_list"),
|
||||
path("jobs/create/", views.create_job, name="job_create"),
|
||||
path(
|
||||
@ -38,31 +44,31 @@ urlpatterns = [
|
||||
),
|
||||
# Candidate URLs
|
||||
path(
|
||||
"candidates/", views_frontend.CandidateListView.as_view(), name="candidate_list"
|
||||
"candidates/", views_frontend.ApplicationListView.as_view(), name="candidate_list"
|
||||
),
|
||||
path(
|
||||
"candidates/create/",
|
||||
views_frontend.CandidateCreateView.as_view(),
|
||||
views_frontend.ApplicationCreateView.as_view(),
|
||||
name="candidate_create",
|
||||
),
|
||||
path(
|
||||
"candidates/create/<slug:slug>/",
|
||||
views_frontend.CandidateCreateView.as_view(),
|
||||
views_frontend.ApplicationCreateView.as_view(),
|
||||
name="candidate_create_for_job",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:slug>/candidates/",
|
||||
views_frontend.JobCandidatesListView.as_view(),
|
||||
views_frontend.JobApplicationListView.as_view(),
|
||||
name="job_candidates_list",
|
||||
),
|
||||
path(
|
||||
"candidates/<slug:slug>/update/",
|
||||
views_frontend.CandidateUpdateView.as_view(),
|
||||
views_frontend.ApplicationUpdateView.as_view(),
|
||||
name="candidate_update",
|
||||
),
|
||||
path(
|
||||
"candidates/<slug:slug>/delete/",
|
||||
views_frontend.CandidateDeleteView.as_view(),
|
||||
views_frontend.ApplicationDeleteView.as_view(),
|
||||
name="candidate_delete",
|
||||
),
|
||||
path(
|
||||
@ -478,6 +484,16 @@ urlpatterns = [
|
||||
views.candidate_portal_dashboard,
|
||||
name="candidate_portal_dashboard",
|
||||
),
|
||||
path(
|
||||
"portal/dashboard/",
|
||||
views.agency_portal_dashboard,
|
||||
name="agency_portal_dashboard",
|
||||
),
|
||||
path(
|
||||
"portal/persons/",
|
||||
views.agency_portal_persons_list,
|
||||
name="agency_portal_persons_list",
|
||||
),
|
||||
path(
|
||||
"portal/assignment/<slug:slug>/",
|
||||
views.agency_portal_assignment_detail,
|
||||
@ -571,7 +587,7 @@ urlpatterns = [
|
||||
path("api/unread-count/", views.api_unread_count, name="api_unread_count"),
|
||||
|
||||
# Documents
|
||||
path("documents/upload/<int:candidate_id>/", views.document_upload, name="document_upload"),
|
||||
path("documents/upload/<int:application_id>/", views.document_upload, name="document_upload"),
|
||||
path("documents/<int:document_id>/delete/", views.document_delete, name="document_delete"),
|
||||
path("documents/<int:document_id>/download/", views.document_download, name="document_download"),
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -30,6 +30,9 @@ from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import json
|
||||
|
||||
# Add imports for user type restrictions
|
||||
from recruitment.decorators import StaffRequiredMixin, staff_user_required
|
||||
|
||||
|
||||
from datastar_py.django import (
|
||||
DatastarResponse,
|
||||
@ -39,7 +42,7 @@ from datastar_py.django import (
|
||||
# from rich import print
|
||||
from rich.markdown import CodeBlock
|
||||
|
||||
class JobListView(LoginRequiredMixin, ListView):
|
||||
class JobListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
||||
model = models.JobPosting
|
||||
template_name = 'jobs/job_list.html'
|
||||
context_object_name = 'jobs'
|
||||
@ -47,7 +50,6 @@ class JobListView(LoginRequiredMixin, ListView):
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().order_by('-created_at')
|
||||
|
||||
# Handle search
|
||||
search_query = self.request.GET.get('search', '')
|
||||
if search_query:
|
||||
@ -58,24 +60,23 @@ class JobListView(LoginRequiredMixin, ListView):
|
||||
)
|
||||
|
||||
# Filter for non-staff users
|
||||
if not self.request.user.is_staff:
|
||||
queryset = queryset.filter(status='Published')
|
||||
# if not self.request.user.is_staff:
|
||||
# queryset = queryset.filter(status='Published')
|
||||
|
||||
status=self.request.GET.get('status')
|
||||
status = self.request.GET.get('status')
|
||||
if status:
|
||||
queryset=queryset.filter(status=status)
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
context['lang'] = get_language()
|
||||
return context
|
||||
|
||||
|
||||
class JobCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
class JobCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
model = models.JobPosting
|
||||
form_class = forms.JobPostingForm
|
||||
template_name = 'jobs/create_job.html'
|
||||
@ -83,7 +84,7 @@ class JobCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
success_message = 'Job created successfully.'
|
||||
|
||||
|
||||
class JobUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
class JobUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
model = models.JobPosting
|
||||
form_class = forms.JobPostingForm
|
||||
template_name = 'jobs/edit_job.html'
|
||||
@ -92,27 +93,25 @@ class JobUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
|
||||
class JobDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
class JobDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
model = models.JobPosting
|
||||
template_name = 'jobs/partials/delete_modal.html'
|
||||
success_url = reverse_lazy('job_list')
|
||||
success_message = 'Job deleted successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
class JobCandidatesListView(LoginRequiredMixin, ListView):
|
||||
model = models.Candidate
|
||||
class JobApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
||||
model = models.Application
|
||||
template_name = 'jobs/job_candidates_list.html'
|
||||
context_object_name = 'candidates'
|
||||
context_object_name = 'applications'
|
||||
paginate_by = 10
|
||||
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
# Get the job by slug
|
||||
self.job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug'])
|
||||
|
||||
# Filter candidates for this specific job
|
||||
queryset = models.Candidate.objects.filter(job=self.job)
|
||||
queryset = models.Application.objects.filter(job=self.job)
|
||||
|
||||
if self.request.GET.get('stage'):
|
||||
stage=self.request.GET.get('stage')
|
||||
@ -132,7 +131,7 @@ class JobCandidatesListView(LoginRequiredMixin, ListView):
|
||||
|
||||
# Filter for non-staff users
|
||||
if not self.request.user.is_staff:
|
||||
return models.Candidate.objects.none() # Restrict for non-staff
|
||||
return models.Application.objects.none() # Restrict for non-staff
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
|
||||
@ -143,10 +142,10 @@ class JobCandidatesListView(LoginRequiredMixin, ListView):
|
||||
return context
|
||||
|
||||
|
||||
class CandidateListView(LoginRequiredMixin, ListView):
|
||||
model = models.Candidate
|
||||
class ApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
||||
model = models.Application
|
||||
template_name = 'recruitment/candidate_list.html'
|
||||
context_object_name = 'candidates'
|
||||
context_object_name = 'applications'
|
||||
paginate_by = 100
|
||||
|
||||
def get_queryset(self):
|
||||
@ -156,22 +155,22 @@ class CandidateListView(LoginRequiredMixin, ListView):
|
||||
search_query = self.request.GET.get('search', '')
|
||||
job = self.request.GET.get('job', '')
|
||||
stage = self.request.GET.get('stage', '')
|
||||
if search_query:
|
||||
queryset = queryset.filter(
|
||||
Q(first_name__icontains=search_query) |
|
||||
Q(last_name__icontains=search_query) |
|
||||
Q(email__icontains=search_query) |
|
||||
Q(phone__icontains=search_query) |
|
||||
Q(stage__icontains=search_query) |
|
||||
Q(job__title__icontains=search_query)
|
||||
)
|
||||
# if search_query:
|
||||
# queryset = queryset.filter(
|
||||
# Q(first_name__icontains=search_query) |
|
||||
# Q(last_name__icontains=search_query) |
|
||||
# Q(email__icontains=search_query) |
|
||||
# Q(phone__icontains=search_query) |
|
||||
# Q(stage__icontains=search_query) |
|
||||
# Q(job__title__icontains=search_query)
|
||||
# )
|
||||
if job:
|
||||
queryset = queryset.filter(job__slug=job)
|
||||
if stage:
|
||||
queryset = queryset.filter(stage=stage)
|
||||
# Filter for non-staff users
|
||||
if not self.request.user.is_staff:
|
||||
return models.Candidate.objects.none() # Restrict for non-staff
|
||||
# if not self.request.user.is_staff:
|
||||
# return models.Application.objects.none() # Restrict for non-staff
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
|
||||
@ -184,9 +183,9 @@ class CandidateListView(LoginRequiredMixin, ListView):
|
||||
return context
|
||||
|
||||
|
||||
class CandidateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
model = models.Candidate
|
||||
form_class = forms.CandidateForm
|
||||
class ApplicationCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
model = models.Application
|
||||
form_class = forms.ApplicationForm
|
||||
template_name = 'recruitment/candidate_create.html'
|
||||
success_url = reverse_lazy('candidate_list')
|
||||
success_message = 'Candidate created successfully.'
|
||||
@ -204,18 +203,23 @@ class CandidateCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
form.instance.job = job
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
if self.request.method == 'GET':
|
||||
context['person_form'] = forms.PersonForm()
|
||||
return context
|
||||
|
||||
class CandidateUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
model = models.Candidate
|
||||
form_class = forms.CandidateForm
|
||||
class ApplicationUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
model = models.Application
|
||||
form_class = forms.ApplicationForm
|
||||
template_name = 'recruitment/candidate_update.html'
|
||||
success_url = reverse_lazy('candidate_list')
|
||||
success_message = 'Candidate updated successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
|
||||
class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
model = models.Candidate
|
||||
class ApplicationDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
model = models.Application
|
||||
template_name = 'recruitment/candidate_delete.html'
|
||||
success_url = reverse_lazy('candidate_list')
|
||||
success_message = 'Candidate deleted successfully.'
|
||||
@ -225,28 +229,30 @@ class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
def retry_scoring_view(request,slug):
|
||||
from django_q.tasks import async_task
|
||||
|
||||
candidate = get_object_or_404(models.Candidate, slug=slug)
|
||||
application = get_object_or_404(models.Application, slug=slug)
|
||||
|
||||
async_task(
|
||||
'recruitment.tasks.handle_reume_parsing_and_scoring',
|
||||
candidate.pk,
|
||||
application.pk,
|
||||
hook='recruitment.hooks.callback_ai_parsing',
|
||||
sync=True,
|
||||
)
|
||||
return redirect('candidate_detail', slug=candidate.slug)
|
||||
return redirect('candidate_detail', slug=application.slug)
|
||||
|
||||
|
||||
|
||||
@login_required
|
||||
@staff_user_required
|
||||
def training_list(request):
|
||||
materials = models.TrainingMaterial.objects.all().order_by('-created_at')
|
||||
return render(request, 'recruitment/training_list.html', {'materials': materials})
|
||||
|
||||
|
||||
@login_required
|
||||
@staff_user_required
|
||||
def candidate_detail(request, slug):
|
||||
from rich.json import JSON
|
||||
candidate = get_object_or_404(models.Candidate, slug=slug)
|
||||
candidate = get_object_or_404(models.Application, slug=slug)
|
||||
try:
|
||||
parsed = ast.literal_eval(candidate.parsed_summary)
|
||||
except:
|
||||
@ -255,7 +261,7 @@ def candidate_detail(request, slug):
|
||||
# Create stage update form for staff users
|
||||
stage_form = None
|
||||
if request.user.is_staff:
|
||||
stage_form = forms.CandidateStageForm()
|
||||
stage_form = forms.ApplicationStageForm()
|
||||
|
||||
|
||||
|
||||
@ -269,31 +275,33 @@ def candidate_detail(request, slug):
|
||||
|
||||
|
||||
@login_required
|
||||
@staff_user_required
|
||||
def candidate_resume_template_view(request, slug):
|
||||
"""Display formatted resume template for a candidate"""
|
||||
candidate = get_object_or_404(models.Candidate, slug=slug)
|
||||
application = get_object_or_404(models.Application, slug=slug)
|
||||
|
||||
if not request.user.is_staff:
|
||||
messages.error(request, _("You don't have permission to view this page."))
|
||||
return redirect('candidate_list')
|
||||
|
||||
return render(request, 'recruitment/candidate_resume_template.html', {
|
||||
'candidate': candidate
|
||||
'application': application
|
||||
})
|
||||
|
||||
@login_required
|
||||
@staff_user_required
|
||||
def candidate_update_stage(request, slug):
|
||||
"""Handle HTMX stage update requests"""
|
||||
candidate = get_object_or_404(models.Candidate, slug=slug)
|
||||
form = forms.CandidateStageForm(request.POST, instance=candidate)
|
||||
application = get_object_or_404(models.Application, slug=slug)
|
||||
form = forms.ApplicationStageForm(request.POST, instance=application)
|
||||
if form.is_valid():
|
||||
stage_value = form.cleaned_data['stage']
|
||||
candidate.stage = stage_value
|
||||
candidate.save(update_fields=['stage'])
|
||||
messages.success(request,"Candidate Stage Updated")
|
||||
return redirect("candidate_detail",slug=candidate.slug)
|
||||
application.stage = stage_value
|
||||
application.save(update_fields=['stage'])
|
||||
messages.success(request,"application Stage Updated")
|
||||
return redirect("candidate_detail",slug=application.slug)
|
||||
|
||||
class TrainingListView(LoginRequiredMixin, ListView):
|
||||
class TrainingListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
||||
model = models.TrainingMaterial
|
||||
template_name = 'recruitment/training_list.html'
|
||||
context_object_name = 'materials'
|
||||
@ -321,7 +329,7 @@ class TrainingListView(LoginRequiredMixin, ListView):
|
||||
return context
|
||||
|
||||
|
||||
class TrainingCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
class TrainingCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
model = models.TrainingMaterial
|
||||
form_class = forms.TrainingMaterialForm
|
||||
template_name = 'recruitment/training_create.html'
|
||||
@ -333,7 +341,7 @@ class TrainingCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class TrainingUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
class TrainingUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
model = models.TrainingMaterial
|
||||
form_class = forms.TrainingMaterialForm
|
||||
template_name = 'recruitment/training_update.html'
|
||||
@ -342,13 +350,13 @@ class TrainingUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
|
||||
class TrainingDetailView(LoginRequiredMixin, DetailView):
|
||||
class TrainingDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
|
||||
model = models.TrainingMaterial
|
||||
template_name = 'recruitment/training_detail.html'
|
||||
context_object_name = 'material'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
class TrainingDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
model = models.TrainingMaterial
|
||||
template_name = 'recruitment/training_delete.html'
|
||||
success_url = reverse_lazy('training_list')
|
||||
@ -366,6 +374,7 @@ TARGET_TIME_TO_HIRE_DAYS = 45 # Used for the template visualization
|
||||
|
||||
|
||||
@login_required
|
||||
@staff_user_required
|
||||
def dashboard_view(request):
|
||||
|
||||
selected_job_pk = request.GET.get('selected_job_pk')
|
||||
@ -374,7 +383,7 @@ def dashboard_view(request):
|
||||
# --- 1. BASE QUERYSETS & GLOBAL METRICS (UNFILTERED) ---
|
||||
|
||||
all_jobs_queryset = models.JobPosting.objects.all().order_by('-created_at')
|
||||
all_candidates_queryset = models.Candidate.objects.all()
|
||||
all_candidates_queryset = models.Application.objects.all()
|
||||
|
||||
# Global KPI Card Metrics
|
||||
total_jobs_global = all_jobs_queryset.count()
|
||||
@ -383,7 +392,7 @@ def dashboard_view(request):
|
||||
|
||||
# Data for Job App Count Chart (always for ALL jobs)
|
||||
job_titles = [job.title for job in all_jobs_queryset]
|
||||
job_app_counts = [job.candidates.count() for job in all_jobs_queryset]
|
||||
job_app_counts = [job.applications.count() for job in all_jobs_queryset]
|
||||
|
||||
# --- 2. TIME SERIES: GLOBAL DAILY APPLICANTS ---
|
||||
|
||||
@ -453,7 +462,7 @@ def dashboard_view(request):
|
||||
open_positions_agg = job_scope_queryset.filter(status="ACTIVE").aggregate(total_open=Sum('open_positions'))
|
||||
total_open_positions = open_positions_agg['total_open'] or 0
|
||||
average_applications_result = job_scope_queryset.annotate(
|
||||
candidate_count=Count('candidates', distinct=True)
|
||||
candidate_count=Count('applications', distinct=True)
|
||||
).aggregate(avg_apps=Avg('candidate_count'))['avg_apps']
|
||||
average_applications = round(average_applications_result or 0, 2)
|
||||
|
||||
@ -588,6 +597,7 @@ def dashboard_view(request):
|
||||
|
||||
|
||||
@login_required
|
||||
@staff_user_required
|
||||
def candidate_offer_view(request, slug):
|
||||
"""View for candidates in the Offer stage"""
|
||||
job = get_object_or_404(models.JobPosting, slug=slug)
|
||||
@ -617,6 +627,7 @@ def candidate_offer_view(request, slug):
|
||||
|
||||
|
||||
@login_required
|
||||
@staff_user_required
|
||||
def candidate_hired_view(request, slug):
|
||||
"""View for hired candidates"""
|
||||
job = get_object_or_404(models.JobPosting, slug=slug)
|
||||
@ -646,13 +657,15 @@ def candidate_hired_view(request, slug):
|
||||
|
||||
|
||||
@login_required
|
||||
@staff_user_required
|
||||
def update_candidate_status(request, job_slug, candidate_slug, stage_type, status):
|
||||
"""Handle exam/interview/offer status updates"""
|
||||
from django.utils import timezone
|
||||
|
||||
job = get_object_or_404(models.JobPosting, slug=job_slug)
|
||||
candidate = get_object_or_404(models.Candidate, 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 stage_type == 'exam':
|
||||
@ -711,6 +724,7 @@ STAGE_CONFIG = {
|
||||
|
||||
|
||||
@login_required
|
||||
@staff_user_required
|
||||
def export_candidates_csv(request, job_slug, stage):
|
||||
"""Export candidates for a specific stage as CSV"""
|
||||
job = get_object_or_404(models.JobPosting, slug=job_slug)
|
||||
@ -724,9 +738,9 @@ def export_candidates_csv(request, job_slug, stage):
|
||||
|
||||
# Filter candidates based on stage
|
||||
if stage == 'hired':
|
||||
candidates = job.candidates.filter(**config['filter'])
|
||||
candidates = job.applications.filter(**config['filter'])
|
||||
else:
|
||||
candidates = job.candidates.filter(**config['filter'])
|
||||
candidates = job.applications.filter(**config['filter'])
|
||||
|
||||
# Handle search if provided
|
||||
search_query = request.GET.get('search', '')
|
||||
@ -850,6 +864,7 @@ def export_candidates_csv(request, job_slug, stage):
|
||||
|
||||
|
||||
@login_required
|
||||
@staff_user_required
|
||||
def sync_hired_candidates(request, job_slug):
|
||||
"""Sync hired candidates to external sources using Django-Q"""
|
||||
from django_q.tasks import async_task
|
||||
@ -888,6 +903,7 @@ def sync_hired_candidates(request, job_slug):
|
||||
|
||||
|
||||
@login_required
|
||||
@staff_user_required
|
||||
def test_source_connection(request, source_id):
|
||||
"""Test connection to an external source"""
|
||||
from .candidate_sync_service import CandidateSyncService
|
||||
@ -922,6 +938,7 @@ def test_source_connection(request, source_id):
|
||||
|
||||
|
||||
@login_required
|
||||
@staff_user_required
|
||||
def sync_task_status(request, task_id):
|
||||
"""Check the status of a sync task"""
|
||||
from django_q.models import Task
|
||||
@ -973,6 +990,7 @@ def sync_task_status(request, task_id):
|
||||
|
||||
|
||||
@login_required
|
||||
@staff_user_required
|
||||
def sync_history(request, job_slug=None):
|
||||
"""View sync history and logs"""
|
||||
from .models import IntegrationLog
|
||||
@ -1007,7 +1025,7 @@ def sync_history(request, job_slug=None):
|
||||
|
||||
|
||||
#participants views
|
||||
class ParticipantsListView(LoginRequiredMixin, ListView):
|
||||
class ParticipantsListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
||||
model = models.Participants
|
||||
template_name = 'participants/participants_list.html'
|
||||
context_object_name = 'participants'
|
||||
@ -1036,13 +1054,13 @@ class ParticipantsListView(LoginRequiredMixin, ListView):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
return context
|
||||
class ParticipantsDetailView(LoginRequiredMixin, DetailView):
|
||||
class ParticipantsDetailView(LoginRequiredMixin, StaffRequiredMixin, DetailView):
|
||||
model = models.Participants
|
||||
template_name = 'participants/participants_detail.html'
|
||||
context_object_name = 'participant'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
class ParticipantsCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
class ParticipantsCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView):
|
||||
model = models.Participants
|
||||
form_class = forms.ParticipantsForm
|
||||
template_name = 'participants/participants_create.html'
|
||||
@ -1058,7 +1076,7 @@ class ParticipantsCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateVie
|
||||
|
||||
|
||||
|
||||
class ParticipantsUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
class ParticipantsUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||
model = models.Participants
|
||||
form_class = forms.ParticipantsForm
|
||||
template_name = 'participants/participants_create.html'
|
||||
@ -1066,7 +1084,7 @@ class ParticipantsUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateVie
|
||||
success_message = 'Participant updated successfully.'
|
||||
slug_url_kwarg = 'slug'
|
||||
|
||||
class ParticipantsDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
class ParticipantsDeleteView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
model = models.Participants
|
||||
|
||||
success_url = reverse_lazy('participants_list') # Redirect to the participants list after success
|
||||
|
||||
@ -238,7 +238,15 @@
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'candidate_list' %}active{% endif %}" href="{% url 'candidate_list' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{% include "icons/users.html" %}
|
||||
{% trans "Applicants" %}
|
||||
{% trans "Applications" %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item me-lg-4">
|
||||
<a class="nav-link {% if request.resolver_match.url_name == 'person_list' %}active{% endif %}" href="{% url 'person_list' %}">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{% include "icons/users.html" %}
|
||||
{% trans "Person" %}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
@ -330,6 +338,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js" integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Navbar collapse auto-close on link click (Standard Mobile UX)
|
||||
|
||||
@ -77,8 +77,8 @@
|
||||
<tr>
|
||||
<td>{{ item.date|date:"F j, Y" }}</td>
|
||||
<td>{{ item.time|time:"g:i A" }}</td>
|
||||
<td>{{ item.candidate.name }}</td>
|
||||
<td>{{ item.candidate.email }}</td>
|
||||
<td>{{ item.applications.name }}</td>
|
||||
<td>{{ item.applications.email }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@ -130,9 +130,9 @@
|
||||
<label for="{{ form.candidates.id_for_label }}">
|
||||
{% trans "Candidates to Schedule (Hold Ctrl/Cmd to select multiple)" %}
|
||||
</label>
|
||||
{{ form.candidates }}
|
||||
{% if form.candidates.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.candidates.errors }}</div>
|
||||
{{ form.applications }}
|
||||
{% if form.applications.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.applications.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -285,7 +285,7 @@
|
||||
<th style="width: calc(50% / 7);">{% trans "Offer" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
|
||||
@ -319,7 +319,7 @@
|
||||
<td><strong class="text-primary"><a href="{% url 'meeting_details' meeting.slug %}" class="text-decoration-none text-secondary">{{ meeting.topic }}<a></strong></td>
|
||||
<td>
|
||||
{% if meeting.interview %}
|
||||
<a class="text-primary text-decoration-none" href="{% url 'candidate_detail' meeting.interview.candidate.slug %}">{{ meeting.interview.candidate.name }} <i class="fas fa-link"></i></a>
|
||||
<a class="text-primary text-decoration-none" href="{% url 'candidate_detail' meeting.interview.application.slug %}">{{ meeting.interview.candidate.name }} <i class="fas fa-link"></i></a>
|
||||
{% else %}
|
||||
<button data-bs-toggle="modal"
|
||||
data-bs-target="#meetingModal"
|
||||
|
||||
@ -249,7 +249,7 @@ body {
|
||||
<h2 class="text-start"><i class="fas fa-briefcase me-2"></i> {% trans "Interview Detail" %}</h2>
|
||||
<div class="detail-row-group flex-grow-1">
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Title" %}:</div><div class="detail-value-simple">{{ meeting.get_job.title|default:"N/A" }}</div></div>
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Name" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.name|default:"N/A" }}</a></div></div>
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Name" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.candidate_full_name|default:"N/A" }}</a></div></div>
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Email" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.email|default:"N/A" }}</a></div></div>
|
||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Type" %}:</div><div class="detail-value-simple">{{ meeting.get_job.job_type|default:"N/A" }}</div></div>
|
||||
</div>
|
||||
|
||||
444
templates/people/create_person.html
Normal file
444
templates/people/create_person.html
Normal file
@ -0,0 +1,444 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n crispy_forms_tags %}
|
||||
|
||||
{% block title %}Create Person - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* UI Variables for the KAAT-S Theme */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-gray-light: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Form Container Styling */
|
||||
.form-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Card Styling */
|
||||
.card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
/* Main Action Button Style */
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Secondary Button Style */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Form Field Styling */
|
||||
.form-control:focus {
|
||||
border-color: var(--kaauh-teal);
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
border-color: var(--kaauh-teal);
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
||||
}
|
||||
|
||||
/* Profile Image Upload Styling */
|
||||
.profile-image-upload {
|
||||
border: 2px dashed var(--kaauh-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-image-upload:hover {
|
||||
border-color: var(--kaauh-teal);
|
||||
background-color: var(--kaauh-gray-light);
|
||||
}
|
||||
|
||||
.profile-image-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--kaauh-teal);
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
/* Breadcrumb Styling */
|
||||
.breadcrumb {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumb-item + .breadcrumb-item::before {
|
||||
content: ">";
|
||||
color: var(--kaauh-teal);
|
||||
}
|
||||
|
||||
/* Alert Styling */
|
||||
.alert {
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.btn.loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn.loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: auto;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="form-container">
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'person_list' %}" class="text-decoration-none">
|
||||
<i class="fas fa-user-friends me-1"></i> {% trans "People" %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{% trans "Create Person" %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||
<i class="fas fa-user-plus me-2"></i> {% trans "Create New Person" %}
|
||||
</h1>
|
||||
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h5 class="alert-heading">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>{% trans "Error" %}
|
||||
</h5>
|
||||
{% for error in form.non_field_errors %}
|
||||
<p class="mb-0">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data" id="person-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Profile Image Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="profile-image-upload" onclick="document.getElementById('id_profile_image').click()">
|
||||
<div id="image-preview-container">
|
||||
<i class="fas fa-camera fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">{% trans "Upload Profile Photo" %}</h5>
|
||||
<p class="text-muted small">{% trans "Click to browse or drag and drop" %}</p>
|
||||
</div>
|
||||
<input type="file" name="profile_image" id="id_profile_image"
|
||||
class="d-none" accept="image/*">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personal Information Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
|
||||
<i class="fas fa-user me-2"></i> {% trans "Personal Information" %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.first_name|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.middle_name|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.last_name|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Information Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
|
||||
<i class="fas fa-envelope me-2"></i> {% trans "Contact Information" %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form.email|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form.phone|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Information Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
|
||||
<i class="fas fa-info-circle me-2"></i> {% trans "Additional Information" %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.date_of_birth|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.nationality|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.gender|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
|
||||
<i class="fas fa-map-marker-alt me-2"></i> {% trans "Address" %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
{{ form.address }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LinkedIn Profile Section -->
|
||||
{% comment %} <div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
|
||||
<i class="fab fa-linkedin me-2"></i> {% trans "Professional Profile" %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-group mb-3">
|
||||
<label for="id_linkedin_profile" class="form-label">
|
||||
{% trans "LinkedIn Profile URL" %}
|
||||
</label>
|
||||
<input type="url" name="linkedin_profile" id="id_linkedin_profile"
|
||||
class="form-control" placeholder="https://linkedin.com/in/username">
|
||||
<small class="form-text text-muted">
|
||||
{% trans "Optional: Add LinkedIn profile URL" %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="reset" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-undo me-1"></i> {% trans "Reset" %}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-main-action">
|
||||
<i class="fas fa-save me-1"></i> {% trans "Create Person" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Profile Image Preview
|
||||
const profileImageInput = document.getElementById('id_profile_image');
|
||||
const imagePreviewContainer = document.getElementById('image-preview-container');
|
||||
|
||||
profileImageInput.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
imagePreviewContainer.innerHTML = `
|
||||
<img src="${e.target.result}" alt="Profile Preview" class="profile-image-preview">
|
||||
<h5 class="text-muted mt-3">${file.name}</h5>
|
||||
<p class="text-muted small">{% trans "Click to change photo" %}</p>
|
||||
`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
|
||||
// Form Validation
|
||||
const form = document.getElementById('person-form');
|
||||
form.addEventListener('submit', function(e) {
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
submitBtn.classList.add('loading');
|
||||
submitBtn.disabled = true;
|
||||
|
||||
// Basic validation
|
||||
const firstName = document.getElementById('id_first_name').value.trim();
|
||||
const lastName = document.getElementById('id_last_name').value.trim();
|
||||
const email = document.getElementById('id_email').value.trim();
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
e.preventDefault();
|
||||
submitBtn.classList.remove('loading');
|
||||
submitBtn.disabled = false;
|
||||
alert('{% trans "First name and last name are required." %}');
|
||||
return;
|
||||
}
|
||||
|
||||
if (email && !isValidEmail(email)) {
|
||||
e.preventDefault();
|
||||
submitBtn.classList.remove('loading');
|
||||
submitBtn.disabled = false;
|
||||
alert('{% trans "Please enter a valid email address." %}');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Email validation helper
|
||||
function isValidEmail(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
// LinkedIn URL validation
|
||||
const linkedinInput = document.getElementById('id_linkedin_profile');
|
||||
linkedinInput.addEventListener('blur', function() {
|
||||
const value = this.value.trim();
|
||||
if (value && !isValidLinkedInURL(value)) {
|
||||
this.classList.add('is-invalid');
|
||||
if (!this.nextElementSibling || !this.nextElementSibling.classList.contains('invalid-feedback')) {
|
||||
const feedback = document.createElement('div');
|
||||
feedback.className = 'invalid-feedback';
|
||||
feedback.textContent = '{% trans "Please enter a valid LinkedIn URL" %}';
|
||||
this.parentNode.appendChild(feedback);
|
||||
}
|
||||
} else {
|
||||
this.classList.remove('is-invalid');
|
||||
const feedback = this.parentNode.querySelector('.invalid-feedback');
|
||||
if (feedback) feedback.remove();
|
||||
}
|
||||
});
|
||||
|
||||
function isValidLinkedInURL(url) {
|
||||
const linkedinRegex = /^https?:\/\/(www\.)?linkedin\.com\/.+/i;
|
||||
return linkedinRegex.test(url);
|
||||
}
|
||||
|
||||
// Drag and Drop functionality
|
||||
const uploadArea = document.querySelector('.profile-image-upload');
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
uploadArea.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
uploadArea.addEventListener(eventName, highlight, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
uploadArea.addEventListener(eventName, unhighlight, false);
|
||||
});
|
||||
|
||||
function highlight(e) {
|
||||
uploadArea.style.borderColor = 'var(--kaauh-teal)';
|
||||
uploadArea.style.backgroundColor = 'var(--kaauh-gray-light)';
|
||||
}
|
||||
|
||||
function unhighlight(e) {
|
||||
uploadArea.style.borderColor = 'var(--kaauh-border)';
|
||||
uploadArea.style.backgroundColor = 'transparent';
|
||||
}
|
||||
|
||||
uploadArea.addEventListener('drop', handleDrop, false);
|
||||
|
||||
function handleDrop(e) {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
|
||||
if (files.length > 0) {
|
||||
profileImageInput.files = files;
|
||||
const event = new Event('change', { bubbles: true });
|
||||
profileImageInput.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js" integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" />
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('.select2').select2();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
607
templates/people/person_detail.html
Normal file
607
templates/people/person_detail.html
Normal file
@ -0,0 +1,607 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{{ person.get_full_name }} - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* UI Variables for the KAAT-S Theme */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-gray-light: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Profile Header Styling */
|
||||
.profile-header {
|
||||
background: linear-gradient(135deg, var(--kaauh-teal) 0%, var(--kaauh-teal-dark) 100%);
|
||||
color: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.profile-image-large {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
border: 4px solid white;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Card Styling */
|
||||
.card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Main Action Button Style */
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Secondary Button Style */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Info Section Styling */
|
||||
.info-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.info-section h5 {
|
||||
color: var(--kaauh-teal-dark);
|
||||
border-bottom: 2px solid var(--kaauh-teal);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.info-item:hover {
|
||||
background-color: var(--kaauh-gray-light);
|
||||
}
|
||||
|
||||
.info-item i {
|
||||
color: var(--kaauh-teal);
|
||||
width: 20px;
|
||||
margin-right: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
color: var(--kaauh-teal-dark);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #495057;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Badge Styling */
|
||||
.badge {
|
||||
font-weight: 600;
|
||||
padding: 0.4em 0.7em;
|
||||
border-radius: 0.3rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Related Items Styling */
|
||||
.related-item {
|
||||
border-left: 3px solid var(--kaauh-teal);
|
||||
padding-left: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.related-item:hover {
|
||||
border-left-color: var(--kaauh-teal-dark);
|
||||
background-color: var(--kaauh-gray-light);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Breadcrumb Styling */
|
||||
.breadcrumb {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumb-item + .breadcrumb-item::before {
|
||||
content: ">";
|
||||
color: var(--kaauh-teal);
|
||||
}
|
||||
|
||||
/* Empty State Styling */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
color: var(--kaauh-teal);
|
||||
opacity: 0.5;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Status Indicator */
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.profile-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-image-large {
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
min-width: auto;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'person_list' %}" class="text-decoration-none">
|
||||
<i class="fas fa-user-friends me-1"></i> {% trans "People" %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ person.get_full_name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Profile Header -->
|
||||
<div class="profile-header">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-3 text-center">
|
||||
{% if person.profile_image %}
|
||||
<img src="{{ person.profile_image.url }}" alt="{{ person.get_full_name }}"
|
||||
class="profile-image-large">
|
||||
{% else %}
|
||||
<div class="profile-image-large d-flex align-items-center justify-content-center bg-white">
|
||||
<i class="fas fa-user text-muted fa-3x"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<h1 class="display-5 fw-bold mb-2">{{ person.get_full_name }}</h1>
|
||||
{% if person.email %}
|
||||
<p class="lead mb-3">
|
||||
<i class="fas fa-envelope me-2"></i>{{ person.email }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
{% if person.nationality %}
|
||||
<span class="badge bg-light text-dark">
|
||||
<i class="fas fa-globe me-1"></i>{{ person.nationality }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if person.gender %}
|
||||
<span class="badge bg-info">
|
||||
{% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if person.user %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-user-check me-1"></i>{% trans "User Account" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if user.is_staff %}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'person_update' person.slug %}" class="btn btn-light">
|
||||
<i class="fas fa-edit me-1"></i> {% trans "Edit Person" %}
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-light"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-delete-url="{% url 'person_delete' person.slug %}"
|
||||
data-item-name="{{ person.get_full_name }}">
|
||||
<i class="fas fa-trash-alt me-1"></i> {% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Personal Information Column -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="info-section">
|
||||
<h5><i class="fas fa-user me-2"></i>{% trans "Personal Information" %}</h5>
|
||||
|
||||
<div class="info-item">
|
||||
<i class="fas fa-signature"></i>
|
||||
<span class="info-label">{% trans "Full Name" %}:</span>
|
||||
<span class="info-value">{{ person.get_full_name }}</span>
|
||||
</div>
|
||||
|
||||
{% if person.first_name %}
|
||||
<div class="info-item">
|
||||
<i class="fas fa-user"></i>
|
||||
<span class="info-label">{% trans "First Name" %}:</span>
|
||||
<span class="info-value">{{ person.first_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if person.middle_name %}
|
||||
<div class="info-item">
|
||||
<i class="fas fa-user"></i>
|
||||
<span class="info-label">{% trans "Middle Name" %}:</span>
|
||||
<span class="info-value">{{ person.middle_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if person.last_name %}
|
||||
<div class="info-item">
|
||||
<i class="fas fa-user"></i>
|
||||
<span class="info-label">{% trans "Last Name" %}:</span>
|
||||
<span class="info-value">{{ person.last_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if person.date_of_birth %}
|
||||
<div class="info-item">
|
||||
<i class="fas fa-birthday-cake"></i>
|
||||
<span class="info-label">{% trans "Date of Birth" %}:</span>
|
||||
<span class="info-value">{{ person.date_of_birth }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if person.gender %}
|
||||
<div class="info-item">
|
||||
<i class="fas fa-venus-mars"></i>
|
||||
<span class="info-label">{% trans "Gender" %}:</span>
|
||||
<span class="info-value">
|
||||
{% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if person.nationality %}
|
||||
<div class="info-item">
|
||||
<i class="fas fa-globe"></i>
|
||||
<span class="info-label">{% trans "Nationality" %}:</span>
|
||||
<span class="info-value">{{ person.nationality }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Information Column -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="info-section">
|
||||
<h5><i class="fas fa-address-book me-2"></i>{% trans "Contact Information" %}</h5>
|
||||
|
||||
{% if person.email %}
|
||||
<div class="info-item">
|
||||
<i class="fas fa-envelope"></i>
|
||||
<span class="info-label">{% trans "Email" %}:</span>
|
||||
<span class="info-value">
|
||||
<a href="mailto:{{ person.email }}" class="text-decoration-none">
|
||||
{{ person.email }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if person.phone %}
|
||||
<div class="info-item">
|
||||
<i class="fas fa-phone"></i>
|
||||
<span class="info-label">{% trans "Phone" %}:</span>
|
||||
<span class="info-value">
|
||||
<a href="tel:{{ person.phone }}" class="text-decoration-none">
|
||||
{{ person.phone }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if person.address %}
|
||||
<div class="info-item">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
<span class="info-label">{% trans "Address" %}:</span>
|
||||
<span class="info-value">{{ person.address|linebreaksbr }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if person.linkedin_profile %}
|
||||
<div class="info-item">
|
||||
<i class="fab fa-linkedin"></i>
|
||||
<span class="info-label">{% trans "LinkedIn" %}:</span>
|
||||
<span class="info-value">
|
||||
<a href="{{ person.linkedin_profile }}" target="_blank"
|
||||
class="text-decoration-none">
|
||||
{% trans "View Profile" %}
|
||||
<i class="fas fa-external-link-alt ms-1"></i>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Information -->
|
||||
<div class="row">
|
||||
<!-- Applications -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-briefcase me-2"></i>{% trans "Applications" %}
|
||||
<span class="badge bg-primary ms-2">{{ person.applications.count }}</span>
|
||||
</h5>
|
||||
|
||||
{% if person.applications %}
|
||||
{% for application in person.applications.all %}
|
||||
<div class="related-item">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="mb-1">
|
||||
<a href="{% url 'candidate_detail' application.slug %}"
|
||||
class="text-decoration-none">
|
||||
{{ application.job.title }}
|
||||
</a>
|
||||
</h6>
|
||||
<small class="text-muted">
|
||||
{% trans "Applied" %}: {{ application.created_at|date:"d M Y" }}
|
||||
</small>
|
||||
</div>
|
||||
<span class="badge bg-primary">{{ application.stage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-briefcase"></i>
|
||||
<p>{% trans "No applications found" %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-file-alt me-2"></i>{% trans "Documents" %}
|
||||
<span class="badge bg-primary ms-2">{{ person.documents.count }}</span>
|
||||
</h5>
|
||||
|
||||
{% if person.documents %}
|
||||
{% for document in person.documents %}
|
||||
<div class="related-item">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="mb-1">
|
||||
<a href="{{ document.file.url }}" target="_blank"
|
||||
class="text-decoration-none">
|
||||
{{ document.filename }}
|
||||
</a>
|
||||
</h6>
|
||||
<small class="text-muted">
|
||||
{{ document.file_size|filesizeformat }} •
|
||||
{{ document.uploaded_at|date:"d M Y" }}
|
||||
</small>
|
||||
</div>
|
||||
<a href="{{ document.file.url }}" download="{{ document.filename }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
<p>{% trans "No documents found" %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Information -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-info-circle me-2"></i>{% trans "System Information" %}
|
||||
</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="info-item">
|
||||
<i class="fas fa-calendar-plus"></i>
|
||||
<span class="info-label">{% trans "Created" %}:</span>
|
||||
<span class="info-value">{{ person.created_at|date:"d M Y H:i" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="info-item">
|
||||
<i class="fas fa-calendar-edit"></i>
|
||||
<span class="info-label">{% trans "Last Updated" %}:</span>
|
||||
<span class="info-value">{{ person.updated_at|date:"d M Y H:i" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if person.user %}
|
||||
<div class="col-md-6">
|
||||
<div class="info-item">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
<span class="info-label">{% trans "User Account" %}:</span>
|
||||
<span class="info-value">
|
||||
<a href="{% url 'user_detail' person.user.pk %}"
|
||||
class="text-decoration-none">
|
||||
{{ person.user.username }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to People" %}
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'person_update' person.slug %}" class="btn btn-main-action">
|
||||
<i class="fas fa-edit me-1"></i> {% trans "Edit Person" %}
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-delete-url="{% url 'person_delete' person.slug %}"
|
||||
data-item-name="{{ person.get_full_name }}">
|
||||
<i class="fas fa-trash-alt me-1"></i> {% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add smooth scrolling for internal links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add copy to clipboard functionality for email and phone
|
||||
const copyElements = document.querySelectorAll('[data-copy]');
|
||||
copyElements.forEach(element => {
|
||||
element.addEventListener('click', function() {
|
||||
const textToCopy = this.getAttribute('data-copy');
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
// Show temporary feedback
|
||||
const originalText = this.innerHTML;
|
||||
this.innerHTML = '<i class="fas fa-check me-1"></i>Copied!';
|
||||
this.classList.add('text-success');
|
||||
|
||||
setTimeout(() => {
|
||||
this.innerHTML = originalText;
|
||||
this.classList.remove('text-success');
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add hover effects for cards
|
||||
const cards = document.querySelectorAll('.card');
|
||||
cards.forEach(card => {
|
||||
card.addEventListener('mouseenter', function() {
|
||||
this.style.transform = 'translateY(-2px)';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', function() {
|
||||
this.style.transform = 'translateY(0)';
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
411
templates/people/person_list.html
Normal file
411
templates/people/person_list.html
Normal file
@ -0,0 +1,411 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}People - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* UI Variables for the KAAT-S Theme (Consistent with Reference) */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
--kaauh-gray-light: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Primary Color Overrides */
|
||||
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
||||
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
|
||||
.text-success { color: var(--kaauh-success) !important; }
|
||||
.text-danger { color: var(--kaauh-danger) !important; }
|
||||
.text-info { color: #17a2b8 !important; }
|
||||
|
||||
/* Enhanced Card Styling */
|
||||
.card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
background-color: white;
|
||||
}
|
||||
.card:not(.no-hover):hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
|
||||
}
|
||||
.card.no-hover:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
/* Main Action Button Style */
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Secondary Button Style */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Person Card Specifics */
|
||||
.person-card .card-title {
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 600;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.person-card .card-text i {
|
||||
color: var(--kaauh-teal);
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
/* Badge Styling */
|
||||
.badge {
|
||||
font-weight: 600;
|
||||
padding: 0.4em 0.7em;
|
||||
border-radius: 0.3rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Table Styling */
|
||||
.table-view .table thead th {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-color: var(--kaauh-border);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.table-view .table tbody td {
|
||||
vertical-align: middle;
|
||||
padding: 1rem;
|
||||
border-color: var(--kaauh-border);
|
||||
}
|
||||
.table-view .table tbody tr:hover {
|
||||
background-color: var(--kaauh-gray-light);
|
||||
}
|
||||
|
||||
/* Pagination Link Styling */
|
||||
.pagination .page-item .page-link {
|
||||
color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-border);
|
||||
}
|
||||
.pagination .page-item.active .page-link {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
}
|
||||
.pagination .page-item:hover .page-link:not(.active) {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
/* Profile Image Styling */
|
||||
.profile-image-small {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--kaauh-border);
|
||||
}
|
||||
|
||||
.profile-image-medium {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--kaauh-teal);
|
||||
}
|
||||
|
||||
/* Filter & Search Layout */
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||
<i class="fas fa-user-friends me-2"></i> {% trans "People Directory" %}
|
||||
</h1>
|
||||
<a href="{% url 'person_create' %}" class="btn btn-main-action">
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Add New Person" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="card mb-4 shadow-sm no-hover">
|
||||
<div class="card-body">
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<label for="search" class="form-label small text-muted">{% trans "Search by Name or Email" %}</label>
|
||||
<form method="get" action="" class="w-100">
|
||||
<div class="input-group input-group-lg">
|
||||
<input type="text" name="q" class="form-control" id="search"
|
||||
placeholder="{% trans 'Search people...' %}"
|
||||
value="{{ request.GET.q }}">
|
||||
<button class="btn btn-main-action" type="submit">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<form method="GET" class="row g-3 align-items-end h-100">
|
||||
{% if request.GET.q %}<input type="hidden" name="q" value="{{ request.GET.q }}">{% endif %}
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="nationality_filter" class="form-label small text-muted">{% trans "Filter by Nationality" %}</label>
|
||||
<select name="nationality" id="nationality_filter" class="form-select form-select-sm">
|
||||
<option value="">{% trans "All Nationalities" %}</option>
|
||||
{% for nationality in nationalities %}
|
||||
<option value="{{ nationality }}" {% if request.GET.nationality == nationality %}selected{% endif %}>{{ nationality }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="gender_filter" class="form-label small text-muted">{% trans "Filter by Gender" %}</label>
|
||||
<select name="gender" id="gender_filter" class="form-select form-select-sm">
|
||||
<option value="">{% trans "All Genders" %}</option>
|
||||
<option value="M" {% if request.GET.gender == 'M' %}selected{% endif %}>{% trans "Male" %}</option>
|
||||
<option value="F" {% if request.GET.gender == 'F' %}selected{% endif %}>{% trans "Female" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 d-flex justify-content-end align-self-end">
|
||||
<div class="filter-buttons">
|
||||
<button type="submit" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-filter me-1"></i> {% trans "Apply" %}
|
||||
</button>
|
||||
{% if request.GET.q or request.GET.nationality or request.GET.gender %}
|
||||
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if people_list %}
|
||||
<div id="person-list">
|
||||
<!-- View Switcher -->
|
||||
{% include "includes/_list_view_switcher.html" with list_id="person-list" %}
|
||||
|
||||
<!-- Table View (Default) -->
|
||||
<div class="table-view">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Photo" %}</th>
|
||||
<th scope="col">{% trans "Name" %}</th>
|
||||
<th scope="col">{% trans "Email" %}</th>
|
||||
<th scope="col">{% trans "Phone" %}</th>
|
||||
<th scope="col">{% trans "Nationality" %}</th>
|
||||
<th scope="col">{% trans "Gender" %}</th>
|
||||
<th scope="col">{% trans "Agency" %}</th>
|
||||
<th scope="col">{% trans "Created" %}</th>
|
||||
<th scope="col" class="text-end">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for person in people_list %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if person.profile_image %}
|
||||
<img src="{{ person.profile_image.url }}" alt="{{ person.get_full_name }}"
|
||||
class="profile-image-small">
|
||||
{% else %}
|
||||
<div class="profile-image-small d-flex align-items-center justify-content-center bg-light">
|
||||
<i class="fas fa-user text-muted"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="fw-medium">
|
||||
<a href="{% url 'person_detail' person.slug %}"
|
||||
class="text-decoration-none link-secondary">
|
||||
{{ person.full_name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ person.email|default:"N/A" }}</td>
|
||||
<td>{{ person.phone|default:"N/A" }}</td>
|
||||
<td>
|
||||
{% if person.nationality %}
|
||||
<span class="badge bg-primary">{{ person.nationality }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if person.gender %}
|
||||
<span class="badge bg-info">
|
||||
{% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="badge bg-secondary">{{ person.agency.name|default:"N/A" }}</span></td>
|
||||
<td>{{ person.created_at|date:"d-m-Y" }}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{% url 'person_detail' person.slug %}"
|
||||
class="btn btn-outline-primary" title="{% trans 'View' %}">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'person_update' person.slug %}"
|
||||
class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
title="{% trans 'Delete' %}"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-delete-url="{% url 'person_delete' person.slug %}"
|
||||
data-item-name="{{ person.get_full_name }}">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card View -->
|
||||
<div class="card-view row">
|
||||
{% for person in people_list %}
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card person-card h-100 shadow-sm">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="me-3">
|
||||
{% if person.profile_image %}
|
||||
<img src="{{ person.profile_image.url }}" alt="{{ person.get_full_name }}"
|
||||
class="profile-image-medium">
|
||||
{% else %}
|
||||
<div class="profile-image-medium d-flex align-items-center justify-content-center bg-light">
|
||||
<i class="fas fa-user text-muted fa-2x"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="card-title mb-1">
|
||||
<a href="{% url 'person_detail' person.slug %}"
|
||||
class="text-decoration-none text-primary-theme">
|
||||
{{ person.get_full_name }}
|
||||
</a>
|
||||
</h5>
|
||||
<p class="text-muted small mb-2">{{ person.email|default:"N/A" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-text text-muted small">
|
||||
{% if person.phone %}
|
||||
<div class="mb-2">
|
||||
<i class="fas fa-phone me-2"></i>{{ person.phone }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if person.nationality %}
|
||||
<div class="mb-2">
|
||||
<i class="fas fa-globe me-2"></i>
|
||||
<span class="badge bg-primary">{{ person.nationality }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if person.gender %}
|
||||
<div class="mb-2">
|
||||
<i class="fas fa-venus-mars me-2"></i>
|
||||
<span class="badge bg-info">
|
||||
{% if person.gender == 'M' %}{% trans "Male" %}{% else %}{% trans "Female" %}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if person.date_of_birth %}
|
||||
<div class="mb-2">
|
||||
<i class="fas fa-birthday-cake me-2"></i>{{ person.date_of_birth }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-3 border-top">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'person_detail' person.slug %}"
|
||||
class="btn btn-sm btn-main-action">
|
||||
<i class="fas fa-eye"></i> {% trans "View" %}
|
||||
</a>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'person_update' person.slug %}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-edit"></i> {% trans "Edit" %}
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
title="{% trans 'Delete' %}"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-delete-url="{% url 'person_delete' person.slug %}"
|
||||
data-item-name="{{ person.get_full_name }}">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% include "includes/paginator.html" %}
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-5 card shadow-sm">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-user-friends fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
|
||||
<h3>{% trans "No people found" %}</h3>
|
||||
<p class="text-muted">{% trans "Create your first person record." %}</p>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'person_create' %}" class="btn btn-main-action mt-3">
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Add Person" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
572
templates/people/update_person.html
Normal file
572
templates/people/update_person.html
Normal file
@ -0,0 +1,572 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n crispy_forms_tags %}
|
||||
|
||||
{% block title %}Update {{ person.get_full_name }} - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* UI Variables for the KAAT-S Theme */
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-gray-light: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Form Container Styling */
|
||||
.form-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Card Styling */
|
||||
.card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
/* Main Action Button Style */
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Secondary Button Style */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Form Field Styling */
|
||||
.form-control:focus {
|
||||
border-color: var(--kaauh-teal);
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
border-color: var(--kaauh-teal);
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
||||
}
|
||||
|
||||
/* Profile Image Upload Styling */
|
||||
.profile-image-upload {
|
||||
border: 2px dashed var(--kaauh-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-image-upload:hover {
|
||||
border-color: var(--kaauh-teal);
|
||||
background-color: var(--kaauh-gray-light);
|
||||
}
|
||||
|
||||
.profile-image-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--kaauh-teal);
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.current-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--kaauh-teal);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
/* Breadcrumb Styling */
|
||||
.breadcrumb {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumb-item + .breadcrumb-item::before {
|
||||
content: ">";
|
||||
color: var(--kaauh-teal);
|
||||
}
|
||||
|
||||
/* Alert Styling */
|
||||
.alert {
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.btn.loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn.loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: auto;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Current Profile Section */
|
||||
.current-profile {
|
||||
background-color: var(--kaauh-gray-light);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.current-profile h6 {
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="form-container">
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'person_list' %}" class="text-decoration-none">
|
||||
<i class="fas fa-user-friends me-1"></i> {% trans "People" %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'person_detail' person.slug %}" class="text-decoration-none">
|
||||
{{ person.get_full_name }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{% trans "Update" %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||
<i class="fas fa-user-edit me-2"></i> {% trans "Update Person" %}
|
||||
</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'person_detail' person.slug %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-eye me-1"></i> {% trans "View Details" %}
|
||||
</a>
|
||||
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to List" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Profile Info -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="current-profile">
|
||||
<h6><i class="fas fa-info-circle me-2"></i>{% trans "Currently Editing" %}</h6>
|
||||
<div class="d-flex align-items-center">
|
||||
{% if person.profile_image %}
|
||||
<img src="{{ person.profile_image.url }}" alt="{{ person.get_full_name }}"
|
||||
class="current-image">
|
||||
{% else %}
|
||||
<div class="current-image d-flex align-items-center justify-content-center bg-light">
|
||||
<i class="fas fa-user text-muted"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<h5 class="mb-1">{{ person.get_full_name }}</h5>
|
||||
{% if person.email %}
|
||||
<p class="text-muted mb-0">{{ person.email }}</p>
|
||||
{% endif %}
|
||||
<small class="text-muted">
|
||||
{% trans "Created" %}: {{ person.created_at|date:"d M Y" }} •
|
||||
{% trans "Last Updated" %}: {{ person.updated_at|date:"d M Y" }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h5 class="alert-heading">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>{% trans "Error" %}
|
||||
</h5>
|
||||
{% for error in form.non_field_errors %}
|
||||
<p class="mb-0">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% trans 'Close' %}"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data" id="person-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Profile Image Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="profile-image-upload" onclick="document.getElementById('id_profile_image').click()">
|
||||
<div id="image-preview-container">
|
||||
{% if person.profile_image %}
|
||||
<img src="{{ person.profile_image.url }}" alt="Current Profile"
|
||||
class="profile-image-preview">
|
||||
<h5 class="text-muted mt-3">{% trans "Click to change photo" %}</h5>
|
||||
<p class="text-muted small">{% trans "Current photo will be replaced" %}</p>
|
||||
{% else %}
|
||||
<i class="fas fa-camera fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">{% trans "Upload Profile Photo" %}</h5>
|
||||
<p class="text-muted small">{% trans "Click to browse or drag and drop" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<input type="file" name="profile_image" id="id_profile_image"
|
||||
class="d-none" accept="image/*">
|
||||
</div>
|
||||
{% if person.profile_image %}
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">
|
||||
{% trans "Leave empty to keep current photo" %}
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personal Information Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
|
||||
<i class="fas fa-user me-2"></i> {% trans "Personal Information" %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.first_name|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.middle_name|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.last_name|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Information Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
|
||||
<i class="fas fa-envelope me-2"></i> {% trans "Contact Information" %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form.email|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form.phone|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Information Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
|
||||
<i class="fas fa-info-circle me-2"></i> {% trans "Additional Information" %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.date_of_birth|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.nationality|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.gender|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
|
||||
<i class="fas fa-map-marker-alt me-2"></i> {% trans "Address Information" %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
{{ form.address|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LinkedIn Profile Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h5 class="mb-3" style="color: var(--kaauh-teal-dark); border-bottom: 2px solid var(--kaauh-teal); padding-bottom: 0.5rem;">
|
||||
<i class="fab fa-linkedin me-2"></i> {% trans "Professional Profile" %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-group mb-3">
|
||||
<label for="id_linkedin_profile" class="form-label">
|
||||
{% trans "LinkedIn Profile URL" %}
|
||||
</label>
|
||||
<input type="url" name="linkedin_profile" id="id_linkedin_profile"
|
||||
class="form-control" placeholder="https://linkedin.com/in/username"
|
||||
value="{{ person.linkedin_profile|default:'' }}">
|
||||
<small class="form-text text-muted">
|
||||
{% trans "Optional: Add LinkedIn profile URL" %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'person_detail' person.slug %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
|
||||
</a>
|
||||
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-list me-1"></i> {% trans "Back to List" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="reset" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-undo me-1"></i> {% trans "Reset Changes" %}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-main-action">
|
||||
<i class="fas fa-save me-1"></i> {% trans "Update Person" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block customJS %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Profile Image Preview
|
||||
const profileImageInput = document.getElementById('id_profile_image');
|
||||
const imagePreviewContainer = document.getElementById('image-preview-container');
|
||||
const originalImage = imagePreviewContainer.innerHTML;
|
||||
|
||||
profileImageInput.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
imagePreviewContainer.innerHTML = `
|
||||
<img src="${e.target.result}" alt="Profile Preview" class="profile-image-preview">
|
||||
<h5 class="text-muted mt-3">${file.name}</h5>
|
||||
<p class="text-muted small">{% trans "New photo selected" %}</p>
|
||||
`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else if (!file) {
|
||||
// Reset to original if no file selected
|
||||
imagePreviewContainer.innerHTML = originalImage;
|
||||
}
|
||||
});
|
||||
|
||||
// Form Validation
|
||||
const form = document.getElementById('person-form');
|
||||
form.addEventListener('submit', function(e) {
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
submitBtn.classList.add('loading');
|
||||
submitBtn.disabled = true;
|
||||
|
||||
// Basic validation
|
||||
const firstName = document.getElementById('id_first_name').value.trim();
|
||||
const lastName = document.getElementById('id_last_name').value.trim();
|
||||
const email = document.getElementById('id_email').value.trim();
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
e.preventDefault();
|
||||
submitBtn.classList.remove('loading');
|
||||
submitBtn.disabled = false;
|
||||
alert('{% trans "First name and last name are required." %}');
|
||||
return;
|
||||
}
|
||||
|
||||
if (email && !isValidEmail(email)) {
|
||||
e.preventDefault();
|
||||
submitBtn.classList.remove('loading');
|
||||
submitBtn.disabled = false;
|
||||
alert('{% trans "Please enter a valid email address." %}');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Email validation helper
|
||||
function isValidEmail(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
// LinkedIn URL validation
|
||||
const linkedinInput = document.getElementById('id_linkedin_profile');
|
||||
linkedinInput.addEventListener('blur', function() {
|
||||
const value = this.value.trim();
|
||||
if (value && !isValidLinkedInURL(value)) {
|
||||
this.classList.add('is-invalid');
|
||||
if (!this.nextElementSibling || !this.nextElementSibling.classList.contains('invalid-feedback')) {
|
||||
const feedback = document.createElement('div');
|
||||
feedback.className = 'invalid-feedback';
|
||||
feedback.textContent = '{% trans "Please enter a valid LinkedIn URL" %}';
|
||||
this.parentNode.appendChild(feedback);
|
||||
}
|
||||
} else {
|
||||
this.classList.remove('is-invalid');
|
||||
const feedback = this.parentNode.querySelector('.invalid-feedback');
|
||||
if (feedback) feedback.remove();
|
||||
}
|
||||
});
|
||||
|
||||
function isValidLinkedInURL(url) {
|
||||
const linkedinRegex = /^https?:\/\/(www\.)?linkedin\.com\/.+/i;
|
||||
return linkedinRegex.test(url);
|
||||
}
|
||||
|
||||
// Drag and Drop functionality
|
||||
const uploadArea = document.querySelector('.profile-image-upload');
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
uploadArea.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
uploadArea.addEventListener(eventName, highlight, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
uploadArea.addEventListener(eventName, unhighlight, false);
|
||||
});
|
||||
|
||||
function highlight(e) {
|
||||
uploadArea.style.borderColor = 'var(--kaauh-teal)';
|
||||
uploadArea.style.backgroundColor = 'var(--kaauh-gray-light)';
|
||||
}
|
||||
|
||||
function unhighlight(e) {
|
||||
uploadArea.style.borderColor = 'var(--kaauh-border)';
|
||||
uploadArea.style.backgroundColor = 'transparent';
|
||||
}
|
||||
|
||||
uploadArea.addEventListener('drop', handleDrop, false);
|
||||
|
||||
function handleDrop(e) {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
|
||||
if (files.length > 0) {
|
||||
profileImageInput.files = files;
|
||||
const event = new Event('change', { bubbles: true });
|
||||
profileImageInput.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset button functionality
|
||||
const resetBtn = form.querySelector('button[type="reset"]');
|
||||
resetBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Reset form fields
|
||||
form.reset();
|
||||
|
||||
// Reset image preview
|
||||
imagePreviewContainer.innerHTML = originalImage;
|
||||
|
||||
// Clear any validation states
|
||||
form.querySelectorAll('.is-invalid').forEach(element => {
|
||||
element.classList.remove('is-invalid');
|
||||
});
|
||||
|
||||
// Remove any invalid feedback messages
|
||||
form.querySelectorAll('.invalid-feedback').forEach(element => {
|
||||
element.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// Warn before leaving if changes are made
|
||||
let formChanged = false;
|
||||
const formInputs = form.querySelectorAll('input, select, textarea');
|
||||
|
||||
formInputs.forEach(input => {
|
||||
input.addEventListener('change', function() {
|
||||
formChanged = true;
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (formChanged) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '{% trans "You have unsaved changes. Are you sure you want to leave?" %}';
|
||||
return e.returnValue;
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function() {
|
||||
formChanged = false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -76,6 +76,24 @@
|
||||
<div class="navbar-nav ms-auto">
|
||||
|
||||
{# NAVIGATION LINKS (Add your portal links here if needed) #}
|
||||
{% if request.user.user_type == 'agency' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white" href="{% url 'agency_portal_dashboard' %}">
|
||||
<i class="fas fa-tachometer-alt me-1"></i> {% trans "Dashboard" %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white" href="{% url 'agency_portal_persons_list' %}">
|
||||
<i class="fas fa-users me-1"></i> {% trans "Persons" %}
|
||||
</a>
|
||||
</li>
|
||||
{% elif request.user.user_type == 'candidate' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white" href="{% url 'candidate_portal_dashboard' %}">
|
||||
<i class="fas fa-tachometer-alt me-1"></i> {% trans "Dashboard" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle text-white" href="#" role="button" data-bs-toggle="dropdown"
|
||||
@ -217,4 +235,4 @@
|
||||
|
||||
{% block customJS %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@ -162,7 +162,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="kaauh-card shadow-sm mb-4">
|
||||
@ -210,10 +210,12 @@
|
||||
{% trans "Share these credentials securely with the agency. They can use this information to log in and submit candidates." %}
|
||||
</div>
|
||||
|
||||
<a href="{% url 'agency_access_link_detail' access_link.slug %}"
|
||||
class="btn btn-outline-info btn-sm mx-2">
|
||||
<i class="fas fa-eye me-1"></i> {% trans "View Access Links Details" %}
|
||||
</a>
|
||||
{% if access_link %}
|
||||
<a href="{% url 'agency_access_link_detail' access_link.slug %}"
|
||||
class="btn btn-outline-info btn-sm mx-2">
|
||||
<i class="fas fa-eye me-1"></i> {% trans "View Access Links Details" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -331,7 +333,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Actions Card -->
|
||||
<div class="kaauh-card p-4">
|
||||
<h5 class="mb-4" style="color: var(--kaauh-teal-dark);">
|
||||
@ -488,14 +490,14 @@ function copyToClipboard(elementId) {
|
||||
function confirmDeactivate() {
|
||||
if (confirm('{% trans "Are you sure you want to deactivate this access link? Agencies will no longer be able to use it." %}')) {
|
||||
// Submit form to deactivate
|
||||
window.location.href = '{% url "agency_access_link_deactivate" access_link.slug %}';
|
||||
window.location.href = '';
|
||||
}
|
||||
}
|
||||
|
||||
function confirmReactivate() {
|
||||
if (confirm('{% trans "Are you sure you want to reactivate this access link?" %}')) {
|
||||
// Submit form to reactivate
|
||||
window.location.href = '{% url "agency_access_link_reactivate" access_link.slug %}';
|
||||
window.location.href = '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
390
templates/recruitment/agency_portal_persons_list.html
Normal file
390
templates/recruitment/agency_portal_persons_list.html
Normal file
@ -0,0 +1,390 @@
|
||||
{% extends 'portal_base.html' %}
|
||||
{% load static i18n crispy_forms_tags %}
|
||||
|
||||
{% block title %}{% trans "Persons List" %} - ATS{% endblock %}
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
:root {
|
||||
--kaauh-teal: #00636e;
|
||||
--kaauh-teal-dark: #004a53;
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
--kaauh-success: #28a745;
|
||||
--kaauh-info: #17a2b8;
|
||||
--kaauh-danger: #dc3545;
|
||||
--kaauh-warning: #ffc107;
|
||||
}
|
||||
|
||||
.kaauh-card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.btn-main-action {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-main-action:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.person-row:hover {
|
||||
background-color: #f8f9fa;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
background-color: white;
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock%}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4 persons-list">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div class="px-2 py-2">
|
||||
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||
<i class="fas fa-users me-2"></i>
|
||||
{% trans "All Persons" %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0">
|
||||
{% trans "All persons who come through" %} {{ agency.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Add Person Button -->
|
||||
<button type="button" class="btn btn-main-action" data-bs-toggle="modal" data-bs-target="#personModal">
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Add New Person" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter Section -->
|
||||
<div class="kaauh-card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="search-form">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="search" class="form-label fw-semibold">
|
||||
<i class="fas fa-search me-1"></i>{% trans "Search" %}
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="search"
|
||||
name="q"
|
||||
value="{{ search_query }}"
|
||||
placeholder="{% trans 'Search by name, email, phone, or job title...' %}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="stage" class="form-label fw-semibold">
|
||||
<i class="fas fa-filter me-1"></i>{% trans "Stage" %}
|
||||
</label>
|
||||
<select class="form-select" id="stage" name="stage">
|
||||
<option value="">{% trans "All Stages" %}</option>
|
||||
{% for stage_value, stage_label in stage_choices %}
|
||||
<option value="{{ stage_value }}"
|
||||
{% if stage_filter == stage_value %}selected{% endif %}>
|
||||
{{ stage_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-main-action w-100">
|
||||
<i class="fas fa-search me-1"></i> {% trans "Search" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Summary -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="kaauh-card shadow-sm h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-info mb-2">
|
||||
<i class="fas fa-users fa-2x"></i>
|
||||
</div>
|
||||
<h4 class="card-title">{{ total_persons }}</h4>
|
||||
<p class="card-text text-muted">{% trans "Total Persons" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="kaauh-card shadow-sm h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-success mb-2">
|
||||
<i class="fas fa-check-circle fa-2x"></i>
|
||||
</div>
|
||||
<h4 class="card-title">{{ page_obj|length }}</h4>
|
||||
<p class="card-text text-muted">{% trans "Showing on this page" %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Persons Table -->
|
||||
<div class="kaauh-card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
{% if page_obj %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col">{% trans "Name" %}</th>
|
||||
<th scope="col">{% trans "Email" %}</th>
|
||||
<th scope="col">{% trans "Phone" %}</th>
|
||||
<th scope="col">{% trans "Job" %}</th>
|
||||
<th scope="col">{% trans "Stage" %}</th>
|
||||
<th scope="col">{% trans "Applied Date" %}</th>
|
||||
<th scope="col" class="text-center">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for person in page_obj %}
|
||||
<tr class="person-row">
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center me-2"
|
||||
style="width: 32px; height: 32px; font-size: 14px; font-weight: 600;">
|
||||
{{ person.first_name|first|upper }}{{ person.last_name|first|upper }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold">{{ person.first_name }} {{ person.last_name }}</div>
|
||||
{% if person.address %}
|
||||
<small class="text-muted">{{ person.address|truncatechars:50 }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="mailto:{{ person.email }}" class="text-decoration-none">
|
||||
{{ person.email }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ person.phone|default:"-" }}</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark">
|
||||
{{ person.job.title|truncatechars:30 }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% with stage_class=person.stage|lower %}
|
||||
<span class="stage-badge
|
||||
{% if stage_class == 'applied' %}bg-secondary{% endif %}
|
||||
{% if stage_class == 'exam' %}bg-info{% endif %}
|
||||
{% if stage_class == 'interview' %}bg-warning{% endif %}
|
||||
{% if stage_class == 'offer' %}bg-success{% endif %}
|
||||
{% if stage_class == 'hired' %}bg-primary{% endif %}
|
||||
{% if stage_class == 'rejected' %}bg-danger{% endif %}
|
||||
text-white">
|
||||
{{ person.get_stage_display }}
|
||||
</span>
|
||||
{% endwith %}
|
||||
</td>
|
||||
<td>{{ person.created_at|date:"Y-m-d" }}</td>
|
||||
<td class="text-center">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{% url 'candidate_detail' person.slug %}"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
title="{% trans 'View Details' %}">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
title="{% trans 'Edit Person' %}"
|
||||
onclick="editPerson({{ person.id }})">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-users fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">{% trans "No persons found" %}</h5>
|
||||
<p class="text-muted">
|
||||
{% if search_query or stage_filter %}
|
||||
{% trans "Try adjusting your search or filter criteria." %}
|
||||
{% else %}
|
||||
{% trans "No persons have been added yet." %}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if not search_query and not stage_filter and agency.assignments.exists %}
|
||||
<a href="{% url 'agency_portal_submit_candidate_page' agency.assignments.first.slug %}"
|
||||
class="btn btn-main-action">
|
||||
<i class="fas fa-user-plus me-1"></i> {% trans "Add First Person" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<nav aria-label="{% trans 'Persons pagination' %}" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page=1{% if search_query %}&q={{ search_query }}{% endif %}{% if stage_filter %}&stage={{ stage_filter }}{% endif %}">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if stage_filter %}&stage={{ stage_filter }}{% endif %}">
|
||||
<i class="fas fa-angle-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ num }}</span>
|
||||
</li>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ num }}{% if search_query %}&q={{ search_query }}{% endif %}{% if stage_filter %}&stage={{ stage_filter }}{% endif %}">{{ num }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&q={{ search_query }}{% endif %}{% if stage_filter %}&stage={{ stage_filter }}{% endif %}">
|
||||
<i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if search_query %}&q={{ search_query }}{% endif %}{% if stage_filter %}&stage={{ stage_filter }}{% endif %}">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Person Modal -->
|
||||
<div class="modal fade modal-lg" id="personModal" tabindex="-1" aria-labelledby="personModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="personModalLabel">
|
||||
<i class="fas fa-users me-2"></i>
|
||||
{% trans "Person Details" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="personModalBody">
|
||||
<form id="person_form" hx-post="{% url 'person_create' %}" hx-vals='{"view":"portal","agency":"{{ agency.slug }}"}' hx-select=".persons-list" hx-target=".persons-list" hx-swap="outerHTML"
|
||||
hx-on:afterRequest="$('#personModal').modal('hide')">
|
||||
{% csrf_token %}
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
{{ person_form.first_name|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ person_form.middle_name|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ person_form.last_name|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
{{ person_form.email|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ person_form.phone|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
{{ person_form.date_of_birth|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ person_form.nationality|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<div class="col-12">
|
||||
{{ person_form.address|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-main-action" type="submit" form="person_form">{% trans "Save" %}</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
{% trans "Close" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openPersonModal(personId, personName) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('personModal'));
|
||||
document.getElementById('person-modal-text').innerHTML = `<strong>${personName}</strong> (ID: ${personId})`;
|
||||
modal.show();
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
function editPerson(personId) {
|
||||
// Placeholder for edit functionality
|
||||
// This would typically open a modal or navigate to edit page
|
||||
console.log('Edit person:', personId);
|
||||
// For now, you can redirect to a placeholder edit URL
|
||||
// window.location.href = `/portal/candidates/${personId}/edit/`;
|
||||
}
|
||||
|
||||
// Auto-submit form on filter change
|
||||
document.getElementById('stage').addEventListener('change', function() {
|
||||
this.form.submit();
|
||||
});
|
||||
|
||||
// Add row click functionality
|
||||
document.querySelectorAll('.person-row').forEach(row => {
|
||||
row.addEventListener('click', function(e) {
|
||||
// Don't trigger if clicking on buttons or links
|
||||
if (e.target.closest('a, button')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the view details button and click it
|
||||
const viewBtn = this.querySelector('a[title*="View"]');
|
||||
if (viewBtn) {
|
||||
viewBtn.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n crispy_forms_tags %}
|
||||
|
||||
{% block title %}Create Candidate - {{ block.super }}{% endblock %}
|
||||
{% block title %}Create Application - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
@ -36,7 +36,7 @@
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
|
||||
/* Outlined Button Styles */
|
||||
.btn-secondary, .btn-outline-secondary {
|
||||
background-color: #f8f9fa;
|
||||
@ -58,7 +58,7 @@
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
|
||||
/* Colored Header Card */
|
||||
.candidate-header-card {
|
||||
background: linear-gradient(135deg, var(--kaauh-teal), #004d57);
|
||||
@ -84,18 +84,22 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="candidate-header-card">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap">
|
||||
<div class="flex-grow-1">
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
{% trans "Create New Candidate" %}
|
||||
<i class="fas fa-user-plus"></i>
|
||||
{% trans "Create New Application" %}
|
||||
</h1>
|
||||
<p class="text-white opacity-75 mb-0">{% trans "Enter details to create a new candidate record." %}</p>
|
||||
<p class="text-white opacity-75 mb-0">{% trans "Enter details to create a new application record." %}</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-1">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#personModal">
|
||||
<i class="fas fa-user-plus me-1"></i>
|
||||
<span class="d-none d-sm-inline">{% trans "Create New Person" %}</span>
|
||||
</button>
|
||||
<a href="{% url 'candidate_list' %}" class="btn btn-outline-light btn-sm" title="{% trans 'Back to List' %}">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span class="d-none d-sm-inline">{% trans "Back to List" %}</span>
|
||||
@ -109,13 +113,13 @@
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h2 class="h5 mb-0 text-primary">
|
||||
<i class="fas fa-file-alt me-1"></i>
|
||||
{% trans "Candidate Information" %}
|
||||
{% trans "Application Information" %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
|
||||
{# Split form into two columns for better horizontal use #}
|
||||
<div class="row g-4">
|
||||
{% for field in form %}
|
||||
@ -124,14 +128,69 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
<hr class="mt-4 mb-4">
|
||||
<button class="btn btn-main-action" type="submit">
|
||||
<i class="fas fa-save me-1"></i>
|
||||
{% trans "Create Candidate" %}
|
||||
{% trans "Create Application" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade modal-lg" id="personModal" tabindex="-1" aria-labelledby="personModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="personModalLabel">
|
||||
<i class="fas fa-question-circle me-2"></i>{% trans "Help" %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="person_form" hx-post="{% url 'person_create' %}" hx-vals='{"view":"job"}' hx-target="#div_id_person" hx-select="#div_id_person" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
{{ person_form.first_name|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ person_form.middle_name|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ person_form.last_name|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
{{ person_form.email|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ person_form.phone|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
{{ person_form.date_of_birth|as_crispy_field }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ person_form.nationality|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<div class="col-12">
|
||||
{{ person_form.address|as_crispy_field }}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-main-action" data-bs-dismiss="modal" form="person_form">{% trans "Save" %}</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -467,14 +467,13 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# TAB 4 CONTENT: DOCUMENTS #}
|
||||
<div class="tab-pane fade" id="documents-pane" role="tabpanel" aria-labelledby="documents-tab">
|
||||
{% with documents=candidate.documents.all %}
|
||||
{% with documents=candidate.documents %}
|
||||
{% include 'includes/document_list.html' %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
@ -302,7 +302,7 @@
|
||||
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'passed' %}"
|
||||
hx-target="#candidateviewModalBody"
|
||||
title="Pass Exam">
|
||||
{{ candidate.interview_status }}
|
||||
{{ candidate.exam_status }}
|
||||
</button>
|
||||
{% else %}
|
||||
--
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}Candidates - {{ block.super }}{% endblock %}
|
||||
{% block title %}Applications - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
@ -190,13 +190,11 @@
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||
<i class="fas fa-users me-2"></i> {% trans "Candidate Profiles" %}
|
||||
<i class="fas fa-users me-2"></i> {% trans "Applications List" %}
|
||||
</h1>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'candidate_create' %}" class="btn btn-main-action">
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Add New Candidate" %}
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Add New Application" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card mb-4 shadow-sm no-hover">
|
||||
@ -255,7 +253,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if candidates %}
|
||||
{% if applications %}
|
||||
<div id="candidate-list">
|
||||
{# View Switcher - list_id must match the container ID #}
|
||||
{% include "includes/_list_view_switcher.html" with list_id="candidate-list" %}
|
||||
@ -278,7 +276,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for candidate in candidates %}
|
||||
{% for candidate in applications %}
|
||||
<tr>
|
||||
<td class="fw-medium"><a href="{% url 'candidate_detail' candidate.slug %}" class="text-decoration-none link-secondary">{{ candidate.name }}<a></td>
|
||||
<td>{{ candidate.email }}</td>
|
||||
@ -396,11 +394,11 @@
|
||||
<div class="text-center py-5 card shadow-sm">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-users fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
|
||||
<h3>{% trans "No candidate profiles found" %}</h3>
|
||||
<p class="text-muted">{% trans "Create your first candidate profile or adjust your filters." %}</p>
|
||||
<h3>{% trans "No application found" %}</h3>
|
||||
<p class="text-muted">{% trans "Create your first application." %}</p>
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'candidate_create' %}" class="btn btn-main-action mt-3">
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Add Candidate" %}
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Add Application" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,148 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Candidate Signup" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="fas fa-user-plus me-2"></i>
|
||||
{% trans "Candidate Signup" %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.first_name.id_for_label }}" class="form-label">
|
||||
{% trans "First Name" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.first_name }}
|
||||
{% if form.first_name.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.first_name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.last_name.id_for_label }}" class="form-label">
|
||||
{% trans "Last Name" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.last_name }}
|
||||
{% if form.last_name.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.last_name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.middle_name.id_for_label }}" class="form-label">
|
||||
{% trans "Middle Name" %}
|
||||
</label>
|
||||
{{ form.middle_name }}
|
||||
{% if form.middle_name.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.middle_name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.phone.id_for_label }}" class="form-label">
|
||||
{% trans "Phone Number" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.phone }}
|
||||
{% if form.phone.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.phone.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.email.id_for_label }}" class="form-label">
|
||||
{% trans "Email Address" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.email }}
|
||||
{% if form.email.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.email.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.password.id_for_label }}" class="form-label">
|
||||
{% trans "Password" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.password }}
|
||||
{% if form.password.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.password.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.confirm_password.id_for_label }}" class="form-label">
|
||||
{% trans "Confirm Password" %} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.confirm_password }}
|
||||
{% if form.confirm_password.errors %}
|
||||
<div class="text-danger small">
|
||||
{{ form.confirm_password.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for error in form.non_field_errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-user-plus me-2"></i>
|
||||
{% trans "Sign Up" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<small class="text-muted">
|
||||
{% trans "Already have an account?" %}
|
||||
<a href="{% url 'portal_login' %}" class="text-decoration-none">
|
||||
{% trans "Login here" %}
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,14 +1,4 @@
|
||||
<td class="text-center" id="status-result-{{ candidate.pk}}">
|
||||
{% if not candidate.interview_status %}
|
||||
<button type="button" class="btn btn-warning btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateviewModal"
|
||||
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'exam' 'passed' %}"
|
||||
hx-target="#candidateviewModalBody"
|
||||
title="Pass Exam">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
{% if candidate.exam_status %}
|
||||
<button type="button" class="btn btn-{% if candidate.exam_status == 'Passed' %}success{% else %}danger{% endif %} btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
@ -21,5 +11,4 @@
|
||||
{% else %}
|
||||
--
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
@ -1,22 +1,22 @@
|
||||
<td class="text-center" id="status-result-{{ candidate.pk}}">
|
||||
<td class="text-center" id="interview-result-{{ candidate.pk}}">
|
||||
{% if not candidate.interview_status %}
|
||||
<button type="button" class="btn btn-warning btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateviewModal"
|
||||
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'offer' 'passed' %}"
|
||||
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'interview' 'passed' %}"
|
||||
hx-target="#candidateviewModalBody"
|
||||
title="Pass Exam">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
{% if candidate.offer_status %}
|
||||
<button type="button" class="btn btn-{% if candidate.offer_status == 'Passed' %}success{% else %}danger{% endif %} btn-sm"
|
||||
{% if candidate.interview_status %}
|
||||
<button type="button" class="btn btn-{% if candidate.interview_status == 'Passed' %}success{% else %}danger{% endif %} btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateviewModal"
|
||||
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'offer' 'passed' %}"
|
||||
hx-get="{% url 'update_candidate_status' job.slug candidate.slug 'interview' 'passed' %}"
|
||||
hx-target="#candidateviewModalBody"
|
||||
title="Pass Exam">
|
||||
{{ candidate.offer_status }}
|
||||
{{ candidate.interview_status }}
|
||||
</button>
|
||||
{% else %}
|
||||
--
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user