scheduled interview
This commit is contained in:
parent
d0235bfefe
commit
64e04a011d
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-14 21:43
|
||||
# Generated by Django 5.2.7 on 2025-11-17 09:52
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
@ -127,6 +127,8 @@ class Migration(migrations.Migration):
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('user_type', models.CharField(choices=[('staff', 'Staff'), ('agency', 'Agency'), ('candidate', 'Candidate')], default='staff', max_length=20, verbose_name='User Type')),
|
||||
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
|
||||
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')),
|
||||
('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
@ -221,6 +223,7 @@ class Migration(migrations.Migration):
|
||||
('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
|
||||
('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)),
|
||||
('address', models.TextField(blank=True, null=True)),
|
||||
('generated_password', models.CharField(blank=True, help_text='Generated password for agency user account', max_length=255, null=True)),
|
||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='agency_profile', to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
],
|
||||
options={
|
||||
@ -241,10 +244,11 @@ class Migration(migrations.Migration):
|
||||
('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')),
|
||||
('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'), ('Hired', 'Hired'), ('Rejected', 'Rejected')], db_index=True, default='Applied', max_length=20, verbose_name='Stage')),
|
||||
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Document Review', 'Document Review'), ('Offer', 'Offer'), ('Hired', 'Hired'), ('Rejected', 'Rejected')], db_index=True, default='Applied', max_length=20, verbose_name='Stage')),
|
||||
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=20, 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=20, null=True, verbose_name='Exam Status')),
|
||||
('exam_score', models.FloatField(blank=True, null=True, verbose_name='Exam Score')),
|
||||
('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
|
||||
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Interview Status')),
|
||||
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
|
||||
@ -289,6 +293,7 @@ class Migration(migrations.Migration):
|
||||
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
|
||||
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
|
||||
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
|
||||
('host_email', models.CharField(blank=True, null=True)),
|
||||
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
|
||||
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
|
||||
],
|
||||
@ -337,6 +342,7 @@ class Migration(migrations.Migration):
|
||||
('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')),
|
||||
('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
|
||||
('cancelled_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ai_parsed', models.BooleanField(default=False, help_text='Whether the job posting has been parsed by AI', verbose_name='AI Parsed')),
|
||||
('assigned_to', models.ForeignKey(blank=True, help_text='The user who has been assigned to this job', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_jobs', to=settings.AUTH_USER_MODEL, verbose_name='Assigned To')),
|
||||
('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
|
||||
('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')),
|
||||
@ -428,7 +434,7 @@ class Migration(migrations.Migration):
|
||||
('message_type', models.CharField(choices=[('direct', 'Direct Message'), ('job_related', 'Job Related'), ('system', 'System Notification')], default='direct', 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')),
|
||||
('job', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.jobposting', verbose_name='Related Job')),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.jobposting', verbose_name='Related Job')),
|
||||
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
|
||||
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')),
|
||||
],
|
||||
@ -450,7 +456,6 @@ class Migration(migrations.Migration):
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')),
|
||||
('last_error', models.TextField(blank=True, verbose_name='Last Error Message')),
|
||||
('inteview', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.interviewschedule', verbose_name='Related Interview')),
|
||||
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
|
||||
],
|
||||
options={
|
||||
@ -472,7 +477,8 @@ class Migration(migrations.Migration):
|
||||
('email', models.EmailField(db_index=True, help_text='Unique email address for the person', max_length=254, unique=True, verbose_name='Email')),
|
||||
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
|
||||
('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')),
|
||||
('gender', models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female'), ('O', 'Other'), ('P', 'Prefer not to say')], max_length=1, null=True, verbose_name='Gender')),
|
||||
('gender', models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female')], max_length=1, null=True, verbose_name='Gender')),
|
||||
('gpa', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True, verbose_name='GPA')),
|
||||
('nationality', django_countries.fields.CountryField(blank=True, max_length=2, null=True, verbose_name='Nationality')),
|
||||
('address', models.TextField(blank=True, null=True, verbose_name='Address')),
|
||||
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')),
|
||||
@ -490,16 +496,6 @@ class Migration(migrations.Migration):
|
||||
name='person',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.person', verbose_name='Person'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Profile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('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)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ScheduledInterview',
|
||||
fields=[
|
||||
@ -660,6 +656,11 @@ class Migration(migrations.Migration):
|
||||
model_name='formsubmission',
|
||||
index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='related_meeting',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.zoommeetingdetails', verbose_name='Related Meeting'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='formtemplate',
|
||||
index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'),
|
||||
@ -704,14 +705,6 @@ class Migration(migrations.Migration):
|
||||
model_name='message',
|
||||
index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='person',
|
||||
index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'),
|
||||
@ -764,4 +757,12 @@ class Migration(migrations.Migration):
|
||||
model_name='jobposting',
|
||||
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'),
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-13 13:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='jobposting',
|
||||
name='ai_parsed',
|
||||
field=models.BooleanField(default=False, help_text='Whether the job posting has been parsed by AI', verbose_name='AI Parsed'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-14 22:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='zoommeetingdetails',
|
||||
name='host_email',
|
||||
field=models.CharField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-13 14:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0002_jobposting_ai_parsed'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='hiringagency',
|
||||
name='generated_password',
|
||||
field=models.CharField(blank=True, help_text='Generated password for agency user account', max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-14 23:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0003_add_agency_password_field'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='person',
|
||||
name='gender',
|
||||
field=models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female')], max_length=1, null=True, verbose_name='Gender'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-15 20:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0004_alter_person_gender'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='person',
|
||||
name='gpa',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True, verbose_name='GPA'),
|
||||
),
|
||||
]
|
||||
@ -1,24 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-15 20:56
|
||||
|
||||
import recruitment.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0005_person_gpa'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customuser',
|
||||
name='designation',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='customuser',
|
||||
name='profile_image',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image'),
|
||||
),
|
||||
]
|
||||
@ -1,60 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-15 20:57
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_profile_data_to_customuser(apps, schema_editor):
|
||||
"""
|
||||
Migrate data from Profile model to CustomUser model
|
||||
"""
|
||||
CustomUser = apps.get_model('recruitment', 'CustomUser')
|
||||
Profile = apps.get_model('recruitment', 'Profile')
|
||||
|
||||
# Get all profiles
|
||||
profiles = Profile.objects.all()
|
||||
|
||||
for profile in profiles:
|
||||
if profile.user:
|
||||
# Update CustomUser with Profile data
|
||||
user = profile.user
|
||||
if profile.profile_image:
|
||||
user.profile_image = profile.profile_image
|
||||
if profile.designation:
|
||||
user.designation = profile.designation
|
||||
user.save(update_fields=['profile_image', 'designation'])
|
||||
|
||||
|
||||
def reverse_migrate_profile_data(apps, schema_editor):
|
||||
"""
|
||||
Reverse migration: move data from CustomUser back to Profile
|
||||
"""
|
||||
CustomUser = apps.get_model('recruitment', 'CustomUser')
|
||||
Profile = apps.get_model('recruitment', 'Profile')
|
||||
|
||||
# Get all users with profile data
|
||||
users = CustomUser.objects.exclude(profile_image__isnull=True).exclude(profile_image='')
|
||||
|
||||
for user in users:
|
||||
# Get or create profile for this user
|
||||
profile, created = Profile.objects.get_or_create(user=user)
|
||||
|
||||
# Update Profile with CustomUser data
|
||||
if user.profile_image:
|
||||
profile.profile_image = user.profile_image
|
||||
if user.designation:
|
||||
profile.designation = user.designation
|
||||
profile.save(update_fields=['profile_image', 'designation'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0006_add_profile_fields_to_customuser'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
migrate_profile_data_to_customuser,
|
||||
reverse_migrate_profile_data,
|
||||
),
|
||||
]
|
||||
@ -1,16 +0,0 @@
|
||||
# Generated manually to drop the Profile model after migration to CustomUser
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0007_migrate_profile_data_to_customuser'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='Profile',
|
||||
),
|
||||
]
|
||||
@ -1,20 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-16 10:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0008_drop_profile_model'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='message',
|
||||
name='job',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.jobposting', verbose_name='Related Job'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-16 11:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0009_alter_message_job'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='application',
|
||||
name='stage',
|
||||
field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Document Review', 'Document Review'), ('Offer', 'Offer'), ('Hired', 'Hired'), ('Rejected', 'Rejected')], db_index=True, default='Applied', max_length=20, verbose_name='Stage'),
|
||||
),
|
||||
]
|
||||
@ -1,13 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-16 12:08
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0010_add_document_review_stage'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-16 12:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recruitment', '0011_add_document_review_stage'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='application',
|
||||
name='exam_score',
|
||||
field=models.FloatField(blank=True, null=True, verbose_name='Exam Score'),
|
||||
),
|
||||
]
|
||||
@ -906,11 +906,35 @@ class Application(Base):
|
||||
|
||||
@property
|
||||
def get_latest_meeting(self):
|
||||
"""Legacy compatibility - get latest meeting for this application"""
|
||||
"""
|
||||
Retrieves the most specific location details (subclass instance)
|
||||
of the latest ScheduledInterview for this application, or None.
|
||||
"""
|
||||
# 1. Get the latest ScheduledInterview
|
||||
schedule = self.scheduled_interviews.order_by("-created_at").first()
|
||||
if schedule:
|
||||
return schedule.zoom_meeting
|
||||
return None
|
||||
|
||||
# Check if a schedule exists and if it has an interview location
|
||||
if not schedule or not schedule.interview_location:
|
||||
return None
|
||||
|
||||
# Get the base location instance
|
||||
interview_location = schedule.interview_location
|
||||
|
||||
# 2. Safely retrieve the specific subclass details
|
||||
|
||||
# Determine the expected subclass accessor name based on the location_type
|
||||
if interview_location.location_type == 'Remote':
|
||||
accessor_name = 'zoommeetingdetails'
|
||||
else: # Assumes 'Onsite' or any other type defaults to Onsite
|
||||
accessor_name = 'onsitelocationdetails'
|
||||
|
||||
# Use getattr to safely retrieve the specific meeting object (subclass instance).
|
||||
# If the accessor exists but points to None (because the subclass record was deleted),
|
||||
# or if the accessor name is wrong for the object's true type, it will return None.
|
||||
meeting_details = getattr(interview_location, accessor_name, None)
|
||||
|
||||
return meeting_details
|
||||
|
||||
|
||||
@property
|
||||
def has_future_meeting(self):
|
||||
|
||||
@ -35,16 +35,7 @@ urlpatterns = [
|
||||
),
|
||||
path("jobs/linkedin/login/", views.linkedin_login, name="linkedin_login"),
|
||||
path("jobs/linkedin/callback/", views.linkedin_callback, name="linkedin_callback"),
|
||||
path(
|
||||
"jobs/<slug:slug>/schedule-interviews/",
|
||||
views.schedule_interviews_view,
|
||||
name="schedule_interviews",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:slug>/confirm-schedule-interviews/",
|
||||
views.confirm_schedule_interviews_view,
|
||||
name="confirm_schedule_interviews_view",
|
||||
),
|
||||
|
||||
# Candidate URLs
|
||||
path(
|
||||
"candidates/", views_frontend.ApplicationListView.as_view(), name="candidate_list"
|
||||
@ -299,38 +290,7 @@ urlpatterns = [
|
||||
views.interview_detail_view,
|
||||
name="interview_detail",
|
||||
),
|
||||
# Candidate Meeting Scheduling/Rescheduling URLs
|
||||
path(
|
||||
"jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/",
|
||||
views.schedule_candidate_meeting,
|
||||
name="schedule_candidate_meeting",
|
||||
),
|
||||
path(
|
||||
"api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/",
|
||||
views.api_schedule_candidate_meeting,
|
||||
name="api_schedule_candidate_meeting",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/",
|
||||
views.reschedule_candidate_meeting,
|
||||
name="reschedule_candidate_meeting",
|
||||
),
|
||||
path(
|
||||
"api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/",
|
||||
views.api_reschedule_candidate_meeting,
|
||||
name="api_reschedule_candidate_meeting",
|
||||
),
|
||||
# New URL for simple page-based meeting scheduling
|
||||
path(
|
||||
"jobs/<slug:slug>/candidates/<int:candidate_pk>/schedule-meeting-page/",
|
||||
views.schedule_meeting_for_candidate,
|
||||
name="schedule_meeting_for_candidate",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:slug>/candidates/<int:candidate_pk>/delete_meeting_for_candidate/<int:meeting_id>/",
|
||||
views.delete_meeting_for_candidate,
|
||||
name="delete_meeting_for_candidate",
|
||||
),
|
||||
|
||||
# users urls
|
||||
path("user/<int:pk>", views.user_detail, name="user_detail"),
|
||||
path(
|
||||
@ -623,4 +583,77 @@ urlpatterns = [
|
||||
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'),
|
||||
# path('interviews/<slug:slug>/update/', views.ScheduledInterviewUpdateView.as_view(), name='update_scheduled_interview'),
|
||||
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
|
||||
|
||||
#interview and meeting related urls
|
||||
path(
|
||||
"jobs/<slug:slug>/schedule-interviews/",
|
||||
views.schedule_interviews_view,
|
||||
name="schedule_interviews",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:slug>/confirm-schedule-interviews/",
|
||||
views.confirm_schedule_interviews_view,
|
||||
name="confirm_schedule_interviews_view",
|
||||
),
|
||||
|
||||
# Candidate Meeting Scheduling/Rescheduling URLs
|
||||
path(
|
||||
"jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/",
|
||||
views.schedule_candidate_meeting,
|
||||
name="schedule_candidate_meeting",
|
||||
),
|
||||
path(
|
||||
"api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/",
|
||||
views.api_schedule_candidate_meeting,
|
||||
name="api_schedule_candidate_meeting",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/",
|
||||
views.reschedule_candidate_meeting,
|
||||
name="reschedule_candidate_meeting",
|
||||
),
|
||||
path(
|
||||
"api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/",
|
||||
views.api_reschedule_candidate_meeting,
|
||||
name="api_reschedule_candidate_meeting",
|
||||
),
|
||||
# New URL for simple page-based meeting scheduling
|
||||
path(
|
||||
"jobs/<slug:slug>/candidates/<int:candidate_pk>/schedule-meeting-page/",
|
||||
views.schedule_meeting_for_candidate,
|
||||
name="schedule_meeting_for_candidate",
|
||||
),
|
||||
path(
|
||||
"jobs/<slug:slug>/candidates/<int:candidate_pk>/delete_meeting_for_candidate/<int:meeting_id>/",
|
||||
views.delete_meeting_for_candidate,
|
||||
name="delete_meeting_for_candidate",
|
||||
),
|
||||
|
||||
|
||||
path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
|
||||
|
||||
# 1. Onsite Reschedule URL
|
||||
path(
|
||||
'<slug:slug>/candidate/<int:candidate_id>/onsite/reschedule/<int:meeting_id>/',
|
||||
views.reschedule_onsite_meeting,
|
||||
name='reschedule_onsite_meeting'
|
||||
),
|
||||
|
||||
# 2. Onsite Delete URL
|
||||
|
||||
path(
|
||||
'job/<slug:slug>/candidates/<int:candidate_pk>/delete-onsite-meeting/<int:meeting_id>/',
|
||||
views.delete_onsite_meeting_for_candidate,
|
||||
name='delete_onsite_meeting_for_candidate'
|
||||
),
|
||||
|
||||
path(
|
||||
'job/<slug:slug>/candidate/<int:candidate_pk>/schedule/onsite/',
|
||||
views.schedule_onsite_meeting_for_candidate,
|
||||
name='schedule_onsite_meeting_for_candidate' # This is the name used in the button
|
||||
),
|
||||
|
||||
|
||||
# Detail View (assuming slug is on ScheduledInterview)
|
||||
# path("interviews/meetings/<slug:slug>/", views.MeetingDetailView.as_view(), name="meeting_details"),
|
||||
]
|
||||
|
||||
@ -75,6 +75,10 @@ from .forms import (
|
||||
PortalLoginForm,
|
||||
MessageForm,
|
||||
PersonForm,
|
||||
OnsiteMeetingForm,
|
||||
OnsiteReshuduleForm,
|
||||
OnsiteScheduleForm,
|
||||
InterviewEmailForm
|
||||
)
|
||||
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
|
||||
from rest_framework import viewsets
|
||||
@ -116,7 +120,7 @@ from .models import (
|
||||
JobPosting,
|
||||
ScheduledInterview,
|
||||
JobPostingImage,
|
||||
MeetingComment,
|
||||
|
||||
HiringAgency,
|
||||
AgencyJobAssignment,
|
||||
AgencyAccessLink,
|
||||
@ -127,6 +131,8 @@ from .models import (
|
||||
OnsiteLocationDetails,
|
||||
InterviewLocation
|
||||
)
|
||||
|
||||
|
||||
import logging
|
||||
from datastar_py.django import (
|
||||
DatastarResponse,
|
||||
@ -258,9 +264,7 @@ class ZoomMeetingListView(StaffRequiredMixin, ListView):
|
||||
queryset = queryset.prefetch_related(
|
||||
Prefetch(
|
||||
"interview", # related_name from ZoomMeeting to ScheduledInterview
|
||||
queryset=ScheduledInterview.objects.select_related(
|
||||
"application", "job"
|
||||
),
|
||||
queryset=ScheduledInterview.objects.select_related("application", "job"),
|
||||
to_attr="interview_details", # Changed to not start with underscore
|
||||
)
|
||||
)
|
||||
@ -298,6 +302,7 @@ class ZoomMeetingListView(StaffRequiredMixin, ListView):
|
||||
return context
|
||||
|
||||
|
||||
|
||||
# @login_required
|
||||
# def InterviewListView(request):
|
||||
# # interview_type=request.GET.get('interview_type','Remote')
|
||||
@ -468,7 +473,6 @@ def ZoomMeetingDeleteView(request, slug):
|
||||
messages.error(request, str(e))
|
||||
return redirect(reverse("list_meetings"))
|
||||
|
||||
|
||||
# Job Posting
|
||||
# def job_list(request):
|
||||
# """Display the list of job postings order by creation date descending"""
|
||||
@ -1504,6 +1508,7 @@ def _handle_get_request(request, slug, job):
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _handle_preview_submission(request, slug, job):
|
||||
"""
|
||||
Handles the initial POST request (Preview Schedule).
|
||||
@ -1516,7 +1521,6 @@ def _handle_preview_submission(request, slug, job):
|
||||
if form.is_valid():
|
||||
# Get the form data
|
||||
applications = form.cleaned_data["applications"]
|
||||
interview_type = form.cleaned_data["interview_type"]
|
||||
start_date = form.cleaned_data["start_date"]
|
||||
end_date = form.cleaned_data["end_date"]
|
||||
working_days = form.cleaned_data["working_days"]
|
||||
@ -1572,16 +1576,11 @@ def _handle_preview_submission(request, slug, job):
|
||||
for i, application in enumerate(applications):
|
||||
slot = available_slots[i]
|
||||
preview_schedule.append(
|
||||
{
|
||||
"applications": applications,
|
||||
"date": slot["date"],
|
||||
"time": slot["time"],
|
||||
}
|
||||
{"application": application, "date": slot["date"], "time": slot["time"]}
|
||||
)
|
||||
|
||||
# Save the form data to session for later use
|
||||
schedule_data = {
|
||||
"interview_type": interview_type,
|
||||
"start_date": start_date.isoformat(),
|
||||
"end_date": end_date.isoformat(),
|
||||
"working_days": working_days,
|
||||
@ -1604,7 +1603,6 @@ def _handle_preview_submission(request, slug, job):
|
||||
{
|
||||
"job": job,
|
||||
"schedule": preview_schedule,
|
||||
"interview_type": interview_type,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"working_days": working_days,
|
||||
@ -1842,13 +1840,12 @@ def _handle_confirm_schedule(request, slug, job):
|
||||
|
||||
# 3. Setup candidates and get slots
|
||||
candidates = Application.objects.filter(id__in=schedule_data["candidate_ids"])
|
||||
schedule.candidates.set(candidates)
|
||||
available_slots = get_available_time_slots(
|
||||
schedule
|
||||
) # This should still be synchronous and fast
|
||||
schedule.applications.set(candidates)
|
||||
available_slots = get_available_time_slots(schedule)
|
||||
|
||||
# 4. Queue scheduled interviews asynchronously (FAST RESPONSE)
|
||||
if schedule.interview_type == "Remote":
|
||||
# 4. Handle Remote/Onsite logic
|
||||
if schedule_data.get("schedule_interview_type") == 'Remote':
|
||||
# ... (Remote logic remains unchanged)
|
||||
queued_count = 0
|
||||
for i, candidate in enumerate(candidates):
|
||||
if i < len(available_slots):
|
||||
@ -1869,27 +1866,79 @@ def _handle_confirm_schedule(request, slug, job):
|
||||
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
|
||||
|
||||
return redirect("job_detail", slug=slug)
|
||||
else:
|
||||
for i, candidate in enumerate(candidates):
|
||||
if i < len(available_slots):
|
||||
slot = available_slots[i]
|
||||
ScheduledInterview.objects.create(
|
||||
candidate=candidate,
|
||||
job=job,
|
||||
# zoom_meeting=None,
|
||||
schedule=schedule,
|
||||
interview_date=slot["date"],
|
||||
interview_time=slot["time"],
|
||||
)
|
||||
|
||||
messages.success(request, f"Onsite schedule Interview Create succesfully")
|
||||
elif schedule_data.get("schedule_interview_type") == 'Onsite':
|
||||
print("inside...")
|
||||
|
||||
if request.method == 'POST':
|
||||
form = OnsiteMeetingForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
if not available_slots:
|
||||
messages.error(request, "No available slots found for the selected schedule range.")
|
||||
return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
|
||||
|
||||
# Extract common location data from the form
|
||||
physical_address = form.cleaned_data['physical_address']
|
||||
room_number = form.cleaned_data['room_number']
|
||||
|
||||
try:
|
||||
# 1. Iterate over candidates and create a NEW Location object for EACH
|
||||
for i, candidate in enumerate(candidates):
|
||||
if i < len(available_slots):
|
||||
slot = available_slots[i]
|
||||
|
||||
|
||||
location_start_dt = datetime.combine(slot['date'], schedule.start_time)
|
||||
|
||||
# --- CORE FIX: Create a NEW Location object inside the loop ---
|
||||
onsite_location = OnsiteLocationDetails.objects.create(
|
||||
start_time=location_start_dt,
|
||||
duration=schedule.interview_duration,
|
||||
physical_address=physical_address,
|
||||
room_number=room_number,
|
||||
location_type="Onsite"
|
||||
|
||||
)
|
||||
|
||||
# 2. Create the ScheduledInterview, linking the unique location
|
||||
ScheduledInterview.objects.create(
|
||||
application=candidate,
|
||||
job=job,
|
||||
schedule=schedule,
|
||||
interview_date=slot['date'],
|
||||
interview_time=slot['time'],
|
||||
interview_location=onsite_location,
|
||||
)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"Onsite schedule interviews created successfully for {len(candidates)} candidates."
|
||||
)
|
||||
|
||||
# Clear session data keys upon successful completion
|
||||
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
|
||||
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
|
||||
|
||||
return redirect('job_detail', slug=job.slug)
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"Error creating onsite location/interviews: {e}")
|
||||
# On failure, re-render the form with the error and ensure 'job' is present
|
||||
return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
|
||||
|
||||
else:
|
||||
# Form is invalid, re-render with errors
|
||||
# Ensure 'job' is passed to prevent NoReverseMatch
|
||||
return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
|
||||
|
||||
else:
|
||||
# For a GET request
|
||||
form = OnsiteMeetingForm()
|
||||
|
||||
return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
|
||||
|
||||
# Clear both session data keys upon successful completion
|
||||
if SESSION_DATA_KEY in request.session:
|
||||
del request.session[SESSION_DATA_KEY]
|
||||
if SESSION_ID_KEY in request.session:
|
||||
del request.session[SESSION_ID_KEY]
|
||||
return redirect("schedule_interview_location_form", slug=schedule.slug)
|
||||
|
||||
|
||||
def schedule_interviews_view(request, slug):
|
||||
@ -2135,41 +2184,18 @@ def candidate_update_status(request, slug):
|
||||
def candidate_interview_view(request, slug):
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ParticipantsSelectForm(request.POST, instance=job)
|
||||
|
||||
if form.is_valid():
|
||||
# Save the main instance (JobPosting)
|
||||
job_instance = form.save(commit=False)
|
||||
job_instance.save()
|
||||
|
||||
# MANUALLY set the M2M relationships based on submitted data
|
||||
job_instance.participants.set(form.cleaned_data["participants"])
|
||||
job_instance.users.set(form.cleaned_data["users"])
|
||||
|
||||
messages.success(request, "Interview participants updated successfully.")
|
||||
return redirect("candidate_interview_view", slug=job.slug)
|
||||
|
||||
else:
|
||||
initial_data = {
|
||||
"participants": job.participants.all(),
|
||||
"users": job.users.all(),
|
||||
}
|
||||
form = ParticipantsSelectForm(instance=job, initial=initial_data)
|
||||
|
||||
else:
|
||||
form = ParticipantsSelectForm(instance=job)
|
||||
|
||||
context = {
|
||||
"job": job,
|
||||
"candidates": job.interview_candidates,
|
||||
"current_stage": "Interview",
|
||||
"form": form,
|
||||
"participants_count": 0 #job.participants.count() + job.users.count(),
|
||||
|
||||
}
|
||||
return render(request, "recruitment/candidate_interview_view.html", context)
|
||||
|
||||
|
||||
|
||||
|
||||
@staff_user_required
|
||||
def candidate_document_review_view(request, slug):
|
||||
"""
|
||||
@ -5332,77 +5358,39 @@ def compose_candidate_email(request, job_slug):
|
||||
from .email_service import send_bulk_email
|
||||
|
||||
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||
candidate = get_object_or_404(Application, slug=candidate_slug, job=job)
|
||||
if request.method == "POST":
|
||||
form = CandidateEmailForm(job, candidate, request.POST)
|
||||
candidate_ids = request.GET.getlist("candidate_ids")
|
||||
candidates = Application.objects.filter(id__in=candidate_ids)
|
||||
|
||||
# # candidate = get_object_or_404(Application, slug=candidate_slug, job=job)
|
||||
# if request.method == "POST":
|
||||
# form = CandidateEmailForm(job, candidate, request.POST)
|
||||
candidate_ids=request.GET.getlist('candidate_ids')
|
||||
candidates=Application.objects.filter(id__in=candidate_ids)
|
||||
|
||||
if request.method == "POST":
|
||||
print(
|
||||
"........................................................inside candidate conpose............."
|
||||
)
|
||||
candidate_ids = request.POST.getlist("candidate_ids")
|
||||
candidates = Application.objects.filter(id__in=candidate_ids)
|
||||
|
||||
if request.method == 'POST':
|
||||
print("........................................................inside candidate conpose.............")
|
||||
candidate_ids = request.POST.getlist('candidate_ids')
|
||||
candidates=Application.objects.filter(id__in=candidate_ids)
|
||||
form = CandidateEmailForm(job, candidates, request.POST)
|
||||
if form.is_valid():
|
||||
print("form is valid ...")
|
||||
# Get email addresses
|
||||
email_addresses = form.get_email_addresses()
|
||||
if not email_addresses:
|
||||
messages.error(
|
||||
request, "No valid email addresses found for selected recipients."
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"includes/email_compose_form.html",
|
||||
{"form": form, "job": job, "candidate": candidate},
|
||||
)
|
||||
|
||||
# Check if this is an interview invitation
|
||||
subject = form.cleaned_data.get("subject", "").lower()
|
||||
is_interview_invitation = "interview" in subject or "meeting" in subject
|
||||
|
||||
if is_interview_invitation:
|
||||
# Use HTML template for interview invitations
|
||||
meeting_details = None
|
||||
if form.cleaned_data.get("include_meeting_details"):
|
||||
# Try to get meeting details from candidate
|
||||
meeting_details = {
|
||||
"topic": f"Interview for {job.title}",
|
||||
"date_time": getattr(
|
||||
candidate, "interview_date", "To be scheduled"
|
||||
),
|
||||
"duration": "60 minutes",
|
||||
"join_url": getattr(candidate, "meeting_url", ""),
|
||||
}
|
||||
|
||||
from .email_service import send_interview_invitation_email
|
||||
|
||||
email_result = send_interview_invitation_email(
|
||||
candidate=candidate,
|
||||
job=job,
|
||||
meeting_details=meeting_details,
|
||||
recipient_list=email_addresses,
|
||||
)
|
||||
else:
|
||||
# Get formatted message for regular emails
|
||||
message = form.get_formatted_message()
|
||||
subject = form.cleaned_data.get("subject")
|
||||
print(email_addresses)
|
||||
|
||||
|
||||
if not email_addresses:
|
||||
messages.error(request, "No email selected")
|
||||
referer = request.META.get("HTTP_REFERER")
|
||||
messages.error(request, 'No email selected')
|
||||
referer = request.META.get('HTTP_REFERER')
|
||||
|
||||
if referer:
|
||||
# Redirect back to the referring page
|
||||
return redirect(referer)
|
||||
else:
|
||||
return redirect("dashboard")
|
||||
|
||||
return redirect('dashboard')
|
||||
|
||||
|
||||
message = form.get_formatted_message()
|
||||
subject = form.cleaned_data.get("subject")
|
||||
subject = form.cleaned_data.get('subject')
|
||||
|
||||
# Send emails using email service (no attachments, synchronous to avoid pickle issues)
|
||||
|
||||
@ -5413,7 +5401,7 @@ def compose_candidate_email(request, job_slug):
|
||||
request=request,
|
||||
attachments=None,
|
||||
async_task_=True, # Changed to False to avoid pickle issues
|
||||
from_interview=False,
|
||||
from_interview=False
|
||||
)
|
||||
|
||||
if email_result["success"]:
|
||||
@ -5441,34 +5429,17 @@ def compose_candidate_email(request, job_slug):
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"includes/email_compose_form.html",
|
||||
{"form": form, "job": job, "candidate": candidate},
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"includes/email_compose_form.html",
|
||||
{"form": form, "job": job, "candidate": candidates},
|
||||
)
|
||||
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error sending candidate email: {e}")
|
||||
# messages.error(request, f'An error occurred while sending the email: {str(e)}')
|
||||
|
||||
# # For HTMX requests, return error response
|
||||
# if 'HX-Request' in request.headers:
|
||||
# return JsonResponse({
|
||||
# 'success': False,
|
||||
# 'error': f'An error occurred while sending the email: {str(e)}'
|
||||
# })
|
||||
|
||||
|
||||
|
||||
else:
|
||||
# Form validation errors
|
||||
print("form is not valid")
|
||||
print('form is not valid')
|
||||
print(form.errors)
|
||||
messages.error(request, "Please correct the errors below.")
|
||||
|
||||
@ -5484,9 +5455,8 @@ def compose_candidate_email(request, job_slug):
|
||||
return render(
|
||||
request,
|
||||
"includes/email_compose_form.html",
|
||||
{"form": form, "job": job, "candidates": candidate},
|
||||
s,
|
||||
)
|
||||
{"form": form, "job": job, "candidates": candidates},
|
||||
)
|
||||
|
||||
else:
|
||||
# GET request - show the form
|
||||
@ -5500,6 +5470,7 @@ def compose_candidate_email(request, job_slug):
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Source CRUD Views
|
||||
@staff_user_required
|
||||
def source_list(request):
|
||||
@ -5844,12 +5815,288 @@ def send_interview_email(request, slug):
|
||||
# return render(request,'interviews/schedule_interview_location_form.html',{'form':form,'schedule':schedule})
|
||||
|
||||
|
||||
def onsite_interview_list_view(request):
|
||||
onsite_interviews = ScheduledInterview.objects.filter(
|
||||
schedule__interview_type="Onsite"
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"interviews/onsite_interview_list.html",
|
||||
{"onsite_interviews": onsite_interviews},
|
||||
class MeetingListView(ListView):
|
||||
"""
|
||||
A unified view to list both Remote and Onsite Scheduled Interviews.
|
||||
"""
|
||||
model = ScheduledInterview
|
||||
template_name = "meetings/list_meetings.html"
|
||||
context_object_name = "meetings"
|
||||
paginate_by = 100
|
||||
|
||||
def get_queryset(self):
|
||||
# Start with a base queryset, ensuring an InterviewLocation link exists.
|
||||
queryset = super().get_queryset().filter(interview_location__isnull=False).select_related(
|
||||
'interview_location',
|
||||
'job',
|
||||
'application__person',
|
||||
'application',
|
||||
).prefetch_related(
|
||||
'interview_location__zoommeetingdetails',
|
||||
'interview_location__onsitelocationdetails',
|
||||
)
|
||||
# Note: Printing the queryset here can consume memory for large sets.
|
||||
|
||||
# Get filters from GET request
|
||||
search_query = self.request.GET.get("q")
|
||||
status_filter = self.request.GET.get("status")
|
||||
candidate_name_filter = self.request.GET.get("candidate_name")
|
||||
type_filter = self.request.GET.get("type")
|
||||
print(type_filter)
|
||||
|
||||
# 2. Type Filter: Filter based on the base InterviewLocation's type
|
||||
if type_filter:
|
||||
# Use .title() to handle case variations from URL (e.g., 'remote' -> 'Remote')
|
||||
normalized_type = type_filter.title()
|
||||
print(normalized_type)
|
||||
# Assuming InterviewLocation.LocationType is accessible/defined
|
||||
if normalized_type in ['Remote', 'Onsite']:
|
||||
queryset = queryset.filter(interview_location__location_type=normalized_type)
|
||||
print(queryset)
|
||||
|
||||
# 3. Search by Topic (stored on InterviewLocation)
|
||||
if search_query:
|
||||
queryset = queryset.filter(interview_location__topic__icontains=search_query)
|
||||
|
||||
# 4. Status Filter
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
# 5. Candidate Name Filter
|
||||
if candidate_name_filter:
|
||||
queryset = queryset.filter(
|
||||
Q(application__person__first_name__icontains=candidate_name_filter) |
|
||||
Q(application__person__last_name__icontains=candidate_name_filter)
|
||||
)
|
||||
|
||||
return queryset.order_by("-interview_date", "-interview_time")
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Pass filters back to the template for retention
|
||||
context["search_query"] = self.request.GET.get("q", "")
|
||||
context["status_filter"] = self.request.GET.get("status", "")
|
||||
context["candidate_name_filter"] = self.request.GET.get("candidate_name", "")
|
||||
context["type_filter"] = self.request.GET.get("type", "")
|
||||
|
||||
|
||||
# CORRECTED: Pass the status choices from the model class for the filter dropdown
|
||||
context["status_choices"] = self.model.InterviewStatus.choices
|
||||
|
||||
meetings_data = []
|
||||
|
||||
for interview in context.get(self.context_object_name, []):
|
||||
location = interview.interview_location
|
||||
details = None
|
||||
|
||||
if not location:
|
||||
continue
|
||||
|
||||
# Determine and fetch the CONCRETE details object (prefetched)
|
||||
if location.location_type == location.LocationType.REMOTE:
|
||||
details = getattr(location, 'zoommeetingdetails', None)
|
||||
elif location.location_type == location.LocationType.ONSITE:
|
||||
details = getattr(location, 'onsitelocationdetails', None)
|
||||
|
||||
# Combine date and time for template display/sorting
|
||||
start_datetime = None
|
||||
if interview.interview_date and interview.interview_time:
|
||||
start_datetime = datetime.combine(interview.interview_date, interview.interview_time)
|
||||
|
||||
# SUCCESS: Build the data dictionary
|
||||
meetings_data.append({
|
||||
'interview': interview,
|
||||
'location': location,
|
||||
'details': details,
|
||||
'type': location.location_type,
|
||||
'topic': location.topic,
|
||||
'slug': interview.slug,
|
||||
'start_time': start_datetime, # Combined datetime object
|
||||
# Duration should ideally be on ScheduledInterview or fetched from details
|
||||
'duration': getattr(details, 'duration', 'N/A'),
|
||||
# Use details.join_url and fallback to None, if Remote
|
||||
'join_url': getattr(details, 'join_url', None) if location.location_type == location.LocationType.REMOTE else None,
|
||||
'meeting_id': getattr(details, 'meeting_id', None),
|
||||
# Use the primary status from the ScheduledInterview record
|
||||
'status': interview.status,
|
||||
})
|
||||
|
||||
context["meetings_data"] = meetings_data
|
||||
|
||||
return context
|
||||
|
||||
def reschedule_onsite_meeting(request, slug, candidate_id, meeting_id):
|
||||
"""Handles the rescheduling of an Onsite Interview (updates OnsiteLocationDetails)."""
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
candidate = get_object_or_404(Application, pk=candidate_id)
|
||||
|
||||
# Fetch the OnsiteLocationDetails instance, ensuring it belongs to this candidate.
|
||||
# We use the reverse relationship: onsitelocationdetails -> interviewlocation -> scheduledinterview -> application
|
||||
# The 'interviewlocation_ptr' is the foreign key field name if OnsiteLocationDetails is a proxy/multi-table inheritance model.
|
||||
onsite_meeting = get_object_or_404(
|
||||
OnsiteLocationDetails,
|
||||
pk=meeting_id,
|
||||
# Correct filter: Use the reverse link through the ScheduledInterview model.
|
||||
# This assumes your ScheduledInterview model links back to a generic InterviewLocation base.
|
||||
interviewlocation_ptr__scheduled_interview__application=candidate
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = OnsiteReshuduleForm(request.POST, instance=onsite_meeting)
|
||||
|
||||
if form.is_valid():
|
||||
instance = form.save(commit=False)
|
||||
|
||||
if instance.start_time < timezone.now():
|
||||
messages.error(request, "Start time must be in the future for rescheduling.")
|
||||
return render(request, "meetings/reschedule_onsite.html", {"form": form, "job": job, "candidate": candidate, "meeting": onsite_meeting})
|
||||
|
||||
# Update parent status
|
||||
try:
|
||||
# Retrieve the ScheduledInterview instance via the reverse relationship
|
||||
scheduled_interview = ScheduledInterview.objects.get(
|
||||
interview_location=instance.interviewlocation_ptr # Use the base model FK
|
||||
)
|
||||
scheduled_interview.status = ScheduledInterview.InterviewStatus.SCHEDULED
|
||||
scheduled_interview.save()
|
||||
except ScheduledInterview.DoesNotExist:
|
||||
messages.warning(request, "Parent schedule record not found. Status not updated.")
|
||||
|
||||
instance.save()
|
||||
messages.success(request, "Onsite meeting successfully rescheduled! ✅")
|
||||
|
||||
return redirect(reverse("candidate_interview_view", kwargs={'slug': job.slug}))
|
||||
|
||||
else:
|
||||
form = OnsiteReshuduleForm(instance=onsite_meeting)
|
||||
|
||||
context = {
|
||||
"form": form,
|
||||
"job": job,
|
||||
"candidate": candidate,
|
||||
"meeting": onsite_meeting
|
||||
}
|
||||
return render(request, "meetings/reschedule_onsite_meeting.html", context)
|
||||
|
||||
|
||||
# recruitment/views.py
|
||||
|
||||
@staff_user_required
|
||||
def delete_onsite_meeting_for_candidate(request, slug, candidate_pk, meeting_id):
|
||||
"""
|
||||
Deletes a specific Onsite Location Details instance.
|
||||
This does not require an external API call.
|
||||
"""
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
candidate = get_object_or_404(Application, pk=candidate_pk)
|
||||
|
||||
# Target the specific Onsite meeting details instance
|
||||
meeting = get_object_or_404(OnsiteLocationDetails, pk=meeting_id)
|
||||
|
||||
if request.method == "POST":
|
||||
# Delete the local Django object.
|
||||
# This deletes the base InterviewLocation and updates the ScheduledInterview FK.
|
||||
meeting.delete()
|
||||
messages.success(request, f"Onsite meeting for {candidate.name} deleted successfully.")
|
||||
|
||||
return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug}))
|
||||
|
||||
context = {
|
||||
"job": job,
|
||||
"candidate": candidate,
|
||||
"meeting": meeting,
|
||||
"location_type": "Onsite",
|
||||
"delete_url": reverse(
|
||||
"delete_onsite_meeting_for_candidate", # Use the specific new URL name
|
||||
kwargs={
|
||||
"slug": job.slug,
|
||||
"candidate_pk": candidate_pk,
|
||||
"meeting_id": meeting_id,
|
||||
},
|
||||
),
|
||||
}
|
||||
return render(request, "meetings/delete_meeting_form.html", context)
|
||||
|
||||
|
||||
|
||||
def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk):
|
||||
"""
|
||||
Handles scheduling a NEW Onsite Interview for a candidate using OnsiteScheduleForm.
|
||||
"""
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
candidate = get_object_or_404(Application, pk=candidate_pk)
|
||||
|
||||
action_url = reverse('schedule_onsite_meeting_for_candidate',
|
||||
kwargs={'slug': job.slug, 'candidate_pk': candidate.pk})
|
||||
|
||||
if request.method == 'POST':
|
||||
# Use the new form
|
||||
form = OnsiteScheduleForm(request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
cleaned_data = form.cleaned_data
|
||||
|
||||
# 1. Create OnsiteLocationDetails
|
||||
onsite_loc = OnsiteLocationDetails(
|
||||
topic=cleaned_data['topic'],
|
||||
physical_address=cleaned_data['physical_address'],
|
||||
room_number=cleaned_data['room_number'],
|
||||
start_time=cleaned_data['start_time'],
|
||||
duration=cleaned_data['duration'],
|
||||
status=OnsiteLocationDetails.Status.WAITING,
|
||||
location_type=InterviewLocation.LocationType.ONSITE,
|
||||
)
|
||||
onsite_loc.save()
|
||||
|
||||
# 2. Extract Date and Time
|
||||
interview_date = cleaned_data['start_time'].date()
|
||||
interview_time = cleaned_data['start_time'].time()
|
||||
|
||||
# 3. Create ScheduledInterview linked to the new location
|
||||
# Use cleaned_data['application'] and cleaned_data['job'] from the form
|
||||
ScheduledInterview.objects.create(
|
||||
application=cleaned_data['application'],
|
||||
job=cleaned_data['job'],
|
||||
interview_location=onsite_loc,
|
||||
interview_date=interview_date,
|
||||
interview_time=interview_time,
|
||||
status=ScheduledInterview.InterviewStatus.SCHEDULED,
|
||||
)
|
||||
|
||||
messages.success(request, "Onsite interview scheduled successfully. ✅")
|
||||
return redirect(reverse("candidate_interview_view", kwargs={'slug': job.slug}))
|
||||
|
||||
else:
|
||||
# GET Request: Initialize the hidden fields with the correct objects
|
||||
initial_data = {
|
||||
'application': candidate, # Pass the object itself for ModelChoiceField
|
||||
'job': job, # Pass the object itself for ModelChoiceField
|
||||
}
|
||||
# Use the new form
|
||||
form = OnsiteScheduleForm(initial=initial_data)
|
||||
|
||||
context = {
|
||||
"form": form,
|
||||
"job": job,
|
||||
"candidate": candidate,
|
||||
"action_url": action_url,
|
||||
}
|
||||
|
||||
return render(request, "meetings/schedule_onsite_meeting_form.html", context)
|
||||
# def meeting_list_view(request):
|
||||
# queryset = ScheduledInterview.filter(interview_location__isnull=False).select_related(
|
||||
# 'interview_location',
|
||||
# 'job',
|
||||
# 'application__person',
|
||||
# 'application',
|
||||
# ).prefetch_related(
|
||||
# 'interview_location__zoommeetingdetails',
|
||||
# 'interview_location__onsitelocationdetails',
|
||||
# )
|
||||
# print(queryset)
|
||||
# return render(request,)
|
||||
# =========================================================================
|
||||
# 2. Simple Meeting Creation Views (Placeholders)
|
||||
# =========================================================================
|
||||
|
||||
@ -206,14 +206,11 @@
|
||||
{% csrf_token %}
|
||||
|
||||
{# Select Input Group - No label needed for this one, so we just flex the select and button #}
|
||||
|
||||
|
||||
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="width: 120px;">
|
||||
<option selected>
|
||||
----------
|
||||
</option>
|
||||
<option value="Document Review">
|
||||
{% trans "To Documents Review" %}
|
||||
</option>
|
||||
<option value="Offer">
|
||||
{% trans "To Offer" %}
|
||||
</option>
|
||||
@ -236,7 +233,7 @@
|
||||
</button>
|
||||
</form>
|
||||
<div class="vr" style="height: 28px;"></div>
|
||||
|
||||
|
||||
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
@ -251,7 +248,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<form id="candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="get">
|
||||
@ -285,6 +282,7 @@
|
||||
<div class="form-check">
|
||||
<input name="candidate_ids" value="{{ candidate.id }}" type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}">
|
||||
</div>
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@ -382,15 +380,10 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<<<<<<< HEAD
|
||||
|
||||
{% if candidate.get_latest_meeting %}
|
||||
{% if candidate.get_latest_meeting.location_type == 'Remote'%}
|
||||
|
||||
=======
|
||||
|
||||
{% if candidate.get_latest_meeting %}
|
||||
>>>>>>> 1babb1be63436083b4a5ec7d76c115350b0c9f4a
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateviewModal"
|
||||
@ -408,7 +401,6 @@
|
||||
title="Delete Meeting">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<<<<<<< HEAD
|
||||
{% else%}
|
||||
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@ -431,9 +423,6 @@
|
||||
|
||||
{% endif %}
|
||||
|
||||
=======
|
||||
|
||||
>>>>>>> 1babb1be63436083b4a5ec7d76c115350b0c9f4a
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-main-action btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
@ -508,7 +497,7 @@
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
|
||||
{% trans "Loading email form..." %}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user