changes to interview

This commit is contained in:
Faheed 2025-11-17 09:33:47 +03:00
parent 0213bd6e11
commit 06436a3b9e
38 changed files with 2233 additions and 1012 deletions

6
.env
View File

@ -1,3 +1,3 @@
DB_NAME=norahuniversity DB_NAME=haikal_db
DB_USER=norahuniversity DB_USER=faheed
DB_PASSWORD=norahuniversity DB_PASSWORD=Faheed@215

View File

@ -144,6 +144,9 @@ DATABASES = {
} }
} }
# DATABASES = { # DATABASES = {
# 'default': { # 'default': {
# 'ENGINE': 'django.db.backends.sqlite3', # 'ENGINE': 'django.db.backends.sqlite3',

View File

@ -3,9 +3,9 @@ from django.utils.html import format_html
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from .models import ( from .models import (
JobPosting, Application, TrainingMaterial, ZoomMeeting, JobPosting, Application, TrainingMaterial, ZoomMeetingDetails,
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse, FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment, SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,InterviewNote,
AgencyAccessLink, AgencyJobAssignment AgencyAccessLink, AgencyJobAssignment
) )
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -158,7 +158,7 @@ class TrainingMaterialAdmin(admin.ModelAdmin):
save_on_top = True save_on_top = True
@admin.register(ZoomMeeting) @admin.register(ZoomMeetingDetails)
class ZoomMeetingAdmin(admin.ModelAdmin): class ZoomMeetingAdmin(admin.ModelAdmin):
list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at'] list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
list_filter = ['timezone', 'created_at'] list_filter = ['timezone', 'created_at']
@ -181,24 +181,24 @@ class ZoomMeetingAdmin(admin.ModelAdmin):
save_on_top = True save_on_top = True
@admin.register(MeetingComment) # @admin.register(InterviewNote)
class MeetingCommentAdmin(admin.ModelAdmin): # class MeetingCommentAdmin(admin.ModelAdmin):
list_display = ['meeting', 'author', 'created_at', 'updated_at'] # list_display = ['meeting', 'author', 'created_at', 'updated_at']
list_filter = ['created_at', 'author', 'meeting'] # list_filter = ['created_at', 'author', 'meeting']
search_fields = ['content', 'meeting__topic', 'author__username'] # search_fields = ['content', 'meeting__topic', 'author__username']
readonly_fields = ['created_at', 'updated_at', 'slug'] # readonly_fields = ['created_at', 'updated_at', 'slug']
fieldsets = ( # fieldsets = (
('Meeting Information', { # ('Meeting Information', {
'fields': ('meeting', 'author') # 'fields': ('meeting', 'author')
}), # }),
('Comment Content', { # ('Comment Content', {
'fields': ('content',) # 'fields': ('content',)
}), # }),
('Timestamps', { # ('Timestamps', {
'fields': ('created_at', 'updated_at', 'slug') # 'fields': ('created_at', 'updated_at', 'slug')
}), # }),
) # )
save_on_top = True # save_on_top = True
@admin.register(FormTemplate) @admin.register(FormTemplate)

View File

@ -149,6 +149,7 @@ def candidate_user_required(view_func):
def staff_user_required(view_func): def staff_user_required(view_func):
"""Decorator to restrict view to staff users only.""" """Decorator to restrict view to staff users only."""
return user_type_required(['staff'])(view_func) return user_type_required(['staff'])(view_func)

View File

@ -224,11 +224,8 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi
logger.error(error_msg, exc_info=True) logger.error(error_msg, exc_info=True)
return {'success': False, 'error': error_msg} return {'success': False, 'error': error_msg}
from .models import Candidate
from django.shortcuts import get_object_or_404
# Assuming other necessary imports like logger, settings, EmailMultiAlternatives, strip_tags are present
from .models import Candidate from .models import Application
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
import logging import logging
from django.conf import settings from django.conf import settings
@ -262,15 +259,16 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
email = email.strip().lower() email = email.strip().lower()
try: try:
candidate = get_object_or_404(Candidate, email=email) candidate = get_object_or_404(Application, person__email=email)
except Exception: except Exception:
logger.warning(f"Candidate not found for email: {email}") logger.warning(f"Candidate not found for email: {email}")
continue continue
candidate_name = candidate.first_name candidate_name = candidate.person.full_name
# --- Candidate belongs to an agency (Final Recipient: Agency) --- # --- Candidate belongs to an agency (Final Recipient: Agency) ---
if candidate.belong_to_an_agency and candidate.hiring_agency and candidate.hiring_agency.email: if candidate.hiring_agency and candidate.hiring_agency.email:
agency_email = candidate.hiring_agency.email agency_email = candidate.hiring_agency.email
agency_message = f"Hi, {candidate_name}" + "\n" + message agency_message = f"Hi, {candidate_name}" + "\n" + message
@ -395,7 +393,7 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
if not from_interview: if not from_interview:
# Send Emails - Pure Candidates # Send Emails - Pure Candidates
for email in pure_candidate_emails: for email in pure_candidate_emails:
candidate_name = Candidate.objects.filter(email=email).first().first_name candidate_name = Application.objects.filter(email=email).first().first_name
candidate_message = f"Hi, {candidate_name}" + "\n" + message candidate_message = f"Hi, {candidate_name}" + "\n" + message
send_individual_email(email, candidate_message) send_individual_email(email, candidate_message)
@ -403,7 +401,7 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
i = 0 i = 0
for email in agency_emails: for email in agency_emails:
candidate_email = candidate_through_agency_emails[i] candidate_email = candidate_through_agency_emails[i]
candidate_name = Candidate.objects.filter(email=candidate_email).first().first_name candidate_name = Application.objects.filter(email=candidate_email).first().first_name
agency_message = f"Hi, {candidate_name}" + "\n" + message agency_message = f"Hi, {candidate_name}" + "\n" + message
send_individual_email(email, agency_message) send_individual_email(email, agency_message)
i += 1 i += 1

View File

