diff --git a/NorahUniversity/__pycache__/settings.cpython-312.pyc b/NorahUniversity/__pycache__/settings.cpython-312.pyc index a882953..ef70d24 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-312.pyc and b/NorahUniversity/__pycache__/settings.cpython-312.pyc differ diff --git a/NorahUniversity/__pycache__/urls.cpython-312.pyc b/NorahUniversity/__pycache__/urls.cpython-312.pyc index 7ad3c1f..0e668c6 100644 Binary files a/NorahUniversity/__pycache__/urls.cpython-312.pyc and b/NorahUniversity/__pycache__/urls.cpython-312.pyc differ diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 2333ee5..3eb57de 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -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', } diff --git a/recruitment/__pycache__/admin.cpython-312.pyc b/recruitment/__pycache__/admin.cpython-312.pyc index 9901ab4..86a871d 100644 Binary files a/recruitment/__pycache__/admin.cpython-312.pyc and b/recruitment/__pycache__/admin.cpython-312.pyc differ diff --git a/recruitment/__pycache__/forms.cpython-312.pyc b/recruitment/__pycache__/forms.cpython-312.pyc index 8e0f6cc..a61fc29 100644 Binary files a/recruitment/__pycache__/forms.cpython-312.pyc and b/recruitment/__pycache__/forms.cpython-312.pyc differ diff --git a/recruitment/__pycache__/models.cpython-312.pyc b/recruitment/__pycache__/models.cpython-312.pyc index b3fa580..098b384 100644 Binary files a/recruitment/__pycache__/models.cpython-312.pyc and b/recruitment/__pycache__/models.cpython-312.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-312.pyc b/recruitment/__pycache__/signals.cpython-312.pyc index 03bb073..44189be 100644 Binary files a/recruitment/__pycache__/signals.cpython-312.pyc and b/recruitment/__pycache__/signals.cpython-312.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-312.pyc b/recruitment/__pycache__/urls.cpython-312.pyc index 6b6b9ff..05d0cff 100644 Binary files a/recruitment/__pycache__/urls.cpython-312.pyc and b/recruitment/__pycache__/urls.cpython-312.pyc differ diff --git a/recruitment/__pycache__/views.cpython-312.pyc b/recruitment/__pycache__/views.cpython-312.pyc index 317da31..1b785c1 100644 Binary files a/recruitment/__pycache__/views.cpython-312.pyc and b/recruitment/__pycache__/views.cpython-312.pyc differ diff --git a/recruitment/__pycache__/views_frontend.cpython-312.pyc b/recruitment/__pycache__/views_frontend.cpython-312.pyc index 4b4d2ca..a801254 100644 Binary files a/recruitment/__pycache__/views_frontend.cpython-312.pyc and b/recruitment/__pycache__/views_frontend.cpython-312.pyc differ diff --git a/recruitment/forms.py b/recruitment/forms.py index 495db3e..590a0ea 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -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 + \ No newline at end of file diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py index 45877f8..1e9fde3 100644 --- a/recruitment/migrations/0001_initial.py +++ b/recruitment/migrations/0001_initial.py @@ -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'), diff --git a/recruitment/migrations/0002_remove_profile_created_at_remove_profile_slug_and_more.py b/recruitment/migrations/0002_remove_profile_created_at_remove_profile_slug_and_more.py deleted file mode 100644 index 00aaa1e..0000000 --- a/recruitment/migrations/0002_remove_profile_created_at_remove_profile_slug_and_more.py +++ /dev/null @@ -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', - ), - ] diff --git a/recruitment/migrations/0003_candidate_hired_date_source_custom_headers_and_more.py b/recruitment/migrations/0003_candidate_hired_date_source_custom_headers_and_more.py deleted file mode 100644 index 7c999ad..0000000 --- a/recruitment/migrations/0003_candidate_hired_date_source_custom_headers_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/recruitment/migrations/0004_alter_integrationlog_method.py b/recruitment/migrations/0004_alter_integrationlog_method.py deleted file mode 100644 index e4ab1d0..0000000 --- a/recruitment/migrations/0004_alter_integrationlog_method.py +++ /dev/null @@ -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'), - ), - ] diff --git a/recruitment/migrations/0005_rename_submitted_by_agency_candidate_hiring_agency.py b/recruitment/migrations/0005_rename_submitted_by_agency_candidate_hiring_agency.py deleted file mode 100644 index 48ea4b0..0000000 --- a/recruitment/migrations/0005_rename_submitted_by_agency_candidate_hiring_agency.py +++ /dev/null @@ -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', - ), - ] diff --git a/recruitment/migrations/0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more.py b/recruitment/migrations/0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more.py deleted file mode 100644 index 8c1c20c..0000000 --- a/recruitment/migrations/0006_agencyjobassignment_agencyaccesslink_agencymessage_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/recruitment/migrations/0007_candidate_source_candidate_source_type.py b/recruitment/migrations/0007_candidate_source_candidate_source_type.py deleted file mode 100644 index 5f83b42..0000000 --- a/recruitment/migrations/0007_candidate_source_candidate_source_type.py +++ /dev/null @@ -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'), - ), - ] diff --git a/recruitment/migrations/0008_remove_candidate_source_remove_candidate_source_type_and_more.py b/recruitment/migrations/0008_remove_candidate_source_remove_candidate_source_type_and_more.py deleted file mode 100644 index 591252c..0000000 --- a/recruitment/migrations/0008_remove_candidate_source_remove_candidate_source_type_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/recruitment/migrations/0009_agencymessage_priority_agencymessage_recipient_user_and_more.py b/recruitment/migrations/0009_agencymessage_priority_agencymessage_recipient_user_and_more.py deleted file mode 100644 index 5b27532..0000000 --- a/recruitment/migrations/0009_agencymessage_priority_agencymessage_recipient_user_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/recruitment/migrations/0010_remove_agency_message_model.py b/recruitment/migrations/0010_remove_agency_message_model.py deleted file mode 100644 index f042dcf..0000000 --- a/recruitment/migrations/0010_remove_agency_message_model.py +++ /dev/null @@ -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', - ), - ] diff --git a/recruitment/models.py b/recruitment/models.py index 1bfb364..5e80ec2 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -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 diff --git a/recruitment/views.py b/recruitment/views.py index 7231631..b2d40cf 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -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) diff --git a/recruitment/views_frontend.py b/recruitment/views_frontend.py index 567e7e1..f0b25cc 100644 --- a/recruitment/views_frontend.py +++ b/recruitment/views_frontend.py @@ -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""" diff --git a/templates/recruitment/dashboard.html b/templates/recruitment/dashboard.html index 9dee733..208acd4 100644 --- a/templates/recruitment/dashboard.html +++ b/templates/recruitment/dashboard.html @@ -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; } @@ -126,66 +123,60 @@

