spinner for parsing

This commit is contained in:
Faheed 2025-10-30 19:54:09 +03:00
parent 08774489bc
commit 2cfab7c3ef
10 changed files with 163 additions and 82 deletions

View File

@ -680,36 +680,8 @@ class Candidate(Base):
return future_meetings or today_future_meetings
@property
def check_and_retry_ai_scoring(self):
"""
Triggers an immediate save ONLY if:
1. The resume hasn't been parsed yet.
2. At least 5 minutes have passed since the last attempt.
Returns True if a save was performed, False otherwise.
"""
from datetime import timedelta
min_delay = timedelta(minutes=5)
time_since_last_attempt = timezone.now() - self.created_at
if not self.is_resume_parsed and time_since_last_attempt >= min_delay:
# 1. Update the retry timestamp
self.last_retry_attempt = timezone.now()
self.save()
return True
return False
# @property
# def time_to_hire(self):
# time_to_hire=self.hired_date-self.created_at
# return time_to_hire
def scoring_timeout(self):
return timezone.now() <= (self.created_at + timezone.timedelta(minutes=5))
class TrainingMaterial(Base):

View File

@ -223,10 +223,17 @@ class CandidateDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
def retry_scoring_view(request,slug):
if request.method == 'POST':
candidate = get_object_or_404(models.Candidate, slug=slug)
candidate.save()
return redirect('candidate_detail', slug=candidate.slug)
from django_q.tasks import async_task
candidate = get_object_or_404(models.Candidate, slug=slug)
async_task(
'recruitment.tasks.handle_reume_parsing_and_scoring',
candidate.pk,
hook='recruitment.hooks.callback_ai_parsing',
sync=True,
)
return redirect('candidate_detail', slug=candidate.slug)
@ -507,6 +514,15 @@ def dashboard_view(request):
rotation_degrees = rotation_percent * 180
rotation_degrees_final = round(min(rotation_degrees, 180), 1) # Ensure max 180 degrees
#
hiring_source_counts = candidate_queryset.values('hiring_source').annotate(count=Count('stage'))
source_map= {item['hiring_source']: item['count'] for item in hiring_source_counts}
candidates_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 ---
@ -555,6 +571,10 @@ def dashboard_view(request):
'jobs': all_jobs_queryset,
'current_job_id': selected_job_pk,
'current_job': current_job,
'candidates_count_in_each_source': json.dumps(candidates_count_in_each_source),
'all_hiring_sources': json.dumps(all_hiring_sources),
}
return render(request, 'recruitment/dashboard.html', context)

View File

@ -705,6 +705,5 @@
{% comment %} {% endif %} {% endcomment %}
{% block customJS %}{% endblock %}
</body>
</html>

View File

@ -1,6 +1,6 @@
{% load crispy_forms_tags %}
<div class="modal fade mt-4" id="linkedinData" tabindex="-1" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="myModalLabel">Edit linkedin Post content</h5>

View File

