dashboard

This commit is contained in:
Faheed 2025-10-30 04:07:56 +03:00
parent b13a1dd1ee
commit 8da8d89433
27 changed files with 709 additions and 608 deletions

View File

@ -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',
}

View File

@ -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

View File

@ -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'),

View File

@ -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',
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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',
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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',
),
]

View File

@ -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

View File

@ -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)

View File

@ -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"""

View File

@ -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 %}

View 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>

View 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>