diff --git a/recruitment/forms.py b/recruitment/forms.py index 1e82e2c..cb6a8dd 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -1023,6 +1023,7 @@ class HiringAgencyForm(forms.ModelForm): def clean_email(self): """Validate email format and uniqueness""" email = self.cleaned_data.get("email") + instance=self.instance if email: # Check email format if not "@" in email or "." not in email.split("@")[1]: @@ -1031,8 +1032,16 @@ class HiringAgencyForm(forms.ModelForm): # Check uniqueness (optional - remove if multiple agencies can have same email) # instance = self.instance email = email.lower().strip() - if CustomUser.objects.filter(email=email).exists(): - raise ValidationError("This email is already associated with a user account.") + if not instance.pk: # Creating new instance + if HiringAgency.objects.filter(email=email).exists(): + raise ValidationError("An agency with this email already exists.") + else: # Editing existing instance + if ( + HiringAgency.objects.filter(email=email) + .exclude(pk=instance.pk) + .exists() + ): + raise ValidationError("An agency with this email already exists.") # if not instance.pk: # Creating new instance # if HiringAgency.objects.filter(email=email).exists(): # raise ValidationError("An agency with this email already exists.") @@ -2293,6 +2302,11 @@ class ApplicantSignupForm(forms.ModelForm): raise forms.ValidationError("Passwords do not match.") return cleaned_data + def email_clean(self): + email = self.cleaned_data.get('email') + if CustomUser.objects.filter(email=email).exists(): + raise forms.ValidationError("Email is already in use.") + return email class DocumentUploadForm(forms.ModelForm): diff --git a/recruitment/views.py b/recruitment/views.py index 325d45b..83e07bc 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -160,6 +160,13 @@ class PersonListView(StaffRequiredMixin, ListView): context_object_name = "people_list" def get_queryset(self): queryset=super().get_queryset() + search_query=self.request.GET.get('search','') + if search_query: + queryset=queryset.filter( + Q(first_name__icontains=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) @@ -179,6 +186,7 @@ class PersonListView(StaffRequiredMixin, ListView): nationality=self.request.GET.get('nationality') context['nationality']=nationality context['nationalities']=nationalities + context['search_query'] = self.request.GET.get('search', '') return context @@ -630,7 +638,7 @@ def job_detail(request, slug): # New statistics "avg_match_score": avg_match_score, "high_potential_count": high_potential_count, - "high_potential_ratio": high_potential_ratio, + # "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, @@ -700,6 +708,10 @@ def request_cvs_download(request, slug): 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 @@ -711,6 +723,9 @@ def download_ready_cvs(request, slug): View to serve the file once it is ready. """ job = get_object_or_404(JobPosting, slug=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 @@ -3324,27 +3339,27 @@ def agency_detail(request, slug): """View details of a specific hiring agency""" agency = get_object_or_404(HiringAgency, slug=slug) - # Get candidates associated with this agency - candidates = Application.objects.filter(hiring_agency=agency).order_by( + # Get applications associated with this agency + applications = Application.objects.filter(hiring_agency=agency).order_by( "-created_at" ) # Statistics - total_candidates = candidates.count() - active_candidates = candidates.filter( + total_applications = applications.count() + active_applications = applications.filter( stage__in=["Applied", "Screening", "Exam", "Interview", "Offer"] ).count() - hired_candidates = candidates.filter(stage="Hired").count() - rejected_candidates = candidates.filter(stage="Rejected").count() + hired_applications = applications.filter(stage="Hired").count() + rejected_applications = applications.filter(stage="Rejected").count() job_assignments=AgencyJobAssignment.objects.filter(agency=agency) print(job_assignments) context = { "agency": agency, - "candidates": candidates[:10], # Show recent 10 candidates - "total_candidates": total_candidates, - "active_candidates": active_candidates, - "hired_candidates": hired_candidates, - "rejected_candidates": rejected_candidates, + "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, @@ -4343,7 +4358,7 @@ def agency_portal_submit_application_page(request, slug): "total_submitted": total_submitted, "job": assignment.job, } - return render(request, "recruitment/agency_portal_submit_candidate.html", context) + return render(request, "recruitment/agency_portal_submit_application.html", context) @agency_user_required @@ -4407,7 +4422,7 @@ def agency_portal_submit_application(request): "title": f"Submit Candidate for {assignment.job.title}", "button_text": "Submit Candidate", } - return render(request, "recruitment/agency_portal_submit_candidate.html", context) + return render(request, "recruitment/agency_portal_submit_application.html", context) def agency_portal_assignment_detail(request, slug): @@ -4450,7 +4465,7 @@ def agency_assignment_detail_agency(request, slug, assignment_id): return redirect("agency_portal_dashboard") # Get candidates submitted by this agency for this job - candidates = Application.objects.filter( + applications = Application.objects.filter( hiring_agency=assignment.agency, job=assignment.job ).order_by("-created_at") @@ -4461,7 +4476,7 @@ def agency_assignment_detail_agency(request, slug, assignment_id): # No messages to mark as read # Pagination for candidates - paginator = Paginator(candidates, 20) # Show 20 candidates per page + paginator = Paginator(applications, 20) # Show 20 candidates per page page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) @@ -4471,12 +4486,12 @@ def agency_assignment_detail_agency(request, slug, assignment_id): message_page_obj = message_paginator.get_page(message_page_number) # Calculate progress ring offset for circular progress indicator - total_candidates = candidates.count() - max_candidates = assignment.max_candidates + total_applications = applications.count() + max_applications = assignment.max_candidates circumference = 326.73 # 2 * π * r where r=52 - if max_candidates > 0: - progress_percentage = total_candidates / max_candidates + if max_applications > 0: + progress_percentage = total_applications / max_applications stroke_dashoffset = circumference - (circumference * progress_percentage) else: stroke_dashoffset = circumference @@ -4485,8 +4500,9 @@ def agency_assignment_detail_agency(request, slug, assignment_id): "assignment": assignment, "page_obj": page_obj, "message_page_obj": message_page_obj, - "total_candidates": total_candidates, + "total_applications": total_applications, "stroke_dashoffset": stroke_dashoffset, + "max_applications": max_applications, } return render(request, "recruitment/agency_portal_assignment_detail.html", context) @@ -4667,7 +4683,7 @@ def message_list(request): "search_query": search_query, } if request.user.user_type != "staff": - return render(request, "messages/candidate_message_list.html", context) + return render(request, "messages/application_message_list.html", context) return render(request, "messages/message_list.html", context) @@ -4749,7 +4765,7 @@ def message_create(request): "form": form, } if request.user.user_type != "staff": - return render(request, "messages/candidate_message_form.html", context) + return render(request, "messages/application_message_form.html", context) return render(request, "messages/message_form.html", context) @@ -4817,7 +4833,7 @@ def message_reply(request, message_id): "parent_message": parent_message, } if request.user.user_type != "staff": - return render(request, "messages/candidate_message_form.html", context) + return render(request, "messages/application_message_form.html", context) return render(request, "messages/message_form.html", context) @@ -4875,50 +4891,93 @@ def message_mark_unread(request, message_id): @login_required def message_delete(request, message_id): - """Delete a message""" """ 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). """ - + # 1. Retrieve the message # Use select_related to fetch linked objects efficiently for checks/logging message = get_object_or_404( Message.objects.select_related("sender", "recipient"), id=message_id ) - # Check if user has permission to delete this message + # 2. Permission Check + # Only the sender or recipient can delete the message if message.sender != request.user and message.recipient != request.user: messages.error(request, "You don't have permission to delete this message.") - + # HTMX requests should handle redirection via client-side logic (hx-redirect) if "HX-Request" in request.headers: # Returning 403 or 400 is ideal, but 200 with an empty body is often accepted # by HTMX and the message is shown on the next page/refresh. - return HttpResponse(status=403) - + return HttpResponse(status=403) + # Standard navigation redirect return redirect("message_list") + # 3. Handle POST Request (Deletion) if request.method == "POST": message.delete() messages.success(request, "Message deleted successfully.") # Handle HTMX requests if "HX-Request" in request.headers: - return HttpResponse(status=200) # HTMX success response + # 1. Set the HTMX response header for redirection + response = HttpResponse(status=200) + response["HX-Redirect"] = reverse("message_list") # <--- EXPLICIT HEADER + return response - return redirect("message_list") + # Standard navigation fallback + return redirect("message_list") - # For GET requests, show confirmation page - context = { - "message": message, - "title": "Delete Message", - "message": f"Are you sure you want to delete this message from {message.sender.get_full_name() or message.sender.username}?", - "cancel_url": reverse("message_detail", kwargs={"message_id": message_id}), - } - return render(request, "messages/message_confirm_delete.html", context) +# @login_required +# def message_delete(request, message_id): +# """Delete a message""" +# """ +# 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). +# """ + +# # 1. Retrieve the message +# # Use select_related to fetch linked objects efficiently for checks/logging +# message = get_object_or_404( +# Message.objects.select_related("sender", "recipient"), id=message_id +# ) + +# # Check if user has permission to delete this message +# if message.sender != request.user and message.recipient != request.user: +# messages.error(request, "You don't have permission to delete this message.") + +# # HTMX requests should handle redirection via client-side logic (hx-redirect) +# if "HX-Request" in request.headers: +# # Returning 403 or 400 is ideal, but 200 with an empty body is often accepted +# # by HTMX and the message is shown on the next page/refresh. +# return HttpResponse(status=403) + +# # Standard navigation redirect +# return redirect("message_list") + +# if request.method == "POST": +# message.delete() +# messages.success(request, "Message deleted successfully.") + +# # Handle HTMX requests +# if "HX-Request" in request.headers: +# return HttpResponse(status=200) # HTMX success response + +# return redirect("message_list") + +# # For GET requests, show confirmation page +# context = { +# "message": message, +# "title": "Delete Message", +# "message": f"Are you sure you want to delete this message from {message.sender.get_full_name() or message.sender.username}?", +# "cancel_url": reverse("message_detail", kwargs={"message_id": message_id}), +# } +# return render(request, "messages/message_confirm_delete.html", context) @login_required @@ -5038,7 +5097,7 @@ def document_upload(request, slug): if upload_target == 'person': return redirect("applicant_portal_dashboard") else: - return redirect("applicant_application_detail", application_slug=application.slug) + return redirect("applicant_application_detail", slug=application.slug) # Handle GET request for AJAX if request.headers.get("X-Requested-With") == "XMLHttpRequest": @@ -5050,6 +5109,7 @@ def document_upload(request, slug): def document_delete(request, document_id): """Delete a document""" document = get_object_or_404(Document, id=document_id) + print(document) # Initialize variables for redirection outside of the complex logic is_htmx = "HX-Request" in request.headers @@ -5655,8 +5715,8 @@ def source_update(request, slug): context = { "form": form, "source": source, - "title": f"Edit Source: {source.name}", - "button_text": "Update Source", + "title": _("Edit Source: %(name)s") % {'name': source.name}, + "button_text": _("Update Source"), } return render(request, "recruitment/source_form.html", context) @@ -5674,8 +5734,8 @@ def source_delete(request, slug): context = { "source": source, - "title": "Delete Source", - "message": f'Are you sure you want to delete the source "{source.name}"?', + "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) @@ -5775,6 +5835,12 @@ def application_signup(request, slug): "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( diff --git a/templates/applicant/career.html b/templates/applicant/career.html index 2d6e22f..ddaf7e4 100644 --- a/templates/applicant/career.html +++ b/templates/applicant/career.html @@ -201,10 +201,10 @@ {# Tag Badge (Prominent) #} - - - {% trans "Apply Before: " %}{{job.application_deadline}} + + {% trans "Apply Before: " %}{{job.application_deadline}} + {# Department/Context (Sub-text) #} diff --git a/templates/includes/document_list.html b/templates/includes/document_list.html index e6ad3ad..3e9c040 100644 --- a/templates/includes/document_list.html +++ b/templates/includes/document_list.html @@ -26,7 +26,7 @@
-