@ -10,7 +10,7 @@ from django.contrib.auth.forms import UserCreationForm
User = get_user_model() User = get_user_model()
import re import re
from .models import ( from .models import (
ZoomMeeting, ZoomMeetingDetails,
Application, Application,
TrainingMaterial, TrainingMaterial,
JobPosting, JobPosting,
@ -19,7 +19,7 @@ from .models import (
BreakTime, BreakTime,
JobPostingImage, JobPostingImage,
Profile, Profile,
MeetingComment, InterviewNote,
ScheduledInterview, ScheduledInterview,
Source, Source,
HiringAgency, HiringAgency,
@ -27,7 +27,7 @@ from .models import (
AgencyAccessLink, AgencyAccessLink,
Participants, Participants,
Message, Message,
Person,OnsiteMeeting Person,OnsiteLocationDetails
) )
# from django_summernote.widgets import SummernoteWidget # from django_summernote.widgets import SummernoteWidget
@ -336,7 +336,7 @@ class ApplicationForm(forms.ModelForm):
# person.first_name = self.cleaned_data['first_name'] # person.first_name = self.cleaned_data['first_name']
# person.last_name = self.cleaned_data['last_name'] # person.last_name = self.cleaned_data['last_name']
# person.email = self.cleaned_data['email'] # person.email = self.cleaned_data['email']
# person.phone = self.cleaned_data['phone'] # person.phZoomone = self.cleaned_data['phone']
# if commit: # if commit:
# person.save() # person.save()
@ -359,10 +359,39 @@ class ApplicationStageForm(forms.ModelForm):
"stage": forms.Select(attrs={"class": "form-select"}), "stage": forms.Select(attrs={"class": "form-select"}),
} }
class ZoomMeetingForm(forms.ModelForm): class ZoomMeetingForm(forms.ModelForm):
class Meta: class Meta:
model = ZoomMeeting model = ZoomMeetingDetails
fields = ['topic', 'start_time', 'duration']
labels = {
'topic': _('Topic'),
'start_time': _('Start Time'),
'duration': _('Duration'),
}
widgets = {
'topic': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter meeting topic'),}),
'start_time': forms.DateTimeInput(attrs={'class': 'form-control','type': 'datetime-local'}),
'duration': forms.NumberInput(attrs={'class': 'form-control','min': 1, 'placeholder': _('60')}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-3'
self.helper.field_class = 'col-md-9'
self.helper.layout = Layout(
Field('topic', css_class='form-control'),
Field('start_time', css_class='form-control'),
Field('duration', css_class='form-control'),
Submit('submit', _('Create Meeting'), css_class='btn btn-primary')
)
class MeetingForm(forms.ModelForm):
class Meta:
model = ZoomMeetingDetails
fields = ["topic", "start_time", "duration"] fields = ["topic", "start_time", "duration"]
labels = { labels = {
"topic": _("Topic"), "topic": _("Topic"),
@ -687,6 +716,7 @@ class InterviewScheduleForm(forms.ModelForm):
class Meta: class Meta:
model = InterviewSchedule model = InterviewSchedule
fields = [ fields = [
'schedule_interview_type',
"applications", "applications",
"start_date", "start_date",
"end_date", "end_date",
@ -697,10 +727,8 @@ class InterviewScheduleForm(forms.ModelForm):
"buffer_time", "buffer_time",
"break_start_time", "break_start_time",
"break_end_time", "break_end_time",
"interview_type"
] ]
widgets = { widgets = {
'interview_type': forms.Select(attrs={'class': 'form-control'}),
"start_date": forms.DateInput( "start_date": forms.DateInput(
attrs={"type": "date", "class": "form-control"} attrs={"type": "date", "class": "form-control"}
), ),
@ -721,6 +749,7 @@ class InterviewScheduleForm(forms.ModelForm):
"break_end_time": forms.TimeInput( "break_end_time": forms.TimeInput(
attrs={"type": "time", "class": "form-control"} attrs={"type": "time", "class": "form-control"}
), ),
"schedule_interview_type":forms.RadioSelect()
} }
def __init__(self, slug, *args, **kwargs): def __init__(self, slug, *args, **kwargs):
@ -734,11 +763,11 @@ class InterviewScheduleForm(forms.ModelForm):
return [int(day) for day in working_days] return [int(day) for day in working_days]
class MeetingCommentForm(forms.ModelForm): class InterviewNoteForm(forms.ModelForm):
"""Form for creating and editing meeting comments""" """Form for creating and editing meeting comments"""
class Meta: class Meta:
model = MeetingComment model = InterviewNote
fields = ["content"] fields = ["content"]
widgets = { widgets = {
"content": CKEditor5Widget( "content": CKEditor5Widget(
@ -1491,6 +1520,7 @@ class ParticipantsSelectForm(forms.ModelForm):
fields = ["participants", "users"] # No direct fields from Participants model fields = ["participants", "users"] # No direct fields from Participants model
class CandidateEmailForm(forms.Form): class CandidateEmailForm(forms.Form):
"""Form for composing emails to participants about a candidate""" """Form for composing emails to participants about a candidate"""
to = forms.MultipleChoiceField( to = forms.MultipleChoiceField(
@ -1500,51 +1530,32 @@ class CandidateEmailForm(forms.Form):
label=_('Select Candidates'), # Use a descriptive label label=_('Select Candidates'), # Use a descriptive label
required=False required=False
) )
subject = forms.CharField( subject = forms.CharField(
max_length=200, max_length=200,
widget=forms.TextInput( widget=forms.TextInput(attrs={
attrs={ 'class': 'form-control',
"class": "form-control", 'placeholder': 'Enter email subject',
"placeholder": "Enter email subject", 'required': True
"required": True, }),
} label=_('Subject'),
), required=True
label=_("Subject"),
required=True,
) )
message = forms.CharField( message = forms.CharField(
widget=forms.Textarea( widget=forms.Textarea(attrs={
attrs={ 'class': 'form-control',
"class": "form-control", 'rows': 8,
"rows": 8, 'placeholder': 'Enter your message here...',
"placeholder": "Enter your message here...", 'required': True
"required": True, }),
} label=_('Message'),
), required=True
label=_("Message"),
required=True,
) )
recipients = forms.MultipleChoiceField(
widget=forms.CheckboxSelectMultiple(attrs={"class": "form-check"}),
label=_("Recipients"),
required=True,
)
include_candidate_info = forms.BooleanField(
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
label=_("Include candidate information"),
initial=True,
required=False,
)
include_meeting_details = forms.BooleanField(
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
label=_("Include meeting details"),
initial=True,
required=False,
)
def __init__(self, job, candidates, *args, **kwargs): def __init__(self, job, candidates, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -1565,8 +1576,9 @@ class CandidateEmailForm(forms.Form):
# Set initial message with candidate and meeting info # Set initial message with candidate and meeting info
initial_message = self._get_initial_message() initial_message = self._get_initial_message()
if initial_message: if initial_message:
self.fields["message"].initial = initial_message self.fields['message'].initial = initial_message
def _get_initial_message(self): def _get_initial_message(self):
"""Generate initial message with candidate and meeting information""" """Generate initial message with candidate and meeting information"""
@ -1623,6 +1635,16 @@ class CandidateEmailForm(forms.Form):
f"Best regards, The KAAUH Hiring team" f"Best regards, The KAAUH Hiring team"
] ]
# # Add candidate information
# if self.candidate:
# message_parts.append(f"Candidate Information:")
# message_parts.append(f"Name: {self.candidate.name}")
# message_parts.append(f"Email: {self.candidate.email}")
# message_parts.append(f"Phone: {self.candidate.phone}")
# # Add latest meeting information if available # # Add latest meeting information if available
# latest_meeting = self.candidate.get_latest_meeting # latest_meeting = self.candidate.get_latest_meeting
# if latest_meeting: # if latest_meeting:
@ -1633,43 +1655,33 @@ class CandidateEmailForm(forms.Form):
# if latest_meeting.join_url: # if latest_meeting.join_url:
# message_parts.append(f"Join URL: {latest_meeting.join_url}") # message_parts.append(f"Join URL: {latest_meeting.join_url}")
return "\n".join(message_parts) return '\n'.join(message_parts)
def clean_recipients(self):
"""Ensure at least one recipient is selected"""
recipients = self.cleaned_data.get('recipients')
if not recipients:
raise forms.ValidationError(_('Please select at least one recipient.'))
return recipients
def get_email_addresses(self): def get_email_addresses(self):
"""Extract email addresses from selected recipients""" """Extract email addresses from selected recipients"""
email_addresses = [] email_addresses = []
recipients = self.cleaned_data.get('recipients', [])
for recipient in recipients: candidates=self.cleaned_data.get('to',[])
if recipient.startswith('participant_'):
participant_id = recipient.split('_')[1] if candidates:
try: for candidate in candidates:
participant = Participants.objects.get(id=participant_id) if candidate.startswith('candidate_'):
email_addresses.append(participant.email) print("candidadte: {candidate}")
except Participants.DoesNotExist: candidate_id = candidate.split('_')[1]
continue try:
elif recipient.startswith('user_'): candidate = Application.objects.get(id=candidate_id)
user_id = recipient.split('_')[1] email_addresses.append(candidate.email)
try: except Application.DoesNotExist:
user = User.objects.get(id=user_id) continue
email_addresses.append(user.email)
except User.DoesNotExist:
continue
return list(set(email_addresses)) # Remove duplicates return list(set(email_addresses)) # Remove duplicates
def get_formatted_message(self): def get_formatted_message(self):
"""Get formatted message with optional additional information""" """Get the formatted message with optional additional information"""
message = self.cleaned_data.get("message", "") message = self.cleaned_data.get('message', '')
return message return message
@ -1692,70 +1704,183 @@ class InterviewParticpantsForm(forms.ModelForm):
# class InterviewEmailForm(forms.Form):
# subject = forms.CharField(
# max_length=200,
# widget=forms.TextInput(attrs={
# 'class': 'form-control',
# 'placeholder': 'Enter email subject',
# 'required': True
# }),
# label=_('Subject'),
# required=True
# )
# message_for_candidate= forms.CharField(
# widget=forms.Textarea(attrs={
# 'class': 'form-control',
# 'rows': 8,
# 'placeholder': 'Enter your message here...',
# 'required': True
# }),
# label=_('Message'),
# required=False
# )
# message_for_agency= forms.CharField(
# widget=forms.Textarea(attrs={
# 'class': 'form-control',
# 'rows': 8,
# 'placeholder': 'Enter your message here...',
# 'required': True
# }),
# label=_('Message'),
# required=False
# )
# message_for_participants= forms.CharField(
# widget=forms.Textarea(attrs={
# 'class': 'form-control',
# 'rows': 8,
# 'placeholder': 'Enter your message here...',
# 'required': True
# }),
# label=_('Message'),
# required=False
# )
# def __init__(self, *args,candidate, external_participants, system_participants,meeting,job,**kwargs):
# super().__init__(*args, **kwargs)
# # --- Data Preparation ---
# # Note: Added error handling for agency name if it's missing (though it shouldn't be based on your check)
# formatted_date = meeting.start_time.strftime('%Y-%m-%d')
# formatted_time = meeting.start_time.strftime('%I:%M %p')
# zoom_link = meeting.join_url
# duration = meeting.duration
# job_title = job.title
# agency_name = candidate.hiring_agency.name if candidate.belong_to_an_agency and candidate.hiring_agency else "Hiring Agency"
# # --- Combined Participants List for Internal Email ---
# external_participants_names = ", ".join([p.name for p in external_participants ])
# system_participants_names = ", ".join([p.first_name for p in system_participants ])
# # Combine and ensure no leading/trailing commas if one list is empty
# participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names]))
# # --- 1. Candidate Message (More concise and structured) ---
# candidate_message = f"""
# Dear {candidate.full_name},
# Thank you for your interest in the **{job_title}** position at KAAUH. We're pleased to invite you to an interview!
# The details of your virtual interview are as follows:
# - **Date:** {formatted_date}
# - **Time:** {formatted_time} (RIYADH TIME)
# - **Duration:** {duration}
# - **Meeting Link:** {zoom_link}
# Please click the link at the scheduled time to join the interview.
# Kindly reply to this email to **confirm your attendance** or to propose an alternative time if necessary.
# We look forward to meeting you.
# Best regards,
# KAAUH Hiring Team
# """
# # --- 2. Agency Message (Professional and clear details) ---
# agency_message = f"""
# Dear {agency_name},
# We have scheduled an interview for your candidate, **{candidate.full_name}**, for the **{job_title}** role.
# Please forward the following details to the candidate and ensure they are fully prepared.
# **Interview Details:**
# - **Candidate:** {candidate.full_name}
# - **Job Title:** {job_title}
# - **Date:** {formatted_date}
# - **Time:** {formatted_time} (RIYADH TIME)
# - **Duration:** {duration}
# - **Meeting Link:** {zoom_link}
# Please let us know if you or the candidate have any questions.
# Best regards,
# KAAUH Hiring Team
# """
# # --- 3. Participants Message (Action-oriented and informative) ---
# participants_message = f"""
# Hi Team,
# This is a reminder of the upcoming interview you are scheduled to participate in for the **{job_title}** position.
# **Interview Summary:**
# - **Candidate:** {candidate.full_name}
# - **Date:** {formatted_date}
# - **Time:** {formatted_time} (RIYADH TIME)
# - **Duration:** {duration}
# - **Your Fellow Interviewers:** {participant_names}
# **Action Items:**
# 1. Please review **{candidate.full_name}'s** resume and notes.
# 2. The official calendar invite contains the meeting link ({zoom_link}) and should be used to join.
# 3. Be ready to start promptly at the scheduled time.
# Thank you for your participation.
# Best regards,
# KAAUH HIRING TEAM
# """
# # Set initial data
# self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}"
# # .strip() removes the leading/trailing blank lines caused by the f""" format
# self.initial['message_for_candidate'] = candidate_message.strip()
# self.initial['message_for_agency'] = agency_message.strip()
# self.initial['message_for_participants'] = participants_message.strip()
class InterviewEmailForm(forms.Form): class InterviewEmailForm(forms.Form):
subject = forms.CharField( # ... (Field definitions)
max_length=200,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter email subject',
'required': True
}),
label=_('Subject'),
required=True
)
message_for_candidate= forms.CharField( def __init__(self, *args, candidate, external_participants, system_participants, meeting, job, **kwargs):
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 8,
'placeholder': 'Enter your message here...',
'required': True
}),
label=_('Message'),
required=False
)
message_for_agency= forms.CharField(
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 8,
'placeholder': 'Enter your message here...',
'required': True
}),
label=_('Message'),
required=False
)
message_for_participants= forms.CharField(
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 8,
'placeholder': 'Enter your message here...',
'required': True
}),
label=_('Message'),
required=False
)
def __init__(self, *args,candidate, external_participants, system_participants,meeting,job,**kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
location = meeting.interview_location
# --- Data Preparation --- # --- Data Preparation ---
# Note: Added error handling for agency name if it's missing (though it shouldn't be based on your check)
formatted_date = meeting.start_time.strftime('%Y-%m-%d') # Safely access details through the related InterviewLocation object
formatted_time = meeting.start_time.strftime('%I:%M %p') if location and location.start_time:
zoom_link = meeting.join_url formatted_date = location.start_time.strftime('%Y-%m-%d')
duration = meeting.duration formatted_time = location.start_time.strftime('%I:%M %p')
duration = location.duration
meeting_link = location.details_url if location.details_url else "N/A (See Location Topic)"
else:
# Handle case where location or time is missing/None
formatted_date = "TBD - Awaiting Scheduling"
formatted_time = "TBD"
duration = "N/A"
meeting_link = "Not Available"
job_title = job.title job_title = job.title
agency_name = candidate.hiring_agency.name if candidate.belong_to_an_agency and candidate.hiring_agency else "Hiring Agency" agency_name = candidate.hiring_agency.name if candidate.belong_to_an_agency and candidate.hiring_agency else "Hiring Agency"
# --- Combined Participants List for Internal Email --- # --- Combined Participants List for Internal Email ---
external_participants_names = ", ".join([p.name for p in external_participants ]) external_participants_names = ", ".join([p.name for p in external_participants ])
system_participants_names = ", ".join([p.first_name for p in system_participants ]) system_participants_names = ", ".join([p.first_name for p in system_participants ])
# Combine and ensure no leading/trailing commas if one list is empty
participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names])) participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names]))
# --- 1. Candidate Message (More concise and structured) --- # --- 1. Candidate Message (Use meeting_link) ---
candidate_message = f""" candidate_message = f"""
Dear {candidate.full_name}, Dear {candidate.full_name},
@ -1766,7 +1891,7 @@ The details of your virtual interview are as follows:
- **Date:** {formatted_date} - **Date:** {formatted_date}
- **Time:** {formatted_time} (RIYADH TIME) - **Time:** {formatted_time} (RIYADH TIME)
- **Duration:** {duration} - **Duration:** {duration}
- **Meeting Link:** {zoom_link} - **Meeting Link:** {meeting_link}
Please click the link at the scheduled time to join the interview. Please click the link at the scheduled time to join the interview.
@ -1777,37 +1902,25 @@ We look forward to meeting you.
Best regards, Best regards,
KAAUH Hiring Team KAAUH Hiring Team
""" """
# ... (Messages for agency and participants remain the same, using the updated safe variables)
# --- 2. Agency Message (Professional and clear details) --- # --- 2. Agency Message (Professional and clear details) ---
agency_message = f""" agency_message = f"""
Dear {agency_name}, Dear {agency_name},
...
We have scheduled an interview for your candidate, **{candidate.full_name}**, for the **{job_title}** role.
Please forward the following details to the candidate and ensure they are fully prepared.
**Interview Details:** **Interview Details:**
...
- **Candidate:** {candidate.full_name}
- **Job Title:** {job_title}
- **Date:** {formatted_date} - **Date:** {formatted_date}
- **Time:** {formatted_time} (RIYADH TIME) - **Time:** {formatted_time} (RIYADH TIME)
- **Duration:** {duration} - **Duration:** {duration}
- **Meeting Link:** {zoom_link} - **Meeting Link:** {meeting_link}
...
Please let us know if you or the candidate have any questions.
Best regards,
KAAUH Hiring Team
""" """
# --- 3. Participants Message (Action-oriented and informative) --- # --- 3. Participants Message (Action-oriented and informative) ---
participants_message = f""" participants_message = f"""
Hi Team, Hi Team,
...
This is a reminder of the upcoming interview you are scheduled to participate in for the **{job_title}** position.
**Interview Summary:** **Interview Summary:**
- **Candidate:** {candidate.full_name} - **Candidate:** {candidate.full_name}
@ -1819,25 +1932,16 @@ This is a reminder of the upcoming interview you are scheduled to participate in
**Action Items:** **Action Items:**
1. Please review **{candidate.full_name}'s** resume and notes. 1. Please review **{candidate.full_name}'s** resume and notes.
2. The official calendar invite contains the meeting link ({zoom_link}) and should be used to join. 2. The official calendar invite contains the meeting link ({meeting_link}) and should be used to join.
3. Be ready to start promptly at the scheduled time. 3. Be ready to start promptly at the scheduled time.
...
Thank you for your participation.
Best regards,
KAAUH HIRING TEAM
""" """
# Set initial data # Set initial data
self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}" self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}"
# .strip() removes the leading/trailing blank lines caused by the f""" format
self.initial['message_for_candidate'] = candidate_message.strip() self.initial['message_for_candidate'] = candidate_message.strip()
self.initial['message_for_agency'] = agency_message.strip() self.initial['message_for_agency'] = agency_message.strip()
self.initial['message_for_participants'] = participants_message.strip() self.initial['message_for_participants'] = participants_message.strip()
# class OnsiteLocationForm(forms.ModelForm): # class OnsiteLocationForm(forms.ModelForm):
# class Meta: # class Meta:
# model= # model=
@ -1846,26 +1950,91 @@ KAAUH HIRING TEAM
# 'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}), # 'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}),
# } # }
#during bulk schedule
class OnsiteMeetingForm(forms.ModelForm): class OnsiteMeetingForm(forms.ModelForm):
class Meta: class Meta:
model = OnsiteMeeting model = OnsiteLocationDetails
fields = ['topic', 'start_time', 'duration', 'timezone', 'location', 'status'] # Include 'room_number' and update the field list
fields = ['topic', 'physical_address', 'room_number']
widgets = { widgets = {
'topic': forms.TextInput(attrs={'placeholder': 'Enter the Meeting Topic', 'class': 'form-control'}), 'topic': forms.TextInput(
'start_time': forms.DateTimeInput( attrs={'placeholder': 'Enter the Meeting Topic', 'class': 'form-control'}
attrs={'type': 'datetime-local', 'class': 'form-control'}
), ),
'duration': forms.NumberInput(
attrs={'min': 15, 'placeholder': 'Duration in minutes', 'class': 'form-control'} 'physical_address': forms.TextInput(
attrs={'placeholder': 'Physical address (e.g., street address)', 'class': 'form-control'}
), ),
'location': forms.TextInput(attrs={'placeholder': 'Physical location', 'class': 'form-control'}),
'timezone': forms.TextInput(attrs={'class': 'form-control'}), 'room_number': forms.TextInput(
'status': forms.Select(attrs={'class': 'form-control'}), attrs={'placeholder': 'Room Number/Name (Optional)', 'class': 'form-control'}
),
} }
class OnsiteReshuduleForm(forms.ModelForm):
class Meta:
model = OnsiteLocationDetails
fields = ['topic', 'physical_address', 'room_number','start_time','duration','status']
widgets = {
'topic': forms.TextInput(
attrs={'placeholder': 'Enter the Meeting Topic', 'class': 'form-control'}
),
'physical_address': forms.TextInput(
attrs={'placeholder': 'Physical address (e.g., street address)', 'class': 'form-control'}
),
'room_number': forms.TextInput(
attrs={'placeholder': 'Room Number/Name (Optional)', 'class': 'form-control'}
),
}
class OnsiteScheduleForm(forms.ModelForm):
# Add fields for the foreign keys required by ScheduledInterview
application = forms.ModelChoiceField(
queryset=Application.objects.all(),
widget=forms.HiddenInput(), # Hide this in the form, set by the view
label=_("Candidate Application")
)
job = forms.ModelChoiceField(
queryset=JobPosting.objects.all(),
widget=forms.HiddenInput(), # Hide this in the form, set by the view
label=_("Job Posting")
)
class Meta:
model = OnsiteLocationDetails
# Include all fields from OnsiteLocationDetails plus the new ones
fields = ['topic', 'physical_address', 'room_number', 'start_time', 'duration', 'status', 'application', 'job']
widgets = {
'topic': forms.TextInput(
attrs={'placeholder': _('Enter the Meeting Topic'), 'class': 'form-control'}
),
'physical_address': forms.TextInput(
attrs={'placeholder': _('Physical address (e.g., street address)'), 'class': 'form-control'}
),
'room_number': forms.TextInput(
attrs={'placeholder': _('Room Number/Name (Optional)'), 'class': 'form-control'}
),
# You should explicitly set widgets for start_time, duration, and status here
# if they need Bootstrap classes, otherwise they will use default HTML inputs.
# Example:
'start_time': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
'duration': forms.NumberInput(attrs={'class': 'form-control', 'min': 15}),
'status': forms.HiddenInput(), # Status should default to SCHEDULED, so hide it.
}
class MessageForm(forms.ModelForm): class MessageForm(forms.ModelForm):
"""Form for creating and editing messages between users""" """Form for creating and editing messages between users"""

