person update

This commit is contained in:
ismail 2025-11-13 14:05:59 +03:00
parent eb79173e26
commit da555c1460
44 changed files with 4144 additions and 707 deletions

View File

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

View File

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

View File

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

View File

@ -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',

View 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',
),
]

View File

@ -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')],
},
),
]

View File

@ -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'),
),
]

View 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'),
),
]

View File

@ -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):

View File

@ -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__'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -285,7 +285,7 @@
<th style="width: calc(50% / 7);">{% trans "Offer" %}</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>

View File

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

View File

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

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

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

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

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

View File

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

View File

@ -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 = '';
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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