{% trans "Messages" %}

+

{% trans "Messages" %}

{% trans "Compose Message" %}
- -
+
@@ -41,41 +40,41 @@
-
- + +
-
-
+
-
{% trans "Total Messages" %}
-

{{ total_messages }}

+
{% trans "Total Messages" %}
+

{{ total_messages }}

-
+
-
{% trans "Unread Messages" %}
+
{% trans "Unread Messages" %}

{{ unread_messages }}

- -
+
{% if page_obj %}
@@ -93,14 +92,14 @@ {% for message in page_obj %} - + - {{ message.subject }} + class="{% if not message.is_read %}fw-bold text-primary-theme text-decoration-none{% endif %}"> + {{ message.subject|truncatechars:50 }} {% if message.parent_message %} - {% trans "Reply" %} + {% trans "Reply" %} {% endif %} {{ message.sender.get_full_name|default:message.sender.username }} @@ -120,27 +119,27 @@ {{ message.created_at|date:"M d, Y H:i" }}
- + {% if not message.is_read and message.recipient == request.user %} {% endif %} + class="btn btn-sm btn-outline-primary" title="{% trans 'Reply' %}"> - +
@@ -149,7 +148,7 @@ {% empty %} - +

{% trans "No messages found." %}

{% trans "Try adjusting your filters or compose a new message." %}