View File

@ -1,11 +1,11 @@
from .models import Candidate from .models import Application
from time import sleep from time import sleep
def callback_ai_parsing(task): def callback_ai_parsing(task):
if task.success: if task.success:
try: try:
pk = task.args[0] pk = task.args[0]
c = Candidate.objects.get(pk=pk) c = Application.objects.get(pk=pk)
if c.retry and not c.is_resume_parsed: if c.retry and not c.is_resume_parsed:
sleep(30) sleep(30)
c.retry -= 1 c.retry -= 1

View File

@ -1,4 +1,4 @@
# Generated by Django 5.2.6 on 2025-11-13 13:12 # Generated by Django 5.2.7 on 2025-11-14 21:43
import django.contrib.auth.models import django.contrib.auth.models
import django.contrib.auth.validators import django.contrib.auth.validators
@ -49,21 +49,20 @@ class Migration(migrations.Migration):
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='OnsiteMeeting', name='InterviewLocation',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('topic', models.CharField(max_length=255, verbose_name='Topic')), ('location_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], db_index=True, max_length=10, verbose_name='Location Type')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), ('details_url', models.URLField(blank=True, max_length=2048, null=True, verbose_name='Meeting/Location URL')),
('duration', models.PositiveIntegerField(verbose_name='Duration')), ('topic', models.CharField(blank=True, help_text="e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'", max_length=255, verbose_name='Location/Meeting Topic')),
('timezone', models.CharField(max_length=50, verbose_name='Timezone')), ('timezone', models.CharField(default='UTC', max_length=50, verbose_name='Timezone')),
('location', models.CharField(blank=True, null=True)),
('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')),
], ],
options={ options={
'abstract': False, 'verbose_name': 'Interview Location',
'verbose_name_plural': 'Interview Locations',
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
@ -112,31 +111,6 @@ class Migration(migrations.Migration):
'ordering': ['name'], 'ordering': ['name'],
}, },
), ),
migrations.CreateModel(
name='ZoomMeeting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('topic', models.CharField(max_length=255, verbose_name='Topic')),
('meeting_id', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration')),
('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
('join_url', models.URLField(verbose_name='Join URL')),
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')),
],
options={
'abstract': False,
},
),
migrations.CreateModel( migrations.CreateModel(
name='CustomUser', name='CustomUser',
fields=[ fields=[
@ -287,6 +261,43 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Applications', 'verbose_name_plural': 'Applications',
}, },
), ),
migrations.CreateModel(
name='OnsiteLocationDetails',
fields=[
('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')),
('physical_address', models.CharField(blank=True, max_length=255, null=True, verbose_name='Physical Address')),
('room_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='Room Number/Name')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
],
options={
'verbose_name': 'Onsite Location Details',
'verbose_name_plural': 'Onsite Location Details',
},
bases=('recruitment.interviewlocation',),
),
migrations.CreateModel(
name='ZoomMeetingDetails',
fields=[
('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')),
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
('meeting_id', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='External Meeting ID')),
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
('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')),
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
],
options={
'verbose_name': 'Zoom Meeting Details',
'verbose_name_plural': 'Zoom Meeting Details',
},
bases=('recruitment.interviewlocation',),
),
migrations.CreateModel( migrations.CreateModel(
name='JobPosting', name='JobPosting',
fields=[ fields=[
@ -343,7 +354,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('interview_type', models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type')), ('schedule_interview_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], default='Remote', max_length=10, verbose_name='Interview Type')),
('start_date', models.DateField(db_index=True, verbose_name='Start Date')), ('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
('end_date', models.DateField(db_index=True, verbose_name='End Date')), ('end_date', models.DateField(db_index=True, verbose_name='End Date')),
('working_days', models.JSONField(verbose_name='Working Days')), ('working_days', models.JSONField(verbose_name='Working Days')),
@ -353,10 +364,14 @@ class Migration(migrations.Migration):
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')), ('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')), ('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')), ('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
('applications', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.application')), ('applications', models.ManyToManyField(blank=True, related_name='interview_schedules', to='recruitment.application')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('template_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schedule_templates', to='recruitment.interviewlocation', verbose_name='Location Template (Zoom/Onsite)')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')), ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
], ],
options={
'abstract': False,
},
), ),
migrations.AddField( migrations.AddField(
model_name='formtemplate', model_name='formtemplate',
@ -423,6 +438,27 @@ class Migration(migrations.Migration):
'ordering': ['-created_at'], 'ordering': ['-created_at'],
}, },
), ),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField(verbose_name='Notification Message')),
('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')),
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')),
('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')),
('created_at', models.DateTimeField(auto_now_add=True)),
('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={
'verbose_name': 'Notification',
'verbose_name_plural': 'Notifications',
'ordering': ['-scheduled_for', '-created_at'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='Person', name='Person',
fields=[ fields=[
@ -464,6 +500,42 @@ class Migration(migrations.Migration):
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
], ],
), ),
migrations.CreateModel(
name='ScheduledInterview',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
('interview_time', models.TimeField(verbose_name='Interview Time')),
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')),
('interview_location', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='scheduled_interview', to='recruitment.interviewlocation', verbose_name='Meeting/Location Details')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
('participants', models.ManyToManyField(blank=True, to='recruitment.participants')),
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interviews', to='recruitment.interviewschedule')),
('system_users', models.ManyToManyField(blank=True, related_name='attended_interviews', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='InterviewNote',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')),
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content/Feedback')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_notes', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('interview', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.scheduledinterview', verbose_name='Scheduled Interview')),
],
options={
'verbose_name': 'Interview Note',
'verbose_name_plural': 'Interview Notes',
'ordering': ['created_at'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='SharedFormTemplate', name='SharedFormTemplate',
fields=[ fields=[
@ -523,63 +595,6 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Training Materials', 'verbose_name_plural': 'Training Materials',
}, },
), ),
migrations.CreateModel(
name='ScheduledInterview',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
('interview_time', models.TimeField(verbose_name='Interview Time')),
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
('onsite_meeting', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='onsite_interview', to='recruitment.onsitemeeting')),
('participants', models.ManyToManyField(blank=True, to='recruitment.participants')),
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')),
('system_users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)),
('zoom_meeting', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
],
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField(verbose_name='Notification Message')),
('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')),
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')),
('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')),
('created_at', models.DateTimeField(auto_now_add=True)),
('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')),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
('related_meeting', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.zoommeeting', verbose_name='Related Meeting')),
],
options={
'verbose_name': 'Notification',
'verbose_name_plural': 'Notifications',
'ordering': ['-scheduled_for', '-created_at'],
},
),
migrations.CreateModel(
name='MeetingComment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meeting_comments', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recruitment.zoommeeting', verbose_name='Meeting')),
],
options={
'verbose_name': 'Meeting Comment',
'verbose_name_plural': 'Meeting Comments',
'ordering': ['-created_at'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='AgencyAccessLink', name='AgencyAccessLink',
fields=[ fields=[
@ -645,18 +660,6 @@ class Migration(migrations.Migration):
model_name='formsubmission', model_name='formsubmission',
index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'), index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'),
), ),
migrations.AddIndex(
model_name='interviewschedule',
index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'),
),
migrations.AddIndex(
model_name='interviewschedule',
index=models.Index(fields=['end_date'], name='recruitment_end_dat_aeb00e_idx'),
),
migrations.AddIndex(
model_name='interviewschedule',
index=models.Index(fields=['created_by'], name='recruitment_created_d0bdcc_idx'),
),
migrations.AddIndex( migrations.AddIndex(
model_name='formtemplate', model_name='formtemplate',
index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'), index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'),
@ -701,6 +704,14 @@ class Migration(migrations.Migration):
model_name='message', model_name='message',
index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'), 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( migrations.AddIndex(
model_name='person', model_name='person',
index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'), index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'),
@ -733,14 +744,6 @@ class Migration(migrations.Migration):
name='application', name='application',
unique_together={('person', 'job')}, unique_together={('person', 'job')},
), ),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
),
migrations.AddIndex( migrations.AddIndex(
model_name='scheduledinterview', model_name='scheduledinterview',
index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'), index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'),
@ -754,11 +757,11 @@ class Migration(migrations.Migration):
index=models.Index(fields=['application', 'job'], name='recruitment_applica_927561_idx'), index=models.Index(fields=['application', 'job'], name='recruitment_applica_927561_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='notification', model_name='jobposting',
index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'), index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='notification', model_name='jobposting',
index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'), index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
), ),
] ]

View File

@ -0,0 +1,18 @@
# 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

