update meeting

This commit is contained in:
Faheed 2025-11-09 13:32:59 +03:00
parent da05441f94
commit bfd2ad935a
19 changed files with 977 additions and 590 deletions

View File

@ -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)}"

View File

@ -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()

View File

@ -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'),
),
]

View File

@ -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),
),
]

View File

@ -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',
),
]

View File

@ -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)

View File

@ -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}")

View File

@ -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'),
]

View File

@ -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')

View File

@ -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">

View File

@ -0,0 +1,5 @@
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Save Participants</button>
</form>

View File

@ -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" }} &mdash; {{ 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" }} &mdash; {{ 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" }} &mdash; {{ 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 %}

View File

@ -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>

View File

@ -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">

View File

@ -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">