{% trans "Recruitment Analytics" %}

{# -------------------------------------------------------------------------- #} - {# STATS CARDS SECTION #} + {# JOB FILTER SECTION #} {# -------------------------------------------------------------------------- #} -
- -
-
-

{% trans "Total Jobs" %}

-
-
{{ total_jobs }}
-
{% trans "Active & Drafted Positions" %}
-
- -
-
-

{% trans "Total Candidates" %}

-
-
{{ total_candidates }}
-
{% trans "All Profiles in ATS" %}
-
- -
-
-

{% trans "Avg. Apps per Job" %}

-
-
{{ average_applications|floatformat:1 }}
-
{% trans "Efficiency Metric" %}
-
- -
-
-

{% trans "Avg. Match Score" %}

-
-
{{ avg_match_score|floatformat:1 }}
-
{% trans "Average AI Score (0-100)" %}
-
- -
-
-

{% trans "High Potential" %}

-
-
{{ high_potential_count }}
-
{% trans "Candidates with Score ≥ 75%" %} ({{ high_potential_ratio }})
-
- -
-
-

{% trans "Scored Profiles" %}

-
-
{{ scored_ratio|floatformat:1 }}%
-
{% trans "Percent of profiles processed by AI" %}
+
+
+

+ + {% if current_job %} + {% trans "Data Scope: " %} **{{ current_job.title }}** + {% else %} + {% trans "Data Scope: All Jobs" %} + {% endif %} +

+ {# Job Filter Dropdown #} +
+ + +
+ {# -------------------------------------------------------------------------- #} + {# STATS CARDS SECTION (12 KPIs) #} + {# -------------------------------------------------------------------------- #} + {% include 'recruitment/partials/stats_cards.html' %} + + + {# -------------------------------------------------------------------------- #} - {# CHARTS SECTION (Using a row/col layout for structure) #} + {# CHARTS SECTION #} {# -------------------------------------------------------------------------- #}
- - {# BAR CHART - Application Volume #} + {# AREA CHART - Daily Candidate Applications Trend (Global Chart) #}
+
+
+

+ + {% trans "Daily Candidate Applications Trend" %} +

+
+
+ +
+
+
+ {# BAR CHART - Application Volume (Global Chart) #} +

@@ -199,51 +190,56 @@

- {# HORIZONTAL BAR CHART - Candidate Pipeline Status (NOW FUNNEL EFFECT) #} -
+ {# FUNNEL CHART - Candidate Pipeline Status (Scoped Chart) #} +

- - {% trans "Candidate Pipeline Status for job: " %} + + {% if current_job %} + {% trans "Pipeline Funnel: " %} **{{ current_job.title }}** + {% else %} + {% trans "Total Pipeline Funnel (All Jobs)" %} + {% endif %}

- {{my_job}} - - {# Job Filter Dropdown - Consistent with Card Header Layout #} -
- - -
-
- {# Changed ID to reflect the funnel appearance #}
+ + {# GAUGE CHART - Average Time-to-Hire (Avg. Days) #} +
+
+
+

{% trans "Time-to-Hire Target Check" %}

+
+
+
+ {% include "recruitment/partials/_guage_chart.html" %} +
+
+
+
+ +
+ + + - {% endblock %} \ No newline at end of file diff --git a/templates/recruitment/partials/_guage_chart.html b/templates/recruitment/partials/_guage_chart.html new file mode 100644 index 0000000..d85fdf5 --- /dev/null +++ b/templates/recruitment/partials/_guage_chart.html @@ -0,0 +1,51 @@ +{% load i18n %} + + +{# Use variables directly from the context #} +
+ {{ avg_time_to_hire_days|default:0 }} {% trans "Days" %} +
+ +
+
+
+ + {# Inject the final, calculated degrees directly into the style attribute #} +
+
+
+
+ +
+ {% trans "Target:" %} **{{ gauge_target_days }}** {% trans "Days" %} | {% trans "Max Scale:" %} {{ gauge_max_days }} {% trans "Days" %} +
\ No newline at end of file diff --git a/templates/recruitment/partials/stats_cards.html b/templates/recruitment/partials/stats_cards.html new file mode 100644 index 0000000..5ce4227 --- /dev/null +++ b/templates/recruitment/partials/stats_cards.html @@ -0,0 +1,112 @@ +{%load i18n %} +{# -------------------------------------------------------------------------- #} + {# STATS CARDS SECTION (12 KPIs) #} + {# -------------------------------------------------------------------------- #} +
+ + {# GLOBAL - 1. Total Jobs (System) #} +
+
+

{% trans "Total Jobs" %}

+
+
{{ total_jobs_global }}
+
{% trans "All Active & Drafted Positions (Global)" %}
+
+ + {# SCOPED - 2. Total Active Jobs #} +
+
+

{% trans "Active Jobs" %}

+
+
{{ total_active_jobs }}
+
{% trans "Currently Open Requisitions (Scoped)" %}
+
+ + {# SCOPED - 3. Total Candidates #} +
+
+

{% trans "Total Candidates" %}

+
+
{{ total_candidates }}
+
{% trans "Total Profiles in Current Scope" %}
+
+ + {# SCOPED - 4. Open Positions #} +
+
+

{% trans "Open Positions" %}

+
+
{{ total_open_positions }}
+
{% trans "Total Slots to be Filled (Scoped)" %}
+
+ + {# GLOBAL - 5. Total Participants #} +
+
+

{% trans "Total Participants" %}

+
+
{{ total_participants }}
+
{% trans "Total Recruiters/Interviewers (Global)" %}
+
+ + {# GLOBAL - 6. Total LinkedIn Posts #} +
+
+

{% trans "LinkedIn Posts" %}

+
+
{{ total_jobs_posted_linkedin }}
+
{% trans "Total Job Posts Sent to LinkedIn (Global)" %}
+
+ +
+
+

{% trans "New Apps (7 Days)" %}

+
+
{{ new_candidates_7days }}
+
{% trans "Incoming applications last week" %}
+
+ +
+
+

{% trans "Avg. Apps per Job" %}

+
+
{{ average_applications|floatformat:1 }}
+
{% trans "Average Applications per Job (Scoped)" %}
+
+ + {# --- Efficiency & Quality Metrics --- #} + +
+
+

{% trans "Time-to-Hire" %}

+
+
{{ avg_time_to_hire_days }}
+
{% trans "Avg. Days (Application to Hired)" %}
+
+ +
+
+

{% trans "Avg. Match Score" %}

+
+
{{ avg_match_score|floatformat:1 }}
+
{% trans "Average AI Score (Current Scope)" %}
+
+ +
+
+

{% trans "High Potential" %}

+
+
{{ high_potential_count }}
+
{% trans "Score ≥ 75% Profiles" %} ({{ high_potential_ratio|floatformat:1 }}%)
+
+ +
+
+

{% trans "Meetings This Week" %}

+
+
{{ meetings_scheduled_this_week }}
+
{% trans "Scheduled Interviews (Current Week)" %}
+
+ +
+