scheduled interview

This commit is contained in:
Faheed 2025-11-17 13:49:50 +03:00
parent d0235bfefe
commit 64e04a011d
21 changed files with 529 additions and 494 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = [
]

View File

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

View File

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

View File

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

View File

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

View File

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