Merge pull request 'frontend' (#32) from frontend into main

Reviewed-on: #32
This commit is contained in:
ismail 2025-11-18 13:21:44 +03:00
commit 90c2cee0a3
53 changed files with 2348 additions and 1492 deletions

6
.env
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
# Generated by Django 5.2.6 on 2025-11-13 13:12 # Generated by Django 5.2.7 on 2025-11-17 09:52
import django.contrib.auth.models import django.contrib.auth.models
import django.contrib.auth.validators import django.contrib.auth.validators
@ -49,21 +49,20 @@ class Migration(migrations.Migration):
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='OnsiteMeeting', name='InterviewLocation',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('topic', models.CharField(max_length=255, verbose_name='Topic')), ('location_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], db_index=True, max_length=10, verbose_name='Location Type')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')), ('details_url', models.URLField(blank=True, max_length=2048, null=True, verbose_name='Meeting/Location URL')),
('duration', models.PositiveIntegerField(verbose_name='Duration')), ('topic', models.CharField(blank=True, help_text="e.g., 'Zoom Topic: Software Interview' or 'Main Conference Room, 3rd Floor'", max_length=255, verbose_name='Location/Meeting Topic')),
('timezone', models.CharField(max_length=50, verbose_name='Timezone')), ('timezone', models.CharField(default='UTC', max_length=50, verbose_name='Timezone')),
('location', models.CharField(blank=True, null=True)),
('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')),
], ],
options={ options={
'abstract': False, 'verbose_name': 'Interview Location',
'verbose_name_plural': 'Interview Locations',
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
@ -112,31 +111,6 @@ class Migration(migrations.Migration):
'ordering': ['name'], 'ordering': ['name'],
}, },
), ),
migrations.CreateModel(
name='ZoomMeeting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('topic', models.CharField(max_length=255, verbose_name='Topic')),
('meeting_id', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Meeting ID')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration')),
('timezone', models.CharField(max_length=50, verbose_name='Timezone')),
('join_url', models.URLField(verbose_name='Join URL')),
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
('status', models.CharField(blank=True, db_index=True, default='waiting', max_length=20, null=True, verbose_name='Status')),
],
options={
'abstract': False,
},
),
migrations.CreateModel( migrations.CreateModel(
name='CustomUser', name='CustomUser',
fields=[ fields=[
@ -153,6 +127,8 @@ class Migration(migrations.Migration):
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('user_type', models.CharField(choices=[('staff', 'Staff'), ('agency', 'Agency'), ('candidate', 'Candidate')], default='staff', max_length=20, verbose_name='User Type')), ('user_type', models.CharField(choices=[('staff', 'Staff'), ('agency', 'Agency'), ('candidate', 'Candidate')], default='staff', max_length=20, verbose_name='User Type')),
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')), ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')),
('designation', models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
], ],
@ -247,6 +223,7 @@ class Migration(migrations.Migration):
('notes', models.TextField(blank=True, help_text='Internal notes about the agency')), ('notes', models.TextField(blank=True, help_text='Internal notes about the agency')),
('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)), ('country', django_countries.fields.CountryField(blank=True, max_length=2, null=True)),
('address', models.TextField(blank=True, null=True)), ('address', models.TextField(blank=True, null=True)),
('generated_password', models.CharField(blank=True, help_text='Generated password for agency user account', max_length=255, null=True)),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='agency_profile', to=settings.AUTH_USER_MODEL, verbose_name='User')), ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='agency_profile', to=settings.AUTH_USER_MODEL, verbose_name='User')),
], ],
options={ options={
@ -267,10 +244,11 @@ class Migration(migrations.Migration):
('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')), ('is_resume_parsed', models.BooleanField(default=False, verbose_name='Resume Parsed')),
('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')), ('parsed_summary', models.TextField(blank=True, verbose_name='Parsed Summary')),
('applied', models.BooleanField(default=False, verbose_name='Applied')), ('applied', models.BooleanField(default=False, verbose_name='Applied')),
('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Offer', 'Offer'), ('Hired', 'Hired'), ('Rejected', 'Rejected')], db_index=True, default='Applied', max_length=20, verbose_name='Stage')), ('stage', models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Document Review', 'Document Review'), ('Offer', 'Offer'), ('Hired', 'Hired'), ('Rejected', 'Rejected')], db_index=True, default='Applied', max_length=20, verbose_name='Stage')),
('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=20, null=True, verbose_name='Applicant Status')), ('applicant_status', models.CharField(blank=True, choices=[('Applicant', 'Applicant'), ('Candidate', 'Candidate')], default='Applicant', max_length=20, null=True, verbose_name='Applicant Status')),
('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')), ('exam_date', models.DateTimeField(blank=True, null=True, verbose_name='Exam Date')),
('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Exam Status')), ('exam_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Exam Status')),
('exam_score', models.FloatField(blank=True, null=True, verbose_name='Exam Score')),
('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')), ('interview_date', models.DateTimeField(blank=True, null=True, verbose_name='Interview Date')),
('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Interview Status')), ('interview_status', models.CharField(blank=True, choices=[('Passed', 'Passed'), ('Failed', 'Failed')], max_length=20, null=True, verbose_name='Interview Status')),
('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')), ('offer_date', models.DateField(blank=True, null=True, verbose_name='Offer Date')),
@ -287,6 +265,44 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Applications', 'verbose_name_plural': 'Applications',
}, },
), ),
migrations.CreateModel(
name='OnsiteLocationDetails',
fields=[
('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')),
('physical_address', models.CharField(blank=True, max_length=255, null=True, verbose_name='Physical Address')),
('room_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='Room Number/Name')),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
],
options={
'verbose_name': 'Onsite Location Details',
'verbose_name_plural': 'Onsite Location Details',
},
bases=('recruitment.interviewlocation',),
),
migrations.CreateModel(
name='ZoomMeetingDetails',
fields=[
('interviewlocation_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='recruitment.interviewlocation')),
('status', models.CharField(choices=[('waiting', 'Waiting'), ('started', 'Started'), ('ended', 'Ended'), ('cancelled', 'Cancelled')], db_index=True, default='waiting', max_length=20)),
('start_time', models.DateTimeField(db_index=True, verbose_name='Start Time')),
('duration', models.PositiveIntegerField(verbose_name='Duration (minutes)')),
('meeting_id', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='External Meeting ID')),
('password', models.CharField(blank=True, max_length=20, null=True, verbose_name='Password')),
('zoom_gateway_response', models.JSONField(blank=True, null=True, verbose_name='Zoom Gateway Response')),
('participant_video', models.BooleanField(default=True, verbose_name='Participant Video')),
('join_before_host', models.BooleanField(default=False, verbose_name='Join Before Host')),
('host_email', models.CharField(blank=True, null=True)),
('mute_upon_entry', models.BooleanField(default=False, verbose_name='Mute Upon Entry')),
('waiting_room', models.BooleanField(default=False, verbose_name='Waiting Room')),
],
options={
'verbose_name': 'Zoom Meeting Details',
'verbose_name_plural': 'Zoom Meeting Details',
},
bases=('recruitment.interviewlocation',),
),
migrations.CreateModel( migrations.CreateModel(
name='JobPosting', name='JobPosting',
fields=[ fields=[
@ -326,6 +342,7 @@ class Migration(migrations.Migration):
('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')), ('cancel_reason', models.TextField(blank=True, help_text='Reason for canceling the job posting', verbose_name='Cancel Reason')),
('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')), ('cancelled_by', models.CharField(blank=True, help_text='Name of person who cancelled this job', max_length=100, verbose_name='Cancelled By')),
('cancelled_at', models.DateTimeField(blank=True, null=True)), ('cancelled_at', models.DateTimeField(blank=True, null=True)),
('ai_parsed', models.BooleanField(default=False, help_text='Whether the job posting has been parsed by AI', verbose_name='AI Parsed')),
('assigned_to', models.ForeignKey(blank=True, help_text='The user who has been assigned to this job', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_jobs', to=settings.AUTH_USER_MODEL, verbose_name='Assigned To')), ('assigned_to', models.ForeignKey(blank=True, help_text='The user who has been assigned to this job', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_jobs', to=settings.AUTH_USER_MODEL, verbose_name='Assigned To')),
('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')), ('hiring_agency', models.ManyToManyField(blank=True, help_text='External agency responsible for sourcing candidates for this role', related_name='jobs', to='recruitment.hiringagency', verbose_name='Hiring Agency')),
('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')), ('source', models.ForeignKey(blank=True, help_text='The system or channel from which this job posting originated or was first published.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='job_postings', to='recruitment.source')),
@ -343,7 +360,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('interview_type', models.CharField(choices=[('Remote', 'Remote Interview'), ('Onsite', 'In-Person Interview')], default='Remote', max_length=10, verbose_name='Interview Meeting Type')), ('schedule_interview_type', models.CharField(choices=[('Remote', 'Remote (e.g., Zoom, Google Meet)'), ('Onsite', 'In-Person (Physical Location)')], default='Remote', max_length=10, verbose_name='Interview Type')),
('start_date', models.DateField(db_index=True, verbose_name='Start Date')), ('start_date', models.DateField(db_index=True, verbose_name='Start Date')),
('end_date', models.DateField(db_index=True, verbose_name='End Date')), ('end_date', models.DateField(db_index=True, verbose_name='End Date')),
('working_days', models.JSONField(verbose_name='Working Days')), ('working_days', models.JSONField(verbose_name='Working Days')),
@ -353,10 +370,14 @@ class Migration(migrations.Migration):
('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')), ('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')),
('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')), ('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')),
('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')), ('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')),
('applications', models.ManyToManyField(blank=True, null=True, related_name='interview_schedules', to='recruitment.application')), ('applications', models.ManyToManyField(blank=True, related_name='interview_schedules', to='recruitment.application')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('template_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schedule_templates', to='recruitment.interviewlocation', verbose_name='Location Template (Zoom/Onsite)')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')), ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')),
], ],
options={
'abstract': False,
},
), ),
migrations.AddField( migrations.AddField(
model_name='formtemplate', model_name='formtemplate',
@ -413,7 +434,7 @@ class Migration(migrations.Migration):
('message_type', models.CharField(choices=[('direct', 'Direct Message'), ('job_related', 'Job Related'), ('system', 'System Notification')], default='direct', max_length=20, verbose_name='Message Type')), ('message_type', models.CharField(choices=[('direct', 'Direct Message'), ('job_related', 'Job Related'), ('system', 'System Notification')], default='direct', max_length=20, verbose_name='Message Type')),
('is_read', models.BooleanField(default=False, verbose_name='Is Read')), ('is_read', models.BooleanField(default=False, verbose_name='Is Read')),
('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')), ('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')),
('job', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.jobposting', verbose_name='Related Job')), ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.jobposting', verbose_name='Related Job')),
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')), ('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')), ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')),
], ],
@ -423,6 +444,26 @@ class Migration(migrations.Migration):
'ordering': ['-created_at'], 'ordering': ['-created_at'],
}, },
), ),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField(verbose_name='Notification Message')),
('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')),
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')),
('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')),
('last_error', models.TextField(blank=True, verbose_name='Last Error Message')),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
],
options={
'verbose_name': 'Notification',
'verbose_name_plural': 'Notifications',
'ordering': ['-scheduled_for', '-created_at'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='Person', name='Person',
fields=[ fields=[
@ -436,7 +477,8 @@ class Migration(migrations.Migration):
('email', models.EmailField(db_index=True, help_text='Unique email address for the person', max_length=254, unique=True, verbose_name='Email')), ('email', models.EmailField(db_index=True, help_text='Unique email address for the person', max_length=254, unique=True, verbose_name='Email')),
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')), ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Phone')),
('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')), ('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')),
('gender', models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female'), ('O', 'Other'), ('P', 'Prefer not to say')], max_length=1, null=True, verbose_name='Gender')), ('gender', models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female')], max_length=1, null=True, verbose_name='Gender')),
('gpa', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True, verbose_name='GPA')),
('nationality', django_countries.fields.CountryField(blank=True, max_length=2, null=True, verbose_name='Nationality')), ('nationality', django_countries.fields.CountryField(blank=True, max_length=2, null=True, verbose_name='Nationality')),
('address', models.TextField(blank=True, null=True, verbose_name='Address')), ('address', models.TextField(blank=True, null=True, verbose_name='Address')),
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')), ('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image')),
@ -455,15 +497,41 @@ class Migration(migrations.Migration):
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.person', verbose_name='Person'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='recruitment.person', verbose_name='Person'),
), ),
migrations.CreateModel( migrations.CreateModel(
name='Profile', name='ScheduledInterview',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size])), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('designation', models.CharField(blank=True, max_length=100, null=True)), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('phone', models.CharField(blank=True, max_length=12, null=True, verbose_name='Phone Number')), ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), ('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
('interview_time', models.TimeField(verbose_name='Interview Time')),
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')),
('interview_location', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='scheduled_interview', to='recruitment.interviewlocation', verbose_name='Meeting/Location Details')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
('participants', models.ManyToManyField(blank=True, to='recruitment.participants')),
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interviews', to='recruitment.interviewschedule')),
('system_users', models.ManyToManyField(blank=True, related_name='attended_interviews', to=settings.AUTH_USER_MODEL)),
], ],
), ),
migrations.CreateModel(
name='InterviewNote',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('note_type', models.CharField(choices=[('Feedback', 'Candidate Feedback'), ('Logistics', 'Logistical Note'), ('General', 'General Comment')], default='Feedback', max_length=50, verbose_name='Note Type')),
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content/Feedback')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_notes', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('interview', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='recruitment.scheduledinterview', verbose_name='Scheduled Interview')),
],
options={
'verbose_name': 'Interview Note',
'verbose_name_plural': 'Interview Notes',
'ordering': ['created_at'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='SharedFormTemplate', name='SharedFormTemplate',
fields=[ fields=[
@ -523,63 +591,6 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Training Materials', 'verbose_name_plural': 'Training Materials',
}, },
), ),
migrations.CreateModel(
name='ScheduledInterview',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('interview_date', models.DateField(db_index=True, verbose_name='Interview Date')),
('interview_time', models.TimeField(verbose_name='Interview Time')),
('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], db_index=True, default='scheduled', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.application')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')),
('onsite_meeting', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='onsite_interview', to='recruitment.onsitemeeting')),
('participants', models.ManyToManyField(blank=True, to='recruitment.participants')),
('schedule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')),
('system_users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)),
('zoom_meeting', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')),
],
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField(verbose_name='Notification Message')),
('notification_type', models.CharField(choices=[('email', 'Email'), ('in_app', 'In-App')], default='email', max_length=20, verbose_name='Notification Type')),
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('read', 'Read'), ('failed', 'Failed'), ('retrying', 'Retrying')], default='pending', max_length=20, verbose_name='Status')),
('scheduled_for', models.DateTimeField(help_text='The date and time this notification is scheduled to be sent.', verbose_name='Scheduled Send Time')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('attempts', models.PositiveIntegerField(default=0, verbose_name='Send Attempts')),
('last_error', models.TextField(blank=True, verbose_name='Last Error Message')),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
('related_meeting', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.zoommeeting', verbose_name='Related Meeting')),
],
options={
'verbose_name': 'Notification',
'verbose_name_plural': 'Notifications',
'ordering': ['-scheduled_for', '-created_at'],
},
),
migrations.CreateModel(
name='MeetingComment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')),
('content', django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meeting_comments', to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recruitment.zoommeeting', verbose_name='Meeting')),
],
options={
'verbose_name': 'Meeting Comment',
'verbose_name_plural': 'Meeting Comments',
'ordering': ['-created_at'],
},
),
migrations.CreateModel( migrations.CreateModel(
name='AgencyAccessLink', name='AgencyAccessLink',
fields=[ fields=[
@ -645,17 +656,10 @@ class Migration(migrations.Migration):
model_name='formsubmission', model_name='formsubmission',
index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'), index=models.Index(fields=['submitted_at'], name='recruitment_submitt_7946c8_idx'),
), ),
migrations.AddIndex( migrations.AddField(
model_name='interviewschedule', model_name='notification',
index=models.Index(fields=['start_date'], name='recruitment_start_d_15d55e_idx'), name='related_meeting',
), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='recruitment.zoommeetingdetails', verbose_name='Related Meeting'),
migrations.AddIndex(
model_name='interviewschedule',
index=models.Index(fields=['end_date'], name='recruitment_end_dat_aeb00e_idx'),
),
migrations.AddIndex(
model_name='interviewschedule',
index=models.Index(fields=['created_by'], name='recruitment_created_d0bdcc_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='formtemplate', model_name='formtemplate',
@ -733,14 +737,6 @@ class Migration(migrations.Migration):
name='application', name='application',
unique_together={('person', 'job')}, unique_together={('person', 'job')},
), ),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
),
migrations.AddIndex( migrations.AddIndex(
model_name='scheduledinterview', model_name='scheduledinterview',
index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'), index=models.Index(fields=['job', 'status'], name='recruitment_job_id_f09e22_idx'),
@ -753,6 +749,14 @@ class Migration(migrations.Migration):
model_name='scheduledinterview', model_name='scheduledinterview',
index=models.Index(fields=['application', 'job'], name='recruitment_applica_927561_idx'), index=models.Index(fields=['application', 'job'], name='recruitment_applica_927561_idx'),
), ),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['status', 'created_at', 'title'], name='recruitment_status_8b77aa_idx'),
),
migrations.AddIndex(
model_name='jobposting',
index=models.Index(fields=['slug'], name='recruitment_slug_004045_idx'),
),
migrations.AddIndex( migrations.AddIndex(
model_name='notification', model_name='notification',
index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'), index=models.Index(fields=['status', 'scheduled_for'], name='recruitment_status_0ebbe4_idx'),

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-13 13:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='jobposting',
name='ai_parsed',
field=models.BooleanField(default=False, help_text='Whether the job posting has been parsed by AI', verbose_name='AI Parsed'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-13 14:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0002_jobposting_ai_parsed'),
]
operations = [
migrations.AddField(
model_name='hiringagency',
name='generated_password',
field=models.CharField(blank=True, help_text='Generated password for agency user account', max_length=255, null=True),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-14 23:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0003_add_agency_password_field'),
]
operations = [
migrations.AlterField(
model_name='person',
name='gender',
field=models.CharField(blank=True, choices=[('M', 'Male'), ('F', 'Female')], max_length=1, null=True, verbose_name='Gender'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-15 20:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0004_alter_person_gender'),
]
operations = [
migrations.AddField(
model_name='person',
name='gpa',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True, verbose_name='GPA'),
),
]

View File

@ -1,24 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-15 20:56
import recruitment.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0005_person_gpa'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='designation',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Designation'),
),
migrations.AddField(
model_name='customuser',
name='profile_image',
field=models.ImageField(blank=True, null=True, upload_to='profile_pic/', validators=[recruitment.validators.validate_image_size], verbose_name='Profile Image'),
),
]

View File

@ -1,60 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-15 20:57
from django.db import migrations
def migrate_profile_data_to_customuser(apps, schema_editor):
"""
Migrate data from Profile model to CustomUser model
"""
CustomUser = apps.get_model('recruitment', 'CustomUser')
Profile = apps.get_model('recruitment', 'Profile')
# Get all profiles
profiles = Profile.objects.all()
for profile in profiles:
if profile.user:
# Update CustomUser with Profile data
user = profile.user
if profile.profile_image:
user.profile_image = profile.profile_image
if profile.designation:
user.designation = profile.designation
user.save(update_fields=['profile_image', 'designation'])
def reverse_migrate_profile_data(apps, schema_editor):
"""
Reverse migration: move data from CustomUser back to Profile
"""
CustomUser = apps.get_model('recruitment', 'CustomUser')
Profile = apps.get_model('recruitment', 'Profile')
# Get all users with profile data
users = CustomUser.objects.exclude(profile_image__isnull=True).exclude(profile_image='')
for user in users:
# Get or create profile for this user
profile, created = Profile.objects.get_or_create(user=user)
# Update Profile with CustomUser data
if user.profile_image:
profile.profile_image = user.profile_image
if user.designation:
profile.designation = user.designation
profile.save(update_fields=['profile_image', 'designation'])
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0006_add_profile_fields_to_customuser'),
]
operations = [
migrations.RunPython(
migrate_profile_data_to_customuser,
reverse_migrate_profile_data,
),
]

