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}
|
||||
|
||||
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,
|
||||
supporting synchronous or asynchronous dispatch.
|
||||
"""
|
||||
|
||||
|
||||
# Define messages (Placeholders)
|
||||
|
||||
|
||||
all_candidate_emails = []
|
||||
candidate_through_agency_emails=[]
|
||||
participant_emails = []
|
||||
agency_emails = []
|
||||
left_candidate_emails = []
|
||||
|
||||
if not recipient_list:
|
||||
return {'success': False, 'error': 'No recipients provided'}
|
||||
|
||||
|
||||
for email in recipient_list:
|
||||
email = email.strip().lower() # Clean input email
|
||||
if email:
|
||||
|
||||
candidate = Candidate.objects.filter(email=email).first()
|
||||
if candidate:
|
||||
all_candidate_emails.append(email)
|
||||
else:
|
||||
participant_emails.append(email)
|
||||
|
||||
|
||||
for email in all_candidate_emails:
|
||||
|
||||
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))
|
||||
# --- 1. Categorization and Custom Message Preparation (CORRECTED) ---
|
||||
if not from_interview:
|
||||
|
||||
agency_emails = []
|
||||
pure_candidate_emails = []
|
||||
candidate_through_agency_emails = []
|
||||
|
||||
if not recipient_list:
|
||||
return {'success': False, 'error': 'No recipients provided'}
|
||||
|
||||
total_recipients = len(unique_left_candidates) + len(unique_agencies) + len(unique_participants)
|
||||
if total_recipients == 0:
|
||||
return {'success': False, 'error': 'No valid email addresses found after categorization'}
|
||||
# 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:
|
||||
email = email.strip().lower()
|
||||
|
||||
try:
|
||||
candidate = get_object_or_404(Candidate, email=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:
|
||||
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
|
||||
|
||||
# Calculate total recipients based on the size of the final send list
|
||||
total_recipients = len(customized_sends)
|
||||
|
||||
|
||||
# --- 3. Handle ASYNC Dispatch ---
|
||||
if total_recipients == 0:
|
||||
return {'success': False, 'error': 'No valid recipients found for sending.'}
|
||||
else:
|
||||
# For interview flow
|
||||
total_recipients = len(recipient_list)
|
||||
|
||||
|
||||
# --- 2. Handle ASYNC Dispatch (FIXED: Single loop used) ---
|
||||
if async_task_:
|
||||
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 []
|
||||
|
||||
task_ids = []
|
||||
|
||||
if not from_interview:
|
||||
# Loop through ALL final customized sends
|
||||
for recipient_email, custom_message in customized_sends:
|
||||
task_id = async_task(
|
||||
'recruitment.tasks.send_bulk_email_task',
|
||||
subject,
|
||||
custom_message, # Pass the custom message
|
||||
[recipient_email], # Pass the specific recipient as a list of one
|
||||
processed_attachments,
|
||||
hook='recruitment.tasks.email_success_hook'
|
||||
)
|
||||
task_ids.append(task_id)
|
||||
|
||||
logger.info(f"{len(task_ids)} tasks ({total_recipients} emails) queued.")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'async': True,
|
||||
'task_ids': task_ids,
|
||||
'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)'
|
||||
}
|
||||
|
||||
# Queue Left Candidates
|
||||
if unique_left_candidates:
|
||||
task_id = async_task(
|
||||
'recruitment.tasks.send_bulk_email_task',
|
||||
subject,
|
||||
message,
|
||||
recipient_list,
|
||||
processed_attachments, # Pass serializable data
|
||||
hook='recruitment.tasks.email_success_hook' # Example hook
|
||||
)
|
||||
task_ids.append(task_id)
|
||||
|
||||
logger.info(f" email queued. ID: {task_id}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'async': True,
|
||||
'task_ids': task_ids,
|
||||
'message': f'Emails queued for background sending to {total_recipients} recipient(s)'
|
||||
}
|
||||
except ImportError:
|
||||
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:
|
||||
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)}"}
|
||||
|
||||
# --- 4. Handle SYNCHRONOUS Send (If async_task_=False or fallback) ---
|
||||
# --- 3. Handle SYNCHRONOUS Send (No changes needed here, as it was fixed previously) ---
|
||||
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')
|
||||
is_html = '<' in message and '>' in message
|
||||
successful_sends = 0
|
||||
|
||||
# Helper Function for Sync Send
|
||||
# Helper Function for Sync Send (as provided)
|
||||
def send_individual_email(recipient, body_message):
|
||||
# ... (Existing helper function logic) ...
|
||||
nonlocal successful_sends
|
||||
|
||||
if is_html:
|
||||
@ -336,7 +375,6 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
||||
else:
|
||||
email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
|
||||
|
||||
# Attachment Logic
|
||||
if attachments:
|
||||
for attachment in attachments:
|
||||
if hasattr(attachment, 'read'):
|
||||
@ -349,38 +387,43 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
||||
email_obj.attach(filename, content, content_type)
|
||||
|
||||
try:
|
||||
# FIX: Added the critical .send() call
|
||||
email_obj.send(fail_silently=False)
|
||||
successful_sends += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
|
||||
|
||||
|
||||
# Send Emails
|
||||
for email in unique_left_candidates:
|
||||
candidate_name=Candidate.objects.filter(email=email).first().first_name
|
||||
candidate_message = f"Hi, {candidate_name}"+"\n"+message
|
||||
|
||||
send_individual_email(email, candidate_message)
|
||||
|
||||
i=0
|
||||
for email in unique_agencies:
|
||||
candidate_name=Candidate.objects.filter(email=candidate_through_agency_emails[i]).first().first_name
|
||||
agency_message = f"Hi, {candidate_name}"+"\n"+message
|
||||
send_individual_email(email, agency_message)
|
||||
|
||||
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.")
|
||||
return {
|
||||
'success': True,
|
||||
'recipients_count': successful_sends,
|
||||
'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.'
|
||||
}
|
||||
if not from_interview:
|
||||
# Send Emails - Pure Candidates
|
||||
for email in pure_candidate_emails:
|
||||
candidate_name = Candidate.objects.filter(email=email).first().first_name
|
||||
candidate_message = f"Hi, {candidate_name}" + "\n" + message
|
||||
send_individual_email(email, candidate_message)
|
||||
|
||||
# Send Emails - Agencies
|
||||
i = 0
|
||||
for email in agency_emails:
|
||||
candidate_email = candidate_through_agency_emails[i]
|
||||
candidate_name = Candidate.objects.filter(email=candidate_email).first().first_name
|
||||
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
||||
send_individual_email(email, agency_message)
|
||||
i += 1
|
||||
|
||||
logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.")
|
||||
return {
|
||||
'success': True,
|
||||
'recipients_count': successful_sends,
|
||||
'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:
|
||||
error_msg = f"Failed to process bulk email send request: {str(e)}"
|
||||
|
||||
@ -1243,24 +1243,24 @@ class ParticipantsForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ParticipantsSelectForm(forms.ModelForm):
|
||||
"""Form for selecting Participants"""
|
||||
# class ParticipantsSelectForm(forms.ModelForm):
|
||||
# """Form for selecting Participants"""
|
||||
|
||||
participants=forms.ModelMultipleChoiceField(
|
||||
queryset=Participants.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
label=_("Select Participants"))
|
||||
# participants=forms.ModelMultipleChoiceField(
|
||||
# queryset=Participants.objects.all(),
|
||||
# widget=forms.CheckboxSelectMultiple,
|
||||
# required=False,
|
||||
# label=_("Select Participants"))
|
||||
|
||||
users=forms.ModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
label=_("Select Users"))
|
||||
# users=forms.ModelMultipleChoiceField(
|
||||
# queryset=User.objects.all(),
|
||||
# widget=forms.CheckboxSelectMultiple,
|
||||
# required=False,
|
||||
# label=_("Select Users"))
|
||||
|
||||
class Meta:
|
||||
model = JobPosting
|
||||
fields = ['participants','users'] # No direct fields from Participants model
|
||||
# class Meta:
|
||||
# model = JobPosting
|
||||
# fields = ['participants','users'] # No direct fields from Participants model
|
||||
|
||||
|
||||
class CandidateEmailForm(forms.Form):
|
||||
@ -1272,14 +1272,7 @@ class CandidateEmailForm(forms.Form):
|
||||
label=_('Select Candidates'), # Use a descriptive label
|
||||
required=False
|
||||
)
|
||||
|
||||
# to = forms.MultipleChoiceField(
|
||||
# widget=forms.CheckboxSelectMultiple(attrs={
|
||||
# 'class': 'form-check'
|
||||
# }),
|
||||
# label=_('candidates'),
|
||||
# required=True
|
||||
# )
|
||||
|
||||
|
||||
subject = forms.CharField(
|
||||
max_length=200,
|
||||
@ -1303,58 +1296,13 @@ class CandidateEmailForm(forms.Form):
|
||||
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):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.job = job
|
||||
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=[]
|
||||
for candidate in candidates:
|
||||
@ -1366,11 +1314,11 @@ class CandidateEmailForm(forms.Form):
|
||||
self.fields['to'].choices =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
|
||||
initial_message = self._get_initial_message()
|
||||
|
||||
if initial_message:
|
||||
self.fields['message'].initial = initial_message
|
||||
|
||||
@ -1398,7 +1346,7 @@ class CandidateEmailForm(forms.Form):
|
||||
f"Best regards, The KAAUH Hiring team"
|
||||
]
|
||||
|
||||
elif candidate.stage == 'Exam':
|
||||
elif candidate.stage == 'Interview':
|
||||
message_parts = [
|
||||
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!",
|
||||
@ -1450,42 +1398,13 @@ class CandidateEmailForm(forms.Form):
|
||||
|
||||
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):
|
||||
"""Extract email addresses from selected recipients"""
|
||||
email_addresses = []
|
||||
recipients = self.cleaned_data.get('recipients', [])
|
||||
|
||||
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:
|
||||
for candidate in candidates:
|
||||
if candidate.startswith('candidate_'):
|
||||
@ -1499,29 +1418,187 @@ class CandidateEmailForm(forms.Form):
|
||||
|
||||
|
||||
return list(set(email_addresses)) # Remove duplicates
|
||||
|
||||
|
||||
def get_formatted_message(self):
|
||||
"""Get the formatted message with optional additional information"""
|
||||
message = self.cleaned_data.get('message', 'mesaage from system user hiii')
|
||||
|
||||
# # 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
|
||||
message = self.cleaned_data.get('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"),
|
||||
]
|
||||
|
||||
users=models.ManyToManyField(
|
||||
User,
|
||||
blank=True,related_name="jobs_assigned",
|
||||
verbose_name=_("Internal Participant"),
|
||||
help_text=_("Internal staff involved in the recruitment process for this job"),
|
||||
)
|
||||
# users=models.ManyToManyField(
|
||||
# User,
|
||||
# blank=True,related_name="jobs_assigned",
|
||||
# verbose_name=_("Internal Participant"),
|
||||
# help_text=_("Internal staff involved in the recruitment process for this job"),
|
||||
# )
|
||||
|
||||
participants=models.ManyToManyField('Participants',
|
||||
blank=True,related_name="jobs_participating",
|
||||
verbose_name=_("External Participant"),
|
||||
help_text=_("External participants involved in the recruitment process for this job"),
|
||||
)
|
||||
# participants=models.ManyToManyField('Participants',
|
||||
# blank=True,related_name="jobs_participating",
|
||||
# verbose_name=_("External Participant"),
|
||||
# help_text=_("External participants involved in the recruitment process for this job"),
|
||||
# )
|
||||
|
||||
# Core Fields
|
||||
title = models.CharField(max_length=200)
|
||||
@ -421,6 +421,7 @@ class Candidate(Base):
|
||||
related_name="candidates",
|
||||
verbose_name=_("Job"),
|
||||
)
|
||||
|
||||
first_name = models.CharField(max_length=255, verbose_name=_("First Name"))
|
||||
last_name = models.CharField(max_length=255, verbose_name=_("Last Name"))
|
||||
email = models.EmailField(db_index=True, verbose_name=_("Email")) # Added index
|
||||
@ -706,6 +707,11 @@ class Candidate(Base):
|
||||
time_to_hire = self.hired_date - self.created_at.date()
|
||||
return time_to_hire.days
|
||||
return 0
|
||||
|
||||
@property
|
||||
def belong_to_an_agency(self):
|
||||
return self.hiring_source=='Agency'
|
||||
|
||||
|
||||
class TrainingMaterial(Base):
|
||||
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||
@ -772,7 +778,7 @@ class ZoomMeeting(Base):
|
||||
# Timestamps
|
||||
|
||||
def __str__(self):
|
||||
return self.topic\
|
||||
return self.topic
|
||||
@property
|
||||
def get_job(self):
|
||||
return self.interview.job
|
||||
@ -781,10 +787,10 @@ class ZoomMeeting(Base):
|
||||
return self.interview.candidate
|
||||
@property
|
||||
def get_participants(self):
|
||||
return self.interview.job.participants.all()
|
||||
return self.interview.participants.all()
|
||||
@property
|
||||
def get_users(self):
|
||||
return self.interview.job.users.all()
|
||||
return self.interview.system_users.all()
|
||||
|
||||
class MeetingComment(Base):
|
||||
"""
|
||||
@ -1639,6 +1645,9 @@ class ScheduledInterview(Base):
|
||||
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
|
||||
@ -1753,6 +1762,7 @@ class Notification(models.Model):
|
||||
|
||||
class Participants(Base):
|
||||
"""Model to store Participants details"""
|
||||
|
||||
name = models.CharField(max_length=255, verbose_name=_("Participant Name"),null=True,blank=True)
|
||||
email= models.EmailField(verbose_name=_("Email"))
|
||||
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}
|
||||
|
||||
|
||||
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:
|
||||
subject: Email subject
|
||||
message: Email message (can be HTML)
|
||||
recipient_list: List of email addresses
|
||||
request: Django request object (optional)
|
||||
attachments: List of file attachment data (optional)
|
||||
|
||||
Returns:
|
||||
dict: Result with success status and error message if failed
|
||||
"""
|
||||
from .email_service import send_bulk_email
|
||||
import os
|
||||
|
||||
logger.info(f"Starting bulk email task for {len(recipient_list)} recipients")
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
def _task_send_individual_email(subject, body_message, recipient, attachments):
|
||||
"""Internal helper to create and send a single email."""
|
||||
|
||||
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:
|
||||
# Process attachments - convert file data back to file objects if needed
|
||||
# processed_attachments = []
|
||||
# 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
|
||||
|
||||
email_obj.send(fail_silently=False)
|
||||
return True
|
||||
except Exception as e:
|
||||
error_msg = f"Critical error in bulk email task: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return {'success': False, 'error': error_msg}
|
||||
logger.error(f"Task failed to send email to {recipient}: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
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,
|
||||
AgencyJobAssignmentForm,
|
||||
LinkedPostContentForm,
|
||||
ParticipantsSelectForm,
|
||||
CandidateEmailForm,
|
||||
SourceForm
|
||||
SourceForm,
|
||||
InterviewEmailForm
|
||||
)
|
||||
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
|
||||
from rest_framework import viewsets
|
||||
@ -198,6 +198,30 @@ class ZoomMeetingDetailsView(LoginRequiredMixin, DetailView):
|
||||
model = ZoomMeeting
|
||||
template_name = "meetings/meeting_details.html"
|
||||
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):
|
||||
@ -1457,40 +1481,12 @@ def candidate_update_status(request, slug):
|
||||
def candidate_interview_view(request,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 = {
|
||||
"job":job,
|
||||
"candidates":job.interview_candidates,
|
||||
'current_stage':'Interview',
|
||||
'form':form,
|
||||
'participants_count': job.participants.count() + job.users.count(),
|
||||
|
||||
}
|
||||
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)})
|
||||
|
||||
|
||||
|
||||
@login_required
|
||||
def compose_candidate_email(request, job_slug):
|
||||
"""Compose email to participants about a candidate"""
|
||||
@ -3710,6 +3707,7 @@ def compose_candidate_email(request, job_slug):
|
||||
|
||||
|
||||
if request.method == 'POST':
|
||||
print("........................................................inside candidate conpose.............")
|
||||
candidate_ids = request.POST.getlist('candidate_ids')
|
||||
candidates=Candidate.objects.filter(id__in=candidate_ids)
|
||||
form = CandidateEmailForm(job, candidates, request.POST)
|
||||
@ -3717,6 +3715,7 @@ def compose_candidate_email(request, job_slug):
|
||||
print("form is valid ...")
|
||||
# Get email addresses
|
||||
email_addresses = form.get_email_addresses()
|
||||
print(email_addresses)
|
||||
|
||||
|
||||
if not email_addresses:
|
||||
@ -3731,66 +3730,31 @@ def compose_candidate_email(request, job_slug):
|
||||
return redirect('dashboard')
|
||||
|
||||
|
||||
message = form.get_formatted_message()
|
||||
subject = form.cleaned_data.get('subject')
|
||||
|
||||
# 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()
|
||||
subject = form.cleaned_data.get('subject')
|
||||
|
||||
# Send emails using email service (no attachments, synchronous to avoid pickle issues)
|
||||
|
||||
email_result = send_bulk_email(
|
||||
subject=subject,
|
||||
message=message,
|
||||
recipient_list=email_addresses,
|
||||
request=request,
|
||||
async_task_=True # Changed to False to avoid pickle issues
|
||||
)
|
||||
# Send emails using email service (no attachments, synchronous to avoid pickle issues)
|
||||
|
||||
email_result = send_bulk_email(
|
||||
subject=subject,
|
||||
message=message,
|
||||
recipient_list=email_addresses,
|
||||
request=request,
|
||||
attachments=None,
|
||||
async_task_=True, # Changed to False to avoid pickle issues
|
||||
from_interview=False
|
||||
)
|
||||
|
||||
if email_result['success']:
|
||||
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)
|
||||
else:
|
||||
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', {
|
||||
'form': form,
|
||||
'job': job,
|
||||
@ -3798,21 +3762,7 @@ def compose_candidate_email(request, job_slug):
|
||||
})
|
||||
|
||||
# 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:
|
||||
# Form validation errors
|
||||
print('form is not valid')
|
||||
@ -3825,7 +3775,7 @@ def compose_candidate_email(request, job_slug):
|
||||
'success': False,
|
||||
'error': 'Please correct the form errors and try again.'
|
||||
})
|
||||
|
||||
|
||||
return render(request, 'includes/email_compose_form.html', {
|
||||
'form': form,
|
||||
'job': job,
|
||||
@ -3836,14 +3786,6 @@ def compose_candidate_email(request, job_slug):
|
||||
|
||||
# GET request - show the form
|
||||
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")
|
||||
|
||||
@ -4021,3 +3963,103 @@ def source_toggle_status(request, slug):
|
||||
|
||||
# For GET requests, return error
|
||||
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 %}
|
||||
|
||||
{{ form.media }}
|
||||
<div class="row">
|
||||
|
||||
<div class="container-fluid">
|
||||
@ -41,25 +41,7 @@
|
||||
{% endif %}
|
||||
</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 -->
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.subject.id_for_label }}" class="form-label fw-bold">
|
||||
@ -91,37 +73,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</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 -->
|
||||
<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" %}
|
||||
{% 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 %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div class="container py-5">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title pb-2 border-bottom">Schedule Details</h5>
|
||||
<div class="row">
|
||||
<div class="card schedule-card mb-5">
|
||||
<div class="card-body p-4 p-lg-5">
|
||||
<h4 class="card-title-border">{% trans "Schedule Parameters" %}</h4>
|
||||
<div class="row g-4">
|
||||
|
||||
<div class="col-md-6">
|
||||
<p><strong>Period:</strong> {{ start_date|date:"F j, Y" }} to {{ end_date|date:"F j, Y" }}</p>
|
||||
<p>
|
||||
<strong>Working Days:</strong>
|
||||
<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 class="mb-2"><strong><i class="fas fa-hourglass-half me-2 text-primary-theme"></i> Interview Duration:</strong> {{ interview_duration }} minutes</p>
|
||||
<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 %}
|
||||
{% if day_id == 0 %}Monday{% endif %}
|
||||
{% if day_id == 1 %}Tuesday{% endif %}
|
||||
{% if day_id == 2 %}Wednesday{% endif %}
|
||||
{% if day_id == 3 %}Thursday{% endif %}
|
||||
{% if day_id == 4 %}Friday{% endif %}
|
||||
{% if day_id == 5 %}Saturday{% endif %}
|
||||
{% if day_id == 6 %}Sunday{% endif %}
|
||||
{% if day_id == 0 %}Mon{% endif %}
|
||||
{% if day_id == 1 %}Tue{% endif %}
|
||||
{% if day_id == 2 %}Wed{% endif %}
|
||||
{% if day_id == 3 %}Thu{% endif %}
|
||||
{% if day_id == 4 %}Fri{% endif %}
|
||||
{% if day_id == 5 %}Sat{% endif %}
|
||||
{% if day_id == 6 %}Sun{% endif %}
|
||||
{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</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 class="col-md-6">
|
||||
<p class="mb-2"><strong>Daily Break Times:</strong></p>
|
||||
{% if breaks %}
|
||||
<!-- New structured display for breaks -->
|
||||
<div class="d-flex flex-column gap-1 mb-3 p-3 border rounded bg-light">
|
||||
{% for break in breaks %}
|
||||
<small class="text-dark">
|
||||
<i class="far fa-clock me-1 text-muted"></i>
|
||||
{{ break.start_time|time:"g:i A" }} — {{ break.end_time|time:"g:i A" }}
|
||||
</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="mb-3"><small class="text-muted">No daily breaks scheduled.</small></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-4 pt-3 border-top">{% trans "Daily Break Times" %}</h5>
|
||||
{% if breaks %}
|
||||
<div class="d-flex flex-wrap gap-3 mt-3">
|
||||
{% for break in breaks %}
|
||||
<span class="badge rounded-pill bg-primary-theme-light text-primary-theme p-2 px-3 fw-normal shadow-sm">
|
||||
<i class="far fa-mug-hot me-1"></i>
|
||||
{{ break.start_time|time:"g:i A" }} — {{ break.end_time|time:"g:i A" }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="mt-3"><small class="text-muted"><i class="fas fa-exclamation-circle me-1"></i> No daily breaks scheduled.</small></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title pb-2 border-bottom">Scheduled Interviews</h5>
|
||||
<div class="card schedule-card">
|
||||
<div class="card-body p-4 p-lg-5">
|
||||
<h4 class="card-title-border">{% trans "Scheduled Interviews Overview" %}</h4>
|
||||
|
||||
<!-- Calendar View -->
|
||||
<div id="calendar-container">
|
||||
<div id="calendar-container" class="mb-5 p-3 border rounded bg-light">
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div class="table-responsive mt-4">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<h5 class="pb-2 border-bottom mb-3 text-primary-theme">{% trans "Detailed List" %}</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-striped">
|
||||
<thead class="bg-primary-theme-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Time</th>
|
||||
<th>Candidate</th>
|
||||
<th>Email</th>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Candidate</th>
|
||||
<th scope="col">Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in schedule %}
|
||||
<tr>
|
||||
<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.email }}</td>
|
||||
</tr>
|
||||
@ -85,20 +170,19 @@
|
||||
</table>
|
||||
</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 %}
|
||||
<button type="submit" name="confirm_schedule" class="btn btn-success">
|
||||
<i class="fas fa-check"></i> Confirm Schedule
|
||||
</button>
|
||||
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Edit
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Include FullCalendar CSS and JS -->
|
||||
<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>
|
||||
|
||||
@ -118,6 +202,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
title: '{{ item.candidate.name }}',
|
||||
start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}',
|
||||
url: '#',
|
||||
// Use the theme color for candidate events
|
||||
color: 'var(--kaauh-teal-dark)',
|
||||
extendedProps: {
|
||||
email: '{{ item.candidate.email }}',
|
||||
time: '{{ item.time|time:"g:i A" }}'
|
||||
@ -127,27 +213,36 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
{% for break in breaks %}
|
||||
{
|
||||
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" }}',
|
||||
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'
|
||||
},
|
||||
{% endfor %}
|
||||
],
|
||||
eventClick: function(info) {
|
||||
// Show candidate details in a modal or alert
|
||||
// Log details to console instead of using alert()
|
||||
if (info.event.title !== 'Break') {
|
||||
// IMPORTANT: Since alert() is forbidden, using console log as a fallback.
|
||||
// In a production environment, this would be a custom modal dialog.
|
||||
console.log('Candidate: ' + info.event.title +
|
||||
'\nDate: ' + info.event.start.toLocaleDateString() +
|
||||
'\nTime: ' + info.event.extendedProps.time +
|
||||
'\nEmail: ' + info.event.extendedProps.email);
|
||||
console.log('--- Candidate Interview Details ---');
|
||||
console.log('Candidate: ' + info.event.title);
|
||||
console.log('Date: ' + info.event.start.toLocaleDateString());
|
||||
console.log('Time: ' + info.event.extendedProps.time);
|
||||
console.log('Email: ' + info.event.extendedProps.email);
|
||||
console.log('-----------------------------------');
|
||||
// You would typically open a Bootstrap modal here instead of using console.log
|
||||
}
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@ -1,5 +1,6 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static i18n %}
|
||||
{% load widget_tweaks %}
|
||||
{% block customCSS %}
|
||||
<style>
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@ -248,10 +249,14 @@ body {
|
||||
<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>
|
||||
<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 "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 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 "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 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 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>
|
||||
{% 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>
|
||||
@ -295,7 +300,24 @@ body {
|
||||
{# --- PARTICIPANTS TABLE --- #}
|
||||
<div class="col-lg-12">
|
||||
<div class="p-3 bg-white rounded shadow-sm">
|
||||
<h2 class="text-start"><i class="fas fa-users-cog me-2"></i> {% trans "Assigned Participants" %}</h2>
|
||||
<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>
|
||||
<!--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">
|
||||
<thead>
|
||||
@ -436,6 +458,139 @@ body {
|
||||
</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 %}
|
||||
{% block customJS %}
|
||||
<script>
|
||||
|
||||
@ -233,12 +233,7 @@
|
||||
</button>
|
||||
</form>
|
||||
<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"
|
||||
data-bs-toggle="modal"
|
||||
@ -453,59 +448,7 @@
|
||||
|
||||
</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 -->
|
||||
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
|
||||
|
||||
@ -497,7 +497,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
!-- Email Modal -->
|
||||
<!-- Email Modal -->
|
||||
<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-content kaauh-card">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user