dashboard
This commit is contained in:
parent
b13a1dd1ee
commit
8da8d89433
Binary file not shown.
Binary file not shown.
@ -135,9 +135,9 @@ WSGI_APPLICATION = 'NorahUniversity.wsgi.application'
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql_psycopg2',
|
||||
'NAME': 'norahuniversity',
|
||||
'USER': 'norahuniversity',
|
||||
'PASSWORD': 'norahuniversity',
|
||||
'NAME': 'haikal_db',
|
||||
'USER': 'faheed',
|
||||
'PASSWORD': 'Faheed@215',
|
||||
'HOST': '127.0.0.1',
|
||||
'PORT': '5432',
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -11,7 +11,7 @@ from .models import (
|
||||
ZoomMeeting, Candidate,TrainingMaterial,JobPosting,
|
||||
FormTemplate,InterviewSchedule,BreakTime,JobPostingImage,
|
||||
Profile,MeetingComment,ScheduledInterview,Source,HiringAgency,
|
||||
AgencyJobAssignment, AgencyAccessLink
|
||||
AgencyJobAssignment, AgencyAccessLink,Participants
|
||||
)
|
||||
# from django_summernote.widgets import SummernoteWidget
|
||||
from django_ckeditor_5.widgets import CKEditor5Widget
|
||||
@ -1145,3 +1145,57 @@ class AgencyLoginForm(forms.Form):
|
||||
raise ValidationError('Invalid access token.')
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#participants form
|
||||
class ParticipantsForm(forms.ModelForm):
|
||||
"""Form for creating and editing Participants"""
|
||||
|
||||
class Meta:
|
||||
model = Participants
|
||||
fields = ['name', 'email', 'phone', 'designation']
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter participant name',
|
||||
'required': True
|
||||
}),
|
||||
'email': forms.EmailInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter email address',
|
||||
'required': True
|
||||
}),
|
||||
'phone': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter phone number'
|
||||
}),
|
||||
'designation': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter designation'
|
||||
}),
|
||||
# 'jobs': forms.CheckboxSelectMultiple(),
|
||||
}
|
||||
|
||||
|
||||
class ParticipantsSelectForm(forms.ModelForm):
|
||||
"""Form for selecting Participants"""
|
||||
|
||||
participants=forms.ModelMultipleChoiceField(
|
||||
queryset=Participants.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
label=_("Select Participants"))
|
||||
|
||||
users=forms.ModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
label=_("Select Users"))
|
||||
|
||||
class Meta:
|
||||
model = JobPosting
|
||||
fields = ['participants','users'] # No direct fields from Participants model
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-29 11:45
|
||||
# Generated by Django 5.2.7 on 2025-10-29 18:04
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
@ -100,6 +100,11 @@ class Migration(migrations.Migration):
|
||||
('integration_version', models.CharField(blank=True, help_text='Version of the integration protocol', max_length=50, verbose_name='Integration Version')),
|
||||
('last_sync_at', models.DateTimeField(blank=True, help_text='Timestamp of the last successful synchronization', null=True, verbose_name='Last Sync At')),
|
||||
('sync_status', models.CharField(blank=True, choices=[('IDLE', 'Idle'), ('SYNCING', 'Syncing'), ('ERROR', 'Error'), ('DISABLED', 'Disabled')], default='IDLE', max_length=20, verbose_name='Sync Status')),
|
||||
('sync_endpoint', models.URLField(blank=True, help_text='Endpoint URL for sending candidate data (for outbound sync)', null=True, verbose_name='Sync Endpoint')),
|
||||
('sync_method', models.CharField(blank=True, choices=[('POST', 'POST'), ('PUT', 'PUT')], default='POST', help_text='HTTP method for outbound sync requests', max_length=10, verbose_name='Sync Method')),
|
||||
('test_method', models.CharField(blank=True, choices=[('GET', 'GET'), ('POST', 'POST')], default='GET', help_text='HTTP method for connection testing', max_length=10, verbose_name='Test Method')),
|
||||
('custom_headers', models.TextField(blank=True, help_text='JSON object with custom HTTP headers for sync requests', null=True, verbose_name='Custom Headers')),
|
||||
('supports_outbound_sync', models.BooleanField(default=False, help_text='Whether this source supports receiving candidate data from ATS', verbose_name='Supports Outbound Sync')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Source',
|
||||
@ -217,7 +222,7 @@ class Migration(migrations.Migration):
|
||||
('is_potential_candidate', models.BooleanField(default=False, verbose_name='Potential Candidate')),
|
||||
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
|
||||
('applied', models.BooleanField(default=False, verbose_name='Applied')),
|
||||
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')),
|
||||
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired')], db_index=True, default='Applied', max_length=100, verbose_name='Stage')),
|
||||
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=100, null=True, verbose_name='Applicant Status')),
|
||||
('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
|
||||
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Exam Status')),
|
||||
@ -225,10 +230,12 @@ class Migration(migrations.Migration):
|
||||
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=100, null=True, verbose_name='Interview Status')),
|
||||
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
|
||||
('offer_status', models.CharField(blank=True, choices=[('Accepted', 'Accepted'), ('Rejected', 'Rejected')], max_length=100, null=True, verbose_name='Offer Status')),
|
||||
('hired_date', models.DateField(blank=True, null=True, verbose_name='Hired Date')),
|
||||
('join_date', models.DateField(blank=True, null=True, verbose_name='Join Date')),
|
||||
('ai_analysis_data', models.JSONField(default=dict, help_text='Full JSON output from the resume scoring model.', verbose_name='AI Analysis Data')),
|
||||
('retry', models.SmallIntegerField(default=3, verbose_name='Resume Parsing Retry')),
|
||||
('submitted_by_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_candidates', to='recruitment.hiringagency', verbose_name='Submitted by Agency')),
|
||||
('hiring_source', models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source')),
|
||||
('hiring_agency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Candidate',
|
||||
@ -238,6 +245,7 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name='JobPosting',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
@ -255,7 +263,7 @@ class Migration(migrations.Migration):
|
||||
('application_url', models.URLField(blank=True, help_text='URL where candidates apply', null=True, validators=[django.core.validators.URLValidator()])),
|
||||
('application_deadline', models.DateField(db_index=True)),
|
||||
('application_instructions', django_ckeditor_5.fields.CKEditor5Field(blank=True, null=True)),
|
||||
('internal_job_id', models.CharField(editable=False, max_length=50, primary_key=True, serialize=False)),
|
||||
('internal_job_id', models.CharField(editable=False, max_length=50)),
|
||||
('created_by', models.CharField(blank=True, help_text='Name of person who created this job', max_length=100)),
|
||||
('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('CLOSED', 'Closed'), ('CANCELLED', 'Cancelled'), ('ARCHIVED', 'Archived')], db_index=True, default='DRAFT', max_length=20)),
|
||||
('hash_tags', models.CharField(blank=True, help_text='Comma-separated hashtags for linkedin post like #hiring,#jobopening', max_length=200, validators=[recruitment.validators.validate_hash_tags])),
|
||||
@ -315,6 +323,31 @@ class Migration(migrations.Migration):
|
||||
name='job',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='recruitment.jobposting', verbose_name='Job'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AgencyJobAssignment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('max_candidates', models.PositiveIntegerField(help_text='Maximum candidates agency can submit for this job', verbose_name='Maximum Candidates')),
|
||||
('candidates_submitted', models.PositiveIntegerField(default=0, help_text='Number of candidates submitted so far', verbose_name='Candidates Submitted')),
|
||||
('assigned_date', models.DateTimeField(auto_now_add=True, verbose_name='Assigned Date')),
|
||||
('deadline_date', models.DateTimeField(help_text='Deadline for agency to submit candidates', verbose_name='Deadline Date')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
|
||||
('status', models.CharField(choices=[('ACTIVE', 'Active'), ('COMPLETED', 'Completed'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='ACTIVE', max_length=20, verbose_name='Status')),
|
||||
('deadline_extended', models.BooleanField(default=False, verbose_name='Deadline Extended')),
|
||||
('original_deadline', models.DateTimeField(blank=True, help_text='Original deadline before extensions', null=True, verbose_name='Original Deadline')),
|
||||
('admin_notes', models.TextField(blank=True, help_text='Internal notes about this assignment', verbose_name='Admin Notes')),
|
||||
('agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_assignments', to='recruitment.hiringagency', verbose_name='Agency')),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agency_assignments', to='recruitment.jobposting', verbose_name='Job')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Agency Job Assignment',
|
||||
'verbose_name_plural': 'Agency Job Assignments',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='JobPostingImage',
|
||||
fields=[
|
||||
@ -327,17 +360,11 @@ class Migration(migrations.Migration):
|
||||
name='Profile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size])),
|
||||
('designation', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('phone', models.CharField(blank=True, max_length=12, null=True, verbose_name='Phone Number')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SharedFormTemplate',
|
||||
@ -364,7 +391,7 @@ class Migration(migrations.Migration):
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('action', models.CharField(choices=[('REQUEST', 'Request'), ('RESPONSE', 'Response'), ('ERROR', 'Error'), ('SYNC', 'Sync'), ('CREATE_JOB', 'Create Job'), ('UPDATE_JOB', 'Update Job')], max_length=20, verbose_name='Action')),
|
||||
('endpoint', models.CharField(blank=True, max_length=255, verbose_name='Endpoint')),
|
||||
('method', models.CharField(blank=True, max_length=10, verbose_name='HTTP Method')),
|
||||
('method', models.CharField(blank=True, max_length=50, verbose_name='HTTP Method')),
|
||||
('request_data', models.JSONField(blank=True, null=True, verbose_name='Request Data')),
|
||||
('response_data', models.JSONField(blank=True, null=True, verbose_name='Response Data')),
|
||||
('status_code', models.CharField(blank=True, max_length=10, verbose_name='Status Code')),
|
||||
@ -452,6 +479,28 @@ class Migration(migrations.Migration):
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AgencyAccessLink',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('unique_token', models.CharField(editable=False, max_length=64, unique=True, verbose_name='Unique Token')),
|
||||
('access_password', models.CharField(help_text='Password for agency access', max_length=32, verbose_name='Access Password')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('expires_at', models.DateTimeField(help_text='When this access link expires', verbose_name='Expires At')),
|
||||
('last_accessed', models.DateTimeField(blank=True, null=True, verbose_name='Last Accessed')),
|
||||
('access_count', models.PositiveIntegerField(default=0, verbose_name='Access Count')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
|
||||
('assignment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='access_link', to='recruitment.agencyjobassignment', verbose_name='Assignment')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Agency Access Link',
|
||||
'verbose_name_plural': 'Agency Access Links',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['unique_token'], name='recruitment_unique__f91e76_idx'), models.Index(fields=['expires_at'], name='recruitment_expires_954ed9_idx'), models.Index(fields=['is_active'], name='recruitment_is_acti_4b0804_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FieldResponse',
|
||||
fields=[
|
||||
@ -502,6 +551,26 @@ class Migration(migrations.Migration):
|
||||
model_name='candidate',
|
||||
index=models.Index(fields=['created_at'], name='recruitment_created_73590f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencyjobassignment',
|
||||
index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencyjobassignment',
|
||||
index=models.Index(fields=['job', 'status'], name='recruitment_job_id_d798a8_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencyjobassignment',
|
||||
index=models.Index(fields=['deadline_date'], name='recruitment_deadlin_57d3b4_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencyjobassignment',
|
||||
index=models.Index(fields=['is_active'], name='recruitment_is_acti_93b919_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='agencyjobassignment',
|
||||
unique_together={('agency', 'job')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='jobposting',
|
||||
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-29 12:41
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='profile',
|
||||
name='created_at',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='profile',
|
||||
name='slug',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='profile',
|
||||
name='updated_at',
|
||||
),
|
||||
]
|
||||
@ -1,48 +0,0 @@
|
||||
# Generated by Django 5.2.4 on 2025-10-26 13:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0002_candidate_retry'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='hired_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Hired Date'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='custom_headers',
|
||||
field=models.TextField(blank=True, help_text='JSON object with custom HTTP headers for sync requests', null=True, verbose_name='Custom Headers'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='supports_outbound_sync',
|
||||
field=models.BooleanField(default=False, help_text='Whether this source supports receiving candidate data from ATS', verbose_name='Supports Outbound Sync'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='sync_endpoint',
|
||||
field=models.URLField(blank=True, help_text='Endpoint URL for sending candidate data (for outbound sync)', null=True, verbose_name='Sync Endpoint'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='sync_method',
|
||||
field=models.CharField(blank=True, choices=[('POST', 'POST'), ('PUT', 'PUT')], default='POST', help_text='HTTP method for outbound sync requests', max_length=10, verbose_name='Sync Method'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='test_method',
|
||||
field=models.CharField(blank=True, choices=[('GET', 'GET'), ('POST', 'POST')], default='GET', help_text='HTTP method for connection testing', max_length=10, verbose_name='Test Method'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='stage',
|
||||
field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired')], db_index=True, default='Applied', max_length=100, verbose_name='Stage'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.4 on 2025-10-26 13:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0003_candidate_hired_date_source_custom_headers_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='integrationlog',
|
||||
name='method',
|
||||
field=models.CharField(blank=True, max_length=50, verbose_name='HTTP Method'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.4 on 2025-10-26 14:37
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0004_alter_integrationlog_method'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='candidate',
|
||||
old_name='submitted_by_agency',
|
||||
new_name='hiring_agency',
|
||||
),
|
||||
]
|
||||
@ -1,129 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-26 14:51
|
||||
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0005_rename_submitted_by_agency_candidate_hiring_agency'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AgencyJobAssignment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('max_candidates', models.PositiveIntegerField(help_text='Maximum candidates agency can submit for this job', verbose_name='Maximum Candidates')),
|
||||
('candidates_submitted', models.PositiveIntegerField(default=0, help_text='Number of candidates submitted so far', verbose_name='Candidates Submitted')),
|
||||
('assigned_date', models.DateTimeField(auto_now_add=True, verbose_name='Assigned Date')),
|
||||
('deadline_date', models.DateTimeField(help_text='Deadline for agency to submit candidates', verbose_name='Deadline Date')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
|
||||
('status', models.CharField(choices=[('ACTIVE', 'Active'), ('COMPLETED', 'Completed'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='ACTIVE', max_length=20, verbose_name='Status')),
|
||||
('deadline_extended', models.BooleanField(default=False, verbose_name='Deadline Extended')),
|
||||
('original_deadline', models.DateTimeField(blank=True, help_text='Original deadline before extensions', null=True, verbose_name='Original Deadline')),
|
||||
('admin_notes', models.TextField(blank=True, help_text='Internal notes about this assignment', verbose_name='Admin Notes')),
|
||||
('agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_assignments', to='recruitment.hiringagency', verbose_name='Agency')),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agency_assignments', to='recruitment.jobposting', verbose_name='Job')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Agency Job Assignment',
|
||||
'verbose_name_plural': 'Agency Job Assignments',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AgencyAccessLink',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('unique_token', models.CharField(editable=False, max_length=64, unique=True, verbose_name='Unique Token')),
|
||||
('access_password', models.CharField(help_text='Password for agency access', max_length=32, verbose_name='Access Password')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('expires_at', models.DateTimeField(help_text='When this access link expires', verbose_name='Expires At')),
|
||||
('last_accessed', models.DateTimeField(blank=True, null=True, verbose_name='Last Accessed')),
|
||||
('access_count', models.PositiveIntegerField(default=0, verbose_name='Access Count')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
|
||||
('assignment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='access_link', to='recruitment.agencyjobassignment', verbose_name='Assignment')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Agency Access Link',
|
||||
'verbose_name_plural': 'Agency Access Links',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AgencyMessage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||
('subject', models.CharField(max_length=200, verbose_name='Subject')),
|
||||
('message', models.TextField(verbose_name='Message')),
|
||||
('message_type', models.CharField(choices=[('INFO', 'Information'), ('WARNING', 'Warning'), ('EXTENSION', 'Deadline Extension'), ('GENERAL', 'General')], default='GENERAL', max_length=20, verbose_name='Message Type')),
|
||||
('is_read', models.BooleanField(default=False, verbose_name='Is Read')),
|
||||
('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')),
|
||||
('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.agencyjobassignment', verbose_name='Assignment')),
|
||||
('recipient_agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to='recruitment.hiringagency', verbose_name='Recipient Agency')),
|
||||
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Agency Message',
|
||||
'verbose_name_plural': 'Agency Messages',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencyjobassignment',
|
||||
index=models.Index(fields=['agency', 'status'], name='recruitment_agency__491a54_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencyjobassignment',
|
||||
index=models.Index(fields=['job', 'status'], name='recruitment_job_id_d798a8_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencyjobassignment',
|
||||
index=models.Index(fields=['deadline_date'], name='recruitment_deadlin_57d3b4_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencyjobassignment',
|
||||
index=models.Index(fields=['is_active'], name='recruitment_is_acti_93b919_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='agencyjobassignment',
|
||||
unique_together={('agency', 'job')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencyaccesslink',
|
||||
index=models.Index(fields=['unique_token'], name='recruitment_unique__f91e76_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencyaccesslink',
|
||||
index=models.Index(fields=['expires_at'], name='recruitment_expires_954ed9_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencyaccesslink',
|
||||
index=models.Index(fields=['is_active'], name='recruitment_is_acti_4b0804_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencymessage',
|
||||
index=models.Index(fields=['assignment', 'is_read'], name='recruitment_assignm_4f518d_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencymessage',
|
||||
index=models.Index(fields=['recipient_agency', 'is_read'], name='recruitment_recipie_427b10_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencymessage',
|
||||
index=models.Index(fields=['sender'], name='recruitment_sender__97dd96_idx'),
|
||||
),
|
||||
]
|
||||
@ -1,24 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-27 11:42
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='source',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.source', verbose_name='Source'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='source_type',
|
||||
field=models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Source'),
|
||||
),
|
||||
]
|
||||
@ -1,32 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-27 11:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0007_candidate_source_candidate_source_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='candidate',
|
||||
name='source',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='candidate',
|
||||
name='source_type',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='candidate',
|
||||
name='hiring_source',
|
||||
field=models.CharField(blank=True, choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Agency', 'Agency')], default='Public', max_length=255, null=True, verbose_name='Hiring Source'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='candidate',
|
||||
name='hiring_agency',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='candidates', to='recruitment.hiringagency', verbose_name='Hiring Agency'),
|
||||
),
|
||||
]
|
||||
@ -1,59 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-27 20:26
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0008_remove_candidate_source_remove_candidate_source_type_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='agencymessage',
|
||||
name='priority',
|
||||
field=models.CharField(choices=[('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High'), ('URGENT', 'Urgent')], default='MEDIUM', max_length=10, verbose_name='Priority'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='agencymessage',
|
||||
name='recipient_user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient User'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='agencymessage',
|
||||
name='send_email',
|
||||
field=models.BooleanField(default=False, verbose_name='Send Email Notification'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='agencymessage',
|
||||
name='send_sms',
|
||||
field=models.BooleanField(default=False, verbose_name='Send SMS Notification'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='agencymessage',
|
||||
name='sender_agency',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to='recruitment.hiringagency', verbose_name='Sender Agency'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='agencymessage',
|
||||
name='sender_type',
|
||||
field=models.CharField(choices=[('ADMIN', 'Admin'), ('AGENCY', 'Agency')], default='ADMIN', max_length=10, verbose_name='Sender Type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='agencymessage',
|
||||
name='sender',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_agency_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencymessage',
|
||||
index=models.Index(fields=['sender_type', 'created_at'], name='recruitment_sender__14b136_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='agencymessage',
|
||||
index=models.Index(fields=['priority', 'created_at'], name='recruitment_priorit_80d9f1_idx'),
|
||||
),
|
||||
]
|
||||
@ -1,16 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-29 10:59
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0009_agencymessage_priority_agencymessage_recipient_user_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='AgencyMessage',
|
||||
),
|
||||
]
|
||||
@ -108,7 +108,7 @@ class JobPosting(Base):
|
||||
)
|
||||
|
||||
# Internal Tracking
|
||||
internal_job_id = models.CharField(max_length=50, primary_key=True, editable=False)
|
||||
internal_job_id = models.CharField(max_length=50, editable=False)
|
||||
|
||||
created_by = models.CharField(
|
||||
max_length=100, blank=True, help_text="Name of person who created this job"
|
||||
@ -363,6 +363,10 @@ class JobPosting(Base):
|
||||
def offer_candidates_count(self):
|
||||
return self.all_candidates.filter(stage="Offer").count() or 0
|
||||
|
||||
@property
|
||||
def hired_candidates_count(self):
|
||||
return self.all_candidates.filter(stage="Hired").count() or 0
|
||||
|
||||
@property
|
||||
def vacancy_fill_rate(self):
|
||||
total_positions = self.open_positions
|
||||
|
||||
@ -17,7 +17,8 @@ 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.functions import Cast
|
||||
from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields
|
||||
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.db.models import Count, Avg, F,Q
|
||||
@ -38,7 +39,9 @@ from .forms import (
|
||||
AgencyCandidateSubmissionForm,
|
||||
AgencyLoginForm,
|
||||
AgencyAccessLinkForm,
|
||||
AgencyJobAssignmentForm
|
||||
AgencyJobAssignmentForm,
|
||||
LinkedPostContentForm,
|
||||
ParticipantsSelectForm
|
||||
)
|
||||
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
|
||||
from rest_framework import viewsets
|
||||
@ -332,6 +335,8 @@ def edit_job(request, slug):
|
||||
return render(request, "jobs/edit_job.html", {"form": form, "job": job})
|
||||
|
||||
|
||||
SCORE_PATH = 'ai_analysis_data__analysis_data__match_score'
|
||||
HIGH_POTENTIAL_THRESHOLD=75
|
||||
@login_required
|
||||
def job_detail(request, slug):
|
||||
"""View details of a specific job"""
|
||||
@ -391,29 +396,31 @@ def job_detail(request, slug):
|
||||
# --- 2. Quality Metrics (JSON Aggregation) ---
|
||||
|
||||
# Filter for candidates who have been scored and annotate with a sortable score
|
||||
candidates_with_score = applicants.filter(is_resume_parsed=True).annotate(
|
||||
# Extract the score as TEXT
|
||||
score_as_text=KeyTextTransform(
|
||||
'match_score',
|
||||
KeyTextTransform('resume_data', F('ai_analysis_data'))
|
||||
)
|
||||
# candidates_with_score = applicants.filter(is_resume_parsed=True).annotate(
|
||||
# # Extract the score as TEXT
|
||||
# score_as_text=KeyTextTransform(
|
||||
# 'match_score',
|
||||
# KeyTextTransform('resume_data', F('ai_analysis_data'))
|
||||
# )
|
||||
# ).annotate(
|
||||
# # Cast the extracted text score to a FloatField for numerical operations
|
||||
# sortable_score=Cast('score_as_text', output_field=FloatField())
|
||||
# )
|
||||
candidates_with_score = applicants.filter(
|
||||
is_resume_parsed=True
|
||||
).annotate(
|
||||
# Cast the extracted text score to a FloatField for numerical operations
|
||||
sortable_score=Cast('score_as_text', output_field=FloatField())
|
||||
annotated_match_score=Coalesce(
|
||||
Cast(SCORE_PATH, output_field=IntegerField()),
|
||||
0
|
||||
)
|
||||
)
|
||||
total_candidates=applicants.count()
|
||||
avg_match_score_result = candidates_with_score.aggregate(avg_score=Avg('annotated_match_score'))['avg_score']
|
||||
avg_match_score = round(avg_match_score_result or 0, 1)
|
||||
high_potential_count = candidates_with_score.filter(annotated_match_score__gte=HIGH_POTENTIAL_THRESHOLD).count()
|
||||
high_potential_ratio = round( (high_potential_count / total_candidates) * 100, 1 ) if total_candidates > 0 else 0
|
||||
|
||||
# Aggregate: Average Match Score
|
||||
avg_match_score_result = candidates_with_score.aggregate(
|
||||
avg_score=Avg('sortable_score')
|
||||
)['avg_score']
|
||||
avg_match_score = round(avg_match_score_result or 0, 1)
|
||||
|
||||
# Metric: High Potential Count (Score >= 75)
|
||||
high_potential_count = candidates_with_score.filter(
|
||||
sortable_score__gte=75
|
||||
).count()
|
||||
high_potential_ratio = round((high_potential_count / total_applicant) * 100, 1) if total_applicant > 0 else 0
|
||||
|
||||
|
||||
# --- 3. Time Metrics (Duration Aggregation) ---
|
||||
|
||||
# Metric: Average Time from Applied to Interview (T2I)
|
||||
|
||||
@ -339,119 +339,227 @@ class TrainingDeleteView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||
success_url = reverse_lazy('training_list')
|
||||
success_message = 'Training material deleted successfully.'
|
||||
|
||||
from django.db.models import F, IntegerField, Count, Avg
|
||||
from django.db.models.functions import Cast, Coalesce
|
||||
from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields
|
||||
from django.db.models.functions import Cast, Coalesce, TruncDate
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import json
|
||||
|
||||
# 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
|
||||
def dashboard_view(request):
|
||||
all_candidates_count=0
|
||||
|
||||
# --- Performance Optimization: Aggregate Data in ONE Query ---
|
||||
selected_job_pk = request.GET.get('selected_job_pk')
|
||||
today = timezone.now().date()
|
||||
|
||||
# --- 1. BASE QUERYSETS & GLOBAL METRICS (UNFILTERED) ---
|
||||
|
||||
all_jobs_queryset = models.JobPosting.objects.all().order_by('-created_at')
|
||||
all_candidates_queryset = models.Candidate.objects.all()
|
||||
|
||||
# 1. Base Job Query: Get all jobs and annotate with candidate count
|
||||
jobs_with_counts = models.JobPosting.objects.annotate(
|
||||
candidate_count=Count('candidates')
|
||||
).order_by('-candidate_count')
|
||||
# Global KPI Card Metrics
|
||||
total_jobs_global = all_jobs_queryset.count()
|
||||
total_participants = models.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.candidates.count() for job in all_jobs_queryset]
|
||||
|
||||
total_jobs = jobs_with_counts.count()
|
||||
total_candidates = models.Candidate.objects.count()
|
||||
# --- 2. TIME SERIES: GLOBAL DAILY APPLICANTS ---
|
||||
|
||||
# Group ALL candidates by creation date
|
||||
global_daily_applications_qs = all_candidates_queryset.annotate(
|
||||
date=TruncDate('created_at')
|
||||
).values('date').annotate(
|
||||
count=Count('pk')
|
||||
).order_by('date')
|
||||
|
||||
job_titles = [job.title for job in jobs_with_counts]
|
||||
job_app_counts = [job.candidate_count for job in jobs_with_counts]
|
||||
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]
|
||||
|
||||
average_applications = round(jobs_with_counts.aggregate(
|
||||
avg_apps=Avg('candidate_count')
|
||||
)['avg_apps'] or 0, 2)
|
||||
|
||||
# 5. New: Candidate Quality & Funnel Metrics
|
||||
# --- 3. FILTERING LOGIC: Determine the scope for scoped metrics ---
|
||||
|
||||
candidate_queryset = all_candidates_queryset
|
||||
job_scope_queryset = all_jobs_queryset
|
||||
interview_queryset = models.ScheduledInterview.objects.all()
|
||||
|
||||
current_job = None
|
||||
if selected_job_pk:
|
||||
# Filter all base querysets
|
||||
candidate_queryset = candidate_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 = models.JobPosting.objects.filter(pk=selected_job_pk)
|
||||
except models.JobPosting.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Assuming 'match_score' is a direct IntegerField/FloatField on the Candidate model
|
||||
# (based on the final, optimized version of handle_reume_parsing_and_scoring)
|
||||
# The path to your score: ai_analysis_data['analysis_data']['match_score']
|
||||
SCORE_PATH = 'ai_analysis_data__analysis_data__match_score'
|
||||
# --- 4. TIME SERIES: SCOPED DAILY APPLICANTS ---
|
||||
|
||||
# --- The Annotate Step ---
|
||||
candidates_with_score_query = models.Candidate.objects.filter(
|
||||
# Only run if a specific job is selected
|
||||
scoped_dates = []
|
||||
scoped_counts = []
|
||||
if selected_job_pk:
|
||||
scoped_daily_applications_qs = candidate_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_candidates = candidate_queryset.count()
|
||||
|
||||
candidates_with_score_query = candidate_queryset.filter(
|
||||
is_resume_parsed=True
|
||||
).annotate(
|
||||
# 1. Use Coalesce to handle cases where the score might be missing or NULL
|
||||
# (It defaults the value to 0 if missing).
|
||||
# 2. Use Cast to convert the JSON value (which is often returned as text/string by the DB)
|
||||
# into a proper IntegerField so we can perform math on it.
|
||||
annotated_match_score=Coalesce(
|
||||
Cast(SCORE_PATH, output_field=IntegerField()),
|
||||
0
|
||||
)
|
||||
)
|
||||
# Now calculate the average match score
|
||||
avg_match_score_result = candidates_with_score_query.aggregate(
|
||||
avg_score=Avg('annotated_match_score')
|
||||
)['avg_score']
|
||||
avg_match_score = round(avg_match_score_result or 0, 1)
|
||||
|
||||
hight_potential_count=0
|
||||
# --- The Filter Step for High Potential Candidates ---
|
||||
candidates_with_score_gte_75 = candidates_with_score_query.filter(
|
||||
annotated_match_score__gte=75
|
||||
# A. Pipeline & Volume Metrics (Scoped)
|
||||
total_active_jobs = job_scope_queryset.filter(status="ACTIVE").count()
|
||||
last_week = timezone.now() - timedelta(days=7)
|
||||
new_candidates_7days = candidate_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(
|
||||
candidate_count=Count('candidates', distinct=True)
|
||||
).aggregate(avg_apps=Avg('candidate_count'))['avg_apps']
|
||||
average_applications = round(average_applications_result or 0, 2)
|
||||
|
||||
|
||||
# B. Efficiency & Conversion Metrics (Scoped)
|
||||
hired_candidates = candidate_queryset.filter(
|
||||
Q(offer_status="Accepted") | Q(stage='HIRED'),
|
||||
join_date__isnull=False
|
||||
)
|
||||
high_potential_count=candidates_with_score_gte_75.count()
|
||||
high_potential_ratio = round((hight_potential_count / total_candidates) * 100, 1) if total_candidates > 0 else 0
|
||||
time_to_hire_query = hired_candidates.annotate(
|
||||
time_diff=ExpressionWrapper(
|
||||
F('join_date') - F('created_at__date'),
|
||||
output_field=fields.DurationField()
|
||||
)
|
||||
).aggregate(avg_time_to_hire=Avg('time_diff'))
|
||||
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
|
||||
)
|
||||
|
||||
applied_count = candidate_queryset.filter(stage='Applied').count()
|
||||
advanced_count = candidate_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 = candidate_queryset.filter(stage='Offer').count()
|
||||
offers_accepted_count = candidate_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
|
||||
|
||||
# Scored Candidates Ratio
|
||||
|
||||
# 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 = candidates_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 = candidates_with_score_query.filter(annotated_match_score__gte=HIGH_POTENTIAL_THRESHOLD).count()
|
||||
high_potential_ratio = round( (high_potential_count / total_candidates) * 100, 1 ) if total_candidates > 0 else 0
|
||||
total_scored_candidates = candidates_with_score_query.count()
|
||||
scored_ratio = round((total_scored_candidates / total_candidates) * 100, 1) if total_candidates > 0 else 0
|
||||
|
||||
jobs=models.JobPosting.objects.all().order_by('internal_job_id')
|
||||
selected_job_pk=request.GET.get('selected_job_pk','')
|
||||
candidate_stage=['APPLIED','EXAM','INTERVIEW','OFFER']
|
||||
apply_count,exam_count,interview_count,offer_count=[0]*4
|
||||
scored_ratio = round( (total_scored_candidates / total_candidates) * 100, 1 ) if total_candidates > 0 else 0
|
||||
|
||||
if selected_job_pk:
|
||||
try:
|
||||
job=jobs.get(pk=selected_job_pk)
|
||||
apply_count=job.screening_candidates_count
|
||||
exam_count=job.exam_candidates_count
|
||||
interview_count=job.interview_candidates_count
|
||||
offer_count=job.offer_candidates_count
|
||||
all_candidates_count=job.all_candidates_count
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
else: #default job
|
||||
try:
|
||||
job=jobs.first()
|
||||
apply_count=job.screening_candidates_count
|
||||
exam_count=job.exam_candidates_count
|
||||
interview_count=job.interview_candidates_count
|
||||
offer_count=job.offer_candidates_count
|
||||
all_candidates_count=job.all_candidates_count
|
||||
except Exception as e:
|
||||
print(e)
|
||||
candidates_count=[ apply_count,exam_count,interview_count,offer_count ]
|
||||
|
||||
# --- 6. CHART DATA PREPARATION ---
|
||||
|
||||
# A. Pipeline Funnel (Scoped)
|
||||
stage_counts = candidate_queryset.values('stage').annotate(count=Count('stage'))
|
||||
stage_map = {item['stage']: item['count'] for item in stage_counts}
|
||||
candidate_stage = ['Applied', 'Exam', 'Interview', 'Offer', 'HIRED']
|
||||
candidates_count = [
|
||||
stage_map.get('Applied', 0), stage_map.get('Exam', 0), stage_map.get('Interview', 0),
|
||||
stage_map.get('Offer', 0), filled_positions
|
||||
]
|
||||
|
||||
# --- 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
|
||||
|
||||
|
||||
# --- 8. CONTEXT RETURN ---
|
||||
|
||||
context = {
|
||||
'total_jobs': total_jobs,
|
||||
# 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_candidates': total_candidates,
|
||||
'new_candidates_7days': new_candidates_7days,
|
||||
'total_open_positions': total_open_positions,
|
||||
'average_applications': average_applications,
|
||||
|
||||
# Chart Data
|
||||
'job_titles': json.dumps(job_titles),
|
||||
'job_app_counts': json.dumps(job_app_counts),
|
||||
|
||||
# New Analytical Metrics (FIXED)
|
||||
'avg_match_score': avg_match_score,
|
||||
'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,
|
||||
'current_job_id':selected_job_pk,
|
||||
'jobs':jobs,
|
||||
'all_candidates_count':all_candidates_count,
|
||||
'candidate_stage':json.dumps(candidate_stage),
|
||||
'candidates_count':json.dumps(candidates_count)
|
||||
,'my_job':job
|
||||
|
||||
# Chart Data
|
||||
'candidate_stage': json.dumps(candidate_stage),
|
||||
'candidates_count': json.dumps(candidates_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,
|
||||
}
|
||||
|
||||
return render(request, 'recruitment/dashboard.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def candidate_offer_view(request, slug):
|
||||
"""View for candidates in the Offer stage"""
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
background-color: #f8f9fa;
|
||||
display: flex; /* Ensure title and filter are aligned */
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@ -107,14 +107,11 @@
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Bootstrap Overrides (Optional, for full consistency) */
|
||||
.btn-primary {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
/* Funnel Specific Styles */
|
||||
#candidate_funnel_chart {
|
||||
max-height: 400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
</style>
|
||||
@ -126,66 +123,60 @@
|
||||
<h1 class="mb-4" style="color: var(--kaauh-teal-dark); font-weight: 700;">{% trans "Recruitment Analytics" %}</h1>
|
||||
|
||||
{# -------------------------------------------------------------------------- #}
|
||||
{# STATS CARDS SECTION #}
|
||||
{# JOB FILTER SECTION #}
|
||||
{# -------------------------------------------------------------------------- #}
|
||||
<div class="stats">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-briefcase stat-icon"></i> {% trans "Total Jobs" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ total_jobs }}</div>
|
||||
<div class="stat-caption">{% trans "Active & Drafted Positions" %}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users stat-icon"></i> {% trans "Total Candidates" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ total_candidates }}</div>
|
||||
<div class="stat-caption">{% trans "All Profiles in ATS" %}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-chart-line stat-icon"></i> {% trans "Avg. Apps per Job" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ average_applications|floatformat:1 }}</div>
|
||||
<div class="stat-caption">{% trans "Efficiency Metric" %}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-star stat-icon"></i> {% trans "Avg. Match Score" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ avg_match_score|floatformat:1 }}</div>
|
||||
<div class="stat-caption">{% trans "Average AI Score (0-100)" %}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-trophy stat-icon"></i> {% trans "High Potential" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ high_potential_count }}</div>
|
||||
<div class="stat-caption">{% trans "Candidates with Score ≥ 75%" %} ({{ high_potential_ratio }})</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-cogs stat-icon"></i> {% trans "Scored Profiles" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ scored_ratio|floatformat:1 }}%</div>
|
||||
<div class="stat-caption">{% trans "Percent of profiles processed by AI" %}</div>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h2>
|
||||
<i class="fas fa-search stat-icon"></i>
|
||||
{% if current_job %}
|
||||
{% trans "Data Scope: " %} **{{ current_job.title }}**
|
||||
{% else %}
|
||||
{% trans "Data Scope: All Jobs" %}
|
||||
{% endif %}
|
||||
</h2>
|
||||
{# Job Filter Dropdown #}
|
||||
<form method="get" action="" class="job-filter-container">
|
||||
<label for="job-select" class="job-filter-label d-none d-md-inline">{% trans "Filter Job:" %}</label>
|
||||
<select name="selected_job_pk" id="job-select" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">{% trans "All Jobs (Default View)" %}</option>
|
||||
{% for job in jobs%}
|
||||
<option value="{{ job.pk}}" {% if current_job_id|slugify == job.pk|slugify %}selected{% endif %}>
|
||||
{{ job.title }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{# -------------------------------------------------------------------------- #}
|
||||
{# STATS CARDS SECTION (12 KPIs) #}
|
||||
{# -------------------------------------------------------------------------- #}
|
||||
{% include 'recruitment/partials/stats_cards.html' %}
|
||||
|
||||
|
||||
|
||||
|
||||
{# -------------------------------------------------------------------------- #}
|
||||
{# CHARTS SECTION (Using a row/col layout for structure) #}
|
||||
{# CHARTS SECTION #}
|
||||
{# -------------------------------------------------------------------------- #}
|
||||
<div class="row g-4">
|
||||
|
||||
{# BAR CHART - Application Volume #}
|
||||
{# AREA CHART - Daily Candidate Applications Trend (Global Chart) #}
|
||||
<div class="col-lg-12">
|
||||
<div class="card shadow-lg h-100">
|
||||
<div class="card-header">
|
||||
<h2>
|
||||
<i class="fas fa-chart-area stat-icon"></i>
|
||||
{% trans "Daily Candidate Applications Trend" %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="dailyApplicationsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{# BAR CHART - Application Volume (Global Chart) #}
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-lg h-100">
|
||||
<div class="card-header">
|
||||
<h2>
|
||||
@ -199,51 +190,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# HORIZONTAL BAR CHART - Candidate Pipeline Status (NOW FUNNEL EFFECT) #}
|
||||
<div class="col-lg-12">
|
||||
{# FUNNEL CHART - Candidate Pipeline Status (Scoped Chart) #}
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-lg h-100">
|
||||
<div class="card-header">
|
||||
<h2>
|
||||
<i class="fas fa-filter stat-icon"></i>
|
||||
{% trans "Candidate Pipeline Status for job: " %}
|
||||
<i class="fas fa-funnel-dollar stat-icon"></i>
|
||||
{% if current_job %}
|
||||
{% trans "Pipeline Funnel: " %} **{{ current_job.title }}**
|
||||
{% else %}
|
||||
{% trans "Total Pipeline Funnel (All Jobs)" %}
|
||||
{% endif %}
|
||||
</h2>
|
||||
<small>{{my_job}}</small>
|
||||
|
||||
{# Job Filter Dropdown - Consistent with Card Header Layout #}
|
||||
<form method="get" action="" class="job-filter-container">
|
||||
<label for="job-select" class="job-filter-label d-none d-md-inline">{% trans "Filter Job:" %}</label>
|
||||
<select name="selected_job_pk" id="job-select" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
|
||||
<option value="">{% trans "All Jobs (Default View)" %}</option>
|
||||
{% for job in jobs%}
|
||||
<option value="{{ job.pk}}" {% if selected_job_pk == job.pk %}selected{% endif %}>
|
||||
{{ job }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="chart-container d-flex justify-content-center align-items-center">
|
||||
{# Changed ID to reflect the funnel appearance #}
|
||||
<canvas id="candidate_funnel_chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# GAUGE CHART - Average Time-to-Hire (Avg. Days) #}
|
||||
<div class="col-lg-12">
|
||||
<div class="card shadow-lg h-100">
|
||||
<div class="card-header">
|
||||
<h2><i class="fas fa-tachometer-alt stat-icon"></i> {% trans "Time-to-Hire Target Check" %}</h2>
|
||||
</div>
|
||||
<div class="chart-container d-flex justify-content-center align-items-center" style="height: 300px;">
|
||||
<div id="timeToHireGauge" class="text-center">
|
||||
{% include "recruitment/partials/_guage_chart.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/luxon@3.4.4"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.3.1"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
// Get the all_candidates_count value from Django context
|
||||
const ALL_CANDIDATES_COUNT = '{{ all_candidates_count|default:0 }}';
|
||||
|
||||
// Pass context data safely to JavaScript
|
||||
const jobTitles = JSON.parse('{{ job_titles|escapejs }}').slice(0, 5); // Take top 5
|
||||
const jobAppCounts = JSON.parse('{{ job_app_counts|escapejs }}').slice(0, 5); // Take top 5
|
||||
|
||||
// BAR CHART configuration (Top 5 Applications)
|
||||
const totalCandidatesScoped = parseInt('{{ total_candidates|default:0 }}');
|
||||
const jobTitles = JSON.parse('{{ job_titles|escapejs }}').slice(0, 5);
|
||||
const jobAppCounts = JSON.parse('{{ job_app_counts|escapejs }}').slice(0, 5);
|
||||
const stages = JSON.parse('{{ candidate_stage|escapejs }}');
|
||||
const counts = JSON.parse('{{ candidates_count|safe }}');
|
||||
|
||||
// --- 1. BAR CHART configuration (Top 5 Applications) ---
|
||||
const ctxBar = document.getElementById('applicationsChart').getContext('2d');
|
||||
new Chart(ctxBar, {
|
||||
type: 'bar',
|
||||
@ -252,7 +248,7 @@
|
||||
datasets: [{
|
||||
label: '{% trans "Applications" %}',
|
||||
data: jobAppCounts,
|
||||
backgroundColor: '#00636e',
|
||||
backgroundColor: '#00636e',
|
||||
borderColor: 'var(--kaauh-teal-dark)',
|
||||
borderWidth: 1,
|
||||
barThickness: 50
|
||||
@ -287,28 +283,24 @@
|
||||
|
||||
|
||||
// --- 2. CANDIDATE PIPELINE CENTERED FUNNEL CHART ---
|
||||
const stages = JSON.parse('{{ candidate_stage|safe }}');
|
||||
const counts = JSON.parse('{{ candidates_count|safe }}');
|
||||
|
||||
// 1. Find the maximum count (for the widest bar)
|
||||
const maxCount = Math.max(...counts);
|
||||
|
||||
// 2. Calculate the transparent "spacer" data needed to center each bar
|
||||
// spacer = (maxCount - currentCount) / 2
|
||||
const spacerData = counts.map(count => (maxCount - count) / 2);
|
||||
|
||||
// VITAL CHANGE: Define the dark-to-light teal shades
|
||||
// Define the dark-to-light teal shades (5 stages, reverse order for funnel look)
|
||||
const tealShades = [
|
||||
'#004a53', // Darkest Teal (var(--kaauh-teal-dark))
|
||||
'#00636e', // Medium Teal (var(--kaauh-teal))
|
||||
'#007a88', // Medium-Light Teal
|
||||
'#0093a3', // Light Teal (var(--kaauh-teal-light))
|
||||
'#00acc0', // Lighter Teal
|
||||
'#18c5e0' // Lightest Teal (Add more shades if you expect more stages)
|
||||
'#00acc0', // APPLIED - Lighter Teal
|
||||
'#0093a3', // EXAM
|
||||
'#007a88', // INTERVIEW
|
||||
'#00636e', // OFFER
|
||||
'#004a53', // HIRED - Darkest Teal
|
||||
];
|
||||
|
||||
// Assign the first N shades based on the number of stages
|
||||
const stageColors = tealShades.slice(0, stages.length);
|
||||
// Slice and use the first N shades based on the number of stages
|
||||
const stageColors = tealShades.slice(tealShades.length - stages.length);
|
||||
|
||||
const ctxFunnel = document.getElementById('candidate_funnel_chart').getContext('2d');
|
||||
|
||||
@ -321,10 +313,9 @@
|
||||
{
|
||||
label: 'Spacer',
|
||||
data: spacerData,
|
||||
backgroundColor: 'transparent', // Makes this invisible
|
||||
hoverBackgroundColor: 'transparent', // Ensures hover is also invisible
|
||||
backgroundColor: 'transparent',
|
||||
hoverBackgroundColor: 'transparent',
|
||||
barThickness: 50,
|
||||
// Hide the data label/tooltip for the spacer
|
||||
datalabels: { display: false },
|
||||
tooltip: { enabled: false }
|
||||
},
|
||||
@ -332,7 +323,7 @@
|
||||
{
|
||||
label: '{% trans "Candidate Count" %}',
|
||||
data: counts,
|
||||
backgroundColor: stageColors, // <-- Now using the teal gradient
|
||||
backgroundColor: stageColors,
|
||||
barThickness: 50
|
||||
}
|
||||
]
|
||||
@ -341,17 +332,16 @@
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
// Key to funnel effect: allows the transparent and colored bars to sit next to each other
|
||||
// Note: Chart.js treats stacked horizontal bars as stacked vertically unless indexAxis is 'y'
|
||||
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
stacked: true, // MUST be stacked
|
||||
display: false, // Hides the value axis for a clean look
|
||||
stacked: true,
|
||||
display: false,
|
||||
max: maxCount
|
||||
},
|
||||
y: {
|
||||
stacked: true, // MUST be stacked
|
||||
stacked: true,
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: 'var(--kaauh-primary-text)',
|
||||
@ -360,16 +350,9 @@
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: '{% trans "Pipeline Status Funnel" %}',
|
||||
font: { size: 16 }
|
||||
},
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
// Only show the tooltip for the visible data
|
||||
filter: (tooltipItem) => {
|
||||
return tooltipItem.datasetIndex === 1;
|
||||
}
|
||||
@ -377,6 +360,88 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ... (after Treemap/Source Chart JS)
|
||||
|
||||
// Pass context data safely to JavaScript
|
||||
const globalDates = JSON.parse('{{ global_dates|escapejs }}');
|
||||
const globalCounts = JSON.parse('{{ global_counts|escapejs }}');
|
||||
const scopedDates = JSON.parse('{{ scoped_dates|escapejs }}');
|
||||
const scopedCounts = JSON.parse('{{ scoped_counts|escapejs }}');
|
||||
const isJobScoped = '{{ is_job_scoped }}' === 'True';
|
||||
|
||||
|
||||
// --- 4. DAILY APPLICATIONS LINE CHART ---
|
||||
const ctxLine = document.getElementById('dailyApplicationsChart').getContext('2d');
|
||||
|
||||
// Create datasets
|
||||
const datasets = [{
|
||||
label: '{% trans "All Jobs" %}',
|
||||
data: globalCounts,
|
||||
borderColor: '#004a53', // Dark Teal
|
||||
backgroundColor: 'rgba(0, 74, 83, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.2
|
||||
}];
|
||||
|
||||
// Add scoped data if a job is selected
|
||||
if (isJobScoped) {
|
||||
datasets.push({
|
||||
label: '{% trans "Current Job" %}',
|
||||
data: scopedCounts,
|
||||
borderColor: '#0093a3', // Light Teal
|
||||
backgroundColor: 'rgba(0, 147, 163, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.2
|
||||
});
|
||||
}
|
||||
|
||||
new Chart(ctxLine, {
|
||||
type: 'line',
|
||||
data: {
|
||||
// Use global dates as the base labels
|
||||
labels: globalDates,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
aspectRatio: 3.5,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: '{% trans "Daily Applications (Last 30 Days)" %}',
|
||||
font: { size: 16 },
|
||||
color: 'var(--kaauh-primary-text)'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'day',
|
||||
tooltipFormat: 'MMM D',
|
||||
displayFormats: {
|
||||
day: 'MMM D'
|
||||
}
|
||||
},
|
||||
title: { display: true, text: '{% trans "Date" %}' },
|
||||
grid: { display: false }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: { display: true, text: '{% trans "New Candidates" %}' },
|
||||
ticks: { precision: 0 },
|
||||
grid: { color: '#e0e0e0' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
51
templates/recruitment/partials/_guage_chart.html
Normal file
51
templates/recruitment/partials/_guage_chart.html
Normal file
@ -0,0 +1,51 @@
|
||||
{% load i18n %}
|
||||
<style>
|
||||
/* ... (CSS styles remain the same - omitted here for brevity) ... */
|
||||
.gauge-container {
|
||||
width: 250px;
|
||||
height: 125px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 20px auto 10px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
.gauge-arc {
|
||||
/* ... */
|
||||
}
|
||||
.gauge-color-fill {
|
||||
/* Color sectors: Green (0-45), Yellow (45-67.5), Red (67.5-90)
|
||||
Note: The degrees in conic-gradient correspond to the 90-degree
|
||||
segmentation of the 180-degree gauge arc. */
|
||||
background: conic-gradient(
|
||||
var(--color-success) 0deg 90deg,
|
||||
var(--color-warning) 90deg 135deg,
|
||||
red 135deg 180deg,
|
||||
#e9ecef 180deg 360deg
|
||||
);
|
||||
/* ... */
|
||||
}
|
||||
.gauge-needle {
|
||||
/* ... */
|
||||
transition: transform 1.5s ease-out;
|
||||
}
|
||||
/* ... (rest of CSS) ... */
|
||||
</style>
|
||||
|
||||
{# Use variables directly from the context #}
|
||||
<div class="gauge-value-display">
|
||||
{{ avg_time_to_hire_days|default:0 }} {% trans "Days" %}
|
||||
</div>
|
||||
|
||||
<div class="gauge-container">
|
||||
<div class="gauge-color-fill"></div>
|
||||
<div class="gauge-arc"></div>
|
||||
|
||||
{# Inject the final, calculated degrees directly into the style attribute #}
|
||||
<div class="gauge-needle" style="transform: rotate({{ gauge_rotation_degrees }}deg);">
|
||||
<div class="gauge-center"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small mt-3">
|
||||
{% trans "Target:" %} **{{ gauge_target_days }}** {% trans "Days" %} | {% trans "Max Scale:" %} {{ gauge_max_days }} {% trans "Days" %}
|
||||
</div>
|
||||
112
templates/recruitment/partials/stats_cards.html
Normal file
112
templates/recruitment/partials/stats_cards.html
Normal file
@ -0,0 +1,112 @@
|
||||
{%load i18n %}
|
||||
{# -------------------------------------------------------------------------- #}
|
||||
{# STATS CARDS SECTION (12 KPIs) #}
|
||||
{# -------------------------------------------------------------------------- #}
|
||||
<div class="stats">
|
||||
|
||||
{# GLOBAL - 1. Total Jobs (System) #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-list stat-icon"></i> {% trans "Total Jobs" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ total_jobs_global }}</div>
|
||||
<div class="stat-caption">{% trans "All Active & Drafted Positions (Global)" %}</div>
|
||||
</div>
|
||||
|
||||
{# SCOPED - 2. Total Active Jobs #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-briefcase stat-icon"></i> {% trans "Active Jobs" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ total_active_jobs }}</div>
|
||||
<div class="stat-caption">{% trans "Currently Open Requisitions (Scoped)" %}</div>
|
||||
</div>
|
||||
|
||||
{# SCOPED - 3. Total Candidates #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users stat-icon"></i> {% trans "Total Candidates" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ total_candidates }}</div>
|
||||
<div class="stat-caption">{% trans "Total Profiles in Current Scope" %}</div>
|
||||
</div>
|
||||
|
||||
{# SCOPED - 4. Open Positions #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-th-list stat-icon"></i> {% trans "Open Positions" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ total_open_positions }}</div>
|
||||
<div class="stat-caption">{% trans "Total Slots to be Filled (Scoped)" %}</div>
|
||||
</div>
|
||||
|
||||
{# GLOBAL - 5. Total Participants #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-address-book stat-icon"></i> {% trans "Total Participants" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ total_participants }}</div>
|
||||
<div class="stat-caption">{% trans "Total Recruiters/Interviewers (Global)" %}</div>
|
||||
</div>
|
||||
|
||||
{# GLOBAL - 6. Total LinkedIn Posts #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fab fa-linkedin stat-icon"></i> {% trans "LinkedIn Posts" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ total_jobs_posted_linkedin }}</div>
|
||||
<div class="stat-caption">{% trans "Total Job Posts Sent to LinkedIn (Global)" %}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-chart-line stat-icon"></i> {% trans "New Apps (7 Days)" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ new_candidates_7days }}</div>
|
||||
<div class="stat-caption">{% trans "Incoming applications last week" %}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-cogs stat-icon"></i> {% trans "Avg. Apps per Job" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ average_applications|floatformat:1 }}</div>
|
||||
<div class="stat-caption">{% trans "Average Applications per Job (Scoped)" %}</div>
|
||||
</div>
|
||||
|
||||
{# --- Efficiency & Quality Metrics --- #}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clock stat-icon"></i> {% trans "Time-to-Hire" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ avg_time_to_hire_days }}</div>
|
||||
<div class="stat-caption">{% trans "Avg. Days (Application to Hired)" %}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-star stat-icon"></i> {% trans "Avg. Match Score" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ avg_match_score|floatformat:1 }}</div>
|
||||
<div class="stat-caption">{% trans "Average AI Score (Current Scope)" %}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-trophy stat-icon"></i> {% trans "High Potential" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ high_potential_count }}</div>
|
||||
<div class="stat-caption">{% trans "Score ≥ 75% Profiles" %} ({{ high_potential_ratio|floatformat:1 }}%)</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-calendar-alt stat-icon"></i> {% trans "Meetings This Week" %}</h3>
|
||||
</div>
|
||||
<div class="stat-value">{{ meetings_scheduled_this_week }}</div>
|
||||
<div class="stat-caption">{% trans "Scheduled Interviews (Current Week)" %}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user