update the bulk button to select

This commit is contained in:
ismail 2025-10-14 15:55:53 +03:00
parent d0db3d1323
commit 302aa8d0bf
16 changed files with 328 additions and 193 deletions

View File

@ -0,0 +1,14 @@
# Generated by Django 5.2.6 on 2025-10-14 11:03
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0010_alter_scheduledinterview_schedule'),
('recruitment', '0011_alter_jobpostingimage_job_and_more'),
]
operations = [
]

View File

@ -0,0 +1,21 @@
# Generated by Django 5.2.6 on 2025-10-14 11:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0012_merge_20251014_1403'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='formtemplate',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='form_templates', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -273,12 +273,12 @@ class Candidate(Base):
CANDIDATE = "Candidate", _("Candidate")
# Stage transition validation constants
STAGE_SEQUENCE = {
"Applied": ["Exam", "Interview", "Offer"],
"Exam": ["Interview", "Offer"],
"Interview": ["Offer"],
"Offer": [], # Final stage - no further transitions
}
# STAGE_SEQUENCE = {
# "Applied": ["Exam", "Interview", "Offer"],
# "Exam": ["Interview", "Offer"],
# "Interview": ["Offer"],
# "Offer": [], # Final stage - no further transitions
# }
job = models.ForeignKey(
JobPosting,
@ -375,50 +375,50 @@ class Candidate(Base):
return self.resume.size
return 0
def clean(self):
"""Validate stage transitions"""
# Only validate if this is an existing record (not being created)
if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
old_stage = self.__class__.objects.get(pk=self.pk).stage
allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
# def clean(self):
# """Validate stage transitions"""
# # Only validate if this is an existing record (not being created)
# if self.pk and self.stage != self.__class__.objects.get(pk=self.pk).stage:
# old_stage = self.__class__.objects.get(pk=self.pk).stage
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
if self.stage not in allowed_next_stages:
raise ValidationError(
{
"stage": f'Cannot transition from "{old_stage}" to "{self.stage}". '
f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}"
}
)
# if self.stage not in allowed_next_stages:
# raise ValidationError(
# {
# "stage": f'Cannot transition from "{old_stage}" to "{self.stage}". '
# f"Allowed transitions: {', '.join(allowed_next_stages) or 'None (final stage)'}"
# }
# )
# Validate that the stage is a valid choice
if self.stage not in [choice[0] for choice in self.Stage.choices]:
raise ValidationError(
{
"stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}"
}
)
# # Validate that the stage is a valid choice
# if self.stage not in [choice[0] for choice in self.Stage.choices]:
# raise ValidationError(
# {
# "stage": f"Invalid stage. Must be one of: {', '.join(choice[0] for choice in self.Stage.choices)}"
# }
# )
def save(self, *args, **kwargs):
"""Override save to ensure validation is called"""
self.clean() # Call validation before saving
super().save(*args, **kwargs)
def can_transition_to(self, new_stage):
"""Check if a stage transition is allowed"""
if not self.pk: # New record - can be in Applied stage
return new_stage == "Applied"
# def can_transition_to(self, new_stage):
# """Check if a stage transition is allowed"""
# if not self.pk: # New record - can be in Applied stage
# return new_stage == "Applied"
old_stage = self.__class__.objects.get(pk=self.pk).stage
allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
return new_stage in allowed_next_stages
# old_stage = self.__class__.objects.get(pk=self.pk).stage
# allowed_next_stages = self.STAGE_SEQUENCE.get(old_stage, [])
# return new_stage in allowed_next_stages
def get_available_stages(self):
"""Get list of stages this candidate can transition to"""
if not self.pk: # New record
return ["Applied"]
# def get_available_stages(self):
# """Get list of stages this candidate can transition to"""
# if not self.pk: # New record
# return ["Applied"]
old_stage = self.__class__.objects.get(pk=self.pk).stage
return self.STAGE_SEQUENCE.get(old_stage, [])
# old_stage = self.__class__.objects.get(pk=self.pk).stage
# return self.STAGE_SEQUENCE.get(old_stage, [])
@property
def submission(self):
@ -544,7 +544,7 @@ class FormTemplate(Base):
blank=True, help_text="Description of the form template"
)
created_by = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="form_templates"
User, on_delete=models.CASCADE, related_name="form_templates",null=True,blank=True
)
is_active = models.BooleanField(
default=False, help_text="Whether this template is active"

View File

@ -7,10 +7,10 @@ from .models import FormField,FormStage,FormTemplate,Candidate,JobPosting
logger = logging.getLogger(__name__)
@receiver(post_save, sender=JobPosting)
def create_form_for_job(sender, instance, created, **kwargs):
if created:
FormTemplate.objects.create(job=instance, is_active=True, name=instance.title)
# @receiver(post_save, sender=JobPosting)
# def create_form_for_job(sender, instance, created, **kwargs):
# if created:
# FormTemplate.objects.create(job=instance, is_active=True, name=instance.title)
@receiver(post_save, sender=Candidate)
def score_candidate_resume(sender, instance, created, **kwargs):
if not instance.is_resume_parsed:

View File

@ -17,7 +17,6 @@ urlpatterns = [
path('jobs/<slug:slug>/candidate/application/success', views.application_success, name='application_success'),
path('careers/',views.kaauh_career,name='kaauh_career'),
# LinkedIn Integration URLs
path('jobs/<slug:slug>/post-to-linkedin/', views.post_to_linkedin, name='post_to_linkedin'),
path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'),
@ -34,7 +33,6 @@ urlpatterns = [
path('candidate/<slug:slug>/view/', views_frontend.candidate_detail, name='candidate_detail'),
path('candidate/<slug:slug>/update-stage/', views_frontend.candidate_update_stage, name='candidate_update_stage'),
# Training URLs
path('training/', views_frontend.TrainingListView.as_view(), name='training_list'),
path('training/create/', views_frontend.TrainingCreateView.as_view(), name='training_create'),
@ -75,7 +73,8 @@ urlpatterns = [
path('htmx/<int:pk>/candidate_criteria_view/', views.candidate_criteria_view_htmx, name='candidate_criteria_view_htmx'),
path('htmx/<slug:slug>/candidate_set_exam_date/', views.candidate_set_exam_date, name='candidate_set_exam_date'),
path('htmx/bulk_candidate_move_to_exam/', views.bulk_candidate_move_to_exam, name='bulk_candidate_move_to_exam'),
path('htmx/<slug:slug>/candidate_update_status/', views.candidate_update_status, name='candidate_update_status'),
path('forms/form/<int:template_id>/submit/', views.submit_form, name='submit_form'),
path('forms/form/<int:template_id>/', views.form_wizard_view, name='form_wizard'),

View File

@ -5,7 +5,7 @@ from rich import print
from django.template.loader import render_to_string
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.http import JsonResponse
from django.http import HttpResponse, JsonResponse
from datetime import datetime,time,timedelta
from django.views import View
from django.db.models import Q
@ -262,12 +262,13 @@ def create_job(request):
else:
job.created_by = request.POST.get("created_by", "").strip()
if not job.created_by:
job.created_by = "University Administrator"
job.created_by = request.user.username
job.save()
job_apply_url_relative=reverse('job_detail_candidate',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=True, name=job.title,created_by=request.user)
job.save()
messages.success(request, f'Job "{job.title}" created successfully!')
return redirect("job_list")
@ -1646,12 +1647,10 @@ def candidate_screening_view(request, slug):
# This catches if the user enters non-numeric text (e.g., "abc")
min_ai_score = 0
tier1_count = 0
print(min_ai_score)
print(tier1_count)
# You can now safely use min_ai_score and tier1_count as integers (0 or greater)
if min_ai_score > 0:
candidates = candidates.filter(match_score__gte=min_ai_score)
print(candidates)
if tier1_count > 0:
candidates = candidates[:tier1_count]
@ -1697,7 +1696,6 @@ def update_candidate_exam_status(request, slug):
def bulk_update_candidate_exam_status(request,slug):
job = get_object_or_404(JobPosting, slug=slug)
status = request.headers.get('status')
if status:
for candidate in get_candidates_from_request(request):
try:
@ -1724,19 +1722,18 @@ def candidate_set_exam_date(request, slug):
messages.success(request, f"Set exam date for {candidate.name} to {candidate.exam_date}")
return redirect("candidate_screening_view", slug=candidate.job.slug)
def bulk_candidate_move_to_exam(request):
for candidate in get_candidates_from_request(request):
candidate.stage = "Exam"
candidate.applicant_status = "Candidate"
candidate.exam_date = timezone.now()
candidate.save()
def candidate_update_status(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
mark_as = request.POST.get('mark_as')
candidate_ids = request.POST.getlist("candidate_ids")
messages.success(request, f"Candidates Moved to Exam stage")
return redirect("candidate_screening_view", slug=candidate.job.slug)
# def response():
# yield SSE.patch_elements("","")
# yield SSE.execute_script("console.log('hello world');")
# return DatastarResponse(response())
if c := Candidate.objects.filter(pk__in = candidate_ids):
c.update(stage=mark_as,exam_date=timezone.now(),applicant_status="Candidate" if mark_as in ["Exam","Interview","Offer"] else "Applicant")
messages.success(request, f"Candidates Updated")
response = HttpResponse(redirect("candidate_screening_view", slug=job.slug))
response.headers["HX-Refresh"] = "true"
return response
def candidate_interview_view(request,slug):
job = get_object_or_404(JobPosting,slug=slug)

View File

@ -588,7 +588,7 @@
});
</script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.5/bundles/datastar.js"></script>
{% comment %} <script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.5/bundles/datastar.js"></script> {% endcomment %}
{% block customJS %}{% endblock %}

View File

@ -253,32 +253,23 @@
<div class="kaauh-card shadow-sm p-3">
<div class="candidate-table-responsive" data-signals__ifmissing="{_fetching: false, selections: Array({{ candidates|length }}).fill(false)}">
{% url "bulk_update_candidate_exam_status" job.slug as bulk_update_candidate_exam_status_url %}
<div class="mb-3 d-flex gap-2">
<div class="col-md-3 col-sm-6 mb-3 d-flex gap-2">
{% if candidates %}
<button class="btn btn-bulk-pass btn-sm"
data-attr="{disabled: !$selections.filter(Boolean).length}"
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
contentType: 'form',
selector: '#candidate-form',
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'Passed'}
})"
>
<i class="fas fa-check-circle me-1"></i>
{% trans "Bulk Mark Passed (-> Interview)" %}
</button>
<button class="btn btn-bulk-fail btn-sm"
data-attr="{disabled: !$selections.filter(Boolean).length}"
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
contentType: 'form',
selector: '#candidate-form',
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'Failed'}
})"
>
<i class="fas fa-times-circle me-1"></i>
{% trans "Bulk Mark Failed" %}
</button>
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post">
<div class="d-flex align-items-center">
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="height: 3rem;">
<option value="Applied">
<i class="fas fa-arrow-left me-1"></i> {% trans "Apply" %}
</option>
<option value="Interview">
<i class="fas fa-arrow-right me-1"></i> {% trans "Interview" %}
</option>
</select>
<button type="submit" class="btn btn-main-action btn-mds ms-2">
<i class="fas fa-arrow-right me-1"></i> {% trans "Update" %}
</button>
</div>
</form>
{% endif %}
</div>
@ -291,11 +282,7 @@
{% if candidates %}
<div class="form-check">
<input
data-bind-_all
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
data-effect="$selections; $_all = $selections.every(Boolean)"
data-attr-disabled="$_fetching"
type="checkbox" class="form-check-input" id="checkAll">
type="checkbox" class="form-check-input" id="selectAllCheckbox">
</div>
{% endif %}
</th>
@ -312,13 +299,11 @@
<tr>
<td>
<div class="form-check">
<input
data-bind-selections
data-attr-disabled="$_fetching"
name="candidate_ids"
value="{{ candidate.id }}"
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
</div>
<input
name="candidate_ids"
value="{{ candidate.id }}"
type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}">
</div>
</td>
<td>
<div class="candidate-name">
@ -412,3 +397,69 @@
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
if (selectAllCheckbox) {
// Function to safely update the header checkbox state
function updateSelectAllState() {
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
const totalCount = rowCheckboxes.length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
// Set to indeterminate state (partially checked)
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
// IMPORTANT: We do NOT fire a change event here to prevent the infinite loop.
// Your existing data-bind-_all logic should handle the bulk action status.
}
// 1. Logic for the 'Select All' checkbox (Clicking it updates all rows)
selectAllCheckbox.addEventListener('change', function () {
const isChecked = selectAllCheckbox.checked;
// Temporarily disable the change listener on rows to prevent cascading events
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
// Update all row checkboxes
rowCheckboxes.forEach(function (checkbox) {
checkbox.checked = isChecked;
// You must still dispatch the event here so your framework's data-bind-selections
// picks up the change on individual elements. This should NOT trigger the updateSelectAllState.
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
});
// Re-attach the change listeners to the rows
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
// Ensure the header state is correct after forcing all changes
updateSelectAllState();
});
// 2. Logic to update 'Select All' state based on row checkboxes
// Attach the function to be called whenever a row checkbox changes
rowCheckboxes.forEach(function (checkbox) {
checkbox.addEventListener('change', updateSelectAllState);
});
// Initial check to set the correct state on load (in case items are pre-checked)
updateSelectAllState();
}
});
</script>
{% endblock %}