@ -547,6 +547,12 @@ class Person(Base):
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
content_type = ContentType.objects.get_for_model(self.__class__) content_type = ContentType.objects.get_for_model(self.__class__)
return Document.objects.filter(content_type=content_type, object_id=self.id) return Document.objects.filter(content_type=content_type, object_id=self.id)
@property
def belong_to_an_agency(self):
if self.agency:
return True
else:
return False
class Application(Base): class Application(Base):
@ -890,13 +896,57 @@ class Application(Base):
"""Legacy compatibility - get scheduled interviews for this application""" """Legacy compatibility - get scheduled interviews for this application"""
return self.scheduled_interviews.all() return self.scheduled_interviews.all()
# @property
# def get_latest_meeting(self):
# """Legacy compatibility - get latest meeting for this application"""
# #get parent interview location modal:
# schedule=self.scheduled_interviews.order_by("-created_at").first()
# if schedule:
# print(schedule)
# interview_location=schedule.interview_location
# else:
# return None
# if interview_location and interview_location.location_type=='Remote':
# meeting = interview_location.zoommeetingdetails
# return meeting
# else:
# meeting = interview_location.onsitelocationdetails
# return meeting
@property @property
def get_latest_meeting(self): 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() schedule = self.scheduled_interviews.order_by("-created_at").first()
if schedule:
return schedule.zoom_meeting # Check if a schedule exists and if it has an interview location
return None 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 @property
def has_future_meeting(self): def has_future_meeting(self):
@ -966,129 +1016,6 @@ class TrainingMaterial(Base):
def __str__(self): def __str__(self):
return self.title return self.title
class OnsiteMeeting(Base):
class MeetingStatus(models.TextChoices):
WAITING = "waiting", _("Waiting")
STARTED = "started", _("Started")
ENDED = "ended", _("Ended")
CANCELLED = "cancelled",_("Cancelled")
# Basic meeting details
topic = models.CharField(max_length=255, verbose_name=_("Topic"))
start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time")) # Added index
duration = models.PositiveIntegerField(
verbose_name=_("Duration")
) # Duration in minutes
timezone = models.CharField(max_length=50, verbose_name=_("Timezone"))
location=models.CharField(null=True,blank=True)
status = models.CharField(
db_index=True, max_length=20, # Added index
null=True,
blank=True,
verbose_name=_("Status"),
default=MeetingStatus.WAITING,
)
class ZoomMeeting(Base):
class MeetingStatus(models.TextChoices):
WAITING = "waiting", _("Waiting")
STARTED = "started", _("Started")
ENDED = "ended", _("Ended")
CANCELLED = "cancelled", _("Cancelled")
# Basic meeting details
topic = models.CharField(max_length=255, verbose_name=_("Topic"))
meeting_id = models.CharField(
db_index=True,
max_length=20,
unique=True,
verbose_name=_("Meeting ID"), # Added index
) # Unique identifier for the meeting
start_time = models.DateTimeField(
db_index=True, verbose_name=_("Start Time")
) # Added index
duration = models.PositiveIntegerField(
verbose_name=_("Duration")
) # Duration in minutes
timezone = models.CharField(max_length=50, verbose_name=_("Timezone"))
join_url = models.URLField(
verbose_name=_("Join URL")
) # URL for participants to join
participant_video = models.BooleanField(
default=True, verbose_name=_("Participant Video")
)
password = models.CharField(
max_length=20, blank=True, null=True, verbose_name=_("Password")
)
join_before_host = models.BooleanField(
default=False, verbose_name=_("Join Before Host")
)
mute_upon_entry = models.BooleanField(
default=False, verbose_name=_("Mute Upon Entry")
)
waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room"))
zoom_gateway_response = models.JSONField(
blank=True, null=True, verbose_name=_("Zoom Gateway Response")
)
status = models.CharField(
db_index=True,
max_length=20, # Added index
null=True,
blank=True,
verbose_name=_("Status"),
default=MeetingStatus.WAITING,
)
# Timestamps
def __str__(self):
return self.topic
@property
def get_job(self):
return self.interview.job
@property
def get_candidate(self):
return self.interview.application.person
@property
def candidate_full_name(self):
return self.interview.application.person.full_name
@property
def get_participants(self):
return self.interview.job.participants.all()
@property
def get_users(self):
return self.interview.job.users.all()
class MeetingComment(Base):
"""
Model for storing meeting comments/notes
"""
meeting = models.ForeignKey(
ZoomMeeting,
on_delete=models.CASCADE,
related_name="comments",
verbose_name=_("Meeting"),
)
author = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="meeting_comments",
verbose_name=_("Author"),
)
content = CKEditor5Field(verbose_name=_("Content"), config_name="extends")
# Inherited from Base: created_at, updated_at, slug
class Meta:
verbose_name = _("Meeting Comment")
verbose_name_plural = _("Meeting Comments")
ordering = ["-created_at"]
def __str__(self):
return f"Comment by {self.author.get_username()} on {self.meeting.topic}"
class FormTemplate(Base): class FormTemplate(Base):
@ -1894,136 +1821,6 @@ class BreakTime(models.Model):
return f"{self.start_time} - {self.end_time}" return f"{self.start_time} - {self.end_time}"
class InterviewSchedule(Base):
"""Stores the scheduling criteria for interviews"""
class InterviewType(models.TextChoices):
REMOTE = 'Remote', 'Remote Interview'
ONSITE = 'Onsite', 'In-Person Interview'
interview_type = models.CharField(
max_length=10,
choices=InterviewType.choices,
default=InterviewType.REMOTE,
verbose_name="Interview Meeting Type"
)
job = models.ForeignKey(
JobPosting,
on_delete=models.CASCADE,
related_name="interview_schedules",
db_index=True,
)
applications = models.ManyToManyField(
Application, related_name="interview_schedules", blank=True, null=True
)
start_date = models.DateField(
db_index=True, verbose_name=_("Start Date")
) # Added index
end_date = models.DateField(
db_index=True, verbose_name=_("End Date")
) # Added index
working_days = models.JSONField(
verbose_name=_("Working Days")
) # Store days of week as [0,1,2,3,4] for Mon-Fri
start_time = models.TimeField(verbose_name=_("Start Time"))
end_time = models.TimeField(verbose_name=_("End Time"))
break_start_time = models.TimeField(
verbose_name=_("Break Start Time"), null=True, blank=True
)
break_end_time = models.TimeField(
verbose_name=_("Break End Time"), null=True, blank=True
)
interview_duration = models.PositiveIntegerField(
verbose_name=_("Interview Duration (minutes)")
)
buffer_time = models.PositiveIntegerField(
verbose_name=_("Buffer Time (minutes)"), default=0
)
created_by = models.ForeignKey(
User, on_delete=models.CASCADE, db_index=True
) # Added index
def __str__(self):
return f"Interview Schedule for {self.job.title}"
class Meta:
indexes = [
models.Index(fields=["start_date"]),
models.Index(fields=["end_date"]),
models.Index(fields=["created_by"]),
]
class ScheduledInterview(Base):
"""Stores individual scheduled interviews"""
application = models.ForeignKey(
Application,
on_delete=models.CASCADE,
related_name="scheduled_interviews",
db_index=True,
)
participants = models.ManyToManyField('Participants', blank=True)
system_users=models.ManyToManyField(User,blank=True)
job = models.ForeignKey(
"JobPosting",
on_delete=models.CASCADE,
related_name="scheduled_interviews",
db_index=True,
)
zoom_meeting = models.OneToOneField(
ZoomMeeting, on_delete=models.CASCADE, related_name="interview", db_index=True,
null=True, blank=True
)
onsite_meeting= models.OneToOneField(
OnsiteMeeting, on_delete=models.CASCADE, related_name="onsite_interview", db_index=True,
null=True, blank=True
)
schedule = models.ForeignKey(
InterviewSchedule,
on_delete=models.CASCADE,
related_name="interviews",
null=True,
blank=True,
db_index=True,
)
interview_date = models.DateField(
db_index=True, verbose_name=_("Interview Date")
) # Added index
interview_time = models.TimeField(verbose_name=_("Interview Time"))
status = models.CharField(
db_index=True,
max_length=20, # Added index
choices=[
("scheduled", _("Scheduled")),
("confirmed", _("Confirmed")),
("cancelled", _("Cancelled")),
("completed", _("Completed")),
],
default="scheduled",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"Interview with {self.application.person.full_name} for {self.job.title}"
class Meta:
indexes = [
models.Index(fields=["job", "status"]),
models.Index(fields=["interview_date", "interview_time"]),
models.Index(fields=["application", "job"]),
]
class Notification(models.Model): class Notification(models.Model):
@ -2061,13 +1858,13 @@ class Notification(models.Model):
default=Status.PENDING, default=Status.PENDING,
verbose_name=_("Status"), verbose_name=_("Status"),
) )
related_meeting = models.ForeignKey( inteview= models.ForeignKey(
ZoomMeeting, 'InterviewSchedule',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="notifications", related_name="notifications",
null=True, null=True,
blank=True, blank=True,
verbose_name=_("Related Meeting"), verbose_name=_("Related Interview"),
) )
scheduled_for = models.DateTimeField( scheduled_for = models.DateTimeField(
verbose_name=_("Scheduled Send Time"), verbose_name=_("Scheduled Send Time"),
@ -2234,7 +2031,7 @@ class Message(Base):
# If job-related, ensure candidate applied for the job # If job-related, ensure candidate applied for the job
if self.job: if self.job:
if not Candidate.objects.filter(job=self.job, user=self.sender).exists(): if not Application.objects.filter(job=self.job, user=self.sender).exists():
raise ValidationError(_("You can only message about jobs you have applied for.")) raise ValidationError(_("You can only message about jobs you have applied for."))
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@ -2332,3 +2129,323 @@ class Document(Base):
if self.file: if self.file:
return self.file.name.split('.')[-1].upper() return self.file.name.split('.')[-1].upper()
return "" return ""
class InterviewLocation(Base):
"""
Base model for all interview location/meeting details (remote or onsite)
using Multi-Table Inheritance.
"""
class LocationType(models.TextChoices):
REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)')
ONSITE = 'Onsite', _('In-Person (Physical Location)')
class Status(models.TextChoices):
"""Defines the possible real-time statuses for any interview location/meeting."""
WAITING = "waiting", _("Waiting")
STARTED = "started", _("Started")
ENDED = "ended", _("Ended")
CANCELLED = "cancelled", _("Cancelled")
location_type = models.CharField(
max_length=10,
choices=LocationType.choices,
verbose_name=_("Location Type"),
db_index=True
)
details_url = models.URLField(
verbose_name=_("Meeting/Location URL"),
max_length=2048,
blank=True,
null=True
)
topic = models.CharField( # Renamed from 'description' to 'topic' to match your input
max_length=255,
verbose_name=_("Location/Meeting Topic"),
blank=True,
help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'")
)
timezone = models.CharField(
max_length=50,
verbose_name=_("Timezone"),
default='UTC'
)
def __str__(self):
# Use 'topic' instead of 'description'
return f"{self.get_location_type_display()} - {self.topic[:50]}"
class Meta:
verbose_name = _("Interview Location")
verbose_name_plural = _("Interview Locations")
class ZoomMeetingDetails(InterviewLocation):
"""Concrete model for remote interviews (Zoom specifics)."""
status = models.CharField(
db_index=True,
max_length=20,
choices=InterviewLocation.Status.choices,
default=InterviewLocation.Status.WAITING,
)
start_time = models.DateTimeField(
db_index=True, verbose_name=_("Start Time")
)
duration = models.PositiveIntegerField(
verbose_name=_("Duration (minutes)")
)
meeting_id = models.CharField(
db_index=True,
max_length=50,
unique=True,
verbose_name=_("External Meeting ID")
)
password = models.CharField(
max_length=20, blank=True, null=True, verbose_name=_("Password")
)
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(null=True,blank=True)
mute_upon_entry = models.BooleanField(
default=False, verbose_name=_("Mute Upon Entry")
)
waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room"))
# *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
# @classmethod
# def create(cls, **kwargs):
# """Factory method to ensure location_type is set to REMOTE."""
# return cls(location_type=InterviewLocation.LocationType.REMOTE, **kwargs)
class Meta:
verbose_name = _("Zoom Meeting Details")
verbose_name_plural = _("Zoom Meeting Details")
class OnsiteLocationDetails(InterviewLocation):
"""Concrete model for onsite interviews (Room/Address specifics)."""
physical_address = models.CharField(
max_length=255,
verbose_name=_("Physical Address"),
blank=True,
null=True
)
room_number = models.CharField(
max_length=50,
verbose_name=_("Room Number/Name"),
blank=True,
null=True
)
start_time = models.DateTimeField(
db_index=True, verbose_name=_("Start Time")
)
duration = models.PositiveIntegerField(
verbose_name=_("Duration (minutes)")
)
status = models.CharField(
db_index=True,
max_length=20,
choices=InterviewLocation.Status.choices,
default=InterviewLocation.Status.WAITING,
)
# *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
# @classmethod
# def create(cls, **kwargs):
# """Factory method to ensure location_type is set to ONSITE."""
# return cls(location_type=InterviewLocation.LocationType.ONSITE, **kwargs)
class Meta:
verbose_name = _("Onsite Location Details")
verbose_name_plural = _("Onsite Location Details")
# --- 2. Scheduling Models ---
class InterviewSchedule(Base):
"""Stores the TEMPLATE criteria for BULK interview generation."""
# We need a field to store the template location details linked to this bulk schedule.
# This location object contains the generic Zoom/Onsite info to be cloned.
template_location = models.ForeignKey(
InterviewLocation,
on_delete=models.SET_NULL,
related_name="schedule_templates",
null=True,
blank=True,
verbose_name=_("Location Template (Zoom/Onsite)")
)
# NOTE: schedule_interview_type field is needed in the form,
# but not on the model itself if we use template_location.
# If you want to keep it:
schedule_interview_type = models.CharField(
max_length=10,
choices=InterviewLocation.LocationType.choices,
verbose_name=_("Interview Type"),
default=InterviewLocation.LocationType.REMOTE
)
job = models.ForeignKey(
JobPosting,
on_delete=models.CASCADE,
related_name="interview_schedules",
db_index=True,
)
applications = models.ManyToManyField(
Application, related_name="interview_schedules", blank=True
)
start_date = models.DateField(db_index=True, verbose_name=_("Start Date"))
end_date = models.DateField(db_index=True, verbose_name=_("End Date"))
working_days = models.JSONField(
verbose_name=_("Working Days")
)
start_time = models.TimeField(verbose_name=_("Start Time"))
end_time = models.TimeField(verbose_name=_("End Time"))
break_start_time = models.TimeField(
verbose_name=_("Break Start Time"), null=True, blank=True
)
break_end_time = models.TimeField(
verbose_name=_("Break End Time"), null=True, blank=True
)
interview_duration = models.PositiveIntegerField(
verbose_name=_("Interview Duration (minutes)")
)
buffer_time = models.PositiveIntegerField(
verbose_name=_("Buffer Time (minutes)"), default=0
)
created_by = models.ForeignKey(
User, on_delete=models.CASCADE, db_index=True
)
def __str__(self):
return f"Schedule for {self.job.title}"
class ScheduledInterview(Base):
"""Stores individual scheduled interviews (whether bulk or individually created)."""
class InterviewStatus(models.TextChoices):
SCHEDULED = "scheduled", _("Scheduled")
CONFIRMED = "confirmed", _("Confirmed")
CANCELLED = "cancelled", _("Cancelled")
COMPLETED = "completed", _("Completed")
application = models.ForeignKey(
Application,
on_delete=models.CASCADE,
related_name="scheduled_interviews",
db_index=True,
)
job = models.ForeignKey(
JobPosting,
on_delete=models.CASCADE,
related_name="scheduled_interviews",
db_index=True,
)
# Links to the specific, individual location/meeting details for THIS interview
interview_location = models.OneToOneField(
InterviewLocation,
on_delete=models.SET_NULL,
related_name="scheduled_interview",
null=True,
blank=True,
db_index=True,
verbose_name=_("Meeting/Location Details")
)
# Link back to the bulk schedule template (optional if individually created)
schedule = models.ForeignKey(
InterviewSchedule,
on_delete=models.SET_NULL,
related_name="interviews",
null=True,
blank=True,
db_index=True,
)
participants = models.ManyToManyField('Participants', blank=True)
system_users = models.ManyToManyField(User, related_name="attended_interviews", blank=True)
interview_date = models.DateField(db_index=True, verbose_name=_("Interview Date"))
interview_time = models.TimeField(verbose_name=_("Interview Time"))
status = models.CharField(
db_index=True,
max_length=20,
choices=InterviewStatus.choices,
default=InterviewStatus.SCHEDULED,
)
def __str__(self):
return f"Interview with {self.application.person.full_name} for {self.job.title}"
class Meta:
indexes = [
models.Index(fields=["job", "status"]),
models.Index(fields=["interview_date", "interview_time"]),
models.Index(fields=["application", "job"]),
]
# --- 3. Interview Notes Model (Fixed) ---
class InterviewNote(Base):
"""Model for storing notes, feedback, or comments related to a specific ScheduledInterview."""
class NoteType(models.TextChoices):
FEEDBACK = 'Feedback', _('Candidate Feedback')
LOGISTICS = 'Logistics', _('Logistical Note')
GENERAL = 'General', _('General Comment')
1
interview = models.ForeignKey(
ScheduledInterview,
on_delete=models.CASCADE,
related_name="notes",
verbose_name=_("Scheduled Interview"),
db_index=True
)
author = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="interview_notes",
verbose_name=_("Author"),
db_index=True
)
note_type = models.CharField(
max_length=50,
choices=NoteType.choices,
default=NoteType.FEEDBACK,
verbose_name=_("Note Type")
)
content = CKEditor5Field(verbose_name=_("Content/Feedback"), config_name="extends")
class Meta:
verbose_name = _("Interview Note")
verbose_name_plural = _("Interview Notes")
ordering = ["created_at"]
def __str__(self):
return f"{self.get_note_type_display()} by {self.author.get_username()} on {self.interview.id}"

