update meeting
This commit is contained in:
parent
da05441f94
commit
bfd2ad935a
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -225,108 +225,147 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi
|
|||||||
return {'success': False, 'error': error_msg}
|
return {'success': False, 'error': error_msg}
|
||||||
|
|
||||||
from .models import Candidate
|
from .models import Candidate
|
||||||
def send_bulk_email(subject, message, recipient_list, request=None, attachments=None, async_task_=False):
|
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 django.shortcuts import get_object_or_404
|
||||||
|
import logging
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
from django_q.tasks import async_task # Import needed at the top for clarity
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def send_bulk_email(subject, message, recipient_list, request=None, attachments=None, async_task_=False, from_interview=False):
|
||||||
"""
|
"""
|
||||||
Send bulk email to multiple recipients with HTML support and attachments,
|
Send bulk email to multiple recipients with HTML support and attachments,
|
||||||
supporting synchronous or asynchronous dispatch.
|
supporting synchronous or asynchronous dispatch.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# --- 1. Categorization and Custom Message Preparation (CORRECTED) ---
|
||||||
|
if not from_interview:
|
||||||
|
|
||||||
# Define messages (Placeholders)
|
|
||||||
|
|
||||||
|
|
||||||
all_candidate_emails = []
|
|
||||||
candidate_through_agency_emails=[]
|
|
||||||
participant_emails = []
|
|
||||||
agency_emails = []
|
agency_emails = []
|
||||||
left_candidate_emails = []
|
pure_candidate_emails = []
|
||||||
|
candidate_through_agency_emails = []
|
||||||
|
|
||||||
if not recipient_list:
|
if not recipient_list:
|
||||||
return {'success': False, 'error': 'No recipients provided'}
|
return {'success': False, 'error': 'No recipients provided'}
|
||||||
|
|
||||||
|
# This must contain (final_recipient_email, customized_message) for ALL sends
|
||||||
|
customized_sends = []
|
||||||
|
|
||||||
|
# 1a. Classify Recipients and Prepare Custom Messages
|
||||||
for email in recipient_list:
|
for email in recipient_list:
|
||||||
email = email.strip().lower() # Clean input email
|
email = email.strip().lower()
|
||||||
if email:
|
|
||||||
|
|
||||||
candidate = Candidate.objects.filter(email=email).first()
|
try:
|
||||||
if candidate:
|
candidate = get_object_or_404(Candidate, email=email)
|
||||||
all_candidate_emails.append(email)
|
except Exception:
|
||||||
|
logger.warning(f"Candidate not found for email: {email}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidate_name = candidate.first_name
|
||||||
|
|
||||||
|
# --- Candidate belongs to an agency (Final Recipient: Agency) ---
|
||||||
|
if candidate.belong_to_an_agency and candidate.hiring_agency and candidate.hiring_agency.email:
|
||||||
|
agency_email = candidate.hiring_agency.email
|
||||||
|
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
||||||
|
|
||||||
|
# Add Agency email as the recipient with the custom message
|
||||||
|
customized_sends.append((agency_email, agency_message))
|
||||||
|
agency_emails.append(agency_email)
|
||||||
|
candidate_through_agency_emails.append(candidate.email) # For sync block only
|
||||||
|
|
||||||
|
# --- Pure Candidate (Final Recipient: Candidate) ---
|
||||||
else:
|
else:
|
||||||
participant_emails.append(email)
|
candidate_message = f"Hi, {candidate_name}" + "\n" + message
|
||||||
|
|
||||||
|
# Add Candidate email as the recipient with the custom message
|
||||||
|
customized_sends.append((email, candidate_message))
|
||||||
|
pure_candidate_emails.append(email) # For sync block only
|
||||||
|
|
||||||
for email in all_candidate_emails:
|
# Calculate total recipients based on the size of the final send list
|
||||||
|
total_recipients = len(customized_sends)
|
||||||
|
|
||||||
candidate = Candidate.objects.filter(email=email).first()
|
|
||||||
|
|
||||||
if candidate:
|
|
||||||
|
|
||||||
if candidate.hiring_source == 'Agency' and hasattr(candidate, 'hiring_agency') and candidate.hiring_agency:
|
|
||||||
agency = candidate.hiring_agency
|
|
||||||
candidate_through_agency_emails.append(email)
|
|
||||||
if agency and agency.email:
|
|
||||||
agency_emails.append(agency.email)
|
|
||||||
else:
|
|
||||||
left_candidate_emails.append(email)
|
|
||||||
else:
|
|
||||||
left_candidate_emails.append(email)
|
|
||||||
|
|
||||||
# Determine unique recipients
|
|
||||||
unique_left_candidates = list(set(left_candidate_emails)) # Convert to list for async task
|
|
||||||
unique_agencies = list(agency_emails)
|
|
||||||
unique_participants = list(set(participant_emails))
|
|
||||||
|
|
||||||
total_recipients = len(unique_left_candidates) + len(unique_agencies) + len(unique_participants)
|
|
||||||
if total_recipients == 0:
|
if total_recipients == 0:
|
||||||
return {'success': False, 'error': 'No valid email addresses found after categorization'}
|
return {'success': False, 'error': 'No valid recipients found for sending.'}
|
||||||
|
else:
|
||||||
|
# For interview flow
|
||||||
|
total_recipients = len(recipient_list)
|
||||||
|
|
||||||
|
|
||||||
# --- 3. Handle ASYNC Dispatch ---
|
# --- 2. Handle ASYNC Dispatch (FIXED: Single loop used) ---
|
||||||
if async_task_:
|
if async_task_:
|
||||||
try:
|
try:
|
||||||
from django_q.tasks import async_task
|
|
||||||
|
|
||||||
# Simple, serializable attachment format assumed: list of (filename, content, content_type) tuples
|
|
||||||
processed_attachments = attachments if attachments else []
|
processed_attachments = attachments if attachments else []
|
||||||
|
|
||||||
task_ids = []
|
task_ids = []
|
||||||
|
|
||||||
# Queue Left Candidates
|
if not from_interview:
|
||||||
if unique_left_candidates:
|
# Loop through ALL final customized sends
|
||||||
|
for recipient_email, custom_message in customized_sends:
|
||||||
task_id = async_task(
|
task_id = async_task(
|
||||||
'recruitment.tasks.send_bulk_email_task',
|
'recruitment.tasks.send_bulk_email_task',
|
||||||
subject,
|
subject,
|
||||||
message,
|
custom_message, # Pass the custom message
|
||||||
recipient_list,
|
[recipient_email], # Pass the specific recipient as a list of one
|
||||||
processed_attachments, # Pass serializable data
|
processed_attachments,
|
||||||
hook='recruitment.tasks.email_success_hook' # Example hook
|
hook='recruitment.tasks.email_success_hook'
|
||||||
)
|
)
|
||||||
task_ids.append(task_id)
|
task_ids.append(task_id)
|
||||||
|
|
||||||
logger.info(f" email queued. ID: {task_id}")
|
logger.info(f"{len(task_ids)} tasks ({total_recipients} emails) queued.")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'async': True,
|
'async': True,
|
||||||
'task_ids': task_ids,
|
'task_ids': task_ids,
|
||||||
'message': f'Emails queued for background sending to {total_recipients} recipient(s)'
|
'message': f'Emails queued for background sending to {len(task_ids)} recipient(s).'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
else: # from_interview is True (generic send to all participants)
|
||||||
|
task_id = async_task(
|
||||||
|
'recruitment.tasks.send_bulk_email_task',
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
recipient_list, # Send the original message to the entire list
|
||||||
|
processed_attachments,
|
||||||
|
hook='recruitment.tasks.email_success_hook'
|
||||||
|
)
|
||||||
|
task_ids.append(task_id)
|
||||||
|
logger.info(f"Interview emails queued. ID: {task_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'async': True,
|
||||||
|
'task_ids': task_ids,
|
||||||
|
'message': f'Interview emails queued for background sending to {total_recipients} recipient(s)'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.error("Async execution requested, but django_q or required modules not found. Defaulting to sync.")
|
logger.error("Async execution requested, but django_q or required modules not found. Defaulting to sync.")
|
||||||
async_task_ = False # Fallback to sync
|
async_task_ = False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to queue async tasks: {str(e)}", exc_info=True)
|
logger.error(f"Failed to queue async tasks: {str(e)}", exc_info=True)
|
||||||
return {'success': False, 'error': f"Failed to queue async tasks: {str(e)}"}
|
return {'success': False, 'error': f"Failed to queue async tasks: {str(e)}"}
|
||||||
|
|
||||||
# --- 4. Handle SYNCHRONOUS Send (If async_task_=False or fallback) ---
|
# --- 3. Handle SYNCHRONOUS Send (No changes needed here, as it was fixed previously) ---
|
||||||
try:
|
try:
|
||||||
|
# NOTE: The synchronous block below should also use the 'customized_sends'
|
||||||
|
# list for consistency instead of rebuilding messages from 'pure_candidate_emails'
|
||||||
|
# and 'agency_emails', but keeping your current logic structure to minimize changes.
|
||||||
|
|
||||||
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
|
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
|
||||||
is_html = '<' in message and '>' in message
|
is_html = '<' in message and '>' in message
|
||||||
successful_sends = 0
|
successful_sends = 0
|
||||||
|
|
||||||
# Helper Function for Sync Send
|
# Helper Function for Sync Send (as provided)
|
||||||
def send_individual_email(recipient, body_message):
|
def send_individual_email(recipient, body_message):
|
||||||
|
# ... (Existing helper function logic) ...
|
||||||
nonlocal successful_sends
|
nonlocal successful_sends
|
||||||
|
|
||||||
if is_html:
|
if is_html:
|
||||||
@ -336,7 +375,6 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
|||||||
else:
|
else:
|
||||||
email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
|
email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
|
||||||
|
|
||||||
# Attachment Logic
|
|
||||||
if attachments:
|
if attachments:
|
||||||
for attachment in attachments:
|
for attachment in attachments:
|
||||||
if hasattr(attachment, 'read'):
|
if hasattr(attachment, 'read'):
|
||||||
@ -349,31 +387,26 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
|||||||
email_obj.attach(filename, content, content_type)
|
email_obj.attach(filename, content, content_type)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# FIX: Added the critical .send() call
|
|
||||||
email_obj.send(fail_silently=False)
|
email_obj.send(fail_silently=False)
|
||||||
successful_sends += 1
|
successful_sends += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
|
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
|
||||||
|
|
||||||
|
if not from_interview:
|
||||||
# Send Emails
|
# Send Emails - Pure Candidates
|
||||||
for email in unique_left_candidates:
|
for email in pure_candidate_emails:
|
||||||
candidate_name = Candidate.objects.filter(email=email).first().first_name
|
candidate_name = Candidate.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)
|
||||||
|
|
||||||
|
# Send Emails - Agencies
|
||||||
i = 0
|
i = 0
|
||||||
for email in unique_agencies:
|
for email in agency_emails:
|
||||||
candidate_name=Candidate.objects.filter(email=candidate_through_agency_emails[i]).first().first_name
|
candidate_email = candidate_through_agency_emails[i]
|
||||||
|
candidate_name = Candidate.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
|
||||||
for email in unique_participants:
|
|
||||||
|
|
||||||
participant_message = "Hello Participant! This is a general notification for you."
|
|
||||||
send_individual_email(email, participant_message)
|
|
||||||
|
|
||||||
|
|
||||||
logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.")
|
logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.")
|
||||||
return {
|
return {
|
||||||
@ -381,6 +414,16 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
|||||||
'recipients_count': successful_sends,
|
'recipients_count': successful_sends,
|
||||||
'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.'
|
'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.'
|
||||||
}
|
}
|
||||||
|
else:
|
||||||
|
for email in recipient_list:
|
||||||
|
send_individual_email(email, message)
|
||||||
|
|
||||||
|
logger.info(f"Interview email processing complete. Sent successfully to {successful_sends} out of {total_recipients} recipients.")
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'recipients_count': successful_sends,
|
||||||
|
'message': f'Interview emails sent successfully to {successful_sends} recipient(s).'
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Failed to process bulk email send request: {str(e)}"
|
error_msg = f"Failed to process bulk email send request: {str(e)}"
|
||||||
|
|||||||
@ -1243,24 +1243,24 @@ class ParticipantsForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ParticipantsSelectForm(forms.ModelForm):
|
# class ParticipantsSelectForm(forms.ModelForm):
|
||||||
"""Form for selecting Participants"""
|
# """Form for selecting Participants"""
|
||||||
|
|
||||||
participants=forms.ModelMultipleChoiceField(
|
# participants=forms.ModelMultipleChoiceField(
|
||||||
queryset=Participants.objects.all(),
|
# queryset=Participants.objects.all(),
|
||||||
widget=forms.CheckboxSelectMultiple,
|
# widget=forms.CheckboxSelectMultiple,
|
||||||
required=False,
|
# required=False,
|
||||||
label=_("Select Participants"))
|
# label=_("Select Participants"))
|
||||||
|
|
||||||
users=forms.ModelMultipleChoiceField(
|
# users=forms.ModelMultipleChoiceField(
|
||||||
queryset=User.objects.all(),
|
# queryset=User.objects.all(),
|
||||||
widget=forms.CheckboxSelectMultiple,
|
# widget=forms.CheckboxSelectMultiple,
|
||||||
required=False,
|
# required=False,
|
||||||
label=_("Select Users"))
|
# label=_("Select Users"))
|
||||||
|
|
||||||
class Meta:
|
# class Meta:
|
||||||
model = JobPosting
|
# model = JobPosting
|
||||||
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):
|
||||||
@ -1273,13 +1273,6 @@ class CandidateEmailForm(forms.Form):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# to = forms.MultipleChoiceField(
|
|
||||||
# widget=forms.CheckboxSelectMultiple(attrs={
|
|
||||||
# 'class': 'form-check'
|
|
||||||
# }),
|
|
||||||
# label=_('candidates'),
|
|
||||||
# required=True
|
|
||||||
# )
|
|
||||||
|
|
||||||
subject = forms.CharField(
|
subject = forms.CharField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
@ -1303,58 +1296,13 @@ class CandidateEmailForm(forms.Form):
|
|||||||
required=True
|
required=True
|
||||||
)
|
)
|
||||||
|
|
||||||
recipients = forms.MultipleChoiceField(
|
|
||||||
widget=forms.CheckboxSelectMultiple(attrs={
|
|
||||||
'class': 'form-check'
|
|
||||||
}),
|
|
||||||
label=_('Recipients'),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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
|
||||||
stage=self.candidates.first().stage
|
|
||||||
|
|
||||||
# Get all participants and users for this job
|
|
||||||
recipient_choices = []
|
|
||||||
|
|
||||||
# Add job participants
|
|
||||||
#show particpants only in the interview stage
|
|
||||||
if stage=='Interview':
|
|
||||||
for participant in job.participants.all():
|
|
||||||
recipient_choices.append(
|
|
||||||
(f'participant_{participant.id}', f'{participant.name} - {participant.designation} (Participant)')
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add job users
|
|
||||||
for user in job.users.all():
|
|
||||||
recipient_choices.append(
|
|
||||||
(f'user_{user.id}', f'{user.get_full_name() or user.username} - {user.email} (User)')
|
|
||||||
)
|
|
||||||
|
|
||||||
self.fields['recipients'].choices = recipient_choices
|
|
||||||
self.fields['recipients'].initial = [choice[0] for choice in recipient_choices] # Select all by default
|
|
||||||
|
|
||||||
candidate_choices=[]
|
candidate_choices=[]
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
@ -1366,11 +1314,11 @@ class CandidateEmailForm(forms.Form):
|
|||||||
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 subject
|
|
||||||
# self.fields['subject'].initial = f'Interview Update: {candidate.name} - {job.title}'
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
@ -1398,7 +1346,7 @@ class CandidateEmailForm(forms.Form):
|
|||||||
f"Best regards, The KAAUH Hiring team"
|
f"Best regards, The KAAUH Hiring team"
|
||||||
]
|
]
|
||||||
|
|
||||||
elif candidate.stage == 'Exam':
|
elif 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.",
|
||||||
f"We're pleased to inform you that your initial screening was successful!",
|
f"We're pleased to inform you that your initial screening was successful!",
|
||||||
@ -1450,42 +1398,13 @@ class CandidateEmailForm(forms.Form):
|
|||||||
|
|
||||||
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 clean_to(self):
|
|
||||||
# """Ensure at least one recipient is selected"""
|
|
||||||
# candidates = self.cleaned_data.get('to')
|
|
||||||
# print(candidates)
|
|
||||||
# if not candidates:
|
|
||||||
# raise forms.ValidationError(_('Please select at least one candidate.'))
|
|
||||||
# return candidates
|
|
||||||
|
|
||||||
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', [])
|
|
||||||
candidates=self.cleaned_data.get('to',[])
|
candidates=self.cleaned_data.get('to',[])
|
||||||
if recipients:
|
|
||||||
for recipient in recipients:
|
|
||||||
if recipient.startswith('participant_'):
|
|
||||||
participant_id = recipient.split('_')[1]
|
|
||||||
try:
|
|
||||||
participant = Participants.objects.get(id=participant_id)
|
|
||||||
email_addresses.append(participant.email)
|
|
||||||
except Participants.DoesNotExist:
|
|
||||||
continue
|
|
||||||
elif recipient.startswith('user_'):
|
|
||||||
user_id = recipient.split('_')[1]
|
|
||||||
try:
|
|
||||||
user = User.objects.get(id=user_id)
|
|
||||||
email_addresses.append(user.email)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
continue
|
|
||||||
if candidates:
|
if candidates:
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if candidate.startswith('candidate_'):
|
if candidate.startswith('candidate_'):
|
||||||
@ -1500,28 +1419,186 @@ class CandidateEmailForm(forms.Form):
|
|||||||
|
|
||||||
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 the formatted message with optional additional information"""
|
"""Get the formatted message with optional additional information"""
|
||||||
message = self.cleaned_data.get('message', 'mesaage from system user hiii')
|
message = self.cleaned_data.get('message', '')
|
||||||
|
|
||||||
# # Add candidate information if requested
|
|
||||||
# if self.cleaned_data.get('include_candidate_info') and self.candidate:
|
|
||||||
# candidate_info = f"\n\n--- Candidate Information ---\n"
|
|
||||||
# candidate_info += f"Name: {self.candidate.name}\n"
|
|
||||||
# candidate_info += f"Email: {self.candidate.email}\n"
|
|
||||||
# candidate_info += f"Phone: {self.candidate.phone}\n"
|
|
||||||
# message += candidate_info
|
|
||||||
|
|
||||||
# # Add meeting details if requested
|
|
||||||
# if self.cleaned_data.get('include_meeting_details') and self.candidate:
|
|
||||||
# latest_meeting = self.candidate.get_latest_meeting
|
|
||||||
# if latest_meeting:
|
|
||||||
# meeting_info = f"\n\n--- Meeting Details ---\n"
|
|
||||||
# meeting_info += f"Topic: {latest_meeting.topic}\n"
|
|
||||||
# meeting_info += f"Date & Time: {latest_meeting.start_time.strftime('%B %d, %Y at %I:%M %p')}\n"
|
|
||||||
# meeting_info += f"Duration: {latest_meeting.duration} minutes\n"
|
|
||||||
# if latest_meeting.join_url:
|
|
||||||
# meeting_info += f"Join URL: {latest_meeting.join_url}\n"
|
|
||||||
# message += meeting_info
|
|
||||||
|
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
class InterviewParticpantsForm(forms.ModelForm):
|
||||||
|
participants = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=Participants.objects.all(),
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
required=False ,
|
||||||
|
|
||||||
|
)
|
||||||
|
system_users=forms.ModelMultipleChoiceField(
|
||||||
|
queryset=User.objects.all(),
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
required=False,
|
||||||
|
label=_("Select Users"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = InterviewSchedule
|
||||||
|
fields = ['participants','system_users']
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-06 15:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='scheduledinterview',
|
||||||
|
name='participants',
|
||||||
|
field=models.ManyToManyField(blank=True, to='recruitment.participants'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-06 15:37
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0002_scheduledinterview_participants'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='scheduledinterview',
|
||||||
|
name='system_users',
|
||||||
|
field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-06 15:44
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0003_scheduledinterview_system_users'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='jobposting',
|
||||||
|
name='participants',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='jobposting',
|
||||||
|
name='users',
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -54,18 +54,18 @@ class JobPosting(Base):
|
|||||||
("HYBRID", "Hybrid"),
|
("HYBRID", "Hybrid"),
|
||||||
]
|
]
|
||||||
|
|
||||||
users=models.ManyToManyField(
|
# users=models.ManyToManyField(
|
||||||
User,
|
# User,
|
||||||
blank=True,related_name="jobs_assigned",
|
# blank=True,related_name="jobs_assigned",
|
||||||
verbose_name=_("Internal Participant"),
|
# verbose_name=_("Internal Participant"),
|
||||||
help_text=_("Internal staff involved in the recruitment process for this job"),
|
# help_text=_("Internal staff involved in the recruitment process for this job"),
|
||||||
)
|
# )
|
||||||
|
|
||||||
participants=models.ManyToManyField('Participants',
|
# participants=models.ManyToManyField('Participants',
|
||||||
blank=True,related_name="jobs_participating",
|
# blank=True,related_name="jobs_participating",
|
||||||
verbose_name=_("External Participant"),
|
# verbose_name=_("External Participant"),
|
||||||
help_text=_("External participants involved in the recruitment process for this job"),
|
# help_text=_("External participants involved in the recruitment process for this job"),
|
||||||
)
|
# )
|
||||||
|
|
||||||
# Core Fields
|
# Core Fields
|
||||||
title = models.CharField(max_length=200)
|
title = models.CharField(max_length=200)
|
||||||
@ -421,6 +421,7 @@ class Candidate(Base):
|
|||||||
related_name="candidates",
|
related_name="candidates",
|
||||||
verbose_name=_("Job"),
|
verbose_name=_("Job"),
|
||||||
)
|
)
|
||||||
|
|
||||||
first_name = models.CharField(max_length=255, verbose_name=_("First Name"))
|
first_name = models.CharField(max_length=255, verbose_name=_("First Name"))
|
||||||
last_name = models.CharField(max_length=255, verbose_name=_("Last Name"))
|
last_name = models.CharField(max_length=255, verbose_name=_("Last Name"))
|
||||||
email = models.EmailField(db_index=True, verbose_name=_("Email")) # Added index
|
email = models.EmailField(db_index=True, verbose_name=_("Email")) # Added index
|
||||||
@ -707,6 +708,11 @@ class Candidate(Base):
|
|||||||
return time_to_hire.days
|
return time_to_hire.days
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def belong_to_an_agency(self):
|
||||||
|
return self.hiring_source=='Agency'
|
||||||
|
|
||||||
|
|
||||||
class TrainingMaterial(Base):
|
class TrainingMaterial(Base):
|
||||||
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||||
content = CKEditor5Field(blank=True, verbose_name=_("Content"),config_name='extends')
|
content = CKEditor5Field(blank=True, verbose_name=_("Content"),config_name='extends')
|
||||||
@ -772,7 +778,7 @@ class ZoomMeeting(Base):
|
|||||||
# Timestamps
|
# Timestamps
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.topic\
|
return self.topic
|
||||||
@property
|
@property
|
||||||
def get_job(self):
|
def get_job(self):
|
||||||
return self.interview.job
|
return self.interview.job
|
||||||
@ -781,10 +787,10 @@ class ZoomMeeting(Base):
|
|||||||
return self.interview.candidate
|
return self.interview.candidate
|
||||||
@property
|
@property
|
||||||
def get_participants(self):
|
def get_participants(self):
|
||||||
return self.interview.job.participants.all()
|
return self.interview.participants.all()
|
||||||
@property
|
@property
|
||||||
def get_users(self):
|
def get_users(self):
|
||||||
return self.interview.job.users.all()
|
return self.interview.system_users.all()
|
||||||
|
|
||||||
class MeetingComment(Base):
|
class MeetingComment(Base):
|
||||||
"""
|
"""
|
||||||
@ -1639,6 +1645,9 @@ class ScheduledInterview(Base):
|
|||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
participants = models.ManyToManyField('Participants', blank=True)
|
||||||
|
system_users=models.ManyToManyField(User,blank=True)
|
||||||
|
|
||||||
|
|
||||||
job = models.ForeignKey(
|
job = models.ForeignKey(
|
||||||
"JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True
|
"JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True
|
||||||
@ -1753,6 +1762,7 @@ class Notification(models.Model):
|
|||||||
|
|
||||||
class Participants(Base):
|
class Participants(Base):
|
||||||
"""Model to store Participants details"""
|
"""Model to store Participants details"""
|
||||||
|
|
||||||
name = models.CharField(max_length=255, verbose_name=_("Participant Name"),null=True,blank=True)
|
name = models.CharField(max_length=255, verbose_name=_("Participant Name"),null=True,blank=True)
|
||||||
email= models.EmailField(verbose_name=_("Email"))
|
email= models.EmailField(verbose_name=_("Email"))
|
||||||
phone = models.CharField(max_length=12,verbose_name=_("Phone Number"),null=True,blank=True)
|
phone = models.CharField(max_length=12,verbose_name=_("Phone Number"),null=True,blank=True)
|
||||||
|
|||||||
@ -746,70 +746,74 @@ def sync_candidate_to_source_task(candidate_id, source_id):
|
|||||||
return {"success": False, "error": error_msg}
|
return {"success": False, "error": error_msg}
|
||||||
|
|
||||||
|
|
||||||
def send_bulk_email_task(subject, message, recipient_list, request=None, attachments=None):
|
|
||||||
"""
|
|
||||||
Django-Q background task to send bulk email to multiple recipients.
|
|
||||||
|
|
||||||
Args:
|
from django.conf import settings
|
||||||
subject: Email subject
|
from django.core.mail import EmailMultiAlternatives
|
||||||
message: Email message (can be HTML)
|
from django.utils.html import strip_tags
|
||||||
recipient_list: List of email addresses
|
|
||||||
request: Django request object (optional)
|
|
||||||
attachments: List of file attachment data (optional)
|
|
||||||
|
|
||||||
Returns:
|
def _task_send_individual_email(subject, body_message, recipient, attachments):
|
||||||
dict: Result with success status and error message if failed
|
"""Internal helper to create and send a single email."""
|
||||||
"""
|
|
||||||
from .email_service import send_bulk_email
|
|
||||||
import os
|
|
||||||
|
|
||||||
logger.info(f"Starting bulk email task for {len(recipient_list)} recipients")
|
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
|
||||||
|
is_html = '<' in body_message and '>' in body_message
|
||||||
|
|
||||||
|
if is_html:
|
||||||
|
plain_message = strip_tags(body_message)
|
||||||
|
email_obj = EmailMultiAlternatives(subject=subject, body=plain_message, from_email=from_email, to=[recipient])
|
||||||
|
email_obj.attach_alternative(body_message, "text/html")
|
||||||
|
else:
|
||||||
|
email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
|
||||||
|
|
||||||
|
if attachments:
|
||||||
|
for attachment in attachments:
|
||||||
|
if isinstance(attachment, tuple) and len(attachment) == 3:
|
||||||
|
filename, content, content_type = attachment
|
||||||
|
email_obj.attach(filename, content, content_type)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Process attachments - convert file data back to file objects if needed
|
email_obj.send(fail_silently=False)
|
||||||
# processed_attachments = []
|
return True
|
||||||
# if attachments:
|
|
||||||
# for attachment in attachments:
|
|
||||||
# if isinstance(attachment, dict) and 'file_path' in attachment:
|
|
||||||
# # This is a serialized file from background task
|
|
||||||
# file_path = attachment['file_path']
|
|
||||||
# filename = attachment.get('filename', os.path.basename(file_path))
|
|
||||||
# content_type = attachment.get('content_type', 'application/octet-stream')
|
|
||||||
|
|
||||||
# try:
|
|
||||||
# with open(file_path, 'rb') as f:
|
|
||||||
# content = f.read()
|
|
||||||
# processed_attachments.append((filename, content, content_type))
|
|
||||||
|
|
||||||
# # Clean up temporary file
|
|
||||||
# try:
|
|
||||||
# os.unlink(file_path)
|
|
||||||
# except OSError:
|
|
||||||
# pass # File might already be deleted
|
|
||||||
|
|
||||||
# except FileNotFoundError:
|
|
||||||
# logger.warning(f"Attachment file not found: {file_path}")
|
|
||||||
# continue
|
|
||||||
# else:
|
|
||||||
# # Direct attachment (file object or tuple)
|
|
||||||
# processed_attachments.append(attachment)
|
|
||||||
|
|
||||||
# Call the existing send_bulk_email function synchronously within the task
|
|
||||||
result = send_bulk_email(
|
|
||||||
subject=subject,
|
|
||||||
message=message,
|
|
||||||
recipient_list=recipient_list,
|
|
||||||
request=request,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result['success']:
|
|
||||||
logger.info(f"Bulk email task completed successfully for {result.get('recipients_count', len(recipient_list))} recipients")
|
|
||||||
else:
|
|
||||||
logger.error(f"Bulk email task failed: {result.get('error', 'Unknown error')}")
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Critical error in bulk email task: {str(e)}"
|
logger.error(f"Task failed to send email to {recipient}: {str(e)}", exc_info=True)
|
||||||
logger.error(error_msg, exc_info=True)
|
return False
|
||||||
return {'success': False, 'error': error_msg}
|
|
||||||
|
|
||||||
|
def send_bulk_email_task(subject, message, recipient_list, attachments=None, hook='recruitment.tasks.email_success_hook'):
|
||||||
|
"""
|
||||||
|
Django-Q background task to send pre-formatted email to a list of recipients.
|
||||||
|
Receives arguments directly from the async_task call.
|
||||||
|
"""
|
||||||
|
logger.info(f"Starting bulk email task for {len(recipient_list)} recipients")
|
||||||
|
successful_sends = 0
|
||||||
|
total_recipients = len(recipient_list)
|
||||||
|
|
||||||
|
if not recipient_list:
|
||||||
|
return {'success': False, 'error': 'No recipients provided to task.'}
|
||||||
|
|
||||||
|
# Since the async caller sends one task per recipient, total_recipients should be 1.
|
||||||
|
for recipient in recipient_list:
|
||||||
|
# The 'message' is the custom message specific to this recipient.
|
||||||
|
if _task_send_individual_email(subject, message, recipient, attachments):
|
||||||
|
successful_sends += 1
|
||||||
|
|
||||||
|
if successful_sends > 0:
|
||||||
|
logger.info(f"Bulk email task completed successfully. Sent to {successful_sends}/{total_recipients} recipients.")
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'recipients_count': successful_sends,
|
||||||
|
'message': f"Sent successfully to {successful_sends} recipient(s)."
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.error(f"Bulk email task failed: No emails were sent successfully.")
|
||||||
|
return {'success': False, 'error': "No emails were sent successfully in the background task."}
|
||||||
|
|
||||||
|
|
||||||
|
def email_success_hook(task):
|
||||||
|
"""
|
||||||
|
The success hook must accept the Task object as the first and only required positional argument.
|
||||||
|
"""
|
||||||
|
if task.success:
|
||||||
|
logger.info(f"Task ID {task.id} succeeded. Result: {task.result}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Task ID {task.id} failed. Error: {task.result}")
|
||||||
|
|
||||||
@ -232,4 +232,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Email composition URLs
|
# Email composition URLs
|
||||||
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/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -41,9 +41,9 @@ from .forms import (
|
|||||||
AgencyAccessLinkForm,
|
AgencyAccessLinkForm,
|
||||||
AgencyJobAssignmentForm,
|
AgencyJobAssignmentForm,
|
||||||
LinkedPostContentForm,
|
LinkedPostContentForm,
|
||||||
ParticipantsSelectForm,
|
|
||||||
CandidateEmailForm,
|
CandidateEmailForm,
|
||||||
SourceForm
|
SourceForm,
|
||||||
|
InterviewEmailForm
|
||||||
)
|
)
|
||||||
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
|
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
@ -198,6 +198,30 @@ class ZoomMeetingDetailsView(LoginRequiredMixin, DetailView):
|
|||||||
model = ZoomMeeting
|
model = ZoomMeeting
|
||||||
template_name = "meetings/meeting_details.html"
|
template_name = "meetings/meeting_details.html"
|
||||||
context_object_name = "meeting"
|
context_object_name = "meeting"
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context=super().get_context_data(**kwargs)
|
||||||
|
meeting = self.object
|
||||||
|
interview=meeting.interview
|
||||||
|
candidate = interview.candidate
|
||||||
|
job=meeting.get_job
|
||||||
|
|
||||||
|
# Assuming interview.participants and interview.system_users hold the people:
|
||||||
|
participants = list(interview.participants.all()) + list(interview.system_users.all())
|
||||||
|
external_participants=list(interview.participants.all())
|
||||||
|
system_participants= list(interview.system_users.all())
|
||||||
|
total_participants=len(participants)
|
||||||
|
form = InterviewParticpantsForm(instance=interview)
|
||||||
|
context['form']=form
|
||||||
|
context['email_form'] = InterviewEmailForm(
|
||||||
|
candidate=candidate,
|
||||||
|
external_participants=external_participants,
|
||||||
|
system_participants=system_participants,
|
||||||
|
meeting=meeting,
|
||||||
|
job=job
|
||||||
|
)
|
||||||
|
context['total_participants']=total_participants
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ZoomMeetingUpdateView(LoginRequiredMixin, UpdateView):
|
class ZoomMeetingUpdateView(LoginRequiredMixin, UpdateView):
|
||||||
@ -1457,40 +1481,12 @@ def candidate_update_status(request, slug):
|
|||||||
def candidate_interview_view(request,slug):
|
def candidate_interview_view(request,slug):
|
||||||
job = get_object_or_404(JobPosting,slug=slug)
|
job = get_object_or_404(JobPosting,slug=slug)
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
form = ParticipantsSelectForm(request.POST, instance=job)
|
|
||||||
print(form.errors)
|
|
||||||
|
|
||||||
if form.is_valid():
|
|
||||||
|
|
||||||
# Save the main instance (JobPosting)
|
|
||||||
job_instance = form.save(commit=False)
|
|
||||||
job_instance.save()
|
|
||||||
|
|
||||||
# MANUALLY set the M2M relationships based on submitted data
|
|
||||||
job_instance.participants.set(form.cleaned_data['participants'])
|
|
||||||
job_instance.users.set(form.cleaned_data['users'])
|
|
||||||
|
|
||||||
messages.success(request, "Interview participants updated successfully.")
|
|
||||||
return redirect("candidate_interview_view", slug=job.slug)
|
|
||||||
|
|
||||||
else:
|
|
||||||
initial_data = {
|
|
||||||
'participants': job.participants.all(),
|
|
||||||
'users': job.users.all(),
|
|
||||||
}
|
|
||||||
form = ParticipantsSelectForm(instance=job, initial=initial_data)
|
|
||||||
|
|
||||||
else:
|
|
||||||
form = ParticipantsSelectForm(instance=job)
|
|
||||||
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"job":job,
|
"job":job,
|
||||||
"candidates":job.interview_candidates,
|
"candidates":job.interview_candidates,
|
||||||
'current_stage':'Interview',
|
'current_stage':'Interview',
|
||||||
'form':form,
|
|
||||||
'participants_count': job.participants.count() + job.users.count(),
|
|
||||||
}
|
}
|
||||||
return render(request,"recruitment/candidate_interview_view.html",context)
|
return render(request,"recruitment/candidate_interview_view.html",context)
|
||||||
|
|
||||||
@ -3699,6 +3695,7 @@ def api_candidate_detail(request, candidate_id):
|
|||||||
return JsonResponse({'success': False, 'error': str(e)})
|
return JsonResponse({'success': False, 'error': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def compose_candidate_email(request, job_slug):
|
def compose_candidate_email(request, job_slug):
|
||||||
"""Compose email to participants about a candidate"""
|
"""Compose email to participants about a candidate"""
|
||||||
@ -3710,6 +3707,7 @@ def compose_candidate_email(request, job_slug):
|
|||||||
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
print("........................................................inside candidate conpose.............")
|
||||||
candidate_ids = request.POST.getlist('candidate_ids')
|
candidate_ids = request.POST.getlist('candidate_ids')
|
||||||
candidates=Candidate.objects.filter(id__in=candidate_ids)
|
candidates=Candidate.objects.filter(id__in=candidate_ids)
|
||||||
form = CandidateEmailForm(job, candidates, request.POST)
|
form = CandidateEmailForm(job, candidates, request.POST)
|
||||||
@ -3717,6 +3715,7 @@ def compose_candidate_email(request, job_slug):
|
|||||||
print("form is valid ...")
|
print("form is valid ...")
|
||||||
# Get email addresses
|
# Get email addresses
|
||||||
email_addresses = form.get_email_addresses()
|
email_addresses = form.get_email_addresses()
|
||||||
|
print(email_addresses)
|
||||||
|
|
||||||
|
|
||||||
if not email_addresses:
|
if not email_addresses:
|
||||||
@ -3731,32 +3730,6 @@ def compose_candidate_email(request, job_slug):
|
|||||||
return redirect('dashboard')
|
return redirect('dashboard')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Check if this is an interview invitation
|
|
||||||
subject = form.cleaned_data.get('subject', '').lower()
|
|
||||||
is_interview_invitation = 'interview' in subject or 'meeting' in subject
|
|
||||||
|
|
||||||
if is_interview_invitation:
|
|
||||||
# Use HTML template for interview invitations
|
|
||||||
# meeting_details = None
|
|
||||||
# if form.cleaned_data.get('include_meeting_details'):
|
|
||||||
# # Try to get meeting details from candidate
|
|
||||||
# meeting_details = {
|
|
||||||
# 'topic': f'Interview for {job.title}',
|
|
||||||
# 'date_time': getattr(candidate, 'interview_date', 'To be scheduled'),
|
|
||||||
# 'duration': '60 minutes',
|
|
||||||
# 'join_url': getattr(candidate, 'meeting_url', ''),
|
|
||||||
# }
|
|
||||||
|
|
||||||
from .email_service import send_interview_invitation_email
|
|
||||||
email_result = send_interview_invitation_email(
|
|
||||||
candidates=candidates,
|
|
||||||
job=job,
|
|
||||||
# meeting_details=meeting_details,
|
|
||||||
recipient_list=email_addresses
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Get formatted message for regular emails
|
|
||||||
message = form.get_formatted_message()
|
message = form.get_formatted_message()
|
||||||
subject = form.cleaned_data.get('subject')
|
subject = form.cleaned_data.get('subject')
|
||||||
|
|
||||||
@ -3767,29 +3740,20 @@ def compose_candidate_email(request, job_slug):
|
|||||||
message=message,
|
message=message,
|
||||||
recipient_list=email_addresses,
|
recipient_list=email_addresses,
|
||||||
request=request,
|
request=request,
|
||||||
async_task_=True # Changed to False to avoid pickle issues
|
attachments=None,
|
||||||
|
async_task_=True, # Changed to False to avoid pickle issues
|
||||||
|
from_interview=False
|
||||||
)
|
)
|
||||||
|
|
||||||
if email_result['success']:
|
if email_result['success']:
|
||||||
messages.success(request, f'Email sent successfully to {len(email_addresses)} recipient(s).')
|
messages.success(request, f'Email sent successfully to {len(email_addresses)} recipient(s).')
|
||||||
|
|
||||||
# # For HTMX requests, return success response
|
|
||||||
# if 'HX-Request' in request.headers:
|
|
||||||
# return JsonResponse({
|
|
||||||
# 'success': True,
|
|
||||||
# 'message': f'Email sent successfully to {len(email_addresses)} recipient(s).'
|
|
||||||
# })
|
|
||||||
|
|
||||||
return redirect('candidate_interview_view', slug=job.slug)
|
return redirect('candidate_interview_view', slug=job.slug)
|
||||||
else:
|
else:
|
||||||
messages.error(request, f'Failed to send email: {email_result.get("message", "Unknown error")}')
|
messages.error(request, f'Failed to send email: {email_result.get("message", "Unknown error")}')
|
||||||
|
|
||||||
# For HTMX requests, return error response
|
|
||||||
# if 'HX-Request' in request.headers:
|
|
||||||
# return JsonResponse({
|
|
||||||
# 'success': False,
|
|
||||||
# 'error': email_result.get("message", "Failed to send email")
|
|
||||||
# })
|
|
||||||
|
|
||||||
return render(request, 'includes/email_compose_form.html', {
|
return render(request, 'includes/email_compose_form.html', {
|
||||||
'form': form,
|
'form': form,
|
||||||
@ -3798,21 +3762,7 @@ def compose_candidate_email(request, job_slug):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# except Exception as e:
|
# except Exception as e:
|
||||||
# logger.error(f"Error sending candidate email: {e}")
|
|
||||||
# messages.error(request, f'An error occurred while sending the email: {str(e)}')
|
|
||||||
|
|
||||||
# # For HTMX requests, return error response
|
|
||||||
# if 'HX-Request' in request.headers:
|
|
||||||
# return JsonResponse({
|
|
||||||
# 'success': False,
|
|
||||||
# 'error': f'An error occurred while sending the email: {str(e)}'
|
|
||||||
# })
|
|
||||||
|
|
||||||
# return render(request, 'includes/email_compose_form.html', {
|
|
||||||
# 'form': form,
|
|
||||||
# 'job': job,
|
|
||||||
# 'candidate': candidate
|
|
||||||
# })
|
|
||||||
else:
|
else:
|
||||||
# Form validation errors
|
# Form validation errors
|
||||||
print('form is not valid')
|
print('form is not valid')
|
||||||
@ -3836,14 +3786,6 @@ def compose_candidate_email(request, job_slug):
|
|||||||
|
|
||||||
# GET request - show the form
|
# GET request - show the form
|
||||||
form = CandidateEmailForm(job, candidates)
|
form = CandidateEmailForm(job, candidates)
|
||||||
# try:
|
|
||||||
# l = [x.split("_")[1] for x in candidates]
|
|
||||||
# print(l)
|
|
||||||
# candidates_qs = Candidate.objects.filter(pk__in=l)
|
|
||||||
# print(candidates_qs)
|
|
||||||
# form.initial["to"]. = candidates_qs
|
|
||||||
# except:
|
|
||||||
# pass
|
|
||||||
|
|
||||||
print("GET request made for candidate email form")
|
print("GET request made for candidate email form")
|
||||||
|
|
||||||
@ -4021,3 +3963,103 @@ def source_toggle_status(request, slug):
|
|||||||
|
|
||||||
# For GET requests, return error
|
# For GET requests, return error
|
||||||
return JsonResponse({'success': False, 'error': 'Method not allowed'})
|
return JsonResponse({'success': False, 'error': 'Method not allowed'})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from .forms import InterviewParticpantsForm
|
||||||
|
|
||||||
|
def create_interview_participants(request,slug):
|
||||||
|
schedule_interview=get_object_or_404(ScheduledInterview,slug=slug)
|
||||||
|
interview_slug=schedule_interview.zoom_meeting.slug
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = InterviewParticpantsForm(request.POST,instance=schedule_interview)
|
||||||
|
if form.is_valid():
|
||||||
|
# Save the main Candidate object, but don't commit to DB yet
|
||||||
|
candidate = form.save(commit=False)
|
||||||
|
candidate.save()
|
||||||
|
# This is important for ManyToMany fields: save the many-to-many data
|
||||||
|
form.save_m2m()
|
||||||
|
return redirect('meeting_details',slug=interview_slug) # Redirect to a success page
|
||||||
|
else:
|
||||||
|
form = InterviewParticpantsForm(instance=schedule_interview)
|
||||||
|
|
||||||
|
return render(request, 'interviews/interview_participants_form.html', {'form': form})
|
||||||
|
|
||||||
|
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
def send_interview_email(request, slug):
|
||||||
|
from .email_service import send_bulk_email
|
||||||
|
|
||||||
|
interview = get_object_or_404(ScheduledInterview, slug=slug)
|
||||||
|
|
||||||
|
# 2. Retrieve the required data for the form's constructor
|
||||||
|
candidate = interview.candidate
|
||||||
|
job=interview.job
|
||||||
|
meeting=interview.zoom_meeting
|
||||||
|
participants = list(interview.participants.all()) + list(interview.system_users.all())
|
||||||
|
external_participants=list(interview.participants.all())
|
||||||
|
system_participants=list(interview.system_users.all())
|
||||||
|
|
||||||
|
participant_emails = [p.email for p in participants if hasattr(p, 'email')]
|
||||||
|
print(participant_emails)
|
||||||
|
total_recipients=1+len(participant_emails)
|
||||||
|
|
||||||
|
# --- POST REQUEST HANDLING ---
|
||||||
|
if request.method == 'POST':
|
||||||
|
|
||||||
|
form = InterviewEmailForm(
|
||||||
|
request.POST,
|
||||||
|
candidate=candidate,
|
||||||
|
external_participants=external_participants,
|
||||||
|
system_participants=system_participants,
|
||||||
|
meeting=meeting,
|
||||||
|
job=job
|
||||||
|
)
|
||||||
|
|
||||||
|
if form.is_valid():
|
||||||
|
# 4. Extract cleaned data
|
||||||
|
subject = form.cleaned_data['subject']
|
||||||
|
msg_candidate = form.cleaned_data['message_for_candidate']
|
||||||
|
msg_agency = form.cleaned_data['message_for_agency']
|
||||||
|
msg_participants = form.cleaned_data['message_for_participants']
|
||||||
|
|
||||||
|
# --- SEND EMAILS Candidate or agency---
|
||||||
|
if candidate.belong_to_an_agency:
|
||||||
|
send_mail(
|
||||||
|
subject,
|
||||||
|
msg_agency,
|
||||||
|
settings.DEFAULT_FROM_EMAIL,
|
||||||
|
[candidate.hiring_agency.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
send_mail(
|
||||||
|
subject,
|
||||||
|
msg_candidate,
|
||||||
|
settings.DEFAULT_FROM_EMAIL,
|
||||||
|
[candidate.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
email_result = send_bulk_email(
|
||||||
|
subject=subject,
|
||||||
|
message=msg_participants,
|
||||||
|
recipient_list=participant_emails,
|
||||||
|
request=request,
|
||||||
|
attachments=None,
|
||||||
|
async_task_=True, # Changed to False to avoid pickle issues,
|
||||||
|
from_interview=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if email_result['success']:
|
||||||
|
messages.success(request, f'Email sent successfully to {total_recipients} recipient(s).')
|
||||||
|
|
||||||
|
return redirect('list_meetings')
|
||||||
|
else:
|
||||||
|
messages.error(request, f'Failed to send email: {email_result.get("message", "Unknown error")}')
|
||||||
|
return redirect('list_meetings')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{{ form.media }}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
@ -41,25 +41,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label fw-bold">
|
|
||||||
{% trans "Participants" %}
|
|
||||||
</label>
|
|
||||||
<div class="border rounded p-3 bg-light" style="max-height: 200px; overflow-y: auto;">
|
|
||||||
{% for choice in form.recipients %}
|
|
||||||
<div class="form-check mb-2">
|
|
||||||
{{ choice }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% if form.recipients.errors %}
|
|
||||||
<div class="text-danger small mt-1">
|
|
||||||
{% for error in form.recipients.errors %}
|
|
||||||
<span>{{ error }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<!-- Subject Field -->
|
<!-- Subject Field -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="{{ form.subject.id_for_label }}" class="form-label fw-bold">
|
<label for="{{ form.subject.id_for_label }}" class="form-label fw-bold">
|
||||||
@ -91,37 +73,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% comment %}
|
|
||||||
<!-- Options Checkboxes -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox"
|
|
||||||
class="form-check-input"
|
|
||||||
name="{{ form.include_candidate_info.name }}"
|
|
||||||
id="{{ form.include_candidate_info.id_for_label }}"
|
|
||||||
{% if form.include_candidate_info.value %}checked{% endif %}>
|
|
||||||
<label class="form-check-label" for="{{ form.include_candidate_info.id_for_label }}">
|
|
||||||
{{ form.include_candidate_info.label }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox"
|
|
||||||
class="form-check-input"
|
|
||||||
name="{{ form.include_meeting_details.name }}"
|
|
||||||
id="{{ form.include_meeting_details.id_for_label }}"
|
|
||||||
{% if form.include_meeting_details.value %}checked{% endif %}>
|
|
||||||
<label class="form-check-label" for="{{ form.include_meeting_details.id_for_label }}">
|
|
||||||
{{ form.include_meeting_details.label }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div> {% endcomment %}
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
|||||||
5
templates/interviews/interview_participants_form.html
Normal file
5
templates/interviews/interview_participants_form.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
<button type="submit">Save Participants</button>
|
||||||
|
</form>
|
||||||
@ -1,82 +1,167 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{%load i18n %}
|
||||||
|
|
||||||
|
{% block customCSS %}
|
||||||
|
<style>
|
||||||
|
/* Custom Teal Theme Variables (Adapt these if defined globally) */
|
||||||
|
:root {
|
||||||
|
--kaauh-teal: #00636e;
|
||||||
|
--kaauh-teal-light: #e0f2f4; /* Very light background accent */
|
||||||
|
--kaauh-teal-dark: #004a53;
|
||||||
|
--kaauh-border-color: #e3e8ed;
|
||||||
|
--kaauh-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary Theme Utilities */
|
||||||
|
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
||||||
|
.bg-primary-theme-light { background-color: var(--kaauh-teal-light) !important; }
|
||||||
|
.border-primary-theme { border-color: var(--kaauh-teal) !important; }
|
||||||
|
|
||||||
|
/* Custom Button Style */
|
||||||
|
.btn-teal-primary {
|
||||||
|
background-color: var(--kaauh-teal);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.btn-teal-primary:hover {
|
||||||
|
background-color: var(--kaauh-teal-dark);
|
||||||
|
border-color: var(--kaauh-teal-dark);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout and Typography */
|
||||||
|
.page-header {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
border-left: 5px solid var(--kaauh-teal);
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Styling */
|
||||||
|
.schedule-card {
|
||||||
|
border: 1px solid var(--kaauh-border-color);
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: var(--kaauh-shadow);
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
padding: 0; /* Control padding inside body */
|
||||||
|
}
|
||||||
|
.schedule-card:hover {
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.card-title-border {
|
||||||
|
font-weight: 600;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 2px solid var(--kaauh-teal);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Break Times Display */
|
||||||
|
.break-time-container {
|
||||||
|
border-left: 3px solid var(--kaauh-teal);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background-color: var(--kaauh-teal-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FullCalendar Customization */
|
||||||
|
#calendar {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.fc-event-main-frame {
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
/* Event bar color (Candidates) */
|
||||||
|
.fc-event-title-container {
|
||||||
|
background-color: var(--kaauh-teal-light);
|
||||||
|
border-left: 3px solid var(--kaauh-teal);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
/* Break background color is set in JS events */
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mt-4">
|
<div class="container py-5">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-5">
|
||||||
<h1 class="h3 page-header">
|
<h1 class="h3 page-header">
|
||||||
<i class="fas fa-calendar-check me-2"></i> Interview Schedule Preview for {{ job.title }}
|
<i class="fas fa-calendar-alt me-2 text-primary-theme"></i> Interview Schedule Preview: **{{ job.title }}**
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mt-4 shadow-sm">
|
<div class="card schedule-card mb-5">
|
||||||
<div class="card-body">
|
<div class="card-body p-4 p-lg-5">
|
||||||
<h5 class="card-title pb-2 border-bottom">Schedule Details</h5>
|
<h4 class="card-title-border">{% trans "Schedule Parameters" %}</h4>
|
||||||
<div class="row">
|
<div class="row g-4">
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<p><strong>Period:</strong> {{ start_date|date:"F j, Y" }} to {{ end_date|date:"F j, Y" }}</p>
|
<p class="mb-2"><strong><i class="fas fa-clock me-2 text-primary-theme"></i> Working Hours:</strong> {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}</p>
|
||||||
<p>
|
<p class="mb-2"><strong><i class="fas fa-hourglass-half me-2 text-primary-theme"></i> Interview Duration:</strong> {{ interview_duration }} minutes</p>
|
||||||
<strong>Working Days:</strong>
|
<p class="mb-2"><strong><i class="fas fa-shield-alt me-2 text-primary-theme"></i> Buffer Time:</strong> {{ buffer_time }} minutes</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Period:</strong> {{ start_date|date:"F j, Y" }} — {{ end_date|date:"F j, Y" }}</p>
|
||||||
|
<p class="mb-2"><strong><i class="fas fa-list-check me-2 text-primary-theme"></i> Active Days:</strong>
|
||||||
{% for day_id in working_days %}
|
{% for day_id in working_days %}
|
||||||
{% if day_id == 0 %}Monday{% endif %}
|
{% if day_id == 0 %}Mon{% endif %}
|
||||||
{% if day_id == 1 %}Tuesday{% endif %}
|
{% if day_id == 1 %}Tue{% endif %}
|
||||||
{% if day_id == 2 %}Wednesday{% endif %}
|
{% if day_id == 2 %}Wed{% endif %}
|
||||||
{% if day_id == 3 %}Thursday{% endif %}
|
{% if day_id == 3 %}Thu{% endif %}
|
||||||
{% if day_id == 4 %}Friday{% endif %}
|
{% if day_id == 4 %}Fri{% endif %}
|
||||||
{% if day_id == 5 %}Saturday{% endif %}
|
{% if day_id == 5 %}Sat{% endif %}
|
||||||
{% if day_id == 6 %}Sunday{% endif %}
|
{% if day_id == 6 %}Sun{% endif %}
|
||||||
{% if not forloop.last %}, {% endif %}
|
{% if not forloop.last %}, {% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</p>
|
</p>
|
||||||
<p><strong>Working Hours:</strong> {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}</p>
|
|
||||||
<p><strong>Interview Duration:</strong> {{ interview_duration }} minutes</p>
|
|
||||||
<p><strong>Buffer Time:</strong> {{ buffer_time }} minutes</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
</div>
|
||||||
<p class="mb-2"><strong>Daily Break Times:</strong></p>
|
|
||||||
|
<h5 class="mt-4 pt-3 border-top">{% trans "Daily Break Times" %}</h5>
|
||||||
{% if breaks %}
|
{% if breaks %}
|
||||||
<!-- New structured display for breaks -->
|
<div class="d-flex flex-wrap gap-3 mt-3">
|
||||||
<div class="d-flex flex-column gap-1 mb-3 p-3 border rounded bg-light">
|
|
||||||
{% for break in breaks %}
|
{% for break in breaks %}
|
||||||
<small class="text-dark">
|
<span class="badge rounded-pill bg-primary-theme-light text-primary-theme p-2 px-3 fw-normal shadow-sm">
|
||||||
<i class="far fa-clock me-1 text-muted"></i>
|
<i class="far fa-mug-hot me-1"></i>
|
||||||
{{ break.start_time|time:"g:i A" }} — {{ break.end_time|time:"g:i A" }}
|
{{ break.start_time|time:"g:i A" }} — {{ break.end_time|time:"g:i A" }}
|
||||||
</small>
|
</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="mb-3"><small class="text-muted">No daily breaks scheduled.</small></p>
|
<p class="mt-3"><small class="text-muted"><i class="fas fa-exclamation-circle me-1"></i> No daily breaks scheduled.</small></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card mt-4 shadow-sm">
|
<div class="card schedule-card">
|
||||||
<div class="card-body">
|
<div class="card-body p-4 p-lg-5">
|
||||||
<h5 class="card-title pb-2 border-bottom">Scheduled Interviews</h5>
|
<h4 class="card-title-border">{% trans "Scheduled Interviews Overview" %}</h4>
|
||||||
|
|
||||||
<!-- Calendar View -->
|
<div id="calendar-container" class="mb-5 p-3 border rounded bg-light">
|
||||||
<div id="calendar-container">
|
|
||||||
<div id="calendar"></div>
|
<div id="calendar"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- List View -->
|
<h5 class="pb-2 border-bottom mb-3 text-primary-theme">{% trans "Detailed List" %}</h5>
|
||||||
<div class="table-responsive mt-4">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped">
|
<table class="table table-hover table-striped">
|
||||||
<thead>
|
<thead class="bg-primary-theme-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th scope="col">Date</th>
|
||||||
<th>Time</th>
|
<th scope="col">Time</th>
|
||||||
<th>Candidate</th>
|
<th scope="col">Candidate</th>
|
||||||
<th>Email</th>
|
<th scope="col">Email</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in schedule %}
|
{% for item in schedule %}
|
||||||
<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 class="fw-bold text-primary-theme">{{ item.time|time:"g:i A" }}</td>
|
||||||
<td>{{ item.candidate.name }}</td>
|
<td>{{ item.candidate.name }}</td>
|
||||||
<td>{{ item.candidate.email }}</td>
|
<td>{{ item.candidate.email }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -85,20 +170,19 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4">
|
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" name="confirm_schedule" class="btn btn-success">
|
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary px-4">
|
||||||
<i class="fas fa-check"></i> Confirm Schedule
|
<i class="fas fa-arrow-left me-2"></i> {% trans "Back to Edit" %}
|
||||||
</button>
|
|
||||||
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary">
|
|
||||||
<i class="fas fa-arrow-left"></i> Back to Edit
|
|
||||||
</a>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Include FullCalendar CSS and JS -->
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.css" rel="stylesheet">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.js"></script>
|
||||||
|
|
||||||
@ -118,6 +202,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
title: '{{ item.candidate.name }}',
|
title: '{{ item.candidate.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
|
||||||
|
color: 'var(--kaauh-teal-dark)',
|
||||||
extendedProps: {
|
extendedProps: {
|
||||||
email: '{{ item.candidate.email }}',
|
email: '{{ item.candidate.email }}',
|
||||||
time: '{{ item.time|time:"g:i A" }}'
|
time: '{{ item.time|time:"g:i A" }}'
|
||||||
@ -127,24 +213,33 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
{% for break in breaks %}
|
{% for break in breaks %}
|
||||||
{
|
{
|
||||||
title: 'Break',
|
title: 'Break',
|
||||||
|
// FullCalendar requires a specific date for breaks, using start_date as a placeholder for daily breaks.
|
||||||
|
// Note: Breaks displayed on the monthly grid will only show on start_date, but weekly/daily view should reflect it daily if implemented correctly in the backend or using recurring events.
|
||||||
start: '{{ start_date|date:"Y-m-d" }}T{{ break.start_time|time:"H:i:s" }}',
|
start: '{{ start_date|date:"Y-m-d" }}T{{ break.start_time|time:"H:i:s" }}',
|
||||||
end: '{{ start_date|date:"Y-m-d" }}T{{ break.end_time|time:"H:i:s" }}',
|
end: '{{ start_date|date:"Y-m-d" }}T{{ break.end_time|time:"H:i:s" }}',
|
||||||
color: '#ff9f89',
|
color: '#ff9f89', // A nice soft orange/salmon color for breaks
|
||||||
display: 'background'
|
display: 'background'
|
||||||
},
|
},
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
],
|
],
|
||||||
eventClick: function(info) {
|
eventClick: function(info) {
|
||||||
// Show candidate details in a modal or alert
|
// Log details to console instead of using alert()
|
||||||
if (info.event.title !== 'Break') {
|
if (info.event.title !== 'Break') {
|
||||||
// IMPORTANT: Since alert() is forbidden, using console log as a fallback.
|
console.log('--- Candidate Interview Details ---');
|
||||||
// In a production environment, this would be a custom modal dialog.
|
console.log('Candidate: ' + info.event.title);
|
||||||
console.log('Candidate: ' + info.event.title +
|
console.log('Date: ' + info.event.start.toLocaleDateString());
|
||||||
'\nDate: ' + info.event.start.toLocaleDateString() +
|
console.log('Time: ' + info.event.extendedProps.time);
|
||||||
'\nTime: ' + info.event.extendedProps.time +
|
console.log('Email: ' + info.event.extendedProps.email);
|
||||||
'\nEmail: ' + info.event.extendedProps.email);
|
console.log('-----------------------------------');
|
||||||
|
// You would typically open a Bootstrap modal here instead of using console.log
|
||||||
}
|
}
|
||||||
info.jsEvent.preventDefault();
|
info.jsEvent.preventDefault();
|
||||||
|
},
|
||||||
|
eventDidMount: function(info) {
|
||||||
|
// Darken the text for background events (breaks) for better contrast
|
||||||
|
if (info.event.display === 'background') {
|
||||||
|
info.el.style.backgroundColor = '#ff9f89';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
calendar.render();
|
calendar.render();
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load static i18n %}
|
{% load static i18n %}
|
||||||
|
{% load widget_tweaks %}
|
||||||
{% block customCSS %}
|
{% block customCSS %}
|
||||||
<style>
|
<style>
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
@ -248,10 +249,14 @@ body {
|
|||||||
<div class="p-3 bg-white rounded shadow-sm h-100 d-flex flex-column">
|
<div class="p-3 bg-white rounded shadow-sm h-100 d-flex flex-column">
|
||||||
<h2 class="text-start"><i class="fas fa-briefcase me-2"></i> {% trans "Interview Detail" %}</h2>
|
<h2 class="text-start"><i class="fas fa-briefcase me-2"></i> {% trans "Interview Detail" %}</h2>
|
||||||
<div class="detail-row-group flex-grow-1">
|
<div class="detail-row-group flex-grow-1">
|
||||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Title" %}:</div><div class="detail-value-simple">{{ meeting.get_job.title|default:"N/A" }}</div></div>
|
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Title" %}:</div><div class="detail-value-simple"><a class="text-decoration-none text-dark" href="{% url 'job_detail' meeting.get_job.slug %}">{{ meeting.get_job.title|default:"N/A" }}</a></div></div>
|
||||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Name" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.name|default:"N/A" }}</a></div></div>
|
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Name" %}:</div><div class="detail-value-simple"><a class="text-decoration-none text-dark" href="{% url 'candidate_detail' meeting.get_candidate.slug %}">{{ meeting.get_candidate.name|default:"N/A" }}</a></div></div>
|
||||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Email" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.email|default:"N/A" }}</a></div></div>
|
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Email" %}:</div><div class="detail-value-simple"><a class="text-decoration-none text-dark" href="{% url 'candidate_detail' meeting.get_candidate.slug %}">{{ meeting.get_candidate.email|default:"N/A" }}</a></div></div>
|
||||||
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Type" %}:</div><div class="detail-value-simple">{{ meeting.get_job.job_type|default:"N/A" }}</div></div>
|
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Type" %}:</div><div class="detail-value-simple">{{ meeting.get_job.job_type|default:"N/A" }}</div></div>
|
||||||
|
{% if meeting.get_candidate.belong_to_agency %}
|
||||||
|
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Agency" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.hiring_agency.name|default:"N/A" }}</a></div></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -295,7 +300,24 @@ body {
|
|||||||
{# --- PARTICIPANTS TABLE --- #}
|
{# --- PARTICIPANTS TABLE --- #}
|
||||||
<div class="col-lg-12">
|
<div class="col-lg-12">
|
||||||
<div class="p-3 bg-white rounded shadow-sm">
|
<div class="p-3 bg-white rounded shadow-sm">
|
||||||
|
<div class="d-flex justify-content-between align-item-center" >
|
||||||
<h2 class="text-start"><i class="fas fa-users-cog me-2"></i> {% trans "Assigned Participants" %}</h2>
|
<h2 class="text-start"><i class="fas fa-users-cog me-2"></i> {% trans "Assigned Participants" %}</h2>
|
||||||
|
<!--manage participants for interview-->
|
||||||
|
<div class="d-flex justify-content-center align-item-center">
|
||||||
|
<button type="button" class="btn btn-primary-teal btn-sm me-2"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#assignParticipants">
|
||||||
|
<i class="fas fa-users-cog me-1"></i> {% trans "Manage Participants" %} ({{total_participants}})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-outline-info"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
title="Send Interview Emails"
|
||||||
|
data-bs-target="#emailModal">
|
||||||
|
<i class="fas fa-envelope"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table class="simple-table">
|
<table class="simple-table">
|
||||||
<thead>
|
<thead>
|
||||||
@ -436,6 +458,139 @@ body {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="modal fade" id="assignParticipants" tabindex="-1" aria-labelledby="assignParticipantsLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="jobAssignmentLabel">{% trans "Manage all participants" %}</h5>
|
||||||
|
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'create_interview_participants' meeting.interview.slug %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="modal-body table-responsive">
|
||||||
|
|
||||||
|
{{ meeting.name }}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<table class="table tab table-bordered mt-3">
|
||||||
|
<thead>
|
||||||
|
<th class="col">👥 {% trans "Participants" %}</th>
|
||||||
|
<th class="col">🧑💼 {% trans "Users" %}</th>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ form.participants.errors }}
|
||||||
|
{{ form.participants }}
|
||||||
|
</td>
|
||||||
|
<td> {{ form.system_users.errors }}
|
||||||
|
{{ form.system_users }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-danger-red" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
||||||
|
<button type="submit" class="btn btn-primary-teal btn-sm">{% trans "Save" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--email modal class-->
|
||||||
|
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-light">
|
||||||
|
<h5 class="modal-title" id="emailModalLabel">📧 {% trans "Compose Interview Invitation" %}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'send_interview_email' meeting.interview.slug %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ email_form.subject.id_for_label }}" class="form-label fw-bold">Subject</label>
|
||||||
|
{{ email_form.subject | add_class:"form-control" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs" id="messageTabs" role="tablist">
|
||||||
|
{# Candidate/Agency Tab - Active by default #}
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="candidate-tab" data-bs-toggle="tab" data-bs-target="#candidate-pane" type="button" role="tab" aria-controls="candidate-pane" aria-selected="true">
|
||||||
|
{% if candidate.belong_to_an_agency %}
|
||||||
|
Agency Message
|
||||||
|
{% else %}
|
||||||
|
Candidate Message
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{# Participants Tab #}
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="participants-tab" data-bs-toggle="tab" data-bs-target="#participants-pane" type="button" role="tab" aria-controls="participants-pane" aria-selected="false">
|
||||||
|
Panel Message (Interviewers)
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content border border-top-0 p-3 bg-light-subtle">
|
||||||
|
|
||||||
|
{# --- Candidate/Agency Pane --- #}
|
||||||
|
<div class="tab-pane fade show active" id="candidate-pane" role="tabpanel" aria-labelledby="candidate-tab">
|
||||||
|
<p class="text-muted small">This email will be sent to the candidate or their hiring agency.</p>
|
||||||
|
|
||||||
|
{% if not candidate.belong_to_an_agency %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ email_form.message_for_candidate.id_for_label }}" class="form-label d-none">Candidate Message</label>
|
||||||
|
{{ email_form.message_for_candidate | add_class:"form-control" }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if candidate.belong_to_an_agency %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ email_form.message_for_agency.id_for_label }}" class="form-label d-none">Agency Message</label>
|
||||||
|
{{ email_form.message_for_agency | add_class:"form-control" }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# --- Participants Pane --- #}
|
||||||
|
<div class="tab-pane fade" id="participants-pane" role="tabpanel" aria-labelledby="participants-tab">
|
||||||
|
<p class="text-muted small">This email will be sent to the internal and external interview participants.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ email_form.message_for_participants.id_for_label }}" class="form-label d-none">Participants Message</label>
|
||||||
|
{{ email_form.message_for_participants | add_class:"form-control" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-danger-red" data-bs-dismiss="modal">Close</button>
|
||||||
|
<button type="submit" class="btn btn-primary-teal">Send Invitation</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block customJS %}
|
{% block customJS %}
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -233,12 +233,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="vr" style="height: 28px;"></div>
|
<div class="vr" style="height: 28px;"></div>
|
||||||
<!--manage participants for interview-->
|
|
||||||
<button type="button" class="btn btn-main-action btn-sm"
|
|
||||||
data-bs-toggle="modal"
|
|
||||||
data-bs-target="#jobAssignmentModal">
|
|
||||||
<i class="fas fa-users-cog me-1"></i> {% trans "Manage Participants" %} ({{participants_count}})
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<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"
|
||||||
@ -453,59 +448,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="jobAssignmentModal" tabindex="-1" aria-labelledby="jobAssignmentLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-lg" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="jobAssignmentLabel">{% trans "Manage all participants" %}</h5>
|
|
||||||
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" action="{% url 'candidate_interview_view' job.slug %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
|
|
||||||
<div class="modal-body table-responsive">
|
|
||||||
|
|
||||||
{{ job.internal_job_id }} {{ job.title}}
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<table class="table tab table-bordered mt-3">
|
|
||||||
<thead>
|
|
||||||
<th class="col">👥 {% trans "Participants" %}</th>
|
|
||||||
<th class="col">🧑💼 {% trans "Users" %}</th>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{{ form.participants.errors }}
|
|
||||||
{{ form.participants }}
|
|
||||||
</td>
|
|
||||||
<td> {{ form.users.errors }}
|
|
||||||
{{ form.users }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
|
|
||||||
</table>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
|
||||||
<button type="submit" class="btn btn-main-action">{% trans "Save" %}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Email Modal -->
|
<!-- Email Modal -->
|
||||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||||
|
|||||||
@ -497,7 +497,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
!-- Email Modal -->
|
<!-- Email Modal -->
|
||||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg" role="document">
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
<div class="modal-content kaauh-card">
|
<div class="modal-content kaauh-card">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user