changes to interview
This commit is contained in:
parent
0213bd6e11
commit
06436a3b9e
6
.env
6
.env
@ -1,3 +1,3 @@
|
|||||||
DB_NAME=norahuniversity
|
DB_NAME=haikal_db
|
||||||
DB_USER=norahuniversity
|
DB_USER=faheed
|
||||||
DB_PASSWORD=norahuniversity
|
DB_PASSWORD=Faheed@215
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -144,6 +144,9 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# DATABASES = {
|
# DATABASES = {
|
||||||
# 'default': {
|
# 'default': {
|
||||||
# 'ENGINE': 'django.db.backends.sqlite3',
|
# '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.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from .models import (
|
from .models import (
|
||||||
JobPosting, Application, TrainingMaterial, ZoomMeeting,
|
JobPosting, Application, TrainingMaterial, ZoomMeetingDetails,
|
||||||
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
|
FormTemplate, FormStage, FormField, FormSubmission, FieldResponse,
|
||||||
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,MeetingComment,
|
SharedFormTemplate, Source, HiringAgency, IntegrationLog,InterviewSchedule,Profile,JobPostingImage,InterviewNote,
|
||||||
AgencyAccessLink, AgencyJobAssignment
|
AgencyAccessLink, AgencyJobAssignment
|
||||||
)
|
)
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
@ -158,7 +158,7 @@ class TrainingMaterialAdmin(admin.ModelAdmin):
|
|||||||
save_on_top = True
|
save_on_top = True
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ZoomMeeting)
|
@admin.register(ZoomMeetingDetails)
|
||||||
class ZoomMeetingAdmin(admin.ModelAdmin):
|
class ZoomMeetingAdmin(admin.ModelAdmin):
|
||||||
list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
|
list_display = ['topic', 'meeting_id', 'start_time', 'duration', 'created_at']
|
||||||
list_filter = ['timezone', 'created_at']
|
list_filter = ['timezone', 'created_at']
|
||||||
@ -181,24 +181,24 @@ class ZoomMeetingAdmin(admin.ModelAdmin):
|
|||||||
save_on_top = True
|
save_on_top = True
|
||||||
|
|
||||||
|
|
||||||
@admin.register(MeetingComment)
|
# @admin.register(InterviewNote)
|
||||||
class MeetingCommentAdmin(admin.ModelAdmin):
|
# class MeetingCommentAdmin(admin.ModelAdmin):
|
||||||
list_display = ['meeting', 'author', 'created_at', 'updated_at']
|
# list_display = ['meeting', 'author', 'created_at', 'updated_at']
|
||||||
list_filter = ['created_at', 'author', 'meeting']
|
# list_filter = ['created_at', 'author', 'meeting']
|
||||||
search_fields = ['content', 'meeting__topic', 'author__username']
|
# search_fields = ['content', 'meeting__topic', 'author__username']
|
||||||
readonly_fields = ['created_at', 'updated_at', 'slug']
|
# readonly_fields = ['created_at', 'updated_at', 'slug']
|
||||||
fieldsets = (
|
# fieldsets = (
|
||||||
('Meeting Information', {
|
# ('Meeting Information', {
|
||||||
'fields': ('meeting', 'author')
|
# 'fields': ('meeting', 'author')
|
||||||
}),
|
# }),
|
||||||
('Comment Content', {
|
# ('Comment Content', {
|
||||||
'fields': ('content',)
|
# 'fields': ('content',)
|
||||||
}),
|
# }),
|
||||||
('Timestamps', {
|
# ('Timestamps', {
|
||||||
'fields': ('created_at', 'updated_at', 'slug')
|
# 'fields': ('created_at', 'updated_at', 'slug')
|
||||||
}),
|
# }),
|
||||||
)
|
# )
|
||||||
save_on_top = True
|
# save_on_top = True
|
||||||
|
|
||||||
|
|
||||||
@admin.register(FormTemplate)
|
@admin.register(FormTemplate)
|
||||||
|
|||||||
@ -149,6 +149,7 @@ def candidate_user_required(view_func):
|
|||||||
|
|
||||||
|
|
||||||
def staff_user_required(view_func):
|
def staff_user_required(view_func):
|
||||||
|
|
||||||
"""Decorator to restrict view to staff users only."""
|
"""Decorator to restrict view to staff users only."""
|
||||||
return user_type_required(['staff'])(view_func)
|
return user_type_required(['staff'])(view_func)
|
||||||
|
|
||||||
|
|||||||
@ -224,11 +224,8 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi
|
|||||||
logger.error(error_msg, exc_info=True)
|
logger.error(error_msg, exc_info=True)
|
||||||
return {'success': False, 'error': error_msg}
|
return {'success': False, 'error': error_msg}
|
||||||
|
|
||||||
from .models import Candidate
|
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
# Assuming other necessary imports like logger, settings, EmailMultiAlternatives, strip_tags are present
|
|
||||||
|
|
||||||
from .models import Candidate
|
from .models import Application
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
import logging
|
import logging
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -262,15 +259,16 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
|||||||
email = email.strip().lower()
|
email = email.strip().lower()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
candidate = get_object_or_404(Candidate, email=email)
|
candidate = get_object_or_404(Application, person__email=email)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(f"Candidate not found for email: {email}")
|
logger.warning(f"Candidate not found for email: {email}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
candidate_name = candidate.first_name
|
candidate_name = candidate.person.full_name
|
||||||
|
|
||||||
|
|
||||||
# --- Candidate belongs to an agency (Final Recipient: Agency) ---
|
# --- Candidate belongs to an agency (Final Recipient: Agency) ---
|
||||||
if candidate.belong_to_an_agency and candidate.hiring_agency and candidate.hiring_agency.email:
|
if candidate.hiring_agency and candidate.hiring_agency.email:
|
||||||
agency_email = candidate.hiring_agency.email
|
agency_email = candidate.hiring_agency.email
|
||||||
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
||||||
|
|
||||||
@ -395,7 +393,7 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
|||||||
if not from_interview:
|
if not from_interview:
|
||||||
# Send Emails - Pure Candidates
|
# Send Emails - Pure Candidates
|
||||||
for email in pure_candidate_emails:
|
for email in pure_candidate_emails:
|
||||||
candidate_name = Candidate.objects.filter(email=email).first().first_name
|
candidate_name = Application.objects.filter(email=email).first().first_name
|
||||||
candidate_message = f"Hi, {candidate_name}" + "\n" + message
|
candidate_message = f"Hi, {candidate_name}" + "\n" + message
|
||||||
send_individual_email(email, candidate_message)
|
send_individual_email(email, candidate_message)
|
||||||
|
|
||||||
@ -403,7 +401,7 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
|||||||
i = 0
|
i = 0
|
||||||
for email in agency_emails:
|
for email in agency_emails:
|
||||||
candidate_email = candidate_through_agency_emails[i]
|
candidate_email = candidate_through_agency_emails[i]
|
||||||
candidate_name = Candidate.objects.filter(email=candidate_email).first().first_name
|
candidate_name = Application.objects.filter(email=candidate_email).first().first_name
|
||||||
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
||||||
send_individual_email(email, agency_message)
|
send_individual_email(email, agency_message)
|
||||||
i += 1
|
i += 1
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from django.contrib.auth.forms import UserCreationForm
|
|||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
import re
|
import re
|
||||||
from .models import (
|
from .models import (
|
||||||
ZoomMeeting,
|
ZoomMeetingDetails,
|
||||||
Application,
|
Application,
|
||||||
TrainingMaterial,
|
TrainingMaterial,
|
||||||
JobPosting,
|
JobPosting,
|
||||||
@ -19,7 +19,7 @@ from .models import (
|
|||||||
BreakTime,
|
BreakTime,
|
||||||
JobPostingImage,
|
JobPostingImage,
|
||||||
Profile,
|
Profile,
|
||||||
MeetingComment,
|
InterviewNote,
|
||||||
ScheduledInterview,
|
ScheduledInterview,
|
||||||
Source,
|
Source,
|
||||||
HiringAgency,
|
HiringAgency,
|
||||||
@ -27,7 +27,7 @@ from .models import (
|
|||||||
AgencyAccessLink,
|
AgencyAccessLink,
|
||||||
Participants,
|
Participants,
|
||||||
Message,
|
Message,
|
||||||
Person,OnsiteMeeting
|
Person,OnsiteLocationDetails
|
||||||
)
|
)
|
||||||
|
|
||||||
# from django_summernote.widgets import SummernoteWidget
|
# from django_summernote.widgets import SummernoteWidget
|
||||||
@ -336,7 +336,7 @@ class ApplicationForm(forms.ModelForm):
|
|||||||
# person.first_name = self.cleaned_data['first_name']
|
# person.first_name = self.cleaned_data['first_name']
|
||||||
# person.last_name = self.cleaned_data['last_name']
|
# person.last_name = self.cleaned_data['last_name']
|
||||||
# person.email = self.cleaned_data['email']
|
# person.email = self.cleaned_data['email']
|
||||||
# person.phone = self.cleaned_data['phone']
|
# person.phZoomone = self.cleaned_data['phone']
|
||||||
|
|
||||||
# if commit:
|
# if commit:
|
||||||
# person.save()
|
# person.save()
|
||||||
@ -359,10 +359,39 @@ class ApplicationStageForm(forms.ModelForm):
|
|||||||
"stage": forms.Select(attrs={"class": "form-select"}),
|
"stage": forms.Select(attrs={"class": "form-select"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ZoomMeetingForm(forms.ModelForm):
|
class ZoomMeetingForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ZoomMeeting
|
model = ZoomMeetingDetails
|
||||||
|
fields = ['topic', 'start_time', 'duration']
|
||||||
|
labels = {
|
||||||
|
'topic': _('Topic'),
|
||||||
|
'start_time': _('Start Time'),
|
||||||
|
'duration': _('Duration'),
|
||||||
|
}
|
||||||
|
widgets = {
|
||||||
|
'topic': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Enter meeting topic'),}),
|
||||||
|
'start_time': forms.DateTimeInput(attrs={'class': 'form-control','type': 'datetime-local'}),
|
||||||
|
'duration': forms.NumberInput(attrs={'class': 'form-control','min': 1, 'placeholder': _('60')}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_method = 'post'
|
||||||
|
self.helper.form_class = 'form-horizontal'
|
||||||
|
self.helper.label_class = 'col-md-3'
|
||||||
|
self.helper.field_class = 'col-md-9'
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
Field('topic', css_class='form-control'),
|
||||||
|
Field('start_time', css_class='form-control'),
|
||||||
|
Field('duration', css_class='form-control'),
|
||||||
|
Submit('submit', _('Create Meeting'), css_class='btn btn-primary')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MeetingForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = ZoomMeetingDetails
|
||||||
fields = ["topic", "start_time", "duration"]
|
fields = ["topic", "start_time", "duration"]
|
||||||
labels = {
|
labels = {
|
||||||
"topic": _("Topic"),
|
"topic": _("Topic"),
|
||||||
@ -687,6 +716,7 @@ class InterviewScheduleForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = InterviewSchedule
|
model = InterviewSchedule
|
||||||
fields = [
|
fields = [
|
||||||
|
'schedule_interview_type',
|
||||||
"applications",
|
"applications",
|
||||||
"start_date",
|
"start_date",
|
||||||
"end_date",
|
"end_date",
|
||||||
@ -697,10 +727,8 @@ class InterviewScheduleForm(forms.ModelForm):
|
|||||||
"buffer_time",
|
"buffer_time",
|
||||||
"break_start_time",
|
"break_start_time",
|
||||||
"break_end_time",
|
"break_end_time",
|
||||||
"interview_type"
|
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'interview_type': forms.Select(attrs={'class': 'form-control'}),
|
|
||||||
"start_date": forms.DateInput(
|
"start_date": forms.DateInput(
|
||||||
attrs={"type": "date", "class": "form-control"}
|
attrs={"type": "date", "class": "form-control"}
|
||||||
),
|
),
|
||||||
@ -721,6 +749,7 @@ class InterviewScheduleForm(forms.ModelForm):
|
|||||||
"break_end_time": forms.TimeInput(
|
"break_end_time": forms.TimeInput(
|
||||||
attrs={"type": "time", "class": "form-control"}
|
attrs={"type": "time", "class": "form-control"}
|
||||||
),
|
),
|
||||||
|
"schedule_interview_type":forms.RadioSelect()
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, slug, *args, **kwargs):
|
def __init__(self, slug, *args, **kwargs):
|
||||||
@ -734,11 +763,11 @@ class InterviewScheduleForm(forms.ModelForm):
|
|||||||
return [int(day) for day in working_days]
|
return [int(day) for day in working_days]
|
||||||
|
|
||||||
|
|
||||||
class MeetingCommentForm(forms.ModelForm):
|
class InterviewNoteForm(forms.ModelForm):
|
||||||
"""Form for creating and editing meeting comments"""
|
"""Form for creating and editing meeting comments"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MeetingComment
|
model = InterviewNote
|
||||||
fields = ["content"]
|
fields = ["content"]
|
||||||
widgets = {
|
widgets = {
|
||||||
"content": CKEditor5Widget(
|
"content": CKEditor5Widget(
|
||||||
@ -1491,6 +1520,7 @@ class ParticipantsSelectForm(forms.ModelForm):
|
|||||||
fields = ["participants", "users"] # No direct fields from Participants model
|
fields = ["participants", "users"] # No direct fields from Participants model
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class CandidateEmailForm(forms.Form):
|
class CandidateEmailForm(forms.Form):
|
||||||
"""Form for composing emails to participants about a candidate"""
|
"""Form for composing emails to participants about a candidate"""
|
||||||
to = forms.MultipleChoiceField(
|
to = forms.MultipleChoiceField(
|
||||||
@ -1500,79 +1530,61 @@ class CandidateEmailForm(forms.Form):
|
|||||||
label=_('Select Candidates'), # Use a descriptive label
|
label=_('Select Candidates'), # Use a descriptive label
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
subject = forms.CharField(
|
subject = forms.CharField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
widget=forms.TextInput(
|
widget=forms.TextInput(attrs={
|
||||||
attrs={
|
'class': 'form-control',
|
||||||
"class": "form-control",
|
'placeholder': 'Enter email subject',
|
||||||
"placeholder": "Enter email subject",
|
'required': True
|
||||||
"required": True,
|
}),
|
||||||
}
|
label=_('Subject'),
|
||||||
),
|
required=True
|
||||||
label=_("Subject"),
|
|
||||||
required=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
message = forms.CharField(
|
message = forms.CharField(
|
||||||
widget=forms.Textarea(
|
widget=forms.Textarea(attrs={
|
||||||
attrs={
|
'class': 'form-control',
|
||||||
"class": "form-control",
|
'rows': 8,
|
||||||
"rows": 8,
|
'placeholder': 'Enter your message here...',
|
||||||
"placeholder": "Enter your message here...",
|
'required': True
|
||||||
"required": True,
|
}),
|
||||||
}
|
label=_('Message'),
|
||||||
),
|
required=True
|
||||||
label=_("Message"),
|
|
||||||
required=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
recipients = forms.MultipleChoiceField(
|
|
||||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "form-check"}),
|
|
||||||
label=_("Recipients"),
|
|
||||||
required=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
include_candidate_info = forms.BooleanField(
|
|
||||||
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
|
||||||
label=_("Include candidate information"),
|
|
||||||
initial=True,
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
include_meeting_details = forms.BooleanField(
|
|
||||||
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
|
||||||
label=_("Include meeting details"),
|
|
||||||
initial=True,
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, job, candidates, *args, **kwargs):
|
def __init__(self, job, candidates, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.job = job
|
self.job = job
|
||||||
self.candidates=candidates
|
self.candidates=candidates
|
||||||
|
|
||||||
candidate_choices=[]
|
candidate_choices=[]
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
candidate_choices.append(
|
candidate_choices.append(
|
||||||
(f'candidate_{candidate.id}', f'{candidate.email}')
|
(f'candidate_{candidate.id}', f'{candidate.email}')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
self.fields['to'].choices =candidate_choices
|
self.fields['to'].choices =candidate_choices
|
||||||
self.fields['to'].initial = [choice[0] for choice in candidate_choices]
|
self.fields['to'].initial = [choice[0] for choice in candidate_choices]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Set initial message with candidate and meeting info
|
# Set initial message with candidate and meeting info
|
||||||
initial_message = self._get_initial_message()
|
initial_message = self._get_initial_message()
|
||||||
|
|
||||||
if initial_message:
|
if initial_message:
|
||||||
self.fields["message"].initial = initial_message
|
self.fields['message'].initial = initial_message
|
||||||
|
|
||||||
def _get_initial_message(self):
|
def _get_initial_message(self):
|
||||||
"""Generate initial message with candidate and meeting information"""
|
"""Generate initial message with candidate and meeting information"""
|
||||||
candidate=self.candidates.first()
|
candidate=self.candidates.first()
|
||||||
message_parts=[]
|
message_parts=[]
|
||||||
|
|
||||||
if candidate and candidate.stage == 'Applied':
|
if candidate and candidate.stage == 'Applied':
|
||||||
message_parts = [
|
message_parts = [
|
||||||
f"Than you, for your interest in the {self.job.title} role.",
|
f"Than you, for your interest in the {self.job.title} role.",
|
||||||
@ -1592,7 +1604,7 @@ class CandidateEmailForm(forms.Form):
|
|||||||
f"We look forward to reviewing your results.",
|
f"We look forward to reviewing your results.",
|
||||||
f"Best regards, The KAAUH Hiring team"
|
f"Best regards, The KAAUH Hiring team"
|
||||||
]
|
]
|
||||||
|
|
||||||
elif candidate and candidate.stage == 'Interview':
|
elif candidate and candidate.stage == 'Interview':
|
||||||
message_parts = [
|
message_parts = [
|
||||||
f"Than you, for your interest in the {self.job.title} role.",
|
f"Than you, for your interest in the {self.job.title} role.",
|
||||||
@ -1603,7 +1615,7 @@ class CandidateEmailForm(forms.Form):
|
|||||||
f"We look forward to reviewing your results.",
|
f"We look forward to reviewing your results.",
|
||||||
f"Best regards, The KAAUH Hiring team"
|
f"Best regards, The KAAUH Hiring team"
|
||||||
]
|
]
|
||||||
|
|
||||||
elif candidate and candidate.stage == 'Offer':
|
elif candidate and candidate.stage == 'Offer':
|
||||||
message_parts = [
|
message_parts = [
|
||||||
f"Congratulations, ! We are delighted to inform you that we are extending a formal offer of employment for the {self.job.title} role.",
|
f"Congratulations, ! We are delighted to inform you that we are extending a formal offer of employment for the {self.job.title} role.",
|
||||||
@ -1622,6 +1634,16 @@ class CandidateEmailForm(forms.Form):
|
|||||||
f"If you have any questions before your start date, please contact [Onboarding Contact].",
|
f"If you have any questions before your start date, please contact [Onboarding Contact].",
|
||||||
f"Best regards, The KAAUH Hiring team"
|
f"Best regards, The KAAUH Hiring team"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# # Add candidate information
|
||||||
|
# if self.candidate:
|
||||||
|
# message_parts.append(f"Candidate Information:")
|
||||||
|
# message_parts.append(f"Name: {self.candidate.name}")
|
||||||
|
# message_parts.append(f"Email: {self.candidate.email}")
|
||||||
|
# message_parts.append(f"Phone: {self.candidate.phone}")
|
||||||
|
|
||||||
# # Add latest meeting information if available
|
# # Add latest meeting information if available
|
||||||
# latest_meeting = self.candidate.get_latest_meeting
|
# latest_meeting = self.candidate.get_latest_meeting
|
||||||
@ -1633,43 +1655,33 @@ class CandidateEmailForm(forms.Form):
|
|||||||
# if latest_meeting.join_url:
|
# if latest_meeting.join_url:
|
||||||
# message_parts.append(f"Join URL: {latest_meeting.join_url}")
|
# message_parts.append(f"Join URL: {latest_meeting.join_url}")
|
||||||
|
|
||||||
return "\n".join(message_parts)
|
return '\n'.join(message_parts)
|
||||||
|
|
||||||
def clean_recipients(self):
|
|
||||||
"""Ensure at least one recipient is selected"""
|
|
||||||
recipients = self.cleaned_data.get('recipients')
|
|
||||||
if not recipients:
|
|
||||||
raise forms.ValidationError(_('Please select at least one recipient.'))
|
|
||||||
return recipients
|
|
||||||
|
|
||||||
def get_email_addresses(self):
|
def get_email_addresses(self):
|
||||||
"""Extract email addresses from selected recipients"""
|
"""Extract email addresses from selected recipients"""
|
||||||
email_addresses = []
|
email_addresses = []
|
||||||
recipients = self.cleaned_data.get('recipients', [])
|
|
||||||
for recipient in recipients:
|
candidates=self.cleaned_data.get('to',[])
|
||||||
if recipient.startswith('participant_'):
|
|
||||||
participant_id = recipient.split('_')[1]
|
if candidates:
|
||||||
try:
|
for candidate in candidates:
|
||||||
participant = Participants.objects.get(id=participant_id)
|
if candidate.startswith('candidate_'):
|
||||||
email_addresses.append(participant.email)
|
print("candidadte: {candidate}")
|
||||||
except Participants.DoesNotExist:
|
candidate_id = candidate.split('_')[1]
|
||||||
continue
|
try:
|
||||||
elif recipient.startswith('user_'):
|
candidate = Application.objects.get(id=candidate_id)
|
||||||
user_id = recipient.split('_')[1]
|
email_addresses.append(candidate.email)
|
||||||
try:
|
except Application.DoesNotExist:
|
||||||
user = User.objects.get(id=user_id)
|
continue
|
||||||
email_addresses.append(user.email)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
return list(set(email_addresses)) # Remove duplicates
|
return list(set(email_addresses)) # Remove duplicates
|
||||||
|
|
||||||
|
|
||||||
def get_formatted_message(self):
|
def get_formatted_message(self):
|
||||||
"""Get formatted message with optional additional information"""
|
"""Get the formatted message with optional additional information"""
|
||||||
message = self.cleaned_data.get("message", "")
|
message = self.cleaned_data.get('message', '')
|
||||||
|
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
||||||
@ -1692,70 +1704,183 @@ class InterviewParticpantsForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# class InterviewEmailForm(forms.Form):
|
||||||
|
# subject = forms.CharField(
|
||||||
|
# max_length=200,
|
||||||
|
# widget=forms.TextInput(attrs={
|
||||||
|
# 'class': 'form-control',
|
||||||
|
# 'placeholder': 'Enter email subject',
|
||||||
|
# 'required': True
|
||||||
|
# }),
|
||||||
|
# label=_('Subject'),
|
||||||
|
# required=True
|
||||||
|
# )
|
||||||
|
|
||||||
|
# message_for_candidate= forms.CharField(
|
||||||
|
# widget=forms.Textarea(attrs={
|
||||||
|
# 'class': 'form-control',
|
||||||
|
# 'rows': 8,
|
||||||
|
# 'placeholder': 'Enter your message here...',
|
||||||
|
# 'required': True
|
||||||
|
# }),
|
||||||
|
# label=_('Message'),
|
||||||
|
# required=False
|
||||||
|
# )
|
||||||
|
# message_for_agency= forms.CharField(
|
||||||
|
# widget=forms.Textarea(attrs={
|
||||||
|
# 'class': 'form-control',
|
||||||
|
# 'rows': 8,
|
||||||
|
# 'placeholder': 'Enter your message here...',
|
||||||
|
# 'required': True
|
||||||
|
# }),
|
||||||
|
# label=_('Message'),
|
||||||
|
# required=False
|
||||||
|
# )
|
||||||
|
# message_for_participants= forms.CharField(
|
||||||
|
# widget=forms.Textarea(attrs={
|
||||||
|
# 'class': 'form-control',
|
||||||
|
# 'rows': 8,
|
||||||
|
# 'placeholder': 'Enter your message here...',
|
||||||
|
# 'required': True
|
||||||
|
# }),
|
||||||
|
# label=_('Message'),
|
||||||
|
# required=False
|
||||||
|
# )
|
||||||
|
|
||||||
|
# def __init__(self, *args,candidate, external_participants, system_participants,meeting,job,**kwargs):
|
||||||
|
# super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# # --- Data Preparation ---
|
||||||
|
# # Note: Added error handling for agency name if it's missing (though it shouldn't be based on your check)
|
||||||
|
# formatted_date = meeting.start_time.strftime('%Y-%m-%d')
|
||||||
|
# formatted_time = meeting.start_time.strftime('%I:%M %p')
|
||||||
|
# zoom_link = meeting.join_url
|
||||||
|
# duration = meeting.duration
|
||||||
|
# job_title = job.title
|
||||||
|
# agency_name = candidate.hiring_agency.name if candidate.belong_to_an_agency and candidate.hiring_agency else "Hiring Agency"
|
||||||
|
|
||||||
|
# # --- Combined Participants List for Internal Email ---
|
||||||
|
# external_participants_names = ", ".join([p.name for p in external_participants ])
|
||||||
|
# system_participants_names = ", ".join([p.first_name for p in system_participants ])
|
||||||
|
|
||||||
|
# # Combine and ensure no leading/trailing commas if one list is empty
|
||||||
|
# participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names]))
|
||||||
|
|
||||||
|
|
||||||
|
# # --- 1. Candidate Message (More concise and structured) ---
|
||||||
|
# candidate_message = f"""
|
||||||
|
# Dear {candidate.full_name},
|
||||||
|
|
||||||
|
# Thank you for your interest in the **{job_title}** position at KAAUH. We're pleased to invite you to an interview!
|
||||||
|
|
||||||
|
# The details of your virtual interview are as follows:
|
||||||
|
|
||||||
|
# - **Date:** {formatted_date}
|
||||||
|
# - **Time:** {formatted_time} (RIYADH TIME)
|
||||||
|
# - **Duration:** {duration}
|
||||||
|
# - **Meeting Link:** {zoom_link}
|
||||||
|
|
||||||
|
# Please click the link at the scheduled time to join the interview.
|
||||||
|
|
||||||
|
# Kindly reply to this email to **confirm your attendance** or to propose an alternative time if necessary.
|
||||||
|
|
||||||
|
# We look forward to meeting you.
|
||||||
|
|
||||||
|
# Best regards,
|
||||||
|
# KAAUH Hiring Team
|
||||||
|
# """
|
||||||
|
|
||||||
|
|
||||||
|
# # --- 2. Agency Message (Professional and clear details) ---
|
||||||
|
# agency_message = f"""
|
||||||
|
# Dear {agency_name},
|
||||||
|
|
||||||
|
# We have scheduled an interview for your candidate, **{candidate.full_name}**, for the **{job_title}** role.
|
||||||
|
|
||||||
|
# Please forward the following details to the candidate and ensure they are fully prepared.
|
||||||
|
|
||||||
|
# **Interview Details:**
|
||||||
|
|
||||||
|
# - **Candidate:** {candidate.full_name}
|
||||||
|
# - **Job Title:** {job_title}
|
||||||
|
# - **Date:** {formatted_date}
|
||||||
|
# - **Time:** {formatted_time} (RIYADH TIME)
|
||||||
|
# - **Duration:** {duration}
|
||||||
|
# - **Meeting Link:** {zoom_link}
|
||||||
|
|
||||||
|
# Please let us know if you or the candidate have any questions.
|
||||||
|
|
||||||
|
# Best regards,
|
||||||
|
# KAAUH Hiring Team
|
||||||
|
# """
|
||||||
|
|
||||||
|
# # --- 3. Participants Message (Action-oriented and informative) ---
|
||||||
|
# participants_message = f"""
|
||||||
|
# Hi Team,
|
||||||
|
|
||||||
|
# This is a reminder of the upcoming interview you are scheduled to participate in for the **{job_title}** position.
|
||||||
|
|
||||||
|
# **Interview Summary:**
|
||||||
|
|
||||||
|
# - **Candidate:** {candidate.full_name}
|
||||||
|
# - **Date:** {formatted_date}
|
||||||
|
# - **Time:** {formatted_time} (RIYADH TIME)
|
||||||
|
# - **Duration:** {duration}
|
||||||
|
# - **Your Fellow Interviewers:** {participant_names}
|
||||||
|
|
||||||
|
# **Action Items:**
|
||||||
|
|
||||||
|
# 1. Please review **{candidate.full_name}'s** resume and notes.
|
||||||
|
# 2. The official calendar invite contains the meeting link ({zoom_link}) and should be used to join.
|
||||||
|
# 3. Be ready to start promptly at the scheduled time.
|
||||||
|
|
||||||
|
# Thank you for your participation.
|
||||||
|
|
||||||
|
# Best regards,
|
||||||
|
# KAAUH HIRING TEAM
|
||||||
|
# """
|
||||||
|
|
||||||
|
# # Set initial data
|
||||||
|
# self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}"
|
||||||
|
# # .strip() removes the leading/trailing blank lines caused by the f""" format
|
||||||
|
# self.initial['message_for_candidate'] = candidate_message.strip()
|
||||||
|
# self.initial['message_for_agency'] = agency_message.strip()
|
||||||
|
# self.initial['message_for_participants'] = participants_message.strip()
|
||||||
|
|
||||||
|
|
||||||
class InterviewEmailForm(forms.Form):
|
class InterviewEmailForm(forms.Form):
|
||||||
subject = forms.CharField(
|
# ... (Field definitions)
|
||||||
max_length=200,
|
|
||||||
widget=forms.TextInput(attrs={
|
|
||||||
'class': 'form-control',
|
|
||||||
'placeholder': 'Enter email subject',
|
|
||||||
'required': True
|
|
||||||
}),
|
|
||||||
label=_('Subject'),
|
|
||||||
required=True
|
|
||||||
)
|
|
||||||
|
|
||||||
message_for_candidate= forms.CharField(
|
def __init__(self, *args, candidate, external_participants, system_participants, meeting, job, **kwargs):
|
||||||
widget=forms.Textarea(attrs={
|
|
||||||
'class': 'form-control',
|
|
||||||
'rows': 8,
|
|
||||||
'placeholder': 'Enter your message here...',
|
|
||||||
'required': True
|
|
||||||
}),
|
|
||||||
label=_('Message'),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
message_for_agency= forms.CharField(
|
|
||||||
widget=forms.Textarea(attrs={
|
|
||||||
'class': 'form-control',
|
|
||||||
'rows': 8,
|
|
||||||
'placeholder': 'Enter your message here...',
|
|
||||||
'required': True
|
|
||||||
}),
|
|
||||||
label=_('Message'),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
message_for_participants= forms.CharField(
|
|
||||||
widget=forms.Textarea(attrs={
|
|
||||||
'class': 'form-control',
|
|
||||||
'rows': 8,
|
|
||||||
'placeholder': 'Enter your message here...',
|
|
||||||
'required': True
|
|
||||||
}),
|
|
||||||
label=_('Message'),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, *args,candidate, external_participants, system_participants,meeting,job,**kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
location = meeting.interview_location
|
||||||
|
|
||||||
# --- Data Preparation ---
|
# --- Data Preparation ---
|
||||||
# Note: Added error handling for agency name if it's missing (though it shouldn't be based on your check)
|
|
||||||
formatted_date = meeting.start_time.strftime('%Y-%m-%d')
|
# Safely access details through the related InterviewLocation object
|
||||||
formatted_time = meeting.start_time.strftime('%I:%M %p')
|
if location and location.start_time:
|
||||||
zoom_link = meeting.join_url
|
formatted_date = location.start_time.strftime('%Y-%m-%d')
|
||||||
duration = meeting.duration
|
formatted_time = location.start_time.strftime('%I:%M %p')
|
||||||
|
duration = location.duration
|
||||||
|
meeting_link = location.details_url if location.details_url else "N/A (See Location Topic)"
|
||||||
|
else:
|
||||||
|
# Handle case where location or time is missing/None
|
||||||
|
formatted_date = "TBD - Awaiting Scheduling"
|
||||||
|
formatted_time = "TBD"
|
||||||
|
duration = "N/A"
|
||||||
|
meeting_link = "Not Available"
|
||||||
|
|
||||||
job_title = job.title
|
job_title = job.title
|
||||||
agency_name = candidate.hiring_agency.name if candidate.belong_to_an_agency and candidate.hiring_agency else "Hiring Agency"
|
agency_name = candidate.hiring_agency.name if candidate.belong_to_an_agency and candidate.hiring_agency else "Hiring Agency"
|
||||||
|
|
||||||
# --- Combined Participants List for Internal Email ---
|
# --- Combined Participants List for Internal Email ---
|
||||||
external_participants_names = ", ".join([p.name for p in external_participants ])
|
external_participants_names = ", ".join([p.name for p in external_participants ])
|
||||||
system_participants_names = ", ".join([p.first_name for p in system_participants ])
|
system_participants_names = ", ".join([p.first_name for p in system_participants ])
|
||||||
|
|
||||||
# Combine and ensure no leading/trailing commas if one list is empty
|
|
||||||
participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names]))
|
participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names]))
|
||||||
|
|
||||||
|
|
||||||
# --- 1. Candidate Message (More concise and structured) ---
|
# --- 1. Candidate Message (Use meeting_link) ---
|
||||||
candidate_message = f"""
|
candidate_message = f"""
|
||||||
Dear {candidate.full_name},
|
Dear {candidate.full_name},
|
||||||
|
|
||||||
@ -1766,7 +1891,7 @@ The details of your virtual interview are as follows:
|
|||||||
- **Date:** {formatted_date}
|
- **Date:** {formatted_date}
|
||||||
- **Time:** {formatted_time} (RIYADH TIME)
|
- **Time:** {formatted_time} (RIYADH TIME)
|
||||||
- **Duration:** {duration}
|
- **Duration:** {duration}
|
||||||
- **Meeting Link:** {zoom_link}
|
- **Meeting Link:** {meeting_link}
|
||||||
|
|
||||||
Please click the link at the scheduled time to join the interview.
|
Please click the link at the scheduled time to join the interview.
|
||||||
|
|
||||||
@ -1777,37 +1902,25 @@ We look forward to meeting you.
|
|||||||
Best regards,
|
Best regards,
|
||||||
KAAUH Hiring Team
|
KAAUH Hiring Team
|
||||||
"""
|
"""
|
||||||
|
# ... (Messages for agency and participants remain the same, using the updated safe variables)
|
||||||
|
|
||||||
# --- 2. Agency Message (Professional and clear details) ---
|
# --- 2. Agency Message (Professional and clear details) ---
|
||||||
agency_message = f"""
|
agency_message = f"""
|
||||||
Dear {agency_name},
|
Dear {agency_name},
|
||||||
|
...
|
||||||
We have scheduled an interview for your candidate, **{candidate.full_name}**, for the **{job_title}** role.
|
|
||||||
|
|
||||||
Please forward the following details to the candidate and ensure they are fully prepared.
|
|
||||||
|
|
||||||
**Interview Details:**
|
**Interview Details:**
|
||||||
|
...
|
||||||
- **Candidate:** {candidate.full_name}
|
|
||||||
- **Job Title:** {job_title}
|
|
||||||
- **Date:** {formatted_date}
|
- **Date:** {formatted_date}
|
||||||
- **Time:** {formatted_time} (RIYADH TIME)
|
- **Time:** {formatted_time} (RIYADH TIME)
|
||||||
- **Duration:** {duration}
|
- **Duration:** {duration}
|
||||||
- **Meeting Link:** {zoom_link}
|
- **Meeting Link:** {meeting_link}
|
||||||
|
...
|
||||||
Please let us know if you or the candidate have any questions.
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
KAAUH Hiring Team
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# --- 3. Participants Message (Action-oriented and informative) ---
|
# --- 3. Participants Message (Action-oriented and informative) ---
|
||||||
participants_message = f"""
|
participants_message = f"""
|
||||||
Hi Team,
|
Hi Team,
|
||||||
|
...
|
||||||
This is a reminder of the upcoming interview you are scheduled to participate in for the **{job_title}** position.
|
|
||||||
|
|
||||||
**Interview Summary:**
|
**Interview Summary:**
|
||||||
|
|
||||||
- **Candidate:** {candidate.full_name}
|
- **Candidate:** {candidate.full_name}
|
||||||
@ -1819,25 +1932,16 @@ This is a reminder of the upcoming interview you are scheduled to participate in
|
|||||||
**Action Items:**
|
**Action Items:**
|
||||||
|
|
||||||
1. Please review **{candidate.full_name}'s** resume and notes.
|
1. Please review **{candidate.full_name}'s** resume and notes.
|
||||||
2. The official calendar invite contains the meeting link ({zoom_link}) and should be used to join.
|
2. The official calendar invite contains the meeting link ({meeting_link}) and should be used to join.
|
||||||
3. Be ready to start promptly at the scheduled time.
|
3. Be ready to start promptly at the scheduled time.
|
||||||
|
...
|
||||||
Thank you for your participation.
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
KAAUH HIRING TEAM
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Set initial data
|
# Set initial data
|
||||||
self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}"
|
self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}"
|
||||||
# .strip() removes the leading/trailing blank lines caused by the f""" format
|
|
||||||
self.initial['message_for_candidate'] = candidate_message.strip()
|
self.initial['message_for_candidate'] = candidate_message.strip()
|
||||||
self.initial['message_for_agency'] = agency_message.strip()
|
self.initial['message_for_agency'] = agency_message.strip()
|
||||||
self.initial['message_for_participants'] = participants_message.strip()
|
self.initial['message_for_participants'] = participants_message.strip()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# class OnsiteLocationForm(forms.ModelForm):
|
# class OnsiteLocationForm(forms.ModelForm):
|
||||||
# class Meta:
|
# class Meta:
|
||||||
# model=
|
# model=
|
||||||
@ -1846,26 +1950,91 @@ KAAUH HIRING TEAM
|
|||||||
# 'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}),
|
# 'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}),
|
||||||
# }
|
# }
|
||||||
|
|
||||||
|
#during bulk schedule
|
||||||
|
|
||||||
class OnsiteMeetingForm(forms.ModelForm):
|
class OnsiteMeetingForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = OnsiteMeeting
|
model = OnsiteLocationDetails
|
||||||
fields = ['topic', 'start_time', 'duration', 'timezone', 'location', 'status']
|
# Include 'room_number' and update the field list
|
||||||
|
fields = ['topic', 'physical_address', 'room_number']
|
||||||
widgets = {
|
widgets = {
|
||||||
'topic': forms.TextInput(attrs={'placeholder': 'Enter the Meeting Topic', 'class': 'form-control'}),
|
'topic': forms.TextInput(
|
||||||
'start_time': forms.DateTimeInput(
|
attrs={'placeholder': 'Enter the Meeting Topic', 'class': 'form-control'}
|
||||||
attrs={'type': 'datetime-local', 'class': 'form-control'}
|
|
||||||
),
|
),
|
||||||
'duration': forms.NumberInput(
|
|
||||||
attrs={'min': 15, 'placeholder': 'Duration in minutes', 'class': 'form-control'}
|
'physical_address': forms.TextInput(
|
||||||
|
attrs={'placeholder': 'Physical address (e.g., street address)', 'class': 'form-control'}
|
||||||
),
|
),
|
||||||
'location': forms.TextInput(attrs={'placeholder': 'Physical location', 'class': 'form-control'}),
|
|
||||||
'timezone': forms.TextInput(attrs={'class': 'form-control'}),
|
'room_number': forms.TextInput(
|
||||||
'status': forms.Select(attrs={'class': 'form-control'}),
|
attrs={'placeholder': 'Room Number/Name (Optional)', 'class': 'form-control'}
|
||||||
|
),
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OnsiteReshuduleForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = OnsiteLocationDetails
|
||||||
|
fields = ['topic', 'physical_address', 'room_number','start_time','duration','status']
|
||||||
|
widgets = {
|
||||||
|
'topic': forms.TextInput(
|
||||||
|
attrs={'placeholder': 'Enter the Meeting Topic', 'class': 'form-control'}
|
||||||
|
),
|
||||||
|
|
||||||
|
'physical_address': forms.TextInput(
|
||||||
|
attrs={'placeholder': 'Physical address (e.g., street address)', 'class': 'form-control'}
|
||||||
|
),
|
||||||
|
|
||||||
|
'room_number': forms.TextInput(
|
||||||
|
attrs={'placeholder': 'Room Number/Name (Optional)', 'class': 'form-control'}
|
||||||
|
),
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OnsiteScheduleForm(forms.ModelForm):
|
||||||
|
# Add fields for the foreign keys required by ScheduledInterview
|
||||||
|
application = forms.ModelChoiceField(
|
||||||
|
queryset=Application.objects.all(),
|
||||||
|
widget=forms.HiddenInput(), # Hide this in the form, set by the view
|
||||||
|
label=_("Candidate Application")
|
||||||
|
)
|
||||||
|
job = forms.ModelChoiceField(
|
||||||
|
queryset=JobPosting.objects.all(),
|
||||||
|
widget=forms.HiddenInput(), # Hide this in the form, set by the view
|
||||||
|
label=_("Job Posting")
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = OnsiteLocationDetails
|
||||||
|
# Include all fields from OnsiteLocationDetails plus the new ones
|
||||||
|
fields = ['topic', 'physical_address', 'room_number', 'start_time', 'duration', 'status', 'application', 'job']
|
||||||
|
|
||||||
|
widgets = {
|
||||||
|
'topic': forms.TextInput(
|
||||||
|
attrs={'placeholder': _('Enter the Meeting Topic'), 'class': 'form-control'}
|
||||||
|
),
|
||||||
|
'physical_address': forms.TextInput(
|
||||||
|
attrs={'placeholder': _('Physical address (e.g., street address)'), 'class': 'form-control'}
|
||||||
|
),
|
||||||
|
'room_number': forms.TextInput(
|
||||||
|
attrs={'placeholder': _('Room Number/Name (Optional)'), 'class': 'form-control'}
|
||||||
|
),
|
||||||
|
# You should explicitly set widgets for start_time, duration, and status here
|
||||||
|
# if they need Bootstrap classes, otherwise they will use default HTML inputs.
|
||||||
|
# Example:
|
||||||
|
'start_time': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
|
||||||
|
'duration': forms.NumberInput(attrs={'class': 'form-control', 'min': 15}),
|
||||||
|
'status': forms.HiddenInput(), # Status should default to SCHEDULED, so hide it.
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class MessageForm(forms.ModelForm):
|
class MessageForm(forms.ModelForm):
|
||||||
"""Form for creating and editing messages between users"""
|
"""Form for creating and editing messages between users"""
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
from .models import Candidate
|
from .models import Application
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
def callback_ai_parsing(task):
|
def callback_ai_parsing(task):
|
||||||
if task.success:
|
if task.success:
|
||||||
try:
|
try:
|
||||||
pk = task.args[0]
|
pk = task.args[0]
|
||||||
c = Candidate.objects.get(pk=pk)
|
c = Application.objects.get(pk=pk)
|
||||||
if c.retry and not c.is_resume_parsed:
|
if c.retry and not c.is_resume_parsed:
|
||||||
sleep(30)
|
sleep(30)
|
||||||
c.retry -= 1
|
c.retry -= 1
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.2.6 on 2025-11-13 13:12
|
# Generated by Django 5.2.7 on 2025-11-14 21:43
|
||||||
|
|
||||||
import django.contrib.auth.models
|
import django.contrib.auth.models
|
||||||
import django.contrib.auth.validators
|
import django.contrib.auth.validators
|
||||||
@ -49,21 +49,20 @@ class Migration(migrations.Migration):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='OnsiteMeeting',
|
name='InterviewLocation',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||||
('topic', models.CharField(max_length=255, verbose_name='Topic')),
|
('location_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], db_index=True, max_length=10, verbose_name='Location Type')),
|
||||||
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
|
('details_url', models.URLField(blank=True, max_length=2048, null=True, verbose_name='Meeting/Location URL')),
|
||||||
('duration', models.PositiveIntegerField(verbose_name='Duration')),
|
('topic', models.CharField(blank=True, help_text="e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'", max_length=255, verbose_name='Location/Meeting Topic')),
|
||||||
('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
|
('timezone', models.CharField(default='UTC', max_length=50, verbose_name='Timezone')),
|
||||||
('location', models.CharField(blank=True, null=True)),
|
|
||||||
('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')),
|
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'abstract': False,
|
'verbose_name': 'Interview Location',
|
||||||
|
'verbose_name_plural': 'Interview Locations',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
@ -112,31 +111,6 @@ class Migration(migrations.Migration):
|
|||||||
'ordering': ['name'],
|
'ordering': ['name'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
|
||||||
name='ZoomMeeting',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
|
||||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
|
||||||
('topic', models.CharField(max_length=255, verbose_name='Topic')),
|
|
||||||
('meeting_id', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID')),
|
|
||||||
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
|
|
||||||
('duration', models.PositiveIntegerField(verbose_name='Duration')),
|
|
||||||
('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
|
|
||||||
('join_url', models.URLField(verbose_name='Join URL')),
|
|
||||||
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
|
|
||||||
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
|
|
||||||
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
|
|
||||||
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
|
|
||||||
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
|
|
||||||
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
|
|
||||||
('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'abstract': False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='CustomUser',
|
name='CustomUser',
|
||||||
fields=[
|
fields=[
|
||||||
@ -287,6 +261,43 @@ class Migration(migrations.Migration):
|
|||||||
'verbose_name_plural': 'Applications',
|
'verbose_name_plural': 'Applications',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OnsiteLocationDetails',
|
||||||
|
fields=[
|
||||||
|
('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')),
|
||||||
|
('physical_address', models.CharField(blank=True, max_length=255, null=True, verbose_name='Physical Address')),
|
||||||
|
('room_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='Room Number/Name')),
|
||||||
|
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
|
||||||
|
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
|
||||||
|
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Onsite Location Details',
|
||||||
|
'verbose_name_plural': 'Onsite Location Details',
|
||||||
|
},
|
||||||
|
bases=('recruitment.interviewlocation',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ZoomMeetingDetails',
|
||||||
|
fields=[
|
||||||
|
('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')),
|
||||||
|
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
|
||||||
|
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
|
||||||
|
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
|
||||||
|
('meeting_id', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='External Meeting ID')),
|
||||||
|
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
|
||||||
|
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
|
||||||
|
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
|
||||||
|
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
|
||||||
|
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
|
||||||
|
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Zoom Meeting Details',
|
||||||
|
'verbose_name_plural': 'Zoom Meeting Details',
|
||||||
|
},
|
||||||
|
bases=('recruitment.interviewlocation',),
|
||||||
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='JobPosting',
|
name='JobPosting',
|
||||||
fields=[
|
fields=[
|
||||||
@ -343,7 +354,7 @@ class Migration(migrations.Migration):
|
|||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||||
('interview_type', models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type')),
|
('schedule_interview_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], default='Remote', max_length=10, verbose_name='Interview Type')),
|
||||||
('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
|
('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
|
||||||
('end_date', models.DateField(db_index=True, verbose_name='End Date')),
|
('end_date', models.DateField(db_index=True, verbose_name='End Date')),
|
||||||
('working_days', models.JSONField(verbose_name='Working Days')),
|
('working_days', models.JSONField(verbose_name='Working Days')),
|
||||||
@ -353,10 +364,14 @@ class Migration(migrations.Migration):
|
|||||||
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
|
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
|
||||||
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
|
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
|
||||||
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
|
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
|
||||||
('applications', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.application')),
|
('applications', models.ManyToManyField(blank=True, related_name='interview_schedules', to='recruitment.application')),
|
||||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
('template_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schedule_templates', to='recruitment.interviewlocation', verbose_name='Location Template (Zoom/Onsite)')),
|
||||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
|
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
|
||||||
],
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='formtemplate',
|
model_name='formtemplate',
|
||||||
@ -423,6 +438,27 @@ class Migration(migrations.Migration):
|
|||||||
'ordering': ['-created_at'],
|
'ordering': ['-created_at'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Notification',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('message', models.TextField(verbose_name='Notification Message')),
|
||||||
|
('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')),
|
||||||
|
('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')),
|
||||||
|
('last_error', models.TextField(blank=True, verbose_name='Last Error Message')),
|
||||||
|
('inteview', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.interviewschedule', verbose_name='Related Interview')),
|
||||||
|
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Notification',
|
||||||
|
'verbose_name_plural': 'Notifications',
|
||||||
|
'ordering': ['-scheduled_for', '-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Person',
|
name='Person',
|
||||||
fields=[
|
fields=[
|
||||||
@ -464,6 +500,42 @@ class Migration(migrations.Migration):
|
|||||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ScheduledInterview',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||||
|
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||||
|
('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
|
||||||
|
('interview_time', models.TimeField(verbose_name='Interview Time')),
|
||||||
|
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
|
||||||
|
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')),
|
||||||
|
('interview_location', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='scheduled_interview', to='recruitment.interviewlocation', verbose_name='Meeting/Location Details')),
|
||||||
|
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
|
||||||
|
('participants', models.ManyToManyField(blank=True, to='recruitment.participants')),
|
||||||
|
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interviews', to='recruitment.interviewschedule')),
|
||||||
|
('system_users', models.ManyToManyField(blank=True, related_name='attended_interviews', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='InterviewNote',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||||
|
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
||||||
|
('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')),
|
||||||
|
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content/Feedback')),
|
||||||
|
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_notes', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
|
||||||
|
('interview', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.scheduledinterview', verbose_name='Scheduled Interview')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Interview Note',
|
||||||
|
'verbose_name_plural': 'Interview Notes',
|
||||||
|
'ordering': ['created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='SharedFormTemplate',
|
name='SharedFormTemplate',
|
||||||
fields=[
|
fields=[
|
||||||
@ -523,63 +595,6 @@ class Migration(migrations.Migration):
|
|||||||
'verbose_name_plural': 'Training Materials',
|
'verbose_name_plural': 'Training Materials',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
|
||||||
name='ScheduledInterview',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
|
||||||
('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
|
|
||||||
('interview_time', models.TimeField(verbose_name='Interview Time')),
|
|
||||||
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')),
|
|
||||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
|
|
||||||
('onsite_meeting', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='onsite_interview', to='recruitment.onsitemeeting')),
|
|
||||||
('participants', models.ManyToManyField(blank=True, to='recruitment.participants')),
|
|
||||||
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')),
|
|
||||||
('system_users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)),
|
|
||||||
('zoom_meeting', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Notification',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('message', models.TextField(verbose_name='Notification Message')),
|
|
||||||
('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')),
|
|
||||||
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')),
|
|
||||||
('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')),
|
|
||||||
('last_error', models.TextField(blank=True, verbose_name='Last Error Message')),
|
|
||||||
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
|
|
||||||
('related_meeting', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.zoommeeting', verbose_name='Related Meeting')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Notification',
|
|
||||||
'verbose_name_plural': 'Notifications',
|
|
||||||
'ordering': ['-scheduled_for', '-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='MeetingComment',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
|
||||||
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
|
|
||||||
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content')),
|
|
||||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meeting_comments', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
|
|
||||||
('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recruitment.zoommeeting', verbose_name='Meeting')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Meeting Comment',
|
|
||||||
'verbose_name_plural': 'Meeting Comments',
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='AgencyAccessLink',
|
name='AgencyAccessLink',
|
||||||
fields=[
|
fields=[
|
||||||
@ -645,18 +660,6 @@ class Migration(migrations.Migration):
|
|||||||
model_name='formsubmission',
|
model_name='formsubmission',
|
||||||
index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'),
|
index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='interviewschedule',
|
|
||||||
index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='interviewschedule',
|
|
||||||
index=models.Index(fields=['end_date'], name='recruitment_end_dat_aeb00e_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='interviewschedule',
|
|
||||||
index=models.Index(fields=['created_by'], name='recruitment_created_d0bdcc_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='formtemplate',
|
model_name='formtemplate',
|
||||||
index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'),
|
index=models.Index(fields=['created_at'], name='recruitment_created_c21775_idx'),
|
||||||
@ -701,6 +704,14 @@ class Migration(migrations.Migration):
|
|||||||
model_name='message',
|
model_name='message',
|
||||||
index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'),
|
index=models.Index(fields=['message_type', 'created_at'], name='recruitment_message_f25659_idx'),
|
||||||
),
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='notification',
|
||||||
|
index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='notification',
|
||||||
|
index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'),
|
||||||
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='person',
|
model_name='person',
|
||||||
index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'),
|
index=models.Index(fields=['email'], name='recruitment_email_0b1ab1_idx'),
|
||||||
@ -733,14 +744,6 @@ class Migration(migrations.Migration):
|
|||||||
name='application',
|
name='application',
|
||||||
unique_together={('person', 'job')},
|
unique_together={('person', 'job')},
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='jobposting',
|
|
||||||
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='jobposting',
|
|
||||||
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='scheduledinterview',
|
model_name='scheduledinterview',
|
||||||
index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'),
|
index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'),
|
||||||
@ -754,11 +757,11 @@ class Migration(migrations.Migration):
|
|||||||
index=models.Index(fields=['application', 'job'], name='recruitment_applica_927561_idx'),
|
index=models.Index(fields=['application', 'job'], name='recruitment_applica_927561_idx'),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='notification',
|
model_name='jobposting',
|
||||||
index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'),
|
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='notification',
|
model_name='jobposting',
|
||||||
index=models.Index(fields=['recipient'], name='recruitment_recipie_eadf4c_idx'),
|
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
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
|
from django.contrib.contenttypes.models import ContentType
|
||||||
content_type = ContentType.objects.get_for_model(self.__class__)
|
content_type = ContentType.objects.get_for_model(self.__class__)
|
||||||
return Document.objects.filter(content_type=content_type, object_id=self.id)
|
return Document.objects.filter(content_type=content_type, object_id=self.id)
|
||||||
|
@property
|
||||||
|
def belong_to_an_agency(self):
|
||||||
|
if self.agency:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class Application(Base):
|
class Application(Base):
|
||||||
@ -890,13 +896,57 @@ class Application(Base):
|
|||||||
"""Legacy compatibility - get scheduled interviews for this application"""
|
"""Legacy compatibility - get scheduled interviews for this application"""
|
||||||
return self.scheduled_interviews.all()
|
return self.scheduled_interviews.all()
|
||||||
|
|
||||||
|
# @property
|
||||||
|
# def get_latest_meeting(self):
|
||||||
|
# """Legacy compatibility - get latest meeting for this application"""
|
||||||
|
# #get parent interview location modal:
|
||||||
|
|
||||||
|
# schedule=self.scheduled_interviews.order_by("-created_at").first()
|
||||||
|
|
||||||
|
|
||||||
|
# if schedule:
|
||||||
|
# print(schedule)
|
||||||
|
# interview_location=schedule.interview_location
|
||||||
|
# else:
|
||||||
|
# return None
|
||||||
|
# if interview_location and interview_location.location_type=='Remote':
|
||||||
|
# meeting = interview_location.zoommeetingdetails
|
||||||
|
|
||||||
|
# return meeting
|
||||||
|
# else:
|
||||||
|
# meeting = interview_location.onsitelocationdetails
|
||||||
|
# return meeting
|
||||||
@property
|
@property
|
||||||
def get_latest_meeting(self):
|
def get_latest_meeting(self):
|
||||||
"""Legacy compatibility - get latest meeting for this application"""
|
"""
|
||||||
|
Retrieves the most specific location details (subclass instance)
|
||||||
|
of the latest ScheduledInterview for this application, or None.
|
||||||
|
"""
|
||||||
|
# 1. Get the latest ScheduledInterview
|
||||||
schedule = self.scheduled_interviews.order_by("-created_at").first()
|
schedule = self.scheduled_interviews.order_by("-created_at").first()
|
||||||
if schedule:
|
|
||||||
return schedule.zoom_meeting
|
# Check if a schedule exists and if it has an interview location
|
||||||
return None
|
if not schedule or not schedule.interview_location:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get the base location instance
|
||||||
|
interview_location = schedule.interview_location
|
||||||
|
|
||||||
|
# 2. Safely retrieve the specific subclass details
|
||||||
|
|
||||||
|
# Determine the expected subclass accessor name based on the location_type
|
||||||
|
if interview_location.location_type == 'Remote':
|
||||||
|
accessor_name = 'zoommeetingdetails'
|
||||||
|
else: # Assumes 'Onsite' or any other type defaults to Onsite
|
||||||
|
accessor_name = 'onsitelocationdetails'
|
||||||
|
|
||||||
|
# Use getattr to safely retrieve the specific meeting object (subclass instance).
|
||||||
|
# If the accessor exists but points to None (because the subclass record was deleted),
|
||||||
|
# or if the accessor name is wrong for the object's true type, it will return None.
|
||||||
|
meeting_details = getattr(interview_location, accessor_name, None)
|
||||||
|
|
||||||
|
return meeting_details
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_future_meeting(self):
|
def has_future_meeting(self):
|
||||||
@ -966,129 +1016,6 @@ class TrainingMaterial(Base):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
class OnsiteMeeting(Base):
|
|
||||||
class MeetingStatus(models.TextChoices):
|
|
||||||
WAITING = "waiting", _("Waiting")
|
|
||||||
STARTED = "started", _("Started")
|
|
||||||
ENDED = "ended", _("Ended")
|
|
||||||
CANCELLED = "cancelled",_("Cancelled")
|
|
||||||
# Basic meeting details
|
|
||||||
topic = models.CharField(max_length=255, verbose_name=_("Topic"))
|
|
||||||
start_time = models.DateTimeField(db_index=True, verbose_name=_("Start Time")) # Added index
|
|
||||||
duration = models.PositiveIntegerField(
|
|
||||||
verbose_name=_("Duration")
|
|
||||||
) # Duration in minutes
|
|
||||||
timezone = models.CharField(max_length=50, verbose_name=_("Timezone"))
|
|
||||||
location=models.CharField(null=True,blank=True)
|
|
||||||
status = models.CharField(
|
|
||||||
db_index=True, max_length=20, # Added index
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name=_("Status"),
|
|
||||||
default=MeetingStatus.WAITING,
|
|
||||||
)
|
|
||||||
|
|
||||||
class ZoomMeeting(Base):
|
|
||||||
class MeetingStatus(models.TextChoices):
|
|
||||||
WAITING = "waiting", _("Waiting")
|
|
||||||
STARTED = "started", _("Started")
|
|
||||||
ENDED = "ended", _("Ended")
|
|
||||||
CANCELLED = "cancelled", _("Cancelled")
|
|
||||||
|
|
||||||
# Basic meeting details
|
|
||||||
topic = models.CharField(max_length=255, verbose_name=_("Topic"))
|
|
||||||
meeting_id = models.CharField(
|
|
||||||
db_index=True,
|
|
||||||
max_length=20,
|
|
||||||
unique=True,
|
|
||||||
verbose_name=_("Meeting ID"), # Added index
|
|
||||||
) # Unique identifier for the meeting
|
|
||||||
start_time = models.DateTimeField(
|
|
||||||
db_index=True, verbose_name=_("Start Time")
|
|
||||||
) # Added index
|
|
||||||
duration = models.PositiveIntegerField(
|
|
||||||
verbose_name=_("Duration")
|
|
||||||
) # Duration in minutes
|
|
||||||
timezone = models.CharField(max_length=50, verbose_name=_("Timezone"))
|
|
||||||
join_url = models.URLField(
|
|
||||||
verbose_name=_("Join URL")
|
|
||||||
) # URL for participants to join
|
|
||||||
participant_video = models.BooleanField(
|
|
||||||
default=True, verbose_name=_("Participant Video")
|
|
||||||
)
|
|
||||||
password = models.CharField(
|
|
||||||
max_length=20, blank=True, null=True, verbose_name=_("Password")
|
|
||||||
)
|
|
||||||
join_before_host = models.BooleanField(
|
|
||||||
default=False, verbose_name=_("Join Before Host")
|
|
||||||
)
|
|
||||||
mute_upon_entry = models.BooleanField(
|
|
||||||
default=False, verbose_name=_("Mute Upon Entry")
|
|
||||||
)
|
|
||||||
waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room"))
|
|
||||||
|
|
||||||
zoom_gateway_response = models.JSONField(
|
|
||||||
blank=True, null=True, verbose_name=_("Zoom Gateway Response")
|
|
||||||
)
|
|
||||||
status = models.CharField(
|
|
||||||
db_index=True,
|
|
||||||
max_length=20, # Added index
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name=_("Status"),
|
|
||||||
default=MeetingStatus.WAITING,
|
|
||||||
)
|
|
||||||
# Timestamps
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.topic
|
|
||||||
@property
|
|
||||||
|
|
||||||
def get_job(self):
|
|
||||||
return self.interview.job
|
|
||||||
|
|
||||||
@property
|
|
||||||
def get_candidate(self):
|
|
||||||
return self.interview.application.person
|
|
||||||
@property
|
|
||||||
def candidate_full_name(self):
|
|
||||||
return self.interview.application.person.full_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def get_participants(self):
|
|
||||||
return self.interview.job.participants.all()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def get_users(self):
|
|
||||||
return self.interview.job.users.all()
|
|
||||||
|
|
||||||
class MeetingComment(Base):
|
|
||||||
"""
|
|
||||||
Model for storing meeting comments/notes
|
|
||||||
"""
|
|
||||||
|
|
||||||
meeting = models.ForeignKey(
|
|
||||||
ZoomMeeting,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="comments",
|
|
||||||
verbose_name=_("Meeting"),
|
|
||||||
)
|
|
||||||
author = models.ForeignKey(
|
|
||||||
User,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="meeting_comments",
|
|
||||||
verbose_name=_("Author"),
|
|
||||||
)
|
|
||||||
content = CKEditor5Field(verbose_name=_("Content"), config_name="extends")
|
|
||||||
# Inherited from Base: created_at, updated_at, slug
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("Meeting Comment")
|
|
||||||
verbose_name_plural = _("Meeting Comments")
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Comment by {self.author.get_username()} on {self.meeting.topic}"
|
|
||||||
|
|
||||||
|
|
||||||
class FormTemplate(Base):
|
class FormTemplate(Base):
|
||||||
@ -1894,136 +1821,6 @@ class BreakTime(models.Model):
|
|||||||
return f"{self.start_time} - {self.end_time}"
|
return f"{self.start_time} - {self.end_time}"
|
||||||
|
|
||||||
|
|
||||||
class InterviewSchedule(Base):
|
|
||||||
"""Stores the scheduling criteria for interviews"""
|
|
||||||
|
|
||||||
class InterviewType(models.TextChoices):
|
|
||||||
REMOTE = 'Remote', 'Remote Interview'
|
|
||||||
ONSITE = 'Onsite', 'In-Person Interview'
|
|
||||||
|
|
||||||
interview_type = models.CharField(
|
|
||||||
max_length=10,
|
|
||||||
choices=InterviewType.choices,
|
|
||||||
default=InterviewType.REMOTE,
|
|
||||||
verbose_name="Interview Meeting Type"
|
|
||||||
)
|
|
||||||
|
|
||||||
job = models.ForeignKey(
|
|
||||||
JobPosting,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="interview_schedules",
|
|
||||||
db_index=True,
|
|
||||||
)
|
|
||||||
applications = models.ManyToManyField(
|
|
||||||
Application, related_name="interview_schedules", blank=True, null=True
|
|
||||||
)
|
|
||||||
start_date = models.DateField(
|
|
||||||
db_index=True, verbose_name=_("Start Date")
|
|
||||||
) # Added index
|
|
||||||
end_date = models.DateField(
|
|
||||||
db_index=True, verbose_name=_("End Date")
|
|
||||||
) # Added index
|
|
||||||
working_days = models.JSONField(
|
|
||||||
verbose_name=_("Working Days")
|
|
||||||
) # Store days of week as [0,1,2,3,4] for Mon-Fri
|
|
||||||
start_time = models.TimeField(verbose_name=_("Start Time"))
|
|
||||||
end_time = models.TimeField(verbose_name=_("End Time"))
|
|
||||||
|
|
||||||
break_start_time = models.TimeField(
|
|
||||||
verbose_name=_("Break Start Time"), null=True, blank=True
|
|
||||||
)
|
|
||||||
break_end_time = models.TimeField(
|
|
||||||
verbose_name=_("Break End Time"), null=True, blank=True
|
|
||||||
)
|
|
||||||
|
|
||||||
interview_duration = models.PositiveIntegerField(
|
|
||||||
verbose_name=_("Interview Duration (minutes)")
|
|
||||||
)
|
|
||||||
buffer_time = models.PositiveIntegerField(
|
|
||||||
verbose_name=_("Buffer Time (minutes)"), default=0
|
|
||||||
)
|
|
||||||
created_by = models.ForeignKey(
|
|
||||||
User, on_delete=models.CASCADE, db_index=True
|
|
||||||
) # Added index
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Interview Schedule for {self.job.title}"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["start_date"]),
|
|
||||||
models.Index(fields=["end_date"]),
|
|
||||||
models.Index(fields=["created_by"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class ScheduledInterview(Base):
|
|
||||||
"""Stores individual scheduled interviews"""
|
|
||||||
|
|
||||||
application = models.ForeignKey(
|
|
||||||
Application,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="scheduled_interviews",
|
|
||||||
db_index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
participants = models.ManyToManyField('Participants', blank=True)
|
|
||||||
system_users=models.ManyToManyField(User,blank=True)
|
|
||||||
|
|
||||||
|
|
||||||
job = models.ForeignKey(
|
|
||||||
"JobPosting",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="scheduled_interviews",
|
|
||||||
db_index=True,
|
|
||||||
)
|
|
||||||
zoom_meeting = models.OneToOneField(
|
|
||||||
ZoomMeeting, on_delete=models.CASCADE, related_name="interview", db_index=True,
|
|
||||||
null=True, blank=True
|
|
||||||
)
|
|
||||||
|
|
||||||
onsite_meeting= models.OneToOneField(
|
|
||||||
OnsiteMeeting, on_delete=models.CASCADE, related_name="onsite_interview", db_index=True,
|
|
||||||
null=True, blank=True
|
|
||||||
)
|
|
||||||
schedule = models.ForeignKey(
|
|
||||||
InterviewSchedule,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="interviews",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
db_index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
interview_date = models.DateField(
|
|
||||||
db_index=True, verbose_name=_("Interview Date")
|
|
||||||
) # Added index
|
|
||||||
interview_time = models.TimeField(verbose_name=_("Interview Time"))
|
|
||||||
status = models.CharField(
|
|
||||||
db_index=True,
|
|
||||||
max_length=20, # Added index
|
|
||||||
choices=[
|
|
||||||
("scheduled", _("Scheduled")),
|
|
||||||
("confirmed", _("Confirmed")),
|
|
||||||
("cancelled", _("Cancelled")),
|
|
||||||
("completed", _("Completed")),
|
|
||||||
],
|
|
||||||
default="scheduled",
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Interview with {self.application.person.full_name} for {self.job.title}"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["job", "status"]),
|
|
||||||
models.Index(fields=["interview_date", "interview_time"]),
|
|
||||||
models.Index(fields=["application", "job"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Notification(models.Model):
|
class Notification(models.Model):
|
||||||
@ -2061,13 +1858,13 @@ class Notification(models.Model):
|
|||||||
default=Status.PENDING,
|
default=Status.PENDING,
|
||||||
verbose_name=_("Status"),
|
verbose_name=_("Status"),
|
||||||
)
|
)
|
||||||
related_meeting = models.ForeignKey(
|
inteview= models.ForeignKey(
|
||||||
ZoomMeeting,
|
'InterviewSchedule',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="notifications",
|
related_name="notifications",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name=_("Related Meeting"),
|
verbose_name=_("Related Interview"),
|
||||||
)
|
)
|
||||||
scheduled_for = models.DateTimeField(
|
scheduled_for = models.DateTimeField(
|
||||||
verbose_name=_("Scheduled Send Time"),
|
verbose_name=_("Scheduled Send Time"),
|
||||||
@ -2234,7 +2031,7 @@ class Message(Base):
|
|||||||
|
|
||||||
# If job-related, ensure candidate applied for the job
|
# If job-related, ensure candidate applied for the job
|
||||||
if self.job:
|
if self.job:
|
||||||
if not Candidate.objects.filter(job=self.job, user=self.sender).exists():
|
if not Application.objects.filter(job=self.job, user=self.sender).exists():
|
||||||
raise ValidationError(_("You can only message about jobs you have applied for."))
|
raise ValidationError(_("You can only message about jobs you have applied for."))
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
@ -2332,3 +2129,323 @@ class Document(Base):
|
|||||||
if self.file:
|
if self.file:
|
||||||
return self.file.name.split('.')[-1].upper()
|
return self.file.name.split('.')[-1].upper()
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
class InterviewLocation(Base):
|
||||||
|
"""
|
||||||
|
Base model for all interview location/meeting details (remote or onsite)
|
||||||
|
using Multi-Table Inheritance.
|
||||||
|
"""
|
||||||
|
class LocationType(models.TextChoices):
|
||||||
|
REMOTE = 'Remote', _('Remote (e.g., Zoom, Google Meet)')
|
||||||
|
ONSITE = 'Onsite', _('In-Person (Physical Location)')
|
||||||
|
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
"""Defines the possible real-time statuses for any interview location/meeting."""
|
||||||
|
WAITING = "waiting", _("Waiting")
|
||||||
|
STARTED = "started", _("Started")
|
||||||
|
ENDED = "ended", _("Ended")
|
||||||
|
CANCELLED = "cancelled", _("Cancelled")
|
||||||
|
|
||||||
|
location_type = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=LocationType.choices,
|
||||||
|
verbose_name=_("Location Type"),
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
details_url = models.URLField(
|
||||||
|
verbose_name=_("Meeting/Location URL"),
|
||||||
|
max_length=2048,
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
topic = models.CharField( # Renamed from 'description' to 'topic' to match your input
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_("Location/Meeting Topic"),
|
||||||
|
blank=True,
|
||||||
|
help_text=_("e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'")
|
||||||
|
)
|
||||||
|
|
||||||
|
timezone = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
verbose_name=_("Timezone"),
|
||||||
|
default='UTC'
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
# Use 'topic' instead of 'description'
|
||||||
|
return f"{self.get_location_type_display()} - {self.topic[:50]}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Interview Location")
|
||||||
|
verbose_name_plural = _("Interview Locations")
|
||||||
|
|
||||||
|
|
||||||
|
class ZoomMeetingDetails(InterviewLocation):
|
||||||
|
"""Concrete model for remote interviews (Zoom specifics)."""
|
||||||
|
|
||||||
|
status = models.CharField(
|
||||||
|
db_index=True,
|
||||||
|
max_length=20,
|
||||||
|
choices=InterviewLocation.Status.choices,
|
||||||
|
default=InterviewLocation.Status.WAITING,
|
||||||
|
)
|
||||||
|
start_time = models.DateTimeField(
|
||||||
|
db_index=True, verbose_name=_("Start Time")
|
||||||
|
)
|
||||||
|
duration = models.PositiveIntegerField(
|
||||||
|
verbose_name=_("Duration (minutes)")
|
||||||
|
)
|
||||||
|
meeting_id = models.CharField(
|
||||||
|
db_index=True,
|
||||||
|
max_length=50,
|
||||||
|
unique=True,
|
||||||
|
verbose_name=_("External Meeting ID")
|
||||||
|
)
|
||||||
|
password = models.CharField(
|
||||||
|
max_length=20, blank=True, null=True, verbose_name=_("Password")
|
||||||
|
)
|
||||||
|
zoom_gateway_response = models.JSONField(
|
||||||
|
blank=True, null=True, verbose_name=_("Zoom Gateway Response")
|
||||||
|
)
|
||||||
|
participant_video = models.BooleanField(
|
||||||
|
default=True, verbose_name=_("Participant Video")
|
||||||
|
)
|
||||||
|
join_before_host = models.BooleanField(
|
||||||
|
default=False, verbose_name=_("Join Before Host")
|
||||||
|
)
|
||||||
|
|
||||||
|
host_email=models.CharField(null=True,blank=True)
|
||||||
|
mute_upon_entry = models.BooleanField(
|
||||||
|
default=False, verbose_name=_("Mute Upon Entry")
|
||||||
|
)
|
||||||
|
waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room"))
|
||||||
|
|
||||||
|
# *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
|
||||||
|
# @classmethod
|
||||||
|
# def create(cls, **kwargs):
|
||||||
|
# """Factory method to ensure location_type is set to REMOTE."""
|
||||||
|
# return cls(location_type=InterviewLocation.LocationType.REMOTE, **kwargs)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Zoom Meeting Details")
|
||||||
|
verbose_name_plural = _("Zoom Meeting Details")
|
||||||
|
|
||||||
|
|
||||||
|
class OnsiteLocationDetails(InterviewLocation):
|
||||||
|
"""Concrete model for onsite interviews (Room/Address specifics)."""
|
||||||
|
|
||||||
|
physical_address = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_("Physical Address"),
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
|
room_number = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
verbose_name=_("Room Number/Name"),
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
|
start_time = models.DateTimeField(
|
||||||
|
db_index=True, verbose_name=_("Start Time")
|
||||||
|
)
|
||||||
|
duration = models.PositiveIntegerField(
|
||||||
|
verbose_name=_("Duration (minutes)")
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
db_index=True,
|
||||||
|
max_length=20,
|
||||||
|
choices=InterviewLocation.Status.choices,
|
||||||
|
default=InterviewLocation.Status.WAITING,
|
||||||
|
)
|
||||||
|
|
||||||
|
# *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
|
||||||
|
# @classmethod
|
||||||
|
# def create(cls, **kwargs):
|
||||||
|
# """Factory method to ensure location_type is set to ONSITE."""
|
||||||
|
# return cls(location_type=InterviewLocation.LocationType.ONSITE, **kwargs)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Onsite Location Details")
|
||||||
|
verbose_name_plural = _("Onsite Location Details")
|
||||||
|
|
||||||
|
|
||||||
|
# --- 2. Scheduling Models ---
|
||||||
|
|
||||||
|
class InterviewSchedule(Base):
|
||||||
|
"""Stores the TEMPLATE criteria for BULK interview generation."""
|
||||||
|
|
||||||
|
# We need a field to store the template location details linked to this bulk schedule.
|
||||||
|
# This location object contains the generic Zoom/Onsite info to be cloned.
|
||||||
|
template_location = models.ForeignKey(
|
||||||
|
InterviewLocation,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="schedule_templates",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("Location Template (Zoom/Onsite)")
|
||||||
|
)
|
||||||
|
|
||||||
|
# NOTE: schedule_interview_type field is needed in the form,
|
||||||
|
# but not on the model itself if we use template_location.
|
||||||
|
# If you want to keep it:
|
||||||
|
schedule_interview_type = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=InterviewLocation.LocationType.choices,
|
||||||
|
verbose_name=_("Interview Type"),
|
||||||
|
default=InterviewLocation.LocationType.REMOTE
|
||||||
|
)
|
||||||
|
|
||||||
|
job = models.ForeignKey(
|
||||||
|
JobPosting,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="interview_schedules",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
applications = models.ManyToManyField(
|
||||||
|
Application, related_name="interview_schedules", blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
start_date = models.DateField(db_index=True, verbose_name=_("Start Date"))
|
||||||
|
end_date = models.DateField(db_index=True, verbose_name=_("End Date"))
|
||||||
|
|
||||||
|
working_days = models.JSONField(
|
||||||
|
verbose_name=_("Working Days")
|
||||||
|
)
|
||||||
|
|
||||||
|
start_time = models.TimeField(verbose_name=_("Start Time"))
|
||||||
|
end_time = models.TimeField(verbose_name=_("End Time"))
|
||||||
|
|
||||||
|
break_start_time = models.TimeField(
|
||||||
|
verbose_name=_("Break Start Time"), null=True, blank=True
|
||||||
|
)
|
||||||
|
break_end_time = models.TimeField(
|
||||||
|
verbose_name=_("Break End Time"), null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
interview_duration = models.PositiveIntegerField(
|
||||||
|
verbose_name=_("Interview Duration (minutes)")
|
||||||
|
)
|
||||||
|
buffer_time = models.PositiveIntegerField(
|
||||||
|
verbose_name=_("Buffer Time (minutes)"), default=0
|
||||||
|
)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
User, on_delete=models.CASCADE, db_index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Schedule for {self.job.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledInterview(Base):
|
||||||
|
"""Stores individual scheduled interviews (whether bulk or individually created)."""
|
||||||
|
|
||||||
|
class InterviewStatus(models.TextChoices):
|
||||||
|
SCHEDULED = "scheduled", _("Scheduled")
|
||||||
|
CONFIRMED = "confirmed", _("Confirmed")
|
||||||
|
CANCELLED = "cancelled", _("Cancelled")
|
||||||
|
COMPLETED = "completed", _("Completed")
|
||||||
|
|
||||||
|
application = models.ForeignKey(
|
||||||
|
Application,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="scheduled_interviews",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
job = models.ForeignKey(
|
||||||
|
JobPosting,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="scheduled_interviews",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Links to the specific, individual location/meeting details for THIS interview
|
||||||
|
interview_location = models.OneToOneField(
|
||||||
|
InterviewLocation,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="scheduled_interview",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
verbose_name=_("Meeting/Location Details")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Link back to the bulk schedule template (optional if individually created)
|
||||||
|
schedule = models.ForeignKey(
|
||||||
|
InterviewSchedule,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="interviews",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
participants = models.ManyToManyField('Participants', blank=True)
|
||||||
|
system_users = models.ManyToManyField(User, related_name="attended_interviews", blank=True)
|
||||||
|
|
||||||
|
interview_date = models.DateField(db_index=True, verbose_name=_("Interview Date"))
|
||||||
|
interview_time = models.TimeField(verbose_name=_("Interview Time"))
|
||||||
|
|
||||||
|
status = models.CharField(
|
||||||
|
db_index=True,
|
||||||
|
max_length=20,
|
||||||
|
choices=InterviewStatus.choices,
|
||||||
|
default=InterviewStatus.SCHEDULED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Interview with {self.application.person.full_name} for {self.job.title}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["job", "status"]),
|
||||||
|
models.Index(fields=["interview_date", "interview_time"]),
|
||||||
|
models.Index(fields=["application", "job"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- 3. Interview Notes Model (Fixed) ---
|
||||||
|
|
||||||
|
class InterviewNote(Base):
|
||||||
|
"""Model for storing notes, feedback, or comments related to a specific ScheduledInterview."""
|
||||||
|
|
||||||
|
class NoteType(models.TextChoices):
|
||||||
|
FEEDBACK = 'Feedback', _('Candidate Feedback')
|
||||||
|
LOGISTICS = 'Logistics', _('Logistical Note')
|
||||||
|
GENERAL = 'General', _('General Comment')
|
||||||
|
|
||||||
|
1
|
||||||
|
interview = models.ForeignKey(
|
||||||
|
ScheduledInterview,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notes",
|
||||||
|
verbose_name=_("Scheduled Interview"),
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
author = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="interview_notes",
|
||||||
|
verbose_name=_("Author"),
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
note_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=NoteType.choices,
|
||||||
|
default=NoteType.FEEDBACK,
|
||||||
|
verbose_name=_("Note Type")
|
||||||
|
)
|
||||||
|
|
||||||
|
content = CKEditor5Field(verbose_name=_("Content/Feedback"), config_name="extends")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Interview Note")
|
||||||
|
verbose_name_plural = _("Interview Notes")
|
||||||
|
ordering = ["created_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.get_note_type_display()} by {self.author.get_username()} on {self.interview.id}"
|
||||||
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 django.shortcuts import get_object_or_404
|
||||||
from . models import JobPosting
|
from . models import JobPosting
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from . models import InterviewSchedule,ScheduledInterview,ZoomMeeting
|
from . models import InterviewSchedule,ScheduledInterview,ZoomMeetingDetails
|
||||||
|
|
||||||
# Add python-docx import for Word document processing
|
# Add python-docx import for Word document processing
|
||||||
try:
|
try:
|
||||||
@ -26,7 +26,7 @@ except ImportError:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
OPENROUTER_API_KEY ='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'
|
OPENROUTER_API_KEY ='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'
|
||||||
OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free'
|
OPENROUTER_MODEL = 'x-ai/grok-code-fast-1'
|
||||||
|
|
||||||
# OPENROUTER_MODEL = 'openai/gpt-oss-20b:free'
|
# OPENROUTER_MODEL = 'openai/gpt-oss-20b:free'
|
||||||
# OPENROUTER_MODEL = 'openai/gpt-oss-20b'
|
# OPENROUTER_MODEL = 'openai/gpt-oss-20b'
|
||||||
@ -440,7 +440,7 @@ def handle_reume_parsing_and_scoring(pk):
|
|||||||
print(f"Successfully scored and saved analysis for candidate {instance.id}")
|
print(f"Successfully scored and saved analysis for candidate {instance.id}")
|
||||||
|
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
def create_interview_and_meeting(
|
def create_interview_and_meeting(
|
||||||
candidate_id,
|
candidate_id,
|
||||||
job_id,
|
job_id,
|
||||||
@ -457,7 +457,7 @@ def create_interview_and_meeting(
|
|||||||
job = JobPosting.objects.get(pk=job_id)
|
job = JobPosting.objects.get(pk=job_id)
|
||||||
schedule = InterviewSchedule.objects.get(pk=schedule_id)
|
schedule = InterviewSchedule.objects.get(pk=schedule_id)
|
||||||
|
|
||||||
interview_datetime = datetime.combine(slot_date, slot_time)
|
interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time))
|
||||||
meeting_topic = f"Interview for {job.title} - {candidate.name}"
|
meeting_topic = f"Interview for {job.title} - {candidate.name}"
|
||||||
|
|
||||||
# 1. External API Call (Slow)
|
# 1. External API Call (Slow)
|
||||||
@ -466,24 +466,26 @@ def create_interview_and_meeting(
|
|||||||
|
|
||||||
if result["status"] == "success":
|
if result["status"] == "success":
|
||||||
# 2. Database Writes (Slow)
|
# 2. Database Writes (Slow)
|
||||||
zoom_meeting = ZoomMeeting.objects.create(
|
zoom_meeting = ZoomMeetingDetails.objects.create(
|
||||||
topic=meeting_topic,
|
topic=meeting_topic,
|
||||||
start_time=interview_datetime,
|
start_time=interview_datetime,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
meeting_id=result["meeting_details"]["meeting_id"],
|
meeting_id=result["meeting_details"]["meeting_id"],
|
||||||
join_url=result["meeting_details"]["join_url"],
|
details_url=result["meeting_details"]["join_url"],
|
||||||
zoom_gateway_response=result["zoom_gateway_response"],
|
zoom_gateway_response=result["zoom_gateway_response"],
|
||||||
host_email=result["meeting_details"]["host_email"],
|
host_email=result["meeting_details"]["host_email"],
|
||||||
password=result["meeting_details"]["password"]
|
password=result["meeting_details"]["password"],
|
||||||
|
location_type="Remote"
|
||||||
)
|
)
|
||||||
ScheduledInterview.objects.create(
|
ScheduledInterview.objects.create(
|
||||||
application=Application,
|
application=candidate,
|
||||||
job=job,
|
job=job,
|
||||||
zoom_meeting=zoom_meeting,
|
interview_location=zoom_meeting,
|
||||||
schedule=schedule,
|
schedule=schedule,
|
||||||
interview_date=slot_date,
|
interview_date=slot_date,
|
||||||
interview_time=slot_time
|
interview_time=slot_time
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log success or use Django-Q result system for monitoring
|
# Log success or use Django-Q result system for monitoring
|
||||||
logger.info(f"Successfully scheduled interview for {Application.name}")
|
logger.info(f"Successfully scheduled interview for {Application.name}")
|
||||||
return True # Task succeeded
|
return True # Task succeeded
|
||||||
@ -517,7 +519,7 @@ def handle_zoom_webhook_event(payload):
|
|||||||
try:
|
try:
|
||||||
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
|
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
|
||||||
# and to simplify the logic flow.
|
# and to simplify the logic flow.
|
||||||
meeting_instance = ZoomMeeting.objects.filter(meeting_id=meeting_id_zoom).first()
|
meeting_instance = ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first()
|
||||||
print(meeting_instance)
|
print(meeting_instance)
|
||||||
# --- 1. Creation and Update Events ---
|
# --- 1. Creation and Update Events ---
|
||||||
if event_type == 'meeting.updated':
|
if event_type == 'meeting.updated':
|
||||||
@ -698,7 +700,7 @@ def sync_candidate_to_source_task(candidate_id, source_id):
|
|||||||
dict: Sync result for this specific candidate-source pair
|
dict: Sync result for this specific candidate-source pair
|
||||||
"""
|
"""
|
||||||
from .candidate_sync_service import CandidateSyncService
|
from .candidate_sync_service import CandidateSyncService
|
||||||
from .models import Candidate, Source, IntegrationLog
|
from .models import Application, Source, IntegrationLog
|
||||||
|
|
||||||
logger.info(f"Starting sync task for candidate {candidate_id} to source {source_id}")
|
logger.info(f"Starting sync task for candidate {candidate_id} to source {source_id}")
|
||||||
|
|
||||||
|
|||||||
@ -575,7 +575,7 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
# Email composition URLs
|
# Email composition URLs
|
||||||
path(
|
path(
|
||||||
"jobs/<slug:job_slug>/candidates/<slug:candidate_slug>/compose-email/",
|
"jobs/<slug:job_slug>/candidates/compose-email/",
|
||||||
views.compose_candidate_email,
|
views.compose_candidate_email,
|
||||||
name="compose_candidate_email",
|
name="compose_candidate_email",
|
||||||
),
|
),
|
||||||
@ -594,16 +594,41 @@ urlpatterns = [
|
|||||||
path("documents/<int:document_id>/delete/", views.document_delete, name="document_delete"),
|
path("documents/<int:document_id>/delete/", views.document_delete, name="document_delete"),
|
||||||
path("documents/<int:document_id>/download/", views.document_download, name="document_download"),
|
path("documents/<int:document_id>/download/", views.document_download, name="document_download"),
|
||||||
path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'),
|
path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'),
|
||||||
|
|
||||||
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
|
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
|
||||||
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
|
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
|
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
|
||||||
# path('interview/list/', views.InterviewListView.as_view(), name='interview_list'),
|
# path('interview/list/', views.interview_list, name='interview_list'),
|
||||||
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'),
|
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'),
|
||||||
# path('interviews/<slug:slug>/update/', views.ScheduledInterviewUpdateView.as_view(), name='update_scheduled_interview'),
|
# path('interviews/<slug:slug>/update/', views.ScheduledInterviewUpdateView.as_view(), name='update_scheduled_interview'),
|
||||||
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
|
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
|
||||||
|
path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
|
||||||
|
|
||||||
|
# 1. Onsite Reschedule URL
|
||||||
|
path(
|
||||||
|
'<slug:slug>/candidate/<int:candidate_id>/onsite/reschedule/<int:meeting_id>/',
|
||||||
|
views.reschedule_onsite_meeting,
|
||||||
|
name='reschedule_onsite_meeting'
|
||||||
|
),
|
||||||
|
|
||||||
|
# 2. Onsite Delete URL
|
||||||
|
|
||||||
|
path(
|
||||||
|
'job/<slug:slug>/candidates/<int:candidate_pk>/delete-onsite-meeting/<int:meeting_id>/',
|
||||||
|
views.delete_onsite_meeting_for_candidate,
|
||||||
|
name='delete_onsite_meeting_for_candidate'
|
||||||
|
),
|
||||||
|
|
||||||
|
path(
|
||||||
|
'job/<slug:slug>/candidate/<int:candidate_pk>/schedule/onsite/',
|
||||||
|
views.schedule_onsite_meeting_for_candidate,
|
||||||
|
name='schedule_onsite_meeting_for_candidate' # This is the name used in the button
|
||||||
|
),
|
||||||
|
|
||||||
|
|
||||||
|
# Detail View (assuming slug is on ScheduledInterview)
|
||||||
|
# path("interviews/meetings/<slug:slug>/", views.MeetingDetailView.as_view(), name="meeting_details"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -594,7 +594,7 @@ def update_meeting(instance, updated_data):
|
|||||||
instance.topic = zoom_details.get("topic", instance.topic)
|
instance.topic = zoom_details.get("topic", instance.topic)
|
||||||
|
|
||||||
instance.duration = zoom_details.get("duration", instance.duration)
|
instance.duration = zoom_details.get("duration", instance.duration)
|
||||||
instance.join_url = zoom_details.get("join_url", instance.join_url)
|
instance.details_url = zoom_details.get("join_url", instance.details_url)
|
||||||
instance.password = zoom_details.get("password", instance.password)
|
instance.password = zoom_details.get("password", instance.password)
|
||||||
# Corrected status assignment: instance.status, not instance.password
|
# Corrected status assignment: instance.status, not instance.password
|
||||||
instance.status = zoom_details.get("status")
|
instance.status = zoom_details.get("status")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ from django.db.models.fields.json import KeyTextTransform
|
|||||||
from recruitment.utils import json_to_markdown_table
|
from recruitment.utils import json_to_markdown_table
|
||||||
from django.db.models import Count, Avg, F, FloatField
|
from django.db.models import Count, Avg, F, FloatField
|
||||||
from django.db.models.functions import Cast
|
from django.db.models.functions import Cast
|
||||||
|
from django.db.models.functions import Coalesce, Cast, Replace, NullIf
|
||||||
from . import models
|
from . import models
|
||||||
from django.utils.translation import get_language
|
from django.utils.translation import get_language
|
||||||
from . import forms
|
from . import forms
|
||||||
@ -22,7 +23,7 @@ from django.views.generic import ListView, CreateView, UpdateView, DeleteView, D
|
|||||||
# JobForm removed - using JobPostingForm instead
|
# JobForm removed - using JobPostingForm instead
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.db.models import FloatField
|
from django.db.models import FloatField
|
||||||
from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields
|
from django.db.models import F, IntegerField, Count, Avg, Sum, Q, ExpressionWrapper, fields, Value,CharField
|
||||||
from django.db.models.functions import Cast, Coalesce, TruncDate
|
from django.db.models.functions import Cast, Coalesce, TruncDate
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
@ -454,6 +455,29 @@ def dashboard_view(request):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# safe_match_score_cast = Cast(
|
||||||
|
# # 3. If the result after stripping quotes is an empty string (''), convert it to NULL.
|
||||||
|
# NullIf(
|
||||||
|
# # 2. Use Replace to remove the literal double quotes (") that might be present.
|
||||||
|
# Replace(
|
||||||
|
# # 1. Use the double-underscore path (which uses the ->> operator for the final value)
|
||||||
|
# # and cast to CharField for text-based cleanup functions.
|
||||||
|
# Cast(SCORE_PATH, output_field=CharField()),
|
||||||
|
# Value('"'), Value('') # Replace the double quote character with an empty string
|
||||||
|
# ),
|
||||||
|
# Value('') # Value to check for (empty string)
|
||||||
|
# ),
|
||||||
|
# output_field=IntegerField() # 4. Cast the clean, non-empty string (or NULL) to an integer.
|
||||||
|
# )
|
||||||
|
|
||||||
|
|
||||||
|
# candidates_with_score_query= candidate_queryset.filter(is_resume_parsed=True).annotate(
|
||||||
|
# # The Coalesce handles NULL values (from missing data, non-numeric data, or NullIf) and sets them to 0.
|
||||||
|
# annotated_match_score=Coalesce(safe_match_score_cast, Value(0))
|
||||||
|
# )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# A. Pipeline & Volume Metrics (Scoped)
|
# A. Pipeline & Volume Metrics (Scoped)
|
||||||
total_active_jobs = job_scope_queryset.filter(status="ACTIVE").count()
|
total_active_jobs = job_scope_queryset.filter(status="ACTIVE").count()
|
||||||
last_week = timezone.now() - timedelta(days=7)
|
last_week = timezone.now() - timedelta(days=7)
|
||||||
|
|||||||
@ -119,10 +119,10 @@ def create_zoom_meeting(topic, start_time, duration, host_email):
|
|||||||
|
|
||||||
# Step 11: Analytics Dashboard (recruitment/dashboard.py)
|
# Step 11: Analytics Dashboard (recruitment/dashboard.py)
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from .models import Candidate
|
from .models import Application
|
||||||
|
|
||||||
def get_dashboard_data():
|
def get_dashboard_data():
|
||||||
df = pd.DataFrame(list(Candidate.objects.all().values('status', 'created_at')))
|
df = pd.DataFrame(list( Application.objects.all().values('status', 'created_at')))
|
||||||
summary = df['status'].value_counts().to_dict()
|
summary = df['status'].value_counts().to_dict()
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{{interviews}}
|
||||||
<div class="container-fluid py-4">
|
<div class="container-fluid py-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
<h3 class="text-center font-weight-light my-4">Set Interview Location</h3>
|
<h3 class="text-center font-weight-light my-4">Set Interview Location</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="{% url 'schedule_interview_location_form' schedule.slug %}" enctype="multipart/form-data">
|
<form method="post" action="{% url 'confirm_schedule_interviews_view' job.slug %}" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{# Renders the single 'location' field using the crispy filter #}
|
{# Renders the single 'location' field using the crispy filter #}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static %}
|
{% load static crispy_forms_tags %}
|
||||||
{%load i18n %}
|
{%load i18n %}
|
||||||
|
|
||||||
{% block customCSS %}
|
{% block customCSS %}
|
||||||
@ -119,7 +119,7 @@
|
|||||||
{% if not forloop.last %}, {% endif %}
|
{% if not forloop.last %}, {% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Type:</strong> {{interview_type}}</p>
|
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Type:</strong> {{schedule_interview_type}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -163,23 +163,57 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ item.date|date:"F j, Y" }}</td>
|
<td>{{ item.date|date:"F j, Y" }}</td>
|
||||||
<td>{{ item.time|time:"g:i A" }}</td>
|
<td>{{ item.time|time:"g:i A" }}</td>
|
||||||
<td>{{ item.applications.name }}</td>
|
<td>{{ item.application.name }}</td>
|
||||||
<td>{{ item.applications.email }}</td>
|
<td>{{ item.application.email }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{% if schedule_interview_type == "Onsite" %}
|
||||||
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3">
|
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4" data-bs-toggle="modal" data-bs-target="#interviewDetailsModal" data-placement="top">
|
||||||
{% csrf_token %}
|
|
||||||
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary px-4">
|
|
||||||
<i class="fas fa-arrow-left me-2"></i> {% trans "Back to Edit" %}
|
|
||||||
</a>
|
|
||||||
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4">
|
|
||||||
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
|
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
{% else %}
|
||||||
|
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary px-4">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i> {% trans "Back to Edit" %}
|
||||||
|
</a>
|
||||||
|
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4">
|
||||||
|
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="interviewDetailsModal" tabindex="-1" aria-labelledby="interviewDetailsModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="interviewDetailsModalLabel">{% trans "Interview Details" %}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body"> <form method="post" action="{% url 'confirm_schedule_interviews_view' job.slug %}" enctype="multipart/form-data" id="onsite-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{# Renders the single 'location' field using the crispy filter #}
|
||||||
|
{{ form|crispy }}
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
|
||||||
|
<a href="{% url 'list_meetings' %}" class="btn btn-secondary me-2">
|
||||||
|
<i class="fas fa-times me-1"></i> Close
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary" form="onsite-form">
|
||||||
|
<i class="fas fa-save me-1"></i> Save Location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -200,13 +234,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
events: [
|
events: [
|
||||||
{% for item in schedule %}
|
{% for item in schedule %}
|
||||||
{
|
{
|
||||||
title: '{{ item.candidate.name }}',
|
title: '{{ item.application.name }}',
|
||||||
start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}',
|
start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}',
|
||||||
url: '#',
|
url: '#',
|
||||||
// Use the theme color for candidate events
|
// Use the theme color for candidate events
|
||||||
color: 'var(--kaauh-teal-dark)',
|
color: 'var(--kaauh-teal-dark)',
|
||||||
extendedProps: {
|
extendedProps: {
|
||||||
email: '{{ item.candidate.email }}',
|
email: '{{ item.application.email }}',
|
||||||
time: '{{ item.time|time:"g:i A" }}'
|
time: '{{ item.time|time:"g:i A" }}'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -142,8 +142,8 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div class="form-group mb-3">
|
<div class="form-group mb-3">
|
||||||
<label for="{{ form.start_date.id_for_label }}">{% trans "Interview Type" %}</label>
|
<label for="{{ form.schedule_interview_type.id_for_label }}">{% trans "Interview Type" %}</label>
|
||||||
{{ form.interview_type }}
|
{{ form.schedule_interview_type }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
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" %}
|
{% extends "base.html" %}
|
||||||
{% load static i18n %}
|
{% load static i18n %}
|
||||||
|
|
||||||
{% block title %}{% trans "Zoom Meetings" %} - {{ block.super }}{% endblock %}
|
{% block title %}{% trans "Interviews & Meetings" %} - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
{% block customCSS %}
|
{% block customCSS %}
|
||||||
<style>
|
<style>
|
||||||
@ -12,9 +12,12 @@
|
|||||||
--kaauh-border: #eaeff3;
|
--kaauh-border: #eaeff3;
|
||||||
--kaauh-primary-text: #343a40;
|
--kaauh-primary-text: #343a40;
|
||||||
--kaauh-gray-light: #f8f9fa;
|
--kaauh-gray-light: #f8f9fa;
|
||||||
|
--kaauh-warning: #ffc107;
|
||||||
|
--kaauh-danger: #dc3545;
|
||||||
|
--kaauh-success: #28a745;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enhanced Card Styling (Consistent) */
|
/* Enhanced Card Styling */
|
||||||
.card {
|
.card {
|
||||||
border: 1px solid var(--kaauh-border);
|
border: 1px solid var(--kaauh-border);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
@ -26,12 +29,6 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
|
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
.card.no-hover:hover {
|
|
||||||
transform: none;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main Action Button Style (Teal Theme) */
|
|
||||||
.btn-main-action {
|
.btn-main-action {
|
||||||
background-color: var(--kaauh-teal);
|
background-color: var(--kaauh-teal);
|
||||||
border-color: var(--kaauh-teal);
|
border-color: var(--kaauh-teal);
|
||||||
@ -42,14 +39,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-main-action:hover {
|
.btn-main-action:hover {
|
||||||
background-color: var(--kaauh-teal-dark);
|
background-color: var(--kaauh-teal-dark);
|
||||||
border-color: var(--kaauh-teal-dark);
|
border-color: var(--kaauh-teal-dark);
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Secondary Button Style (For Edit/Outline - Consistent) */
|
|
||||||
.btn-outline-secondary {
|
.btn-outline-secondary {
|
||||||
color: var(--kaauh-teal-dark);
|
color: var(--kaauh-teal-dark);
|
||||||
border-color: var(--kaauh-teal);
|
border-color: var(--kaauh-teal);
|
||||||
@ -59,7 +53,6 @@
|
|||||||
color: white;
|
color: white;
|
||||||
border-color: var(--kaauh-teal-dark);
|
border-color: var(--kaauh-teal-dark);
|
||||||
}
|
}
|
||||||
/* Primary Outline for View/Join */
|
|
||||||
.btn-outline-primary {
|
.btn-outline-primary {
|
||||||
color: var(--kaauh-teal);
|
color: var(--kaauh-teal);
|
||||||
border-color: var(--kaauh-teal);
|
border-color: var(--kaauh-teal);
|
||||||
@ -68,8 +61,6 @@
|
|||||||
background-color: var(--kaauh-teal);
|
background-color: var(--kaauh-teal);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Meeting Card Specifics (Adapted to Standard Card View) */
|
|
||||||
.meeting-card .card-title {
|
.meeting-card .card-title {
|
||||||
color: var(--kaauh-teal-dark);
|
color: var(--kaauh-teal-dark);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@ -79,14 +70,7 @@
|
|||||||
color: var(--kaauh-teal);
|
color: var(--kaauh-teal);
|
||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
}
|
}
|
||||||
/* Primary Color Overrides */
|
|
||||||
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
||||||
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
|
|
||||||
.text-success { color: var(--kaauh-success) !important; }
|
|
||||||
.text-danger { color: var(--kaauh-danger) !important; }
|
|
||||||
.text-info { color: #17a2b8 !important; }
|
|
||||||
|
|
||||||
/* Status Badges (Standardized) */
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
padding: 0.4em 0.8em;
|
padding: 0.4em 0.8em;
|
||||||
@ -95,13 +79,16 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.7px;
|
letter-spacing: 0.7px;
|
||||||
}
|
}
|
||||||
/* Status Badge Mapping */
|
/* Statuses */
|
||||||
.bg-waiting { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;}
|
.bg-waiting { background-color: var(--kaauh-warning) !important; color: var(--kaauh-primary-text) !important;}
|
||||||
.bg-scheduled { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;}
|
|
||||||
.bg-started { background-color: var(--kaauh-teal) !important; color: white !important;}
|
.bg-started { background-color: var(--kaauh-teal) !important; color: white !important;}
|
||||||
.bg-ended { background-color: #dc3545 !important; color: white !important;}
|
.bg-ended { background-color: var(--kaauh-danger) !important; color: white !important;}
|
||||||
|
.bg-scheduled { background-color: #5bc0de !important; color: white !important;}
|
||||||
|
/* Event Types */
|
||||||
|
.bg-Remote { background-color: var(--kaauh-teal) !important; color: white !important; }
|
||||||
|
.bg-Onsite { background-color: #007bff !important; color: white !important; }
|
||||||
|
|
||||||
/* Table Styling (Consistent with Reference) */
|
/* Table Styling */
|
||||||
.table-view .table thead th {
|
.table-view .table thead th {
|
||||||
background-color: var(--kaauh-teal-dark);
|
background-color: var(--kaauh-teal-dark);
|
||||||
color: white;
|
color: white;
|
||||||
@ -120,51 +107,11 @@
|
|||||||
.table-view .table tbody tr:hover {
|
.table-view .table tbody tr:hover {
|
||||||
background-color: var(--kaauh-gray-light);
|
background-color: var(--kaauh-gray-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pagination Link Styling (Consistent) */
|
|
||||||
.pagination .page-item .page-link {
|
|
||||||
color: var(--kaauh-teal-dark);
|
|
||||||
border-color: var(--kaauh-border);
|
|
||||||
}
|
|
||||||
.pagination .page-item.active .page-link {
|
.pagination .page-item.active .page-link {
|
||||||
background-color: var(--kaauh-teal);
|
background-color: var(--kaauh-teal);
|
||||||
border-color: var(--kaauh-teal);
|
border-color: var(--kaauh-teal);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
.pagination .page-item:hover .page-link:not(.active) {
|
|
||||||
background-color: #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Filter & Search Layout Adjustments */
|
|
||||||
.filter-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Icon color for empty state */
|
|
||||||
.text-muted.fa-3x {
|
|
||||||
color: var(--kaauh-teal-dark) !important;
|
|
||||||
}
|
|
||||||
@keyframes svg-pulse {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.9);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(0.9);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Apply the animation to the custom class */
|
|
||||||
.svg-pulse {
|
|
||||||
animation: svg-pulse 2s infinite ease-in-out;
|
|
||||||
transform-origin: center; /* Ensure scaling is centered */
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -172,48 +119,66 @@
|
|||||||
<div class="container-fluid py-4">
|
<div class="container-fluid py-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
<h1 style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||||
<i class="fas fa-video me-2"></i> {% trans "Zoom Meetings" %}
|
<i class="fas fa-calendar-alt me-2"></i> {% trans "Interviews & Meetings" %}
|
||||||
</h1>
|
</h1>
|
||||||
<a href="{% url 'create_meeting' %}" class="btn btn-main-action">
|
{% comment %} <div class="btn-group" role="group">
|
||||||
<i class="fas fa-plus me-1"></i> {% trans "Create Meeting" %}
|
<a href="{% url 'create_remote_meeting' %}" class="btn btn-main-action">
|
||||||
</a>
|
<i class="fas fa-globe me-1"></i> {% trans "Create Remote" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'create_onsite_meeting' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-building me-1"></i> {% trans "Create Onsite" %}
|
||||||
|
</a>
|
||||||
|
</div> {% endcomment %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-4 shadow-sm no-hover">
|
<div class="card mb-4 shadow-sm no-hover">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
<label for="search" class="form-label small text-muted">{% trans "Search by Topic" %}</label>
|
<label for="search" class="form-label small text-muted">{% trans "Search by Topic" %}</label>
|
||||||
<div class="input-group input-group-lg mb-3">
|
<div class="input-group input-group-lg mb-3">
|
||||||
<form method="get" action="" class="w-100">
|
<form method="get" action="" class="w-100">
|
||||||
|
{# Assuming includes/search_form.html handles the 'q' parameter #}
|
||||||
{% include "includes/search_form.html" with search_query=search_query %}
|
{% include "includes/search_form.html" with search_query=search_query %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-8">
|
||||||
<form method="GET" class="row g-3 align-items-end" >
|
<form method="GET" class="row g-3 align-items-end" >
|
||||||
{% if search_query %}<input class="form-control form-control-sm" type="hidden" name="q" value="{{ search_query }}">{% endif %}
|
{# Hidden inputs to persist other filters #}
|
||||||
{% if status_filter %}<input class="form-control form-control-sm" type="hidden" name="status" value="{{ status_filter }}">{% endif %}
|
{% if search_query %}<input type="hidden" name="q" value="{{ search_query }}">{% endif %}
|
||||||
|
{% if status_filter %}<input type="hidden" name="status" value="{{ status_filter }}">{% endif %}
|
||||||
|
{% if candidate_name_filter %}<input type="hidden" name="candidate_name" value="{{ candidate_name_filter }}">{% endif %}
|
||||||
|
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
|
<label for="type_filter" class="form-label small text-muted">{% trans "Interview Type" %}</label>
|
||||||
|
<select name="type" id="type_filter" class="form-select form-select-sm">
|
||||||
|
<option value="">{% trans "All Types" %}</option>
|
||||||
|
<option value="Remote" {% if type_filter == 'Remote' %}selected{% endif %}>{% trans "Remote" %}</option>
|
||||||
|
<option value="Onsite" {% if type_filter == 'Onsite' %}selected{% endif %}>{% trans "Onsite" %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
<label for="status" class="form-label small text-muted">{% trans "Filter by Status" %}</label>
|
<label for="status" class="form-label small text-muted">{% trans "Filter by Status" %}</label>
|
||||||
<select name="status" id="status" class="form-select form-select-sm">
|
<select name="status" id="status" class="form-select form-select-sm">
|
||||||
<option value="">{% trans "All Statuses" %}</option>
|
<option value="">{% trans "All Statuses" %}</option>
|
||||||
<option value="waiting" {% if status_filter == 'waiting' %}selected{% endif %}>{% trans "Waiting" %}</option>
|
{# CORRECTED: Using the context variable passed from the view #}
|
||||||
<option value="started" {% if status_filter == 'started' %}selected{% endif %}>{% trans "Started" %}</option>
|
{% for choice, display in status_choices %}
|
||||||
<option value="ended" {% if status_filter == 'ended' %}selected{% endif %}>{% trans "Ended" %}</option>
|
<option value="{{ choice }}" {% if status_filter == choice %}selected{% endif %}>{{ display }}</option>
|
||||||
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<label for="candidate_name" class="form-label small text-muted">{% trans "Candidate Name" %}</label>
|
<label for="candidate_name" class="form-label small text-muted">{% trans "Candidate Name" %}</label>
|
||||||
<input type="text" class="form-control form-control-sm" id="candidate_name" name="candidate_name" placeholder="{% trans 'Search by candidate...' %}" value="{{ candidate_name_filter }}">
|
<input type="text" class="form-control form-control-sm" id="candidate_name" name="candidate_name" placeholder="{% trans 'Search by candidate...' %}" value="{{ candidate_name_filter }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<div class="filter-buttons">
|
<div class="filter-buttons">
|
||||||
<button type="submit" class="btn btn-main-action btn-sm">
|
<button type="submit" class="btn btn-main-action btn-sm">
|
||||||
<i class="fas fa-filter me-1"></i> {% trans "Apply Filters" %}
|
<i class="fas fa-filter me-1"></i> {% trans "Apply Filters" %}
|
||||||
</button>
|
</button>
|
||||||
{% if status_filter or search_query or candidate_name_filter %}
|
{% if status_filter or search_query or candidate_name_filter or type_filter %}
|
||||||
<a href="{% url 'list_meetings' %}" class="btn btn-outline-secondary btn-sm">
|
<a href="{% url 'list_meetings' %}" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
|
<i class="fas fa-times me-1"></i> {% trans "Clear" %}
|
||||||
</a>
|
</a>
|
||||||
@ -225,59 +190,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if meetings %}
|
|
||||||
|
{% if meetings_data %}
|
||||||
<div id="meetings-list">
|
<div id="meetings-list">
|
||||||
{# View Switcher #}
|
{# View Switcher (not provided, assuming standard include) #}
|
||||||
{% include "includes/_list_view_switcher.html" with list_id="meetings-list" %}
|
{% include "includes/_list_view_switcher.html" with list_id="meetings-list" %}
|
||||||
|
|
||||||
{# Card View #}
|
{# Card View #}
|
||||||
<div class="card-view active row">
|
<div class="card-view active row">
|
||||||
{% for meeting in meetings %}
|
{% for meeting in meetings_data %}
|
||||||
<div class="col-md-6 col-lg-4 mb-4">
|
<div class="col-md-6 col-lg-4 mb-4">
|
||||||
<div class="card meeting-card h-100 shadow-sm">
|
<div class="card meeting-card h-100 shadow-sm">
|
||||||
<div class="card-body d-flex flex-column">
|
<div class="card-body d-flex flex-column">
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<h5 class="card-title flex-grow-1 me-3"><a href="{% url 'meeting_details' meeting.slug %}" class="text-decoration-none text-primary-theme">{{ meeting.topic }}</a></h5>
|
<h5 class="card-title flex-grow-1 me-3"><a href="" class="text-decoration-none text-primary-theme">{{ meeting.topic }}</a></h5>
|
||||||
<span class="status-badge bg-{{ meeting.status }}">
|
{# Display the type badge (Remote/Onsite) #}
|
||||||
{{ meeting.status|title }}
|
<span class="status-badge bg-{{ meeting.type }}">
|
||||||
|
{{ meeting.type|title }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="card-text text-muted small mb-3">
|
<p class="card-text text-muted small mb-3">
|
||||||
<i class="fas fa-user"></i> {% trans "Candidate" %}: {% if meeting.interview %}{{ meeting.interview.candidate.name }}{% else %}
|
<i class="fas fa-user"></i> {% trans "Candidate" %}: {{ meeting.interview.application.person.full_name|default:"N/A" }}<br>
|
||||||
<button data-bs-toggle="modal"
|
<i class="fas fa-briefcase"></i> {% trans "Job" %}: {{ meeting.interview.job.title|default:"N/A" }}<br>
|
||||||
data-bs-target="#meetingModal"
|
|
||||||
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
|
{# Dynamic location/type details #}
|
||||||
hx-target="#meetingModalBody"
|
{% if meeting.type == 'Remote' %}
|
||||||
hx-swap="outerHTML"
|
<i class="fas fa-link"></i> {% trans "Remote ID" %}: {{ meeting.meeting_id|default:meeting.location.id }}<br>
|
||||||
class="btn text-primary-theme btn-link btn-sm">Set Candidate</button>
|
{% elif meeting.type == 'Onsite' %}
|
||||||
{% endif %}<br>
|
{# Use the details object for concrete location info #}
|
||||||
<i class="fas fa-briefcase"></i> {% trans "Job" %}: {% if meeting.interview %}{{ meeting.interview.job.title }}{% else %}
|
<i class="fas fa-map-marker-alt"></i> {% trans "Location" %}: {{ meeting.details.room_number|default:meeting.details.physical_address|truncatechars:30 }}<br>
|
||||||
<button data-bs-toggle="modal"
|
{% endif %}
|
||||||
data-bs-target="#meetingModal"
|
|
||||||
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
|
|
||||||
hx-target="#meetingModalBody"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="btn text-primary-theme btn-link btn-sm">Set Job</button>
|
|
||||||
{% endif %}<br>
|
|
||||||
<i class="fas fa-hashtag"></i> {% trans "ID" %}: {{ meeting.meeting_id|default:meeting.id }}<br>
|
|
||||||
<i class="fas fa-clock"></i> {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }}<br>
|
<i class="fas fa-clock"></i> {% trans "Start" %}: {{ meeting.start_time|date:"M d, Y H:i" }}<br>
|
||||||
<i class="fas fa-stopwatch"></i> {% trans "Duration" %}: {{ meeting.duration }} minutes{% if meeting.password %}<br><i class="fas fa-lock"></i> {% trans "Password" %}: Yes{% endif %}
|
<i class="fas fa-stopwatch"></i> {% trans "Duration" %}: {{ meeting.duration }} minutes
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<span class="status-badge bg-{{ meeting.status }}">
|
||||||
|
{{ meeting.interview.get_status_display }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div class="mt-auto pt-2 border-top">
|
<div class="mt-auto pt-2 border-top">
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<a href="{% url 'meeting_details' meeting.slug %}" class="btn btn-sm btn-outline-primary">
|
<a href="" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="fas fa-eye"></i> {% trans "View" %}
|
<i class="fas fa-eye"></i> {% trans "View" %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if meeting.join_url %}
|
{% if meeting.type == 'Remote' and meeting.join_url %}
|
||||||
<a href="{{ meeting.join_url }}" target="_blank" class="btn btn-sm btn-main-action">
|
<a href="{{ meeting.join_url }}" target="_blank" class="btn btn-sm btn-main-action">
|
||||||
<i class="fas fa-link"></i> {% trans "Join" %}
|
<i class="fas fa-sign-in-alt"></i> {% trans "Join Remote" %}
|
||||||
</a>
|
</a>
|
||||||
|
{% elif meeting.type == 'Onsite' %}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" disabled>
|
||||||
|
<i class="fas fa-check"></i> {% trans "Physical Event" %}
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-sm btn-outline-secondary">
|
{# CORRECTED: Passing the slug to the update URL #}
|
||||||
|
<a href="" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}"
|
<button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}"
|
||||||
@ -303,9 +272,9 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">{% trans "Topic" %}</th>
|
<th scope="col">{% trans "Topic" %}</th>
|
||||||
|
<th scope="col">{% trans "Type" %}</th>
|
||||||
<th scope="col">{% trans "Candidate" %}</th>
|
<th scope="col">{% trans "Candidate" %}</th>
|
||||||
<th scope="col">{% trans "Job" %}</th>
|
<th scope="col">{% trans "Job" %}</th>
|
||||||
<th scope="col">{% trans "ID" %}</th>
|
|
||||||
<th scope="col">{% trans "Start Time" %}</th>
|
<th scope="col">{% trans "Start Time" %}</th>
|
||||||
<th scope="col">{% trans "Duration" %}</th>
|
<th scope="col">{% trans "Duration" %}</th>
|
||||||
<th scope="col">{% trans "Status" %}</th>
|
<th scope="col">{% trans "Status" %}</th>
|
||||||
@ -313,66 +282,46 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for meeting in meetings %}
|
{% for meeting in meetings_data %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong class="text-primary"><a href="{% url 'meeting_details' meeting.slug %}" class="text-decoration-none text-secondary">{{ meeting.topic }}<a></strong></td>
|
<td><strong class="text-primary"><a href="" class="text-decoration-none text-secondary">{{ meeting.topic }}<a></strong></td>
|
||||||
<td>
|
<td>
|
||||||
{% if meeting.interview %}
|
{# Display the event type badge #}
|
||||||
<a class="text-primary text-decoration-none" href="{% url 'candidate_detail' meeting.interview.application.slug %}">{{ meeting.interview.candidate.name }} <i class="fas fa-link"></i></a>
|
<span class="status-badge bg-{{ meeting.type }}">{{ meeting.type|title }}</span>
|
||||||
{% else %}
|
</td>
|
||||||
<button data-bs-toggle="modal"
|
<td>
|
||||||
data-bs-target="#meetingModal"
|
<a class="text-primary text-decoration-none" href="{% url 'candidate_detail' meeting.interview.application.person.slug %}">{{ meeting.interview.application.person.full_name }} <i class="fas fa-link"></i></a>
|
||||||
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
|
|
||||||
hx-target="#meetingModalBody"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="btn btn-outline-primary btn-sm">Set Candidate</button>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if meeting.interview %}
|
|
||||||
<a class="text-primary text-decoration-none" href="{% url 'job_detail' meeting.interview.job.slug %}">{{ meeting.interview.job.title }} <i class="fas fa-link"></i></a>
|
<a class="text-primary text-decoration-none" href="{% url 'job_detail' meeting.interview.job.slug %}">{{ meeting.interview.job.title }} <i class="fas fa-link"></i></a>
|
||||||
{% else %}
|
|
||||||
<button data-bs-toggle="modal"
|
|
||||||
data-bs-target="#meetingModal"
|
|
||||||
hx-get="{% url 'set_meeting_candidate' meeting.slug %}"
|
|
||||||
hx-target="#meetingModalBody"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="btn btn-outline-primary btn-sm">Set Job</button>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
<td>{{ meeting.meeting_id|default:meeting.id }}</td>
|
|
||||||
<td>{{ meeting.start_time|date:"M d, Y H:i" }}</td>
|
<td>{{ meeting.start_time|date:"M d, Y H:i" }}</td>
|
||||||
<td>{{ meeting.duration }} min</td>
|
<td>{{ meeting.duration }} min</td>
|
||||||
<td>
|
<td>
|
||||||
{% if meeting %}
|
{# Display the meeting status badge from the ScheduledInterview model #}
|
||||||
<span class="badge {% if meeting.status == 'waiting' %}bg-warning{% elif meeting.status == 'started' %}bg-success{% elif meeting.status == 'ended' %}bg-danger{% endif %}">
|
<span class="status-badge bg-{{ meeting.status }}">
|
||||||
{% if meeting.status == 'started' %}
|
{{ meeting.interview.get_status_display }}
|
||||||
<i class="fas fa-circle me-1 text-success"></i>
|
|
||||||
{% endif %}
|
|
||||||
{{ meeting.status|title }}
|
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">--</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
{% if meeting.join_url %}
|
{% if meeting.type == 'Remote' and meeting.join_url %}
|
||||||
<a href="{{ meeting.join_url }}" target="_blank" class="btn btn-main-action" title="{% trans 'Join' %}">
|
<a href="{{ meeting.join_url }}" target="_blank" class="btn btn-main-action" title="{% trans 'Join' %}">
|
||||||
<i class="fas fa-sign-in-alt"></i>
|
<i class="fas fa-sign-in-alt"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'meeting_details' meeting.slug %}" class="btn btn-outline-primary" title="{% trans 'View' %}">
|
<a href="" class="btn btn-outline-primary" title="{% trans 'View' %}">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-outline-secondary" title="{% trans 'Update' %}">
|
{# CORRECTED: Passing the slug to the update URL #}
|
||||||
|
<a href="" class="btn btn-outline-secondary" title="{% trans 'Update' %}">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
|
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#meetingModal"
|
data-bs-target="#deleteModal"
|
||||||
hx-post="{% url 'delete_meeting' meeting.slug %}"
|
hx-post="{% url 'delete_meeting' meeting.slug %}"
|
||||||
hx-target="#meetingModalBody"
|
hx-target="#deleteModalBody"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
data-item-name="{{ meeting.topic }}">
|
data-item-name="{{ meeting.topic }}">
|
||||||
<i class="fas fa-trash-alt"></i>
|
<i class="fas fa-trash-alt"></i>
|
||||||
@ -387,16 +336,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Pagination (Standardized) #}
|
{# Pagination (All filters correctly included in query strings) #}
|
||||||
{% if is_paginated %}
|
{% if is_paginated %}
|
||||||
<nav aria-label="Page navigation" class="mt-4">
|
<nav aria-label="Page navigation" class="mt-4">
|
||||||
<ul class="pagination justify-content-center">
|
<ul class="pagination justify-content-center">
|
||||||
{% if page_obj.has_previous %}
|
{% if page_obj.has_previous %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" href="?page=1{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">First</a>
|
<a class="page-link" href="?page=1{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&type={{ type_filter }}{% endif %}{% if candidate_name_filter %}&candidate_name={{ candidate_name_filter }}{% endif %}">First</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">Previous</a>
|
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&type={{ type_filter }}{% endif %}{% if candidate_name_filter %}&candidate_name={{ candidate_name_filter }}{% endif %}">Previous</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -406,10 +355,10 @@
|
|||||||
|
|
||||||
{% if page_obj.has_next %}
|
{% if page_obj.has_next %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">Next</a>
|
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&type={{ type_filter }}{% endif %}{% if candidate_name_filter %}&candidate_name={{ candidate_name_filter }}{% endif %}">Next</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">Last</a>
|
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if status_filter %}&status={{ status_filter }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}{% if type_filter %}&type={{ type_filter }}{% endif %}{% if candidate_name_filter %}&candidate_name={{ candidate_name_filter }}{% endif %}">Last</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
@ -418,14 +367,19 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-5 card shadow-sm">
|
<div class="text-center py-5 card shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<i class="fas fa-video fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
|
<i class="fas fa-calendar-alt fa-3x mb-3" style="color: var(--kaauh-teal-dark);"></i>
|
||||||
<h3>{% trans "No Zoom meetings found" %}</h3>
|
<h3>{% trans "No interviews or meetings found" %}</h3>
|
||||||
<p class="text-muted">{% trans "Create your first meeting or adjust your filters." %}</p>
|
<p class="text-muted">{% trans "Create your first interview or adjust your filters." %}</p>
|
||||||
<a href="{% url 'create_meeting' %}" class="btn btn-main-action mt-3">
|
{% comment %} <div class="btn-group mt-3" role="group">
|
||||||
<i class="fas fa-plus me-1"></i> {% trans "Create Your First Meeting" %}
|
<a href="{% url 'create_remote_meeting' %}" class="btn btn-main-action">
|
||||||
</a>
|
<i class="fas fa-globe me-1"></i> {% trans "Create Remote" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'create_onsite_meeting' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-building me-1"></i> {% trans "Create Onsite" %}
|
||||||
|
</a>
|
||||||
|
</div> {% endcomment %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="candidate-details text-muted">
|
<td class="candidate-details text-muted">
|
||||||
{% if candidate.get_latest_meeting.topic %}
|
{% if candidate.get_latest_meeting %}
|
||||||
{{ candidate.get_latest_meeting.topic }}
|
{{ candidate.get_latest_meeting }}
|
||||||
{% else %}
|
{% else %}
|
||||||
--
|
--
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -380,8 +380,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
||||||
{% if candidate.get_latest_meeting %}
|
{% if candidate.get_latest_meeting %}
|
||||||
|
{% if candidate.get_latest_meeting.location_type == 'Remote'%}
|
||||||
|
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#candidateviewModal"
|
data-bs-target="#candidateviewModal"
|
||||||
@ -399,16 +401,47 @@
|
|||||||
title="Delete Meeting">
|
title="Delete Meeting">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
|
{% else%}
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#candidateviewModal"
|
||||||
|
hx-get="{% url 'reschedule_onsite_meeting' job.slug candidate.pk candidate.get_latest_meeting.pk %}"
|
||||||
|
hx-target="#candidateviewModalBody"
|
||||||
|
title="Reschedule">
|
||||||
|
<i class="fas fa-redo-alt"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#candidateviewModal"
|
||||||
|
hx-get="{% url 'delete_onsite_meeting_for_candidate' job.slug candidate.pk candidate.get_latest_meeting.pk %}"
|
||||||
|
hx-target="#candidateviewModalBody"
|
||||||
|
title="Delete Meeting">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<button type="button" class="btn btn-main-action btn-sm"
|
<button type="button" class="btn btn-main-action btn-sm"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#candidateviewModal"
|
data-bs-target="#candidateviewModal"
|
||||||
hx-get="{% url 'schedule_meeting_for_candidate' job.slug candidate.pk %}"
|
hx-get="{% url 'schedule_meeting_for_candidate' job.slug candidate.pk %}"
|
||||||
hx-target="#candidateviewModalBody"
|
hx-target="#candidateviewModalBody"
|
||||||
data-modal-title="{% trans 'Schedule Interview' %}"
|
data-modal-title="{% trans 'Schedule Interview' %}"
|
||||||
title="Schedule Interview">
|
title="Schedule Interview">
|
||||||
<i class="fas fa-calendar-plus"></i>
|
<i class="fas fa-video"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-main-action btn-sm"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#candidateviewModal"
|
||||||
|
{# UPDATED: Points to the specific Onsite scheduling URL #}
|
||||||
|
hx-get="{% url 'schedule_onsite_meeting_for_candidate' job.slug candidate.pk %}"
|
||||||
|
hx-target="#candidateviewModalBody"
|
||||||
|
data-modal-title="{% trans 'Schedule Onsite Interview' %}"
|
||||||
|
title="Schedule Onsite Interview">
|
||||||
|
<i class="fas fa-building"></i>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user