View File

@ -1,16 +0,0 @@
# Generated manually to drop the Profile model after migration to CustomUser
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0007_migrate_profile_data_to_customuser'),
]
operations = [
migrations.DeleteModel(
name='Profile',
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-16 10:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0008_drop_profile_model'),
]
operations = [
migrations.AlterField(
model_name='message',
name='job',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='recruitment.jobposting', verbose_name='Related Job'),
preserve_default=False,
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-16 11:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0009_alter_message_job'),
]
operations = [
migrations.AlterField(
model_name='application',
name='stage',
field=models.CharField(choices=[('Applied', 'Applied'), ('Exam', 'Exam'), ('Interview', 'Interview'), ('Document Review', 'Document Review'), ('Offer', 'Offer'), ('Hired', 'Hired'), ('Rejected', 'Rejected')], db_index=True, default='Applied', max_length=20, verbose_name='Stage'),
),
]

View File

@ -1,13 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-16 12:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0010_add_document_review_stage'),
]
operations = [
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.6 on 2025-11-16 12:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0011_add_document_review_stage'),
]
operations = [
migrations.AddField(
model_name='application',
name='exam_score',
field=models.FloatField(blank=True, null=True, verbose_name='Exam Score'),
),
]

View File

@ -906,11 +906,35 @@ class Application(Base):
@property @property
def get_latest_meeting(self): def get_latest_meeting(self):
"""Legacy compatibility - get latest meeting for this application""" """
Retrieves the most specific location details (subclass instance)
of the latest ScheduledInterview for this application, or None.
"""
# 1. Get the latest ScheduledInterview
schedule = self.scheduled_interviews.order_by("-created_at").first() schedule = self.scheduled_interviews.order_by("-created_at").first()
if schedule:
return schedule.zoom_meeting # Check if a schedule exists and if it has an interview location
return None if not schedule or not schedule.interview_location:
return None
# Get the base location instance
interview_location = schedule.interview_location
# 2. Safely retrieve the specific subclass details
# Determine the expected subclass accessor name based on the location_type
if interview_location.location_type == 'Remote':
accessor_name = 'zoommeetingdetails'
else: # Assumes 'Onsite' or any other type defaults to Onsite
accessor_name = 'onsitelocationdetails'
# Use getattr to safely retrieve the specific meeting object (subclass instance).
# If the accessor exists but points to None (because the subclass record was deleted),
# or if the accessor name is wrong for the object's true type, it will return None.
meeting_details = getattr(interview_location, accessor_name, None)
return meeting_details
@property @property
def has_future_meeting(self): def has_future_meeting(self):
@ -983,137 +1007,326 @@ class TrainingMaterial(Base):
return self.title return self.title
class OnsiteMeeting(Base): class InterviewLocation(Base):
class MeetingStatus(models.TextChoices): """
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") WAITING = "waiting", _("Waiting")
STARTED = "started", _("Started") STARTED = "started", _("Started")
ENDED = "ended", _("Ended") ENDED = "ended", _("Ended")
CANCELLED = "cancelled", _("Cancelled") CANCELLED = "cancelled", _("Cancelled")
# Basic meeting details location_type = models.CharField(
topic = models.CharField(max_length=255, verbose_name=_("Topic")) max_length=10,
start_time = models.DateTimeField( choices=LocationType.choices,
db_index=True, verbose_name=_("Start Time") verbose_name=_("Location Type"),
) # Added index db_index=True
duration = models.PositiveIntegerField( )
verbose_name=_("Duration")
) # Duration in minutes details_url = models.URLField(
timezone = models.CharField(max_length=50, verbose_name=_("Timezone")) verbose_name=_("Meeting/Location URL"),
location = models.CharField(null=True, blank=True) max_length=2048,
status = models.CharField(
db_index=True,
max_length=20, # Added index
null=True,
blank=True, blank=True,
verbose_name=_("Status"), null=True
default=MeetingStatus.WAITING, )
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 ZoomMeeting(Base): class Meta:
class MeetingStatus(models.TextChoices): verbose_name = _("Interview Location")
WAITING = "waiting", _("Waiting") verbose_name_plural = _("Interview Locations")
STARTED = "started", _("Started")
ENDED = "ended", _("Ended")
CANCELLED = "cancelled", _("Cancelled")
# Basic meeting details
topic = models.CharField(max_length=255, verbose_name=_("Topic")) class ZoomMeetingDetails(InterviewLocation):
meeting_id = models.CharField( """Concrete model for remote interviews (Zoom specifics)."""
status = models.CharField(
db_index=True, db_index=True,
max_length=20, max_length=20,
unique=True, choices=InterviewLocation.Status.choices,
verbose_name=_("Meeting ID"), # Added index default=InterviewLocation.Status.WAITING,
) # Unique identifier for the meeting )
start_time = models.DateTimeField( start_time = models.DateTimeField(
db_index=True, verbose_name=_("Start Time") db_index=True, verbose_name=_("Start Time")
) # Added index )
duration = models.PositiveIntegerField( duration = models.PositiveIntegerField(
verbose_name=_("Duration") verbose_name=_("Duration (minutes)")
) # Duration in minutes )
timezone = models.CharField(max_length=50, verbose_name=_("Timezone")) meeting_id = models.CharField(
join_url = models.URLField( db_index=True,
verbose_name=_("Join URL") max_length=50,
) # URL for participants to join unique=True,
participant_video = models.BooleanField( verbose_name=_("External Meeting ID")
default=True, verbose_name=_("Participant Video")
) )
password = models.CharField( password = models.CharField(
max_length=20, blank=True, null=True, verbose_name=_("Password") 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( join_before_host = models.BooleanField(
default=False, verbose_name=_("Join Before Host") default=False, verbose_name=_("Join Before Host")
) )
host_email=models.CharField(null=True,blank=True)
mute_upon_entry = models.BooleanField( mute_upon_entry = models.BooleanField(
default=False, verbose_name=_("Mute Upon Entry") default=False, verbose_name=_("Mute Upon Entry")
) )
waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room")) waiting_room = models.BooleanField(default=False, verbose_name=_("Waiting Room"))
zoom_gateway_response = models.JSONField( # *** REVERTED TO @classmethod (Factory Method) for cleaner instantiation ***
blank=True, null=True, verbose_name=_("Zoom Gateway Response") # @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( status = models.CharField(
db_index=True, db_index=True,
max_length=20, # Added index 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, null=True,
blank=True, blank=True,
verbose_name=_("Status"), verbose_name=_("Location Template (Zoom/Onsite)")
default=MeetingStatus.WAITING, )
# 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
) )
# Timestamps
def __str__(self): def __str__(self):
return self.topic return f"Schedule for {self.job.title}"
@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): class ScheduledInterview(Base):
""" """Stores individual scheduled interviews (whether bulk or individually created)."""
Model for storing meeting comments/notes
""" class InterviewStatus(models.TextChoices):
SCHEDULED = "scheduled", _("Scheduled")
CONFIRMED = "confirmed", _("Confirmed")
CANCELLED = "cancelled", _("Cancelled")
COMPLETED = "completed", _("Completed")
meeting = models.ForeignKey( application = models.ForeignKey(
ZoomMeeting, Application,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="comments", related_name="scheduled_interviews",
verbose_name=_("Meeting"), 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.CASCADE,
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( author = models.ForeignKey(
User, User,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="meeting_comments", related_name="interview_notes",
verbose_name=_("Author"), verbose_name=_("Author"),
db_index=True
) )
content = CKEditor5Field(verbose_name=_("Content"), config_name="extends")
# Inherited from Base: created_at, updated_at, slug 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: class Meta:
verbose_name = _("Meeting Comment") verbose_name = _("Interview Note")
verbose_name_plural = _("Meeting Comments") verbose_name_plural = _("Interview Notes")
ordering = ["-created_at"] ordering = ["created_at"]
def __str__(self): def __str__(self):
return f"Comment by {self.author.get_username()} on {self.meeting.topic}" return f"{self.get_note_type_display()} by {self.author.get_username()} on {self.interview.id}"
class FormTemplate(Base): class FormTemplate(Base):
""" """
@ -1926,143 +2139,6 @@ class BreakTime(models.Model):
return f"{self.start_time} - {self.end_time}" return f"{self.start_time} - {self.end_time}"
class InterviewSchedule(Base):
"""Stores the scheduling criteria for interviews"""
class InterviewType(models.TextChoices):
REMOTE = "Remote", "Remote Interview"
ONSITE = "Onsite", "In-Person Interview"
interview_type = models.CharField(
max_length=10,
choices=InterviewType.choices,
default=InterviewType.REMOTE,
verbose_name="Interview Meeting Type",
)
job = models.ForeignKey(
JobPosting,
on_delete=models.CASCADE,
related_name="interview_schedules",
db_index=True,
)
applications = models.ManyToManyField(
Application, related_name="interview_schedules", blank=True, null=True
)
start_date = models.DateField(
db_index=True, verbose_name=_("Start Date")
) # Added index
end_date = models.DateField(
db_index=True, verbose_name=_("End Date")
) # Added index
working_days = models.JSONField(
verbose_name=_("Working Days")
) # Store days of week as [0,1,2,3,4] for Mon-Fri
start_time = models.TimeField(verbose_name=_("Start Time"))
end_time = models.TimeField(verbose_name=_("End Time"))
break_start_time = models.TimeField(
verbose_name=_("Break Start Time"), null=True, blank=True
)
break_end_time = models.TimeField(
verbose_name=_("Break End Time"), null=True, blank=True
)
interview_duration = models.PositiveIntegerField(
verbose_name=_("Interview Duration (minutes)")
)
buffer_time = models.PositiveIntegerField(
verbose_name=_("Buffer Time (minutes)"), default=0
)
created_by = models.ForeignKey(
User, on_delete=models.CASCADE, db_index=True
) # Added index
def __str__(self):
return f"Interview Schedule for {self.job.title}"
class Meta:
indexes = [
models.Index(fields=["start_date"]),
models.Index(fields=["end_date"]),
models.Index(fields=["created_by"]),
]
class ScheduledInterview(Base):
"""Stores individual scheduled interviews"""
application = models.ForeignKey(
Application,
on_delete=models.CASCADE,
related_name="scheduled_interviews",
db_index=True,
)
participants = models.ManyToManyField("Participants", blank=True)
system_users = models.ManyToManyField(User, blank=True)
job = models.ForeignKey(
"JobPosting",
on_delete=models.CASCADE,
related_name="scheduled_interviews",
db_index=True,
)
zoom_meeting = models.OneToOneField(
ZoomMeeting,
on_delete=models.CASCADE,
related_name="interview",
db_index=True,
null=True,
blank=True,
)
onsite_meeting = models.OneToOneField(
OnsiteMeeting,
on_delete=models.CASCADE,
related_name="onsite_interview",
db_index=True,
null=True,
blank=True,
)
schedule = models.ForeignKey(
InterviewSchedule,
on_delete=models.CASCADE,
related_name="interviews",
null=True,
blank=True,
db_index=True,
)
interview_date = models.DateField(
db_index=True, verbose_name=_("Interview Date")
) # Added index
interview_time = models.TimeField(verbose_name=_("Interview Time"))
status = models.CharField(
db_index=True,
max_length=20, # Added index
choices=[
("scheduled", _("Scheduled")),
("confirmed", _("Confirmed")),
("cancelled", _("Cancelled")),
("completed", _("Completed")),
],
default="scheduled",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return (
f"Interview with {self.application.person.full_name} for {self.job.title}"
)
class Meta:
indexes = [
models.Index(fields=["job", "status"]),
models.Index(fields=["interview_date", "interview_time"]),
models.Index(fields=["application", "job"]),
]
class Notification(models.Model): class Notification(models.Model):
@ -2101,7 +2177,7 @@ class Notification(models.Model):
verbose_name=_("Status"), verbose_name=_("Status"),
) )
related_meeting = models.ForeignKey( related_meeting = models.ForeignKey(
ZoomMeeting, ZoomMeetingDetails,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="notifications", related_name="notifications",
null=True, null=True,

View File

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

View File

@ -12,7 +12,7 @@ from . linkedin_service import LinkedInService
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from . models import JobPosting from . models import JobPosting
from django.utils import timezone from django.utils import timezone
from . models import InterviewSchedule,ScheduledInterview,ZoomMeeting from . models import InterviewSchedule,ScheduledInterview,ZoomMeetingDetails
# Add python-docx import for Word document processing # Add python-docx import for Word document processing
try: try:
@ -441,7 +441,7 @@ def handle_reume_parsing_and_scoring(pk):
print(f"Successfully scored and saved analysis for candidate {instance.id}") print(f"Successfully scored and saved analysis for candidate {instance.id}")
from django.utils import timezone
def create_interview_and_meeting( def create_interview_and_meeting(
candidate_id, candidate_id,
job_id, job_id,
@ -458,7 +458,7 @@ def create_interview_and_meeting(
job = JobPosting.objects.get(pk=job_id) job = JobPosting.objects.get(pk=job_id)
schedule = InterviewSchedule.objects.get(pk=schedule_id) schedule = InterviewSchedule.objects.get(pk=schedule_id)
interview_datetime = datetime.combine(slot_date, slot_time) interview_datetime = timezone.make_aware(datetime.combine(slot_date, slot_time))
meeting_topic = f"Interview for {job.title} - {candidate.name}" meeting_topic = f"Interview for {job.title} - {candidate.name}"
# 1. External API Call (Slow) # 1. External API Call (Slow)
@ -467,24 +467,26 @@ def create_interview_and_meeting(
if result["status"] == "success": if result["status"] == "success":
# 2. Database Writes (Slow) # 2. Database Writes (Slow)
zoom_meeting = ZoomMeeting.objects.create( zoom_meeting = ZoomMeetingDetails.objects.create(
topic=meeting_topic, topic=meeting_topic,
start_time=interview_datetime, start_time=interview_datetime,
duration=duration, duration=duration,
meeting_id=result["meeting_details"]["meeting_id"], meeting_id=result["meeting_details"]["meeting_id"],
join_url=result["meeting_details"]["join_url"], details_url=result["meeting_details"]["join_url"],
zoom_gateway_response=result["zoom_gateway_response"], zoom_gateway_response=result["zoom_gateway_response"],
host_email=result["meeting_details"]["host_email"], host_email=result["meeting_details"]["host_email"],
password=result["meeting_details"]["password"] password=result["meeting_details"]["password"],
location_type="Remote"
) )
ScheduledInterview.objects.create( ScheduledInterview.objects.create(
application=Application, application=candidate,
job=job, job=job,
zoom_meeting=zoom_meeting, interview_location=zoom_meeting,
schedule=schedule, schedule=schedule,
interview_date=slot_date, interview_date=slot_date,
interview_time=slot_time interview_time=slot_time
) )
# Log success or use Django-Q result system for monitoring # Log success or use Django-Q result system for monitoring
logger.info(f"Successfully scheduled interview for {Application.name}") logger.info(f"Successfully scheduled interview for {Application.name}")
return True # Task succeeded return True # Task succeeded
@ -518,7 +520,7 @@ def handle_zoom_webhook_event(payload):
try: try:
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet, # Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
# and to simplify the logic flow. # and to simplify the logic flow.
meeting_instance = ZoomMeeting.objects.filter(meeting_id=meeting_id_zoom).first() meeting_instance = ZoomMeetingDetails.objects.filter(meeting_id=meeting_id_zoom).first()
print(meeting_instance) print(meeting_instance)
# --- 1. Creation and Update Events --- # --- 1. Creation and Update Events ---
if event_type == 'meeting.updated': if event_type == 'meeting.updated':
@ -699,7 +701,7 @@ def sync_candidate_to_source_task(candidate_id, source_id):
dict: Sync result for this specific candidate-source pair dict: Sync result for this specific candidate-source pair
""" """
from .candidate_sync_service import CandidateSyncService from .candidate_sync_service import CandidateSyncService
from .models import Candidate, Source, IntegrationLog from .models import Application, Source, IntegrationLog
logger.info(f"Starting sync task for candidate {candidate_id} to source {source_id}") logger.info(f"Starting sync task for candidate {candidate_id} to source {source_id}")

View File

@ -35,16 +35,7 @@ urlpatterns = [
), ),
path("jobs/linkedin/login/", views.linkedin_login, name="linkedin_login"), path("jobs/linkedin/login/", views.linkedin_login, name="linkedin_login"),
path("jobs/linkedin/callback/", views.linkedin_callback, name="linkedin_callback"), path("jobs/linkedin/callback/", views.linkedin_callback, name="linkedin_callback"),
path(
"jobs/<slug:slug>/schedule-interviews/",
views.schedule_interviews_view,
name="schedule_interviews",
),
path(
"jobs/<slug:slug>/confirm-schedule-interviews/",
views.confirm_schedule_interviews_view,
name="confirm_schedule_interviews_view",
),
# Candidate URLs # Candidate URLs
path( path(
"candidates/", views_frontend.ApplicationListView.as_view(), name="candidate_list" "candidates/", views_frontend.ApplicationListView.as_view(), name="candidate_list"
@ -117,27 +108,8 @@ urlpatterns = [
name="training_delete", name="training_delete",
), ),
# Meeting URLs # Meeting URLs
path("meetings/", views.ZoomMeetingListView.as_view(), name="list_meetings"), # path("meetings/", views.ZoomMeetingListView.as_view(), name="list_meetings"),
path(
"meetings/create-meeting/",
views.ZoomMeetingCreateView.as_view(),
name="create_meeting",
),
path(
"meetings/meeting-details/<slug:slug>/",
views.ZoomMeetingDetailsView.as_view(),
name="meeting_details",
),
path(
"meetings/update-meeting/<slug:slug>/",
views.ZoomMeetingUpdateView.as_view(),
name="update_meeting",
),
path(
"meetings/delete-meeting/<slug:slug>/",
views.ZoomMeetingDeleteView,
name="delete_meeting",
),
# JobPosting functional views URLs (keeping for compatibility) # JobPosting functional views URLs (keeping for compatibility)
path("api/create/", views.create_job, name="create_job_api"), path("api/create/", views.create_job, name="create_job_api"),
path("api/<slug:slug>/edit/", views.edit_job, name="edit_job_api"), path("api/<slug:slug>/edit/", views.edit_job, name="edit_job_api"),
@ -299,38 +271,7 @@ urlpatterns = [
views.interview_detail_view, views.interview_detail_view,
name="interview_detail", name="interview_detail",
), ),
# Candidate Meeting Scheduling/Rescheduling URLs
path(
"jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/",
views.schedule_candidate_meeting,
name="schedule_candidate_meeting",
),
path(
"api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/",
views.api_schedule_candidate_meeting,
name="api_schedule_candidate_meeting",
),
path(
"jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/",
views.reschedule_candidate_meeting,
name="reschedule_candidate_meeting",
),
path(
"api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/",
views.api_reschedule_candidate_meeting,
name="api_reschedule_candidate_meeting",
),
# New URL for simple page-based meeting scheduling
path(
"jobs/<slug:slug>/candidates/<int:candidate_pk>/schedule-meeting-page/",
views.schedule_meeting_for_candidate,
name="schedule_meeting_for_candidate",
),
path(
"jobs/<slug:slug>/candidates/<int:candidate_pk>/delete_meeting_for_candidate/<int:meeting_id>/",
views.delete_meeting_for_candidate,
name="delete_meeting_for_candidate",
),
# users urls # users urls
path("user/<int:pk>", views.user_detail, name="user_detail"), path("user/<int:pk>", views.user_detail, name="user_detail"),
path( path(
@ -586,7 +527,7 @@ urlpatterns = [
), ),
# Email composition URLs # Email composition URLs
path( path(
"jobs/<slug:job_slug>/candidates/<slug:candidate_slug>/compose-email/", "jobs/<slug:job_slug>/candidates/compose-email/",
views.compose_candidate_email, views.compose_candidate_email,
name="compose_candidate_email", name="compose_candidate_email",
), ),
@ -610,6 +551,7 @@ urlpatterns = [
path("candidate/documents/<int:document_id>/delete/", views.document_delete, name="candidate_document_delete"), path("candidate/documents/<int:document_id>/delete/", views.document_delete, name="candidate_document_delete"),
path("candidate/documents/<int:document_id>/download/", views.document_download, name="candidate_document_download"), path("candidate/documents/<int:document_id>/download/", views.document_download, name="candidate_document_download"),
path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'), path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'),
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'), path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'), path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
# Candidate Signup # Candidate Signup
@ -618,8 +560,101 @@ urlpatterns = [
path('user/<int:pk>/password-reset/', views.portal_password_reset, name='portal_password_reset'), path('user/<int:pk>/password-reset/', views.portal_password_reset, name='portal_password_reset'),
# # --- SCHEDULED INTERVIEW URLS (New Centralized Management) --- # # --- SCHEDULED INTERVIEW URLS (New Centralized Management) ---
# path('interview/list/', views.InterviewListView.as_view(), name='interview_list'), # path('interview/list/', views.interview_list, name='interview_list'),
# path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'), # path('interviews/<slug:slug>/', views.ScheduledInterviewDetailView.as_view(), name='scheduled_interview_detail'),
# path('interviews/<slug:slug>/update/', views.ScheduledInterviewUpdateView.as_view(), name='update_scheduled_interview'), # path('interviews/<slug:slug>/update/', views.ScheduledInterviewUpdateView.as_view(), name='update_scheduled_interview'),
# path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'), # path('interviews/<slug:slug>/delete/', views.ScheduledInterviewDeleteView.as_view(), name='delete_scheduled_interview'),
#interview and meeting related urls
path(
"jobs/<slug:slug>/schedule-interviews/",
views.schedule_interviews_view,
name="schedule_interviews",
),
path(
"jobs/<slug:slug>/confirm-schedule-interviews/",
views.confirm_schedule_interviews_view,
name="confirm_schedule_interviews_view",
),
path(
"meetings/create-meeting/",
views.ZoomMeetingCreateView.as_view(),
name="create_meeting",
),
# path(
# "meetings/meeting-details/<slug:slug>/",
# views.ZoomMeetingDetailsView.as_view(),
# name="meeting_details",
# ),
path(
"meetings/update-meeting/<slug:slug>/",
views.ZoomMeetingUpdateView.as_view(),
name="update_meeting",
),
path(
"meetings/delete-meeting/<slug:slug>/",
views.ZoomMeetingDeleteView,
name="delete_meeting",
),
# Candidate Meeting Scheduling/Rescheduling URLs
path(
"jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/",
views.schedule_candidate_meeting,
name="schedule_candidate_meeting",
),
path(
"api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/schedule-meeting/",
views.api_schedule_candidate_meeting,
name="api_schedule_candidate_meeting",
),
path(
"jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/",
views.reschedule_candidate_meeting,
name="reschedule_candidate_meeting",
),
path(
"api/jobs/<slug:job_slug>/candidates/<int:candidate_pk>/reschedule-meeting/<int:interview_pk>/",
views.api_reschedule_candidate_meeting,
name="api_reschedule_candidate_meeting",
),
# New URL for simple page-based meeting scheduling
path(
"jobs/<slug:slug>/candidates/<int:candidate_pk>/schedule-meeting-page/",
views.schedule_meeting_for_candidate,
name="schedule_meeting_for_candidate",
),
path(
"jobs/<slug:slug>/candidates/<int:candidate_pk>/delete_meeting_for_candidate/<int:meeting_id>/",
views.delete_meeting_for_candidate,
name="delete_meeting_for_candidate",
),
path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
# 1. Onsite Reschedule URL
path(
'<slug:slug>/candidate/<int:candidate_id>/onsite/reschedule/<int:meeting_id>/',
views.reschedule_onsite_meeting,
name='reschedule_onsite_meeting'
),
# 2. Onsite Delete URL
path(
'job/<slug:slug>/candidates/<int:candidate_pk>/delete-onsite-meeting/<int:meeting_id>/',
views.delete_onsite_meeting_for_candidate,
name='delete_onsite_meeting_for_candidate'
),
path(
'job/<slug:slug>/candidate/<int:candidate_pk>/schedule/onsite/',
views.schedule_onsite_meeting_for_candidate,
name='schedule_onsite_meeting_for_candidate' # This is the name used in the button
),
# Detail View (assuming slug is on ScheduledInterview)
# path("interviews/meetings/<slug:slug>/", views.MeetingDetailView.as_view(), name="meeting_details"),
] ]

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -143,12 +143,12 @@
{% trans "Type" %}: {% trans "Type" %}:
{# Map the key back to its human-readable translation #} {# Map the key back to its human-readable translation #}
<strong class="mx-1"> <strong class="mx-1">
{% if selected_job_type == 'FULL_TIME' %}{% trans "Full-time" %} {% if selected_job_type == 'Full-time' %}{% trans "Full-time" %}
{% elif selected_job_type == 'PART_TIME' %}{% trans "Part-time" %} {% elif selected_job_type == 'Part-time' %}{% trans "Part-time" %}
{% elif selected_job_type == 'CONTRACT' %}{% trans "Contract" %} {% elif selected_job_type == 'Contract' %}{% trans "Contract" %}
{% elif selected_job_type == 'INTERNSHIP' %}{% trans "Internship" %} {% elif selected_job_type == 'Internship' %}{% trans "Internship" %}
{% elif selected_job_type == 'FACULTY' %}{% trans "Faculty" %} {% elif selected_job_type == 'Faculty' %}{% trans "Faculty" %}
{% elif selected_job_type == 'TEMPORARY' %}{% trans "Temporary" %} {% elif selected_job_type == 'Temporary' %}{% trans "Temporary" %}
{% endif %} {% endif %}
</strong> </strong>
{# Link to clear this specific filter: use current URL but remove `employment_type` parameter #} {# Link to clear this specific filter: use current URL but remove `employment_type` parameter #}
@ -159,15 +159,15 @@
</span> </span>
{% endif %} {% endif %}
{# --- Active Workplace Type Filter Chip --- #} {# --- Active Workplace Type Filter Chip --- #}
{% if selected_workplace_type %} {% if selected_workplace_type %}
<span class="filter-chip badge bg-primary-theme-subtle text-primary-theme fw-normal p-2 active-filter-chip"> <span class="filter-chip badge bg-primary-theme-subtle text-primary-theme fw-normal p-2 active-filter-chip">
{% trans "Workplace" %}: {% trans "Workplace" %}:
{# Map the key back to its human-readable translation #} {# Map the key back to its human-readable translation #}
<strong class="mx-1"> <strong class="mx-1">
{% if selected_workplace_type == 'ON_SITE' %}{% trans "On-site" %} {% if selected_workplace_type == 'On-site' %}{% trans "On-site" %}
{% elif selected_workplace_type == 'REMOTE' %}{% trans "Remote" %} {% elif selected_workplace_type == 'Remote' %}{% trans "Remote" %}
{% elif selected_workplace_type == 'HYBRID' %}{% trans "Hybrid" %} {% elif selected_workplace_type == 'Hybrid' %}{% trans "Hybrid" %}
{% endif %} {% endif %}
</strong> </strong>
{# Link to clear this specific filter: use current URL but remove `workplace_type` parameter #} {# Link to clear this specific filter: use current URL but remove `workplace_type` parameter #}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@ -39,7 +39,8 @@
} }
/* Main Action Button Style (Teal Theme) */ /* Main Action Button Style (Teal Theme) */
.btn-main-action {
.btn-main-action {
background-color: var(--kaauh-teal); background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal); border-color: var(--kaauh-teal);
color: white; color: white;
@ -47,8 +48,7 @@
transition: all 0.2s ease; transition: all 0.2s ease;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.25rem;
padding: 0.5rem 1rem;
} }
.btn-main-action:hover { .btn-main-action:hover {
@ -168,7 +168,7 @@
<form method="GET" class="row g-3 align-items-end h-100"> <form method="GET" class="row g-3 align-items-end h-100">
{% if search_query %}<input type="hidden" name="q" value="{{ search_query }}">{% endif %} {% if search_query %}<input type="hidden" name="q" value="{{ search_query }}">{% endif %}
<div class="col-md-8"> <div class="col-md-4">
<label for="job_filter" class="form-label small text-muted">{% trans "Filter by Assigned Job" %}</label> <label for="job_filter" class="form-label small text-muted">{% trans "Filter by Assigned Job" %}</label>
<select name="job" id="job_filter" class="form-select form-select-sm"> <select name="job" id="job_filter" class="form-select form-select-sm">
<option value="">{% trans "All Jobs" %}</option> <option value="">{% trans "All Jobs" %}</option>
@ -180,10 +180,10 @@
</div> </div>
{# Buttons Group (pushed to the right/bottom) #} {# Buttons Group (pushed to the right/bottom) #}
<div class="col-md-4 d-flex justify-content-end align-self-end"> <div class="col-md-4 d-flex">
<div class="filter-buttons"> <div class="filter-buttons">
<button type="submit" class="btn btn-main-action btn-sm"> <button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-filter me-1"></i> {% trans "Apply" %} <i class="fas fa-filter me-1"></i> {% trans "Apply Filters" %}
</button> </button>
{% if job_filter or search_query %} {% if job_filter or search_query %}
<a href="{% url 'participant_list' %}" class="btn btn-outline-secondary btn-sm"> <a href="{% url 'participant_list' %}" class="btn btn-outline-secondary btn-sm">

View File

@ -175,6 +175,38 @@
color: #6c757d; color: #6c757d;
} }
/* Job List - Consistent with Candidate List */
.job-item {
background-color: white;
border: 1px solid var(--kaauh-border);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
transition: all 0.2s ease;
}
.job-item:hover {
background-color: #f8f9fa;
border-color: var(--kaauh-teal);
}
.job-title {
font-weight: 600;
color: var(--kaauh-primary-text);
margin-bottom: 0.25rem;
}
.job-details {
font-size: 0.875rem;
color: #6c757d;
}
.job-status-badge {
font-size: 0.75rem;
padding: 0.25rem 0.6rem;
border-radius: 0.3rem;
font-weight: 600;
display: inline-block;
background-color: #e9ecef;
color: #495057;
}
/* Stage Badge */ /* Stage Badge */
.stage-badge { .stage-badge {
font-size: 0.75rem; font-size: 0.75rem;
@ -200,7 +232,7 @@
} }
.empty-state i { .empty-state i {
font-size: 3rem; font-size: 1.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
opacity: 0.5; opacity: 0.5;
} }
@ -235,12 +267,57 @@
.password-value:hover { .password-value:hover {
background-color: #f8f9fa; background-color: #f8f9fa;
} }
/* --- TAB OVERRIDES FOR TEAL THEME CONSISTENCY AND VISIBILITY --- */
/* Ensure card-header-tabs sit correctly, use kaauh-border */
.card-header-tabs {
border-bottom: 1px solid var(--kaauh-border); /* Consistent thin bottom border for the entire row */
}
/* Default tab link styling */
.nav-tabs .nav-link {
color: var(--kaauh-primary-text); /* Default text color */
border: 1px solid var(--kaauh-border); /* Add border to all sides */
border-bottom: none; /* Remove tab's own bottom border */
border-radius: 0.5rem 0.5rem 0 0; /* Slightly smaller radius for tabs */
margin-right: 0.25rem;
padding: 0.75rem 1.25rem;
transition: all 0.2s ease-in-out;
background-color: #f8f9fa; /* Visible light background for inactive tabs */
}
/* Tab link hover state */
.nav-tabs .nav-link:hover:not(.active) {
color: var(--kaauh-teal);
background-color: #e9ecef; /* Slightly darker on hover */
border-color: var(--kaauh-teal); /* Use teal border on hover */
border-bottom: none; /* Keep the bottom flat */
}
/* Active tab link styling */
.nav-tabs .nav-link.active {
color: var(--kaauh-teal-dark);
background-color: white; /* White background for active */
border-color: var(--kaauh-border); /* Use border color for all three sides */
border-bottom: 2px solid white; /* Override the tab strip border with white to lift the tab */
margin-bottom: -1px; /* Overlap slightly with the card border for a cleaner look */
font-weight: 600;
}
/* Tab pane styling for border consistency */
.tab-content .tab-pane {
border: 1px solid var(--kaauh-border); /* Consistent border for the content */
border-top: none; /* The nav tabs handle the top border */
border-radius: 0 0 0.75rem 0.75rem;
background-color: white;
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<!-- Header Section -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;"> <h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
@ -252,6 +329,9 @@
</p> </p>
</div> </div>
<div> <div>
<a href="{% url 'agency_assignment_list' %}" class="btn btn-main-action me-2">
<i class="fas fa-tasks me-1"></i> {% trans "All Assignments" %}
</a>
<a href="{% url 'agency_assignment_create' agency.slug %}" class="btn btn-main-action me-2"> <a href="{% url 'agency_assignment_create' agency.slug %}" class="btn btn-main-action me-2">
<i class="fas fa-edit me-1"></i> {% trans "Assign job" %} <i class="fas fa-edit me-1"></i> {% trans "Assign job" %}
</a> </a>
@ -265,9 +345,7 @@
</div> </div>
<div class="row"> <div class="row">
<!-- Agency Information -->
<div class="col-lg-8"> <div class="col-lg-8">
<!-- Agency Header Card -->
<div class="card kaauh-card mb-4"> <div class="card kaauh-card mb-4">
<div class="agency-header"> <div class="agency-header">
<div class="row align-items-center"> <div class="row align-items-center">
@ -302,7 +380,6 @@
</div> </div>
<div class="card-body p-4"> <div class="card-body p-4">
<!-- Contact Information -->
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="info-section"> <div class="info-section">
@ -310,7 +387,6 @@
<i class="fas fa-address-book me-2" style="color: var(--kaauh-teal);"></i> <i class="fas fa-address-book me-2" style="color: var(--kaauh-teal);"></i>
{% trans "Contact Information" %} {% trans "Contact Information" %}
</h5> </h5>
{% if agency.phone %} {% if agency.phone %}
<div class="info-item"> <div class="info-item">
<div class="info-icon"> <div class="info-icon">
@ -322,7 +398,6 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if agency.email %} {% if agency.email %}
<div class="info-item"> <div class="info-item">
<div class="info-icon"> <div class="info-icon">
@ -334,7 +409,6 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if agency.website %} {% if agency.website %}
<div class="info-item"> <div class="info-item">
<div class="info-icon"> <div class="info-icon">
@ -360,7 +434,6 @@
<i class="fas fa-map-marker-alt me-2" style="color: var(--kaauh-teal);"></i> <i class="fas fa-map-marker-alt me-2" style="color: var(--kaauh-teal);"></i>
{% trans "Location Information" %} {% trans "Location Information" %}
</h5> </h5>
{% if agency.address %} {% if agency.address %}
<div class="info-item"> <div class="info-item">
<div class="info-icon"> <div class="info-icon">
@ -372,7 +445,6 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if agency.city %} {% if agency.city %}
<div class="info-item"> <div class="info-item">
<div class="info-icon"> <div class="info-icon">
@ -384,7 +456,6 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if agency.country %} {% if agency.country %}
<div class="info-item"> <div class="info-item">
<div class="info-icon"> <div class="info-icon">
@ -400,7 +471,6 @@
</div> </div>
</div> </div>
<!-- Description -->
{% if agency.description %} {% if agency.description %}
<div class="info-section mt-3"> <div class="info-section mt-3">
<h5 class="mb-3"> <h5 class="mb-3">
@ -411,14 +481,12 @@
</div> </div>
{% endif %} {% endif %}
<!-- Agency Login Information -->
{% if generated_password and request.user.is_staff %} {% if generated_password and request.user.is_staff %}
<div class="info-section mt-4"> <div class="info-section mt-4">
<h5 class="mb-3"> <h5 class="mb-3">
<i class="fas fa-key me-2" style="color: var(--kaauh-teal);"></i> <i class="fas fa-key me-2" style="color: var(--kaauh-teal);"></i>
{% trans "Agency Login Information" %} {% trans "Agency Login Information" %}
</h5> </h5>
<div class="alert alert-info" role="alert"> <div class="alert alert-info" role="alert">
<h6 class="alert-heading"> <h6 class="alert-heading">
<i class="fas fa-info-circle me-2"></i> <i class="fas fa-info-circle me-2"></i>
@ -428,7 +496,6 @@
{% trans "This password provides access to the agency portal. Share it securely with the agency contact person." %} {% trans "This password provides access to the agency portal. Share it securely with the agency contact person." %}
</p> </p>
</div> </div>
<div class="password-display-section"> <div class="password-display-section">
<div class="info-item"> <div class="info-item">
<div class="info-icon"> <div class="info-icon">
@ -437,9 +504,8 @@
<div class="info-content"> <div class="info-content">
<div class="info-label">{% trans "Username" %}</div> <div class="info-label">{% trans "Username" %}</div>
<div class="info-value">{{ agency.user.username }}</div> <div class="info-value">{{ agency.user.username }}</div>
</div> </div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-icon"> <div class="info-icon">
<i class="fas fa-lock"></i> <i class="fas fa-lock"></i>
@ -461,59 +527,138 @@
</div> </div>
</div> </div>
<!-- Recent Candidates --> <div class="card kaauh-card mb-4">
<div class="card kaauh-card">
<div class="card-header bg-white border-bottom"> <div class="card-header p-0 bg-white">
<div class="d-flex justify-content-between align-items-center"> <ul class="nav nav-tabs card-header-tabs" id="agencyTabs" role="tablist">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);"> <li class="nav-item ms-2" role="presentation">
<i class="fas fa-users me-2"></i> <button
{% trans "Recent Candidates" %} class="nav-link active"
</h5> id="candidates-tab"
{% comment %} <a href="{% url 'agency_candidates' agency.slug %}" class="btn btn-main-action btn-sm"> data-bs-toggle="tab"
{% trans "View All Candidates" %} data-bs-target="#candidates"
<i class="fas fa-arrow-right ms-1"></i> type="button"
</a> {% endcomment %} role="tab"
</div> aria-controls="candidates"
aria-selected="true"
>
<i class="fas fa-users me-1"></i>
{% trans "Recent Candidates" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link"
id="jobs-tab"
data-bs-toggle="tab"
data-bs-target="#jobs"
type="button"
role="tab"
aria-controls="jobs"
aria-selected="false"
>
<i class="fas fa-briefcase me-1"></i>
{% trans "Assigned Jobs" %}
</button>
</li>
</ul>
</div> </div>
<div class="card-body">
{% if candidates %} <div class="tab-content" id="agencyTabsContent">
{% for candidate in candidates %}
<div class="candidate-item"> <div
<div class="d-flex justify-content-between align-items-center"> class="tab-pane fade show active p-4"
<div> id="candidates"
<div class="candidate-name">{{ candidate.name }}</div> role="tabpanel"
<div class="candidate-details"> aria-labelledby="candidates-tab"
<i class="fas fa-envelope me-1"></i> {{ candidate.email }} >
{% if candidate.phone %} {% if candidates %}
<span class="ms-3"><i class="fas fa-phone me-1"></i> {{ candidate.phone }}</span> {% for candidate in candidates %}
{% endif %} <div class="candidate-item">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="candidate-name">{{ candidate.name }}</div>
<div class="candidate-details">
<i class="fas fa-envelope me-1"></i> {{ candidate.email }}
{% if candidate.phone %}
<span class="ms-3"><i class="fas fa-phone me-1"></i> {{ candidate.phone }}</span>
{% endif %}
</div>
</div> </div>
</div> <div class="text-end">
<div class="text-end"> <span class="stage-badge stage-{{ candidate.stage }}">
<span class="stage-badge stage-{{ candidate.stage }}"> {{ candidate.get_stage_display }}
{{ candidate.get_stage_display }} </span>
</span> <div class="small text-muted mt-1">
<div class="small text-muted mt-1"> {{ candidate.created_at|date:"M d, Y" }}
{{ candidate.created_at|date:"M d, Y" }} </div>
</div> </div>
</div> </div>
</div> </div>
{% endfor %}
{% else %}
<div class="empty-state">
<i class="fas fa-user-slash"></i>
<h6>{% trans "No candidates yet" %}</h6>
<p class="mb-0">{% trans "This agency hasn't submitted any candidates yet." %}</p>
</div> </div>
{% endfor %} {% endif %}
{% else %} </div>
<div class="empty-state">
<i class="fas fa-user-slash"></i> <div
<h6>{% trans "No candidates yet" %}</h6> class="tab-pane fade p-4"
<p class="mb-0">{% trans "This agency hasn't submitted any candidates yet." %}</p> id="jobs"
</div> role="tabpanel"
{% endif %} aria-labelledby="jobs-tab"
>
{% comment %}
NOTE: You will need to pass an 'assigned_jobs' list
from your Django view context to populate this section.
{% endcomment %}
{% if assigned_jobs %}
{% for assignment in assigned_jobs %}
<div class="job-item">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="job-title">
<a href="{% url 'job_details' assignment.job.slug %}" class="text-decoration-none text-primary-theme">
{{ assignment.job.title }}
</a>
</div>
<div class="job-details">
<i class="fas fa-map-pin me-1"></i> {{ assignment.job.location }}
<span class="ms-3"><i class="fas fa-user-tie me-1"></i> {{ assignment.job.department.name }}</span>
</div>
</div>
<div class="text-end">
<span class="job-status-badge">
{% trans "Assigned" %}
</span>
<div class="small text-muted mt-1">
{% trans "Assigned On:" %} {{ assignment.created_at|date:"M d, Y" }}
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<i class="fas fa-briefcase-slash"></i>
<h6>{% trans "No jobs assigned" %}</h6>
<p class="mb-0">{% trans "There are no open job assignments for this agency." %}</p>
<a href="{% url 'agency_assignment_create' agency.slug %}" class="btn btn-main-action mt-3 btn-sm">
<i class="fas fa-plus me-1"></i> {% trans "Assign New Job" %}
</a>
</div>
{% endif %}
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Sidebar -->
<div class="col-lg-4"> <div class="col-lg-4">
<!-- Statistics -->
<div class="card kaauh-card mb-4"> <div class="card kaauh-card mb-4">
<div class="card-header bg-white border-bottom"> <div class="card-header bg-white border-bottom">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);"> <h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
@ -551,40 +696,6 @@
</div> </div>
</div> </div>
<!-- Quick Actions -->
{% comment %} <div class="card kaauh-card mb-4">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-bolt me-2"></i>
{% trans "Quick Actions" %}
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{% url 'agency_update' agency.slug %}" class="btn btn-main-action">
<i class="fas fa-edit me-2"></i> {% trans "Edit Agency" %}
</a>
<a href="{% url 'agency_candidates' agency.slug %}" class="btn btn-outline-info">
<i class="fas fa-users me-2"></i> {% trans "View All Candidates" %}
</a>
<a href="#" class="btn btn-main-action">
<i class="fas fa-paper-plane me-2"></i> {% trans "Send Message" %}
</a>
{% if agency.website %}
<a href="{{ agency.website }}" target="_blank" class="btn btn-outline-secondary">
<i class="fas fa-external-link-alt me-2"></i> {% trans "Visit Website" %}
</a>
{% endif %}
{% if agency.email %}
<a href="mailto:{{ agency.email }}" class="btn btn-outline-success">
<i class="fas fa-envelope me-2"></i> {% trans "Send Email" %}
</a>
{% endif %}
</div>
</div>
</div> {% endcomment %}
<!-- Agency Information -->
<div class="card kaauh-card"> <div class="card kaauh-card">
<div class="card-header bg-white border-bottom"> <div class="card-header bg-white border-bottom">
<h5 class="mb-0" style="color: var(--kaauh-teal-dark);"> <h5 class="mb-0" style="color: var(--kaauh-teal-dark);">
@ -634,4 +745,4 @@ function copyPassword() {
} }
</script> </script>
{% endblock %} {% endblock %}

View File

@ -147,28 +147,23 @@
</div> </div>
</div> </div>
<!-- Search Form -->
<div class="search-form"> <div class="card mb-4 shadow-sm no-hover">
<form method="get" class="row g-3"> <div class="card-body">
<div class="col-md-10"> <div class="row g-4">
<div class="input-group"> <div class="col-md-6">
<span class="input-group-text"> <label for="search" class="form-label small text-muted">{% trans "Search by name, contact person, email, or country..." %}</label>
<i class="fas fa-search"></i> <div class="input-group input-group-lg">
</span> <form method="get" action="" class="w-100">
<input type="text" {% include 'includes/search_form.html' %}
name="q" </form>
class="form-control"
placeholder="{% trans 'Search by name, contact person, email, or country...' %}"
value="{{ search_query }}">
</div> </div>
</div> </div>
<div class="col-md-1">
<button type="submit" class="btn btn-main-action w-100">
<i class="fas fa-search me-1"></i> {% trans "Search" %} </div>
</button>
</div>
</form>
</div> </div>
</div>
<!-- Agencies List --> <!-- Agencies List -->
{% if page_obj %} {% if page_obj %}

View File

@ -206,14 +206,11 @@
{% csrf_token %} {% csrf_token %}
{# Select Input Group - No label needed for this one, so we just flex the select and button #} {# Select Input Group - No label needed for this one, so we just flex the select and button #}
<select name="mark_as" id="update_status" class="form-select form-select-sm" style="width: 120px;"> <select name="mark_as" id="update_status" class="form-select form-select-sm" style="width: 120px;">
<option selected> <option selected>
---------- ----------
</option> </option>
<option value="Document Review">
{% trans "To Documents Review" %}
</option>
<option value="Offer"> <option value="Offer">
{% trans "To Offer" %} {% trans "To Offer" %}
</option> </option>
@ -236,7 +233,7 @@
</button> </button>
</form> </form>
<div class="vr" style="height: 28px;"></div> <div class="vr" style="height: 28px;"></div>
<button type="button" class="btn btn-outline-info btn-sm" <button type="button" class="btn btn-outline-info btn-sm"
data-bs-toggle="modal" data-bs-toggle="modal"
@ -251,7 +248,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<form id="candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="get"> <form id="candidate-form" action="{% url 'candidate_update_status' job.slug %}" method="get">
@ -285,6 +282,7 @@
<div class="form-check"> <div class="form-check">
<input name="candidate_ids" value="{{ candidate.id }}" type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}"> <input name="candidate_ids" value="{{ candidate.id }}" type="checkbox" class="form-check-input rowCheckbox" id="candidate-{{ candidate.id }}">
</div> </div>
</td> </td>
<td> <td>
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
@ -306,8 +304,8 @@
</div> </div>
</td> </td>
<td class="candidate-details text-muted"> <td class="candidate-details text-muted">
{% if candidate.get_latest_meeting.topic %} {% if candidate.get_latest_meeting %}
{{ candidate.get_latest_meeting.topic }} {{ candidate.get_latest_meeting }}
{% else %} {% else %}
-- --
{% endif %} {% endif %}
@ -330,8 +328,8 @@
</td> </td>
<td> <td>
{% with latest_meeting=candidate.get_latest_meeting %} {% with latest_meeting=candidate.get_latest_meeting %}
{% if latest_meeting and latest_meeting.join_url %} {% if latest_meeting and latest_meeting.details_url %}
<a href="{{ latest_meeting.join_url }}" target="_blank" class="btn btn-sm bg-primary-theme text-white" title="Join Interview" <a href="{{ latest_meeting.details_url }}" target="_blank" class="btn btn-sm bg-primary-theme text-white" title="Join Interview"
{% if latest_meeting.status == 'ended' %}disabled{% endif %}> {% if latest_meeting.status == 'ended' %}disabled{% endif %}>
join join
<i class="fas fa-video me-1"></i> <i class="fas fa-video me-1"></i>
@ -382,8 +380,10 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if candidate.get_latest_meeting %}
{% if candidate.get_latest_meeting.location_type == 'Remote'%}
{% if candidate.get_latest_meeting %}
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#candidateviewModal" data-bs-target="#candidateviewModal"
@ -401,16 +401,47 @@
title="Delete Meeting"> title="Delete Meeting">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
{% else%}
{% else %}
<button type="button" class="btn btn-main-action btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#candidateviewModal" data-bs-target="#candidateviewModal"
hx-get="{% url 'schedule_meeting_for_candidate' job.slug candidate.pk %}" hx-get="{% url 'reschedule_onsite_meeting' job.slug candidate.pk candidate.get_latest_meeting.pk %}"
hx-target="#candidateviewModalBody" hx-target="#candidateviewModalBody"
data-modal-title="{% trans 'Schedule Interview' %}" title="Reschedule">
title="Schedule Interview"> <i class="fas fa-redo-alt"></i>
<i class="fas fa-calendar-plus"></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-video"></i>
</button>
<button type="button" class="btn btn-main-action btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
{# UPDATED: Points to the specific Onsite scheduling URL #}
hx-get="{% url 'schedule_onsite_meeting_for_candidate' job.slug candidate.pk %}"
hx-target="#candidateviewModalBody"
data-modal-title="{% trans 'Schedule Onsite Interview' %}"
title="Schedule Onsite Interview">
<i class="fas fa-building"></i>
</button> </button>
{% endif %} {% endif %}
@ -466,7 +497,7 @@
<div class="text-center py-5 text-muted"> <div class="text-center py-5 text-muted">
<i class="fas fa-spinner fa-spin fa-2x"></i><br> <i class="fas fa-spinner fa-spin fa-2x"></i><br>
{% trans "Loading email form..." %} {% trans "Loading email form..." %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -39,7 +39,7 @@
} }
/* Main Action Button Style (Teal Theme) */ /* Main Action Button Style (Teal Theme) */
.btn-main-action { .btn-main-action {
background-color: var(--kaauh-teal); background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal); border-color: var(--kaauh-teal);
color: white; color: white;
@ -47,8 +47,7 @@
transition: all 0.2s ease; transition: all 0.2s ease;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.25rem;
padding: 0.5rem 1rem;
} }
.btn-main-action:hover { .btn-main-action:hover {
@ -224,6 +223,14 @@
<option value="{{ job.slug }}" {% if job_filter == job.slug %}selected{% endif %}>{{ job.title }}</option> <option value="{{ job.slug }}" {% if job_filter == job.slug %}selected{% endif %}>{{ job.title }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div>
</div>
<div class="col-md-4">
<label for="job_filter" class="form-label small text-muted">{% trans "Filter by Stages" %}</label>
<div class="d-flex gap-2">
<select name="stage" id="stage_filter" class="form-select form-select-sm"> <select name="stage" id="stage_filter" class="form-select form-select-sm">
<option value="">{% trans "All Stages" %}</option> <option value="">{% trans "All Stages" %}</option>
<option value="Applied" {% if stage_filter == 'Applied' %}selected{% endif %}>{% trans "Applied" %}</option> <option value="Applied" {% if stage_filter == 'Applied' %}selected{% endif %}>{% trans "Applied" %}</option>
@ -235,10 +242,10 @@
</div> </div>
{# Buttons Group (pushed to the right/bottom) #} {# Buttons Group (pushed to the right/bottom) #}
<div class="col-md-4 d-flex justify-content-end align-self-end"> <div class="col-md-4 d-flex ">
<div class="filter-buttons"> <div class="filter-buttons">
<button type="submit" class="btn btn-main-action btn-sm"> <button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-filter me-1"></i> {% trans "Apply" %} <i class="fas fa-filter me-1"></i> {% trans "Apply Filters" %}
</button> </button>
{% if job_filter or stage_filter or search_query %} {% if job_filter or stage_filter or search_query %}
<a href="{% url 'candidate_list' %}" class="btn btn-outline-secondary btn-sm"> <a href="{% url 'candidate_list' %}" class="btn btn-outline-secondary btn-sm">