@@ -159,13 +158,12 @@
- {% if page_obj.has_other_pages %}
+
diff --git a/templates/recruitment/agency_form.html b/templates/recruitment/agency_form.html index 4f72f50..7cc1122 100644 --- a/templates/recruitment/agency_form.html +++ b/templates/recruitment/agency_form.html @@ -161,9 +161,9 @@
-

+

{{ title }} -
+
{% if agency %} @@ -186,9 +186,7 @@
{% trans "Currently Editing" %}
-
- -
+
{{ agency.name }}
{% if agency.contact_person %} diff --git a/templates/recruitment/agency_portal_assignment_detail.html b/templates/recruitment/agency_portal_assignment_detail.html index e403e7e..7c2f5bf 100644 --- a/templates/recruitment/agency_portal_assignment_detail.html +++ b/templates/recruitment/agency_portal_assignment_detail.html @@ -43,10 +43,10 @@ border-radius: 0.35rem; font-weight: 700; } - .status-ACTIVE { background-color: var(--kaauh-success); color: white; } - .status-EXPIRED { background-color: var(--kaauh-danger); color: white; } - .status-COMPLETED { background-color: var(--kaauh-info); color: white; } - .status-CANCELLED { background-color: var(--kaauh-warning); color: #856404; } + .status-ACTIVE { background-color: var(--kaauh-teal-dark); color: white; } + .status-EXPIRED { background-color: var(--kaauh-teal-dark); color: white; } + .status-COMPLETED { background-color: var(--kaauh-teal-dark); color: white; } + .status-CANCELLED { background-color: var(--kaauh-teal-dark); color: white; } .progress-ring { width: 120px; @@ -164,14 +164,14 @@ {% trans "Expired" %} {% else %} - + {{ assignment.days_remaining }} {% trans "days remaining" %} {% endif %}
-
{{ assignment.max_candidates }} {% trans "applications" %}
+
{{max_applications }} {% trans "applications" %}
@@ -228,7 +228,7 @@ {% trans "Submitted applications" %} ({{ total_applications }}) - {{ total_applications }}/{{ assignment.max_applications }} + {{ total_applications }}/{{ max_applications }}
{% if page_obj %} @@ -256,7 +256,7 @@
- {{ application.get_stage_display }} + {{ application.get_stage_display }}
@@ -361,12 +361,12 @@
{% widthratio total_applications assignment.max_candidates 100 as progress %} -
+
{% if assignment.can_submit %} - {% trans "Can Submit" %} + {% trans "Can Submit" %} {% else %} {% trans "Cannot Submit" %} {% endif %} @@ -500,7 +500,7 @@