2025-12-24 15:25:37 +03:00

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},
)