changes to interview
This commit is contained in:
parent
0213bd6e11
commit
06436a3b9e
6
.env
6
.env
@ -1,3 +1,3 @@
|
||||
DB_NAME=norahuniversity
|
||||
DB_USER=norahuniversity
|
||||
DB_PASSWORD=norahuniversity
|
||||
DB_NAME=haikal_db
|
||||
DB_USER=faheed
|
||||
DB_PASSWORD=Faheed@215
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -144,6 +144,9 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
# DATABASES = {
|
||||
# 'default': {
|
||||
# 'ENGINE': 'django.db.backends.sqlite3',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -3,9 +3,9 @@ from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from .models import (
|
||||
JobPosting, Application, TrainingMaterial, ZoomMeeting,
|
||||
JobPosting, Application, TrainingMaterial, ZoomMeetingDetails,
|
||||
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
|
||||
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment,
|
||||
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,InterviewNote,
|
||||
AgencyAccessLink, AgencyJobAssignment
|
||||
)
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -158,7 +158,7 @@ class TrainingMaterialAdmin(admin.ModelAdmin):
|
||||
save_on_top = True
|
||||
|
||||
|
||||
@admin.register(ZoomMeeting)
|
||||
@admin.register(ZoomMeetingDetails)
|
||||
class ZoomMeetingAdmin(admin.ModelAdmin):
|
||||
list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
|
||||
list_filter = ['timezone', 'created_at']
|
||||
@ -181,24 +181,24 @@ class ZoomMeetingAdmin(admin.ModelAdmin):
|
||||
save_on_top = True
|
||||
|
||||
|
||||
@admin.register(MeetingComment)
|
||||
class MeetingCommentAdmin(admin.ModelAdmin):
|
||||
list_display = ['meeting', 'author', 'created_at', 'updated_at']
|
||||
list_filter = ['created_at', 'author', 'meeting']
|
||||
search_fields = ['content', 'meeting__topic', 'author__username']
|
||||
readonly_fields = ['created_at', 'updated_at', 'slug']
|
||||
fieldsets = (
|
||||
('Meeting Information', {
|
||||
'fields': ('meeting', 'author')
|
||||
}),
|
||||
('Comment Content', {
|
||||
'fields': ('content',)
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at', 'slug')
|
||||
}),
|
||||
)
|
||||
save_on_top = True
|
||||
# @admin.register(InterviewNote)
|
||||
# class MeetingCommentAdmin(admin.ModelAdmin):
|
||||
# list_display = ['meeting', 'author', 'created_at', 'updated_at']
|
||||
# list_filter = ['created_at', 'author', 'meeting']
|
||||
# search_fields = ['content', 'meeting__topic', 'author__username']
|
||||
# readonly_fields = ['created_at', 'updated_at', 'slug']
|
||||
# fieldsets = (
|
||||
# ('Meeting Information', {
|
||||
# 'fields': ('meeting', 'author')
|
||||
# }),
|
||||
# ('Comment Content', {
|
||||
# 'fields': ('content',)
|
||||
# }),
|
||||
# ('Timestamps', {
|
||||
# 'fields': ('created_at', 'updated_at', 'slug')
|
||||
# }),
|
||||
# )
|
||||
# save_on_top = True
|
||||
|
||||
|
||||
@admin.register(FormTemplate)
|
||||
|
||||
@ -149,6 +149,7 @@ def candidate_user_required(view_func):
|
||||
|
||||
|
||||
def staff_user_required(view_func):
|
||||
|
||||
"""Decorator to restrict view to staff users only."""
|
||||
return user_type_required(['staff'])(view_func)
|
||||
|
||||
|
||||
@ -224,11 +224,8 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi
|
||||
logger.error(error_msg, exc_info=True)
|
||||
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
|
||||
import logging
|
||||
from django.conf import settings
|
||||
@ -262,15 +259,16 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
||||
email = email.strip().lower()
|
||||
|
||||
try:
|
||||
candidate = get_object_or_404(Candidate, email=email)
|
||||
candidate = get_object_or_404(Application, person__email=email)
|
||||
except Exception:
|
||||
logger.warning(f"Candidate not found for email: {email}")
|
||||
continue
|
||||
|
||||
candidate_name = candidate.first_name
|
||||
candidate_name = candidate.person.full_name
|
||||
|
||||
|
||||
# --- 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_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:
|
||||
# Send Emails - Pure Candidates
|
||||
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
|
||||
send_individual_email(email, candidate_message)
|
||||
|
||||
@ -403,7 +401,7 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
||||
i = 0
|
||||
for email in agency_emails:
|
||||
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
|
||||
send_individual_email(email, agency_message)
|
||||
i += 1
|
||||
|
||||
@ -10,7 +10,7 @@ from django.contrib.auth.forms import UserCreationForm
|
||||
User = get_user_model()
|
||||
import re
|
||||
from .models import (
|
||||
ZoomMeeting,
|
||||
ZoomMeetingDetails,
|
||||
Application,
|
||||
TrainingMaterial,
|
||||
JobPosting,
|
||||
@ -19,7 +19,7 @@ from .models import (
|
||||
BreakTime,
|
||||
JobPostingImage,
|
||||
Profile,
|
||||
MeetingComment,
|
||||
InterviewNote,
|
||||
ScheduledInterview,
|
||||
Source,
|
||||
HiringAgency,
|
||||
@ -27,7 +27,7 @@ from .models import (
|
||||
AgencyAccessLink,
|
||||
Participants,
|
||||
Message,
|
||||
Person,OnsiteMeeting
|
||||
Person,OnsiteLocationDetails
|
||||
)
|
||||
|
||||
# from django_summernote.widgets import SummernoteWidget
|
||||
@ -336,7 +336,7 @@ class ApplicationForm(forms.ModelForm):
|
||||
# person.first_name = self.cleaned_data['first_name']
|
||||
# person.last_name = self.cleaned_data['last_name']
|
||||
# person.email = self.cleaned_data['email']
|
||||
# person.phone = self.cleaned_data['phone']
|
||||
# person.phZoomone = self.cleaned_data['phone']
|
||||
|
||||
# if commit:
|
||||
# person.save()
|
||||
@ -359,10 +359,39 @@ class ApplicationStageForm(forms.ModelForm):
|
||||
"stage": forms.Select(attrs={"class": "form-select"}),
|
||||
}
|
||||
|
||||
|
||||
class ZoomMeetingForm(forms.ModelForm):
|
||||
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"]
|
||||
labels = {
|
||||
"topic": _("Topic"),
|
||||
@ -687,6 +716,7 @@ class InterviewScheduleForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = InterviewSchedule
|
||||
fields = [
|
||||
'schedule_interview_type',
|
||||
"applications",
|
||||
"start_date",
|
||||
"end_date",
|
||||
@ -697,10 +727,8 @@ class InterviewScheduleForm(forms.ModelForm):
|
||||
"buffer_time",
|
||||
"break_start_time",
|
||||
"break_end_time",
|
||||
"interview_type"
|
||||
]
|
||||
widgets = {
|
||||
'interview_type': forms.Select(attrs={'class': 'form-control'}),
|
||||
"start_date": forms.DateInput(
|
||||
attrs={"type": "date", "class": "form-control"}
|
||||
),
|
||||
@ -721,6 +749,7 @@ class InterviewScheduleForm(forms.ModelForm):
|
||||
"break_end_time": forms.TimeInput(
|
||||
attrs={"type": "time", "class": "form-control"}
|
||||
),
|
||||
"schedule_interview_type":forms.RadioSelect()
|
||||
}
|
||||
|
||||
def __init__(self, slug, *args, **kwargs):
|
||||
@ -734,11 +763,11 @@ class InterviewScheduleForm(forms.ModelForm):
|
||||
return [int(day) for day in working_days]
|
||||
|
||||
|
||||
class MeetingCommentForm(forms.ModelForm):
|
||||
class InterviewNoteForm(forms.ModelForm):
|
||||
"""Form for creating and editing meeting comments"""
|
||||
|
||||
class Meta:
|
||||
model = MeetingComment
|
||||
model = InterviewNote
|
||||
fields = ["content"]
|
||||
widgets = {
|
||||
"content": CKEditor5Widget(
|
||||
@ -1491,6 +1520,7 @@ class ParticipantsSelectForm(forms.ModelForm):
|
||||
fields = ["participants", "users"] # No direct fields from Participants model
|
||||
|
||||
|
||||
|
||||
class CandidateEmailForm(forms.Form):
|
||||
"""Form for composing emails to participants about a candidate"""
|
||||
to = forms.MultipleChoiceField(
|
||||
@ -1500,51 +1530,32 @@ class CandidateEmailForm(forms.Form):
|
||||
label=_('Select Candidates'), # Use a descriptive label
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
subject = forms.CharField(
|
||||
max_length=200,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"placeholder": "Enter email subject",
|
||||
"required": True,
|
||||
}
|
||||
),
|
||||
label=_("Subject"),
|
||||
required=True,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter email subject',
|
||||
'required': True
|
||||
}),
|
||||
label=_('Subject'),
|
||||
required=True
|
||||
)
|
||||
|
||||
message = forms.CharField(
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"class": "form-control",
|
||||
"rows": 8,
|
||||
"placeholder": "Enter your message here...",
|
||||
"required": True,
|
||||
}
|
||||
),
|
||||
label=_("Message"),
|
||||
required=True,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 8,
|
||||
'placeholder': 'Enter your message here...',
|
||||
'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):
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -1565,8 +1576,9 @@ class CandidateEmailForm(forms.Form):
|
||||
|
||||
# Set initial message with candidate and meeting info
|
||||
initial_message = self._get_initial_message()
|
||||
|
||||
if initial_message:
|
||||
self.fields["message"].initial = initial_message
|
||||
self.fields['message'].initial = initial_message
|
||||
|
||||
def _get_initial_message(self):
|
||||
"""Generate initial message with candidate and meeting information"""
|
||||
@ -1623,6 +1635,16 @@ class CandidateEmailForm(forms.Form):
|
||||
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
|
||||
# latest_meeting = self.candidate.get_latest_meeting
|
||||
# if latest_meeting:
|
||||
@ -1633,33 +1655,24 @@ class CandidateEmailForm(forms.Form):
|
||||
# if 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):
|
||||
"""Extract email addresses from selected recipients"""
|
||||
email_addresses = []
|
||||
recipients = self.cleaned_data.get('recipients', [])
|
||||
for recipient in recipients:
|
||||
if recipient.startswith('participant_'):
|
||||
participant_id = recipient.split('_')[1]
|
||||
|
||||
candidates=self.cleaned_data.get('to',[])
|
||||
|
||||
if candidates:
|
||||
for candidate in candidates:
|
||||
if candidate.startswith('candidate_'):
|
||||
print("candidadte: {candidate}")
|
||||
candidate_id = candidate.split('_')[1]
|
||||
try:
|
||||
participant = Participants.objects.get(id=participant_id)
|
||||
email_addresses.append(participant.email)
|
||||
except Participants.DoesNotExist:
|
||||
continue
|
||||
elif recipient.startswith('user_'):
|
||||
user_id = recipient.split('_')[1]
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
email_addresses.append(user.email)
|
||||
except User.DoesNotExist:
|
||||
candidate = Application.objects.get(id=candidate_id)
|
||||
email_addresses.append(candidate.email)
|
||||
except Application.DoesNotExist:
|
||||
continue
|
||||
|
||||
|
||||
@ -1667,9 +1680,8 @@ class CandidateEmailForm(forms.Form):
|
||||
|
||||
|
||||
def get_formatted_message(self):
|
||||
"""Get formatted message with optional additional information"""
|
||||
message = self.cleaned_data.get("message", "")
|
||||
|
||||
"""Get the formatted message with optional additional information"""
|
||||
message = self.cleaned_data.get('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
|
||||
)
|
||||
# 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
|
||||
)
|
||||
# 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):
|
||||
# ... (Field definitions)
|
||||
|
||||
def __init__(self, *args, candidate, external_participants, system_participants, meeting, job, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
location = meeting.interview_location
|
||||
|
||||
# --- 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
|
||||
|
||||
# Safely access details through the related InterviewLocation object
|
||||
if location and location.start_time:
|
||||
formatted_date = location.start_time.strftime('%Y-%m-%d')
|
||||
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
|
||||
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) ---
|
||||
# --- 1. Candidate Message (Use meeting_link) ---
|
||||
candidate_message = f"""
|
||||
Dear {candidate.full_name},
|
||||
|
||||
@ -1766,7 +1891,7 @@ The details of your virtual interview are as follows:
|
||||
- **Date:** {formatted_date}
|
||||
- **Time:** {formatted_time} (RIYADH TIME)
|
||||
- **Duration:** {duration}
|
||||
- **Meeting Link:** {zoom_link}
|
||||
- **Meeting Link:** {meeting_link}
|
||||
|
||||
Please click the link at the scheduled time to join the interview.
|
||||
|
||||
@ -1777,37 +1902,25 @@ We look forward to meeting you.
|
||||
Best regards,
|
||||
KAAUH Hiring Team
|
||||
"""
|
||||
|
||||
# ... (Messages for agency and participants remain the same, using the updated safe variables)
|
||||
|
||||
# --- 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
|
||||
- **Meeting Link:** {meeting_link}
|
||||
...
|
||||
"""
|
||||
|
||||
# --- 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}
|
||||
@ -1819,25 +1932,16 @@ This is a reminder of the upcoming interview you are scheduled to participate in
|
||||
**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.
|
||||
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.
|
||||
|
||||
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 OnsiteLocationForm(forms.ModelForm):
|
||||
# class Meta:
|
||||
# model=
|
||||
@ -1846,26 +1950,91 @@ KAAUH HIRING TEAM
|
||||
# 'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}),
|
||||
# }
|
||||
|
||||
|
||||
|
||||
#during bulk schedule
|
||||
class OnsiteMeetingForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = OnsiteMeeting
|
||||
fields = ['topic', 'start_time', 'duration', 'timezone', 'location', 'status']
|
||||
model = OnsiteLocationDetails
|
||||
# Include 'room_number' and update the field list
|
||||
fields = ['topic', 'physical_address', 'room_number']
|
||||
widgets = {
|
||||
'topic': forms.TextInput(attrs={'placeholder': 'Enter the Meeting Topic', 'class': 'form-control'}),
|
||||
'start_time': forms.DateTimeInput(
|
||||
attrs={'type': 'datetime-local', 'class': 'form-control'}
|
||||
'topic': forms.TextInput(
|
||||
attrs={'placeholder': 'Enter the Meeting Topic', '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'}),
|
||||
'status': forms.Select(attrs={'class': 'form-control'}),
|
||||
|
||||
'room_number': forms.TextInput(
|
||||
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):
|
||||
"""Form for creating and editing messages between users"""
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
from .models import Candidate
|
||||
from .models import Application
|
||||
from time import sleep
|
||||
|
||||
def callback_ai_parsing(task):
|
||||
if task.success:
|
||||
try:
|
||||
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:
|
||||
sleep(30)
|
||||
c.retry -= 1
|
||||
|
||||
@ -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.validators
|
||||
@ -49,21 +49,20 @@ class Migration(migrations.Migration):
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OnsiteMeeting',
|
||||
name='InterviewLocation',
|
||||
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')),
|
||||
('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')),
|
||||
('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')),
|
||||
('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')),
|
||||
('details_url', models.URLField(blank=True, max_length=2048, null=True, verbose_name='Meeting/Location URL')),
|
||||
('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(default='UTC', max_length=50, verbose_name='Timezone')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
'verbose_name': 'Interview Location',
|
||||
'verbose_name_plural': 'Interview Locations',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@ -112,31 +111,6 @@ class Migration(migrations.Migration):
|
||||
'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(
|
||||
name='CustomUser',
|
||||
fields=[
|
||||
@ -287,6 +261,43 @@ class Migration(migrations.Migration):
|
||||
'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(
|
||||
name='JobPosting',
|
||||
fields=[
|
||||
@ -343,7 +354,7 @@ class Migration(migrations.Migration):
|
||||
('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_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')),
|
||||
('end_date', models.DateField(db_index=True, verbose_name='End Date')),
|
||||
('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')),
|
||||
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (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)),
|
||||
('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')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='formtemplate',
|
||||
@ -423,6 +438,27 @@ class Migration(migrations.Migration):
|
||||
'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(
|
||||
name='Person',
|
||||
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)),
|
||||
],
|
||||
),
|
||||
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(
|
||||
name='SharedFormTemplate',
|
||||
fields=[
|
||||
@ -523,63 +595,6 @@ class Migration(migrations.Migration):
|
||||
'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(
|
||||
name='AgencyAccessLink',
|
||||
fields=[
|
||||
@ -645,18 +660,6 @@ class Migration(migrations.Migration):
|
||||
model_name='formsubmission',
|
||||
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(
|
||||
model_name='formtemplate',
|
||||
index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'),
|
||||
@ -701,6 +704,14 @@ class Migration(migrations.Migration):
|
||||
model_name='message',
|
||||
index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='person',
|
||||
index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'),
|
||||
@ -733,14 +744,6 @@ class Migration(migrations.Migration):
|
||||
name='application',
|
||||
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(
|
||||
model_name='scheduledinterview',
|
||||
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'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'),
|
||||
model_name='jobposting',
|
||||
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'),
|
||||
model_name='jobposting',
|
||||
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
|
||||
),
|
||||
]
|
||||
|
||||
18
recruitment/migrations/0002_zoommeetingdetails_host_email.py
Normal file
18
recruitment/migrations/0002_zoommeetingdetails_host_email.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -547,6 +547,12 @@ class Person(Base):
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
content_type = ContentType.objects.get_for_model(self.__class__)
|
||||
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):
|
||||
@ -890,14 +896,58 @@ class Application(Base):
|
||||
"""Legacy compatibility - get scheduled interviews for this application"""
|
||||
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
|
||||
def get_latest_meeting(self):
|
||||
"""Legacy compatibility - get latest meeting for this application"""
|
||||
"""
|
||||
Retrieves the most specific location details (subclass instance)
|
||||
of the latest ScheduledInterview for this application, or None.
|
||||
"""
|
||||
# 1. Get the latest ScheduledInterview
|
||||
schedule = self.scheduled_interviews.order_by("-created_at").first()
|
||||
if schedule:
|
||||
return schedule.zoom_meeting
|
||||
|
||||
# Check if a schedule exists and if it has an interview location
|
||||
if not schedule or not schedule.interview_location:
|
||||
return None
|
||||
|
||||
# Get the base location instance
|
||||
interview_location = schedule.interview_location
|
||||
|
||||
# 2. Safely retrieve the specific subclass details
|
||||
|
||||
# Determine the expected subclass accessor name based on the location_type
|
||||
if interview_location.location_type == 'Remote':
|
||||
accessor_name = 'zoommeetingdetails'
|
||||
else: # Assumes 'Onsite' or any other type defaults to Onsite
|
||||
accessor_name = 'onsitelocationdetails'
|
||||
|
||||
# Use getattr to safely retrieve the specific meeting object (subclass instance).
|
||||
# If the accessor exists but points to None (because the subclass record was deleted),
|
||||
# or if the accessor name is wrong for the object's true type, it will return None.
|
||||
meeting_details = getattr(interview_location, accessor_name, None)
|
||||
|
||||
return meeting_details
|
||||
|
||||
|
||||
@property
|
||||
def has_future_meeting(self):
|
||||
"""Legacy compatibility - check for future meetings"""
|
||||
@ -966,129 +1016,6 @@ class TrainingMaterial(Base):
|
||||
def __str__(self):
|
||||
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):
|
||||
@ -1894,136 +1821,6 @@ class BreakTime(models.Model):
|
||||
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):
|
||||
@ -2061,13 +1858,13 @@ class Notification(models.Model):
|
||||
default=Status.PENDING,
|
||||
verbose_name=_("Status"),
|
||||
)
|
||||
related_meeting = models.ForeignKey(
|
||||
ZoomMeeting,
|
||||
inteview= models.ForeignKey(
|
||||
'InterviewSchedule',
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notifications",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Related Meeting"),
|
||||
verbose_name=_("Related Interview"),
|
||||
)
|
||||
scheduled_for = models.DateTimeField(
|
||||
verbose_name=_("Scheduled Send Time"),
|
||||
@ -2234,7 +2031,7 @@ class Message(Base):
|
||||
|
||||
# If job-related, ensure candidate applied for the 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."))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@ -2332,3 +2129,323 @@ class Document(Base):
|
||||
if self.file:
|
||||
return self.file.name.split('.')[-1].upper()
|
||||
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}"
|
||||
36
recruitment/score_utils.py
Normal file
36
recruitment/score_utils.py
Normal 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))
|
||||
@ -12,7 +12,7 @@ from . linkedin_service import LinkedInService
|
||||
from django.shortcuts import get_object_or_404
|
||||
from . models import JobPosting
|
||||
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
|
||||
try:
|
||||
@ -26,7 +26,7 @@ except ImportError:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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'
|
||||
@ -440,7 +440,7 @@ def handle_reume_parsing_and_scoring(pk):
|
||||
print(f"Successfully scored and saved analysis for candidate {instance.id}")
|
||||
|
||||
|
||||
|
||||
from django.utils import timezone
|
||||
def create_interview_and_meeting(
|
||||
candidate_id,
|
||||
job_id,
|
||||
@ -457,7 +457,7 @@ def create_interview_and_meeting(
|
||||
job = JobPosting.objects.get(pk=job_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}"
|
||||
|
||||
# 1. External API Call (Slow)
|
||||
@ -466,24 +466,26 @@ def create_interview_and_meeting(
|
||||
|
||||
if result["status"] == "success":
|
||||
# 2. Database Writes (Slow)
|
||||
zoom_meeting = ZoomMeeting.objects.create(
|
||||
zoom_meeting = ZoomMeetingDetails.objects.create(
|
||||
topic=meeting_topic,
|
||||
start_time=interview_datetime,
|
||||
duration=duration,
|
||||
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"],
|
||||
host_email=result["meeting_details"]["host_email"],
|
||||
password=result["meeting_details"]["password"]
|
||||
password=result["meeting_details"]["password"],
|
||||
location_type="Remote"
|
||||
)
|
||||
ScheduledInterview.objects.create(
|
||||
application=Application,
|
||||
application=candidate,
|
||||
job=job,
|
||||
zoom_meeting=zoom_meeting,
|
||||
interview_location=zoom_meeting,
|
||||
schedule=schedule,
|
||||
interview_date=slot_date,
|
||||
interview_time=slot_time
|
||||
)
|
||||
|
||||
# Log success or use Django-Q result system for monitoring
|
||||
logger.info(f"Successfully scheduled interview for {Application.name}")
|
||||
return True # Task succeeded
|
||||
@ -517,7 +519,7 @@ def handle_zoom_webhook_event(payload):
|
||||
try:
|
||||
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
|
||||
# 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)
|
||||
# --- 1. Creation and Update Events ---
|
||||
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
|
||||
"""
|
||||
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}")
|
||||
|
||||
|
||||
@ -575,7 +575,7 @@ urlpatterns = [
|
||||
),
|
||||
# Email composition URLs
|
||||
path(
|
||||
"jobs/<slug:job_slug>/candidates/<slug:candidate_slug>/compose-email/",
|
||||
"jobs/<slug:job_slug>/candidates/compose-email/",
|
||||
views.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>/download/", views.document_download, name="document_download"),
|
||||
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/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
|
||||
|
||||
|
||||
|
||||
# # --- 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>/update/', views.ScheduledInterviewUpdateView.as_view(), name='update_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"),
|
||||
]
|
||||
|
||||
@ -594,7 +594,7 @@ def update_meeting(instance, updated_data):
|
||||
instance.topic = zoom_details.get("topic", instance.topic)
|
||||
|
||||
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)
|
||||
# Corrected status assignment: instance.status, not instance.password
|
||||
instance.status = zoom_details.get("status")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ from django.db.models.fields.json import KeyTextTransform
|
||||
from recruitment.utils import json_to_markdown_table
|
||||
from django.db.models import Count, Avg, F, FloatField
|
||||
from django.db.models.functions import Cast
|
||||
from django.db.models.functions import Coalesce, Cast, Replace, NullIf
|
||||
from . import models
|
||||
from django.utils.translation import get_language
|
||||
from . import forms
|
||||
@ -22,7 +23,7 @@ from django.views.generic import ListView, CreateView, UpdateView, DeleteView, D
|
||||
# JobForm removed - using JobPostingForm instead
|
||||
from django.urls import reverse_lazy
|
||||
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.contrib.auth.decorators import login_required
|
||||
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)
|
||||
total_active_jobs = job_scope_queryset.filter(status="ACTIVE").count()
|
||||
last_week = timezone.now() - timedelta(days=7)
|
||||
|
||||
@ -119,10 +119,10 @@ def create_zoom_meeting(topic, start_time, duration, host_email):
|
||||
|
||||
# Step 11: Analytics Dashboard (recruitment/dashboard.py)
|
||||
import pandas as pd
|
||||
from .models import Candidate
|
||||
from .models import Application
|
||||
|
||||
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()
|
||||
return summary
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{interviews}}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<h3 class="text-center font-weight-light my-4">Set Interview Location</h3>
|
||||
</div>
|
||||
<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 %}
|
||||
|
||||
{# Renders the single 'location' field using the crispy filter #}
|
||||
@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load static crispy_forms_tags %}
|
||||
{%load i18n %}
|
||||
|
||||
{% block customCSS %}
|
||||
@ -119,7 +119,7 @@
|
||||
{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</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>
|
||||
|
||||
@ -163,14 +163,18 @@
|
||||
<tr>
|
||||
<td>{{ item.date|date:"F j, Y" }}</td>
|
||||
<td>{{ item.time|time:"g:i A" }}</td>
|
||||
<td>{{ item.applications.name }}</td>
|
||||
<td>{{ item.applications.email }}</td>
|
||||
<td>{{ item.application.name }}</td>
|
||||
<td>{{ item.application.email }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if schedule_interview_type == "Onsite" %}
|
||||
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4" data-bs-toggle="modal" data-bs-target="#interviewDetailsModal" data-placement="top">
|
||||
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
|
||||
</button>
|
||||
{% 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">
|
||||
@ -180,6 +184,36 @@
|
||||
<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>
|
||||
@ -200,13 +234,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
events: [
|
||||
{% 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" }}',
|
||||
url: '#',
|
||||
// Use the theme color for candidate events
|
||||
color: 'var(--kaauh-teal-dark)',
|
||||
extendedProps: {
|
||||
email: '{{ item.candidate.email }}',
|
||||
email: '{{ item.application.email }}',
|
||||
time: '{{ item.time|time:"g:i A" }}'
|
||||
}
|
||||
},
|
||||
|
||||
@ -142,8 +142,8 @@
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="form-group mb-3">
|
||||
<label for="{{ form.start_date.id_for_label }}">{% trans "Interview Type" %}</label>
|
||||
{{ form.interview_type }}
|
||||
<label for="{{ form.schedule_interview_type.id_for_label }}">{% trans "Interview Type" %}</label>
|
||||
{{ form.schedule_interview_type }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
136
templates/meetings/create_remote_meeting.html
Normal file
136
templates/meetings/create_remote_meeting.html
Normal 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 %}
|
||||
0
templates/meetings/delete_onsite_meeting.html
Normal file
0
templates/meetings/delete_onsite_meeting.html
Normal file
@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{% trans "Zoom Meetings" %} - {{ block.super }}{% endblock %}
|
||||
{% block title %}{% trans "Interviews & Meetings" %} - {{ block.super }}{% endblock %}
|
||||
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
@ -12,9 +12,12 @@
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
--kaauh-gray-light: #f8f9fa;
|
||||
--kaauh-warning: #ffc107;
|
||||
--kaauh-danger: #dc3545;
|
||||
--kaauh-success: #28a745;
|
||||
}
|
||||
|
||||
/* Enhanced Card Styling (Consistent) */
|
||||
/* Enhanced Card Styling */
|
||||
.card {
|
||||
border: 1px solid var(--kaauh-border);
|
||||
border-radius: 0.75rem;
|
||||
@ -26,12 +29,6 @@
|
||||
transform: translateY(-2px);
|
||||
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 {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
@ -42,14 +39,11 @@
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-main-action:hover {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* Secondary Button Style (For Edit/Outline - Consistent) */
|
||||
.btn-outline-secondary {
|
||||
color: var(--kaauh-teal-dark);
|
||||
border-color: var(--kaauh-teal);
|
||||
@ -59,7 +53,6 @@
|
||||
color: white;
|
||||
border-color: var(--kaauh-teal-dark);
|
||||
}
|
||||
/* Primary Outline for View/Join */
|
||||
.btn-outline-primary {
|
||||
color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
@ -68,8 +61,6 @@
|
||||
background-color: var(--kaauh-teal);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Meeting Card Specifics (Adapted to Standard Card View) */
|
||||
.meeting-card .card-title {
|
||||
color: var(--kaauh-teal-dark);
|
||||
font-weight: 600;
|
||||
@ -79,14 +70,7 @@
|
||||
color: var(--kaauh-teal);
|
||||
width: 1.25rem;
|
||||
}
|
||||
/* Primary Color Overrides */
|
||||
.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 {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4em 0.8em;
|
||||
@ -95,13 +79,16 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.7px;
|
||||
}
|
||||
/* Status Badge Mapping */
|
||||
.bg-waiting { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;}
|
||||
.bg-scheduled { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;}
|
||||
/* Statuses */
|
||||
.bg-waiting { background-color: var(--kaauh-warning) !important; color: var(--kaauh-primary-text) !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 {
|
||||
background-color: var(--kaauh-teal-dark);
|
||||
color: white;
|
||||
@ -120,51 +107,11 @@
|
||||
.table-view .table tbody tr:hover {
|
||||
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 {
|
||||
background-color: var(--kaauh-teal);
|
||||
border-color: var(--kaauh-teal);
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@ -172,48 +119,66 @@
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<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>
|
||||
<a href="{% url 'create_meeting' %}" class="btn btn-main-action">
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Create Meeting" %}
|
||||
{% comment %} <div class="btn-group" role="group">
|
||||
<a href="{% url 'create_remote_meeting' %}" class="btn btn-main-action">
|
||||
<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 class="card mb-4 shadow-sm no-hover">
|
||||
<div class="card-body">
|
||||
<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>
|
||||
<div class="input-group input-group-lg mb-3">
|
||||
<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 %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-8">
|
||||
<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 %}
|
||||
{% if status_filter %}<input class="form-control form-control-sm" type="hidden" name="status" value="{{ status_filter }}">{% endif %}
|
||||
{# Hidden inputs to persist other filters #}
|
||||
{% 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>
|
||||
<select name="status" id="status" class="form-select form-select-sm">
|
||||
<option value="">{% trans "All Statuses" %}</option>
|
||||
<option value="waiting" {% if status_filter == 'waiting' %}selected{% endif %}>{% trans "Waiting" %}</option>
|
||||
<option value="started" {% if status_filter == 'started' %}selected{% endif %}>{% trans "Started" %}</option>
|
||||
<option value="ended" {% if status_filter == 'ended' %}selected{% endif %}>{% trans "Ended" %}</option>
|
||||
{# CORRECTED: Using the context variable passed from the view #}
|
||||
{% for choice, display in status_choices %}
|
||||
<option value="{{ choice }}" {% if status_filter == choice %}selected{% endif %}>{{ display }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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>
|
||||
<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 class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<div class="filter-buttons">
|
||||
<button type="submit" class="btn btn-main-action btn-sm">
|
||||
<i class="fas fa-filter me-1"></i> {% trans "Apply Filters" %}
|
||||
</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">
|
||||
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
|
||||
</a>
|
||||
@ -225,59 +190,63 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if meetings %}
|
||||
|
||||
{% if meetings_data %}
|
||||
<div id="meetings-list">
|
||||
{# View Switcher #}
|
||||
{# View Switcher (not provided, assuming standard include) #}
|
||||
{% include "includes/_list_view_switcher.html" with list_id="meetings-list" %}
|
||||
|
||||
{# Card View #}
|
||||
<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="card meeting-card h-100 shadow-sm">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<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>
|
||||
<span class="status-badge bg-{{ meeting.status }}">
|
||||
{{ meeting.status|title }}
|
||||
<h5 class="card-title flex-grow-1 me-3"><a href="" class="text-decoration-none text-primary-theme">{{ meeting.topic }}</a></h5>
|
||||
{# Display the type badge (Remote/Onsite) #}
|
||||
<span class="status-badge bg-{{ meeting.type }}">
|
||||
{{ meeting.type|title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
<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 text-primary-theme btn-link btn-sm">Set Candidate</button>
|
||||
{% endif %}<br>
|
||||
<i class="fas fa-briefcase"></i> {% trans "Job" %}: {% if meeting.interview %}{{ meeting.interview.job.title }}{% 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 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-user"></i> {% trans "Candidate" %}: {{ meeting.interview.application.person.full_name|default:"N/A" }}<br>
|
||||
<i class="fas fa-briefcase"></i> {% trans "Job" %}: {{ meeting.interview.job.title|default:"N/A" }}<br>
|
||||
|
||||
{# Dynamic location/type details #}
|
||||
{% if meeting.type == 'Remote' %}
|
||||
<i class="fas fa-link"></i> {% trans "Remote ID" %}: {{ meeting.meeting_id|default:meeting.location.id }}<br>
|
||||
{% elif meeting.type == 'Onsite' %}
|
||||
{# Use the details object for concrete location info #}
|
||||
<i class="fas fa-map-marker-alt"></i> {% trans "Location" %}: {{ meeting.details.room_number|default:meeting.details.physical_address|truncatechars:30 }}<br>
|
||||
{% endif %}
|
||||
<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>
|
||||
|
||||
<span class="status-badge bg-{{ meeting.status }}">
|
||||
{{ meeting.interview.get_status_display }}
|
||||
</span>
|
||||
|
||||
<div class="mt-auto pt-2 border-top">
|
||||
<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" %}
|
||||
</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">
|
||||
<i class="fas fa-link"></i> {% trans "Join" %}
|
||||
<i class="fas fa-sign-in-alt"></i> {% trans "Join Remote" %}
|
||||
</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 %}
|
||||
|
||||
<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>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}"
|
||||
@ -303,9 +272,9 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Topic" %}</th>
|
||||
<th scope="col">{% trans "Type" %}</th>
|
||||
<th scope="col">{% trans "Candidate" %}</th>
|
||||
<th scope="col">{% trans "Job" %}</th>
|
||||
<th scope="col">{% trans "ID" %}</th>
|
||||
<th scope="col">{% trans "Start Time" %}</th>
|
||||
<th scope="col">{% trans "Duration" %}</th>
|
||||
<th scope="col">{% trans "Status" %}</th>
|
||||
@ -313,66 +282,46 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for meeting in meetings %}
|
||||
{% for meeting in meetings_data %}
|
||||
<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>
|
||||
{% if meeting.interview %}
|
||||
<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>
|
||||
{% 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 Candidate</button>
|
||||
{% endif %}
|
||||
{# Display the event type badge #}
|
||||
<span class="status-badge bg-{{ meeting.type }}">{{ meeting.type|title }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
</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>
|
||||
{% 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>{{ meeting.meeting_id|default:meeting.id }}</td>
|
||||
<td>{{ meeting.start_time|date:"M d, Y H:i" }}</td>
|
||||
<td>{{ meeting.duration }} min</td>
|
||||
<td>
|
||||
{% if meeting %}
|
||||
<span class="badge {% if meeting.status == 'waiting' %}bg-warning{% elif meeting.status == 'started' %}bg-success{% elif meeting.status == 'ended' %}bg-danger{% endif %}">
|
||||
{% if meeting.status == 'started' %}
|
||||
<i class="fas fa-circle me-1 text-success"></i>
|
||||
{% endif %}
|
||||
{{ meeting.status|title }}
|
||||
{# Display the meeting status badge from the ScheduledInterview model #}
|
||||
<span class="status-badge bg-{{ meeting.status }}">
|
||||
{{ meeting.interview.get_status_display }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">--</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<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' %}">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
</a>
|
||||
{% 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>
|
||||
</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>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#meetingModal"
|
||||
data-bs-target="#deleteModal"
|
||||
hx-post="{% url 'delete_meeting' meeting.slug %}"
|
||||
hx-target="#meetingModalBody"
|
||||
hx-target="#deleteModalBody"
|
||||
hx-swap="outerHTML"
|
||||
data-item-name="{{ meeting.topic }}">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
@ -387,16 +336,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Pagination (Standardized) #}
|
||||
{# Pagination (All filters correctly included in query strings) #}
|
||||
{% if is_paginated %}
|
||||
<nav aria-label="Page navigation" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<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 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>
|
||||
{% endif %}
|
||||
|
||||
@ -406,10 +355,10 @@
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<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 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>
|
||||
{% endif %}
|
||||
</ul>
|
||||
@ -418,12 +367,17 @@
|
||||
{% else %}
|
||||
<div class="text-center py-5 card shadow-sm">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-video fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
|
||||
<h3>{% trans "No Zoom meetings found" %}</h3>
|
||||
<p class="text-muted">{% trans "Create your first meeting or adjust your filters." %}</p>
|
||||
<a href="{% url 'create_meeting' %}" class="btn btn-main-action mt-3">
|
||||
<i class="fas fa-plus me-1"></i> {% trans "Create Your First Meeting" %}
|
||||
<i class="fas fa-calendar-alt fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
|
||||
<h3>{% trans "No interviews or meetings found" %}</h3>
|
||||
<p class="text-muted">{% trans "Create your first interview or adjust your filters." %}</p>
|
||||
{% comment %} <div class="btn-group mt-3" role="group">
|
||||
<a href="{% url 'create_remote_meeting' %}" class="btn btn-main-action">
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
111
templates/meetings/reschedule_onsite_meeting.html
Normal file
111
templates/meetings/reschedule_onsite_meeting.html
Normal 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>
|
||||
98
templates/meetings/schedule_onsite_meeting_form.html
Normal file
98
templates/meetings/schedule_onsite_meeting_form.html
Normal 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>
|
||||
@ -304,8 +304,8 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="candidate-details text-muted">
|
||||
{% if candidate.get_latest_meeting.topic %}
|
||||
{{ candidate.get_latest_meeting.topic }}
|
||||
{% if candidate.get_latest_meeting %}
|
||||
{{ candidate.get_latest_meeting }}
|
||||
{% else %}
|
||||
--
|
||||
{% endif %}
|
||||
@ -382,6 +382,8 @@
|
||||
<td>
|
||||
|
||||
{% if candidate.get_latest_meeting %}
|
||||
{% if candidate.get_latest_meeting.location_type == 'Remote'%}
|
||||
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#candidateviewModal"
|
||||
@ -399,6 +401,27 @@
|
||||
title="Delete Meeting">
|
||||
<i class="fas fa-trash"></i>
|
||||
</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 %}
|
||||
<button type="button" class="btn btn-main-action btn-sm"
|
||||
@ -408,7 +431,17 @@
|
||||
hx-target="#candidateviewModalBody"
|
||||
data-modal-title="{% trans '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>
|
||||
{% endif %}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user