6712 lines
230 KiB
Python
6712 lines
230 KiB
Python
# logger for recruitment views
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
import json
|
|
import csv
|
|
import ast
|
|
import logging
|
|
from datetime import datetime, time, timedelta
|
|
|
|
# Django Core
|
|
from django.conf import settings
|
|
from django.db import transaction
|
|
from django.shortcuts import render, get_object_or_404, redirect
|
|
from django.http import JsonResponse, HttpResponse
|
|
from django.urls import reverse, reverse_lazy
|
|
from django.utils import timezone
|
|
from django.utils.text import slugify
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.core.cache import cache
|
|
|
|
# Django Authentication
|
|
from django.contrib.auth import get_user_model, authenticate, login, logout
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
|
|
# Django ORM
|
|
from django.db.models import (
|
|
F,
|
|
Q,
|
|
Count,
|
|
Avg,
|
|
Sum,
|
|
Value,
|
|
CharField,
|
|
DurationField,
|
|
ExpressionWrapper,
|
|
IntegerField,
|
|
)
|
|
from django.utils.translation import get_language
|
|
from django.db.models import fields
|
|
from django.db.models.fields.json import KeyTextTransform, KeyTransform
|
|
from django.db.models.functions import Coalesce, Cast, TruncDate
|
|
|
|
# Django Views and Forms
|
|
from django.contrib import messages
|
|
from django.contrib.messages.views import SuccessMessageMixin
|
|
from django.forms import HiddenInput
|
|
from django.views.generic import (
|
|
ListView,
|
|
CreateView,
|
|
UpdateView,
|
|
DeleteView,
|
|
DetailView,
|
|
)
|
|
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
|
from django.views.decorators.http import require_http_methods, require_POST
|
|
|
|
# Pagination
|
|
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
|
|
|
|
# Third-party
|
|
from rest_framework import viewsets
|
|
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
|
|
from django_q.tasks import async_task
|
|
|
|
|
|
# Local Apps
|
|
from .decorators import (
|
|
agency_user_required,
|
|
candidate_user_required,
|
|
staff_user_required,
|
|
StaffRequiredMixin,
|
|
StaffOrAgencyRequiredMixin,
|
|
staff_or_candidate_required,
|
|
superuser_required,
|
|
staff_or_agency_required,
|
|
)
|
|
from .forms import (
|
|
StaffUserCreationForm,
|
|
ToggleAccountForm,
|
|
JobPostingStatusForm,
|
|
LinkedPostContentForm,
|
|
CandidateEmailForm,
|
|
ProfileImageUploadForm,
|
|
ApplicationForm,
|
|
PasswordResetForm,
|
|
StaffAssignmentForm,
|
|
RemoteInterviewForm,
|
|
OnsiteInterviewForm,
|
|
BulkInterviewTemplateForm,
|
|
SettingsForm,
|
|
InterviewCancelForm,
|
|
InterviewEmailForm,
|
|
ApplicationStageForm,
|
|
InterviewResultForm
|
|
)
|
|
from .utils import generate_random_password
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
from django.views.decorators.http import require_http_methods
|
|
from django.http import HttpResponse, JsonResponse
|
|
from datetime import datetime, time, timedelta
|
|
from django.views import View
|
|
from django.urls import reverse
|
|
from django.conf import settings
|
|
from django.utils import timezone
|
|
from django.db.models import FloatField, CharField, DurationField
|
|
from django.db.models import (
|
|
F,
|
|
IntegerField,
|
|
Count,
|
|
Avg,
|
|
Sum,
|
|
Q,
|
|
ExpressionWrapper,
|
|
fields,
|
|
Value,
|
|
)
|
|
from django.db.models.functions import Coalesce, Cast, Replace, NullIf
|
|
from django.db.models.functions import Cast, Coalesce, TruncDate
|
|
from django.db.models.fields.json import KeyTextTransform
|
|
from django.db.models.expressions import ExpressionWrapper
|
|
from django.urls import reverse_lazy
|
|
from django.db.models import Count, Avg, F, Q
|
|
from .forms import (
|
|
# ZoomMeetingForm,
|
|
ApplicationExamDateForm,
|
|
JobPostingForm,
|
|
JobPostingImageForm,
|
|
FormTemplateForm,
|
|
SourceForm,
|
|
HiringAgencyForm,
|
|
AgencyJobAssignmentForm,
|
|
AgencyAccessLinkForm,
|
|
AgencyApplicationSubmissionForm,
|
|
PortalLoginForm,
|
|
MessageForm,
|
|
PersonForm,
|
|
ScheduledInterviewForm,
|
|
)
|
|
from .models import (
|
|
FormTemplate,
|
|
FormStage,
|
|
FormField,
|
|
FieldResponse,
|
|
FormSubmission,
|
|
Application,
|
|
Person,
|
|
JobPosting,
|
|
ScheduledInterview,
|
|
JobPostingImage,
|
|
HiringAgency,
|
|
AgencyJobAssignment,
|
|
AgencyAccessLink,
|
|
Source,
|
|
Message,
|
|
Document,
|
|
Interview,
|
|
BulkInterviewTemplate,
|
|
Settings,
|
|
)
|
|
from .utils import (
|
|
get_applications_from_request,
|
|
get_available_time_slots,
|
|
)
|
|
from .zoom_api import (
|
|
delete_zoom_meeting,
|
|
)
|
|
from .linkedin_service import LinkedInService
|
|
from .serializers import JobPostingSerializer, ApplicationSerializer
|
|
|
|
logger = logging.getLogger(__name__)
|
|
User = get_user_model()
|
|
|
|
|
|
@login_required
|
|
@superuser_required
|
|
def settings(request):
|
|
return render(request, "user/settings.html")
|
|
|
|
|
|
class PersonListView(StaffRequiredMixin, ListView, LoginRequiredMixin):
|
|
model = Person
|
|
template_name = "people/person_list.html"
|
|
context_object_name = "people_list"
|
|
paginate_by=100
|
|
|
|
def get_queryset(self):
|
|
queryset = super().get_queryset().select_related("user")
|
|
search_query = self.request.GET.get("search", "")
|
|
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(first_name=search_query)
|
|
| Q(last_name__icontains=search_query)
|
|
| Q(email__icontains=search_query)
|
|
)
|
|
gender = self.request.GET.get("gender")
|
|
if gender:
|
|
queryset = queryset.filter(gender=gender)
|
|
|
|
nationality = self.request.GET.get("nationality")
|
|
if nationality:
|
|
queryset = queryset.filter(nationality=nationality)
|
|
|
|
return queryset
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
# We query the base model to ensure we list ALL options, not just those currently displayed.
|
|
nationalities = (
|
|
self.model.objects.values_list("nationality", flat=True)
|
|
.filter(nationality__isnull=False)
|
|
.distinct()
|
|
.order_by("nationality")
|
|
)
|
|
|
|
nationality = self.request.GET.get("nationality")
|
|
context["nationality"] = nationality
|
|
context["nationalities"] = nationalities
|
|
context["search_query"] = self.request.GET.get("search", "")
|
|
return context
|
|
|
|
|
|
class PersonCreateView(CreateView, LoginRequiredMixin, StaffOrAgencyRequiredMixin):
|
|
model = Person
|
|
template_name = "people/create_person.html"
|
|
form_class = PersonForm
|
|
success_url = reverse_lazy("person_list")
|
|
|
|
def form_valid(self, form):
|
|
instance = form.save()
|
|
view = self.request.POST.get("view")
|
|
if view == "portal":
|
|
slug = self.request.POST.get("agency")
|
|
if slug:
|
|
agency = HiringAgency.objects.get(slug=slug)
|
|
print(agency)
|
|
instance.agency = agency
|
|
instance.save()
|
|
|
|
# 2. Add the content to update (e.g., re-render the person list table)
|
|
# response.content = render_to_string('recruitment/persons_table.html',
|
|
return redirect("agency_portal_persons_list")
|
|
if view == "job":
|
|
return redirect("application_create")
|
|
return super().form_valid(form)
|
|
def form_invalid(self, form):
|
|
"""
|
|
Re-renders the form with error messages while maintaining the UI state.
|
|
"""
|
|
messages.error(self.request, "There was an error saving the applicant. Please check the details below.")
|
|
|
|
# Optional: Add specific field errors as messages
|
|
for field, errors in form.errors.items():
|
|
for error in errors:
|
|
messages.error(self.request, f"{field.title()}: {error}")
|
|
view = self.request.POST.get("view")
|
|
agency_slug = self.request.POST.get("agency")
|
|
|
|
|
|
context = self.get_context_data(form=form)
|
|
|
|
|
|
context['view_type'] = view
|
|
context['agency_slug'] = agency_slug
|
|
|
|
|
|
if view == "portal":
|
|
|
|
return redirect('agency_portal_dashboard')
|
|
|
|
|
|
return self.render_to_response(context)
|
|
|
|
|
|
class PersonDetailView(DetailView, LoginRequiredMixin, StaffRequiredMixin):
|
|
model = Person
|
|
template_name = "people/person_detail.html"
|
|
context_object_name = "person"
|
|
|
|
def get_context_data(self, **kwargs):
|
|
from .forms import PersonPasswordResetForm
|
|
context = super().get_context_data(**kwargs)
|
|
context['password_form'] = PersonPasswordResetForm()
|
|
return context
|
|
|
|
class PersonUpdateView(UpdateView, LoginRequiredMixin, StaffOrAgencyRequiredMixin):
|
|
model = Person
|
|
template_name = "people/update_person.html"
|
|
form_class = PersonForm
|
|
success_url = reverse_lazy("person_list")
|
|
|
|
def form_valid(self, form):
|
|
if self.request.POST.get("view") == "portal":
|
|
form.save()
|
|
return redirect("agency_portal_persons_list")
|
|
return super().form_valid(form)
|
|
|
|
|
|
class PersonDeleteView(StaffRequiredMixin, DeleteView, LoginRequiredMixin):
|
|
model = Person
|
|
template_name = "people/delete_person.html"
|
|
success_url = reverse_lazy("person_list")
|
|
|
|
|
|
class JobPostingViewSet(viewsets.ModelViewSet):
|
|
queryset = JobPosting.objects.all()
|
|
serializer_class = JobPostingSerializer
|
|
|
|
|
|
class CandidateViewSet(viewsets.ModelViewSet):
|
|
queryset = Application.objects.all()
|
|
serializer_class = ApplicationSerializer
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def create_job(request):
|
|
"""Create a new job posting"""
|
|
|
|
if request.method == "POST":
|
|
form = JobPostingForm(request.POST)
|
|
# to check user is authenticated or not
|
|
if form.is_valid():
|
|
try:
|
|
job = form.save(commit=False)
|
|
job.save()
|
|
job_apply_url_relative = reverse(
|
|
"job_application_detail", kwargs={"slug": job.slug}
|
|
)
|
|
job_apply_url_absolute = request.build_absolute_uri(
|
|
job_apply_url_relative
|
|
)
|
|
job.application_url = job_apply_url_absolute
|
|
# FormTemplate.objects.create(job=job, is_active=False, name=job.title,created_by=request.user)
|
|
job.save()
|
|
messages.success(request, f'Job "{job.title}" created successfully!')
|
|
return redirect("job_list")
|
|
except Exception as e:
|
|
logger.error(f"Error creating job: {e}")
|
|
messages.error(request, f"Error creating job: {e}")
|
|
else:
|
|
messages.error(request, f"Please correct the errors below.")
|
|
else:
|
|
form = JobPostingForm()
|
|
return render(request, "jobs/create_job.html", {"form": form})
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def edit_job(request, slug):
|
|
"""Edit an existing job posting"""
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
if request.method == "POST":
|
|
form = JobPostingForm(request.POST, instance=job)
|
|
if form.is_valid():
|
|
try:
|
|
form.save()
|
|
messages.success(
|
|
request,
|
|
_('Job "%(title)s" updated successfully!') % {"title": job.title},
|
|
)
|
|
return redirect("job_list")
|
|
except Exception as e:
|
|
logger.error(f"Error updating job: {e}")
|
|
messages.error(
|
|
request, _("Error updating job: %(error)s") % {"error": e}
|
|
)
|
|
else:
|
|
messages.error(request, _("Please correct the errors below."))
|
|
else:
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
form = JobPostingForm(instance=job)
|
|
return render(request, "jobs/edit_job.html", {"form": form, "job": job})
|
|
|
|
|
|
SCORE_PATH = "ai_analysis_data__analysis_data__match_score"
|
|
HIGH_POTENTIAL_THRESHOLD = 75
|
|
from django.contrib.sites.shortcuts import get_current_site
|
|
|
|
|
|
@staff_user_required
|
|
@login_required
|
|
def job_detail(request, slug):
|
|
"""View details of a specific job"""
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
# Get all applications for this job, ordered by most recent
|
|
applications = job.applications.all().order_by("-created_at")
|
|
|
|
# Count applications by stage for summary statistics - OPTIMIZED: Single aggregation query
|
|
stage_stats = job.applications.aggregate(
|
|
total_applications=Count("id"),
|
|
applied_count=Count("id", filter=Q(stage="Applied")),
|
|
exam_count=Count("id", filter=Q(stage="Exam")),
|
|
interview_count=Count("id", filter=Q(stage="Interview")),
|
|
offer_count=Count("id", filter=Q(stage="Offer")),
|
|
)
|
|
|
|
total_applications = stage_stats["total_applications"]
|
|
applied_count = stage_stats["applied_count"]
|
|
exam_count = stage_stats["exam_count"]
|
|
interview_count = stage_stats["interview_count"]
|
|
offer_count = stage_stats["offer_count"]
|
|
|
|
status_form = JobPostingStatusForm(instance=job)
|
|
linkedin_content_form = LinkedPostContentForm(instance=job)
|
|
try:
|
|
# If the related object exists, use its instance data
|
|
image_upload_form = JobPostingImageForm(instance=job.post_images)
|
|
except Exception as e:
|
|
# If the related object does NOT exist, create a blank form
|
|
image_upload_form = JobPostingImageForm()
|
|
|
|
# 2. Check for POST request (Status Update Submission)
|
|
if request.method == "POST":
|
|
status_form = JobPostingStatusForm(request.POST, instance=job)
|
|
|
|
if status_form.is_valid():
|
|
job_status = status_form.cleaned_data["status"]
|
|
form_template = job.form_template
|
|
if job_status == "ACTIVE":
|
|
form_template.is_active = True
|
|
form_template.save(update_fields=["is_active"])
|
|
else:
|
|
form_template.is_active = False
|
|
form_template.save(update_fields=["is_active"])
|
|
|
|
status_form.save()
|
|
|
|
# Add a success message
|
|
messages.success(
|
|
request,
|
|
f"Status for '{job.title}' updated to '{job.get_status_display()}' successfully!",
|
|
)
|
|
|
|
return redirect("job_detail", slug=slug)
|
|
else:
|
|
error_messages = status_form.errors.get("status", [])
|
|
formatted_errors = "<br>".join(error_messages)
|
|
messages.error(request, f"{formatted_errors}")
|
|
|
|
# --- 2. Quality Metrics (JSON Aggregation) ---
|
|
|
|
# OPTIMIZED: Combine JSON field operations into single efficient query
|
|
score_expression = Cast(
|
|
Coalesce(
|
|
KeyTextTransform("match_score", "ai_analysis_data__analysis_data_en"),
|
|
Value("0"),
|
|
),
|
|
output_field=IntegerField(),
|
|
)
|
|
|
|
# Single query for all score-related statistics
|
|
applications_with_score = applications.filter(is_resume_parsed=True).annotate(
|
|
annotated_match_score=score_expression
|
|
)
|
|
|
|
score_stats = applications_with_score.aggregate(
|
|
total_applications=Count("id"),
|
|
avg_score=Avg("annotated_match_score"),
|
|
high_potential_count=Count(
|
|
"id", filter=Q(annotated_match_score__gte=HIGH_POTENTIAL_THRESHOLD)
|
|
),
|
|
)
|
|
|
|
total_applications_ = score_stats["total_applications"]
|
|
avg_match_score = score_stats["avg_score"] or 0.0
|
|
high_potential_count = score_stats["high_potential_count"]
|
|
|
|
# --- 3. Time Metrics (Duration Aggregation) ---
|
|
|
|
# Metric: Average Time from Applied to Interview (T2I)
|
|
t2i_applications = applications.filter(interview_date__isnull=False).annotate(
|
|
time_to_interview=ExpressionWrapper(
|
|
F("interview_date") - F("created_at"), output_field=DurationField()
|
|
)
|
|
)
|
|
avg_t2i_duration = t2i_applications.aggregate(avg_t2i=Avg("time_to_interview"))[
|
|
"avg_t2i"
|
|
]
|
|
|
|
# Convert timedelta to days
|
|
avg_t2i_days = (
|
|
round(avg_t2i_duration.total_seconds() / (60 * 60 * 24), 1)
|
|
if avg_t2i_duration
|
|
else 0
|
|
)
|
|
|
|
# Metric: Average Time in Exam Stage
|
|
t_in_exam_applications = applications.filter(
|
|
exam_date__isnull=False, interview_date__isnull=False
|
|
).annotate(
|
|
time_in_exam=ExpressionWrapper(
|
|
F("interview_date") - F("exam_date"), output_field=DurationField()
|
|
)
|
|
)
|
|
avg_t_in_exam_duration = t_in_exam_applications.aggregate(
|
|
avg_t_in_exam=Avg("time_in_exam")
|
|
)["avg_t_in_exam"]
|
|
|
|
# Convert timedelta to days
|
|
avg_t_in_exam_days = (
|
|
round(avg_t_in_exam_duration.total_seconds() / (60 * 60 * 24), 1)
|
|
if avg_t_in_exam_duration
|
|
else 0
|
|
)
|
|
|
|
# OPTIMIZED: Simplified JSON field query for category data
|
|
category_data = (
|
|
applications.filter(ai_analysis_data__analysis_data_en__category__isnull=False)
|
|
.exclude(ai_analysis_data__analysis_data_en__category__exact=None)
|
|
.values("ai_analysis_data__analysis_data_en__category")
|
|
.annotate(
|
|
application_count=Count("id"),
|
|
category=Cast(
|
|
"ai_analysis_data__analysis_data_en__category", output_field=CharField()
|
|
),
|
|
)
|
|
.order_by("category") # Use annotated field instead of JSON path
|
|
)
|
|
|
|
# Prepare data for Chart.js
|
|
categories = [item["category"] for item in category_data]
|
|
applications_count = [item["application_count"] for item in category_data]
|
|
# avg_scores = [round(item['avg_match_score'], 2) if item['avg_match_score'] is not None else 0 for item in category_data]
|
|
|
|
context = {
|
|
"job": job,
|
|
"applications": applications,
|
|
"total_applications": total_applications, # This was total_candidates in the prompt, using total_applicant for consistency
|
|
"applied_count": applied_count,
|
|
"exam_count": exam_count,
|
|
"interview_count": interview_count,
|
|
"offer_count": offer_count,
|
|
"status_form": status_form,
|
|
"image_upload_form": image_upload_form,
|
|
"categories": categories,
|
|
"applications_count": applications_count,
|
|
# 'avg_scores': avg_scores,
|
|
# New statistics
|
|
"avg_match_score": avg_match_score,
|
|
"high_potential_count": high_potential_count,
|
|
# "high_potential_ratio": high_potential_ratio,
|
|
"avg_t2i_days": avg_t2i_days,
|
|
"avg_t_in_exam_days": avg_t_in_exam_days,
|
|
"linkedin_content_form": linkedin_content_form,
|
|
"staff_form": StaffAssignmentForm(),
|
|
}
|
|
return render(request, "jobs/job_detail.html", context)
|
|
|
|
|
|
def request_cvs_download(request, slug):
|
|
"""
|
|
View to initiate the background task.
|
|
"""
|
|
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
if job.status != "CLOSED":
|
|
messages.info(
|
|
"request",
|
|
_(
|
|
"You can request bulk CV dowload only if the job status is changed to CLOSED"
|
|
),
|
|
)
|
|
return redirect("job_detail", kwargs={slug: job.slug})
|
|
job.zip_created = False
|
|
job.save(update_fields=["zip_created"])
|
|
# Use async_task to run the function in the background
|
|
# Pass only simple arguments (like the job ID)
|
|
if not job.applications.exists():
|
|
messages.warning(
|
|
request,
|
|
_("No applications found for this job. ZIP file generation skipped."),
|
|
)
|
|
return redirect("job_detail", slug=slug)
|
|
|
|
async_task("recruitment.tasks.generate_and_save_cv_zip", job.id)
|
|
|
|
# Provide user feedback and redirect
|
|
messages.info(
|
|
request,
|
|
"The CV compilation has started in the background. It may take a few moments. Refresh this page to check status.",
|
|
)
|
|
return redirect("job_detail", slug=slug) # Redirect back to the job detail page
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def download_ready_cvs(request, slug):
|
|
"""
|
|
View to serve the file once it is ready.
|
|
"""
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
if job.status != "CLOSED":
|
|
messages.info(
|
|
"request",
|
|
_(
|
|
"You can request bulk CV dowload only if the job status is changed to CLOSED"
|
|
),
|
|
)
|
|
return redirect("job_detail", kwargs={slug: job.slug})
|
|
|
|
if not job.applications.exists():
|
|
messages.warning(
|
|
request,
|
|
_("No applications found for this job. ZIP file download unavailable."),
|
|
)
|
|
return redirect("job_detail", slug=slug)
|
|
|
|
if job.cv_zip_file and job.zip_created:
|
|
# Django FileField handles the HttpResponse and file serving easily
|
|
response = HttpResponse(job.cv_zip_file.read(), content_type="application/zip")
|
|
response["Content-Disposition"] = (
|
|
f'attachment; filename="{job.cv_zip_file.name.split("/")[-1]}"'
|
|
)
|
|
return response
|
|
else:
|
|
# File is not ready or doesn't exist
|
|
messages.warning(
|
|
request, "The ZIP file is still being generated or an error occurred."
|
|
)
|
|
return redirect("job_detail", slug=slug)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def job_image_upload(request, slug):
|
|
# only for handling the post request
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
try:
|
|
instance = JobPostingImage.objects.get(job=job)
|
|
except JobPostingImage.DoesNotExist:
|
|
# If it doesn't exist, create a new instance placeholder
|
|
instance = None
|
|
|
|
if request.method == "POST":
|
|
# Pass the existing instance to the form if it exists
|
|
image_upload_form = JobPostingImageForm(
|
|
request.POST, request.FILES, instance=instance
|
|
)
|
|
|
|
if image_upload_form.is_valid():
|
|
# If creating a new one (instance is None), set the job link manually
|
|
if instance is None:
|
|
image_instance = image_upload_form.save(commit=False)
|
|
image_instance.job = job
|
|
image_instance.save()
|
|
messages.success(
|
|
request, f"Image uploaded successfully for {job.title}."
|
|
)
|
|
else:
|
|
# If updating, the form will update the instance passed to it
|
|
image_upload_form.save()
|
|
messages.success(
|
|
request, f"Image updated successfully for {job.title}."
|
|
)
|
|
|
|
else:
|
|
messages.error(
|
|
request,
|
|
"Image upload failed: Please ensure a valid image file was selected.",
|
|
)
|
|
return redirect("job_detail", slug=job.slug)
|
|
return redirect("job_detail", slug=job.slug)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def edit_linkedin_post_content(request, slug):
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
linkedin_content_form = LinkedPostContentForm(instance=job)
|
|
if request.method == "POST":
|
|
linkedin_content_form = LinkedPostContentForm(request.POST, instance=job)
|
|
if linkedin_content_form.is_valid():
|
|
linkedin_content_form.save()
|
|
messages.success(request, "Linked post content updated successfully!")
|
|
return redirect("job_detail", job.slug)
|
|
else:
|
|
messages.error(request, "Error update the Linkedin Post content")
|
|
return redirect("job_detail", job.slug)
|
|
|
|
else:
|
|
linkedin_content_form = LinkedPostContentForm()
|
|
return redirect("job_detail", job.slug)
|
|
|
|
|
|
JOB_TYPES = [
|
|
("FULL_TIME", "Full-time"),
|
|
("PART_TIME", "Part-time"),
|
|
("CONTRACT", "Contract"),
|
|
("INTERNSHIP", "Internship"),
|
|
("FACULTY", "Faculty"),
|
|
("TEMPORARY", "Temporary"),
|
|
]
|
|
|
|
WORKPLACE_TYPES = [
|
|
("ON_SITE", "On-site"),
|
|
("REMOTE", "Remote"),
|
|
("HYBRID", "Hybrid"),
|
|
]
|
|
|
|
|
|
def kaauh_career(request):
|
|
active_jobs = JobPosting.objects.select_related("form_template").filter(
|
|
status="ACTIVE", form_template__is_active=True
|
|
)
|
|
selected_department = request.GET.get("department", "")
|
|
department_type_keys = (
|
|
active_jobs.exclude(department__isnull=True)
|
|
.exclude(department__exact="")
|
|
.values_list("department", flat=True)
|
|
.distinct()
|
|
.order_by("department")
|
|
)
|
|
|
|
if selected_department and selected_department in department_type_keys:
|
|
active_jobs = active_jobs.filter(department=selected_department)
|
|
selected_workplace_type = request.GET.get("workplace_type", "")
|
|
|
|
selected_job_type = request.GET.get("employment_type", "")
|
|
|
|
job_type_keys = (
|
|
active_jobs.order_by("job_type")
|
|
.distinct("job_type")
|
|
.values_list("job_type", flat=True)
|
|
)
|
|
|
|
workplace_type_keys = (
|
|
active_jobs.order_by("workplace_type")
|
|
.distinct("workplace_type")
|
|
.values_list("workplace_type", flat=True)
|
|
.distinct()
|
|
)
|
|
|
|
if selected_job_type and selected_job_type in job_type_keys:
|
|
active_jobs = active_jobs.filter(job_type=selected_job_type)
|
|
if selected_workplace_type and selected_workplace_type in workplace_type_keys:
|
|
active_jobs = active_jobs.filter(workplace_type=selected_workplace_type)
|
|
|
|
JOBS_PER_PAGE = 10
|
|
paginator = Paginator(active_jobs, JOBS_PER_PAGE)
|
|
page_number = request.GET.get("page", 1)
|
|
|
|
try:
|
|
page_obj = paginator.get_page(page_number)
|
|
except EmptyPage:
|
|
page_obj = paginator.page(paginator.num_pages)
|
|
|
|
total_open_roles = active_jobs.all().count()
|
|
|
|
return render(
|
|
request,
|
|
"applicant/career.html",
|
|
{
|
|
"active_jobs": page_obj.object_list,
|
|
"job_type_keys": job_type_keys,
|
|
"selected_job_type": selected_job_type,
|
|
"workplace_type_keys": workplace_type_keys,
|
|
"selected_workplace_type": selected_workplace_type,
|
|
"selected_department": selected_department,
|
|
"department_type_keys": department_type_keys,
|
|
"total_open_roles": total_open_roles,
|
|
"page_obj": page_obj,
|
|
},
|
|
)
|
|
|
|
|
|
# job detail facing the candidate:
|
|
def job_application_detail(request, slug):
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
return render(request, "applicant/job_application_detail.html", {"job": job})
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def post_to_linkedin(request, slug):
|
|
"""Post a job to LinkedIn"""
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
if job.status != "ACTIVE":
|
|
messages.info(request, "Only active jobs can be posted to LinkedIn.")
|
|
return redirect("job_list")
|
|
|
|
if request.method == "POST":
|
|
linkedin_access_token = request.session.get("linkedin_access_token")
|
|
# Check if user is authenticated with LinkedIn
|
|
if not "linkedin_access_token":
|
|
messages.error(request, "Please authenticate with LinkedIn first.")
|
|
return redirect("linkedin_login")
|
|
try:
|
|
# Clear previous LinkedIn data for re-posting
|
|
# Prepare the job object for background processing
|
|
job.posted_to_linkedin = False
|
|
job.linkedin_post_id = ""
|
|
job.linkedin_post_url = ""
|
|
job.linkedin_post_status = "QUEUED"
|
|
job.linkedin_posted_at = None
|
|
job.save()
|
|
|
|
# ENQUEUE THE TASK
|
|
# Pass the function path, the job slug, and the token as arguments
|
|
|
|
async_task(
|
|
"recruitment.tasks.linkedin_post_task", job.slug, linkedin_access_token
|
|
)
|
|
|
|
messages.success(
|
|
request,
|
|
_(
|
|
f"✅ Job posting process for job with JOB ID: {job.internal_job_id} started! Check the job details page in a moment for the final status."
|
|
),
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error enqueuing LinkedIn post: {e}")
|
|
messages.error(
|
|
request, _("Failed to start the job posting process. Please try again.")
|
|
)
|
|
|
|
return redirect("job_detail", slug=job.slug)
|
|
|
|
|
|
def linkedin_login(request):
|
|
"""Redirect to LinkedIn OAuth"""
|
|
service = LinkedInService()
|
|
auth_url = service.get_auth_url()
|
|
"""
|
|
It creates a special URL that:
|
|
Sends the user to LinkedIn to log in
|
|
Asks the user to grant your app permission to post on their behalf
|
|
Tells LinkedIn where to send the user back after they approve (your redirect_uri)
|
|
http://yoursite.com/linkedin/callback/?code=TEMPORARY_CODE_HERE
|
|
"""
|
|
return redirect(auth_url)
|
|
|
|
|
|
def linkedin_callback(request):
|
|
"""Handle LinkedIn OAuth callback"""
|
|
code = request.GET.get("code")
|
|
if not code:
|
|
messages.error(request, "No authorization code received from LinkedIn.")
|
|
return redirect("job_list")
|
|
|
|
try:
|
|
service = LinkedInService()
|
|
# get_access_token(code)->It makes a POST request to LinkedIn's token endpoint with parameters
|
|
access_token = service.get_access_token(code)
|
|
request.session["linkedin_access_token"] = access_token
|
|
request.session["linkedin_authenticated"] = True
|
|
settings.LINKEDIN_IS_CONNECTED = True
|
|
messages.success(request, "Successfully authenticated with LinkedIn!")
|
|
except Exception as e:
|
|
logger.error(f"LinkedIn authentication error: {e}")
|
|
messages.error(request, f"LinkedIn authentication failed: {e}")
|
|
|
|
return redirect("job_list")
|
|
|
|
|
|
# applicant views
|
|
# def applicant_job_detail(request, slug):
|
|
# """View job details for applicants"""
|
|
# job = get_object_or_404(JobPosting, slug=slug, status="ACTIVE")
|
|
# return render(request, "jobs/applicant_job_detail.html", {"job": job})
|
|
|
|
|
|
@login_required
|
|
@candidate_user_required
|
|
def application_success(request, slug):
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
return render(request, "jobs/application_success.html", {"job": job})
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
@login_required
|
|
@staff_user_required
|
|
def form_builder(request, template_slug=None):
|
|
"""Render the form builder interface"""
|
|
context = {}
|
|
if template_slug:
|
|
template = get_object_or_404(FormTemplate, slug=template_slug)
|
|
job_slug=template.job.slug
|
|
context['job_slug']=job_slug
|
|
context["template"] = template
|
|
context["template_slug"] = template.slug
|
|
context["template_name"] = template.name
|
|
return render(request, "forms/form_builder.html", context)
|
|
|
|
|
|
@csrf_exempt
|
|
@require_http_methods(["POST"])
|
|
@login_required
|
|
@staff_user_required
|
|
# def save_form_template(request):
|
|
# """Save a new or existing form template"""
|
|
# try:
|
|
# data = json.loads(request.body)
|
|
# template_name = data.get("name", "Untitled Form")
|
|
# stages_data = data.get("stages", [])
|
|
# template_slug = data.get("template_slug")
|
|
|
|
# if template_slug:
|
|
# # Update existing template
|
|
# template = get_object_or_404(FormTemplate, slug=template_slug)
|
|
# template.name = template_name
|
|
# template.save()
|
|
# # Clear existing stages and fields
|
|
# template.stages.all().delete()
|
|
# else:
|
|
# # Create new template
|
|
# template = FormTemplate.objects.create(name=template_name)
|
|
|
|
# # Create stages and fields
|
|
# for stage_order, stage_data in enumerate(stages_data):
|
|
# stage = FormStage.objects.create(
|
|
# template=template,
|
|
# name=stage_data["name"],
|
|
# order=stage_order,
|
|
# is_predefined=stage_data.get("predefined", False),
|
|
# )
|
|
|
|
# for field_order, field_data in enumerate(stage_data["fields"]):
|
|
# options = field_data.get("options", [])
|
|
# if not isinstance(options, list):
|
|
# options = []
|
|
|
|
# file_types = field_data.get("fileTypes", "")
|
|
# max_file_size = field_data.get("maxFileSize", 5)
|
|
|
|
# FormField.objects.create(
|
|
# stage=stage,
|
|
# label=field_data.get("label", ""),
|
|
# field_type=field_data.get("type", "text"),
|
|
# placeholder=field_data.get("placeholder", ""),
|
|
# required=field_data.get("required", False),
|
|
# order=field_order,
|
|
# is_predefined=field_data.get("predefined", False),
|
|
# options=options,
|
|
# file_types=file_types,
|
|
# max_file_size=max_file_size,
|
|
# )
|
|
|
|
# return JsonResponse(
|
|
# {
|
|
# "success": True,
|
|
# "template_slug": template.slug,
|
|
# "message": "Form template saved successfully!",
|
|
# }
|
|
# )
|
|
# except Exception as e:
|
|
# return JsonResponse({"success": False, "error": str(e)}, status=400)
|
|
def save_form_template(request):
|
|
"""Save a new or existing form template"""
|
|
try:
|
|
data = json.loads(request.body)
|
|
template_name = data.get("name", "Untitled Form")
|
|
template_description = data.get("description", "")
|
|
template_is_active = data.get("is_active", False)
|
|
stages_data = data.get("stages", [])
|
|
template_slug = data.get("template_slug")
|
|
job_id = data.get("job")
|
|
|
|
if template_slug:
|
|
# Update existing template
|
|
template = get_object_or_404(FormTemplate, slug=template_slug)
|
|
template.name = template_name
|
|
template.description = template_description
|
|
template.is_active = template_is_active
|
|
if job_id:
|
|
template.job_id = job_id
|
|
template.save()
|
|
# Clear existing stages and fields
|
|
template.stages.all().delete()
|
|
else:
|
|
# Create new template
|
|
template = FormTemplate.objects.create(
|
|
name=template_name,
|
|
description=template_description,
|
|
is_active=template_is_active,
|
|
job_id=job_id if job_id else None,
|
|
created_by=request.user if request.user.is_authenticated else None,
|
|
)
|
|
|
|
# Create stages and fields
|
|
for stage_order, stage_data in enumerate(stages_data):
|
|
stage = FormStage.objects.create(
|
|
template=template,
|
|
name=stage_data["name"],
|
|
order=stage_order,
|
|
is_predefined=stage_data.get("predefined", False),
|
|
)
|
|
|
|
for field_order, field_data in enumerate(stage_data["fields"]):
|
|
# Get options
|
|
options = field_data.get("options", [])
|
|
if not isinstance(options, list):
|
|
options = []
|
|
|
|
# Get file settings
|
|
file_types = field_data.get("fileTypes", "")
|
|
max_file_size = field_data.get("maxFileSize", 5)
|
|
multiple_files = field_data.get("multipleFiles", False)
|
|
max_files = field_data.get("maxFiles", 1)
|
|
|
|
# Get validation data
|
|
is_required = field_data.get("required", False)
|
|
required_message = field_data.get("required_message", "")
|
|
min_length = field_data.get("min_length")
|
|
max_length = field_data.get("max_length")
|
|
validation_pattern = field_data.get("validation_pattern", "")
|
|
custom_pattern = field_data.get("custom_pattern", "")
|
|
min_value = field_data.get("min_value", "")
|
|
max_value = field_data.get("max_value", "")
|
|
min_file_size = field_data.get("min_file_size")
|
|
min_image_width = field_data.get("min_image_width")
|
|
min_image_height = field_data.get("min_image_height")
|
|
|
|
# Handle validation_pattern if sent in validation object
|
|
validation_obj = field_data.get("validation", {})
|
|
if validation_obj:
|
|
# If pattern exists in validation object, use it
|
|
if "pattern" in validation_obj:
|
|
pattern_value = validation_obj["pattern"]
|
|
# Determine pattern type
|
|
if pattern_value in [
|
|
"email",
|
|
"phone",
|
|
"url",
|
|
"number",
|
|
"alpha",
|
|
"alphanum",
|
|
]:
|
|
validation_pattern = pattern_value
|
|
elif pattern_value:
|
|
# Custom pattern
|
|
validation_pattern = "custom"
|
|
custom_pattern = pattern_value
|
|
|
|
# Get other validation fields from validation object
|
|
required_message = validation_obj.get(
|
|
"errorMessage", required_message
|
|
)
|
|
min_length = validation_obj.get("minLength", min_length)
|
|
max_length = validation_obj.get("maxLength", max_length)
|
|
min_value = validation_obj.get("minValue", min_value)
|
|
max_value = validation_obj.get("maxValue", max_value)
|
|
|
|
# Get specific validation for dates
|
|
min_date = validation_obj.get("minDate")
|
|
max_date = validation_obj.get("maxDate")
|
|
if min_date and field_data.get("type") == "date":
|
|
min_value = min_date
|
|
if max_date and field_data.get("type") == "date":
|
|
max_value = max_date
|
|
|
|
# Create the field with all validation data
|
|
FormField.objects.create(
|
|
stage=stage,
|
|
label=field_data.get("label", ""),
|
|
field_type=field_data.get("type", "text"),
|
|
placeholder=field_data.get("placeholder", ""),
|
|
required=is_required,
|
|
order=field_order,
|
|
is_predefined=field_data.get("predefined", False),
|
|
options=options,
|
|
file_types=file_types,
|
|
max_file_size=max_file_size,
|
|
multiple_files=multiple_files,
|
|
max_files=max_files,
|
|
# Validation fields
|
|
is_required=is_required,
|
|
required_message=required_message,
|
|
min_length=min_length if min_length is not None else None,
|
|
max_length=max_length if max_length is not None else None,
|
|
validation_pattern=validation_pattern,
|
|
custom_pattern=custom_pattern,
|
|
min_value=min_value,
|
|
max_value=max_value,
|
|
min_file_size=min_file_size,
|
|
min_image_width=min_image_width,
|
|
min_image_height=min_image_height,
|
|
)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"success": True,
|
|
"template_slug": template.slug,
|
|
"message": "Form template saved successfully!",
|
|
}
|
|
)
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
return JsonResponse({"success": False, "error": str(e)}, status=400)
|
|
|
|
|
|
# @require_http_methods(["GET"])
|
|
# @login_required
|
|
# def load_form_template(request, template_slug):
|
|
# """Load an existing form template"""
|
|
# print(template_slug)
|
|
# template = get_object_or_404(FormTemplate, slug=template_slug)
|
|
|
|
# stages = []
|
|
# for stage in template.stages.all():
|
|
# fields = []
|
|
# for field in stage.fields.all():
|
|
# fields.append(
|
|
# {
|
|
# "id": field.id,
|
|
# "type": field.field_type,
|
|
# "label": field.label,
|
|
# "placeholder": field.placeholder,
|
|
# "required": field.required,
|
|
# "options": field.options,
|
|
# "fileTypes": field.file_types,
|
|
# "maxFileSize": field.max_file_size,
|
|
# "predefined": field.is_predefined,
|
|
# }
|
|
# )
|
|
# stages.append(
|
|
# {
|
|
# "id": stage.id,
|
|
# "name": stage.name,
|
|
# "predefined": stage.is_predefined,
|
|
# "fields": fields,
|
|
# }
|
|
# )
|
|
|
|
# return JsonResponse(
|
|
# {
|
|
# "success": True,
|
|
# "template": {
|
|
# "id": template.id,
|
|
# "template_slug": template.slug,
|
|
# "name": template.name,
|
|
# "description": template.description,
|
|
# "is_active": template.is_active,
|
|
# "job": template.job_id if template.job else None,
|
|
# "stages": stages,
|
|
# },
|
|
# }
|
|
# )
|
|
|
|
|
|
def load_form_template(request, slug):
|
|
"""Load an existing form template"""
|
|
try:
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
template = job.form_template
|
|
|
|
# Get stages with fields
|
|
stages = []
|
|
for stage in template.stages.all().order_by("order"):
|
|
stage_data = {
|
|
"id": stage.id,
|
|
"name": stage.name,
|
|
"order": stage.order,
|
|
"is_predefined": stage.is_predefined,
|
|
"fields": [],
|
|
}
|
|
|
|
for field in stage.fields.all().order_by("order"):
|
|
field_data = {
|
|
"id": field.id,
|
|
"type": field.field_type,
|
|
"label": field.label,
|
|
"placeholder": field.placeholder,
|
|
"required": field.required,
|
|
"order": field.order,
|
|
"is_predefined": field.is_predefined,
|
|
"options": field.options if field.options else [],
|
|
"file_types": field.file_types,
|
|
"max_file_size": field.max_file_size,
|
|
"multiple_files": field.multiple_files,
|
|
"max_files": field.max_files,
|
|
# Validation fields
|
|
"min_length": field.min_length,
|
|
"max_length": field.max_length,
|
|
"validation_pattern": field.validation_pattern,
|
|
"custom_pattern": field.custom_pattern,
|
|
"min_value": field.min_value,
|
|
"max_value": field.max_value,
|
|
"min_file_size": field.min_file_size,
|
|
"min_image_width": field.min_image_width,
|
|
"min_image_height": field.min_image_height,
|
|
"required_message": field.required_message,
|
|
}
|
|
stage_data["fields"].append(field_data)
|
|
|
|
stages.append(stage_data)
|
|
|
|
template_data = {
|
|
"id": template.id,
|
|
"template_slug": template.slug,
|
|
"name": template.name,
|
|
"description": template.description,
|
|
"is_active": template.is_active,
|
|
"stages": stages,
|
|
}
|
|
|
|
return JsonResponse({"success": True, "template": template_data})
|
|
except Exception as e:
|
|
return JsonResponse({"success": False, "error": str(e)}, status=400)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def form_templates_list(request):
|
|
"""List all form templates for the current user"""
|
|
query = request.GET.get("q", "")
|
|
templates = FormTemplate.objects.filter()
|
|
|
|
if query:
|
|
templates = templates.filter(
|
|
Q(name__icontains=query) | Q(description__icontains=query)
|
|
)
|
|
|
|
templates = templates.order_by("-created_at")
|
|
paginator = Paginator(templates, 10) # Show 10 templates per page
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
form = FormTemplateForm()
|
|
form.fields["job"].queryset = JobPosting.objects.filter(form_template__isnull=True)
|
|
context = {"templates": page_obj, "query": query, "form": form}
|
|
return render(request, "forms/form_templates_list.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def create_form_template(request):
|
|
"""Create a new form template"""
|
|
if request.method == "POST":
|
|
form = FormTemplateForm(request.POST)
|
|
if form.is_valid():
|
|
template = form.save(commit=False)
|
|
template.created_by = request.user
|
|
template.save()
|
|
messages.success(
|
|
request, f'Form template "{template.name}" created successfully!'
|
|
)
|
|
return redirect("form_templates_list")
|
|
else:
|
|
form = FormTemplateForm()
|
|
|
|
return render(request, "forms/create_form_template.html", {"form": form})
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
@require_http_methods(["GET"])
|
|
def list_form_templates(request):
|
|
"""List all form templates for the current user"""
|
|
templates = FormTemplate.objects.filter().values(
|
|
"id", "name", "description", "created_at", "updated_at"
|
|
)
|
|
return JsonResponse({"success": True, "templates": list(templates)})
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def form_submission_details(request, template_id, slug):
|
|
"""Display detailed view of a specific form submission"""
|
|
# Get form template and verify ownership
|
|
template = get_object_or_404(FormTemplate, id=template_id)
|
|
# Get the specific submission
|
|
submission = get_object_or_404(FormSubmission, slug=slug, template=template)
|
|
|
|
# Get all stages with their fields
|
|
stages = template.stages.prefetch_related("fields").order_by("order")
|
|
|
|
# Get all responses for this submission, ordered by field order
|
|
responses = submission.responses.select_related("field").order_by("field__order")
|
|
|
|
# Group responses by stage
|
|
stage_responses = {}
|
|
for stage in stages:
|
|
stage_responses[stage.id] = {
|
|
"stage": stage,
|
|
"responses": responses.filter(field__stage=stage),
|
|
}
|
|
|
|
return render(
|
|
request,
|
|
"forms/form_submission_details.html",
|
|
{
|
|
"template": template,
|
|
"submission": submission,
|
|
"stages": stages,
|
|
"responses": responses,
|
|
"stage_responses": stage_responses,
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
@require_http_methods(["DELETE"])
|
|
def delete_form_template(request, template_id):
|
|
"""Delete a form template"""
|
|
template = get_object_or_404(FormTemplate, id=template_id)
|
|
template.delete()
|
|
return JsonResponse(
|
|
{"success": True, "message": "Form template deleted successfully!"}
|
|
)
|
|
|
|
|
|
# @login_required
|
|
# @staff_or_candidate_required
|
|
def application_submit_form(request, slug):
|
|
"""Display the form as a step-by-step wizard"""
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
|
|
if not request.user.is_authenticated:
|
|
return redirect("application_signup", slug=slug)
|
|
|
|
if request.user.user_type == "candidate":
|
|
person = request.user.person_profile
|
|
if job.has_already_applied_to_this_job(person):
|
|
messages.error(
|
|
request,
|
|
_(
|
|
"You have already applied to this job: Multiple applications are not allowed."
|
|
),
|
|
)
|
|
return redirect("job_application_detail", slug=slug)
|
|
|
|
if job.is_application_limit_reached:
|
|
messages.error(
|
|
request,
|
|
_(
|
|
"Application limit reached: This job is no longer accepting new applications."
|
|
),
|
|
)
|
|
return redirect("job_application_detail", slug=slug)
|
|
if job.is_expired:
|
|
messages.error(
|
|
request,
|
|
_(
|
|
"Application deadline passed: This job is no longer accepting new applications."
|
|
),
|
|
)
|
|
return redirect("job_application_detail", slug=slug)
|
|
|
|
return render(
|
|
request,
|
|
"applicant/application_submit_form.html",
|
|
{"template_slug": job.form_template.slug,"job_slug": job.slug, "job_id": job.internal_job_id},
|
|
)
|
|
|
|
|
|
@csrf_exempt
|
|
@require_POST
|
|
@login_required
|
|
@candidate_user_required
|
|
def application_submit(request, slug):
|
|
"""Handle form submission"""
|
|
# if not request.user.is_authenticated or request.user.user_type != "candidate":
|
|
# return JsonResponse({"success": False, "message": "Unauthorized access."})
|
|
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
template = job.form_template
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
with transaction.atomic():
|
|
current_count = job.applications.count()
|
|
if current_count >= job.max_applications:
|
|
template.is_active = False
|
|
template.save()
|
|
return JsonResponse(
|
|
{
|
|
"success": False,
|
|
"message": "Application limit reached for this job.",
|
|
}
|
|
)
|
|
submission = FormSubmission.objects.create(
|
|
template=template, submitted_by=request.user
|
|
)
|
|
|
|
# Process field responses
|
|
for field_id, value in request.POST.items():
|
|
if field_id.startswith("field_"):
|
|
actual_field_id = field_id.replace("field_", "")
|
|
try:
|
|
field = FormField.objects.get(
|
|
id=actual_field_id, stage__template=template
|
|
)
|
|
FieldResponse.objects.create(
|
|
submission=submission,
|
|
field=field,
|
|
value=value if value else None,
|
|
)
|
|
except FormField.DoesNotExist:
|
|
continue
|
|
|
|
# Handle file uploads
|
|
for field_id, uploaded_file in request.FILES.items():
|
|
if field_id.startswith("field_"):
|
|
actual_field_id = field_id.replace("field_", "")
|
|
try:
|
|
field = FormField.objects.get(
|
|
id=actual_field_id, stage__template=template
|
|
)
|
|
FieldResponse.objects.create(
|
|
submission=submission,
|
|
field=field,
|
|
uploaded_file=uploaded_file,
|
|
)
|
|
except FormField.DoesNotExist:
|
|
continue
|
|
try:
|
|
# gpa = submission.responses.get(field__label="GPA")
|
|
# if gpa and gpa.value:
|
|
# gpa_str = gpa.value.replace("/", "").strip()
|
|
|
|
# if not re.match(r"^\d+(\.\d+)?$", gpa_str):
|
|
# # --- FIX APPLIED HERE ---
|
|
# return JsonResponse(
|
|
# {
|
|
# "success": False,
|
|
# "message": _("GPA must be a numeric value."),
|
|
# }
|
|
# )
|
|
|
|
# try:
|
|
# gpa_float = float(gpa_str)
|
|
# except ValueError:
|
|
# # --- FIX APPLIED HERE ---
|
|
# return JsonResponse(
|
|
# {
|
|
# "success": False,
|
|
# "message": _("GPA must be a numeric value."),
|
|
# }
|
|
# )
|
|
|
|
# if not (0.0 <= gpa_float <= 4.0):
|
|
# # --- FIX APPLIED HERE ---
|
|
# return JsonResponse(
|
|
# {
|
|
# "success": False,
|
|
# "message": _("GPA must be between 0.0 and 4.0."),
|
|
# }
|
|
# )
|
|
|
|
resume = submission.responses.get(field__label="Resume Upload")
|
|
|
|
submission.applicant_name = (
|
|
f"{request.user.first_name} {request.user.last_name}"
|
|
)
|
|
submission.applicant_email = request.user.email
|
|
submission.save()
|
|
# time=timezone.now()
|
|
person = request.user.person_profile
|
|
# person.gpa = gpa.value if gpa else None
|
|
person.save()
|
|
Application.objects.create(
|
|
person=person,
|
|
resume=resume.get_file if resume.is_file else None,
|
|
job=job,
|
|
)
|
|
return JsonResponse(
|
|
{
|
|
"success": True,
|
|
"message": "Form submitted successfully!",
|
|
"redirect_url": reverse(
|
|
"application_success", kwargs={"slug": job.slug}
|
|
),
|
|
}
|
|
)
|
|
# return redirect('application_success',slug=job.slug)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Application creation failed,{e}")
|
|
pass
|
|
return JsonResponse(
|
|
{
|
|
"success": True,
|
|
"message": "Form submitted successfully!",
|
|
"submission_id": submission.id,
|
|
}
|
|
)
|
|
except Exception as e:
|
|
return JsonResponse({"success": False, "error": str(e)}, status=400)
|
|
else:
|
|
# Handle GET request - this should not happen for form submission
|
|
return JsonResponse(
|
|
{"success": False, "error": "GET method not allowed for form submission"},
|
|
status=405,
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def form_template_submissions_list(request, slug):
|
|
"""List all submissions for a specific form template"""
|
|
template = get_object_or_404(FormTemplate, slug=slug)
|
|
|
|
submissions = FormSubmission.objects.filter(template=template).order_by(
|
|
"-submitted_at"
|
|
)
|
|
|
|
# Pagination
|
|
paginator = Paginator(submissions, 10) # Show 10 submissions per page
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
return render(
|
|
request,
|
|
"forms/form_template_submissions_list.html",
|
|
{"template": template, "page_obj": page_obj},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def form_template_all_submissions(request, template_id):
|
|
"""Display all submissions for a form template in table format"""
|
|
template = get_object_or_404(FormTemplate, id=template_id)
|
|
print(template)
|
|
# Get all submissions for this template
|
|
submissions = FormSubmission.objects.filter(template=template).order_by(
|
|
"-submitted_at"
|
|
)
|
|
|
|
# Get all fields for this template, ordered by stage and field order
|
|
fields = (
|
|
FormField.objects.filter(stage__template=template)
|
|
.select_related("stage")
|
|
.order_by("stage__order", "order")
|
|
)
|
|
|
|
# Pagination
|
|
paginator = Paginator(submissions, 10) # Show 10 submissions per page
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
return render(
|
|
request,
|
|
"forms/form_template_all_submissions.html",
|
|
{
|
|
"template": template,
|
|
"page_obj": page_obj,
|
|
"fields": fields,
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def form_submission_details(request, template_id, slug):
|
|
"""Display detailed view of a specific form submission"""
|
|
# Get the form template and verify ownership
|
|
template = get_object_or_404(FormTemplate, id=template_id)
|
|
# Get the specific submission
|
|
submission = get_object_or_404(FormSubmission, slug=slug, template=template)
|
|
|
|
# Get all stages with their fields
|
|
stages = template.stages.prefetch_related("fields").order_by("order")
|
|
|
|
# Get all responses for this submission, ordered by field order
|
|
responses = submission.responses.select_related("field").order_by("field__order")
|
|
|
|
# Group responses by stage
|
|
stage_responses = {}
|
|
for stage in stages:
|
|
stage_responses[stage.id] = {
|
|
"stage": stage,
|
|
"responses": responses.filter(field__stage=stage),
|
|
}
|
|
|
|
return render(
|
|
request,
|
|
"forms/form_submission_details.html",
|
|
{
|
|
"template": template,
|
|
"submission": submission,
|
|
"stages": stages,
|
|
"responses": responses,
|
|
"stage_responses": stage_responses,
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def _handle_get_request(request, slug, job):
|
|
"""
|
|
Handles GET requests, setting up forms and restoring candidate selections
|
|
from the session for persistence.
|
|
"""
|
|
SESSION_KEY = f"schedule_candidate_ids_{slug}"
|
|
|
|
form = BulkInterviewTemplateForm(slug=slug)
|
|
# break_formset = BreakTimeFormSet(prefix='breaktime')
|
|
|
|
selected_ids = []
|
|
|
|
# 1. Capture IDs from HTMX request and store in session (when first clicked)
|
|
if "HX-Request" in request.headers:
|
|
candidate_ids = request.GET.getlist("candidate_ids")
|
|
|
|
if candidate_ids:
|
|
request.session[SESSION_KEY] = candidate_ids
|
|
selected_ids = candidate_ids
|
|
|
|
# 2. Restore IDs from session (on refresh or navigation)
|
|
if not selected_ids:
|
|
selected_ids = request.session.get(SESSION_KEY, [])
|
|
|
|
# 3. Use the list of IDs to initialize the form
|
|
if selected_ids:
|
|
candidates_to_load = Application.objects.filter(pk__in=selected_ids)
|
|
form.initial["applications"] = candidates_to_load
|
|
|
|
return render(
|
|
request,
|
|
"interviews/schedule_interviews.html",
|
|
{"form": form, "job": job},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def _handle_preview_submission(request, slug, job):
|
|
"""
|
|
Handles the initial POST request (Preview Schedule).
|
|
Validates forms, calculates slots, saves data to session, and renders preview.
|
|
"""
|
|
SESSION_DATA_KEY = "interview_schedule_data"
|
|
form = BulkInterviewTemplateForm(slug, request.POST)
|
|
|
|
if form.is_valid():
|
|
# Get the form data
|
|
applications = form.cleaned_data["applications"]
|
|
start_date = form.cleaned_data["start_date"]
|
|
end_date = form.cleaned_data["end_date"]
|
|
working_days = form.cleaned_data["working_days"]
|
|
start_time = form.cleaned_data["start_time"]
|
|
end_time = form.cleaned_data["end_time"]
|
|
interview_duration = form.cleaned_data["interview_duration"]
|
|
buffer_time = form.cleaned_data["buffer_time"]
|
|
break_start_time = form.cleaned_data["break_start_time"]
|
|
break_end_time = form.cleaned_data["break_end_time"]
|
|
schedule_interview_type = form.cleaned_data["schedule_interview_type"]
|
|
physical_address = form.cleaned_data["physical_address"]
|
|
|
|
temp_schedule = BulkInterviewTemplate(
|
|
job=job,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
working_days=working_days,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
interview_duration=interview_duration,
|
|
buffer_time=buffer_time or 5,
|
|
break_start_time=break_start_time or None,
|
|
break_end_time=break_end_time or None,
|
|
schedule_interview_type=schedule_interview_type,
|
|
physical_address=physical_address,
|
|
)
|
|
|
|
# Get available slots (temp_breaks logic moved into get_available_time_slots if needed)
|
|
available_slots = get_available_time_slots(temp_schedule)
|
|
if len(available_slots) < len(applications):
|
|
messages.error(
|
|
request,
|
|
f"Not enough available slots. Required: {len(applications)}, Available: {len(available_slots)}",
|
|
)
|
|
return render(
|
|
request,
|
|
"interviews/schedule_interviews.html",
|
|
{"form": form, "job": job},
|
|
)
|
|
|
|
# Create a preview schedule
|
|
preview_schedule = []
|
|
for i, application in enumerate(applications):
|
|
slot = available_slots[i]
|
|
preview_schedule.append(
|
|
{"application": application, "date": slot["date"], "time": slot["time"]}
|
|
)
|
|
|
|
# Save the form data to session for later use
|
|
schedule_data = {
|
|
"start_date": start_date.isoformat(),
|
|
"end_date": end_date.isoformat(),
|
|
"working_days": working_days,
|
|
"start_time": start_time.isoformat(),
|
|
"end_time": end_time.isoformat(),
|
|
"interview_duration": interview_duration,
|
|
"buffer_time": buffer_time,
|
|
"break_start_time": break_start_time.isoformat()
|
|
if break_start_time
|
|
else None,
|
|
"break_end_time": break_end_time.isoformat() if break_end_time else None,
|
|
"candidate_ids": [c.id for c in applications],
|
|
"schedule_interview_type": schedule_interview_type,
|
|
"physical_address": physical_address,
|
|
"topic": form.cleaned_data.get("topic"),
|
|
}
|
|
request.session[SESSION_DATA_KEY] = schedule_data
|
|
|
|
# Render the preview page
|
|
return render(
|
|
request,
|
|
"interviews/preview_schedule.html",
|
|
{
|
|
"job": job,
|
|
"schedule": preview_schedule,
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
"working_days": working_days,
|
|
"start_time": start_time,
|
|
"end_time": end_time,
|
|
"break_start_time": break_start_time,
|
|
"break_end_time": break_end_time,
|
|
"interview_duration": interview_duration,
|
|
"buffer_time": buffer_time,
|
|
},
|
|
)
|
|
else:
|
|
# Re-render the form if validation fails
|
|
return render(
|
|
request,
|
|
"interviews/schedule_interviews.html",
|
|
{"form": form, "job": job},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def _handle_confirm_schedule(request, slug, job):
|
|
"""
|
|
Handles the final POST request (Confirm Schedule).
|
|
Creates the main schedule record and queues individual interviews asynchronously.
|
|
"""
|
|
|
|
SESSION_DATA_KEY = "interview_schedule_data"
|
|
SESSION_ID_KEY = f"schedule_candidate_ids_{slug}"
|
|
|
|
# 1. Get schedule data from session
|
|
schedule_data = request.session.get(SESSION_DATA_KEY)
|
|
|
|
if not schedule_data:
|
|
messages.error(request, "Session expired. Please try again.")
|
|
return redirect("schedule_interviews", slug=slug)
|
|
|
|
# 2. Create the Interview Schedule (Parent Record)
|
|
try:
|
|
# Handle break times: If they exist, convert them; otherwise, pass None.
|
|
break_start = schedule_data.get("break_start_time")
|
|
break_end = schedule_data.get("break_end_time")
|
|
|
|
schedule = BulkInterviewTemplate.objects.create(
|
|
job=job,
|
|
created_by=request.user,
|
|
start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
|
|
end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
|
|
working_days=schedule_data["working_days"],
|
|
start_time=time.fromisoformat(schedule_data["start_time"]),
|
|
end_time=time.fromisoformat(schedule_data["end_time"]),
|
|
interview_duration=schedule_data["interview_duration"],
|
|
buffer_time=schedule_data["buffer_time"],
|
|
break_start_time=time.fromisoformat(break_start) if break_start else None,
|
|
break_end_time=time.fromisoformat(break_end) if break_end else None,
|
|
schedule_interview_type=schedule_data.get("schedule_interview_type"),
|
|
physical_address=schedule_data.get("physical_address"),
|
|
topic=schedule_data.get("topic"),
|
|
)
|
|
except Exception as e:
|
|
# Clear data on failure to prevent stale data causing repeated errors
|
|
messages.error(request, f"Error creating schedule: {e}")
|
|
if SESSION_ID_KEY in request.session:
|
|
del request.session[SESSION_ID_KEY]
|
|
if SESSION_DATA_KEY in request.session:
|
|
del request.session[SESSION_DATA_KEY]
|
|
return redirect("schedule_interviews", slug=slug)
|
|
|
|
applications = Application.objects.filter(id__in=schedule_data["candidate_ids"])
|
|
schedule.applications.set(applications)
|
|
available_slots = get_available_time_slots(schedule)
|
|
|
|
for i, application in enumerate(applications):
|
|
if i >= len(available_slots):
|
|
continue
|
|
|
|
slot = available_slots[i]
|
|
|
|
# start_dt = datetime.combine(slot["date"], slot["time"])
|
|
start_time = timezone.make_aware(datetime.combine(slot["date"], slot["time"]))
|
|
logger.info(f"Creating interview for {application.person.full_name} at {start_time}")
|
|
|
|
interview = Interview.objects.create(
|
|
topic=schedule.topic,
|
|
start_time=start_time,
|
|
duration=schedule.interview_duration,
|
|
location_type="Onsite",
|
|
physical_address=schedule.physical_address,
|
|
)
|
|
|
|
scheduled = ScheduledInterview.objects.create(
|
|
application=application,
|
|
job=job,
|
|
schedule=schedule,
|
|
interview_date=slot["date"],
|
|
interview_time=slot["time"],
|
|
interview=interview,
|
|
)
|
|
|
|
if schedule_data.get("schedule_interview_type") == "Remote":
|
|
interview.location_type = "Remote"
|
|
interview.save(update_fields=["location_type"])
|
|
async_task("recruitment.tasks.create_interview_and_meeting",scheduled.pk)
|
|
|
|
messages.success(request,f"Schedule successfully created.")
|
|
|
|
if SESSION_DATA_KEY in request.session:
|
|
del request.session[SESSION_DATA_KEY]
|
|
if SESSION_ID_KEY in request.session:
|
|
del request.session[SESSION_ID_KEY]
|
|
|
|
return redirect("applications_interview_view", slug=slug)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def schedule_interviews_view(request, slug):
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
if request.method == "POST":
|
|
return _handle_preview_submission(request, slug, job)
|
|
else:
|
|
return _handle_get_request(request, slug, job)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def confirm_schedule_interviews_view(request, slug):
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
if request.method == "POST":
|
|
# print(request.session['interview_schedule_data'])
|
|
return _handle_confirm_schedule(request, slug, job)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def applications_screening_view(request, slug):
|
|
"""
|
|
Manage candidate tiers and stage transitions
|
|
"""
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
applications = job.screening_applications
|
|
|
|
# Get filter parameters
|
|
min_ai_score_str = request.GET.get("min_ai_score")
|
|
min_experience_str = request.GET.get("min_experience")
|
|
screening_rating = request.GET.get("screening_rating")
|
|
tier1_count_str = request.GET.get("tier1_count")
|
|
gpa = request.GET.get("GPA")
|
|
|
|
try:
|
|
# Check if the string value exists and is not an empty string before conversion
|
|
if min_ai_score_str:
|
|
min_ai_score = int(min_ai_score_str)
|
|
else:
|
|
min_ai_score = 0
|
|
|
|
if min_experience_str:
|
|
min_experience = float(min_experience_str)
|
|
else:
|
|
min_experience = 0
|
|
|
|
if tier1_count_str:
|
|
tier1_count = int(tier1_count_str)
|
|
else:
|
|
tier1_count = 0
|
|
|
|
if gpa:
|
|
gpa = float(gpa)
|
|
else:
|
|
gpa = 0
|
|
|
|
except ValueError:
|
|
# This catches if the user enters non-numeric text (e.g., "abc")
|
|
min_ai_score = 0
|
|
min_experience = 0
|
|
tier1_count = 0
|
|
gpa = 0
|
|
|
|
# Apply filters
|
|
if min_ai_score > 0:
|
|
applications = applications.filter(
|
|
ai_analysis_data__analysis_data_en__match_score__gte=min_ai_score
|
|
)
|
|
|
|
if min_experience > 0:
|
|
applications = applications.filter(
|
|
ai_analysis_data__analysis_data_en__years_of_experience__gte=min_experience
|
|
)
|
|
|
|
if screening_rating:
|
|
applications = applications.filter(
|
|
ai_analysis_data__analysis_data_en__screening_stage_rating=screening_rating
|
|
)
|
|
if gpa:
|
|
applications = applications.filter(person__gpa__gte=gpa)
|
|
|
|
if tier1_count > 0:
|
|
applications = applications[:tier1_count]
|
|
|
|
context = {
|
|
"job": job,
|
|
"applications": applications,
|
|
"min_ai_score": min_ai_score,
|
|
"min_experience": min_experience,
|
|
"screening_rating": screening_rating,
|
|
"tier1_count": tier1_count,
|
|
"gpa": gpa,
|
|
"current_stage": "Applied",
|
|
}
|
|
|
|
return render(request, "recruitment/applications_screening_view.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def applications_exam_view(request, slug):
|
|
"""
|
|
Manage candidate tiers and stage transitions
|
|
"""
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
context = {
|
|
"job": job,
|
|
"applications": job.exam_applications,
|
|
"current_stage": "Exam",
|
|
}
|
|
return render(request, "recruitment/applications_exam_view.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def update_application_exam_status(request, slug):
|
|
application = get_object_or_404(Application, slug=slug)
|
|
if request.method == "POST":
|
|
form = ApplicationExamDateForm(request.POST, instance=application)
|
|
if form.is_valid():
|
|
form.save()
|
|
return redirect("applications_exam_view", slug=application.job.slug)
|
|
else:
|
|
form = ApplicationExamDateForm(request.POST, instance=application)
|
|
return render(
|
|
request,
|
|
"includes/application_exam_status_form.html",
|
|
{"application": application, "form": form},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def bulk_update_application_exam_status(request, slug):
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
status = request.headers.get("status")
|
|
if status:
|
|
for application in get_applications_from_request(request):
|
|
try:
|
|
if status == "pass":
|
|
application.exam_status = "Passed"
|
|
application.stage = "Interview"
|
|
else:
|
|
application.exam_status = "Failed"
|
|
application.save()
|
|
except Exception as e:
|
|
print(e)
|
|
messages.success(request, f"Updated exam status selected applications")
|
|
return redirect("applications_exam_view", slug=job.slug)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def application_criteria_view_htmx(request, pk):
|
|
application = get_object_or_404(Application, pk=pk)
|
|
return render(
|
|
request, "includes/application_modal_body.html", {"application": application}
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def application_set_exam_date(request, slug):
|
|
application = get_object_or_404(Application, slug=slug)
|
|
application.exam_date = timezone.now()
|
|
application.save()
|
|
messages.success(
|
|
request, f"Set exam date for {application.name} to {application.exam_date}"
|
|
)
|
|
return redirect("applications_screening_view", slug=application.job.slug)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def application_update_status(request, slug):
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
mark_as = request.POST.get("mark_as")
|
|
if mark_as != "----------":
|
|
application_ids = request.POST.getlist("candidate_ids")
|
|
|
|
if c := Application.objects.filter(pk__in=application_ids):
|
|
if mark_as == "Exam":
|
|
print("exam")
|
|
c.update(
|
|
exam_date=timezone.now(),
|
|
interview_date=None,
|
|
offer_date=None,
|
|
hired_date=None,
|
|
stage=mark_as,
|
|
applicant_status="Candidate"
|
|
if mark_as in ["Exam", "Interview", "Document Review", "Offer"]
|
|
else "Applicant",
|
|
)
|
|
elif mark_as == "Interview":
|
|
# interview_date update when scheduling the interview
|
|
print("interview")
|
|
c.update(
|
|
stage=mark_as,
|
|
offer_date=None,
|
|
hired_date=None,
|
|
applicant_status="Candidate"
|
|
if mark_as in ["Exam", "Interview", "Document Review", "Offer"]
|
|
else "Applicant",
|
|
)
|
|
elif mark_as == "Document Review":
|
|
print("document review")
|
|
c.update(
|
|
stage=mark_as,
|
|
offer_date=None,
|
|
hired_date=None,
|
|
applicant_status="Candidate"
|
|
if mark_as in ["Exam", "Interview", "Document Review", "Offer"]
|
|
else "Applicant",
|
|
)
|
|
elif mark_as == "Offer":
|
|
print("offer")
|
|
c.update(
|
|
stage=mark_as,
|
|
offer_date=timezone.now(),
|
|
hired_date=None,
|
|
applicant_status="Candidate"
|
|
if mark_as in ["Exam", "Interview", "Document Review", "Offer"]
|
|
else "Applicant",
|
|
)
|
|
elif mark_as == "Hired":
|
|
print("hired")
|
|
c.update(
|
|
stage=mark_as,
|
|
hired_date=timezone.now(),
|
|
applicant_status="Candidate"
|
|
if mark_as in ["Exam", "Interview", "Offer"]
|
|
else "Applicant",
|
|
)
|
|
else:
|
|
print("rejected")
|
|
c.update(
|
|
stage=mark_as,
|
|
exam_date=None,
|
|
interview_date=None,
|
|
offer_date=None,
|
|
hired_date=None,
|
|
applicant_status="Candidate"
|
|
if mark_as in ["Exam", "Interview", "Offer"]
|
|
else "Applicant",
|
|
)
|
|
|
|
messages.success(request, f"Applications Updated")
|
|
response = HttpResponse(redirect("applications_screening_view", slug=job.slug))
|
|
response.headers["HX-Refresh"] = "true"
|
|
return response
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def applications_interview_view(request, slug):
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
context = {
|
|
"job": job,
|
|
"applications": job.interview_applications,
|
|
"current_stage": "Interview",
|
|
}
|
|
return render(request, "recruitment/applications_interview_view.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def applications_document_review_view(request, slug):
|
|
"""
|
|
Document review view for candidates after interview stage and before offer stage
|
|
"""
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
|
|
# Get candidates from Interview stage who need document review
|
|
applications = job.document_review_applications.select_related("person")
|
|
# Get search query for filtering
|
|
search_query = request.GET.get("q", "")
|
|
if search_query:
|
|
applications = applications.filter(
|
|
Q(person__first_name=search_query)
|
|
| Q(person__last_name__icontains=search_query)
|
|
| Q(person__email__icontains=search_query)
|
|
)
|
|
|
|
context = {
|
|
"job": job,
|
|
"applications": applications,
|
|
"current_stage": "Document Review",
|
|
"search_query": search_query,
|
|
}
|
|
return render(
|
|
request, "recruitment/applications_document_review_view.html", context
|
|
)
|
|
|
|
|
|
@login_required
|
|
@require_POST
|
|
@staff_user_required
|
|
def reschedule_meeting_for_application(request, slug):
|
|
from .utils import update_meeting
|
|
from .forms import OnsiteScheduleInterviewUpdateForm
|
|
|
|
schedule = get_object_or_404(ScheduledInterview, slug=slug)
|
|
interview = schedule.interview
|
|
|
|
if request.method == "POST":
|
|
if interview.location_type == "Remote":
|
|
form = ScheduledInterviewForm(request.POST)
|
|
else:
|
|
form = OnsiteScheduleInterviewUpdateForm(request.POST)
|
|
if form.is_valid():
|
|
topic = form.cleaned_data.get("topic")
|
|
start_time = form.cleaned_data.get("start_time")
|
|
duration = form.cleaned_data.get("duration")
|
|
physical_address = form.cleaned_data.get("physical_address")
|
|
room_number = form.cleaned_data.get("room_number")
|
|
if interview.location_type == "Remote":
|
|
updated_data = {
|
|
"topic": topic,
|
|
"start_time": start_time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
"duration": duration,
|
|
}
|
|
result = update_meeting(schedule.interview, updated_data)
|
|
|
|
if result["status"] == "success":
|
|
messages.success(request, result["message"])
|
|
else:
|
|
messages.error(request, result["message"])
|
|
else:
|
|
interview.topic = topic
|
|
interview.start_time = start_time
|
|
interview.duration = duration
|
|
interview.room_number = room_number
|
|
interview.physical_address = physical_address
|
|
interview.save()
|
|
messages.success(request, "Meeting updated successfully")
|
|
else:
|
|
messages.error(request, "Invalid data submitted.")
|
|
return redirect("interview_detail", slug=schedule.slug)
|
|
|
|
# context = {"job": job, "application": application, "meeting": meeting, "form": form}
|
|
# return render(request, "meetings/reschedule_meeting.html", context)
|
|
|
|
|
|
# @staff_user_required
|
|
# def schedule_meeting_for_application(request, slug, candidate_pk, meeting_id):
|
|
# job = get_object_or_404(JobPosting, slug=slug)
|
|
# application = get_object_or_404(Application, pk=candidate_pk)
|
|
# meeting = get_object_or_404(ZoomMeetingDetails, pk=meeting_id)
|
|
# if request.method == "POST":
|
|
# result = delete_zoom_meeting(meeting.meeting_id)
|
|
# if (
|
|
# result["status"] == "success"
|
|
# or "Meeting does not exist" in result["details"]["message"]
|
|
# ):
|
|
# meeting.delete()
|
|
# messages.success(request, "Meeting deleted successfully")
|
|
# else:
|
|
# messages.error(request, result["message"])
|
|
# return redirect(reverse("applications_interview_view", kwargs={"slug": job.slug}))
|
|
|
|
# context = {
|
|
# "job": job,
|
|
# "application": application,
|
|
# "meeting": meeting,
|
|
# "delete_url": reverse(
|
|
# "schedule_meeting_for_application",
|
|
# kwargs={
|
|
# "slug": job.slug,
|
|
# "candidate_pk": candidate_pk,
|
|
# "meeting_id": meeting_id,
|
|
# },
|
|
# ),
|
|
# }
|
|
# return render(request, "meetings/delete_meeting_form.html", context)
|
|
|
|
# @staff_user_required
|
|
# def delete_zoom_meeting_for_candidate(request, slug, candidate_pk, meeting_id):
|
|
# """
|
|
# Deletes a specific Zoom (Remote) meeting instance.
|
|
# The ZoomMeetingDetails object inherits from InterviewLocation,
|
|
# which is linked to ScheduledInterview. Deleting the subclass
|
|
# should trigger CASCADE/SET_NULL correctly on the FK chain.
|
|
# """
|
|
# job = get_object_or_404(JobPosting, slug=slug)
|
|
# candidate = get_object_or_404(Application, pk=candidate_pk)
|
|
|
|
# # Target the specific Zoom meeting details instance
|
|
# # meeting = get_object_or_404(ZoomMeetingDetails, pk=meeting_id)
|
|
# meeting = None#TODO:Update
|
|
|
|
# if request.method == "POST":
|
|
# # 1. Attempt to delete the meeting from the external Zoom API
|
|
# result = delete_zoom_meeting(meeting.meeting_id)
|
|
|
|
# # 2. Check for success OR if the meeting was already deleted externally
|
|
# if (
|
|
# result["status"] == "success"
|
|
# or "Meeting does not exist" in result["details"]["message"]
|
|
# ):
|
|
# # 3. Delete the local Django object. This will delete the base
|
|
# # InterviewLocation object and update the ScheduledInterview FK.
|
|
# meeting.delete()
|
|
# messages.success(request, f"Remote meeting for {candidate.name} deleted successfully.")
|
|
# else:
|
|
# messages.error(request, result["message"])
|
|
|
|
# return redirect(reverse("applications_interview_view", kwargs={"slug": job.slug}))
|
|
|
|
# context = {
|
|
# "job": job,
|
|
# "candidate": candidate,
|
|
# "meeting": meeting,
|
|
# "location_type": "Remote",
|
|
# "delete_url": reverse(
|
|
# "delete_remote_meeting_for_candidate", # Use the specific new URL name
|
|
# kwargs={
|
|
# "slug": job.slug,
|
|
# "candidate_pk": candidate_pk,
|
|
# "meeting_id": meeting_id,
|
|
# },
|
|
# ),
|
|
# }
|
|
# return render(request, "meetings/delete_meeting_form.html", context)
|
|
|
|
|
|
# @staff_user_required
|
|
def interview_calendar_view(request, slug):
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
|
|
# Get all scheduled interviews for this job
|
|
scheduled_interviews = ScheduledInterview.objects.filter(job=job).select_related(
|
|
"interview","application"
|
|
)
|
|
|
|
# Convert interviews to calendar events
|
|
events = []
|
|
for interview in scheduled_interviews:
|
|
# Create start datetime
|
|
start_datetime = datetime.combine(
|
|
interview.interview_date, interview.interview_time
|
|
)
|
|
|
|
# Calculate end datetime based on interview duration
|
|
duration = interview.interview.duration if interview.interview else 60
|
|
end_datetime = start_datetime + timedelta(minutes=duration)
|
|
|
|
# Determine event color based on status
|
|
color = "#00636e" # Default color
|
|
if interview.status == "confirmed":
|
|
color = "#00a86b" # Green for confirmed
|
|
elif interview.status == "cancelled":
|
|
color = "#e74c3c" # Red for cancelled
|
|
elif interview.status == "completed":
|
|
color = "#95a5a6" # Gray for completed
|
|
|
|
events.append(
|
|
{
|
|
"title": f"Interview: {interview.application.person.full_name}",
|
|
"start": start_datetime.isoformat(),
|
|
"end": end_datetime.isoformat(),
|
|
"url": f"{request.path}interview/{interview.id}/",
|
|
"color": color,
|
|
"extendedProps": {
|
|
"candidate": interview.application.person.full_name,
|
|
"email": interview.application.person.email,
|
|
"status": interview.interview.status,
|
|
"meeting_id": interview.interview.meeting_id
|
|
if interview.interview
|
|
else None,
|
|
"join_url": interview.interview.join_url
|
|
if interview.interview
|
|
else None,
|
|
},
|
|
}
|
|
)
|
|
|
|
context = {
|
|
"job": job,
|
|
"events": events,
|
|
"calendar_color": "#00636e",
|
|
}
|
|
|
|
return render(request, "recruitment/interview_calendar.html", context)
|
|
|
|
|
|
def user_profile_image_update(request, pk):
|
|
user = get_object_or_404(User, pk=pk)
|
|
|
|
if request.method == "POST":
|
|
profile_form = ProfileImageUploadForm(
|
|
request.POST, request.FILES, instance=user
|
|
)
|
|
if profile_form.is_valid():
|
|
profile_form.save()
|
|
messages.success(request, "Image uploaded successfully")
|
|
return redirect("user_detail", pk=user.pk)
|
|
else:
|
|
messages.error(
|
|
request,
|
|
"An error occurred while uploading image. Please check errors below.",
|
|
)
|
|
else:
|
|
profile_form = ProfileImageUploadForm(instance=user)
|
|
|
|
context = {
|
|
"profile_form": profile_form,
|
|
"user": user,
|
|
}
|
|
return render(request, "user/profile.html", context)
|
|
|
|
|
|
@login_required
|
|
def user_detail(request, pk):
|
|
user = get_object_or_404(User, pk=pk)
|
|
|
|
profile_form = ProfileImageUploadForm(instance=user)
|
|
|
|
if request.method == "POST":
|
|
first_name = request.POST.get("first_name")
|
|
last_name = request.POST.get("last_name")
|
|
if first_name:
|
|
user.first_name = first_name
|
|
if last_name:
|
|
user.last_name = last_name
|
|
user.save()
|
|
context = {
|
|
"user": user,
|
|
"profile_form": profile_form,
|
|
"password_reset_form": PasswordResetForm(),
|
|
}
|
|
if request.user.user_type != "staff":
|
|
return render(request, "user/portal_profile.html", context)
|
|
return render(request, "user/profile.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def easy_logs(request):
|
|
"""
|
|
Function-based view to display Django Easy Audit logs with tab switching and pagination.
|
|
"""
|
|
logs_per_page = 20
|
|
|
|
active_tab = request.GET.get("tab", "crud")
|
|
|
|
if active_tab == "login":
|
|
queryset = LoginEvent.objects.order_by("-datetime")
|
|
tab_title = _("User Authentication")
|
|
elif active_tab == "request":
|
|
queryset = RequestEvent.objects.order_by("-datetime")
|
|
tab_title = _("HTTP Requests")
|
|
else:
|
|
queryset = CRUDEvent.objects.order_by("-datetime")
|
|
tab_title = _("Model Changes (CRUD)")
|
|
active_tab = "crud"
|
|
|
|
paginator = Paginator(queryset, logs_per_page)
|
|
page = request.GET.get("page")
|
|
|
|
try:
|
|
logs_page = paginator.page(page)
|
|
except PageNotAnInteger:
|
|
logs_page = paginator.page(1)
|
|
except EmptyPage:
|
|
logs_page = paginator.page(paginator.num_pages)
|
|
|
|
context = {
|
|
"logs": logs_page,
|
|
"total_count": queryset.count(),
|
|
"active_tab": active_tab,
|
|
"tab_title": tab_title,
|
|
}
|
|
|
|
return render(request, "includes/easy_logs.html", context)
|
|
|
|
|
|
from allauth.account.views import SignupView
|
|
from django.contrib.auth.decorators import user_passes_test
|
|
|
|
|
|
def is_superuser_check(user):
|
|
return user.is_superuser
|
|
|
|
|
|
@login_required
|
|
@superuser_required
|
|
def create_staff_user(request):
|
|
if request.method == "POST":
|
|
form = StaffUserCreationForm(request.POST)
|
|
if form.is_valid():
|
|
form.save()
|
|
messages.success(
|
|
request,
|
|
f"Staff user {form.cleaned_data['first_name']} {form.cleaned_data['last_name']} "
|
|
f"({form.cleaned_data['email']}) created successfully!",
|
|
)
|
|
return redirect("admin_settings")
|
|
else:
|
|
form = StaffUserCreationForm()
|
|
return render(request, "user/create_staff.html", {"form": form})
|
|
|
|
|
|
@login_required
|
|
@superuser_required
|
|
def admin_settings(request):
|
|
staffs = User.objects.filter(user_type="staff", is_superuser=False)
|
|
paginator=Paginator(staffs,20)
|
|
page_number=request.GET.get('page')
|
|
page_obj=paginator.get_page(page_number)
|
|
form = ToggleAccountForm()
|
|
context = {"staffs": page_obj, "form": form,"page_obj":page_obj}
|
|
return render(request, "user/admin_settings.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def staff_assignment_view(request, slug):
|
|
"""
|
|
View to assign staff to a job posting
|
|
"""
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
staff_users = User.objects.filter(user_type="staff", is_superuser=False)
|
|
applications = job.applications.all()
|
|
|
|
if request.method == "POST":
|
|
form = StaffAssignmentForm(request.POST, instance=job)
|
|
|
|
if form.is_valid():
|
|
job.assigned_to = form.cleaned_data["assigned_to"]
|
|
job.save(update_fields=["assigned_to"])
|
|
messages.success(
|
|
request, f"Staff assigned to job '{job.title}' successfully!"
|
|
)
|
|
return redirect("job_detail", slug=job.slug)
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = StaffAssignmentForm(instance=job)
|
|
print(staff_users)
|
|
context = {
|
|
"job": job,
|
|
"applications": applications,
|
|
"staff_users": staff_users,
|
|
"form": form,
|
|
}
|
|
return render(request, "recruitment/staff_assignment_view.html", context)
|
|
|
|
|
|
from django.contrib.auth.forms import SetPasswordForm
|
|
|
|
|
|
@login_required
|
|
@superuser_required
|
|
def set_staff_password(request, pk):
|
|
user = get_object_or_404(User, pk=pk)
|
|
print(request.POST)
|
|
if request.method == "POST":
|
|
form = SetPasswordForm(user, data=request.POST)
|
|
if form.is_valid():
|
|
form.save()
|
|
messages.success(request, f"Password successfully changed")
|
|
return redirect("admin_settings")
|
|
else:
|
|
form = SetPasswordForm(user=user)
|
|
messages.error(request, f"Password does not match please try again.")
|
|
return redirect("admin_settings")
|
|
|
|
else:
|
|
form = SetPasswordForm(user=user)
|
|
return render(
|
|
request, "user/staff_password_create.html", {"form": form, "user": user}
|
|
)
|
|
|
|
|
|
@login_required
|
|
@superuser_required
|
|
def account_toggle_status(request, pk):
|
|
user = get_object_or_404(User, pk=pk)
|
|
if request.method == "POST":
|
|
print(user.is_active)
|
|
form = ToggleAccountForm(request.POST)
|
|
if form.is_valid():
|
|
if user.is_active:
|
|
user.is_active = False
|
|
user.save()
|
|
messages.success(
|
|
request, f"Staff with email: {user.email} deactivated successfully"
|
|
)
|
|
return redirect("admin_settings")
|
|
else:
|
|
user.is_active = True
|
|
user.save()
|
|
messages.success(
|
|
request, f"Staff with email: {user.email} activated successfully"
|
|
)
|
|
return redirect("admin_settings")
|
|
else:
|
|
messages.error(f"Please correct the error below")
|
|
|
|
|
|
@csrf_exempt
|
|
def zoom_webhook_view(request):
|
|
from .utils import get_setting
|
|
api_key = request.headers.get("X-Zoom-API-KEY")
|
|
if api_key != get_setting("ZOOM_WEBHOOK_API_KEY"):
|
|
return HttpResponse(status=405)
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
payload = json.loads(request.body)
|
|
logger.info(payload)
|
|
async_task("recruitment.tasks.handle_zoom_webhook_event", payload)
|
|
return HttpResponse(status=200)
|
|
except Exception:
|
|
return HttpResponse(status=400)
|
|
return HttpResponse(status=405)
|
|
|
|
|
|
# Hiring Agency CRUD Views
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_list(request):
|
|
"""List all hiring agencies with search and pagination"""
|
|
search_query = request.GET.get("search", "")
|
|
agencies = HiringAgency.objects.all()
|
|
|
|
if search_query:
|
|
agencies = agencies.filter(
|
|
Q(name__icontains=search_query)
|
|
| Q(contact_person__icontains=search_query)
|
|
| Q(email__icontains=search_query)
|
|
| Q(country__icontains=search_query)
|
|
| Q(phone=search_query)
|
|
)
|
|
|
|
# Order by most recently created
|
|
agencies = agencies.order_by("-created_at")
|
|
|
|
# Pagination
|
|
paginator = Paginator(agencies,20) # Show 10 agencies per page
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"search_query": search_query,
|
|
"total_agencies": agencies.count(),
|
|
}
|
|
return render(request, "recruitment/agency_list.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_create(request):
|
|
"""Create a new hiring agency"""
|
|
if request.method == "POST":
|
|
form = HiringAgencyForm(request.POST)
|
|
if form.is_valid():
|
|
agency = form.save()
|
|
messages.success(request, f'Agency "{agency.name}" created successfully!')
|
|
return redirect("agency_detail", slug=agency.slug)
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = HiringAgencyForm()
|
|
|
|
context = {
|
|
"form": form,
|
|
"title": _("Create New Agency"),
|
|
"button_text": _("Create Agency"),
|
|
}
|
|
return render(request, "recruitment/agency_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def regenerate_agency_password(request, slug):
|
|
agency = HiringAgency.objects.get(slug=slug)
|
|
new_password = generate_random_password()
|
|
agency.generated_password = new_password
|
|
agency.save()
|
|
if agency.user is None:
|
|
messages.error(
|
|
request,
|
|
_(
|
|
"Error: The user account associated with this agency could not be found."
|
|
),
|
|
)
|
|
# Redirect the staff user back to the agency detail page or list
|
|
return redirect("agency_detail", slug=agency.slug) # Or wherever appropriate
|
|
user = agency.user
|
|
user.set_password(new_password)
|
|
user.save()
|
|
messages.success(
|
|
request, f'New password generated for agency "{agency.name}" successfully!'
|
|
)
|
|
return redirect("agency_detail", slug=agency.slug)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def deactivate_agency(request, slug):
|
|
agency = get_object_or_404(HiringAgency, slug=slug)
|
|
agency.is_active = False
|
|
agency.save()
|
|
messages.success(request, f'Agency "{agency.name}" deactivated successfully!')
|
|
return redirect("agency_detail", slug=agency.slug)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_detail(request, slug):
|
|
"""View details of a specific hiring agency"""
|
|
agency = get_object_or_404(HiringAgency, slug=slug)
|
|
|
|
# Get applications associated with this agency
|
|
applications = Application.objects.filter(hiring_agency=agency).order_by(
|
|
"-created_at"
|
|
)
|
|
|
|
# Statistics
|
|
total_applications = applications.count()
|
|
active_applications = applications.filter(
|
|
stage__in=["Applied", "Screening", "Exam", "Interview", "Offer"]
|
|
).count()
|
|
hired_applications = applications.filter(stage="Hired").count()
|
|
rejected_applications = applications.filter(stage="Rejected").count()
|
|
job_assignments = AgencyJobAssignment.objects.filter(agency=agency)
|
|
total_job_assignments = job_assignments.count()
|
|
print(job_assignments)
|
|
context = {
|
|
"agency": agency,
|
|
"applications": applications[:10], # Show recent 10 applications
|
|
"total_applications": total_applications,
|
|
"active_applications": active_applications,
|
|
"hired_applications": hired_applications,
|
|
"rejected_applications": rejected_applications,
|
|
"generated_password": agency.generated_password
|
|
if agency.generated_password
|
|
else None,
|
|
"job_assignments": job_assignments,
|
|
"total_job_assignments": total_job_assignments,
|
|
}
|
|
return render(request, "recruitment/agency_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_update(request, slug):
|
|
"""Update an existing hiring agency"""
|
|
agency = get_object_or_404(HiringAgency, slug=slug)
|
|
|
|
if request.method == "POST":
|
|
form = HiringAgencyForm(request.POST, instance=agency)
|
|
if form.is_valid():
|
|
agency = form.save()
|
|
messages.success(request, f'Agency "{agency.name}" updated successfully!')
|
|
return redirect("agency_detail", slug=agency.slug)
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = HiringAgencyForm(instance=agency)
|
|
|
|
context = {
|
|
"form": form,
|
|
"agency": agency,
|
|
"title": f"Edit Agency: {agency.name}",
|
|
"button_text": _("Update Agency"),
|
|
}
|
|
return render(request, "recruitment/agency_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_delete(request, slug):
|
|
"""Delete a hiring agency"""
|
|
agency = get_object_or_404(HiringAgency, slug=slug)
|
|
|
|
if request.method == "POST":
|
|
agency_name = agency.name
|
|
agency.delete()
|
|
messages.success(request, f'Agency "{agency_name}" deleted successfully!')
|
|
return redirect("agency_list")
|
|
|
|
context = {
|
|
"agency": agency,
|
|
"title": "Delete Agency",
|
|
"message": f'Are you sure you want to delete the agency "{agency.name}"?',
|
|
"cancel_url": reverse("agency_detail", kwargs={"slug": agency.slug}),
|
|
}
|
|
return render(request, "recruitment/agency_confirm_delete.html", context)
|
|
|
|
|
|
@staff_user_required
|
|
def agency_applications(request, slug):
|
|
"""View all applications from a specific agency"""
|
|
agency = get_object_or_404(HiringAgency, slug=slug)
|
|
applications = Application.objects.filter(hiring_agency=agency).order_by(
|
|
"-created_at"
|
|
)
|
|
|
|
# Filter by stage if provided
|
|
stage_filter = request.GET.get("stage")
|
|
if stage_filter:
|
|
applications = applications.filter(stage=stage_filter)
|
|
|
|
# Get total applications before pagination for accurate count
|
|
total_applications = applications.count()
|
|
|
|
# Pagination
|
|
paginator = Paginator(applications, 20) # Show 20 applications per page
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
context = {
|
|
"agency": agency,
|
|
"page_obj": page_obj,
|
|
"stage_filter": stage_filter,
|
|
"total_applications": total_applications,
|
|
}
|
|
return render(request, "recruitment/agency_applications.html", context)
|
|
|
|
|
|
# Agency Portal Management Views
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_assignment_list(request):
|
|
"""List all agency job assignments"""
|
|
search_query = request.GET.get("q", "")
|
|
status_filter = request.GET.get("status", "")
|
|
|
|
assignments = AgencyJobAssignment.objects.select_related("agency", "job").order_by(
|
|
"-created_at"
|
|
)
|
|
|
|
if search_query:
|
|
assignments = assignments.filter(
|
|
Q(agency__name__icontains=search_query)
|
|
| Q(job__title__icontains=search_query)
|
|
| Q(agency__contact_person__icontains=search_query)
|
|
)
|
|
|
|
if status_filter:
|
|
assignments = assignments.filter(status=status_filter)
|
|
|
|
# Pagination
|
|
paginator = Paginator(assignments, 20) # Show 15 assignments per page
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"search_query": search_query,
|
|
"status_filter": status_filter,
|
|
"total_assignments": assignments.count(),
|
|
}
|
|
return render(request, "recruitment/agency_assignment_list.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_assignment_create(request, slug=None):
|
|
"""Create a new agency job assignment"""
|
|
agency = HiringAgency.objects.get(slug=slug) if slug else None
|
|
|
|
if request.method == "POST":
|
|
form = AgencyJobAssignmentForm(request.POST)
|
|
# if agency:
|
|
# form.instance.agency = agency
|
|
if form.is_valid():
|
|
assignment = form.save()
|
|
messages.success(
|
|
request,
|
|
f"Assignment created for {assignment.agency.name} - {assignment.job.title}!",
|
|
)
|
|
return redirect("agency_assignment_detail", slug=assignment.slug)
|
|
else:
|
|
messages.error(
|
|
request, f"Please correct the errors below.{form.errors.as_text()}"
|
|
)
|
|
print(form.errors.as_json())
|
|
else:
|
|
form = AgencyJobAssignmentForm()
|
|
try:
|
|
# from django.forms import HiddenInput
|
|
form.initial["agency"] = agency
|
|
# form.fields['agency'].widget = HiddenInput()
|
|
except HiringAgency.DoesNotExist:
|
|
pass
|
|
|
|
context = {
|
|
"form": form,
|
|
"title": "Create New Assignment",
|
|
"button_text": "Create Assignment",
|
|
}
|
|
return render(request, "recruitment/agency_assignment_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_assignment_detail(request, slug):
|
|
"""View details of a specific agency assignment"""
|
|
assignment = get_object_or_404(
|
|
AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug
|
|
)
|
|
|
|
# Get applications submitted by this agency for this job
|
|
applications = Application.objects.filter(
|
|
hiring_agency=assignment.agency, job=assignment.job
|
|
).order_by("-created_at")
|
|
|
|
# Get access link if exists
|
|
access_link = getattr(assignment, "access_link", None)
|
|
|
|
# Get messages for this assignment
|
|
|
|
total_applications = applications.count()
|
|
max_applications = assignment.max_candidates
|
|
circumference = 326.73 # 2 * π * r where r=52
|
|
|
|
if max_applications > 0:
|
|
progress_percentage = total_applications / max_applications
|
|
stroke_dashoffset = circumference - (circumference * progress_percentage)
|
|
else:
|
|
stroke_dashoffset = circumference
|
|
|
|
context = {
|
|
"assignment": assignment,
|
|
"applications": applications,
|
|
"access_link": access_link,
|
|
"total_applications": applications.count(),
|
|
"stroke_dashoffset": stroke_dashoffset,
|
|
}
|
|
return render(request, "recruitment/agency_assignment_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_assignment_update(request, slug):
|
|
"""Update an existing agency assignment"""
|
|
assignment = get_object_or_404(AgencyJobAssignment, slug=slug)
|
|
|
|
if request.method == "POST":
|
|
form = AgencyJobAssignmentForm(request.POST, instance=assignment)
|
|
if form.is_valid():
|
|
assignment = form.save()
|
|
messages.success(request, f"Assignment updated successfully!")
|
|
return redirect("agency_assignment_detail", slug=assignment.slug)
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = AgencyJobAssignmentForm(instance=assignment)
|
|
|
|
context = {
|
|
"form": form,
|
|
"assignment": assignment,
|
|
"title": f"Edit Assignment: {assignment.agency.name} - {assignment.job.title}",
|
|
"button_text": "Update Assignment",
|
|
}
|
|
return render(request, "recruitment/agency_assignment_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_access_link_create(request):
|
|
"""Create access link for agency assignment"""
|
|
if request.method == "POST":
|
|
form = AgencyAccessLinkForm(request.POST)
|
|
if form.is_valid():
|
|
access_link = form.save()
|
|
messages.success(
|
|
request,
|
|
f"Access link created for {access_link.assignment.agency.name}!",
|
|
)
|
|
return redirect(
|
|
"agency_assignment_detail", slug=access_link.assignment.slug
|
|
)
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = AgencyAccessLinkForm()
|
|
|
|
context = {
|
|
"form": form,
|
|
"title": "Create Access Link",
|
|
"button_text": "Create Link",
|
|
}
|
|
return render(request, "recruitment/agency_access_link_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_access_link_detail(request, slug):
|
|
"""View details of an access link"""
|
|
access_link = get_object_or_404(
|
|
AgencyAccessLink.objects.select_related(
|
|
"assignment__agency", "assignment__job"
|
|
),
|
|
slug=slug,
|
|
)
|
|
|
|
context = {
|
|
"access_link": access_link,
|
|
}
|
|
return render(request, "recruitment/agency_access_link_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_assignment_extend_deadline(request, slug):
|
|
"""Extend deadline for an agency assignment"""
|
|
assignment = get_object_or_404(AgencyJobAssignment, slug=slug)
|
|
|
|
if request.method == "POST":
|
|
new_deadline = request.POST.get("new_deadline")
|
|
if new_deadline:
|
|
try:
|
|
from datetime import datetime
|
|
|
|
new_deadline_dt = datetime.fromisoformat(
|
|
new_deadline.replace("Z", "+00:00")
|
|
)
|
|
# Ensure the new deadline is timezone-aware
|
|
if timezone.is_naive(new_deadline_dt):
|
|
new_deadline_dt = timezone.make_aware(new_deadline_dt)
|
|
|
|
if assignment.extend_deadline(new_deadline_dt):
|
|
messages.success(
|
|
request,
|
|
f"Deadline extended to {new_deadline_dt.strftime('%Y-%m-%d %H:%M')}!",
|
|
)
|
|
else:
|
|
messages.error(
|
|
request, "New deadline must be later than current deadline."
|
|
)
|
|
except ValueError:
|
|
messages.error(request, "Invalid date format.")
|
|
else:
|
|
messages.error(request, "Please provide a new deadline.")
|
|
|
|
return redirect("agency_assignment_detail", slug=assignment.slug)
|
|
|
|
|
|
@require_POST
|
|
def portal_password_reset(request, pk):
|
|
user = get_object_or_404(User, pk=pk)
|
|
if request.method == "POST":
|
|
form = PasswordResetForm(request.POST)
|
|
if form.is_valid():
|
|
# Verify old password
|
|
old_password = form.cleaned_data["old_password"]
|
|
if not user.check_password(old_password):
|
|
messages.error(request, "Old password is incorrect.")
|
|
return redirect("user_detail", pk=user.pk)
|
|
|
|
# Set new password
|
|
user.set_password(form.cleaned_data["new_password1"])
|
|
user.save()
|
|
messages.success(request, "Password reset successfully.")
|
|
return redirect("user_detail", pk=user.pk)
|
|
else:
|
|
for field, errors in form.errors.items():
|
|
for error in errors:
|
|
messages.error(request, f"{field}: {error}")
|
|
|
|
@require_POST
|
|
def password_reset(request, slug):
|
|
from .forms import PersonPasswordResetForm
|
|
person = get_object_or_404(Person, slug=slug)
|
|
if request.method == "POST":
|
|
form = PersonPasswordResetForm(request.POST)
|
|
if form.is_valid():
|
|
person.user.set_password(form.cleaned_data["new_password1"])
|
|
person.user.save()
|
|
messages.success(request, "Password reset successfully.")
|
|
return redirect("person_detail", slug=person.slug)
|
|
else:
|
|
for field, errors in form.errors.items():
|
|
for error in errors:
|
|
messages.error(request, f"{field}: {error}")
|
|
|
|
|
|
def portal_login(request):
|
|
"""Unified portal login for agency and applicant"""
|
|
if request.user.is_authenticated:
|
|
if request.user.user_type == "agency":
|
|
return redirect("agency_portal_dashboard")
|
|
if request.user.user_type == "candidate":
|
|
print(request.user)
|
|
return redirect("applicant_portal_dashboard")
|
|
|
|
if request.method == "POST":
|
|
form = PortalLoginForm(request.POST)
|
|
|
|
if form.is_valid():
|
|
email = form.cleaned_data["email"]
|
|
password = form.cleaned_data["password"]
|
|
user_type = form.cleaned_data["user_type"]
|
|
|
|
user = authenticate(request, username=email, password=password)
|
|
if user is not None:
|
|
if hasattr(user, "user_type") and user.user_type == user_type:
|
|
login(request, user)
|
|
return redirect("agency_portal_dashboard")
|
|
else:
|
|
messages.error(request, "Invalid user type selected.")
|
|
else:
|
|
messages.error(request, "Invalid email or password.")
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = PortalLoginForm()
|
|
|
|
context = {
|
|
"form": form,
|
|
}
|
|
return render(request, "recruitment/portal_login.html", context)
|
|
|
|
|
|
@login_required
|
|
@candidate_user_required
|
|
def applicant_portal_dashboard(request):
|
|
"""applicant portal dashboard"""
|
|
if not request.user.is_authenticated:
|
|
return redirect("account_login")
|
|
|
|
# Get candidate profile (Person record)
|
|
try:
|
|
applicant = request.user.person_profile
|
|
except:
|
|
messages.error(request, "No candidate profile found.")
|
|
return redirect("account_login")
|
|
|
|
# Get candidate's applications with related job data
|
|
applications = (
|
|
Application.objects.filter(person=applicant)
|
|
.select_related("job")
|
|
.order_by("-created_at")
|
|
)
|
|
|
|
# Get candidate's documents using the Person documents property
|
|
documents = applicant.documents.order_by("-created_at")
|
|
|
|
print(documents)
|
|
|
|
# Add password change form for modal
|
|
password_form = PasswordResetForm()
|
|
|
|
# Add document upload form for modal
|
|
from .forms import DocumentUploadForm
|
|
|
|
document_form = DocumentUploadForm()
|
|
|
|
context = {
|
|
"applicant": applicant,
|
|
"applications": applications,
|
|
"documents": documents,
|
|
"password_form": password_form,
|
|
"document_form": document_form,
|
|
}
|
|
return render(request, "recruitment/applicant_profile.html", context)
|
|
|
|
|
|
@login_required
|
|
@candidate_user_required
|
|
def applicant_application_detail(request, slug):
|
|
"""View detailed information about a specific application"""
|
|
if not request.user.is_authenticated:
|
|
return redirect("account_login")
|
|
|
|
# Get candidate profile (Person record)
|
|
agency = getattr(request.user, "agency_profile", None)
|
|
if agency:
|
|
candidate = get_object_or_404(Application, slug=slug)
|
|
# if Application.objects.filter(person=candidate,hirin).exists()
|
|
else:
|
|
try:
|
|
candidate = request.user.person_profile
|
|
except:
|
|
messages.error(request, "No candidate profile found.")
|
|
return redirect("account_login")
|
|
|
|
# Get the specific application and verify it belongs to this candidate
|
|
application = get_object_or_404(
|
|
Application.objects.select_related("job", "person").prefetch_related(
|
|
"scheduled_interviews" # Only prefetch interviews, not documents (Generic FK)
|
|
),
|
|
slug=slug,
|
|
person=candidate.person if agency else candidate,
|
|
)
|
|
|
|
# Get AI analysis data if available
|
|
ai_analysis = None
|
|
if application.ai_analysis_data:
|
|
try:
|
|
ai_analysis = application.ai_analysis_data.get("analysis_data_en", {})
|
|
except (AttributeError, KeyError):
|
|
ai_analysis = {}
|
|
|
|
# Get interview details
|
|
interviews = application.scheduled_interviews.all().order_by("-created_at")
|
|
|
|
# Get documents
|
|
documents = application.documents.all().order_by("-created_at")
|
|
|
|
context = {
|
|
"application": application,
|
|
"candidate": candidate,
|
|
"ai_analysis": ai_analysis,
|
|
"interviews": interviews,
|
|
"documents": documents,
|
|
}
|
|
return render(request, "recruitment/applicant_application_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
@agency_user_required
|
|
def agency_portal_persons_list(request):
|
|
"""Agency portal page showing all persons who come through this agency"""
|
|
try:
|
|
agency = request.user.agency_profile
|
|
except Exception as e:
|
|
print(e)
|
|
messages.error(request, "No agency profile found.")
|
|
return redirect("account_login")
|
|
|
|
persons = Person.objects.filter(agency=agency)
|
|
search_query = request.GET.get("q", "")
|
|
if search_query:
|
|
persons = persons.filter(
|
|
Q(first_name=search_query)
|
|
| Q(last_name__icontains=search_query)
|
|
| Q(email__icontains=search_query)
|
|
| Q(phone=search_query)
|
|
)
|
|
|
|
paginator = Paginator(persons, 20) # Show 20 persons per page
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
stage_choices = Application.Stage.choices
|
|
person_form = PersonForm()
|
|
person_form.initial["agency"] = agency
|
|
|
|
context = {
|
|
"agency": agency,
|
|
"page_obj": page_obj,
|
|
"search_query": search_query,
|
|
"stage_choices": stage_choices,
|
|
"total_persons": persons.count(),
|
|
"person_form": person_form,
|
|
}
|
|
return render(request, "recruitment/agency_portal_persons_list.html", context)
|
|
|
|
|
|
@login_required
|
|
@agency_user_required
|
|
def agency_portal_dashboard(request):
|
|
"""Agency portal dashboard showing all assignments for the agency"""
|
|
# Get the current assignment to determine the agency
|
|
try:
|
|
agency = request.user.agency_profile
|
|
except Exception as e:
|
|
print(e)
|
|
messages.error(request, "No agency profile found.")
|
|
return redirect("portal_login")
|
|
|
|
# Get ALL assignments for this agency
|
|
assignments = (
|
|
AgencyJobAssignment.objects.filter(agency=agency)
|
|
.select_related("job")
|
|
.order_by("-created_at")
|
|
)
|
|
current_assignment = assignments.filter(is_active=True).first()
|
|
|
|
# Calculate statistics for each assignment
|
|
assignment_stats = []
|
|
for assignment in assignments:
|
|
applications = Application.objects.filter(
|
|
hiring_agency=agency, job=assignment.job
|
|
).order_by("-created_at")
|
|
|
|
unread_messages = Message.objects.filter(
|
|
job=assignment.job, recipient=agency.user, is_read=False
|
|
).count()
|
|
|
|
assignment_stats.append(
|
|
{
|
|
"assignment": assignment,
|
|
"applications": applications,
|
|
"application_count": applications.count(),
|
|
"unread_messages": unread_messages,
|
|
"days_remaining": assignment.days_remaining,
|
|
"is_active": assignment.is_currently_active,
|
|
"can_submit": assignment.can_submit,
|
|
}
|
|
)
|
|
|
|
# Get overall statistics
|
|
total_applications = sum(stats["application_count"] for stats in assignment_stats)
|
|
total_unread_messages = sum(stats["unread_messages"] for stats in assignment_stats)
|
|
active_assignments = sum(1 for stats in assignment_stats if stats["is_active"])
|
|
|
|
context = {
|
|
"agency": agency,
|
|
"current_assignment": current_assignment,
|
|
"assignment_stats": assignment_stats,
|
|
"total_assignments": assignments.count(),
|
|
"active_assignments": active_assignments,
|
|
"total_applications": total_applications,
|
|
"total_unread_messages": total_unread_messages,
|
|
}
|
|
return render(request, "recruitment/agency_portal_dashboard.html", context)
|
|
|
|
|
|
@login_required
|
|
@agency_user_required
|
|
def agency_portal_submit_application_page(request, slug):
|
|
"""Dedicated page for submitting a application"""
|
|
assignment = get_object_or_404(
|
|
AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug
|
|
)
|
|
current_agency = assignment.agency
|
|
current_job = assignment.job
|
|
|
|
if assignment.is_full:
|
|
messages.error(
|
|
request, "Maximum Application limit reached for this assignment."
|
|
)
|
|
return redirect("agency_portal_assignment_detail", slug=assignment.slug)
|
|
# Verify this assignment belongs to the same agency as the logged-in session
|
|
if assignment.agency.id != assignment.agency.id:
|
|
messages.error(
|
|
request, "Access denied: This assignment does not belong to your agency."
|
|
)
|
|
return redirect("agency_portal_dashboard")
|
|
|
|
# Check if assignment allows submission
|
|
if not assignment.can_submit:
|
|
messages.error(
|
|
request,
|
|
"Cannot submit applications: Assignment is not active, expired, or full.",
|
|
)
|
|
return redirect("agency_portal_assignment_detail", slug=assignment.slug)
|
|
|
|
# Get total submitted applications for this assignment
|
|
total_submitted = Application.objects.filter(
|
|
hiring_agency=assignment.agency, job=assignment.job
|
|
).count()
|
|
|
|
form = ApplicationForm(current_agency=current_agency, current_job=current_job)
|
|
if request.method == "POST":
|
|
form = ApplicationForm(
|
|
request.POST,
|
|
request.FILES,
|
|
current_agency=current_agency,
|
|
current_job=current_job,
|
|
)
|
|
if form.is_valid():
|
|
candidate = form.save(commit=False)
|
|
|
|
candidate.hiring_source = "AGENCY"
|
|
candidate.hiring_agency = assignment.agency
|
|
candidate.save()
|
|
assignment.increment_submission_count()
|
|
return redirect("agency_portal_dashboard")
|
|
|
|
form.fields["hiring_agency"].initial = assignment.agency.id
|
|
form.fields["hiring_source"].initial = "Agency"
|
|
|
|
form.fields["hiring_agency"].widget = HiddenInput()
|
|
form.fields["hiring_source"].widget = HiddenInput()
|
|
|
|
context = {
|
|
"form": form,
|
|
"assignment": assignment,
|
|
"total_submitted": total_submitted,
|
|
"job": assignment.job,
|
|
}
|
|
return render(request, "recruitment/agency_portal_submit_application.html", context)
|
|
|
|
|
|
@login_required
|
|
@agency_user_required
|
|
def agency_portal_submit_application(request):
|
|
"""Handle candidate submission via AJAX (for embedded form)"""
|
|
assignment_id = request.session.get("agency_assignment_id")
|
|
if not assignment_id:
|
|
return redirect("agency_portal_login")
|
|
|
|
assignment = get_object_or_404(
|
|
AgencyJobAssignment.objects.select_related("agency", "job"), id=assignment_id
|
|
)
|
|
if assignment.is_full:
|
|
messages.error(request, "Maximum candidate limit reached for this assignment.")
|
|
return redirect("agency_portal_assignment_detail", slug=assignment.slug)
|
|
|
|
# Check if assignment allows submission
|
|
if not assignment.can_submit:
|
|
messages.error(
|
|
request,
|
|
"Cannot submit candidates: Assignment is not active, expired, or full.",
|
|
)
|
|
return redirect("agency_portal_dashboard")
|
|
|
|
if request.method == "POST":
|
|
form = AgencyApplicationSubmissionForm(assignment, request.POST, request.FILES)
|
|
if form.is_valid():
|
|
candidate = form.save(commit=False)
|
|
candidate.hiring_source = "AGENCY"
|
|
candidate.hiring_agency = assignment.agency
|
|
candidate.save()
|
|
|
|
# Increment the assignment's submitted count
|
|
assignment.increment_submission_count()
|
|
|
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
|
return JsonResponse(
|
|
{
|
|
"success": True,
|
|
"message": f"Candidate {candidate.name} submitted successfully!",
|
|
}
|
|
)
|
|
else:
|
|
messages.success(
|
|
request, f"Candidate {candidate.name} submitted successfully!"
|
|
)
|
|
return redirect("agency_portal_dashboard")
|
|
else:
|
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
|
return JsonResponse(
|
|
{"success": False, "message": "Please correct the errors below."}
|
|
)
|
|
else:
|
|
messages.error(request, "Please correct errors below.")
|
|
else:
|
|
form = AgencyApplicationSubmissionForm(assignment)
|
|
|
|
context = {
|
|
"form": form,
|
|
"assignment": assignment,
|
|
"title": f"Submit Candidate for {assignment.job.title}",
|
|
"button_text": "Submit Candidate",
|
|
}
|
|
return render(request, "recruitment/agency_portal_submit_application.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_or_agency_required
|
|
def agency_portal_assignment_detail(request, slug):
|
|
"""View details of a specific assignment - routes to admin or agency template"""
|
|
assignment = get_object_or_404(
|
|
AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug
|
|
)
|
|
|
|
# Check if user is authenticated and determine user type
|
|
if request.user.is_authenticated:
|
|
# Check if user has agency profile (agency user)
|
|
if hasattr(request.user, "agency_profile") and request.user.agency_profile:
|
|
# Agency Portal User - Route to agency-specific template
|
|
return agency_assignment_detail_agency(request, slug, assignment.id)
|
|
else:
|
|
# Admin User - Route to admin template
|
|
return agency_assignment_detail_admin(request, slug)
|
|
else:
|
|
# Not authenticated - redirect to login
|
|
return redirect("portal_login")
|
|
|
|
|
|
@login_required
|
|
@agency_user_required
|
|
def agency_assignment_detail_agency(request, slug, assignment_id):
|
|
"""Handle agency portal assignment detail view"""
|
|
# Get the assignment by slug and verify it belongs to same agency
|
|
assignment = get_object_or_404(
|
|
AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug
|
|
)
|
|
|
|
# Verify this assignment belongs to the same agency as the logged-in session
|
|
current_assignment = get_object_or_404(
|
|
AgencyJobAssignment.objects.select_related("agency"), id=assignment_id
|
|
)
|
|
|
|
if assignment.agency.id != current_assignment.agency.id:
|
|
messages.error(
|
|
request, "Access denied: This assignment does not belong to your agency."
|
|
)
|
|
return redirect("agency_portal_dashboard")
|
|
|
|
# Get candidates submitted by this agency for this job
|
|
applications = Application.objects.filter(
|
|
hiring_agency=assignment.agency, job=assignment.job
|
|
).order_by("-created_at")
|
|
|
|
messages = []
|
|
paginator = Paginator(applications, 20) # Show 20 candidates per page
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
message_paginator = Paginator(messages, 15) # Show 15 messages per page
|
|
message_page_number = request.GET.get("message_page")
|
|
message_page_obj = message_paginator.get_page(message_page_number)
|
|
|
|
total_applications = applications.count()
|
|
max_applications = assignment.max_candidates
|
|
circumference = 326.73 # 2 * π * r where r=52
|
|
|
|
if max_applications > 0:
|
|
progress_percentage = total_applications / max_applications
|
|
stroke_dashoffset = circumference - (circumference * progress_percentage)
|
|
else:
|
|
stroke_dashoffset = circumference
|
|
|
|
context = {
|
|
"assignment": assignment,
|
|
"page_obj": page_obj,
|
|
"message_page_obj": message_page_obj,
|
|
"total_applications": total_applications,
|
|
"stroke_dashoffset": stroke_dashoffset,
|
|
"max_applications": max_applications,
|
|
}
|
|
return render(request, "recruitment/agency_portal_assignment_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_assignment_detail_admin(request, slug):
|
|
"""Handle admin assignment detail view"""
|
|
assignment = get_object_or_404(
|
|
AgencyJobAssignment.objects.select_related("agency", "job"), slug=slug
|
|
)
|
|
|
|
# Get candidates submitted by this agency for this job
|
|
candidates = Application.objects.filter(
|
|
hiring_agency=assignment.agency, job=assignment.job
|
|
).order_by("-created_at")
|
|
|
|
# Get access link if exists
|
|
access_link = getattr(assignment, "access_link", None)
|
|
|
|
# Get messages for this assignment
|
|
messages = []
|
|
|
|
context = {
|
|
"assignment": assignment,
|
|
"candidates": candidates,
|
|
"access_link": access_link,
|
|
"total_candidates": candidates.count(),
|
|
}
|
|
return render(request, "recruitment/agency_assignment_detail.html", context)
|
|
|
|
|
|
# will check the changes application to appliaction in this function
|
|
@login_required
|
|
@agency_user_required
|
|
def agency_portal_edit_application(request, candidate_id):
|
|
"""Edit a candidate for agency portal"""
|
|
assignment_id = request.session.get("agency_assignment_id")
|
|
if not assignment_id:
|
|
return redirect("agency_portal_login")
|
|
|
|
# Get current assignment to determine agency
|
|
current_assignment = get_object_or_404(
|
|
AgencyJobAssignment.objects.select_related("agency"), id=assignment_id
|
|
)
|
|
|
|
agency = current_assignment.agency
|
|
|
|
# Get candidate and verify it belongs to this agency
|
|
candidate = get_object_or_404(Application, id=candidate_id, hiring_agency=agency)
|
|
|
|
if request.method == "POST":
|
|
# Handle form submission
|
|
candidate.first_name = request.POST.get("first_name", candidate.first_name)
|
|
candidate.last_name = request.POST.get("last_name", candidate.last_name)
|
|
candidate.email = request.POST.get("email", candidate.email)
|
|
candidate.phone = request.POST.get("phone", candidate.phone)
|
|
candidate.address = request.POST.get("address", candidate.address)
|
|
|
|
# Handle resume upload if provided
|
|
if "resume" in request.FILES:
|
|
candidate.resume = request.FILES["resume"]
|
|
|
|
try:
|
|
candidate.save()
|
|
messages.success(
|
|
request, f"Candidate {candidate.name} updated successfully!"
|
|
)
|
|
return redirect(
|
|
"agency_assignment_detail",
|
|
slug=candidate.job.agencyjobassignment_set.first().slug,
|
|
)
|
|
except Exception as e:
|
|
messages.error(request, f"Error updating candidate: {e}")
|
|
|
|
# For GET requests or POST errors, return JSON response for AJAX
|
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
|
return JsonResponse(
|
|
{
|
|
"success": True,
|
|
"candidate": {
|
|
"id": candidate.id,
|
|
"first_name": candidate.first_name,
|
|
"last_name": candidate.last_name,
|
|
"email": candidate.email,
|
|
"phone": candidate.phone,
|
|
"address": candidate.address,
|
|
},
|
|
}
|
|
)
|
|
|
|
# Fallback for non-AJAX requests
|
|
return redirect("agency_portal_dashboard")
|
|
|
|
|
|
@login_required
|
|
@agency_user_required
|
|
def agency_portal_delete_application(request, candidate_id):
|
|
"""Delete a candidate for agency portal"""
|
|
assignment_id = request.session.get("agency_assignment_id")
|
|
if not assignment_id:
|
|
return redirect("agency_portal_login")
|
|
|
|
# Get current assignment to determine agency
|
|
current_assignment = get_object_or_404(
|
|
AgencyJobAssignment.objects.select_related("agency"), id=assignment_id
|
|
)
|
|
|
|
agency = current_assignment.agency
|
|
|
|
# Get candidate and verify it belongs to this agency
|
|
candidate = get_object_or_404(Application, id=candidate_id, hiring_agency=agency)
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
candidate_name = candidate.name
|
|
candidate.delete()
|
|
|
|
current_assignment.candidates_submitted -= 1
|
|
current_assignment.status = current_assignment.AssignmentStatus.ACTIVE
|
|
current_assignment.save(update_fields=["candidates_submitted", "status"])
|
|
|
|
messages.success(
|
|
request, f"Candidate {candidate_name} removed successfully!"
|
|
)
|
|
return JsonResponse({"success": True})
|
|
except Exception as e:
|
|
return JsonResponse({"success": False, "error": str(e)})
|
|
|
|
# For GET requests, return error
|
|
return JsonResponse({"success": False, "error": "Method not allowed"})
|
|
|
|
|
|
# Message Views
|
|
@login_required
|
|
def message_list(request):
|
|
"""List all messages for the current user"""
|
|
# Get filter parameters
|
|
status_filter = request.GET.get("status", "")
|
|
message_type_filter = request.GET.get("type", "")
|
|
search_query = request.GET.get("q", "")
|
|
job_filter = request.GET.get("job_filter", "")
|
|
|
|
# Base queryset - get messages where user is either sender or recipient
|
|
message_list = (
|
|
Message.objects.filter(Q(sender=request.user) | Q(recipient=request.user))
|
|
.select_related("sender", "recipient", "job")
|
|
.order_by("-created_at")
|
|
)
|
|
|
|
jobs = JobPosting.objects.all()
|
|
|
|
# Apply filters
|
|
if status_filter:
|
|
if status_filter == "read":
|
|
message_list = message_list.filter(is_read=True)
|
|
elif status_filter == "unread":
|
|
message_list = message_list.filter(is_read=False)
|
|
if message_type_filter:
|
|
message_list = message_list.filter(message_type=message_type_filter)
|
|
|
|
if request.user.user_type == "staff" and job_filter:
|
|
job = get_object_or_404(JobPosting, pk=job_filter)
|
|
message_list = message_list.filter(job=job)
|
|
if search_query:
|
|
message_list = message_list.filter(
|
|
Q(subject__icontains=search_query) | Q(content__icontains=search_query)
|
|
)
|
|
|
|
# Pagination
|
|
paginator = Paginator(message_list, 20) # Show 20 messages per page
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
# Statistics
|
|
total_messages = message_list.count()
|
|
unread_messages = message_list.filter(is_read=False).count()
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"total_messages": total_messages,
|
|
"unread_messages": unread_messages,
|
|
"status_filter": status_filter,
|
|
"type_filter": message_type_filter,
|
|
"search_query": search_query,
|
|
"job_filter": job_filter,
|
|
"jobs": jobs,
|
|
}
|
|
if request.user.user_type != "staff":
|
|
return render(request, "messages/application_message_list.html", context)
|
|
return render(request, "messages/message_list.html", context)
|
|
|
|
|
|
@login_required
|
|
def message_detail(request, message_id):
|
|
"""View details of a specific message"""
|
|
message = get_object_or_404(
|
|
Message.objects.select_related("sender", "recipient", "job"), id=message_id
|
|
)
|
|
|
|
# Check if user has permission to view this message
|
|
if message.sender != request.user and message.recipient != request.user:
|
|
messages.error(request, "You don't have permission to view this message.")
|
|
return redirect("message_list")
|
|
|
|
# Mark as read if it was unread and user is the recipient
|
|
if not message.is_read and message.recipient == request.user:
|
|
message.is_read = True
|
|
message.read_at = timezone.now()
|
|
message.save(update_fields=["is_read", "read_at"])
|
|
|
|
context = {
|
|
"message": message,
|
|
}
|
|
if request.user.user_type != "staff":
|
|
return render(request, "messages/application_message_detail.html", context)
|
|
return render(request, "messages/message_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
def message_create(request):
|
|
"""Create a new message"""
|
|
from django.conf import settings
|
|
from django_q.tasks import async_task
|
|
if request.method == "POST":
|
|
form = MessageForm(request.user, request.POST)
|
|
|
|
if form.is_valid():
|
|
message = form.save(commit=False)
|
|
message.sender = request.user
|
|
message.save()
|
|
# Send email if message_type is 'email' and recipient has email
|
|
|
|
if message.recipient and message.recipient.email:
|
|
if request.user.user_type != "staff":
|
|
body = message.content
|
|
else:
|
|
body = (
|
|
message.content
|
|
+ f"\n\n Sent by: {request.user.get_full_name()} ({request.user.email})"
|
|
)
|
|
try:
|
|
# Use new unified email service for background processing
|
|
# from .services.email_service import UnifiedEmailService
|
|
# from .dto.email_dto import EmailConfig, EmailPriority
|
|
|
|
|
|
|
|
email_addresses = [message.recipient.email]
|
|
subject=message.subject
|
|
|
|
email_result=async_task(
|
|
"recruitment.tasks.send_email_task",
|
|
email_addresses,
|
|
subject,
|
|
# message,
|
|
"emails/email_template.html",
|
|
{
|
|
"email_message": body,
|
|
"logo_url": settings.STATIC_URL + "image/kaauh.png",
|
|
"message_created":True
|
|
},
|
|
)
|
|
# Send email using unified service
|
|
|
|
if email_result:
|
|
messages.success(
|
|
request, "Message sent successfully via email!"
|
|
)
|
|
else:
|
|
messages.warning(
|
|
request,
|
|
f"email failed: {email_result.get('message', 'Unknown error')}",
|
|
)
|
|
|
|
except Exception as e:
|
|
messages.warning(
|
|
request, f"Message saved but email sending failed: {str(e)}"
|
|
)
|
|
else:
|
|
messages.success(request, "Message sent successfully!")
|
|
|
|
return redirect("message_list")
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = MessageForm(request.user)
|
|
|
|
form.fields["job"].widget.attrs.update(
|
|
{
|
|
"hx-get": "/en/messages/create/",
|
|
"hx-target": "#id_recipient",
|
|
"hx-select": "#id_recipient",
|
|
"hx-swap": "outerHTML",
|
|
}
|
|
)
|
|
if request.user.user_type == "staff":
|
|
job_id = request.GET.get("job")
|
|
if job_id:
|
|
job = get_object_or_404(JobPosting, id=job_id)
|
|
applications = job.applications.all()
|
|
applicant_users = User.objects.filter(
|
|
person_profile__in=applications.values_list("person", flat=True)
|
|
)
|
|
agency_users = User.objects.filter(
|
|
id__in=AgencyJobAssignment.objects.filter(job=job).values_list(
|
|
"agency__user", flat=True
|
|
)
|
|
)
|
|
form.fields["recipient"].queryset = applicant_users | agency_users
|
|
|
|
# form.fields["recipient"].queryset = User.objects.filter(person_profile__)
|
|
else:
|
|
form.fields["recipient"].widget = HiddenInput()
|
|
if (
|
|
request.method == "GET"
|
|
and "HX-Request" in request.headers
|
|
and request.user.user_type in ["candidate", "agency"]
|
|
):
|
|
|
|
job_id = request.GET.get("job")
|
|
if job_id:
|
|
job = get_object_or_404(JobPosting, id=job_id)
|
|
form.fields["recipient"].queryset = User.objects.filter(
|
|
id=job.assigned_to.id
|
|
)
|
|
form.fields["recipient"].initial = job.assigned_to
|
|
|
|
context = {
|
|
"form": form,
|
|
}
|
|
if request.user.user_type != "staff":
|
|
return render(request, "messages/application_message_form.html", context)
|
|
return render(request, "messages/message_form.html", context)
|
|
|
|
|
|
@login_required
|
|
def message_reply(request, message_id):
|
|
"""Reply to a message"""
|
|
parent_message = get_object_or_404(
|
|
Message.objects.select_related("sender", "recipient", "job"), id=message_id
|
|
)
|
|
|
|
# Check if user has permission to reply to this message
|
|
if (
|
|
parent_message.recipient != request.user
|
|
and parent_message.sender != request.user
|
|
):
|
|
messages.error(request, "You don't have permission to reply to this message.")
|
|
return redirect("message_list")
|
|
|
|
if request.method == "POST":
|
|
form = MessageForm(request.user, request.POST)
|
|
if form.is_valid():
|
|
print(form.cleaned_data)
|
|
message = form.save(commit=False)
|
|
message.sender = request.user
|
|
message.save()
|
|
if message.recipient and message.recipient.email:
|
|
try:
|
|
email_result = async_task(
|
|
"recruitment.tasks._task_send_individual_email",
|
|
subject=message.subject,
|
|
body_message=message.content,
|
|
recipient=message.recipient.email,
|
|
attachments=None,
|
|
sender=False,
|
|
job=False,
|
|
)
|
|
if email_result:
|
|
messages.success(
|
|
request, "Message sent successfully via email!"
|
|
)
|
|
else:
|
|
messages.warning(
|
|
request,
|
|
f"email failed: {email_result.get('message', 'Unknown error')}",
|
|
)
|
|
|
|
except Exception as e:
|
|
messages.warning(
|
|
request, f"Reply saved but email sending failed: {str(e)}"
|
|
)
|
|
else:
|
|
messages.success(request, "Reply sent successfully!")
|
|
|
|
return redirect("message_detail", message_id=parent_message.id)
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
# Pre-fill form with reply context
|
|
form = MessageForm(request.user)
|
|
form.initial["subject"] = f"Re: {parent_message.subject}"
|
|
form.initial["recipient"] = parent_message.sender
|
|
if parent_message.job:
|
|
form.fields["job"].queryset = JobPosting.objects.all()
|
|
form.initial["job"] = parent_message.job
|
|
form.initial["message_type"] = Message.MessageType.JOB_RELATED
|
|
|
|
context = {
|
|
"form": form,
|
|
"parent_message": parent_message,
|
|
}
|
|
if request.user.user_type != "staff":
|
|
return render(request, "messages/application_message_form.html", context)
|
|
return render(request, "messages/message_form.html", context)
|
|
|
|
|
|
@login_required
|
|
def message_mark_read(request, message_id):
|
|
"""Mark a message as read"""
|
|
message = get_object_or_404(
|
|
Message.objects.select_related("sender", "recipient"), id=message_id
|
|
)
|
|
|
|
if message.recipient != request.user:
|
|
messages.error(request, "You can only mark messages you received as read.")
|
|
return redirect("message_list")
|
|
|
|
message.is_read = True
|
|
message.read_at = timezone.now()
|
|
message.save(update_fields=["is_read", "read_at"])
|
|
messages.success(request, "Message marked as read.")
|
|
|
|
if "HX-Request" in request.headers:
|
|
return HttpResponse(status=200)
|
|
|
|
return redirect("message_list")
|
|
|
|
|
|
@login_required
|
|
def message_mark_unread(request, message_id):
|
|
"""Mark a message as unread"""
|
|
message = get_object_or_404(
|
|
Message.objects.select_related("sender", "recipient"), id=message_id
|
|
)
|
|
if message.recipient != request.user:
|
|
messages.error(request, "You can only mark messages you received as unread.")
|
|
return redirect("message_list")
|
|
message.is_read = False
|
|
message.read_at = None
|
|
message.save(update_fields=["is_read", "read_at"])
|
|
|
|
messages.success(request, "Message marked as unread.")
|
|
|
|
if "HX-Request" in request.headers:
|
|
return HttpResponse(status=200)
|
|
|
|
return redirect("message_list")
|
|
|
|
|
|
@login_required
|
|
def message_delete(request, message_id):
|
|
"""
|
|
Deletes a message using a POST request, primarily designed for HTMX.
|
|
Redirects to the message list on success (either via standard redirect
|
|
or HTMX's hx-redirect header).
|
|
"""
|
|
message = get_object_or_404(
|
|
Message.objects.select_related("sender", "recipient"), id=message_id
|
|
)
|
|
|
|
if message.sender != request.user and message.recipient != request.user:
|
|
messages.error(request, "You don't have permission to delete this message.")
|
|
if "HX-Request" in request.headers:
|
|
return HttpResponse(status=403)
|
|
return redirect("message_list")
|
|
|
|
if request.method == "POST":
|
|
message.delete()
|
|
messages.success(request, "Message deleted successfully.")
|
|
if "HX-Request" in request.headers:
|
|
response = HttpResponse(status=200)
|
|
response["HX-Redirect"] = reverse("message_list")
|
|
return response
|
|
return redirect("message_list")
|
|
|
|
|
|
@login_required
|
|
def api_unread_count(request):
|
|
"""API endpoint to get unread message count"""
|
|
unread_count = Message.objects.filter(recipient=request.user, is_read=False).count()
|
|
|
|
return JsonResponse({"unread_count": unread_count})
|
|
|
|
|
|
# Document Views
|
|
@login_required
|
|
def document_upload(request, slug):
|
|
"""Upload a document for an application or person"""
|
|
application = Application.objects.filter(slug=slug).first()
|
|
person = Person.objects.filter(slug=slug).first()
|
|
|
|
if not any([application, person]):
|
|
messages.error(request, "not found.")
|
|
return redirect("dashboard")
|
|
if request.method == "POST":
|
|
if request.FILES.get("file"):
|
|
document = Document.objects.create(
|
|
content_object=application if application else person,
|
|
file=request.FILES["file"],
|
|
document_type=request.POST.get("document_type", "other"),
|
|
description=request.POST.get("description", ""),
|
|
uploaded_by=request.user,
|
|
)
|
|
|
|
messages.success(
|
|
request,
|
|
f'Document "{document.get_document_type_display()}" uploaded successfully!',
|
|
)
|
|
|
|
response = HttpResponse(status=204)
|
|
response["HX-Refresh"] = "true" # Instruct HTMX to refresh the current view
|
|
return response
|
|
|
|
|
|
@login_required
|
|
def document_delete(request, document_id):
|
|
"""Delete a document"""
|
|
document = get_object_or_404(Document, id=document_id)
|
|
|
|
is_htmx = "HX-Request" in request.headers
|
|
|
|
has_permission = False
|
|
|
|
content_object = document.content_object
|
|
|
|
if hasattr(content_object, "job"):
|
|
if (
|
|
content_object.job.assigned_to == request.user
|
|
) or request.user.is_superuser:
|
|
has_permission = True
|
|
elif (
|
|
request.user.user_type == "candidate"
|
|
and content_object.person.user == request.user
|
|
):
|
|
has_permission = True
|
|
if request.user.user_type == "candidate":
|
|
redirect_view_name = "applicant_portal_dashboard"
|
|
else:
|
|
redirect_view_name = "job_detail"
|
|
redirect_args = [content_object.job.slug] # Pass the job slug
|
|
elif hasattr(content_object, "user"):
|
|
if (
|
|
request.user.user_type == "candidate"
|
|
and content_object.user == request.user
|
|
):
|
|
has_permission = True
|
|
redirect_view_name = "applicant_portal_dashboard"
|
|
elif request.user.is_staff or request.user.is_superuser:
|
|
has_permission = True
|
|
redirect_view_name = "dashboard"
|
|
else:
|
|
has_permission = (
|
|
request.user.is_superuser
|
|
) # Only superuser can delete unlinked docs
|
|
if not has_permission:
|
|
messages.error(request, "Permission denied: You cannot delete this document.")
|
|
return HttpResponse(status=403)
|
|
|
|
if request.method == "POST":
|
|
file_name = document.file.name if document.file else "Unknown"
|
|
document.delete()
|
|
messages.success(request, f'Document "{file_name}" deleted successfully!')
|
|
|
|
# --- HTMX / AJAX Response ---
|
|
if is_htmx or request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
|
response = HttpResponse(status=204)
|
|
response["HX-Refresh"] = "true"
|
|
return response
|
|
else:
|
|
try:
|
|
if "redirect_args" in locals():
|
|
return redirect(redirect_view_name, *redirect_args)
|
|
else:
|
|
return redirect(redirect_view_name)
|
|
except NameError:
|
|
return redirect("dashboard")
|
|
|
|
return HttpResponse(status=405)
|
|
|
|
|
|
@login_required
|
|
def document_download(request, document_id):
|
|
"""Download a document"""
|
|
document = get_object_or_404(Document, id=document_id)
|
|
if hasattr(document.content_object, "job"):
|
|
if (
|
|
document.content_object.job.assigned_to != request.user
|
|
and not request.user.is_superuser
|
|
):
|
|
messages.error(
|
|
request, "You don't have permission to download this document."
|
|
)
|
|
return JsonResponse({"success": False, "error": "Permission denied"})
|
|
job_slug = document.content_object.job.slug
|
|
redirect_url = (
|
|
"application_detail"
|
|
if request.user.user_type == "candidate"
|
|
else "job_detail"
|
|
)
|
|
elif hasattr(document.content_object, "person"):
|
|
# Person document
|
|
if request.user.user_type == "candidate":
|
|
candidate = request.user.person_profile
|
|
if document.content_object != candidate:
|
|
messages.error(request, "You can only download your own documents.")
|
|
return JsonResponse({"success": False, "error": "Permission denied"})
|
|
redirect_url = "applicant_portal_dashboard"
|
|
else:
|
|
# Handle other content object types
|
|
messages.error(request, "You don't have permission to download this document.")
|
|
return JsonResponse({"success": False, "error": "Permission denied"})
|
|
|
|
if document.file:
|
|
response = HttpResponse(
|
|
document.file.read(), content_type="application/octet-stream"
|
|
)
|
|
response["Content-Disposition"] = f'attachment; filename="{document.file.name}"'
|
|
return response
|
|
|
|
return JsonResponse({"success": False, "error": "File not found"})
|
|
|
|
|
|
@login_required
|
|
def portal_logout(request):
|
|
"""Logout from portal"""
|
|
logout(request)
|
|
|
|
messages.success(request, "You have been logged out.")
|
|
return redirect("portal_login")
|
|
|
|
|
|
# Interview Creation Views
|
|
@login_required
|
|
@staff_user_required
|
|
def interview_create_type_selection(request, application_slug):
|
|
"""Show interview type selection page for a application"""
|
|
application = get_object_or_404(Application, slug=application_slug)
|
|
|
|
context = {
|
|
"application": application,
|
|
"job": application.job,
|
|
}
|
|
return render(request, "interviews/interview_create_type_selection.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def interview_create_remote(request, application_slug):
|
|
"""Create remote interview for a candidate"""
|
|
application = get_object_or_404(Application, slug=application_slug)
|
|
|
|
if request.method == "POST":
|
|
form = RemoteInterviewForm(request.POST)
|
|
if form.is_valid():
|
|
try:
|
|
with transaction.atomic():
|
|
schedule = ScheduledInterview.objects.create(
|
|
application=application,
|
|
job=application.job,
|
|
interview_date=form.cleaned_data["interview_date"],
|
|
interview_time=form.cleaned_data["interview_time"],
|
|
)
|
|
start_time = timezone.make_aware(
|
|
datetime.combine(
|
|
schedule.interview_date, schedule.interview_time
|
|
)
|
|
)
|
|
interview = Interview.objects.create(
|
|
topic=form.cleaned_data["topic"],
|
|
location_type="Remote",
|
|
start_time=start_time,
|
|
duration=form.cleaned_data["duration"],
|
|
)
|
|
schedule.interview = interview
|
|
schedule.save()
|
|
async_task(
|
|
"recruitment.tasks.create_interview_and_meeting", schedule.pk
|
|
)
|
|
|
|
messages.success(
|
|
request, f"Remote interview scheduled for {application.name}"
|
|
)
|
|
return redirect(
|
|
"applications_interview_view", slug=application.job.slug
|
|
)
|
|
|
|
except Exception as e:
|
|
messages.error(request, f"Error creating remote interview: {str(e)}")
|
|
form = RemoteInterviewForm()
|
|
form.initial["topic"] = (
|
|
f"Interview for {application.job.title} - {application.name}"
|
|
)
|
|
context = {
|
|
"application": application,
|
|
"job": application.job,
|
|
"form": form,
|
|
}
|
|
return render(request, "interviews/interview_create_remote.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def interview_create_onsite(request, application_slug):
|
|
"""Create onsite interview for a candidate"""
|
|
application = get_object_or_404(Application, slug=application_slug)
|
|
|
|
if request.method == "POST":
|
|
from .models import Interview
|
|
|
|
form = OnsiteInterviewForm(request.POST)
|
|
if form.is_valid():
|
|
try:
|
|
with transaction.atomic():
|
|
interview = Interview.objects.create(
|
|
topic=form.cleaned_data["topic"],
|
|
start_time=form.cleaned_data["interview_date"],
|
|
room_number=form.cleaned_data["room_number"],
|
|
physical_address=form.cleaned_data["physical_address"],
|
|
duration=form.cleaned_data["duration"],
|
|
location_type="Onsite",
|
|
status="SCHEDULED",
|
|
)
|
|
|
|
schedule = ScheduledInterview.objects.create(
|
|
application=application,
|
|
job=application.job,
|
|
interview=interview,
|
|
interview_date=form.cleaned_data["interview_date"],
|
|
interview_time=form.cleaned_data["interview_time"],
|
|
)
|
|
|
|
messages.success(
|
|
request, f"Onsite interview scheduled for {application.name}"
|
|
)
|
|
return redirect(
|
|
"applications_interview_view", slug=application.job.slug
|
|
)
|
|
|
|
except Exception as e:
|
|
messages.error(request, f"Error creating onsite interview: {str(e)}")
|
|
else:
|
|
# Pre-populate topic
|
|
form.initial["topic"] = (
|
|
f"Interview for {application.job.title} - {application.name}"
|
|
)
|
|
messages.error(request, "Please fix the highlighted errors below.")
|
|
|
|
|
|
form = OnsiteInterviewForm()
|
|
form.initial["topic"] = (
|
|
f"Interview for {application.job.title} - {application.name}"
|
|
)
|
|
context = {
|
|
"application": application,
|
|
"job": application.job,
|
|
"form": form,
|
|
}
|
|
return render(request, "interviews/interview_create_onsite.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def get_interview_list(request, job_slug):
|
|
from .forms import ScheduledInterviewUpdateStatusForm
|
|
|
|
application = Application.objects.get(slug=job_slug)
|
|
interviews = (
|
|
ScheduledInterview.objects.filter(application=application)
|
|
.order_by("interview_date", "interview_time")
|
|
.select_related("interview")
|
|
)
|
|
interview_status_form = ScheduledInterviewUpdateStatusForm()
|
|
return render(
|
|
request,
|
|
"interviews/partials/interview_list.html",
|
|
{
|
|
"interviews": interviews,
|
|
"application": application,
|
|
"interview_status_form": interview_status_form,
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
@require_POST
|
|
def update_interview_status(request, slug):
|
|
from .forms import ScheduledInterviewUpdateStatusForm
|
|
|
|
if request.method == "POST":
|
|
form = ScheduledInterviewUpdateStatusForm(request.POST)
|
|
if form.is_valid():
|
|
scheduled_interview = get_object_or_404(ScheduledInterview, slug=slug)
|
|
scheduled_interview.status = form.cleaned_data["status"]
|
|
scheduled_interview.save(update_fields=["status"])
|
|
messages.success(request, "Interview status updated successfully.")
|
|
return redirect("interview_detail", slug=slug)
|
|
|
|
|
|
# @require_POST
|
|
# def cancel_interview_for_application(request,slug):
|
|
# scheduled_interview = get_object_or_404(ScheduledInterview, slug=slug)
|
|
# if request.method == 'POST':
|
|
# if scheduled_interview.interview_type == 'REMOTE':
|
|
# result = delete_zoom_meeting(scheduled_interview.interview.meeting_id)
|
|
# if result["status"] != "success":
|
|
# messages.error(request, f"Error cancelling Zoom meeting: {result.get('message', 'Unknown error')}")
|
|
# return redirect('interview_detail', slug=slug)
|
|
|
|
|
|
# scheduled_interview.delete()
|
|
# messages.success(request, "Interview cancelled successfully.")
|
|
# return redirect('interview_list')
|
|
@require_POST
|
|
@login_required # Assuming this should be protected
|
|
@staff_user_required # Assuming only staff can cancel
|
|
def cancel_interview_for_application(request, slug):
|
|
"""
|
|
Handles POST request to cancel an interview, setting the status
|
|
and saving the form data (likely a reason for cancellation).
|
|
"""
|
|
scheduled_interview = get_object_or_404(ScheduledInterview,slug=slug)
|
|
form = InterviewCancelForm(request.POST, instance=scheduled_interview)
|
|
|
|
if form.is_valid():
|
|
scheduled_interview.status = scheduled_interview.InterviewStatus.CANCELLED
|
|
scheduled_interview.save(update_fields=["status"])
|
|
scheduled_interview.save(update_fields=["status"]) # Saves the new status
|
|
|
|
form.save() # Saves form data
|
|
|
|
messages.success(request, _("Interview cancelled successfully."))
|
|
return redirect("interview_detail", slug=scheduled_interview.slug)
|
|
else:
|
|
error_list = [
|
|
f"{field}: {', '.join(errors)}" for field, errors in form.errors.items()
|
|
]
|
|
error_message = _("Please correct the following errors: ") + " ".join(
|
|
error_list
|
|
)
|
|
messages.error(request, error_message)
|
|
|
|
return redirect("interview_detail", slug=scheduled_interview.slug)
|
|
|
|
|
|
@require_POST
|
|
@login_required # Assuming this should be protected
|
|
@staff_user_required # Assuming only staff can cancel
|
|
def update_interview_result(request,slug):
|
|
interview = get_object_or_404(Interview,slug=slug)
|
|
schedule=interview.scheduled_interview
|
|
form = InterviewResultForm(request.POST)
|
|
|
|
if form.is_valid():
|
|
interview_result=form.cleaned_data.get("interview_result")
|
|
result_comments=form.cleaned_data.get("result_comments")
|
|
interview.interview_result=interview_result
|
|
interview.result_comments=result_comments
|
|
interview.save(update_fields=['interview_result', 'result_comments'])
|
|
|
|
messages.success(request, _(f"Interview result updated successfully to {interview.interview_result}."))
|
|
return redirect("interview_detail", slug=schedule.slug)
|
|
else:
|
|
error_list = [
|
|
f"{field}: {', '.join(errors)}" for field, errors in form.errors.items()
|
|
]
|
|
error_message = _("Please correct the following errors: ") + " ".join(
|
|
error_list
|
|
)
|
|
messages.error(request, error_message)
|
|
|
|
return redirect("interview_detail", slug=schedule.slug)
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_access_link_deactivate(request, slug):
|
|
"""Deactivate an agency access link"""
|
|
access_link = get_object_or_404(
|
|
AgencyAccessLink.objects.select_related(
|
|
"assignment__agency", "assignment__job"
|
|
),
|
|
slug=slug,
|
|
)
|
|
|
|
if request.method == "POST":
|
|
access_link.is_active = False
|
|
access_link.save(update_fields=["is_active"])
|
|
|
|
messages.success(
|
|
request,
|
|
f"Access link for {access_link.assignment.agency.name} - {access_link.assignment.job.title} has been deactivated.",
|
|
)
|
|
|
|
# Handle HTMX requests
|
|
if "HX-Request" in request.headers:
|
|
return HttpResponse(status=200) # HTMX success response
|
|
|
|
return redirect("agency_assignment_detail", slug=access_link.assignment.slug)
|
|
|
|
# For GET requests, show confirmation page
|
|
context = {
|
|
"access_link": access_link,
|
|
"title": "Deactivate Access Link",
|
|
"message": f"Are you sure you want to deactivate the access link for {access_link.assignment.agency.name}?",
|
|
"cancel_url": reverse(
|
|
"agency_assignment_detail", kwargs={"slug": access_link.assignment.slug}
|
|
),
|
|
}
|
|
return render(request, "recruitment/agency_access_link_confirm.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def agency_access_link_reactivate(request, slug):
|
|
"""Reactivate an agency access link"""
|
|
access_link = get_object_or_404(
|
|
AgencyAccessLink.objects.select_related(
|
|
"assignment__agency", "assignment__job"
|
|
),
|
|
slug=slug,
|
|
)
|
|
|
|
if request.method == "POST":
|
|
access_link.is_active = True
|
|
access_link.save(update_fields=["is_active"])
|
|
|
|
messages.success(
|
|
request,
|
|
f"Access link for {access_link.assignment.agency.name} - {access_link.assignment.job.title} has been reactivated.",
|
|
)
|
|
|
|
# Handle HTMX requests
|
|
if "HX-Request" in request.headers:
|
|
return HttpResponse(status=200) # HTMX success response
|
|
|
|
return redirect("agency_assignment_detail", slug=access_link.assignment.slug)
|
|
|
|
# For GET requests, show confirmation page
|
|
context = {
|
|
"access_link": access_link,
|
|
"title": "Reactivate Access Link",
|
|
"message": f"Are you sure you want to reactivate the access link for {access_link.assignment.agency.name}?",
|
|
"cancel_url": reverse(
|
|
"agency_assignment_detail", kwargs={"slug": access_link.assignment.slug}
|
|
),
|
|
}
|
|
return render(request, "recruitment/agency_access_link_confirm.html", context)
|
|
|
|
|
|
@agency_user_required
|
|
def api_application_detail(request, candidate_id):
|
|
"""API endpoint to get candidate details for agency portal"""
|
|
try:
|
|
# Get candidate from session-based agency access
|
|
assignment_id = request.session.get("agency_assignment_id")
|
|
if not assignment_id:
|
|
return JsonResponse({"success": False, "error": "Access denied"})
|
|
|
|
# Get current assignment to determine agency
|
|
current_assignment = get_object_or_404(
|
|
AgencyJobAssignment.objects.select_related("agency"), id=assignment_id
|
|
)
|
|
|
|
agency = current_assignment.agency
|
|
|
|
# Get candidate and verify it belongs to this agency
|
|
candidate = get_object_or_404(
|
|
Application, id=candidate_id, hiring_agency=agency
|
|
)
|
|
|
|
# Return candidate data
|
|
response_data = {
|
|
"success": True,
|
|
"id": candidate.id,
|
|
"first_name": candidate.first_name,
|
|
"last_name": candidate.last_name,
|
|
"email": candidate.email,
|
|
"phone": candidate.phone,
|
|
"address": candidate.address,
|
|
}
|
|
|
|
return JsonResponse(response_data)
|
|
|
|
except Exception as e:
|
|
return JsonResponse({"success": False, "error": str(e)})
|
|
|
|
|
|
# @login_required
|
|
# @staff_user_required
|
|
# def compose_application_email(request, slug):
|
|
# """Compose email to participants about a candidate"""
|
|
# from django.conf import settings
|
|
|
|
# job = get_object_or_404(JobPosting, slug=slug)
|
|
# candidate_ids = request.GET.getlist("candidate_ids")
|
|
# candidates = Application.objects.filter(id__in=candidate_ids)
|
|
|
|
# if request.method == "POST":
|
|
# candidate_ids = request.POST.getlist("candidate_ids")
|
|
|
|
# applications = Application.objects.filter(id__in=candidate_ids)
|
|
# form = CandidateEmailForm(job, applications, request.POST)
|
|
|
|
# if form.is_valid():
|
|
# # Get email addresses
|
|
# email_addresses = form.get_email_addresses()
|
|
|
|
# if not email_addresses:
|
|
# messages.error(request, "No email selected")
|
|
# referer = request.META.get("HTTP_REFERER")
|
|
|
|
# if referer:
|
|
# # Redirect back to the referring page
|
|
# return redirect(referer)
|
|
# else:
|
|
# return redirect("dashboard")
|
|
|
|
# subject = form.cleaned_data.get("subject")
|
|
# message = form.get_formatted_message()
|
|
|
|
|
|
# async_task(
|
|
# "recruitment.tasks.send_bulk_email_task",
|
|
# email_addresses,
|
|
# subject,
|
|
# # message,
|
|
# "emails/email_template.html",
|
|
# {
|
|
# "job": job,
|
|
# "applications": applications,
|
|
# "email_message": message,
|
|
# "logo_url": settings.STATIC_URL + "image/kaauh.png",
|
|
# },
|
|
# )
|
|
# return redirect(request.path)
|
|
|
|
|
|
# else:
|
|
# # Form validation errors
|
|
# messages.error(request, "Please correct the errors below.")
|
|
|
|
# # For HTMX requests, return error response
|
|
# if "HX-Request" in request.headers:
|
|
# return JsonResponse(
|
|
# {
|
|
# "success": False,
|
|
# "error": "Please correct the form errors and try again.",
|
|
# }
|
|
# )
|
|
|
|
# return render(
|
|
# request,
|
|
# "includes/email_compose_form.html",
|
|
# {"form": form, "job": job, "candidates": candidates},
|
|
# )
|
|
|
|
# else:
|
|
# # GET request - show the form
|
|
# form = CandidateEmailForm(job, candidates)
|
|
|
|
# return render(
|
|
# request,
|
|
# "includes/email_compose_form.html",
|
|
# # {"form": form, "job": job, "candidates": candidates},
|
|
# {"form": form, "job": job},
|
|
# )
|
|
|
|
|
|
# Source CRUD Views
|
|
# @login_required
|
|
# @staff_user_required
|
|
# def source_list(request):
|
|
# """List all sources with search and pagination"""
|
|
# search_query = request.GET.get("q", "")
|
|
# sources = Source.objects.all()
|
|
|
|
# if search_query:
|
|
# sources = sources.filter(
|
|
# Q(name__icontains=search_query)
|
|
# | Q(source_type__icontains=search_query)
|
|
# | Q(description__icontains=search_query)
|
|
# )
|
|
|
|
# # Order by most recently created
|
|
# sources = sources.order_by("-created_at")
|
|
|
|
# # Pagination
|
|
# paginator = Paginator(sources, 1) # Show 15 sources per page
|
|
# page_number = request.GET.get("page")
|
|
# page_obj = paginator.get_page(page_number)
|
|
|
|
# context = {
|
|
# "page_obj": page_obj,
|
|
# "search_query": search_query,
|
|
# "total_sources": sources.count(),
|
|
# }
|
|
# return render(request, "recruitment/source_list.html", context)
|
|
|
|
|
|
# @login_required
|
|
# @staff_user_required
|
|
# def source_create(request):
|
|
# """Create a new source"""
|
|
# if request.method == "POST":
|
|
# form = SourceForm(request.POST)
|
|
# if form.is_valid():
|
|
# source = form.save()
|
|
# messages.success(request, f'Source "{source.name}" created successfully!')
|
|
# return redirect("source_detail", slug=source.slug)
|
|
# else:
|
|
# messages.error(request, "Please correct the errors below.")
|
|
# else:
|
|
# form = SourceForm()
|
|
|
|
# context = {
|
|
# "form": form,
|
|
# "title": "Create New Source",
|
|
# "button_text": "Create Source",
|
|
# }
|
|
# return render(request, "recruitment/source_form.html", context)
|
|
|
|
|
|
# @login_required
|
|
# @staff_user_required
|
|
# def source_detail(request, slug):
|
|
# """View details of a specific source"""
|
|
# source = get_object_or_404(Source, slug=slug)
|
|
|
|
# # Get integration logs for this source
|
|
# integration_logs = source.integration_logs.order_by("-created_at")[
|
|
# :10
|
|
# ] # Show recent 10 logs
|
|
|
|
# # Statistics
|
|
# total_logs = source.integration_logs.count()
|
|
# successful_logs = source.integration_logs.filter(method="POST").count()
|
|
# failed_logs = source.integration_logs.filter(
|
|
# method="POST", status_code__gte=400
|
|
# ).count()
|
|
|
|
# context = {
|
|
# "source": source,
|
|
# "integration_logs": integration_logs,
|
|
# "total_logs": total_logs,
|
|
# "successful_logs": successful_logs,
|
|
# "failed_logs": failed_logs,
|
|
# }
|
|
# return render(request, "recruitment/source_detail.html", context)
|
|
|
|
|
|
# @login_required
|
|
# @staff_user_required
|
|
# def source_update(request, slug):
|
|
# """Update an existing source"""
|
|
# source = get_object_or_404(Source, slug=slug)
|
|
|
|
# if request.method == "POST":
|
|
# form = SourceForm(request.POST, instance=source)
|
|
# if form.is_valid():
|
|
# source = form.save()
|
|
# messages.success(request, f'Source "{source.name}" updated successfully!')
|
|
# return redirect("source_detail", slug=source.slug)
|
|
# else:
|
|
# messages.error(request, "Please correct the errors below.")
|
|
# else:
|
|
# form = SourceForm(instance=source)
|
|
|
|
# context = {
|
|
# "form": form,
|
|
# "source": source,
|
|
# "title": _("Edit Source: %(name)s") % {"name": source.name},
|
|
# "button_text": _("Update Source"),
|
|
# }
|
|
# return render(request, "recruitment/source_form.html", context)
|
|
|
|
|
|
# @login_required
|
|
# @staff_user_required
|
|
# def source_delete(request, slug):
|
|
# """Delete a source"""
|
|
# source = get_object_or_404(Source, slug=slug)
|
|
|
|
# if request.method == "POST":
|
|
# source_name = source.name
|
|
# source.delete()
|
|
# messages.success(request, f'Source "{source_name}" deleted successfully!')
|
|
# return redirect("source_list")
|
|
|
|
# context = {
|
|
# "source": source,
|
|
# "title": _("Delete Source: %(name)s") % {"name": source.name},
|
|
# "message": _('Are you sure you want to delete the source "%(name)s"?')
|
|
# % {"name": source.name},
|
|
# "cancel_url": reverse("source_detail", kwargs={"slug": source.slug}),
|
|
# }
|
|
# return render(request, "recruitment/source_confirm_delete.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def source_generate_keys(request, slug):
|
|
"""Generate new API keys for a source"""
|
|
source = get_object_or_404(Source, slug=slug)
|
|
|
|
if request.method == "POST":
|
|
# Generate new API key and secret
|
|
from .forms import generate_api_key, generate_api_secret
|
|
|
|
source.api_key = generate_api_key()
|
|
source.api_secret = generate_api_secret()
|
|
source.save(update_fields=["api_key", "api_secret"])
|
|
|
|
messages.success(request, f'New API keys generated for "{source.name}"!')
|
|
return redirect("source_detail", slug=source.slug)
|
|
|
|
# For GET requests, show confirmation page
|
|
context = {
|
|
"source": source,
|
|
"title": "Generate New API Keys",
|
|
"message": f'Are you sure you want to generate new API keys for "{source.name}"? This will invalidate the existing keys.',
|
|
"cancel_url": reverse("source_detail", kwargs={"slug": source.slug}),
|
|
}
|
|
return render(request, "recruitment/source_confirm_generate_keys.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def source_toggle_status(request, slug):
|
|
"""Toggle active status of a source"""
|
|
source = get_object_or_404(Source, slug=slug)
|
|
|
|
if request.method == "POST":
|
|
source.is_active = not source.is_active
|
|
source.save(update_fields=["is_active"])
|
|
|
|
status_text = "activated" if source.is_active else "deactivated"
|
|
messages.success(request, f'Source "{source.name}" has been {status_text}!')
|
|
|
|
# Handle HTMX requests
|
|
if "HX-Request" in request.headers:
|
|
return HttpResponse(status=200) # HTMX success response
|
|
|
|
return redirect("source_detail", slug=source.slug)
|
|
|
|
# For GET requests, return error
|
|
return JsonResponse({"success": False, "error": "Method not allowed"})
|
|
|
|
|
|
def application_signup(request, slug):
|
|
from .forms import ApplicantSignupForm
|
|
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
|
|
if request.method == "POST":
|
|
form = ApplicantSignupForm(request.POST)
|
|
if form.is_valid():
|
|
try:
|
|
first_name = form.cleaned_data["first_name"]
|
|
last_name = form.cleaned_data["last_name"]
|
|
email = form.cleaned_data["email"]
|
|
phone = form.cleaned_data["phone"]
|
|
gender = form.cleaned_data["gender"]
|
|
nationality = form.cleaned_data["nationality"]
|
|
address = form.cleaned_data["address"]
|
|
# gpa = form.cleaned_data["gpa"]
|
|
password = form.cleaned_data["password"]
|
|
gpa = form.cleaned_data["gpa"]
|
|
national_id = form.cleaned_data["national_id"]
|
|
|
|
user = User.objects.create_user(
|
|
username=email,
|
|
email=email,
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
phone=phone,
|
|
user_type="candidate",
|
|
)
|
|
user.set_password(password)
|
|
user.save()
|
|
Person.objects.create(
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
email=email,
|
|
phone=phone,
|
|
gender=gender,
|
|
nationality=nationality,
|
|
gpa=gpa,
|
|
national_id=national_id,
|
|
address=address,
|
|
user=user,
|
|
)
|
|
login(
|
|
request, user, backend="django.contrib.auth.backends.ModelBackend"
|
|
)
|
|
|
|
return redirect("application_submit_form", slug=slug)
|
|
except Exception as e:
|
|
messages.error(request, f"Error creating application: {str(e)}")
|
|
return render(
|
|
request,
|
|
"recruitment/applicant_signup.html",
|
|
{"form": form, "job": job},
|
|
)
|
|
else:
|
|
# messages.error(request, "Please correct the errors below.")
|
|
form = ApplicantSignupForm(request.POST)
|
|
return render(
|
|
request, "recruitment/applicant_signup.html", {"form": form, "job": job}
|
|
)
|
|
|
|
form = ApplicantSignupForm()
|
|
return render(
|
|
request, "recruitment/applicant_signup.html", {"form": form, "job": job}
|
|
)
|
|
|
|
|
|
# Interview Views
|
|
@login_required
|
|
@staff_user_required
|
|
def interview_list(request):
|
|
|
|
"""List all interviews with filtering and pagination"""
|
|
interviews = ScheduledInterview.objects.select_related(
|
|
"application",
|
|
"application__person",
|
|
"job",
|
|
).order_by("-interview_date", "-interview_time")
|
|
|
|
# Get filter parameters
|
|
status_filter = request.GET.get("status", "")
|
|
interview_type = request.GET.get("type")
|
|
job_filter = request.GET.get("job", "")
|
|
print(job_filter)
|
|
search_query = request.GET.get("search", "")
|
|
jobs = JobPosting.objects.filter(status="ACTIVE")
|
|
|
|
# Apply filters
|
|
if interview_type:
|
|
interviews = interviews.filter(interview__location_type=interview_type)
|
|
if status_filter:
|
|
interviews = interviews.filter(status=status_filter)
|
|
if job_filter:
|
|
interviews = interviews.filter(job__slug=job_filter)
|
|
if search_query:
|
|
interviews = interviews.filter(
|
|
Q(application__person__first_name=search_query)
|
|
| Q(application__person__last_name__icontains=search_query)
|
|
| Q(application__person__email__icontains=search_query)
|
|
| Q(job__title__icontains=search_query)
|
|
)
|
|
|
|
# Pagination
|
|
paginator = Paginator(interviews, 20) # Show 20 interviews per page
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"status_filter": status_filter,
|
|
"job_filter": job_filter,
|
|
"search_query": search_query,
|
|
"interviews": page_obj,
|
|
"jobs": jobs,
|
|
"interview_type":interview_type,
|
|
|
|
}
|
|
return render(request, "interviews/interview_list.html", context)
|
|
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def generate_ai_questions(request, slug):
|
|
"""Generate AI-powered interview questions for a scheduled interview"""
|
|
from django_q.tasks import async_task
|
|
|
|
schedule = get_object_or_404(ScheduledInterview, slug=slug)
|
|
messages.info(request,_("Generating interview questions."))
|
|
|
|
if request.method == "POST":
|
|
# Queue the AI question generation task
|
|
|
|
task_id = async_task(
|
|
"recruitment.tasks.generate_interview_questions",
|
|
schedule.id,
|
|
sync=False
|
|
)
|
|
|
|
|
|
# if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
|
# return JsonResponse({
|
|
# "status": "success",
|
|
# "message": "AI question generation started in background",
|
|
# "task_id": task_id
|
|
# })
|
|
# else:
|
|
# messages.success(
|
|
# request,
|
|
# "AI question generation started. Questions will appear shortly."
|
|
# )
|
|
# return redirect("interview_detail", slug=slug)
|
|
|
|
# # For GET requests, return existing questions if any
|
|
# questions = schedule.ai_questions.all().order_by("created_at")
|
|
|
|
# if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
|
# return JsonResponse({
|
|
# "status": "success",
|
|
# "questions": [
|
|
# {
|
|
# "id": q.id,
|
|
# "text": q.question_text,
|
|
# "type": q.question_type,
|
|
# "difficulty": q.difficulty_level,
|
|
# "category": q.category,
|
|
# "created_at": q.created_at.isoformat()
|
|
# }
|
|
# for q in questions
|
|
# ]
|
|
# })
|
|
|
|
return redirect("interview_detail", slug=slug)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def interview_detail(request, slug):
|
|
"""View details of a specific interview"""
|
|
from .forms import (
|
|
ScheduledInterviewUpdateStatusForm,
|
|
OnsiteScheduleInterviewUpdateForm,
|
|
)
|
|
schedule = get_object_or_404(ScheduledInterview, slug=slug)
|
|
interview = schedule.interview
|
|
interview_result_form=InterviewResultForm()
|
|
application = schedule.application
|
|
job = schedule.job
|
|
if interview.location_type == "Remote":
|
|
reschedule_form = ScheduledInterviewForm()
|
|
else:
|
|
reschedule_form = OnsiteScheduleInterviewUpdateForm()
|
|
reschedule_form.initial["physical_address"] = interview.physical_address
|
|
reschedule_form.initial["room_number"] = interview.room_number
|
|
reschedule_form.initial["topic"] = interview.topic
|
|
reschedule_form.initial["start_time"] = interview.start_time
|
|
reschedule_form.initial["duration"] = interview.duration
|
|
|
|
meeting = interview
|
|
interview_email_form = InterviewEmailForm(job, application, schedule)
|
|
context = {
|
|
"schedule": schedule,
|
|
"interview": interview,
|
|
"reschedule_form": reschedule_form,
|
|
"interview_status_form": ScheduledInterviewUpdateStatusForm(),
|
|
"cancel_form": InterviewCancelForm(instance=meeting),
|
|
"interview_email_form": interview_email_form,
|
|
"interview_result_form":interview_result_form,
|
|
}
|
|
return render(request, "interviews/interview_detail.html", context)
|
|
|
|
|
|
def application_add_note(request, slug):
|
|
from .models import Note
|
|
from .forms import NoteForm
|
|
|
|
application = get_object_or_404(Application, slug=slug)
|
|
notes = Note.objects.filter(application=application).order_by("-created_at")
|
|
|
|
if request.method == "POST":
|
|
form = NoteForm(request.POST)
|
|
if form.is_valid():
|
|
form.save()
|
|
# messages.success(request, "Note added successfully.")
|
|
else:
|
|
messages.error(request, "Note content cannot be empty.")
|
|
|
|
return render(request, "recruitment/partials/note_form.html", {"notes": notes})
|
|
else:
|
|
form = NoteForm()
|
|
|
|
form.initial["application"] = application
|
|
form.fields["application"].widget = HiddenInput()
|
|
form.fields["interview"].widget = HiddenInput()
|
|
form.initial["author"] = request.user
|
|
form.fields["author"].widget = HiddenInput()
|
|
url = reverse("application_add_note", kwargs={"slug": slug})
|
|
notes = Note.objects.filter(application=application).order_by("-created_at")
|
|
return render(
|
|
request,
|
|
"recruitment/partials/note_form.html",
|
|
{"form": form, "instance": application, "notes": notes, "url": url},
|
|
)
|
|
|
|
|
|
def interview_add_note(request, slug):
|
|
from .models import Note
|
|
from .forms import NoteForm
|
|
|
|
interview = get_object_or_404(Interview, slug=slug)
|
|
|
|
if request.method == "POST":
|
|
form = NoteForm(request.POST)
|
|
if form.is_valid():
|
|
form.save()
|
|
messages.success(request, "Note added successfully.")
|
|
else:
|
|
messages.error(request, "Note content cannot be empty.")
|
|
|
|
return redirect("interview_detail", slug=slug)
|
|
else:
|
|
form = NoteForm()
|
|
|
|
form.initial["interview"] = interview
|
|
form.fields["interview"].widget = HiddenInput()
|
|
form.fields["author"].widget = HiddenInput()
|
|
form.initial["author"] = request.user
|
|
form.fields["author"].widget = HiddenInput()
|
|
|
|
return render(
|
|
request,
|
|
"recruitment/partials/note_form.html",
|
|
{"form": form, "instance": interview, "notes": interview.notes.all()},
|
|
)
|
|
|
|
|
|
# @require_POST
|
|
@staff_user_required
|
|
def delete_note(request, slug):
|
|
from .models import Note
|
|
|
|
note = get_object_or_404(Note, slug=slug)
|
|
print(request.method)
|
|
if request.method == "DELETE":
|
|
note.delete()
|
|
messages.success(request, "Note deleted successfully.")
|
|
response = HttpResponse(status=200)
|
|
# response["HX-Refresh"] = "true"
|
|
return response
|
|
|
|
|
|
def job_bank_view(request):
|
|
"""Display job bank page with all jobs and advanced filtering"""
|
|
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
|
|
|
|
# Get all job postings
|
|
jobs = JobPosting.objects.all().order_by("-created_at")
|
|
|
|
# Get filter parameters
|
|
search_query = request.GET.get("q", "")
|
|
department_filter = request.GET.get("department", "")
|
|
job_type_filter = request.GET.get("job_type", "")
|
|
workplace_type_filter = request.GET.get("workplace_type", "")
|
|
status_filter = request.GET.get("status", "")
|
|
date_filter = request.GET.get("date_filter", "")
|
|
sort_by = request.GET.get("sort", "-created_at")
|
|
|
|
# Apply filters
|
|
if search_query:
|
|
jobs = jobs.filter(
|
|
Q(title__icontains=search_query)
|
|
| Q(description__icontains=search_query)
|
|
| Q(department__icontains=search_query)
|
|
)
|
|
|
|
if department_filter:
|
|
jobs = jobs.filter(department=department_filter)
|
|
|
|
if job_type_filter:
|
|
jobs = jobs.filter(job_type=job_type_filter)
|
|
|
|
if workplace_type_filter:
|
|
jobs = jobs.filter(workplace_type=workplace_type_filter)
|
|
|
|
if status_filter:
|
|
jobs = jobs.filter(status=status_filter)
|
|
|
|
# Date filtering
|
|
if date_filter:
|
|
from datetime import datetime, timedelta
|
|
|
|
now = timezone.now()
|
|
if date_filter == "week":
|
|
jobs = jobs.filter(created_at__gte=now - timedelta(days=7))
|
|
elif date_filter == "month":
|
|
jobs = jobs.filter(created_at__gte=now - timedelta(days=30))
|
|
elif date_filter == "quarter":
|
|
jobs = jobs.filter(created_at__gte=now - timedelta(days=90))
|
|
|
|
# Apply sorting
|
|
if sort_by in [
|
|
"title",
|
|
"-title",
|
|
"department",
|
|
"-department",
|
|
"created_at",
|
|
"-created_at",
|
|
]:
|
|
jobs = jobs.order_by(sort_by)
|
|
|
|
# Get filter options for dropdowns
|
|
departments = (
|
|
JobPosting.objects.values_list("department", flat=True)
|
|
.filter(department__isnull=False)
|
|
.exclude(department="")
|
|
.distinct()
|
|
.order_by("department")
|
|
)
|
|
|
|
job_types = dict(JobPosting.JOB_TYPES)
|
|
workplace_types = dict(JobPosting.WORKPLACE_TYPES)
|
|
status_choices = dict(JobPosting.STATUS_CHOICES)
|
|
|
|
# Pagination
|
|
paginator = Paginator(jobs, 12) # 12 jobs per page
|
|
page = request.GET.get("page")
|
|
try:
|
|
page_obj = paginator.get_page(page)
|
|
except PageNotAnInteger:
|
|
page_obj = paginator.get_page(1)
|
|
except EmptyPage:
|
|
page_obj = paginator.get_page(paginator.num_pages)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"search_query": search_query,
|
|
"department_filter": department_filter,
|
|
"job_type_filter": job_type_filter,
|
|
"workplace_type_filter": workplace_type_filter,
|
|
"status_filter": status_filter,
|
|
"date_filter": date_filter,
|
|
"sort_by": sort_by,
|
|
"departments": departments,
|
|
"job_types": job_types,
|
|
"workplace_types": workplace_types,
|
|
"status_choices": status_choices,
|
|
"total_jobs": jobs.count(),
|
|
}
|
|
|
|
return render(request, "jobs/job_bank.html", context)
|
|
|
|
|
|
@staff_user_required
|
|
def job_applicants_view(request, slug):
|
|
"""Display all applicants for a specific job with advanced filtering"""
|
|
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
|
|
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
|
|
# Get all applications for this job
|
|
applications = job.applications.select_related("person").order_by("-created_at")
|
|
|
|
# Get filter parameters
|
|
search_query = request.GET.get("q", "")
|
|
stage_filter = request.GET.get("stage", "")
|
|
min_ai_score = request.GET.get("min_ai_score", "")
|
|
max_ai_score = request.GET.get("max_ai_score", "")
|
|
date_from = request.GET.get("date_from", "")
|
|
date_to = request.GET.get("date_to", "")
|
|
sort_by = request.GET.get("sort", "-created_at")
|
|
|
|
# Apply filters
|
|
if search_query:
|
|
applications = applications.filter(
|
|
Q(person__first_name=search_query)
|
|
| Q(person__last_name__icontains=search_query)
|
|
| Q(person__email__icontains=search_query)
|
|
| Q(email__icontains=search_query)
|
|
)
|
|
|
|
if stage_filter:
|
|
applications = applications.filter(stage=stage_filter)
|
|
|
|
# AI Score filtering
|
|
if min_ai_score:
|
|
try:
|
|
min_score = int(min_ai_score)
|
|
applications = applications.filter(
|
|
ai_analysis_data__analysis_data_en__match_score__gte=min_score
|
|
)
|
|
except ValueError:
|
|
pass
|
|
|
|
if max_ai_score:
|
|
try:
|
|
max_score = int(max_ai_score)
|
|
applications = applications.filter(
|
|
ai_analysis_data__analysis_data_en__match_score__lte=max_score
|
|
)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Date filtering
|
|
if date_from:
|
|
try:
|
|
from datetime import datetime
|
|
|
|
date_from_dt = datetime.strptime(date_from, "%Y-%m-%d").date()
|
|
applications = applications.filter(created_at__date__gte=date_from_dt)
|
|
except ValueError:
|
|
pass
|
|
|
|
if date_to:
|
|
try:
|
|
from datetime import datetime
|
|
|
|
date_to_dt = datetime.strptime(date_to, "%Y-%m-%d").date()
|
|
applications = applications.filter(created_at__date__lte=date_to_dt)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Apply sorting
|
|
valid_sort_fields = [
|
|
"-created_at",
|
|
"created_at",
|
|
"person__first_name",
|
|
"-person__first_name",
|
|
"person__last_name",
|
|
"-person__last_name",
|
|
"stage",
|
|
"-stage",
|
|
]
|
|
if sort_by in valid_sort_fields:
|
|
applications = applications.order_by(sort_by)
|
|
|
|
# Calculate statistics
|
|
total_applications = applications.count()
|
|
stage_stats = {}
|
|
for stage_choice in Application.Stage.choices:
|
|
stage_key = stage_choice[0]
|
|
stage_label = stage_choice[1]
|
|
count = applications.filter(stage=stage_key).count()
|
|
stage_stats[stage_key] = {"label": stage_label, "count": count}
|
|
|
|
# Calculate AI score statistics
|
|
ai_score_stats = {}
|
|
scored_applications = applications.filter(
|
|
ai_analysis_data__analysis_data_en__match_score__isnull=False
|
|
)
|
|
if scored_applications.exists():
|
|
from django.db.models import Avg
|
|
|
|
# avg_score_result = scored_applications.aggregate(
|
|
# avg_score=Avg('ai_analysis_data__analysis_data_en__match_score')
|
|
# )
|
|
ai_score_stats["average"] = 0
|
|
|
|
# Score distribution
|
|
high_score = scored_applications.filter(
|
|
ai_analysis_data__analysis_data_en__match_score__gte=75
|
|
).count()
|
|
medium_score = scored_applications.filter(
|
|
ai_analysis_data__analysis_data_en__match_score__gte=50,
|
|
ai_analysis_data__analysis_data_en__match_score__lt=75,
|
|
).count()
|
|
low_score = scored_applications.filter(
|
|
ai_analysis_data__analysis_data_en__match_score__lt=50
|
|
).count()
|
|
|
|
ai_score_stats["distribution"] = {
|
|
"high": high_score,
|
|
"medium": medium_score,
|
|
"low": low_score,
|
|
}
|
|
|
|
# Pagination
|
|
paginator = Paginator(applications, 20) # 20 applicants per page
|
|
page = request.GET.get("page")
|
|
try:
|
|
page_obj = paginator.get_page(page)
|
|
except PageNotAnInteger:
|
|
page_obj = paginator.get_page(1)
|
|
except EmptyPage:
|
|
page_obj = paginator.get_page(paginator.num_pages)
|
|
|
|
context = {
|
|
"job": job,
|
|
"page_obj": page_obj,
|
|
"total_applications": total_applications,
|
|
"stage_stats": stage_stats,
|
|
"ai_score_stats": ai_score_stats,
|
|
"search_query": search_query,
|
|
"stage_filter": stage_filter,
|
|
"min_ai_score": min_ai_score,
|
|
"max_ai_score": max_ai_score,
|
|
"date_from": date_from,
|
|
"date_to": date_to,
|
|
"sort_by": sort_by,
|
|
"stage_choices": Application.Stage.choices,
|
|
}
|
|
|
|
return render(request, "jobs/job_applicants.html", context)
|
|
|
|
|
|
# Settings CRUD Views
|
|
@staff_user_required
|
|
def settings_list(request):
|
|
"""List all settings with search and pagination"""
|
|
search_query = request.GET.get("q", "")
|
|
settings = Settings.objects.all()
|
|
|
|
if search_query:
|
|
settings = settings.filter(key__icontains=search_query)
|
|
|
|
# Order by key alphabetically
|
|
settings = settings.order_by("key")
|
|
|
|
# Pagination
|
|
paginator = Paginator(settings, 20) # Show 20 settings per page
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"search_query": search_query,
|
|
"total_settings": settings.count(),
|
|
}
|
|
return render(request, "recruitment/settings_list.html", context)
|
|
|
|
|
|
@staff_user_required
|
|
def settings_create(request):
|
|
"""Create a new setting"""
|
|
if request.method == "POST":
|
|
form = SettingsForm(request.POST)
|
|
if form.is_valid():
|
|
setting = form.save()
|
|
messages.success(request, f'Setting "{setting.key}" created successfully!')
|
|
return redirect("settings_list")
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = SettingsForm()
|
|
|
|
context = {
|
|
"form": form,
|
|
"title": "Create New Setting",
|
|
"button_text": "Create Setting",
|
|
}
|
|
return render(request, "recruitment/settings_form.html", context)
|
|
|
|
|
|
@staff_user_required
|
|
def settings_detail(request, pk):
|
|
"""View details of a specific setting"""
|
|
setting = get_object_or_404(Settings, pk=pk)
|
|
|
|
context = {
|
|
"setting": setting,
|
|
}
|
|
return render(request, "recruitment/settings_detail.html", context)
|
|
|
|
|
|
@staff_user_required
|
|
def settings_update(request, pk):
|
|
"""Update an existing setting"""
|
|
setting = get_object_or_404(Settings, pk=pk)
|
|
|
|
if request.method == "POST":
|
|
form = SettingsForm(request.POST, instance=setting)
|
|
if form.is_valid():
|
|
form.save()
|
|
messages.success(request, f'Setting "{setting.key}" updated successfully!')
|
|
return redirect("settings_detail", pk=setting.pk)
|
|
else:
|
|
messages.error(request, "Please correct the errors below.")
|
|
else:
|
|
form = SettingsForm(instance=setting)
|
|
|
|
context = {
|
|
"form": form,
|
|
"setting": setting,
|
|
"title": f"Edit Setting: {setting.key}",
|
|
"button_text": "Update Setting",
|
|
}
|
|
return render(request, "recruitment/settings_form.html", context)
|
|
|
|
|
|
@staff_user_required
|
|
def settings_delete(request, pk):
|
|
"""Delete a setting"""
|
|
setting = get_object_or_404(Settings, pk=pk)
|
|
|
|
if request.method == "POST":
|
|
setting_name = setting.key
|
|
setting.delete()
|
|
messages.success(request, f'Setting "{setting_name}" deleted successfully!')
|
|
return redirect("settings_list")
|
|
|
|
context = {
|
|
"setting": setting,
|
|
"title": "Delete Setting",
|
|
"message": f'Are you sure you want to delete the setting "{setting_name}"?',
|
|
"cancel_url": reverse("settings_detail", kwargs={"pk": setting.pk}),
|
|
}
|
|
return render(request, "recruitment/settings_confirm_delete.html", context)
|
|
|
|
|
|
@staff_user_required
|
|
def settings_toggle_status(request, pk):
|
|
"""Toggle active status of a setting"""
|
|
setting = get_object_or_404(Settings, pk=pk)
|
|
|
|
if request.method == "POST":
|
|
setting.is_active = not setting.is_active
|
|
setting.save(update_fields=["is_active"])
|
|
|
|
status_text = "activated" if setting.is_active else "deactivated"
|
|
messages.success(
|
|
request, f'Setting "{setting.key}" {status_text} successfully!'
|
|
)
|
|
return redirect("settings_detail", pk=setting.pk)
|
|
|
|
# For GET requests or HTMX, return JSON response
|
|
if request.headers.get("HX-Request"):
|
|
return JsonResponse(
|
|
{
|
|
"success": True,
|
|
"is_active": setting.is_active,
|
|
"message": f'Setting "{setting.key}" {status_text} successfully!',
|
|
}
|
|
)
|
|
|
|
return redirect("settings_detail", pk=setting.pk)
|
|
|
|
|
|
############################################################
|
|
|
|
|
|
class JobListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
|
model = JobPosting
|
|
template_name = "jobs/job_list.html"
|
|
context_object_name = "jobs"
|
|
paginate_by = 20
|
|
|
|
def get_queryset(self):
|
|
queryset = super().get_queryset().order_by("-created_at")
|
|
# Handle search
|
|
search_query = self.request.GET.get("search", "")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(title__icontains=search_query)
|
|
| Q(description__icontains=search_query)
|
|
| Q(department__icontains=search_query)
|
|
)
|
|
|
|
status_filter = self.request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
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()
|
|
context["status_filter"] = self.request.GET.get("status")
|
|
return context
|
|
|
|
|
|
class JobCreateView(
|
|
LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView
|
|
):
|
|
model = JobPosting
|
|
form_class = JobPostingForm
|
|
template_name = "jobs/create_job.html"
|
|
success_url = reverse_lazy("job_list")
|
|
success_message = "Job created successfully."
|
|
|
|
|
|
class JobUpdateView(
|
|
LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView
|
|
):
|
|
model = JobPosting
|
|
form_class = JobPostingForm
|
|
template_name = "jobs/edit_job.html"
|
|
success_url = reverse_lazy("job_list")
|
|
success_message = _("Job updated successfully.")
|
|
slug_url_kwarg = "slug"
|
|
|
|
|
|
class JobDeleteView(
|
|
LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView
|
|
):
|
|
model = JobPosting
|
|
template_name = "jobs/partials/delete_modal.html"
|
|
success_url = reverse_lazy("job_list")
|
|
success_message = _("Job deleted successfully.")
|
|
slug_url_kwarg = "slug"
|
|
|
|
|
|
class JobApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
|
model = Application
|
|
template_name = "jobs/job_applications_list.html"
|
|
context_object_name = "applications"
|
|
paginate_by = 10
|
|
|
|
def get_queryset(self):
|
|
# Get the job by slug
|
|
self.job = get_object_or_404(JobPosting, slug=self.kwargs["slug"])
|
|
|
|
# Filter candidates for this specific job
|
|
queryset = (
|
|
Application.objects.filter(job=self.job)
|
|
.select_related("person", "job")
|
|
.prefetch_related("interview_set")
|
|
)
|
|
|
|
if self.request.GET.get("stage"):
|
|
stage = self.request.GET.get("stage")
|
|
queryset = queryset.filter(stage=stage)
|
|
|
|
# Handle search
|
|
search_query = self.request.GET.get("search", "")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(first_name=search_query)
|
|
| Q(last_name__icontains=search_query)
|
|
| Q(email__icontains=search_query)
|
|
| Q(phone=search_query)
|
|
| Q(stage__icontains=search_query)
|
|
)
|
|
|
|
# Filter for non-staff users
|
|
if not self.request.user.is_staff:
|
|
return Application.objects.none() # Restrict for non-staff
|
|
|
|
return queryset.order_by("-created_at")
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context["search_query"] = self.request.GET.get("search", "")
|
|
context["job"] = getattr(self, "job", None)
|
|
return context
|
|
|
|
|
|
class ApplicationListView(LoginRequiredMixin, StaffRequiredMixin, ListView):
|
|
model = Application
|
|
template_name = "recruitment/applications_list.html"
|
|
context_object_name = "applications"
|
|
paginate_by = 100
|
|
|
|
def get_queryset(self):
|
|
queryset = super().get_queryset().select_related("person", "job")
|
|
|
|
# Handle search
|
|
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(person__first_name=search_query)
|
|
| Q(person__last_name__icontains=search_query)
|
|
| Q(person__email__icontains=search_query)
|
|
| Q(person__phone=search_query)
|
|
)
|
|
if job:
|
|
queryset = queryset.filter(job__slug=job)
|
|
if stage:
|
|
queryset = queryset.filter(stage=stage)
|
|
queryset=queryset.order_by("-created_at")
|
|
return queryset
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context["search_query"] = self.request.GET.get("search", "")
|
|
context["job_filter"] = self.request.GET.get("job", "")
|
|
context["stage_filter"] = self.request.GET.get("stage", "")
|
|
# OPTIMIZED: Cache available jobs query for 15 minutes
|
|
cache_key = "available_jobs"
|
|
available_jobs = cache.get(cache_key)
|
|
if available_jobs is None:
|
|
available_jobs = list(
|
|
JobPosting.objects.all().order_by("created_at").distinct()
|
|
)
|
|
cache.set(cache_key, available_jobs, 900) # 15 minutes
|
|
context["available_jobs"] = available_jobs
|
|
return context
|
|
|
|
|
|
class ApplicationCreateView(
|
|
LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, CreateView
|
|
):
|
|
model = Application
|
|
form_class = ApplicationForm
|
|
template_name = "recruitment/application_create.html"
|
|
success_url = reverse_lazy("application_list")
|
|
success_message = _("Application created successfully.")
|
|
|
|
def get_initial(self):
|
|
initial = super().get_initial()
|
|
if "slug" in self.kwargs:
|
|
job = get_object_or_404(JobPosting, slug=self.kwargs["slug"])
|
|
initial["job"] = job
|
|
return initial
|
|
|
|
def form_valid(self, form):
|
|
if "slug" in self.kwargs:
|
|
job = get_object_or_404(JobPosting, slug=self.kwargs["slug"])
|
|
form.instance.job = job
|
|
return super().form_valid(form)
|
|
|
|
def form_invalid(self, form):
|
|
messages.error(self.request, f"{form.errors.as_text()}")
|
|
return super().form_invalid(form)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
# OPTIMIZED: Cache nationalities query for 1 hour
|
|
cache_key = "person_nationalities"
|
|
nationalities = cache.get(cache_key)
|
|
if nationalities is None:
|
|
nationalities = list(
|
|
Person.objects.values_list("nationality", flat=True)
|
|
.filter(nationality__isnull=False)
|
|
.distinct()
|
|
.order_by("nationality")
|
|
)
|
|
cache.set(cache_key, nationalities, 3600) # 1 hour
|
|
|
|
nationality = self.request.GET.get("nationality")
|
|
context["nationality"] = nationality
|
|
context["nationalities"] = nationalities
|
|
context["search_query"] = self.request.GET.get("search", "")
|
|
context["person_form"] = PersonForm()
|
|
return context
|
|
|
|
|
|
class ApplicationUpdateView(
|
|
LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView
|
|
):
|
|
model = Application
|
|
form_class = ApplicationForm
|
|
template_name = "recruitment/application_update.html"
|
|
success_url = reverse_lazy("application_list")
|
|
success_message = _("Application updated successfully.")
|
|
slug_url_kwarg = "slug"
|
|
|
|
|
|
class ApplicationDeleteView(
|
|
LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, DeleteView
|
|
):
|
|
model = Application
|
|
template_name = "recruitment/application_delete.html"
|
|
success_url = reverse_lazy("application_list")
|
|
success_message = _("Application deleted successfully.")
|
|
slug_url_kwarg = "slug"
|
|
|
|
|
|
def retry_scoring_view(request, slug):
|
|
from django_q.tasks import async_task
|
|
|
|
application = get_object_or_404(Application, slug=slug)
|
|
|
|
async_task(
|
|
"recruitment.tasks.handle_resume_parsing_and_scoring",
|
|
application.pk,
|
|
hook="recruitment.hooks.callback_ai_parsing",
|
|
sync=True,
|
|
)
|
|
return redirect("application_detail", slug=application.slug)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def application_detail(request, slug):
|
|
from rich.json import JSON
|
|
|
|
application = get_object_or_404(Application, slug=slug)
|
|
try:
|
|
parsed = ast.literal_eval(application.parsed_summary)
|
|
except:
|
|
parsed = {}
|
|
|
|
# Create stage update form for staff users
|
|
stage_form = None
|
|
if request.user.is_staff:
|
|
stage_form = ApplicationStageForm()
|
|
|
|
return render(
|
|
request,
|
|
"recruitment/application_detail.html",
|
|
{
|
|
"application": application,
|
|
"parsed": parsed,
|
|
"stage_form": stage_form,
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def application_resume_template_view(request, slug):
|
|
"""Display formatted resume template for a application"""
|
|
application = get_object_or_404(Application, slug=slug)
|
|
|
|
if not request.user.is_staff:
|
|
messages.error(request, _("You don't have permission to view this page."))
|
|
return redirect("application_list")
|
|
|
|
return render(
|
|
request,
|
|
"recruitment/application_resume_template.html",
|
|
{"application": application},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def application_update_stage(request, slug):
|
|
"""Handle HTMX stage update requests"""
|
|
application = get_object_or_404(Application, slug=slug)
|
|
form = ApplicationStageForm(request.POST, instance=application)
|
|
if form.is_valid():
|
|
stage_value = form.cleaned_data["stage"]
|
|
application.stage = stage_value
|
|
application.save(update_fields=["stage"])
|
|
messages.success(request, _("application Stage Updated"))
|
|
return redirect("application_detail", slug=application.slug)
|
|
|
|
|
|
# IMPORTANT: Ensure 'models' correctly refers to your Django models file
|
|
# Example: from . import models
|
|
|
|
# --- Constants ---
|
|
SCORE_PATH = "ai_analysis_data__analysis_data__match_score"
|
|
HIGH_POTENTIAL_THRESHOLD = 75
|
|
MAX_TIME_TO_HIRE_DAYS = 90
|
|
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")
|
|
today = timezone.now().date()
|
|
|
|
# --- 1. BASE QUERYSETS & GLOBAL METRICS (UNFILTERED) ---
|
|
|
|
all_jobs_queryset = JobPosting.objects.all().order_by("-created_at")
|
|
all_applications_queryset = Application.objects.all()
|
|
|
|
# Global KPI Card Metrics
|
|
total_jobs_global = all_jobs_queryset.count()
|
|
# total_participants = Participants.objects.count()
|
|
total_jobs_posted_linkedin = all_jobs_queryset.filter(
|
|
linkedin_post_id__isnull=False
|
|
).count()
|
|
|
|
# Data for Job App Count Chart (always for ALL jobs)
|
|
job_titles = [job.title for job in all_jobs_queryset]
|
|
job_app_counts = [job.applications.count() for job in all_jobs_queryset]
|
|
|
|
# --- 2. TIME SERIES: GLOBAL DAILY APPLICANTS ---
|
|
|
|
# Group ALL applications by creation date
|
|
global_daily_applications_qs = (
|
|
all_applications_queryset.annotate(date=TruncDate("created_at"))
|
|
.values("date")
|
|
.annotate(count=Count("pk"))
|
|
.order_by("date")
|
|
)
|
|
|
|
global_dates = [
|
|
item["date"].strftime("%Y-%m-%d") for item in global_daily_applications_qs
|
|
]
|
|
global_counts = [item["count"] for item in global_daily_applications_qs]
|
|
|
|
# --- 3. FILTERING LOGIC: Determine the scope for scoped metrics ---
|
|
|
|
application_queryset = all_applications_queryset
|
|
job_scope_queryset = all_jobs_queryset
|
|
interview_queryset = ScheduledInterview.objects.all()
|
|
|
|
current_job = None
|
|
if selected_job_pk:
|
|
# Filter all base querysets
|
|
application_queryset = application_queryset.filter(job__pk=selected_job_pk)
|
|
interview_queryset = interview_queryset.filter(job__pk=selected_job_pk)
|
|
|
|
try:
|
|
current_job = all_jobs_queryset.get(pk=selected_job_pk)
|
|
job_scope_queryset = JobPosting.objects.filter(pk=selected_job_pk)
|
|
except JobPosting.DoesNotExist:
|
|
pass
|
|
|
|
# --- 4. TIME SERIES: SCOPED DAILY APPLICANTS ---
|
|
|
|
# Only run if a specific job is selected
|
|
scoped_dates = []
|
|
scoped_counts = []
|
|
if selected_job_pk:
|
|
scoped_daily_applications_qs = (
|
|
application_queryset.annotate(date=TruncDate("created_at"))
|
|
.values("date")
|
|
.annotate(count=Count("pk"))
|
|
.order_by("date")
|
|
)
|
|
|
|
scoped_dates = [
|
|
item["date"].strftime("%Y-%m-%d") for item in scoped_daily_applications_qs
|
|
]
|
|
scoped_counts = [item["count"] for item in scoped_daily_applications_qs]
|
|
|
|
# --- 5. SCOPED CORE AGGREGATIONS (FILTERED OR ALL) ---
|
|
|
|
total_applications = application_queryset.count()
|
|
|
|
score_expression = Cast(
|
|
Coalesce(
|
|
KeyTextTransform(
|
|
"match_score", KeyTransform("analysis_data_en", "ai_analysis_data")
|
|
),
|
|
Value("0"),
|
|
),
|
|
output_field=IntegerField(),
|
|
)
|
|
|
|
# 2. ANNOTATE the queryset with the new field
|
|
applications_with_score_query = application_queryset.annotate(
|
|
annotated_match_score=score_expression
|
|
)
|
|
|
|
# A. Pipeline & Volume Metrics (Scoped)
|
|
total_active_jobs = job_scope_queryset.filter(status="ACTIVE").count()
|
|
last_week = timezone.now() - timedelta(days=7)
|
|
new_applications_7days = application_queryset.filter(
|
|
created_at__gte=last_week
|
|
).count()
|
|
|
|
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(
|
|
applications_count=Count("applications", distinct=True)
|
|
).aggregate(avg_apps=Avg("applications_count"))["avg_apps"]
|
|
average_applications = round(average_applications_result or 0, 2)
|
|
|
|
# B. Efficiency & Conversion Metrics (Scoped)
|
|
hired_applications = application_queryset.filter(stage="Hired")
|
|
|
|
lst = [c.time_to_hire_days for c in hired_applications]
|
|
|
|
time_to_hire_query = hired_applications.annotate(
|
|
time_diff=ExpressionWrapper(
|
|
F("join_date") - F("created_at__date"), output_field=fields.DurationField()
|
|
)
|
|
).aggregate(avg_time_to_hire=Avg("time_diff"))
|
|
|
|
print(time_to_hire_query)
|
|
|
|
avg_time_to_hire_days = (
|
|
time_to_hire_query.get("avg_time_to_hire").days
|
|
if time_to_hire_query.get("avg_time_to_hire")
|
|
else 0
|
|
)
|
|
print(avg_time_to_hire_days)
|
|
|
|
applied_count = application_queryset.filter(stage="Applied").count()
|
|
advanced_count = application_queryset.filter(
|
|
stage__in=["Exam", "Interview", "Offer"]
|
|
).count()
|
|
screening_pass_rate = (
|
|
round((advanced_count / applied_count) * 100, 1) if applied_count > 0 else 0
|
|
)
|
|
offers_extended_count = application_queryset.filter(stage="Offer").count()
|
|
offers_accepted_count = application_queryset.filter(offer_status="Accepted").count()
|
|
offers_accepted_rate = (
|
|
round((offers_accepted_count / offers_extended_count) * 100, 1)
|
|
if offers_extended_count > 0
|
|
else 0
|
|
)
|
|
filled_positions = offers_accepted_count
|
|
vacancy_fill_rate = (
|
|
round((filled_positions / total_open_positions) * 100, 1)
|
|
if total_open_positions > 0
|
|
else 0
|
|
)
|
|
|
|
# C. Activity & Quality Metrics (Scoped)
|
|
current_year, current_week, _ = today.isocalendar()
|
|
meetings_scheduled_this_week = interview_queryset.filter(
|
|
interview_date__week=current_week, interview_date__year=current_year
|
|
).count()
|
|
avg_match_score_result = applications_with_score_query.aggregate(
|
|
avg_score=Avg("annotated_match_score")
|
|
)["avg_score"]
|
|
avg_match_score = round(avg_match_score_result or 0, 1)
|
|
high_potential_count = applications_with_score_query.filter(
|
|
annotated_match_score__gte=HIGH_POTENTIAL_THRESHOLD
|
|
).count()
|
|
high_potential_ratio = (
|
|
round((high_potential_count / total_applications) * 100, 1)
|
|
if total_applications > 0
|
|
else 0
|
|
)
|
|
total_scored_candidates = applications_with_score_query.count()
|
|
scored_ratio = (
|
|
round((total_scored_candidates / total_applications) * 100, 1)
|
|
if total_applications > 0
|
|
else 0
|
|
)
|
|
|
|
# --- 6. CHART DATA PREPARATION ---
|
|
|
|
# A. Pipeline Funnel (Scoped)
|
|
stage_counts = application_queryset.values("stage").annotate(count=Count("stage"))
|
|
stage_map = {item["stage"]: item["count"] for item in stage_counts}
|
|
application_stage = ["Applied", "Exam", "Interview", "Offer", "Hired"]
|
|
application_count = [
|
|
stage_map.get("Applied", 0),
|
|
stage_map.get("Exam", 0),
|
|
stage_map.get("Interview", 0),
|
|
stage_map.get("Offer", 0),
|
|
stage_map.get("Hired", 0),
|
|
]
|
|
|
|
# --- 7. GAUGE CHART CALCULATION (Time-to-Hire) ---
|
|
|
|
current_days = avg_time_to_hire_days
|
|
rotation_percent = (
|
|
current_days / MAX_TIME_TO_HIRE_DAYS if MAX_TIME_TO_HIRE_DAYS > 0 else 0
|
|
)
|
|
rotation_degrees = rotation_percent * 180
|
|
rotation_degrees_final = round(
|
|
min(rotation_degrees, 180), 1
|
|
) # Ensure max 180 degrees
|
|
|
|
#
|
|
hiring_source_counts = application_queryset.values("hiring_source").annotate(
|
|
count=Count("stage")
|
|
)
|
|
source_map = {item["hiring_source"]: item["count"] for item in hiring_source_counts}
|
|
applications_count_in_each_source = [
|
|
source_map.get("Public", 0),
|
|
source_map.get("Internal", 0),
|
|
source_map.get("Agency", 0),
|
|
]
|
|
all_hiring_sources = ["Public", "Internal", "Agency"]
|
|
|
|
# --- 8. CONTEXT RETURN ---
|
|
|
|
context = {
|
|
# Global KPIs
|
|
"total_jobs_global": total_jobs_global,
|
|
# 'total_participants': total_participants,
|
|
"total_jobs_posted_linkedin": total_jobs_posted_linkedin,
|
|
# Scoped KPIs
|
|
"total_active_jobs": total_active_jobs,
|
|
"total_applications": total_applications,
|
|
"new_applications_7days": new_applications_7days,
|
|
"total_open_positions": total_open_positions,
|
|
"average_applications": average_applications,
|
|
"avg_time_to_hire_days": avg_time_to_hire_days,
|
|
"screening_pass_rate": screening_pass_rate,
|
|
"offers_accepted_rate": offers_accepted_rate,
|
|
"vacancy_fill_rate": vacancy_fill_rate,
|
|
"meetings_scheduled_this_week": meetings_scheduled_this_week,
|
|
"avg_match_score": avg_match_score,
|
|
"high_potential_count": high_potential_count,
|
|
"high_potential_ratio": high_potential_ratio,
|
|
"scored_ratio": scored_ratio,
|
|
# Chart Data
|
|
"application_stage": json.dumps(application_stage),
|
|
"application_count": json.dumps(application_count),
|
|
"job_titles": json.dumps(job_titles),
|
|
"job_app_counts": json.dumps(job_app_counts),
|
|
# 'source_volume_chart_data' is intentionally REMOVED
|
|
# Time Series Data
|
|
"global_dates": json.dumps(global_dates),
|
|
"global_counts": json.dumps(global_counts),
|
|
"scoped_dates": json.dumps(scoped_dates),
|
|
"scoped_counts": json.dumps(scoped_counts),
|
|
"is_job_scoped": bool(selected_job_pk),
|
|
# Gauge Data
|
|
"gauge_max_days": MAX_TIME_TO_HIRE_DAYS,
|
|
"gauge_target_days": TARGET_TIME_TO_HIRE_DAYS,
|
|
"gauge_rotation_degrees": rotation_degrees_final,
|
|
# UI Control
|
|
"jobs": all_jobs_queryset,
|
|
"current_job_id": selected_job_pk,
|
|
"current_job": current_job,
|
|
"applications_count_in_each_source": json.dumps(
|
|
applications_count_in_each_source
|
|
),
|
|
"all_hiring_sources": json.dumps(all_hiring_sources),
|
|
}
|
|
|
|
return render(request, "recruitment/dashboard.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def applications_offer_view(request, slug):
|
|
"""View for applications in the Offer stage"""
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
|
|
# Filter applications for this specific job and stage
|
|
applications = job.offer_applications
|
|
|
|
# Handle search
|
|
search_query = request.GET.get("search", "")
|
|
if search_query:
|
|
applications = applications.filter(
|
|
Q(first_name=search_query)
|
|
| Q(last_name__icontains=search_query)
|
|
| Q(email__icontains=search_query)
|
|
| Q(phone=search_query)
|
|
)
|
|
|
|
applications = applications.order_by("-created_at")
|
|
|
|
context = {
|
|
"job": job,
|
|
"applications": applications,
|
|
"search_query": search_query,
|
|
"current_stage": "Offer",
|
|
}
|
|
return render(request, "recruitment/applications_offer_view.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def applications_hired_view(request, slug):
|
|
"""View for hired applications"""
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
|
|
# Filter applications with offer_status = 'Accepted'
|
|
applications = job.hired_applications
|
|
|
|
# Handle search
|
|
search_query = request.GET.get("search", "")
|
|
if search_query:
|
|
applications = applications.filter(
|
|
Q(first_name=search_query)
|
|
| Q(last_name__icontains=search_query)
|
|
| Q(email__icontains=search_query)
|
|
| Q(phone=search_query)
|
|
)
|
|
|
|
applications = applications.order_by("-created_at")
|
|
|
|
context = {
|
|
"job": job,
|
|
"applications": applications,
|
|
"search_query": search_query,
|
|
"current_stage": "Hired",
|
|
}
|
|
return render(request, "recruitment/applications_hired_view.html", context)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def update_application_status(request, job_slug, application_slug, stage_type, status):
|
|
"""Handle exam/interview/offer status updates"""
|
|
from django.utils import timezone
|
|
|
|
job = get_object_or_404(JobPosting, slug=job_slug)
|
|
application = get_object_or_404(Application, slug=application_slug, job=job)
|
|
|
|
if request.method == "POST":
|
|
if stage_type == "exam":
|
|
status = request.POST.get("exam_status")
|
|
score = request.POST.get("exam_score")
|
|
application.exam_status = status
|
|
application.exam_score = score
|
|
application.exam_date = timezone.now()
|
|
application.save(update_fields=["exam_status", "exam_score", "exam_date"])
|
|
return render(
|
|
request,
|
|
"recruitment/partials/exam-results.html",
|
|
{"application": application, "job": job},
|
|
)
|
|
elif stage_type == "interview":
|
|
application.interview_status = status
|
|
application.interview_date = timezone.now()
|
|
application.save(update_fields=["interview_status", "interview_date"])
|
|
return render(
|
|
request,
|
|
"recruitment/partials/interview-results.html",
|
|
{"application": application, "job": job},
|
|
)
|
|
elif stage_type == "offer":
|
|
application.offer_status = status
|
|
application.offer_date = timezone.now()
|
|
application.save(update_fields=["offer_status", "offer_date"])
|
|
return render(
|
|
request,
|
|
"recruitment/partials/offer-results.html",
|
|
{"application": application, "job": job},
|
|
)
|
|
return redirect("application_detail", application.slug)
|
|
else:
|
|
if stage_type == "exam":
|
|
return render(
|
|
request,
|
|
"includes/applications_update_exam_form.html",
|
|
{"application": application, "job": job},
|
|
)
|
|
elif stage_type == "interview":
|
|
return render(
|
|
request,
|
|
"includes/applications_update_interview_form.html",
|
|
{"application": application, "job": job},
|
|
)
|
|
elif stage_type == "offer":
|
|
return render(
|
|
request,
|
|
"includes/applications_update_offer_form.html",
|
|
{"application": application, "job": job},
|
|
)
|
|
|
|
|
|
# Stage configuration for CSV export
|
|
STAGE_CONFIG = {
|
|
"screening": {
|
|
"filter": {"stage": "Applied"},
|
|
"fields": [
|
|
"name",
|
|
"email",
|
|
"phone",
|
|
"created_at",
|
|
"stage",
|
|
"ai_score",
|
|
"years_experience",
|
|
"screening_rating",
|
|
"professional_category",
|
|
"top_skills",
|
|
"strengths",
|
|
"weaknesses",
|
|
],
|
|
"headers": [
|
|
"Name",
|
|
"Email",
|
|
"Phone",
|
|
"Application Date",
|
|
"Screening Status",
|
|
"Match Score",
|
|
"Years Experience",
|
|
"Screening Rating",
|
|
"Professional Category",
|
|
"Top 3 Skills",
|
|
"Strengths",
|
|
"Weaknesses",
|
|
],
|
|
},
|
|
"exam": {
|
|
"filter": {"stage": "Exam"},
|
|
"fields": [
|
|
"name",
|
|
"email",
|
|
"phone",
|
|
"created_at",
|
|
"exam_status",
|
|
"exam_date",
|
|
"ai_score",
|
|
"years_experience",
|
|
"screening_rating",
|
|
],
|
|
"headers": [
|
|
"Name",
|
|
"Email",
|
|
"Phone",
|
|
"Application Date",
|
|
"Exam Status",
|
|
"Exam Date",
|
|
"Match Score",
|
|
"Years Experience",
|
|
"Screening Rating",
|
|
],
|
|
},
|
|
"interview": {
|
|
"filter": {"stage": "Interview"},
|
|
"fields": [
|
|
"name",
|
|
"email",
|
|
"phone",
|
|
"created_at",
|
|
"interview_status",
|
|
"interview_date",
|
|
"ai_score",
|
|
"years_experience",
|
|
"professional_category",
|
|
"top_skills",
|
|
],
|
|
"headers": [
|
|
"Name",
|
|
"Email",
|
|
"Phone",
|
|
"Application Date",
|
|
"Interview Status",
|
|
"Interview Date",
|
|
"Match Score",
|
|
"Years Experience",
|
|
"Professional Category",
|
|
"Top 3 Skills",
|
|
],
|
|
},
|
|
"offer": {
|
|
"filter": {"stage": "Offer"},
|
|
"fields": [
|
|
"name",
|
|
"email",
|
|
"phone",
|
|
"created_at",
|
|
"offer_status",
|
|
"offer_date",
|
|
"ai_score",
|
|
"years_experience",
|
|
"professional_category",
|
|
],
|
|
"headers": [
|
|
"Name",
|
|
"Email",
|
|
"Phone",
|
|
"Application Date",
|
|
"Offer Status",
|
|
"Offer Date",
|
|
"Match Score",
|
|
"Years Experience",
|
|
"Professional Category",
|
|
],
|
|
},
|
|
"hired": {
|
|
"filter": {"offer_status": "Accepted"},
|
|
"fields": [
|
|
"name",
|
|
"email",
|
|
"phone",
|
|
"created_at",
|
|
"offer_date",
|
|
"ai_score",
|
|
"years_experience",
|
|
"professional_category",
|
|
"join_date",
|
|
],
|
|
"headers": [
|
|
"Name",
|
|
"Email",
|
|
"Phone",
|
|
"Application Date",
|
|
"Hire Date",
|
|
"Match Score",
|
|
"Years Experience",
|
|
"Professional Category",
|
|
"Join Date",
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def export_applications_csv(request, slug, stage):
|
|
"""Export applications for a specific stage as CSV"""
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
|
|
# Validate stage
|
|
if stage not in STAGE_CONFIG:
|
|
messages.error(request, "Invalid stage specified for export.")
|
|
return redirect("job_detail", job.slug)
|
|
|
|
config = STAGE_CONFIG[stage]
|
|
|
|
# Filter applications based on stage
|
|
if stage == "hired":
|
|
applications = job.applications.filter(**config["filter"])
|
|
else:
|
|
applications = job.applications.filter(**config["filter"])
|
|
|
|
# Handle search if provided
|
|
search_query = request.GET.get("search", "")
|
|
if search_query:
|
|
applications = applications.filter(
|
|
Q(first_name=search_query)
|
|
| Q(last_name__icontains=search_query)
|
|
| Q(email__icontains=search_query)
|
|
| Q(phone=search_query)
|
|
)
|
|
|
|
applications = applications.order_by("-created_at")
|
|
|
|
# Create CSV response
|
|
response = HttpResponse(content_type="text/csv")
|
|
filename = f"{slugify(job.title)}_{stage}_{datetime.now().strftime('%Y-%m-%d')}.csv"
|
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
|
|
|
# Write UTF-8 BOM for Excel compatibility
|
|
response.write("\ufeff")
|
|
|
|
writer = csv.writer(response)
|
|
|
|
# Write headers
|
|
headers = config["headers"].copy()
|
|
headers.extend(["Job Title", "Department"])
|
|
writer.writerow(headers)
|
|
|
|
# Write application data
|
|
for application in applications:
|
|
row = []
|
|
|
|
# Extract data based on stage configuration
|
|
for field in config["fields"]:
|
|
if field == "name":
|
|
row.append(application.name)
|
|
elif field == "email":
|
|
row.append(application.email)
|
|
elif field == "phone":
|
|
row.append(application.phone)
|
|
elif field == "created_at":
|
|
row.append(
|
|
application.created_at.strftime("%Y-%m-%d %H:%M")
|
|
if application.created_at
|
|
else ""
|
|
)
|
|
elif field == "stage":
|
|
row.append(application.stage or "")
|
|
elif field == "exam_status":
|
|
row.append(application.exam_status or "")
|
|
elif field == "exam_date":
|
|
row.append(
|
|
application.exam_date.strftime("%Y-%m-%d %H:%M")
|
|
if application.exam_date
|
|
else ""
|
|
)
|
|
elif field == "interview_status":
|
|
row.append(application.interview_status or "")
|
|
elif field == "interview_date":
|
|
row.append(
|
|
application.interview_date.strftime("%Y-%m-%d %H:%M")
|
|
if application.interview_date
|
|
else ""
|
|
)
|
|
elif field == "offer_status":
|
|
row.append(application.offer_status or "")
|
|
elif field == "offer_date":
|
|
row.append(
|
|
application.offer_date.strftime("%Y-%m-%d %H:%M")
|
|
if application.offer_date
|
|
else ""
|
|
)
|
|
elif field == "ai_score":
|
|
# Extract AI score using model property
|
|
try:
|
|
score = application.match_score
|
|
row.append(f"{score}%" if score else "")
|
|
except:
|
|
row.append("")
|
|
elif field == "years_experience":
|
|
# Extract years of experience using model property
|
|
try:
|
|
years = application.years_of_experience
|
|
row.append(f"{years}" if years else "")
|
|
except:
|
|
row.append("")
|
|
elif field == "screening_rating":
|
|
# Extract screening rating using model property
|
|
try:
|
|
rating = application.screening_stage_rating
|
|
row.append(rating if rating else "")
|
|
except:
|
|
row.append("")
|
|
elif field == "professional_category":
|
|
# Extract professional category using model property
|
|
try:
|
|
category = application.professional_category
|
|
row.append(category if category else "")
|
|
except:
|
|
row.append("")
|
|
elif field == "top_skills":
|
|
# Extract top 3 skills using model property
|
|
try:
|
|
skills = application.top_3_keywords
|
|
row.append(", ".join(skills) if skills else "")
|
|
except:
|
|
row.append("")
|
|
elif field == "strengths":
|
|
# Extract strengths using model property
|
|
try:
|
|
strengths = application.strengths
|
|
row.append(strengths if strengths else "")
|
|
except:
|
|
row.append("")
|
|
elif field == "weaknesses":
|
|
# Extract weaknesses using model property
|
|
try:
|
|
weaknesses = application.weaknesses
|
|
row.append(weaknesses if weaknesses else "")
|
|
except:
|
|
row.append("")
|
|
elif field == "join_date":
|
|
row.append(
|
|
application.join_date.strftime("%Y-%m-%d")
|
|
if application.join_date
|
|
else ""
|
|
)
|
|
else:
|
|
row.append(getattr(application, field, ""))
|
|
|
|
# Add job information
|
|
row.extend([job.title, job.department or ""])
|
|
|
|
writer.writerow(row)
|
|
|
|
return response
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def sync_hired_applications(request, job_slug):
|
|
"""Sync hired applications to external sources using Django-Q"""
|
|
from django_q.tasks import async_task
|
|
from .tasks import sync_hired_candidates_task
|
|
|
|
if request.method == "POST":
|
|
job = get_object_or_404(JobPosting, slug=job_slug)
|
|
|
|
try:
|
|
# Enqueue sync task to Django-Q for background processing
|
|
task_id = async_task(
|
|
sync_hired_candidates_task,
|
|
job_slug,
|
|
group=f"sync_job_{job_slug}",
|
|
timeout=300, # 5 minutes timeout
|
|
)
|
|
print("task_id", task_id)
|
|
# Return immediate response with task ID for tracking
|
|
return JsonResponse(
|
|
{
|
|
"status": "queued",
|
|
"message": "Sync task has been queued for background processing",
|
|
"task_id": task_id,
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
return JsonResponse(
|
|
{"status": "error", "message": f"Failed to queue sync task: {str(e)}"},
|
|
status=500,
|
|
)
|
|
|
|
# For GET requests, return error
|
|
return JsonResponse(
|
|
{"status": "error", "message": "Only POST requests are allowed"}, status=405
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def test_source_connection(request, source_id):
|
|
"""Test connection to an external source"""
|
|
from .candidate_sync_service import CandidateSyncService
|
|
|
|
if request.method == "POST":
|
|
source = get_object_or_404(Source, id=source_id)
|
|
|
|
try:
|
|
# Initialize sync service
|
|
sync_service = CandidateSyncService()
|
|
|
|
# Test connection
|
|
result = sync_service.test_source_connection(source)
|
|
|
|
# Return JSON response
|
|
return JsonResponse({"status": "success", "result": result})
|
|
|
|
except Exception as e:
|
|
return JsonResponse(
|
|
{"status": "error", "message": f"Connection test failed: {str(e)}"},
|
|
status=500,
|
|
)
|
|
|
|
# For GET requests, return error
|
|
return JsonResponse(
|
|
{"status": "error", "message": "Only POST requests are allowed"}, status=405
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def sync_task_status(request, task_id):
|
|
"""Check the status of a sync task"""
|
|
from django_q.models import Task
|
|
|
|
try:
|
|
# Get the task from Django-Q
|
|
task = Task.objects.get(pk=task_id)
|
|
print("task", task)
|
|
|
|
# Determine status based on task state
|
|
if task.success:
|
|
status = "completed"
|
|
message = "Sync completed successfully"
|
|
result = task.result
|
|
elif task.stopped:
|
|
status = "failed"
|
|
message = "Sync task failed or was stopped"
|
|
result = task.result
|
|
elif task.started:
|
|
status = "running"
|
|
message = "Sync is currently running"
|
|
result = None
|
|
else:
|
|
status = "pending"
|
|
message = "Sync task is queued and waiting to start"
|
|
result = None
|
|
print("result", result)
|
|
return JsonResponse(
|
|
{
|
|
"status": status,
|
|
"message": message,
|
|
"result": result,
|
|
"task_id": task_id,
|
|
"started": task.started,
|
|
"stopped": task.stopped,
|
|
"success": task.success,
|
|
}
|
|
)
|
|
|
|
except Task.DoesNotExist:
|
|
return JsonResponse(
|
|
{"status": "error", "message": "Task not found"}, status=404
|
|
)
|
|
|
|
except Exception as e:
|
|
return JsonResponse(
|
|
{"status": "error", "message": f"Failed to check task status: {str(e)}"},
|
|
status=500,
|
|
)
|
|
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def sync_history(request, job_slug=None):
|
|
"""View sync history and logs"""
|
|
from .models import IntegrationLog
|
|
from django_q.models import Task
|
|
|
|
# Get sync logs
|
|
if job_slug:
|
|
# Filter for specific job
|
|
job = get_object_or_404(JobPosting, slug=job_slug)
|
|
logs = IntegrationLog.objects.filter(
|
|
action=IntegrationLog.ActionChoices.SYNC, request_data__job_slug=job_slug
|
|
).order_by("-created_at")
|
|
else:
|
|
# Get all sync logs
|
|
logs = IntegrationLog.objects.filter(
|
|
action=IntegrationLog.ActionChoices.SYNC
|
|
).order_by("-created_at")
|
|
|
|
# Get recent sync tasks
|
|
recent_tasks = Task.objects.filter(group__startswith="sync_job_").order_by(
|
|
"-started"
|
|
)[:20]
|
|
|
|
context = {
|
|
"logs": logs,
|
|
"recent_tasks": recent_tasks,
|
|
"job": job if job_slug else None,
|
|
}
|
|
|
|
return render(request, "recruitment/sync_history.html", context)
|
|
|
|
|
|
# def send_interview_email(request, slug):
|
|
# from django.conf import settings
|
|
# schedule = get_object_or_404(ScheduledInterview, slug=slug)
|
|
# application = schedule.application
|
|
# job = application.job
|
|
# form = InterviewEmailForm(job, application, schedule)
|
|
# if request.method == "POST":
|
|
# form = InterviewEmailForm(job, application, schedule, request.POST)
|
|
# if form.is_valid():
|
|
# recipient = form.cleaned_data.get("to").strip()
|
|
# body_message = form.cleaned_data.get("message")
|
|
# subject = form.cleaned_data.get("subject")
|
|
# sender_user = request.user
|
|
# job = job
|
|
# try:
|
|
|
|
# # Send email using background task
|
|
# email_result= async_task(
|
|
# "recruitment.tasks.send_bulk_email_task",
|
|
# recipient,
|
|
# subject,
|
|
# # message,
|
|
# "emails/email_template.html",
|
|
# {
|
|
# "job": job,
|
|
# "applications": application,
|
|
# "email_message":body_message,
|
|
# "logo_url": settings.STATIC_URL + "image/kaauh.png",
|
|
# },
|
|
# )
|
|
|
|
# if email_result:
|
|
# messages.success(request, "Message sent successfully via email!")
|
|
# else:
|
|
# messages.warning(
|
|
# request,
|
|
# f"email failed: {email_result.get('message', 'Unknown error')}",
|
|
# )
|
|
|
|
# except Exception as e:
|
|
# messages.warning(
|
|
# request, f"Message saved but email sending failed: {str(e)}"
|
|
# )
|
|
# else:
|
|
# form = InterviewEmailForm(job, application, schedule)
|
|
# else: # GET request
|
|
# form = InterviewEmailForm(job, application, schedule)
|
|
|
|
# # This is the final return, which handles GET requests and invalid POST requests.
|
|
# return redirect("interview_detail", slug=schedule.slug)
|
|
|
|
|
|
def send_interview_email(request, slug):
|
|
from django.conf import settings
|
|
from django_q.tasks import async_task
|
|
|
|
schedule = get_object_or_404(ScheduledInterview, slug=slug)
|
|
application = schedule.application
|
|
job = application.job
|
|
|
|
if request.method == "POST":
|
|
form = InterviewEmailForm(job, application, schedule, request.POST)
|
|
if form.is_valid():
|
|
# 1. Ensure recipient is a list (fixes the "@" error)
|
|
recipient_str = form.cleaned_data.get("to").strip()
|
|
recipient_list = [recipient_str]
|
|
|
|
body_message = form.cleaned_data.get("message")
|
|
subject = form.cleaned_data.get("subject")
|
|
|
|
try:
|
|
# 2. Match the context expected by your task/service
|
|
# We pass IDs for the sender/job to avoid serialization issues
|
|
async_task(
|
|
"recruitment.tasks.send_email_task",
|
|
recipient_list,
|
|
subject,
|
|
"emails/email_template.html",
|
|
{
|
|
"job": job, # Useful for Message creation
|
|
"sender_user": request.user,
|
|
"applications": application,
|
|
"email_message": body_message,
|
|
"message_created":False,
|
|
"logo_url": settings.STATIC_URL + "image/kaauh.png",
|
|
},
|
|
)
|
|
|
|
messages.success(request, "Interview email enqueued successfully!")
|
|
return redirect("interview_detail", slug=schedule.slug)
|
|
|
|
except Exception as e:
|
|
messages.error(request, f"Task scheduling failed: {str(e)}")
|
|
else:
|
|
messages.error(request, "Please correct the errors in the form.")
|
|
else:
|
|
# GET request
|
|
form = InterviewEmailForm(job, application, schedule)
|
|
|
|
# 3. FIX: Instead of always redirecting, render the template
|
|
# This allows users to see validation errors.
|
|
return render(
|
|
request,
|
|
"recruitment/interview_email_form.html", # Replace with your actual template path
|
|
{
|
|
"form": form,
|
|
"schedule": schedule,
|
|
"job": job
|
|
}
|
|
)
|
|
|
|
@login_required
|
|
@staff_user_required
|
|
def compose_application_email(request, slug):
|
|
"""Compose email to participants about a candidate"""
|
|
from django.conf import settings
|
|
|
|
job = get_object_or_404(JobPosting, slug=slug)
|
|
candidate_ids = request.GET.getlist("candidate_ids")
|
|
candidates = Application.objects.filter(id__in=candidate_ids)
|
|
|
|
if request.method == "POST":
|
|
candidate_ids = request.POST.getlist("candidate_ids")
|
|
|
|
applications = Application.objects.filter(id__in=candidate_ids)
|
|
form = CandidateEmailForm(job, applications, request.POST)
|
|
|
|
if form.is_valid():
|
|
# Get email addresses
|
|
email_addresses = form.get_email_addresses()
|
|
|
|
if not email_addresses:
|
|
messages.error(request, "No email selected")
|
|
referer = request.META.get("HTTP_REFERER")
|
|
if "HX-Request" in request.headers:
|
|
response = HttpResponse()
|
|
response.headers["HX-Refresh"] = "true"
|
|
return response
|
|
if referer:
|
|
# Redirect back to the referring page
|
|
return redirect(referer)
|
|
else:
|
|
return redirect("dashboard")
|
|
|
|
subject = form.cleaned_data.get("subject")
|
|
message = form.get_formatted_message()
|
|
|
|
|
|
async_task(
|
|
"recruitment.tasks.send_email_task",
|
|
email_addresses,
|
|
subject,
|
|
# message,
|
|
"emails/email_template.html",
|
|
{
|
|
"job": job,
|
|
"sender_user": request.user,
|
|
"applications": applications,
|
|
"email_message": message,
|
|
"message_created":False,
|
|
"logo_url": settings.STATIC_URL + "image/kaauh.png",
|
|
|
|
},
|
|
)
|
|
if "HX-Request" in request.headers:
|
|
response = HttpResponse()
|
|
response.headers["HX-Refresh"] = "true"
|
|
return response
|
|
return redirect(request.path)
|
|
|
|
else:
|
|
# Form validation errors
|
|
messages.error(request, "Please correct the errors below.")
|
|
|
|
# For HTMX requests, return error response
|
|
if "HX-Request" in request.headers:
|
|
response = HttpResponse()
|
|
response.headers["HX-Refresh"] = "true"
|
|
return response
|
|
|
|
return render(
|
|
request,
|
|
"includes/email_compose_form.html",
|
|
{"form": form, "job": job, "candidates": candidates},
|
|
)
|
|
|
|
else:
|
|
# GET request - show the form
|
|
form = CandidateEmailForm(job, candidates)
|
|
|
|
return render(
|
|
request,
|
|
"includes/email_compose_form.html",
|
|
# {"form": form, "job": job, "candidates": candidates},
|
|
{"form": form, "job": job},
|
|
)
|