@ -659,46 +659,30 @@
</div>
</div>
</div>
<div class="resume-parsed-section">
{% if candidate.is_resume_parsed %}
{% include 'recruitment/candidate_resume_template.html' %}
{% else %}
<a href="{% url 'candidate_detail' candidate.slug %}" class="text-decoration-none">
<div style="display: flex; justify-content: center; align-items: center; height: 100%;" class="mb-2">
<div class="ai-loading-container">
{# Robot Icon (Requires Font Awesome or similar library) #}
<i class="fas fa-robot ai-robot-icon"></i>
{# The Spinner #}
<svg class="kaats-spinner" viewBox="0 0 50 50">
<circle cx="25" cy="25" r="20"></circle>
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="4"
style="animation: kaats-spinner-dash 1.5s ease-in-out infinite;"></circle>
</svg>
<span>AI Scoring...</span>
</div>
</div>
</a>
<div style="display: flex; justify-content: center; align-items: center; height: 100%;">
{% if candidate.check_and_retry_ai_scoring %}
<form method="post" action="{% url 'candidate_retry_scoring' slug=candidate.slug %}" class="d-inline">
{% csrf_token %}
{# Use your established teal button style #}
<button type="submit" class="btn btn-sm btn-main-action">
<i class="fas fa-redo me-1"></i>
{% if candidate.scoring_timeout %}
<div style="display: flex; justify-content: center; align-items: center; height: 100%;" class="mb-2">
<div class="ai-loading-container">
<i class="fas fa-robot ai-robot-icon"></i>
<span>Resume is been Scoring...</span>
</div>
</div>
{% else %}
<div style="display: flex; justify-content: center; align-items: center; height: 100%;">
<button type="submit" class="btn btn-sm btn-main-action" hx-get="{% url 'candidate_retry_scoring' candidate.slug %}" hx-select=".resume-parsed-section" hx-target=".resume-parsed-section" hx-swap="outerHTML" hx-on:click="this.disabled=true;this.innerHTML=`Scoring Resume , Please Wait.. <i class='fa-solid fa-spinner fa-spin'></i>`">
{% trans "Retry AI Scoring" %}
</button>
</form>
{% endif %}
</div>
</button>
</div>
{% endif %}
{% endif %}
</div>
{# STAGE UPDATE MODAL INCLUDED FOR STAFF USERS #}
{% if user.is_staff %}

View File

@ -438,7 +438,7 @@
</div>
<div class="modal fade" id="jobAssignmentModal" tabindex="-1" aria-labelledby="jobAssignmentLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="jobAssignmentLabel">{% trans "Manage all participants" %}</h5>
@ -449,20 +449,32 @@
<form method="post" action="{% url 'candidate_interview_view' job.slug %}">
{% csrf_token %}
<div class="modal-body">
<div class="modal-body table-responsive">
{{ job.internal_job_id }} {{ job.title}}
<hr>
<h3>👥 {% trans "Participants" %}</h3>
{{ form.participants.errors }}
{{ form.participants }}
<hr>
<h3>🧑‍💼 {% trans "Users" %}</h3>
{{ form.users.errors }}
{{ form.users }}
<table class="table tab table-bordered mt-3">
<thead>
<th class="col">👥 {% trans "Participants" %}</th>
<th class="col">🧑‍💼 {% trans "Users" %}</th>
</thead>
<tbody>
<tr>
<td>
{{ form.participants.errors }}
{{ form.participants }}
</td>
<td> {{ form.users.errors }}
{{ form.users }}
</td>
</tr>
</table>
</div>
<div class="modal-footer">

View File

@ -361,7 +361,7 @@
{% endif %}
</th>
<th scope="col" style="width: 8%;">
<i class="fas fa-user me-1"></i> {% trans "Candidate Name" %}
<i class="fas fa-user me-1"></i> {% trans "Name" %}
</th>
<th scope="col" style="width: 10%;">
<i class="fas fa-phone me-1"></i> {% trans "Contact Info" %}

View File

@ -223,6 +223,20 @@
</div>
</div>
<div class="card shadow-sm no-hover mb-4">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-chart-pie me-2 text-primary"></i>
{% trans "Candidates From Each Sources" %}
</h6>
</div>
<div class="card-body p-4">
<div style="height: 300px;">
<canvas id="candidatesourceschart"></canvas>
</div>
</div>
</div>
</div>
@ -442,6 +456,86 @@
}
});
// Chart for Candidate Categories and Match Scores
document.addEventListener('DOMContentLoaded', function() {
const ctx = document.getElementById('candidatesourceschart');
if (!ctx) {
console.warn('Candidates sources chart element not found.');
return;
}
const chartCtx = ctx.getContext('2d');
// Safely get job_category_data from Django context
// Using window.jobChartData to avoid template parsing issues
if (categories.length > 0) { // Only render if there's data
const chart = new Chart(chartCtx, {
type: 'doughnut',
data: {
labels: categories,
datasets: [
{
label: 'Number of Candidates',
data: candidates_count_in_each_source,
backgroundColor: [
'rgba(0, 99, 110, 0.7)', // --kaauh-teal
'rgba(23, 162, 184, 0.7)', // Teal shade
'rgba(0, 150, 136, 0.7)', // Teal green
'rgba(0, 188, 212, 0.7)', // Cyan
'rgba(38, 166, 154, 0.7)', // Turquoise
'rgba(77, 182, 172, 0.7)', // Medium teal
// Add more colors if you expect more categories
],
borderColor: [
'rgba(0, 99, 110, 1)',
'rgba(23, 162, 184, 1)',
'rgba(0, 150, 136, 1)',
'rgba(0, 188, 212, 1)',
'rgba(38, 166, 154, 1)',
'rgba(77, 182, 172, 1)',
// Add more colors if you expect more categories
],
borderWidth: 1,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false, // Important for fixed height container
plugins: {
legend: {
position: 'right', // Position legend for doughnut chart
},
title: {
display: false, // Chart title is handled by the card header
},
tooltip: {
callbacks: {
label: function(context) {
let label = context.label || '';
if (label) {
label += ': ';
}
label += context.parsed + ' candidate(s)';
return label;
}
}
}
}
}
});
} else {
// Display a message if no data is available
chartCtx.canvas.parentNode.innerHTML = '<p class="text-center text-muted mt-4">No candidate category data available for this job.</p>';
}
});
</script>
{% endblock %}