View File

@ -194,48 +194,40 @@
</a>
</div>
<div class="filter-controls shadow-sm">
<h4 class="h6 mb-3 fw-bold" style="color: var(--kaauh-primary-text);">
<i class="fas fa-sort-numeric-up me-1"></i> {% trans "AI Scoring & Top Candidate Filter" %}
</h4>
<form method="GET" class="mb-0">
{% csrf_token %}
<div class="row g-3 align-items-end">
<form method="GET" class="mb-0 pb-3">
{% csrf_token %}
<div class="d-flex flex-nowrap g-3 align-items-end" style="overflow-x: auto;">
<div class="col-md-3 col-sm-6">
<label for="min_ai_score" class="form-label small text-muted">
{% trans "Minimum AI Score" %}
<div class="p-2">
<label for="min_ai_score" class="form-label small text-muted mb-1">
{% trans "Min AI Score" %}
</label>
<input type="number" name="min_ai_score" id="min_ai_score" class="form-control form-control-sm"
value="{{ min_ai_score}}" min="0" max="100" step="1"
placeholder="e.g., 75" style="min-width: 120px;">
</div>
<div class="col-md-2 col-sm-6">
<label for="min_ai_score" class="form-label small text-muted mb-1">
{% trans "Min AI Score" %}
</label>
<input type="number" name="min_ai_score" id="min_ai_score" class="form-control form-control-sm"
value="{{ min_ai_score}}" min="0" max="100" step="1"
placeholder="e.g., 75">
</div>
<div class="p-2">
<label for="tier1_count" class="form-label small text-muted mb-1">
{% trans "Top N" %}
</label>
<input type="number" name="tier1_count" id="tier1_count" class="form-control form-control-sm"
value="{{ tier1_count }}" min="1" max="{{ total_candidates }}" style="min-width: 100px;">
</div>
<div class="col-md-2 col-sm-6">
<label for="tier1_count" class="form-label small text-muted mb-1">
{% trans "Top N" %}
</label>
<input type="number" name="tier1_count" id="tier1_count" class="form-control form-control-sm"
value="{{ tier1_count }}" min="1" max="{{ total_candidates }}">
</div>
<div class="col-md-3 col-sm-6">
<button type="submit" name="update_tiers" class="btn btn-main-action btn-sm w-100">
<i class="fas fa-sync-alt me-1"></i> {% trans "Update Filters" %}
</button>
</div>
{% comment %} Empty col for spacing (2 + 2 + 3 + 5 = 12) {% endcomment %}
<div class="col-md-5 d-none d-md-block"></div>
</div>
</form>
<div class="p-2">
<label class="form-label small text-muted mb-1 d-block">&nbsp;</label>
<button type="submit" name="update_tiers" class="btn btn-main-action btn-sm w-100" style="min-width: 150px;">
<i class="fas fa-sync-alt me-1"></i> {% trans "Update Filters" %}
</button>
</div>
</div>
</form>
</div>
<h2 class="h4 mb-3" style="color: var(--kaauh-primary-text);">
@ -244,22 +236,23 @@
</h2>
<div class="kaauh-card shadow-sm p-3">
{% url "bulk_candidate_move_to_exam" as move_to_exam_url %}
{% if candidates %}
<button class="btn btn-bulk-action btn-sm mb-3"
data-attr="{disabled: !$selections.filter(Boolean).length}"
data-on-click="@post('{{move_to_exam_url}}',{
contentType: 'form',
selector: '#candidate-form',
headers: {'X-CSRFToken': '{{ csrf_token }}'}})"
>
<i class="fas fa-arrow-right me-1"></i> {% trans "Bulk Move to Exam" %}
</button>
<form hx-boost="true" hx-include="#candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post">
<div class="d-flex align-items-center">
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="height: 3rem;">
<option value="Exam">
<i class="fas fa-arrow-right me-1"></i> {% trans "Exam" %}
</option>
</select>
<button type="submit" class="btn btn-main-action btn-mds ms-2">
<i class="fas fa-arrow-right me-1"></i> {% trans "Update" %}
</button>
</div>
</form>
{% endif %}
<div class="table-responsive">
<form id="candidate-form" action="{{move_to_exam_url}}" method="post">
<form id="candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="post">
{% csrf_token %}
<table class="table candidate-table align-middle">
<thead>
@ -268,11 +261,7 @@
{% if candidates %}
<div class="form-check">
<input
data-bind-_all
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
data-effect="$selections; $_all = $selections.every(Boolean)"
data-attr-disabled="$_fetching"
type="checkbox" class="form-check-input" id="checkAll">
type="checkbox" class="form-check-input" id="selectAllCheckbox">
</div>
{% endif %}
</th>
@ -290,11 +279,9 @@
<td>
<div class="form-check">
<input
data-bind-selections
data-attr-disabled="$_fetching"
name="candidate_ids"
value="{{ candidate.id }}"
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}">
</div>
</td>
<td>
@ -379,3 +366,69 @@
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const rowCheckboxes = document.querySelectorAll('.rowCheckbox');
if (selectAllCheckbox) {
// Function to safely update the header checkbox state
function updateSelectAllState() {
const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length;
const totalCount = rowCheckboxes.length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === totalCount) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
// Set to indeterminate state (partially checked)
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
// IMPORTANT: We do NOT fire a change event here to prevent the infinite loop.
// Your existing data-bind-_all logic should handle the bulk action status.
}
// 1. Logic for the 'Select All' checkbox (Clicking it updates all rows)
selectAllCheckbox.addEventListener('change', function () {
const isChecked = selectAllCheckbox.checked;
// Temporarily disable the change listener on rows to prevent cascading events
rowCheckboxes.forEach(checkbox => checkbox.removeEventListener('change', updateSelectAllState));
// Update all row checkboxes
rowCheckboxes.forEach(function (checkbox) {
checkbox.checked = isChecked;
// You must still dispatch the event here so your framework's data-bind-selections
// picks up the change on individual elements. This should NOT trigger the updateSelectAllState.
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
});
// Re-attach the change listeners to the rows
rowCheckboxes.forEach(checkbox => checkbox.addEventListener('change', updateSelectAllState));
// Ensure the header state is correct after forcing all changes
updateSelectAllState();
});
// 2. Logic to update 'Select All' state based on row checkboxes
// Attach the function to be called whenever a row checkbox changes
rowCheckboxes.forEach(function (checkbox) {
checkbox.addEventListener('change', updateSelectAllState);
});
// Initial check to set the correct state on load (in case items are pre-checked)
updateSelectAllState();
}
});
</script>
{% endblock %}