View File

@ -0,0 +1,36 @@
from django.db.models import Value, IntegerField, CharField, F
from django.db.models.functions import Coalesce, Cast, Replace, NullIf, KeyTextTransform
# Define the path to the match score
# Based on your tracebacks, the path is: ai_analysis_data -> analysis_data -> match_score
SCORE_PATH_RAW = F('ai_analysis_data__analysis_data__match_score')
# Define a robust annotation expression for safely extracting and casting the match score.
# This sequence handles three common failure points:
# 1. Missing keys (handled by Coalesce).
# 2. Textual scores (e.g., "N/A" or "") (handled by NullIf).
# 3. Quoted numeric scores (e.g., "50") from JSONB extraction (handled by Replace).
def get_safe_score_annotation():
"""
Returns a Django Expression object that safely extracts a score from the
JSONField, cleans it, and casts it to an IntegerField.
"""
# 1. Extract the JSON value as text and force a CharField for cleaning functions
# Using the double-underscore path is equivalent to the KeyTextTransform
# for the final nested key in a PostgreSQL JSONField.
extracted_text = Cast(SCORE_PATH_RAW, output_field=CharField())
# 2. Clean up any residual double-quotes that sometimes remain if the data
# was stored as a quoted string (e.g., "50")
cleaned_text = Replace(extracted_text, Value('"'), Value(''))
# 3. Use NullIf to convert the cleaned text to NULL if it is an empty string
# (or if it was a non-numeric string like "N/A" after quote removal)
null_if_empty = NullIf(cleaned_text, Value(''))
# 4. Cast the result (which is now either a clean numeric string or NULL) to an IntegerField.
final_cast = Cast(null_if_empty, output_field=IntegerField())
# 5. Use Coalesce to ensure NULL scores (from errors or missing data) default to 0.
return Coalesce(final_cast, Value(0))

View File

@ -12,7 +12,7 @@ from . linkedin_service import LinkedInService
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from . models import JobPosting from . models import JobPosting
from django.utils import timezone from django.utils import timezone
from . models import InterviewSchedule,ScheduledInterview,ZoomMeeting from . models import InterviewSchedule,ScheduledInterview,ZoomMeetingDetails
# Add python-docx import for Word document processing # Add python-docx import for Word document processing
try: try:
@ -26,7 +26,7 @@ except ImportError:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
OPENROUTER_API_KEY ='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' OPENROUTER_API_KEY ='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'
OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free' OPENROUTER_MODEL = 'x-ai/grok-code-fast-1'
# OPENROUTER_MODEL = 'openai/gpt-oss-20b:free' # OPENROUTER_MODEL = 'openai/gpt-oss-20b:free'
# OPENROUTER_MODEL = 'openai/gpt-oss-20b' # OPENROUTER_MODEL = 'openai/gpt-oss-20b'
@ -440,7 +440,7 @@ def handle_reume_parsing_and_scoring(pk):
print(f"Successfully scored and saved analysis for candidate {instance.id}") print(f"Successfully scored and saved analysis for candidate {instance.id}")
from django.utils import timezone
def create_interview_and_meeting( def create_interview_and_meeting(
candidate_id, candidate_id,
job_id, job_id,
@ -457,7 +457,7 @@ def create_interview_and_meeting(
job = JobPosting.objects.get(pk=job_id) job = JobPosting.objects.get(pk=job_id)
schedule = InterviewSchedule.objects.get(pk=schedule_id) schedule = InterviewSchedule.objects.get(pk=schedule_id)
interview_datetime = datetime.combine(slot_date, slot_time) interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time))
meeting_topic = f"Interview for {job.title} - {candidate.name}" meeting_topic = f"Interview for {job.title} - {candidate.name}"
# 1. External API Call (Slow) # 1. External API Call (Slow)
@ -466,24 +466,26 @@ def create_interview_and_meeting(
if result["status"] == "success": if result["status"] == "success":
# 2. Database Writes (Slow) # 2. Database Writes (Slow)
zoom_meeting = ZoomMeeting.objects.create( zoom_meeting = ZoomMeetingDetails.objects.create(
topic=meeting_topic, topic=meeting_topic,
start_time=interview_datetime, start_time=interview_datetime,
duration=duration, duration=duration,
meeting_id=result["meeting_details"]["meeting_id"], meeting_id=result["meeting_details"]["meeting_id"],
join_url=result["meeting_details"]["join_url"], details_url=result["meeting_details"]["join_url"],
zoom_gateway_response=result["zoom_gateway_response"], zoom_gateway_response=result["zoom_gateway_response"],
host_email=result["meeting_details"]["host_email"], host_email=result["meeting_details"]["host_email"],
password=result["meeting_details"]["password"] password=result["meeting_details"]["password"],
location_type="Remote"
) )
ScheduledInterview.objects.create( ScheduledInterview.objects.create(
application=Application, application=candidate,
job=job, job=job,
zoom_meeting=zoom_meeting, interview_location=zoom_meeting,
schedule=schedule, schedule=schedule,
interview_date=slot_date, interview_date=slot_date,
interview_time=slot_time interview_time=slot_time
) )
# Log success or use Django-Q result system for monitoring # Log success or use Django-Q result system for monitoring
logger.info(f"Successfully scheduled interview for {Application.name}") logger.info(f"Successfully scheduled interview for {Application.name}")
return True # Task succeeded return True # Task succeeded
@ -517,7 +519,7 @@ def handle_zoom_webhook_event(payload):
try: try:
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet, # Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
# and to simplify the logic flow. # and to simplify the logic flow.
meeting_instance = ZoomMeeting.objects.filter(meeting_id=meeting_id_zoom).first() meeting_instance = ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first()
print(meeting_instance) print(meeting_instance)
# --- 1. Creation and Update Events --- # --- 1. Creation and Update Events ---
if event_type == 'meeting.updated': if event_type == 'meeting.updated':
@ -698,7 +700,7 @@ def sync_candidate_to_source_task(candidate_id, source_id):
dict: Sync result for this specific candidate-source pair dict: Sync result for this specific candidate-source pair
""" """
from .candidate_sync_service import CandidateSyncService from .candidate_sync_service import CandidateSyncService
from .models import Candidate, Source, IntegrationLog from .models import Application, Source, IntegrationLog
logger.info(f"Starting sync task for candidate {candidate_id} to source {source_id}") logger.info(f"Starting sync task for candidate {candidate_id} to source {source_id}")

View File

@ -575,7 +575,7 @@ urlpatterns = [
), ),
# Email composition URLs # Email composition URLs
path( path(
"jobs/<slug:job_slug>/candidates/<slug:candidate_slug>/compose-email/", "jobs/<slug:job_slug>/candidates/compose-email/",
views.compose_candidate_email, views.compose_candidate_email,
name="compose_candidate_email", name="compose_candidate_email",
), ),
@ -594,16 +594,41 @@ urlpatterns = [
path("documents/<int:document_id>/delete/", views.document_delete, name="document_delete"), path("documents/<int:document_id>/delete/", views.document_delete, name="document_delete"),
path("documents/<int:document_id>/download/", views.document_download, name="document_download"), path("documents/<int:document_id>/download/", views.document_download, name="document_download"),
path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'), path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'),
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'), path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'), path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) --- # # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
# path('interview/list/', views.InterviewListView.as_view(), name='interview_list'), # path('interview/list/', views.interview_list, name='interview_list'),
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'), # 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>/update/', views.ScheduledInterviewUpdateView.as_view(), name='update_scheduled_interview'),
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'), # path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
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

@ -594,7 +594,7 @@ def update_meeting(instance, updated_data):
instance.topic = zoom_details.get("topic", instance.topic) instance.topic = zoom_details.get("topic", instance.topic)
instance.duration = zoom_details.get("duration", instance.duration) instance.duration = zoom_details.get("duration", instance.duration)
instance.join_url = zoom_details.get("join_url", instance.join_url) instance.details_url = zoom_details.get("join_url", instance.details_url)
instance.password = zoom_details.get("password", instance.password) instance.password = zoom_details.get("password", instance.password)
# Corrected status assignment: instance.status, not instance.password # Corrected status assignment: instance.status, not instance.password
instance.status = zoom_details.get("status") instance.status = zoom_details.get("status")

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ from django.db.models.fields.json import KeyTextTransform
from recruitment.utils import json_to_markdown_table from recruitment.utils import json_to_markdown_table
from django.db.models import Count, Avg, F, FloatField from django.db.models import Count, Avg, F, FloatField
from django.db.models.functions import Cast from django.db.models.functions import Cast
from django.db.models.functions import Coalesce, Cast, Replace, NullIf
from . import models from . import models
from django.utils.translation import get_language from django.utils.translation import get_language
from . import forms from . import forms
@ -22,7 +23,7 @@ from django.views.generic import ListView, CreateView, UpdateView, DeleteView, D
# JobForm removed - using JobPostingForm instead # JobForm removed - using JobPostingForm instead
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.db.models import FloatField from django.db.models import FloatField
from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields, Value,CharField
from django.db.models.functions import Cast, Coalesce, TruncDate from django.db.models.functions import Cast, Coalesce, TruncDate
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.shortcuts import render from django.shortcuts import render
@ -454,6 +455,29 @@ def dashboard_view(request):
) )
) )
# safe_match_score_cast = Cast(
# # 3. If the result after stripping quotes is an empty string (''), convert it to NULL.
# NullIf(
# # 2. Use Replace to remove the literal double quotes (") that might be present.
# Replace(
# # 1. Use the double-underscore path (which uses the ->> operator for the final value)
# # and cast to CharField for text-based cleanup functions.
# Cast(SCORE_PATH, output_field=CharField()),
# Value('"'), Value('') # Replace the double quote character with an empty string
# ),
# Value('') # Value to check for (empty string)
# ),
# output_field=IntegerField() # 4. Cast the clean, non-empty string (or NULL) to an integer.
# )
# candidates_with_score_query= candidate_queryset.filter(is_resume_parsed=True).annotate(
# # The Coalesce handles NULL values (from missing data, non-numeric data, or NullIf) and sets them to 0.
# annotated_match_score=Coalesce(safe_match_score_cast, Value(0))
# )
# A. Pipeline & Volume Metrics (Scoped) # A. Pipeline & Volume Metrics (Scoped)
total_active_jobs = job_scope_queryset.filter(status="ACTIVE").count() total_active_jobs = job_scope_queryset.filter(status="ACTIVE").count()
last_week = timezone.now() - timedelta(days=7) last_week = timezone.now() - timedelta(days=7)

View File

