changes to interview

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

6
.env
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-14 22:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='zoommeetingdetails',
name='host_email',
field=models.CharField(blank=True, null=True),
),
]

View File

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

View File

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

View File

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

View File

@ -575,7 +575,7 @@ urlpatterns = [
),
# Email composition URLs
path(
"jobs/<slug:job_slug>/candidates/<slug:candidate_slug>/compose-email/",
"jobs/<slug:job_slug>/candidates/compose-email/",
views.compose_candidate_email,
name="compose_candidate_email",
),
@ -594,16 +594,41 @@ urlpatterns = [
path("documents/<int:document_id>/delete/", views.document_delete, name="document_delete"),
path("documents/<int:document_id>/download/", views.document_download, name="document_download"),
path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'),
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
# path('interview/list/', views.InterviewListView.as_view(), name='interview_list'),
# path('interview/list/', views.interview_list, name='interview_list'),
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'),
# path('interviews/<slug:slug>/update/', views.ScheduledInterviewUpdateView.as_view(), name='update_scheduled_interview'),
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
# 1. Onsite Reschedule URL
path(
'<slug:slug>/candidate/<int:candidate_id>/onsite/reschedule/<int:meeting_id>/',
views.reschedule_onsite_meeting,
name='reschedule_onsite_meeting'
),
# 2. Onsite Delete URL
path(
'job/<slug:slug>/candidates/<int:candidate_pk>/delete-onsite-meeting/<int:meeting_id>/',
views.delete_onsite_meeting_for_candidate,
name='delete_onsite_meeting_for_candidate'
),
path(
'job/<slug:slug>/candidate/<int:candidate_pk>/schedule/onsite/',
views.schedule_onsite_meeting_for_candidate,
name='schedule_onsite_meeting_for_candidate' # This is the name used in the button
),
# Detail View (assuming slug is on ScheduledInterview)
# path("interviews/meetings/<slug:slug>/", views.MeetingDetailView.as_view(), name="meeting_details"),
]

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
{% extends "base.html" %}
{% load static %}
{% load static crispy_forms_tags %}
{%load i18n %}
{% block customCSS %}
@ -119,7 +119,7 @@
{% if not forloop.last %}, {% endif %}
{% endfor %}
</p>
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Type:</strong> {{interview_type}}</p>
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Type:</strong> {{schedule_interview_type}}</p>
</div>
</div>
@ -163,23 +163,57 @@
<tr>
<td>{{ item.date|date:"F j, Y" }}</td>
<td>{{ item.time|time:"g:i A" }}</td>
<td>{{ item.applications.name }}</td>
<td>{{ item.applications.email }}</td>
<td>{{ item.application.name }}</td>
<td>{{ item.application.email }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<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">
{% if schedule_interview_type == "Onsite" %}
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4" data-bs-toggle="modal" data-bs-target="#interviewDetailsModal" data-placement="top">
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
</button>
</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>
@ -200,13 +234,13 @@ document.addEventListener('DOMContentLoaded', function() {
events: [
{% for item in schedule %}
{
title: '{{ item.candidate.name }}',
title: '{{ item.application.name }}',
start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}',
url: '#',
// Use the theme color for candidate events
color: 'var(--kaauh-teal-dark)',
extendedProps: {
email: '{{ item.candidate.email }}',
email: '{{ item.application.email }}',
time: '{{ item.time|time:"g:i A" }}'
}
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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