@ -119,10 +119,10 @@ def create_zoom_meeting(topic, start_time, duration, host_email):
# Step 11: Analytics Dashboard (recruitment/dashboard.py) # Step 11: Analytics Dashboard (recruitment/dashboard.py)
import pandas as pd import pandas as pd
from .models import Candidate from .models import Application
def get_dashboard_data(): def get_dashboard_data():
df = pd.DataFrame(list(Candidate.objects.all().values('status', 'created_at'))) df = pd.DataFrame(list( Application.objects.all().values('status', 'created_at')))
summary = df['status'].value_counts().to_dict() summary = df['status'].value_counts().to_dict()
return summary return summary

View File

@ -11,6 +11,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{{interviews}}
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;"> <h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">

View File

@ -9,7 +9,7 @@
<h3 class="text-center font-weight-light my-4">Set Interview Location</h3> <h3 class="text-center font-weight-light my-4">Set Interview Location</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="post" action="{% url 'schedule_interview_location_form' schedule.slug %}" enctype="multipart/form-data"> <form method="post" action="{% url 'confirm_schedule_interviews_view' job.slug %}" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{# Renders the single 'location' field using the crispy filter #} {# Renders the single 'location' field using the crispy filter #}

View File

@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static crispy_forms_tags %}
{%load i18n %} {%load i18n %}
{% block customCSS %} {% block customCSS %}
@ -119,7 +119,7 @@
{% if not forloop.last %}, {% endif %} {% if not forloop.last %}, {% endif %}
{% endfor %} {% endfor %}
</p> </p>
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Type:</strong> {{interview_type}}</p> <p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Type:</strong> {{schedule_interview_type}}</p>
</div> </div>
</div> </div>
@ -163,23 +163,57 @@
<tr> <tr>
<td>{{ item.date|date:"F j, Y" }}</td> <td>{{ item.date|date:"F j, Y" }}</td>
<td>{{ item.time|time:"g:i A" }}</td> <td>{{ item.time|time:"g:i A" }}</td>
<td>{{ item.applications.name }}</td> <td>{{ item.application.name }}</td>
<td>{{ item.applications.email }}</td> <td>{{ item.application.email }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% if schedule_interview_type == "Onsite" %}
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3"> <button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4" data-bs-toggle="modal" data-bs-target="#interviewDetailsModal" data-placement="top">
{% csrf_token %}
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary px-4">
<i class="fas fa-arrow-left me-2"></i> {% trans "Back to Edit" %}
</a>
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4">
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %} <i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
</button> </button>
</form> {% else %}
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3">
{% csrf_token %}
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary px-4">
<i class="fas fa-arrow-left me-2"></i> {% trans "Back to Edit" %}
</a>
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4">
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
</button>
</form>
{% endif %}
</div>
</div>
</div>
<div class="modal fade" id="interviewDetailsModal" tabindex="-1" aria-labelledby="interviewDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="interviewDetailsModalLabel">{% trans "Interview Details" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body"> <form method="post" action="{% url 'confirm_schedule_interviews_view' job.slug %}" enctype="multipart/form-data" id="onsite-form">
{% csrf_token %}
{# Renders the single 'location' field using the crispy filter #}
{{ form|crispy }}
</form>
</div>
<div class="modal-footer">
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
<a href="{% url 'list_meetings' %}" class="btn btn-secondary me-2">
<i class="fas fa-times me-1"></i> Close
</a>
<button type="submit" class="btn btn-primary" form="onsite-form">
<i class="fas fa-save me-1"></i> Save Location
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -200,13 +234,13 @@ document.addEventListener('DOMContentLoaded', function() {
events: [ events: [
{% for item in schedule %} {% for item in schedule %}
{ {
title: '{{ item.candidate.name }}', title: '{{ item.application.name }}',
start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}', start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}',
url: '#', url: '#',
// Use the theme color for candidate events // Use the theme color for candidate events
color: 'var(--kaauh-teal-dark)', color: 'var(--kaauh-teal-dark)',
extendedProps: { extendedProps: {
email: '{{ item.candidate.email }}', email: '{{ item.application.email }}',
time: '{{ item.time|time:"g:i A" }}' time: '{{ item.time|time:"g:i A" }}'
} }
}, },

View File

@ -142,8 +142,8 @@
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="{{ form.start_date.id_for_label }}">{% trans "Interview Type" %}</label> <label for="{{ form.schedule_interview_type.id_for_label }}">{% trans "Interview Type" %}</label>
{{ form.interview_type }} {{ form.schedule_interview_type }}
</div> </div>
</div> </div>

View File

@ -0,0 +1,136 @@
{% extends "base.html" %}
{% load static i18n widget_tweaks %}
{% block title %}{% trans "Schedule Remote Meeting" %} - {{ block.super }}{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-lg-8 col-xl-7">
<div class="d-flex align-items-center mb-4">
<a href="{% url 'list_meetings' %}" class="btn btn-outline-secondary me-3">
<i class="fas fa-arrow-left"></i>
</a>
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-globe me-2"></i> {% trans "Create Remote Interview" %}
</h1>
</div>
<div class="card shadow-lg">
<div class="card-header bg-white border-bottom py-3">
<h5 class="mb-0 text-muted">{% trans "Remote Meeting Details" %}</h5>
</div>
<div class="card-body">
<form method="post" novalidate>
{% csrf_token %}
{# --- Non-Field Errors --- #}
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
{% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
{# --- Core Meeting Details (BaseMeetingForm fields) --- #}
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-bold">{{ form.application.label }}</label>
{% render_field form.application class="form-select" %}
<div class="form-text text-muted">{{ form.application.help_text }}</div>
{% for error in form.application.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</div>
<div class="col-md-6">
<label class="form-label fw-bold">{{ form.job.label }}</label>
{% render_field form.job class="form-select" %}
<div class="form-text text-muted">{{ form.job.help_text }}</div>
{% for error in form.job.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12">
<label class="form-label fw-bold">{{ form.topic.label }}</label>
{% render_field form.topic class="form-control" placeholder=form.topic.label %}
<div class="form-text text-muted">{{ form.topic.help_text }}</div>
{% for error in form.topic.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</div>
<div class="col-md-6">
<label class="form-label fw-bold">{{ form.start_time.label }}</label>
{# Note: input type='datetime-local' is set in the form definition (forms.py) #}
{% render_field form.start_time class="form-control" %}
<div class="form-text text-muted">{{ form.start_time.help_text }}</div>
{% for error in form.start_time.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</div>
<div class="col-md-6">
<label class="form-label fw-bold">{{ form.duration.label }}</label>
{% render_field form.duration class="form-control" placeholder="30" %}
<div class="form-text text-muted">{{ form.duration.help_text }}</div>
{% for error in form.duration.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</div>
</div>
<hr class="mt-4 mb-4">
{# --- Remote Specific Details (SimpleRemoteMeetingForm fields) --- #}
<h6 class="mb-3 text-primary-theme fw-bold">{% trans "Remote Configuration" %}</h6>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-bold">{{ form.host_email.label }}</label>
{% render_field form.host_email class="form-control" %}
<div class="form-text text-muted">{{ form.host_email.help_text }}</div>
{% for error in form.host_email.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</div>
<div class="col-md-6">
<label class="form-label fw-bold">{{ form.password.label }}</label>
{% render_field form.password class="form-control" %}
<div class="form-text text-muted">{{ form.password.help_text }}</div>
{% for error in form.password.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</div>
</div>
<div class="mb-4">
<div class="form-check form-switch">
{% render_field form.participant_video class="form-check-input" %}
<label class="form-check-label fw-bold" for="{{ form.participant_video.id_for_label }}">
{{ form.participant_video.label }}
</label>
</div>
<div class="form-text text-muted">{{ form.participant_video.help_text }}</div>
{% for error in form.participant_video.errors %}
<div class="text-danger small">{{ error }}</div>
{% endfor %}
</div>
{# Hidden status field #}
{% render_field form.status type="hidden" %}
<div class="mt-4 border-top pt-3 text-end">
<button type="submit" class="btn btn-lg btn-main-action">
<i class="fas fa-video me-2"></i> {% trans "Create Remote Interview" %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static i18n %} {% load static i18n %}
{% block title %}{% trans "Zoom Meetings" %} - {{ block.super }}{% endblock %} {% block title %}{% trans "Interviews & Meetings" %} - {{ block.super }}{% endblock %}
{% block customCSS %} {% block customCSS %}
<style> <style>
@ -12,9 +12,12 @@
--kaauh-border: #eaeff3; --kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40; --kaauh-primary-text: #343a40;
--kaauh-gray-light: #f8f9fa; --kaauh-gray-light: #f8f9fa;
--kaauh-warning: #ffc107;
--kaauh-danger: #dc3545;
--kaauh-success: #28a745;
} }
/* Enhanced Card Styling (Consistent) */ /* Enhanced Card Styling */
.card { .card {
border: 1px solid var(--kaauh-border); border: 1px solid var(--kaauh-border);
border-radius: 0.75rem; border-radius: 0.75rem;
@ -26,12 +29,6 @@
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0,0,0,0.1); box-shadow: 0 6px 16px rgba(0,0,0,0.1);
} }
.card.no-hover:hover {
transform: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
/* Main Action Button Style (Teal Theme) */
.btn-main-action { .btn-main-action {
background-color: var(--kaauh-teal); background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal); border-color: var(--kaauh-teal);
@ -42,14 +39,11 @@
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
} }
.btn-main-action:hover { .btn-main-action:hover {
background-color: var(--kaauh-teal-dark); background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark); border-color: var(--kaauh-teal-dark);
box-shadow: 0 4px 8px rgba(0,0,0,0.15); box-shadow: 0 4px 8px rgba(0,0,0,0.15);
} }
/* Secondary Button Style (For Edit/Outline - Consistent) */
.btn-outline-secondary { .btn-outline-secondary {
color: var(--kaauh-teal-dark); color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal); border-color: var(--kaauh-teal);
@ -59,7 +53,6 @@
color: white; color: white;
border-color: var(--kaauh-teal-dark); border-color: var(--kaauh-teal-dark);
} }
/* Primary Outline for View/Join */
.btn-outline-primary { .btn-outline-primary {
color: var(--kaauh-teal); color: var(--kaauh-teal);
border-color: var(--kaauh-teal); border-color: var(--kaauh-teal);
@ -68,8 +61,6 @@
background-color: var(--kaauh-teal); background-color: var(--kaauh-teal);
color: white; color: white;
} }
/* Meeting Card Specifics (Adapted to Standard Card View) */
.meeting-card .card-title { .meeting-card .card-title {
color: var(--kaauh-teal-dark); color: var(--kaauh-teal-dark);
font-weight: 600; font-weight: 600;
@ -79,14 +70,7 @@
color: var(--kaauh-teal); color: var(--kaauh-teal);
width: 1.25rem; width: 1.25rem;
} }
/* Primary Color Overrides */
.text-primary-theme { color: var(--kaauh-teal) !important; } .text-primary-theme { color: var(--kaauh-teal) !important; }
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
.text-success { color: var(--kaauh-success) !important; }
.text-danger { color: var(--kaauh-danger) !important; }
.text-info { color: #17a2b8 !important; }
/* Status Badges (Standardized) */
.status-badge { .status-badge {
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.4em 0.8em; padding: 0.4em 0.8em;
@ -95,13 +79,16 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.7px; letter-spacing: 0.7px;
} }
/* Status Badge Mapping */ /* Statuses */
.bg-waiting { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;} .bg-waiting { background-color: var(--kaauh-warning) !important; color: var(--kaauh-primary-text) !important;}
.bg-scheduled { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;}
.bg-started { background-color: var(--kaauh-teal) !important; color: white !important;} .bg-started { background-color: var(--kaauh-teal) !important; color: white !important;}
.bg-ended { background-color: #dc3545 !important; color: white !important;} .bg-ended { background-color: var(--kaauh-danger) !important; color: white !important;}
.bg-scheduled { background-color: #5bc0de !important; color: white !important;}
/* Event Types */
.bg-Remote { background-color: var(--kaauh-teal) !important; color: white !important; }
.bg-Onsite { background-color: #007bff !important; color: white !important; }
/* Table Styling (Consistent with Reference) */ /* Table Styling */
.table-view .table thead th { .table-view .table thead th {
background-color: var(--kaauh-teal-dark); background-color: var(--kaauh-teal-dark);
color: white; color: white;
@ -120,51 +107,11 @@
.table-view .table tbody tr:hover { .table-view .table tbody tr:hover {
background-color: var(--kaauh-gray-light); background-color: var(--kaauh-gray-light);
} }
/* Pagination Link Styling (Consistent) */
.pagination .page-item .page-link {
color: var(--kaauh-teal-dark);
border-color: var(--kaauh-border);
}
.pagination .page-item.active .page-link { .pagination .page-item.active .page-link {
background-color: var(--kaauh-teal); background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal); border-color: var(--kaauh-teal);
color: white; color: white;
} }
.pagination .page-item:hover .page-link:not(.active) {
background-color: #e9ecef;
}
/* Filter & Search Layout Adjustments */
.filter-buttons {
display: flex;
gap: 0.5rem;
}
/* Icon color for empty state */
.text-muted.fa-3x {
color: var(--kaauh-teal-dark) !important;
}
@keyframes svg-pulse {
0% {
transform: scale(0.9);
opacity: 0.8;
}
50% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(0.9);
opacity: 0.8;
}
}
/* Apply the animation to the custom class */
.svg-pulse {
animation: svg-pulse 2s infinite ease-in-out;
transform-origin: center; /* Ensure scaling is centered */
}
</style> </style>
{% endblock %} {% endblock %}
@ -172,48 +119,66 @@
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;"> <h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-video me-2"></i> {% trans "Zoom Meetings" %} <i class="fas fa-calendar-alt me-2"></i> {% trans "Interviews & Meetings" %}
</h1> </h1>
<a href="{% url 'create_meeting' %}" class="btn btn-main-action"> {% comment %} <div class="btn-group" role="group">
<i class="fas fa-plus me-1"></i> {% trans "Create Meeting" %} <a href="{% url 'create_remote_meeting' %}" class="btn btn-main-action">
</a> <i class="fas fa-globe me-1"></i> {% trans "Create Remote" %}
</a>
<a href="{% url 'create_onsite_meeting' %}" class="btn btn-outline-secondary">
<i class="fas fa-building me-1"></i> {% trans "Create Onsite" %}
</a>
</div> {% endcomment %}
</div> </div>
<div class="card mb-4 shadow-sm no-hover"> <div class="card mb-4 shadow-sm no-hover">
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-4">
<label for="search" class="form-label small text-muted">{% trans "Search by Topic" %}</label> <label for="search" class="form-label small text-muted">{% trans "Search by Topic" %}</label>
<div class="input-group input-group-lg mb-3"> <div class="input-group input-group-lg mb-3">
<form method="get" action="" class="w-100"> <form method="get" action="" class="w-100">
{# Assuming includes/search_form.html handles the 'q' parameter #}
{% include "includes/search_form.html" with search_query=search_query %} {% include "includes/search_form.html" with search_query=search_query %}
</form> </form>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-8">
<form method="GET" class="row g-3 align-items-end" > <form method="GET" class="row g-3 align-items-end" >
{% if search_query %}<input class="form-control form-control-sm" type="hidden" name="q" value="{{ search_query }}">{% endif %} {# Hidden inputs to persist other filters #}
{% if status_filter %}<input class="form-control form-control-sm" type="hidden" name="status" value="{{ status_filter }}">{% endif %} {% if search_query %}<input type="hidden" name="q" value="{{ search_query }}">{% endif %}
{% if status_filter %}<input type="hidden" name="status" value="{{ status_filter }}">{% endif %}
{% if candidate_name_filter %}<input type="hidden" name="candidate_name" value="{{ candidate_name_filter }}">{% endif %}
<div class="col-md-4"> <div class="col-md-3">
<label for="type_filter" class="form-label small text-muted">{% trans "Interview Type" %}</label>
<select name="type" id="type_filter" class="form-select form-select-sm">
<option value="">{% trans "All Types" %}</option>
<option value="Remote" {% if type_filter == 'Remote' %}selected{% endif %}>{% trans "Remote" %}</option>
<option value="Onsite" {% if type_filter == 'Onsite' %}selected{% endif %}>{% trans "Onsite" %}</option>
</select>
</div>
<div class="col-md-3">
<label for="status" class="form-label small text-muted">{% trans "Filter by Status" %}</label> <label for="status" class="form-label small text-muted">{% trans "Filter by Status" %}</label>
<select name="status" id="status" class="form-select form-select-sm"> <select name="status" id="status" class="form-select form-select-sm">
<option value="">{% trans "All Statuses" %}</option> <option value="">{% trans "All Statuses" %}</option>
<option value="waiting" {% if status_filter == 'waiting' %}selected{% endif %}>{% trans "Waiting" %}</option> {# CORRECTED: Using the context variable passed from the view #}
<option value="started" {% if status_filter == 'started' %}selected{% endif %}>{% trans "Started" %}</option> {% for choice, display in status_choices %}
<option value="ended" {% if status_filter == 'ended' %}selected{% endif %}>{% trans "Ended" %}</option> <option value="{{ choice }}" {% if status_filter == choice %}selected{% endif %}>{{ display }}</option>
{% endfor %}
</select> </select>
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<label for="candidate_name" class="form-label small text-muted">{% trans "Candidate Name" %}</label> <label for="candidate_name" class="form-label small text-muted">{% trans "Candidate Name" %}</label>
<input type="text" class="form-control form-control-sm" id="candidate_name" name="candidate_name" placeholder="{% trans 'Search by candidate...' %}" value="{{ candidate_name_filter }}"> <input type="text" class="form-control form-control-sm" id="candidate_name" name="candidate_name" placeholder="{% trans 'Search by candidate...' %}" value="{{ candidate_name_filter }}">
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<div class="filter-buttons"> <div class="filter-buttons">
<button type="submit" class="btn btn-main-action btn-sm"> <button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-filter me-1"></i> {% trans "Apply Filters" %} <i class="fas fa-filter me-1"></i> {% trans "Apply Filters" %}
</button> </button>
{% if status_filter or search_query or candidate_name_filter %} {% if status_filter or search_query or candidate_name_filter or type_filter %}
<a href="{% url 'list_meetings' %}" class="btn btn-outline-secondary btn-sm"> <a href="{% url 'list_meetings' %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-times me-1"></i> {% trans "Clear" %} <i class="fas fa-times me-1"></i> {% trans "Clear" %}
</a> </a>
@ -225,59 +190,63 @@
</div> </div>
</div> </div>
</div> </div>
{% if meetings %}
{% if meetings_data %}
<div id="meetings-list"> <div id="meetings-list">
{# View Switcher #} {# View Switcher (not provided, assuming standard include) #}
{% include "includes/_list_view_switcher.html" with list_id="meetings-list" %} {% include "includes/_list_view_switcher.html" with list_id="meetings-list" %}
{# Card View #} {# Card View #}
<div class="card-view active row"> <div class="card-view active row">
{% for meeting in meetings %} {% for meeting in meetings_data %}
<div class="col-md-6 col-lg-4 mb-4"> <div class="col-md-6 col-lg-4 mb-4">
<div class="card meeting-card h-100 shadow-sm"> <div class="card meeting-card h-100 shadow-sm">
<div class="card-body d-flex flex-column"> <div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2"> <div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title flex-grow-1 me-3"><a href="{% url 'meeting_details' meeting.slug %}" class="text-decoration-none text-primary-theme">{{ meeting.topic }}</a></h5> <h5 class="card-title flex-grow-1 me-3"><a href="" class="text-decoration-none text-primary-theme">{{ meeting.topic }}</a></h5>
<span class="status-badge bg-{{ meeting.status }}"> {# Display the type badge (Remote/Onsite) #}
{{ meeting.status|title }} <span class="status-badge bg-{{ meeting.type }}">
{{ meeting.type|title }}
</span> </span>
</div> </div>
<p class="card-text text-muted small mb-3"> <p class="card-text text-muted small mb-3">
<i class="fas fa-user"></i> {% trans "Candidate" %}: {% if meeting.interview %}{{ meeting.interview.candidate.name }}{% else %} <i class="fas fa-user"></i> {% trans "Candidate" %}: {{ meeting.interview.application.person.full_name|default:"N/A" }}<br>
<button data-bs-toggle="modal" <i class="fas fa-briefcase"></i> {% trans "Job" %}: {{ meeting.interview.job.title|default:"N/A" }}<br>
data-bs-target="#meetingModal"
hx-get="{% url 'set_meeting_candidate' meeting.slug %}" {# Dynamic location/type details #}
hx-target="#meetingModalBody" {% if meeting.type == 'Remote' %}
hx-swap="outerHTML" <i class="fas fa-link"></i> {% trans "Remote ID" %}: {{ meeting.meeting_id|default:meeting.location.id }}<br>
class="btn text-primary-theme btn-link btn-sm">Set Candidate</button> {% elif meeting.type == 'Onsite' %}
{% endif %}<br> {# Use the details object for concrete location info #}
<i class="fas fa-briefcase"></i> {% trans "Job" %}: {% if meeting.interview %}{{ meeting.interview.job.title }}{% else %} <i class="fas fa-map-marker-alt"></i> {% trans "Location" %}: {{ meeting.details.room_number|default:meeting.details.physical_address|truncatechars:30 }}<br>
<button data-bs-toggle="modal" {% endif %}
data-bs-target="#meetingModal"
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
hx-target="#meetingModalBody"
hx-swap="outerHTML"
class="btn text-primary-theme btn-link btn-sm">Set Job</button>
{% endif %}<br>
<i class="fas fa-hashtag"></i> {% trans "ID" %}: {{ meeting.meeting_id|default:meeting.id }}<br>
<i class="fas fa-clock"></i> {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }}<br> <i class="fas fa-clock"></i> {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }}<br>
<i class="fas fa-stopwatch"></i> {% trans "Duration" %}: {{ meeting.duration }} minutes{% if meeting.password %}<br><i class="fas fa-lock"></i> {% trans "Password" %}: Yes{% endif %} <i class="fas fa-stopwatch"></i> {% trans "Duration" %}: {{ meeting.duration }} minutes
</p> </p>
<span class="status-badge bg-{{ meeting.status }}">
{{ meeting.interview.get_status_display }}
</span>
<div class="mt-auto pt-2 border-top"> <div class="mt-auto pt-2 border-top">
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'meeting_details' meeting.slug %}" class="btn btn-sm btn-outline-primary"> <a href="" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i> {% trans "View" %} <i class="fas fa-eye"></i> {% trans "View" %}
</a> </a>
{% if meeting.join_url %} {% if meeting.type == 'Remote' and meeting.join_url %}
<a href="{{ meeting.join_url }}" target="_blank" class="btn btn-sm btn-main-action"> <a href="{{ meeting.join_url }}" target="_blank" class="btn btn-sm btn-main-action">
<i class="fas fa-link"></i> {% trans "Join" %} <i class="fas fa-sign-in-alt"></i> {% trans "Join Remote" %}
</a> </a>
{% elif meeting.type == 'Onsite' %}
<button type="button" class="btn btn-sm btn-outline-secondary" disabled>
<i class="fas fa-check"></i> {% trans "Physical Event" %}
</button>
{% endif %} {% endif %}
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-sm btn-outline-secondary"> {# CORRECTED: Passing the slug to the update URL #}
<a href="" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</a> </a>
<button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}" <button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}"
@ -303,9 +272,9 @@
<thead> <thead>
<tr> <tr>
<th scope="col">{% trans "Topic" %}</th> <th scope="col">{% trans "Topic" %}</th>
<th scope="col">{% trans "Type" %}</th>
<th scope="col">{% trans "Candidate" %}</th> <th scope="col">{% trans "Candidate" %}</th>
<th scope="col">{% trans "Job" %}</th> <th scope="col">{% trans "Job" %}</th>
<th scope="col">{% trans "ID" %}</th>
<th scope="col">{% trans "Start Time" %}</th> <th scope="col">{% trans "Start Time" %}</th>
<th scope="col">{% trans "Duration" %}</th> <th scope="col">{% trans "Duration" %}</th>
<th scope="col">{% trans "Status" %}</th> <th scope="col">{% trans "Status" %}</th>
@ -313,66 +282,46 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for meeting in meetings %} {% for meeting in meetings_data %}
<tr> <tr>
<td><strong class="text-primary"><a href="{% url 'meeting_details' meeting.slug %}" class="text-decoration-none text-secondary">{{ meeting.topic }}<a></strong></td> <td><strong class="text-primary"><a href="" class="text-decoration-none text-secondary">{{ meeting.topic }}<a></strong></td>
<td> <td>
{% if meeting.interview %} {# Display the event type badge #}
<a class="text-primary text-decoration-none" href="{% url 'candidate_detail' meeting.interview.application.slug %}">{{ meeting.interview.candidate.name }} <i class="fas fa-link"></i></a> <span class="status-badge bg-{{ meeting.type }}">{{ meeting.type|title }}</span>
{% else %} </td>
<button data-bs-toggle="modal" <td>
data-bs-target="#meetingModal" <a class="text-primary text-decoration-none" href="{% url 'candidate_detail' meeting.interview.application.person.slug %}">{{ meeting.interview.application.person.full_name }} <i class="fas fa-link"></i></a>
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
hx-target="#meetingModalBody"
hx-swap="outerHTML"
class="btn btn-outline-primary btn-sm">Set Candidate</button>
{% endif %}
</td> </td>
<td> <td>
{% if meeting.interview %}
<a class="text-primary text-decoration-none" href="{% url 'job_detail' meeting.interview.job.slug %}">{{ meeting.interview.job.title }} <i class="fas fa-link"></i></a> <a class="text-primary text-decoration-none" href="{% url 'job_detail' meeting.interview.job.slug %}">{{ meeting.interview.job.title }} <i class="fas fa-link"></i></a>
{% else %}
<button data-bs-toggle="modal"
data-bs-target="#meetingModal"
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
hx-target="#meetingModalBody"
hx-swap="outerHTML"
class="btn btn-outline-primary btn-sm">Set Job</button>
{% endif %}
</td> </td>
<td>{{ meeting.meeting_id|default:meeting.id }}</td>
<td>{{ meeting.start_time|date:"M d, Y H:i" }}</td> <td>{{ meeting.start_time|date:"M d, Y H:i" }}</td>
<td>{{ meeting.duration }} min</td> <td>{{ meeting.duration }} min</td>
<td> <td>
{% if meeting %} {# Display the meeting status badge from the ScheduledInterview model #}
<span class="badge {% if meeting.status == 'waiting' %}bg-warning{% elif meeting.status == 'started' %}bg-success{% elif meeting.status == 'ended' %}bg-danger{% endif %}"> <span class="status-badge bg-{{ meeting.status }}">
{% if meeting.status == 'started' %} {{ meeting.interview.get_status_display }}
<i class="fas fa-circle me-1 text-success"></i>
{% endif %}
{{ meeting.status|title }}
</span> </span>
{% else %}
<span class="text-muted">--</span>
{% endif %}
</td> </td>
<td class="text-end"> <td class="text-end">
<div class="btn-group btn-group-sm" role="group"> <div class="btn-group btn-group-sm" role="group">
{% if meeting.join_url %} {% if meeting.type == 'Remote' and meeting.join_url %}
<a href="{{ meeting.join_url }}" target="_blank" class="btn btn-main-action" title="{% trans 'Join' %}"> <a href="{{ meeting.join_url }}" target="_blank" class="btn btn-main-action" title="{% trans 'Join' %}">
<i class="fas fa-sign-in-alt"></i> <i class="fas fa-sign-in-alt"></i>
</a> </a>
{% endif %} {% endif %}
<a href="{% url 'meeting_details' meeting.slug %}" class="btn btn-outline-primary" title="{% trans 'View' %}"> <a href="" class="btn btn-outline-primary" title="{% trans 'View' %}">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</a> </a>
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-outline-secondary" title="{% trans 'Update' %}"> {# CORRECTED: Passing the slug to the update URL #}
<a href="" class="btn btn-outline-secondary" title="{% trans 'Update' %}">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</a> </a>
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}" <button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#meetingModal" data-bs-target="#deleteModal"
hx-post="{% url 'delete_meeting' meeting.slug %}" hx-post="{% url 'delete_meeting' meeting.slug %}"
hx-target="#meetingModalBody" hx-target="#deleteModalBody"
hx-swap="outerHTML" hx-swap="outerHTML"
data-item-name="{{ meeting.topic }}"> data-item-name="{{ meeting.topic }}">
<i class="fas fa-trash-alt"></i> <i class="fas fa-trash-alt"></i>
@ -387,16 +336,16 @@
</div> </div>
</div> </div>
{# Pagination (Standardized) #} {# Pagination (All filters correctly included in query strings) #}
{% if is_paginated %} {% if is_paginated %}
<nav aria-label="Page navigation" class="mt-4"> <nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center"> <ul class="pagination justify-content-center">
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page=1{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">First</a> <a class="page-link" href="?page=1{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&type={{ type_filter }}{% endif %}{% if candidate_name_filter %}&candidate_name={{ candidate_name_filter }}{% endif %}">First</a>
</li> </li>
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">Previous</a> <a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&type={{ type_filter }}{% endif %}{% if candidate_name_filter %}&candidate_name={{ candidate_name_filter }}{% endif %}">Previous</a>
</li> </li>
{% endif %} {% endif %}
@ -406,10 +355,10 @@
{% if page_obj.has_next %} {% if page_obj.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">Next</a> <a class="page-link" href="?page={{ page_obj.next_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&type={{ type_filter }}{% endif %}{% if candidate_name_filter %}&candidate_name={{ candidate_name_filter }}{% endif %}">Next</a>
</li> </li>
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">Last</a> <a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&type={{ type_filter }}{% endif %}{% if candidate_name_filter %}&candidate_name={{ candidate_name_filter }}{% endif %}">Last</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
@ -418,12 +367,17 @@
{% else %} {% else %}
<div class="text-center py-5 card shadow-sm"> <div class="text-center py-5 card shadow-sm">
<div class="card-body"> <div class="card-body">
<i class="fas fa-video fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i> <i class="fas fa-calendar-alt fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
<h3>{% trans "No Zoom meetings found" %}</h3> <h3>{% trans "No interviews or meetings found" %}</h3>
<p class="text-muted">{% trans "Create your first meeting or adjust your filters." %}</p> <p class="text-muted">{% trans "Create your first interview or adjust your filters." %}</p>
<a href="{% url 'create_meeting' %}" class="btn btn-main-action mt-3"> {% comment %} <div class="btn-group mt-3" role="group">
<i class="fas fa-plus me-1"></i> {% trans "Create Your First Meeting" %} <a href="{% url 'create_remote_meeting' %}" class="btn btn-main-action">
</a> <i class="fas fa-globe me-1"></i> {% trans "Create Remote" %}
</a>
<a href="{% url 'create_onsite_meeting' %}" class="btn btn-outline-secondary">
<i class="fas fa-building me-1"></i> {% trans "Create Onsite" %}
</a>
</div> {% endcomment %}
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@ -0,0 +1,111 @@
{% load static i18n %}
{% load widget_tweaks %}
<div class="p-3" id="reschedule-onsite-meeting-form">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-redo-alt me-1"></i>
{% trans "Update Onsite Interview" %} for **{{ candidate.name }}**
</h5>
<p class="text-muted mb-0 small">{% trans "Job" %}: {{ job.title }}</p>
<p class="text-muted mb-0 small">{% trans "Location Type" %}: <span class="badge bg-info">{% trans "Onsite" %}</span></p>
</div>
</div>
<div>
<form method="post" id="updateOnsiteMeeting"
action="{% url 'reschedule_onsite_meeting' job.slug candidate.pk meeting.pk %}">
{% csrf_token %}
<hr style="border-top: 1px solid var(--kaauh-border); margin-bottom: 1.5rem;">
{# --- STATUS FIELD (Now Visible and Selectable) --- #}
<div class="mb-3">
<label for="{{ form.status.id_for_label }}" class="form-label small">
<i class="fas fa-info-circle me-1"></i> {% trans "Meeting Status" %}
</label>
{{ form.status|add_class:"form-select" }}
{% for error in form.status.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
<div class="row">
{# --- TOPIC FIELD --- #}
<div class="col-md-7">
<div class="mb-3">
<label for="{{ form.topic.id_for_label }}" class="form-label small">
<i class="fas fa-tag me-1"></i> {% trans "Meeting Topic" %}
</label>
{{ form.topic|add_class:"form-control" }}
{% for error in form.topic.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
</div>
{# --- ROOM NUMBER FIELD --- #}
<div class="col-md-5">
<div class="mb-3">
<label for="{{ form.room_number.id_for_label }}" class="form-label small">
<i class="fas fa-door-open me-1"></i> {% trans "Room Number/Name" %}
</label>
{{ form.room_number|add_class:"form-control" }}
{% for error in form.room_number.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
</div>
</div>
{# --- ADDRESS FIELD --- #}
<div class="mb-3">
<label for="{{ form.physical_address.id_for_label }}" class="form-label small">
<i class="fas fa-map-marker-alt me-1"></i> {% trans "Physical Address" %}
</label>
{{ form.physical_address|add_class:"form-control" }}
{% for error in form.physical_address.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
<hr style="border-top: 1px solid var(--kaauh-border); margin-top: 1.5rem; margin-bottom: 1.5rem;">
<div class="row">
{# --- START TIME FIELD --- #}
<div class="col-md-7">
<div class="mb-3">
<label for="{{ form.start_time.id_for_label }}" class="form-label small">
<i class="fas fa-clock me-1"></i> {% trans "Start Time" %} (Date & Time)
</label>
{{ form.start_time|add_class:"form-control" }}
{% for error in form.start_time.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
</div>
{# --- DURATION FIELD --- #}
<div class="col-md-5">
<div class="mb-3">
<label for="{{ form.duration.id_for_label }}" class="form-label small">
<i class="fas fa-hourglass-half me-1"></i> {% trans "Duration (minutes)" %}
</label>
{{ form.duration|add_class:"form-control" }}
{% for error in form.duration.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
</div>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-save me-1"></i> {% trans "Update Meeting" %}
</button>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,98 @@
{% load static i18n %}
{% load widget_tweaks %}
<div class="p-3" id="create-onsite-meeting-form">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-calendar-plus me-1"></i>
{% trans "Schedule New Onsite Interview" %} for **{{ candidate.name }}**
</h5>
<p class="text-muted mb-0 small">{% trans "Job" %}: {{ job.title }}</p>
<p class="text-muted mb-0 small">{% trans "Location Type" %}: <span class="badge bg-info">{% trans "Onsite" %}</span></p>
</div>
</div>
<div>
{# The action_url is passed from the view and points back to the POST handler #}
<form method="post" id="createOnsiteMeetingForm" action="{{ action_url }}">
{% csrf_token %}
{# --- HIDDEN FIELDS (application, job, status) --- #}
{# These fields are crucial for creating the ScheduledInterview record #}
{{ form.application }}
{{ form.job }}
{{ form.status }}
{# --- TOPIC FIELD --- #}
<div class="mb-3">
<label for="{{ form.topic.id_for_label }}" class="form-label small">
<i class="fas fa-tag me-1"></i> {% trans "Meeting Topic" %} <span class="text-danger">*</span>
</label>
{{ form.topic|add_class:"form-control"|attr:"required" }}
{% for error in form.topic.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
{# --- ADDRESS FIELD --- #}
<div class="mb-3">
<label for="{{ form.physical_address.id_for_label }}" class="form-label small">
<i class="fas fa-map-marker-alt me-1"></i> {% trans "Physical Address" %} <span class="text-danger">*</span>
</label>
{{ form.physical_address|add_class:"form-control"|attr:"required" }}
{% for error in form.physical_address.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
{# --- ROOM NUMBER FIELD --- #}
<div class="mb-3">
<label for="{{ form.room_number.id_for_label }}" class="form-label small">
<i class="fas fa-door-open me-1"></i> {% trans "Room Number/Name" %}
</label>
{{ form.room_number|add_class:"form-control" }}
{% for error in form.room_number.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
<hr style="border-top: 1px solid var(--kaauh-border); margin-top: 1.5rem; margin-bottom: 1.5rem;">
<div class="row">
{# --- START TIME FIELD --- #}
<div class="col-md-7">
<div class="mb-3">
<label for="{{ form.start_time.id_for_label }}" class="form-label small">
<i class="fas fa-clock me-1"></i> {% trans "Start Time" %} (Date & Time) <span class="text-danger">*</span>
</label>
{# Assumes start_time widget is DateTimeInput with type='datetime-local' #}
{{ form.start_time|add_class:"form-control"|attr:"required" }}
{% for error in form.start_time.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
</div>
{# --- DURATION FIELD --- #}
<div class="col-md-5">
<div class="mb-3">
<label for="{{ form.duration.id_for_label }}" class="form-label small">
<i class="fas fa-hourglass-half me-1"></i> {% trans "Duration (minutes)" %} <span class="text-danger">*</span>
</label>
{{ form.duration|add_class:"form-control"|attr:"required" }}
{% for error in form.duration.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
</div>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-save me-1"></i> {% trans "Schedule Meeting" %}
</button>
</div>
</form>
</div>
</div>

View File

@ -304,8 +304,8 @@
</div> </div>
</td> </td>
<td class="candidate-details text-muted"> <td class="candidate-details text-muted">
{% if candidate.get_latest_meeting.topic %} {% if candidate.get_latest_meeting %}
{{ candidate.get_latest_meeting.topic }} {{ candidate.get_latest_meeting }}
{% else %} {% else %}
-- --
{% endif %} {% endif %}
@ -381,7 +381,9 @@
</td> </td>
<td> <td>
{% if candidate.get_latest_meeting %} {% if candidate.get_latest_meeting %}
{% if candidate.get_latest_meeting.location_type == 'Remote'%}
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#candidateviewModal" data-bs-target="#candidateviewModal"
@ -399,16 +401,47 @@
title="Delete Meeting"> title="Delete Meeting">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
{% else%}
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'reschedule_onsite_meeting' job.slug candidate.pk candidate.get_latest_meeting.pk %}"
hx-target="#candidateviewModalBody"
title="Reschedule">
<i class="fas fa-redo-alt"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'delete_onsite_meeting_for_candidate' job.slug candidate.pk candidate.get_latest_meeting.pk %}"
hx-target="#candidateviewModalBody"
title="Delete Meeting">
<i class="fas fa-trash"></i>
</button>
{% endif %}
{% else %} {% else %}
<button type="button" class="btn btn-main-action btn-sm" <button type="button" class="btn btn-main-action btn-sm"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#candidateviewModal" data-bs-target="#candidateviewModal"
hx-get="{% url 'schedule_meeting_for_candidate' job.slug candidate.pk %}" hx-get="{% url 'schedule_meeting_for_candidate' job.slug candidate.pk %}"
hx-target="#candidateviewModalBody" hx-target="#candidateviewModalBody"
data-modal-title="{% trans 'Schedule Interview' %}" data-modal-title="{% trans 'Schedule Interview' %}"
title="Schedule Interview"> title="Schedule Interview">
<i class="fas fa-calendar-plus"></i> <i class="fas fa-video"></i>
</button>
<button type="button" class="btn btn-main-action btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
{# UPDATED: Points to the specific Onsite scheduling URL #}
hx-get="{% url 'schedule_onsite_meeting_for_candidate' job.slug candidate.pk %}"
hx-target="#candidateviewModalBody"
data-modal-title="{% trans 'Schedule Onsite Interview' %}"
title="Schedule Onsite Interview">
<i class="fas fa-building"></i>
</button> </button>
{% endif %} {% endif %}