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} return {'success': False, 'error': error_msg}
from .models import Candidate from .models import Candidate
def send_bulk_email(subject, message, recipient_list, request=None, attachments=None, async_task_=False): from django.shortcuts import get_object_or_404
# Assuming other necessary imports like logger, settings, EmailMultiAlternatives, strip_tags are present
from .models import Candidate
from django.shortcuts import get_object_or_404
import logging
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.utils.html import strip_tags
from django_q.tasks import async_task # Import needed at the top for clarity
logger = logging.getLogger(__name__)
def send_bulk_email(subject, message, recipient_list, request=None, attachments=None, async_task_=False, from_interview=False):
""" """
Send bulk email to multiple recipients with HTML support and attachments, Send bulk email to multiple recipients with HTML support and attachments,
supporting synchronous or asynchronous dispatch. supporting synchronous or asynchronous dispatch.
""" """
# --- 1. Categorization and Custom Message Preparation (CORRECTED) ---
if not from_interview:
# Define messages (Placeholders)
all_candidate_emails = []
candidate_through_agency_emails=[]
participant_emails = []
agency_emails = [] agency_emails = []
left_candidate_emails = [] pure_candidate_emails = []
candidate_through_agency_emails = []
if not recipient_list: if not recipient_list:
return {'success': False, 'error': 'No recipients provided'} return {'success': False, 'error': 'No recipients provided'}
# This must contain (final_recipient_email, customized_message) for ALL sends
customized_sends = []
# 1a. Classify Recipients and Prepare Custom Messages
for email in recipient_list: for email in recipient_list:
email = email.strip().lower() # Clean input email email = email.strip().lower()
if email:
candidate = Candidate.objects.filter(email=email).first() try:
if candidate: candidate = get_object_or_404(Candidate, email=email)
all_candidate_emails.append(email) except Exception:
logger.warning(f"Candidate not found for email: {email}")
continue
candidate_name = candidate.first_name
# --- Candidate belongs to an agency (Final Recipient: Agency) ---
if candidate.belong_to_an_agency and candidate.hiring_agency and candidate.hiring_agency.email:
agency_email = candidate.hiring_agency.email
agency_message = f"Hi, {candidate_name}" + "\n" + message
# Add Agency email as the recipient with the custom message
customized_sends.append((agency_email, agency_message))
agency_emails.append(agency_email)
candidate_through_agency_emails.append(candidate.email) # For sync block only
# --- Pure Candidate (Final Recipient: Candidate) ---
else: else:
participant_emails.append(email) candidate_message = f"Hi, {candidate_name}" + "\n" + message
# Add Candidate email as the recipient with the custom message
customized_sends.append((email, candidate_message))
pure_candidate_emails.append(email) # For sync block only
for email in all_candidate_emails: # Calculate total recipients based on the size of the final send list
total_recipients = len(customized_sends)
candidate = Candidate.objects.filter(email=email).first()
if candidate:
if candidate.hiring_source == 'Agency' and hasattr(candidate, 'hiring_agency') and candidate.hiring_agency:
agency = candidate.hiring_agency
candidate_through_agency_emails.append(email)
if agency and agency.email:
agency_emails.append(agency.email)
else:
left_candidate_emails.append(email)
else:
left_candidate_emails.append(email)
# Determine unique recipients
unique_left_candidates = list(set(left_candidate_emails)) # Convert to list for async task
unique_agencies = list(agency_emails)
unique_participants = list(set(participant_emails))
total_recipients = len(unique_left_candidates) + len(unique_agencies) + len(unique_participants)
if total_recipients == 0: if total_recipients == 0:
return {'success': False, 'error': 'No valid email addresses found after categorization'} return {'success': False, 'error': 'No valid recipients found for sending.'}
else:
# For interview flow
total_recipients = len(recipient_list)
# --- 3. Handle ASYNC Dispatch --- # --- 2. Handle ASYNC Dispatch (FIXED: Single loop used) ---
if async_task_: if async_task_:
try: try:
from django_q.tasks import async_task
# Simple, serializable attachment format assumed: list of (filename, content, content_type) tuples
processed_attachments = attachments if attachments else [] processed_attachments = attachments if attachments else []
task_ids = [] task_ids = []
# Queue Left Candidates if not from_interview:
if unique_left_candidates: # Loop through ALL final customized sends
for recipient_email, custom_message in customized_sends:
task_id = async_task( task_id = async_task(
'recruitment.tasks.send_bulk_email_task', 'recruitment.tasks.send_bulk_email_task',
subject, subject,
message, custom_message, # Pass the custom message
recipient_list, [recipient_email], # Pass the specific recipient as a list of one
processed_attachments, # Pass serializable data processed_attachments,
hook='recruitment.tasks.email_success_hook' # Example hook hook='recruitment.tasks.email_success_hook'
) )
task_ids.append(task_id) task_ids.append(task_id)
logger.info(f" email queued. ID: {task_id}") logger.info(f"{len(task_ids)} tasks ({total_recipients} emails) queued.")
return { return {
'success': True, 'success': True,
'async': True, 'async': True,
'task_ids': task_ids, 'task_ids': task_ids,
'message': f'Emails queued for background sending to {total_recipients} recipient(s)' 'message': f'Emails queued for background sending to {len(task_ids)} recipient(s).'
} }
else: # from_interview is True (generic send to all participants)
task_id = async_task(
'recruitment.tasks.send_bulk_email_task',
subject,
message,
recipient_list, # Send the original message to the entire list
processed_attachments,
hook='recruitment.tasks.email_success_hook'
)
task_ids.append(task_id)
logger.info(f"Interview emails queued. ID: {task_id}")
return {
'success': True,
'async': True,
'task_ids': task_ids,
'message': f'Interview emails queued for background sending to {total_recipients} recipient(s)'
}
except ImportError: except ImportError:
logger.error("Async execution requested, but django_q or required modules not found. Defaulting to sync.") logger.error("Async execution requested, but django_q or required modules not found. Defaulting to sync.")
async_task_ = False # Fallback to sync async_task_ = False
except Exception as e: except Exception as e:
logger.error(f"Failed to queue async tasks: {str(e)}", exc_info=True) logger.error(f"Failed to queue async tasks: {str(e)}", exc_info=True)
return {'success': False, 'error': f"Failed to queue async tasks: {str(e)}"} return {'success': False, 'error': f"Failed to queue async tasks: {str(e)}"}
# --- 4. Handle SYNCHRONOUS Send (If async_task_=False or fallback) --- # --- 3. Handle SYNCHRONOUS Send (No changes needed here, as it was fixed previously) ---
try: try:
# NOTE: The synchronous block below should also use the 'customized_sends'
# list for consistency instead of rebuilding messages from 'pure_candidate_emails'
# and 'agency_emails', but keeping your current logic structure to minimize changes.
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa') from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
is_html = '<' in message and '>' in message is_html = '<' in message and '>' in message
successful_sends = 0 successful_sends = 0
# Helper Function for Sync Send # Helper Function for Sync Send (as provided)
def send_individual_email(recipient, body_message): def send_individual_email(recipient, body_message):
# ... (Existing helper function logic) ...
nonlocal successful_sends nonlocal successful_sends
if is_html: if is_html:
@ -336,7 +375,6 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
else: else:
email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient]) email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
# Attachment Logic
if attachments: if attachments:
for attachment in attachments: for attachment in attachments:
if hasattr(attachment, 'read'): if hasattr(attachment, 'read'):
@ -349,31 +387,26 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
email_obj.attach(filename, content, content_type) email_obj.attach(filename, content, content_type)
try: try:
# FIX: Added the critical .send() call
email_obj.send(fail_silently=False) email_obj.send(fail_silently=False)
successful_sends += 1 successful_sends += 1
except Exception as e: except Exception as e:
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True) logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
if not from_interview:
# Send Emails # Send Emails - Pure Candidates
for email in unique_left_candidates: for email in pure_candidate_emails:
candidate_name=Candidate.objects.filter(email=email).first().first_name candidate_name = Candidate.objects.filter(email=email).first().first_name
candidate_message = f"Hi, {candidate_name}"+"\n"+message candidate_message = f"Hi, {candidate_name}" + "\n" + message
send_individual_email(email, candidate_message) send_individual_email(email, candidate_message)
i=0 # Send Emails - Agencies
for email in unique_agencies: i = 0
candidate_name=Candidate.objects.filter(email=candidate_through_agency_emails[i]).first().first_name for email in agency_emails:
agency_message = f"Hi, {candidate_name}"+"\n"+message 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) send_individual_email(email, agency_message)
i += 1
for email in unique_participants:
participant_message = "Hello Participant! This is a general notification for you."
send_individual_email(email, participant_message)
logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.") logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.")
return { return {
@ -381,6 +414,16 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
'recipients_count': successful_sends, 'recipients_count': successful_sends,
'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.' 'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.'
} }
else:
for email in recipient_list:
send_individual_email(email, message)
logger.info(f"Interview email processing complete. Sent successfully to {successful_sends} out of {total_recipients} recipients.")
return {
'success': True,
'recipients_count': successful_sends,
'message': f'Interview emails sent successfully to {successful_sends} recipient(s).'
}
except Exception as e: except Exception as e:
error_msg = f"Failed to process bulk email send request: {str(e)}" error_msg = f"Failed to process bulk email send request: {str(e)}"

View File

@ -1243,24 +1243,24 @@ class ParticipantsForm(forms.ModelForm):
} }
class ParticipantsSelectForm(forms.ModelForm): # class ParticipantsSelectForm(forms.ModelForm):
"""Form for selecting Participants""" # """Form for selecting Participants"""
participants=forms.ModelMultipleChoiceField( # participants=forms.ModelMultipleChoiceField(
queryset=Participants.objects.all(), # queryset=Participants.objects.all(),
widget=forms.CheckboxSelectMultiple, # widget=forms.CheckboxSelectMultiple,
required=False, # required=False,
label=_("Select Participants")) # label=_("Select Participants"))
users=forms.ModelMultipleChoiceField( # users=forms.ModelMultipleChoiceField(
queryset=User.objects.all(), # queryset=User.objects.all(),
widget=forms.CheckboxSelectMultiple, # widget=forms.CheckboxSelectMultiple,
required=False, # required=False,
label=_("Select Users")) # label=_("Select Users"))
class Meta: # class Meta:
model = JobPosting # model = JobPosting
fields = ['participants','users'] # No direct fields from Participants model # fields = ['participants','users'] # No direct fields from Participants model
class CandidateEmailForm(forms.Form): class CandidateEmailForm(forms.Form):
@ -1273,13 +1273,6 @@ class CandidateEmailForm(forms.Form):
required=False required=False
) )
# to = forms.MultipleChoiceField(
# widget=forms.CheckboxSelectMultiple(attrs={
# 'class': 'form-check'
# }),
# label=_('candidates'),
# required=True
# )
subject = forms.CharField( subject = forms.CharField(
max_length=200, max_length=200,
@ -1303,58 +1296,13 @@ class CandidateEmailForm(forms.Form):
required=True required=True
) )
recipients = forms.MultipleChoiceField(
widget=forms.CheckboxSelectMultiple(attrs={
'class': 'form-check'
}),
label=_('Recipients'),
required=False
)
# include_candidate_info = forms.BooleanField(
# widget=forms.CheckboxInput(attrs={
# 'class': 'form-check-input'
# }),
# label=_('Include candidate information'),
# initial=True,
# required=False
# )
# include_meeting_details = forms.BooleanField(
# widget=forms.CheckboxInput(attrs={
# 'class': 'form-check-input'
# }),
# label=_('Include meeting details'),
# initial=True,
# required=False
# )
def __init__(self, job, candidates, *args, **kwargs): def __init__(self, job, candidates, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.job = job self.job = job
self.candidates=candidates self.candidates=candidates
stage=self.candidates.first().stage
# Get all participants and users for this job
recipient_choices = []
# Add job participants
#show particpants only in the interview stage
if stage=='Interview':
for participant in job.participants.all():
recipient_choices.append(
(f'participant_{participant.id}', f'{participant.name} - {participant.designation} (Participant)')
)
# Add job users
for user in job.users.all():
recipient_choices.append(
(f'user_{user.id}', f'{user.get_full_name() or user.username} - {user.email} (User)')
)
self.fields['recipients'].choices = recipient_choices
self.fields['recipients'].initial = [choice[0] for choice in recipient_choices] # Select all by default
candidate_choices=[] candidate_choices=[]
for candidate in candidates: for candidate in candidates:
@ -1366,11 +1314,11 @@ class CandidateEmailForm(forms.Form):
self.fields['to'].choices =candidate_choices self.fields['to'].choices =candidate_choices
self.fields['to'].initial = [choice[0] for choice in candidate_choices] self.fields['to'].initial = [choice[0] for choice in candidate_choices]
# # Set initial subject
# self.fields['subject'].initial = f'Interview Update: {candidate.name} - {job.title}'
# Set initial message with candidate and meeting info # Set initial message with candidate and meeting info
initial_message = self._get_initial_message() initial_message = self._get_initial_message()
if initial_message: if initial_message:
self.fields['message'].initial = initial_message self.fields['message'].initial = initial_message
@ -1398,7 +1346,7 @@ class CandidateEmailForm(forms.Form):
f"Best regards, The KAAUH Hiring team" f"Best regards, The KAAUH Hiring team"
] ]
elif candidate.stage == 'Exam': elif candidate.stage == 'Interview':
message_parts = [ message_parts = [
f"Than you, for your interest in the {self.job.title} role.", f"Than you, for your interest in the {self.job.title} role.",
f"We're pleased to inform you that your initial screening was successful!", f"We're pleased to inform you that your initial screening was successful!",
@ -1450,42 +1398,13 @@ class CandidateEmailForm(forms.Form):
return '\n'.join(message_parts) return '\n'.join(message_parts)
# def clean_recipients(self):
# """Ensure at least one recipient is selected"""
# recipients = self.cleaned_data.get('recipients')
# if not recipients:
# raise forms.ValidationError(_('Please select at least one recipient.'))
# return recipients
# def clean_to(self):
# """Ensure at least one recipient is selected"""
# candidates = self.cleaned_data.get('to')
# print(candidates)
# if not candidates:
# raise forms.ValidationError(_('Please select at least one candidate.'))
# return candidates
def get_email_addresses(self): def get_email_addresses(self):
"""Extract email addresses from selected recipients""" """Extract email addresses from selected recipients"""
email_addresses = [] email_addresses = []
recipients = self.cleaned_data.get('recipients', [])
candidates=self.cleaned_data.get('to',[]) candidates=self.cleaned_data.get('to',[])
if recipients:
for recipient in recipients:
if recipient.startswith('participant_'):
participant_id = recipient.split('_')[1]
try:
participant = Participants.objects.get(id=participant_id)
email_addresses.append(participant.email)
except Participants.DoesNotExist:
continue
elif recipient.startswith('user_'):
user_id = recipient.split('_')[1]
try:
user = User.objects.get(id=user_id)
email_addresses.append(user.email)
except User.DoesNotExist:
continue
if candidates: if candidates:
for candidate in candidates: for candidate in candidates:
if candidate.startswith('candidate_'): if candidate.startswith('candidate_'):
@ -1500,28 +1419,186 @@ class CandidateEmailForm(forms.Form):
return list(set(email_addresses)) # Remove duplicates return list(set(email_addresses)) # Remove duplicates
def get_formatted_message(self): def get_formatted_message(self):
"""Get the formatted message with optional additional information""" """Get the formatted message with optional additional information"""
message = self.cleaned_data.get('message', 'mesaage from system user hiii') message = self.cleaned_data.get('message', '')
# # Add candidate information if requested
# if self.cleaned_data.get('include_candidate_info') and self.candidate:
# candidate_info = f"\n\n--- Candidate Information ---\n"
# candidate_info += f"Name: {self.candidate.name}\n"
# candidate_info += f"Email: {self.candidate.email}\n"
# candidate_info += f"Phone: {self.candidate.phone}\n"
# message += candidate_info
# # Add meeting details if requested
# if self.cleaned_data.get('include_meeting_details') and self.candidate:
# latest_meeting = self.candidate.get_latest_meeting
# if latest_meeting:
# meeting_info = f"\n\n--- Meeting Details ---\n"
# meeting_info += f"Topic: {latest_meeting.topic}\n"
# meeting_info += f"Date & Time: {latest_meeting.start_time.strftime('%B %d, %Y at %I:%M %p')}\n"
# meeting_info += f"Duration: {latest_meeting.duration} minutes\n"
# if latest_meeting.join_url:
# meeting_info += f"Join URL: {latest_meeting.join_url}\n"
# message += meeting_info
return message return message
class InterviewParticpantsForm(forms.ModelForm):
participants = forms.ModelMultipleChoiceField(
queryset=Participants.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False ,
)
system_users=forms.ModelMultipleChoiceField(
queryset=User.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label=_("Select Users"))
class Meta:
model = InterviewSchedule
fields = ['participants','system_users']
class InterviewEmailForm(forms.Form):
subject = forms.CharField(
max_length=200,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter email subject',
'required': True
}),
label=_('Subject'),
required=True
)
message_for_candidate= forms.CharField(
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 8,
'placeholder': 'Enter your message here...',
'required': True
}),
label=_('Message'),
required=False
)
message_for_agency= forms.CharField(
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 8,
'placeholder': 'Enter your message here...',
'required': True
}),
label=_('Message'),
required=False
)
message_for_participants= forms.CharField(
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 8,
'placeholder': 'Enter your message here...',
'required': True
}),
label=_('Message'),
required=False
)
def __init__(self, *args,candidate, external_participants, system_participants,meeting,job,**kwargs):
super().__init__(*args, **kwargs)
# --- Data Preparation ---
# Note: Added error handling for agency name if it's missing (though it shouldn't be based on your check)
formatted_date = meeting.start_time.strftime('%Y-%m-%d')
formatted_time = meeting.start_time.strftime('%I:%M %p')
zoom_link = meeting.join_url
duration = meeting.duration
job_title = job.title
agency_name = candidate.hiring_agency.name if candidate.belong_to_an_agency and candidate.hiring_agency else "Hiring Agency"
# --- Combined Participants List for Internal Email ---
external_participants_names = ", ".join([p.name for p in external_participants ])
system_participants_names = ", ".join([p.first_name for p in system_participants ])
# Combine and ensure no leading/trailing commas if one list is empty
participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names]))
# --- 1. Candidate Message (More concise and structured) ---
candidate_message = f"""
Dear {candidate.full_name},
Thank you for your interest in the **{job_title}** position at KAAUH. We're pleased to invite you to an interview!
The details of your virtual interview are as follows:
- **Date:** {formatted_date}
- **Time:** {formatted_time} (RIYADH TIME)
- **Duration:** {duration}
- **Meeting Link:** {zoom_link}
Please click the link at the scheduled time to join the interview.
Kindly reply to this email to **confirm your attendance** or to propose an alternative time if necessary.
We look forward to meeting you.
Best regards,
KAAUH Hiring Team
"""
# --- 2. Agency Message (Professional and clear details) ---
agency_message = f"""
Dear {agency_name},
We have scheduled an interview for your candidate, **{candidate.full_name}**, for the **{job_title}** role.
Please forward the following details to the candidate and ensure they are fully prepared.
**Interview Details:**
- **Candidate:** {candidate.full_name}
- **Job Title:** {job_title}
- **Date:** {formatted_date}
- **Time:** {formatted_time} (RIYADH TIME)
- **Duration:** {duration}
- **Meeting Link:** {zoom_link}
Please let us know if you or the candidate have any questions.
Best regards,
KAAUH Hiring Team
"""
# --- 3. Participants Message (Action-oriented and informative) ---
participants_message = f"""
Hi Team,
This is a reminder of the upcoming interview you are scheduled to participate in for the **{job_title}** position.
**Interview Summary:**
- **Candidate:** {candidate.full_name}
- **Date:** {formatted_date}
- **Time:** {formatted_time} (RIYADH TIME)
- **Duration:** {duration}
- **Your Fellow Interviewers:** {participant_names}
**Action Items:**
1. Please review **{candidate.full_name}'s** resume and notes.
2. The official calendar invite contains the meeting link ({zoom_link}) and should be used to join.
3. Be ready to start promptly at the scheduled time.
Thank you for your participation.
Best regards,
KAAUH HIRING TEAM
"""
# Set initial data
self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}"
# .strip() removes the leading/trailing blank lines caused by the f""" format
self.initial['message_for_candidate'] = candidate_message.strip()
self.initial['message_for_agency'] = agency_message.strip()
self.initial['message_for_participants'] = participants_message.strip()

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"), ("HYBRID", "Hybrid"),
] ]
users=models.ManyToManyField( # users=models.ManyToManyField(
User, # User,
blank=True,related_name="jobs_assigned", # blank=True,related_name="jobs_assigned",
verbose_name=_("Internal Participant"), # verbose_name=_("Internal Participant"),
help_text=_("Internal staff involved in the recruitment process for this job"), # help_text=_("Internal staff involved in the recruitment process for this job"),
) # )
participants=models.ManyToManyField('Participants', # participants=models.ManyToManyField('Participants',
blank=True,related_name="jobs_participating", # blank=True,related_name="jobs_participating",
verbose_name=_("External Participant"), # verbose_name=_("External Participant"),
help_text=_("External participants involved in the recruitment process for this job"), # help_text=_("External participants involved in the recruitment process for this job"),
) # )
# Core Fields # Core Fields
title = models.CharField(max_length=200) title = models.CharField(max_length=200)
@ -421,6 +421,7 @@ class Candidate(Base):
related_name="candidates", related_name="candidates",
verbose_name=_("Job"), verbose_name=_("Job"),
) )
first_name = models.CharField(max_length=255, verbose_name=_("First Name")) first_name = models.CharField(max_length=255, verbose_name=_("First Name"))
last_name = models.CharField(max_length=255, verbose_name=_("Last Name")) last_name = models.CharField(max_length=255, verbose_name=_("Last Name"))
email = models.EmailField(db_index=True, verbose_name=_("Email")) # Added index email = models.EmailField(db_index=True, verbose_name=_("Email")) # Added index
@ -707,6 +708,11 @@ class Candidate(Base):
return time_to_hire.days return time_to_hire.days
return 0 return 0
@property
def belong_to_an_agency(self):
return self.hiring_source=='Agency'
class TrainingMaterial(Base): class TrainingMaterial(Base):
title = models.CharField(max_length=255, verbose_name=_("Title")) title = models.CharField(max_length=255, verbose_name=_("Title"))
content = CKEditor5Field(blank=True, verbose_name=_("Content"),config_name='extends') content = CKEditor5Field(blank=True, verbose_name=_("Content"),config_name='extends')
@ -772,7 +778,7 @@ class ZoomMeeting(Base):
# Timestamps # Timestamps
def __str__(self): def __str__(self):
return self.topic\ return self.topic
@property @property
def get_job(self): def get_job(self):
return self.interview.job return self.interview.job
@ -781,10 +787,10 @@ class ZoomMeeting(Base):
return self.interview.candidate return self.interview.candidate
@property @property
def get_participants(self): def get_participants(self):
return self.interview.job.participants.all() return self.interview.participants.all()
@property @property
def get_users(self): def get_users(self):
return self.interview.job.users.all() return self.interview.system_users.all()
class MeetingComment(Base): class MeetingComment(Base):
""" """
@ -1639,6 +1645,9 @@ class ScheduledInterview(Base):
db_index=True db_index=True
) )
participants = models.ManyToManyField('Participants', blank=True)
system_users=models.ManyToManyField(User,blank=True)
job = models.ForeignKey( job = models.ForeignKey(
"JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True "JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True
@ -1753,6 +1762,7 @@ class Notification(models.Model):
class Participants(Base): class Participants(Base):
"""Model to store Participants details""" """Model to store Participants details"""
name = models.CharField(max_length=255, verbose_name=_("Participant Name"),null=True,blank=True) name = models.CharField(max_length=255, verbose_name=_("Participant Name"),null=True,blank=True)
email= models.EmailField(verbose_name=_("Email")) email= models.EmailField(verbose_name=_("Email"))
phone = models.CharField(max_length=12,verbose_name=_("Phone Number"),null=True,blank=True) phone = models.CharField(max_length=12,verbose_name=_("Phone Number"),null=True,blank=True)

View File

@ -746,70 +746,74 @@ def sync_candidate_to_source_task(candidate_id, source_id):
return {"success": False, "error": error_msg} return {"success": False, "error": error_msg}
def send_bulk_email_task(subject, message, recipient_list, request=None, attachments=None):
"""
Django-Q background task to send bulk email to multiple recipients.
Args: from django.conf import settings
subject: Email subject from django.core.mail import EmailMultiAlternatives
message: Email message (can be HTML) from django.utils.html import strip_tags
recipient_list: List of email addresses
request: Django request object (optional)
attachments: List of file attachment data (optional)
Returns: def _task_send_individual_email(subject, body_message, recipient, attachments):
dict: Result with success status and error message if failed """Internal helper to create and send a single email."""
"""
from .email_service import send_bulk_email
import os
logger.info(f"Starting bulk email task for {len(recipient_list)} recipients") from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
is_html = '<' in body_message and '>' in body_message
if is_html:
plain_message = strip_tags(body_message)
email_obj = EmailMultiAlternatives(subject=subject, body=plain_message, from_email=from_email, to=[recipient])
email_obj.attach_alternative(body_message, "text/html")
else:
email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
if attachments:
for attachment in attachments:
if isinstance(attachment, tuple) and len(attachment) == 3:
filename, content, content_type = attachment
email_obj.attach(filename, content, content_type)
try: try:
# Process attachments - convert file data back to file objects if needed email_obj.send(fail_silently=False)
# processed_attachments = [] return True
# if attachments:
# for attachment in attachments:
# if isinstance(attachment, dict) and 'file_path' in attachment:
# # This is a serialized file from background task
# file_path = attachment['file_path']
# filename = attachment.get('filename', os.path.basename(file_path))
# content_type = attachment.get('content_type', 'application/octet-stream')
# try:
# with open(file_path, 'rb') as f:
# content = f.read()
# processed_attachments.append((filename, content, content_type))
# # Clean up temporary file
# try:
# os.unlink(file_path)
# except OSError:
# pass # File might already be deleted
# except FileNotFoundError:
# logger.warning(f"Attachment file not found: {file_path}")
# continue
# else:
# # Direct attachment (file object or tuple)
# processed_attachments.append(attachment)
# Call the existing send_bulk_email function synchronously within the task
result = send_bulk_email(
subject=subject,
message=message,
recipient_list=recipient_list,
request=request,
)
if result['success']:
logger.info(f"Bulk email task completed successfully for {result.get('recipients_count', len(recipient_list))} recipients")
else:
logger.error(f"Bulk email task failed: {result.get('error', 'Unknown error')}")
return result
except Exception as e: except Exception as e:
error_msg = f"Critical error in bulk email task: {str(e)}" logger.error(f"Task failed to send email to {recipient}: {str(e)}", exc_info=True)
logger.error(error_msg, exc_info=True) return False
return {'success': False, 'error': error_msg}
def send_bulk_email_task(subject, message, recipient_list, attachments=None, hook='recruitment.tasks.email_success_hook'):
"""
Django-Q background task to send pre-formatted email to a list of recipients.
Receives arguments directly from the async_task call.
"""
logger.info(f"Starting bulk email task for {len(recipient_list)} recipients")
successful_sends = 0
total_recipients = len(recipient_list)
if not recipient_list:
return {'success': False, 'error': 'No recipients provided to task.'}
# Since the async caller sends one task per recipient, total_recipients should be 1.
for recipient in recipient_list:
# The 'message' is the custom message specific to this recipient.
if _task_send_individual_email(subject, message, recipient, attachments):
successful_sends += 1
if successful_sends > 0:
logger.info(f"Bulk email task completed successfully. Sent to {successful_sends}/{total_recipients} recipients.")
return {
'success': True,
'recipients_count': successful_sends,
'message': f"Sent successfully to {successful_sends} recipient(s)."
}
else:
logger.error(f"Bulk email task failed: No emails were sent successfully.")
return {'success': False, 'error': "No emails were sent successfully in the background task."}
def email_success_hook(task):
"""
The success hook must accept the Task object as the first and only required positional argument.
"""
if task.success:
logger.info(f"Task ID {task.id} succeeded. Result: {task.result}")
else:
logger.error(f"Task ID {task.id} failed. Error: {task.result}")

View File

@ -232,4 +232,7 @@ urlpatterns = [
# Email composition URLs # Email composition URLs
path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'), path('jobs/<slug:job_slug>/candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'),
path('interview/partcipants/<slug:slug>/',views.create_interview_participants,name='create_interview_participants'),
path('interview/email/<slug:slug>/',views.send_interview_email,name='send_interview_email'),
] ]

View File

@ -41,9 +41,9 @@ from .forms import (
AgencyAccessLinkForm, AgencyAccessLinkForm,
AgencyJobAssignmentForm, AgencyJobAssignmentForm,
LinkedPostContentForm, LinkedPostContentForm,
ParticipantsSelectForm,
CandidateEmailForm, CandidateEmailForm,
SourceForm SourceForm,
InterviewEmailForm
) )
from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent
from rest_framework import viewsets from rest_framework import viewsets
@ -198,6 +198,30 @@ class ZoomMeetingDetailsView(LoginRequiredMixin, DetailView):
model = ZoomMeeting model = ZoomMeeting
template_name = "meetings/meeting_details.html" template_name = "meetings/meeting_details.html"
context_object_name = "meeting" context_object_name = "meeting"
def get_context_data(self, **kwargs):
context=super().get_context_data(**kwargs)
meeting = self.object
interview=meeting.interview
candidate = interview.candidate
job=meeting.get_job
# Assuming interview.participants and interview.system_users hold the people:
participants = list(interview.participants.all()) + list(interview.system_users.all())
external_participants=list(interview.participants.all())
system_participants= list(interview.system_users.all())
total_participants=len(participants)
form = InterviewParticpantsForm(instance=interview)
context['form']=form
context['email_form'] = InterviewEmailForm(
candidate=candidate,
external_participants=external_participants,
system_participants=system_participants,
meeting=meeting,
job=job
)
context['total_participants']=total_participants
return context
class ZoomMeetingUpdateView(LoginRequiredMixin, UpdateView): class ZoomMeetingUpdateView(LoginRequiredMixin, UpdateView):
@ -1457,40 +1481,12 @@ def candidate_update_status(request, slug):
def candidate_interview_view(request,slug): def candidate_interview_view(request,slug):
job = get_object_or_404(JobPosting,slug=slug) job = get_object_or_404(JobPosting,slug=slug)
if request.method == "POST":
form = ParticipantsSelectForm(request.POST, instance=job)
print(form.errors)
if form.is_valid():
# Save the main instance (JobPosting)
job_instance = form.save(commit=False)
job_instance.save()
# MANUALLY set the M2M relationships based on submitted data
job_instance.participants.set(form.cleaned_data['participants'])
job_instance.users.set(form.cleaned_data['users'])
messages.success(request, "Interview participants updated successfully.")
return redirect("candidate_interview_view", slug=job.slug)
else:
initial_data = {
'participants': job.participants.all(),
'users': job.users.all(),
}
form = ParticipantsSelectForm(instance=job, initial=initial_data)
else:
form = ParticipantsSelectForm(instance=job)
context = { context = {
"job":job, "job":job,
"candidates":job.interview_candidates, "candidates":job.interview_candidates,
'current_stage':'Interview', 'current_stage':'Interview',
'form':form,
'participants_count': job.participants.count() + job.users.count(),
} }
return render(request,"recruitment/candidate_interview_view.html",context) return render(request,"recruitment/candidate_interview_view.html",context)
@ -3699,6 +3695,7 @@ def api_candidate_detail(request, candidate_id):
return JsonResponse({'success': False, 'error': str(e)}) return JsonResponse({'success': False, 'error': str(e)})
@login_required @login_required
def compose_candidate_email(request, job_slug): def compose_candidate_email(request, job_slug):
"""Compose email to participants about a candidate""" """Compose email to participants about a candidate"""
@ -3710,6 +3707,7 @@ def compose_candidate_email(request, job_slug):
if request.method == 'POST': if request.method == 'POST':
print("........................................................inside candidate conpose.............")
candidate_ids = request.POST.getlist('candidate_ids') candidate_ids = request.POST.getlist('candidate_ids')
candidates=Candidate.objects.filter(id__in=candidate_ids) candidates=Candidate.objects.filter(id__in=candidate_ids)
form = CandidateEmailForm(job, candidates, request.POST) form = CandidateEmailForm(job, candidates, request.POST)
@ -3717,6 +3715,7 @@ def compose_candidate_email(request, job_slug):
print("form is valid ...") print("form is valid ...")
# Get email addresses # Get email addresses
email_addresses = form.get_email_addresses() email_addresses = form.get_email_addresses()
print(email_addresses)
if not email_addresses: if not email_addresses:
@ -3731,32 +3730,6 @@ def compose_candidate_email(request, job_slug):
return redirect('dashboard') return redirect('dashboard')
# Check if this is an interview invitation
subject = form.cleaned_data.get('subject', '').lower()
is_interview_invitation = 'interview' in subject or 'meeting' in subject
if is_interview_invitation:
# Use HTML template for interview invitations
# meeting_details = None
# if form.cleaned_data.get('include_meeting_details'):
# # Try to get meeting details from candidate
# meeting_details = {
# 'topic': f'Interview for {job.title}',
# 'date_time': getattr(candidate, 'interview_date', 'To be scheduled'),
# 'duration': '60 minutes',
# 'join_url': getattr(candidate, 'meeting_url', ''),
# }
from .email_service import send_interview_invitation_email
email_result = send_interview_invitation_email(
candidates=candidates,
job=job,
# meeting_details=meeting_details,
recipient_list=email_addresses
)
else:
# Get formatted message for regular emails
message = form.get_formatted_message() message = form.get_formatted_message()
subject = form.cleaned_data.get('subject') subject = form.cleaned_data.get('subject')
@ -3767,29 +3740,20 @@ def compose_candidate_email(request, job_slug):
message=message, message=message,
recipient_list=email_addresses, recipient_list=email_addresses,
request=request, request=request,
async_task_=True # Changed to False to avoid pickle issues attachments=None,
async_task_=True, # Changed to False to avoid pickle issues
from_interview=False
) )
if email_result['success']: if email_result['success']:
messages.success(request, f'Email sent successfully to {len(email_addresses)} recipient(s).') messages.success(request, f'Email sent successfully to {len(email_addresses)} recipient(s).')
# # For HTMX requests, return success response
# if 'HX-Request' in request.headers:
# return JsonResponse({
# 'success': True,
# 'message': f'Email sent successfully to {len(email_addresses)} recipient(s).'
# })
return redirect('candidate_interview_view', slug=job.slug) return redirect('candidate_interview_view', slug=job.slug)
else: else:
messages.error(request, f'Failed to send email: {email_result.get("message", "Unknown error")}') messages.error(request, f'Failed to send email: {email_result.get("message", "Unknown error")}')
# For HTMX requests, return error response
# if 'HX-Request' in request.headers:
# return JsonResponse({
# 'success': False,
# 'error': email_result.get("message", "Failed to send email")
# })
return render(request, 'includes/email_compose_form.html', { return render(request, 'includes/email_compose_form.html', {
'form': form, 'form': form,
@ -3798,21 +3762,7 @@ def compose_candidate_email(request, job_slug):
}) })
# except Exception as e: # except Exception as e:
# logger.error(f"Error sending candidate email: {e}")
# messages.error(request, f'An error occurred while sending the email: {str(e)}')
# # For HTMX requests, return error response
# if 'HX-Request' in request.headers:
# return JsonResponse({
# 'success': False,
# 'error': f'An error occurred while sending the email: {str(e)}'
# })
# return render(request, 'includes/email_compose_form.html', {
# 'form': form,
# 'job': job,
# 'candidate': candidate
# })
else: else:
# Form validation errors # Form validation errors
print('form is not valid') print('form is not valid')
@ -3836,14 +3786,6 @@ def compose_candidate_email(request, job_slug):
# GET request - show the form # GET request - show the form
form = CandidateEmailForm(job, candidates) form = CandidateEmailForm(job, candidates)
# try:
# l = [x.split("_")[1] for x in candidates]
# print(l)
# candidates_qs = Candidate.objects.filter(pk__in=l)
# print(candidates_qs)
# form.initial["to"]. = candidates_qs
# except:
# pass
print("GET request made for candidate email form") print("GET request made for candidate email form")
@ -4021,3 +3963,103 @@ def source_toggle_status(request, slug):
# For GET requests, return error # For GET requests, return error
return JsonResponse({'success': False, 'error': 'Method not allowed'}) return JsonResponse({'success': False, 'error': 'Method not allowed'})
from .forms import InterviewParticpantsForm
def create_interview_participants(request,slug):
schedule_interview=get_object_or_404(ScheduledInterview,slug=slug)
interview_slug=schedule_interview.zoom_meeting.slug
if request.method == 'POST':
form = InterviewParticpantsForm(request.POST,instance=schedule_interview)
if form.is_valid():
# Save the main Candidate object, but don't commit to DB yet
candidate = form.save(commit=False)
candidate.save()
# This is important for ManyToMany fields: save the many-to-many data
form.save_m2m()
return redirect('meeting_details',slug=interview_slug) # Redirect to a success page
else:
form = InterviewParticpantsForm(instance=schedule_interview)
return render(request, 'interviews/interview_participants_form.html', {'form': form})
from django.core.mail import send_mail
def send_interview_email(request, slug):
from .email_service import send_bulk_email
interview = get_object_or_404(ScheduledInterview, slug=slug)
# 2. Retrieve the required data for the form's constructor
candidate = interview.candidate
job=interview.job
meeting=interview.zoom_meeting
participants = list(interview.participants.all()) + list(interview.system_users.all())
external_participants=list(interview.participants.all())
system_participants=list(interview.system_users.all())
participant_emails = [p.email for p in participants if hasattr(p, 'email')]
print(participant_emails)
total_recipients=1+len(participant_emails)
# --- POST REQUEST HANDLING ---
if request.method == 'POST':
form = InterviewEmailForm(
request.POST,
candidate=candidate,
external_participants=external_participants,
system_participants=system_participants,
meeting=meeting,
job=job
)
if form.is_valid():
# 4. Extract cleaned data
subject = form.cleaned_data['subject']
msg_candidate = form.cleaned_data['message_for_candidate']
msg_agency = form.cleaned_data['message_for_agency']
msg_participants = form.cleaned_data['message_for_participants']
# --- SEND EMAILS Candidate or agency---
if candidate.belong_to_an_agency:
send_mail(
subject,
msg_agency,
settings.DEFAULT_FROM_EMAIL,
[candidate.hiring_agency.email],
fail_silently=False,
)
else:
send_mail(
subject,
msg_candidate,
settings.DEFAULT_FROM_EMAIL,
[candidate.email],
fail_silently=False,
)
email_result = send_bulk_email(
subject=subject,
message=msg_participants,
recipient_list=participant_emails,
request=request,
attachments=None,
async_task_=True, # Changed to False to avoid pickle issues,
from_interview=True
)
if email_result['success']:
messages.success(request, f'Email sent successfully to {total_recipients} recipient(s).')
return redirect('list_meetings')
else:
messages.error(request, f'Failed to send email: {email_result.get("message", "Unknown error")}')
return redirect('list_meetings')

View File

@ -1,5 +1,5 @@
{% load i18n %} {% load i18n %}
{{ form.media }}
<div class="row"> <div class="row">
<div class="container-fluid"> <div class="container-fluid">
@ -41,25 +41,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="mb-3">
<label class="form-label fw-bold">
{% trans "Participants" %}
</label>
<div class="border rounded p-3 bg-light" style="max-height: 200px; overflow-y: auto;">
{% for choice in form.recipients %}
<div class="form-check mb-2">
{{ choice }}
</div>
{% endfor %}
</div>
{% if form.recipients.errors %}
<div class="text-danger small mt-1">
{% for error in form.recipients.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<!-- Subject Field --> <!-- Subject Field -->
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.subject.id_for_label }}" class="form-label fw-bold"> <label for="{{ form.subject.id_for_label }}" class="form-label fw-bold">
@ -91,37 +73,6 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% comment %}
<!-- Options Checkboxes -->
<div class="mb-4">
<div class="row">
<div class="col-md-6">
<div class="form-check">
<input type="checkbox"
class="form-check-input"
name="{{ form.include_candidate_info.name }}"
id="{{ form.include_candidate_info.id_for_label }}"
{% if form.include_candidate_info.value %}checked{% endif %}>
<label class="form-check-label" for="{{ form.include_candidate_info.id_for_label }}">
{{ form.include_candidate_info.label }}
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input type="checkbox"
class="form-check-input"
name="{{ form.include_meeting_details.name }}"
id="{{ form.include_meeting_details.id_for_label }}"
{% if form.include_meeting_details.value %}checked{% endif %}>
<label class="form-check-label" for="{{ form.include_meeting_details.id_for_label }}">
{{ form.include_meeting_details.label }}
</label>
</div>
</div>
</div>
</div> {% endcomment %}
<!-- Form Actions --> <!-- Form Actions -->
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">

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" %} {% extends "base.html" %}
{% load static %} {% load static %}
{%load i18n %}
{% block customCSS %}
<style>
/* Custom Teal Theme Variables (Adapt these if defined globally) */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-light: #e0f2f4; /* Very light background accent */
--kaauh-teal-dark: #004a53;
--kaauh-border-color: #e3e8ed;
--kaauh-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
/* Primary Theme Utilities */
.text-primary-theme { color: var(--kaauh-teal) !important; }
.bg-primary-theme-light { background-color: var(--kaauh-teal-light) !important; }
.border-primary-theme { border-color: var(--kaauh-teal) !important; }
/* Custom Button Style */
.btn-teal-primary {
background-color: var(--kaauh-teal);
color: white;
border-color: var(--kaauh-teal);
transition: all 0.2s ease;
}
.btn-teal-primary:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
color: white;
}
/* Layout and Typography */
.page-header {
font-weight: 700;
color: var(--kaauh-teal-dark);
border-left: 5px solid var(--kaauh-teal);
padding-left: 15px;
}
/* Card Styling */
.schedule-card {
border: 1px solid var(--kaauh-border-color);
border-radius: 1rem;
box-shadow: var(--kaauh-shadow);
transition: box-shadow 0.3s ease;
padding: 0; /* Control padding inside body */
}
.schedule-card:hover {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
}
.card-title-border {
font-weight: 600;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--kaauh-teal);
margin-bottom: 1.5rem;
color: var(--kaauh-teal);
}
/* Break Times Display */
.break-time-container {
border-left: 3px solid var(--kaauh-teal);
border-radius: 0.5rem;
padding: 10px 15px;
background-color: var(--kaauh-teal-light);
}
/* FullCalendar Customization */
#calendar {
font-size: 0.9em;
}
.fc-event-main-frame {
color: var(--kaauh-teal-dark);
font-weight: 500;
}
/* Event bar color (Candidates) */
.fc-event-title-container {
background-color: var(--kaauh-teal-light);
border-left: 3px solid var(--kaauh-teal);
padding: 2px 5px;
border-radius: 3px;
}
/* Break background color is set in JS events */
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container mt-4"> <div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex justify-content-between align-items-center mb-5">
<h1 class="h3 page-header"> <h1 class="h3 page-header">
<i class="fas fa-calendar-check me-2"></i> Interview Schedule Preview for {{ job.title }} <i class="fas fa-calendar-alt me-2 text-primary-theme"></i> Interview Schedule Preview: **{{ job.title }}**
</h1> </h1>
</div> </div>
<div class="card mt-4 shadow-sm"> <div class="card schedule-card mb-5">
<div class="card-body"> <div class="card-body p-4 p-lg-5">
<h5 class="card-title pb-2 border-bottom">Schedule Details</h5> <h4 class="card-title-border">{% trans "Schedule Parameters" %}</h4>
<div class="row"> <div class="row g-4">
<div class="col-md-6"> <div class="col-md-6">
<p><strong>Period:</strong> {{ start_date|date:"F j, Y" }} to {{ end_date|date:"F j, Y" }}</p> <p class="mb-2"><strong><i class="fas fa-clock me-2 text-primary-theme"></i> Working Hours:</strong> {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}</p>
<p> <p class="mb-2"><strong><i class="fas fa-hourglass-half me-2 text-primary-theme"></i> Interview Duration:</strong> {{ interview_duration }} minutes</p>
<strong>Working Days:</strong> <p class="mb-2"><strong><i class="fas fa-shield-alt me-2 text-primary-theme"></i> Buffer Time:</strong> {{ buffer_time }} minutes</p>
</div>
<div class="col-md-6">
<p class="mb-2"><strong><i class="fas fa-calendar-day me-2 text-primary-theme"></i> Interview Period:</strong> {{ start_date|date:"F j, Y" }} &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 %} {% for day_id in working_days %}
{% if day_id == 0 %}Monday{% endif %} {% if day_id == 0 %}Mon{% endif %}
{% if day_id == 1 %}Tuesday{% endif %} {% if day_id == 1 %}Tue{% endif %}
{% if day_id == 2 %}Wednesday{% endif %} {% if day_id == 2 %}Wed{% endif %}
{% if day_id == 3 %}Thursday{% endif %} {% if day_id == 3 %}Thu{% endif %}
{% if day_id == 4 %}Friday{% endif %} {% if day_id == 4 %}Fri{% endif %}
{% if day_id == 5 %}Saturday{% endif %} {% if day_id == 5 %}Sat{% endif %}
{% if day_id == 6 %}Sunday{% endif %} {% if day_id == 6 %}Sun{% endif %}
{% if not forloop.last %}, {% endif %} {% if not forloop.last %}, {% endif %}
{% endfor %} {% endfor %}
</p> </p>
<p><strong>Working Hours:</strong> {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}</p>
<p><strong>Interview Duration:</strong> {{ interview_duration }} minutes</p>
<p><strong>Buffer Time:</strong> {{ buffer_time }} minutes</p>
</div> </div>
<div class="col-md-6"> </div>
<p class="mb-2"><strong>Daily Break Times:</strong></p>
<h5 class="mt-4 pt-3 border-top">{% trans "Daily Break Times" %}</h5>
{% if breaks %} {% if breaks %}
<!-- New structured display for breaks --> <div class="d-flex flex-wrap gap-3 mt-3">
<div class="d-flex flex-column gap-1 mb-3 p-3 border rounded bg-light">
{% for break in breaks %} {% for break in breaks %}
<small class="text-dark"> <span class="badge rounded-pill bg-primary-theme-light text-primary-theme p-2 px-3 fw-normal shadow-sm">
<i class="far fa-clock me-1 text-muted"></i> <i class="far fa-mug-hot me-1"></i>
{{ break.start_time|time:"g:i A" }} &mdash; {{ break.end_time|time:"g:i A" }} {{ break.start_time|time:"g:i A" }} &mdash; {{ break.end_time|time:"g:i A" }}
</small> </span>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p class="mb-3"><small class="text-muted">No daily breaks scheduled.</small></p> <p class="mt-3"><small class="text-muted"><i class="fas fa-exclamation-circle me-1"></i> No daily breaks scheduled.</small></p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
</div>
<div class="card mt-4 shadow-sm"> <div class="card schedule-card">
<div class="card-body"> <div class="card-body p-4 p-lg-5">
<h5 class="card-title pb-2 border-bottom">Scheduled Interviews</h5> <h4 class="card-title-border">{% trans "Scheduled Interviews Overview" %}</h4>
<!-- Calendar View --> <div id="calendar-container" class="mb-5 p-3 border rounded bg-light">
<div id="calendar-container">
<div id="calendar"></div> <div id="calendar"></div>
</div> </div>
<!-- List View --> <h5 class="pb-2 border-bottom mb-3 text-primary-theme">{% trans "Detailed List" %}</h5>
<div class="table-responsive mt-4"> <div class="table-responsive">
<table class="table table-striped"> <table class="table table-hover table-striped">
<thead> <thead class="bg-primary-theme-light">
<tr> <tr>
<th>Date</th> <th scope="col">Date</th>
<th>Time</th> <th scope="col">Time</th>
<th>Candidate</th> <th scope="col">Candidate</th>
<th>Email</th> <th scope="col">Email</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for item in schedule %} {% for item in schedule %}
<tr> <tr>
<td>{{ item.date|date:"F j, Y" }}</td> <td>{{ item.date|date:"F j, Y" }}</td>
<td>{{ item.time|time:"g:i A" }}</td> <td class="fw-bold text-primary-theme">{{ item.time|time:"g:i A" }}</td>
<td>{{ item.candidate.name }}</td> <td>{{ item.candidate.name }}</td>
<td>{{ item.candidate.email }}</td> <td>{{ item.candidate.email }}</td>
</tr> </tr>
@ -85,20 +170,19 @@
</table> </table>
</div> </div>
<form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4"> <form method="post" action="{% url 'confirm_schedule_interviews_view' slug=job.slug %}" class="mt-4 d-flex justify-content-end gap-3">
{% csrf_token %} {% csrf_token %}
<button type="submit" name="confirm_schedule" class="btn btn-success"> <a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary px-4">
<i class="fas fa-check"></i> Confirm Schedule <i class="fas fa-arrow-left me-2"></i> {% trans "Back to Edit" %}
</button>
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Edit
</a> </a>
<button type="submit" name="confirm_schedule" class="btn btn-teal-primary px-4">
<i class="fas fa-check me-2"></i> {% trans "Confirm Schedule" %}
</button>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<!-- Include FullCalendar CSS and JS -->
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.js"></script>
@ -118,6 +202,8 @@ document.addEventListener('DOMContentLoaded', function() {
title: '{{ item.candidate.name }}', title: '{{ item.candidate.name }}',
start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}', start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}',
url: '#', url: '#',
// Use the theme color for candidate events
color: 'var(--kaauh-teal-dark)',
extendedProps: { extendedProps: {
email: '{{ item.candidate.email }}', email: '{{ item.candidate.email }}',
time: '{{ item.time|time:"g:i A" }}' time: '{{ item.time|time:"g:i A" }}'
@ -127,24 +213,33 @@ document.addEventListener('DOMContentLoaded', function() {
{% for break in breaks %} {% for break in breaks %}
{ {
title: 'Break', title: 'Break',
// FullCalendar requires a specific date for breaks, using start_date as a placeholder for daily breaks.
// Note: Breaks displayed on the monthly grid will only show on start_date, but weekly/daily view should reflect it daily if implemented correctly in the backend or using recurring events.
start: '{{ start_date|date:"Y-m-d" }}T{{ break.start_time|time:"H:i:s" }}', start: '{{ start_date|date:"Y-m-d" }}T{{ break.start_time|time:"H:i:s" }}',
end: '{{ start_date|date:"Y-m-d" }}T{{ break.end_time|time:"H:i:s" }}', end: '{{ start_date|date:"Y-m-d" }}T{{ break.end_time|time:"H:i:s" }}',
color: '#ff9f89', color: '#ff9f89', // A nice soft orange/salmon color for breaks
display: 'background' display: 'background'
}, },
{% endfor %} {% endfor %}
], ],
eventClick: function(info) { eventClick: function(info) {
// Show candidate details in a modal or alert // Log details to console instead of using alert()
if (info.event.title !== 'Break') { if (info.event.title !== 'Break') {
// IMPORTANT: Since alert() is forbidden, using console log as a fallback. console.log('--- Candidate Interview Details ---');
// In a production environment, this would be a custom modal dialog. console.log('Candidate: ' + info.event.title);
console.log('Candidate: ' + info.event.title + console.log('Date: ' + info.event.start.toLocaleDateString());
'\nDate: ' + info.event.start.toLocaleDateString() + console.log('Time: ' + info.event.extendedProps.time);
'\nTime: ' + info.event.extendedProps.time + console.log('Email: ' + info.event.extendedProps.email);
'\nEmail: ' + info.event.extendedProps.email); console.log('-----------------------------------');
// You would typically open a Bootstrap modal here instead of using console.log
} }
info.jsEvent.preventDefault(); info.jsEvent.preventDefault();
},
eventDidMount: function(info) {
// Darken the text for background events (breaks) for better contrast
if (info.event.display === 'background') {
info.el.style.backgroundColor = '#ff9f89';
}
} }
}); });
calendar.render(); calendar.render();

View File

@ -1,5 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static i18n %} {% load static i18n %}
{% load widget_tweaks %}
{% block customCSS %} {% block customCSS %}
<style> <style>
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -248,10 +249,14 @@ body {
<div class="p-3 bg-white rounded shadow-sm h-100 d-flex flex-column"> <div class="p-3 bg-white rounded shadow-sm h-100 d-flex flex-column">
<h2 class="text-start"><i class="fas fa-briefcase me-2"></i> {% trans "Interview Detail" %}</h2> <h2 class="text-start"><i class="fas fa-briefcase me-2"></i> {% trans "Interview Detail" %}</h2>
<div class="detail-row-group flex-grow-1"> <div class="detail-row-group flex-grow-1">
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Title" %}:</div><div class="detail-value-simple">{{ meeting.get_job.title|default:"N/A" }}</div></div> <div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Title" %}:</div><div class="detail-value-simple"><a class="text-decoration-none text-dark" href="{% url 'job_detail' meeting.get_job.slug %}">{{ meeting.get_job.title|default:"N/A" }}</a></div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Name" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.name|default:"N/A" }}</a></div></div> <div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Name" %}:</div><div class="detail-value-simple"><a class="text-decoration-none text-dark" href="{% url 'candidate_detail' meeting.get_candidate.slug %}">{{ meeting.get_candidate.name|default:"N/A" }}</a></div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Email" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.email|default:"N/A" }}</a></div></div> <div class="detail-row-simple"><div class="detail-label-simple">{% trans "Candidate Email" %}:</div><div class="detail-value-simple"><a class="text-decoration-none text-dark" href="{% url 'candidate_detail' meeting.get_candidate.slug %}">{{ meeting.get_candidate.email|default:"N/A" }}</a></div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Type" %}:</div><div class="detail-value-simple">{{ meeting.get_job.job_type|default:"N/A" }}</div></div> <div class="detail-row-simple"><div class="detail-label-simple">{% trans "Job Type" %}:</div><div class="detail-value-simple">{{ meeting.get_job.job_type|default:"N/A" }}</div></div>
{% if meeting.get_candidate.belong_to_agency %}
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Agency" %}:</div><div class="detail-value-simple"><a href="">{{ meeting.get_candidate.hiring_agency.name|default:"N/A" }}</a></div></div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -295,7 +300,24 @@ body {
{# --- PARTICIPANTS TABLE --- #} {# --- PARTICIPANTS TABLE --- #}
<div class="col-lg-12"> <div class="col-lg-12">
<div class="p-3 bg-white rounded shadow-sm"> <div class="p-3 bg-white rounded shadow-sm">
<div class="d-flex justify-content-between align-item-center" >
<h2 class="text-start"><i class="fas fa-users-cog me-2"></i> {% trans "Assigned Participants" %}</h2> <h2 class="text-start"><i class="fas fa-users-cog me-2"></i> {% trans "Assigned Participants" %}</h2>
<!--manage participants for interview-->
<div class="d-flex justify-content-center align-item-center">
<button type="button" class="btn btn-primary-teal btn-sm me-2"
data-bs-toggle="modal"
data-bs-target="#assignParticipants">
<i class="fas fa-users-cog me-1"></i> {% trans "Manage Participants" %} ({{total_participants}})
</button>
<button type="button" class="btn btn-outline-info"
data-bs-toggle="modal"
title="Send Interview Emails"
data-bs-target="#emailModal">
<i class="fas fa-envelope"></i>
</button>
</div>
</div>
<table class="simple-table"> <table class="simple-table">
<thead> <thead>
@ -436,6 +458,139 @@ body {
</div> </div>
</div> </div>
<div class="modal fade" id="assignParticipants" tabindex="-1" aria-labelledby="assignParticipantsLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="jobAssignmentLabel">{% trans "Manage all participants" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="{% url 'create_interview_participants' meeting.interview.slug %}">
{% csrf_token %}
<div class="modal-body table-responsive">
{{ meeting.name }}
<hr>
<table class="table tab table-bordered mt-3">
<thead>
<th class="col">👥 {% trans "Participants" %}</th>
<th class="col">🧑‍💼 {% trans "Users" %}</th>
</thead>
<tbody>
<tr>
<td>
{{ form.participants.errors }}
{{ form.participants }}
</td>
<td> {{ form.system_users.errors }}
{{ form.system_users }}
</td>
</tr>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger-red" data-bs-dismiss="modal">{% trans "Close" %}</button>
<button type="submit" class="btn btn-primary-teal btn-sm">{% trans "Save" %}</button>
</div>
</form>
</div>
</div>
</div>
<!--email modal class-->
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-light">
<h5 class="modal-title" id="emailModalLabel">📧 {% trans "Compose Interview Invitation" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="{% url 'send_interview_email' meeting.interview.slug %}">
{% csrf_token %}
<div class="modal-body">
<div class="mb-3">
<label for="{{ email_form.subject.id_for_label }}" class="form-label fw-bold">Subject</label>
{{ email_form.subject | add_class:"form-control" }}
</div>
<ul class="nav nav-tabs" id="messageTabs" role="tablist">
{# Candidate/Agency Tab - Active by default #}
<li class="nav-item" role="presentation">
<button class="nav-link active" id="candidate-tab" data-bs-toggle="tab" data-bs-target="#candidate-pane" type="button" role="tab" aria-controls="candidate-pane" aria-selected="true">
{% if candidate.belong_to_an_agency %}
Agency Message
{% else %}
Candidate Message
{% endif %}
</button>
</li>
{# Participants Tab #}
<li class="nav-item" role="presentation">
<button class="nav-link" id="participants-tab" data-bs-toggle="tab" data-bs-target="#participants-pane" type="button" role="tab" aria-controls="participants-pane" aria-selected="false">
Panel Message (Interviewers)
</button>
</li>
</ul>
<div class="tab-content border border-top-0 p-3 bg-light-subtle">
{# --- Candidate/Agency Pane --- #}
<div class="tab-pane fade show active" id="candidate-pane" role="tabpanel" aria-labelledby="candidate-tab">
<p class="text-muted small">This email will be sent to the candidate or their hiring agency.</p>
{% if not candidate.belong_to_an_agency %}
<div class="form-group">
<label for="{{ email_form.message_for_candidate.id_for_label }}" class="form-label d-none">Candidate Message</label>
{{ email_form.message_for_candidate | add_class:"form-control" }}
</div>
{% endif %}
{% if candidate.belong_to_an_agency %}
<div class="form-group">
<label for="{{ email_form.message_for_agency.id_for_label }}" class="form-label d-none">Agency Message</label>
{{ email_form.message_for_agency | add_class:"form-control" }}
</div>
{% endif %}
</div>
{# --- Participants Pane --- #}
<div class="tab-pane fade" id="participants-pane" role="tabpanel" aria-labelledby="participants-tab">
<p class="text-muted small">This email will be sent to the internal and external interview participants.</p>
<div class="form-group">
<label for="{{ email_form.message_for_participants.id_for_label }}" class="form-label d-none">Participants Message</label>
{{ email_form.message_for_participants | add_class:"form-control" }}
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger-red" data-bs-dismiss="modal">Close</button>
<button type="submit" class="btn btn-primary-teal">Send Invitation</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block customJS %} {% block customJS %}
<script> <script>

View File

@ -233,12 +233,7 @@
</button> </button>
</form> </form>
<div class="vr" style="height: 28px;"></div> <div class="vr" style="height: 28px;"></div>
<!--manage participants for interview-->
<button type="button" class="btn btn-main-action btn-sm"
data-bs-toggle="modal"
data-bs-target="#jobAssignmentModal">
<i class="fas fa-users-cog me-1"></i> {% trans "Manage Participants" %} ({{participants_count}})
</button>
<button type="button" class="btn btn-outline-info btn-sm" <button type="button" class="btn btn-outline-info btn-sm"
data-bs-toggle="modal" data-bs-toggle="modal"
@ -453,59 +448,7 @@
</div> </div>
<div class="modal fade" id="jobAssignmentModal" tabindex="-1" aria-labelledby="jobAssignmentLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="jobAssignmentLabel">{% trans "Manage all participants" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="{% url 'candidate_interview_view' job.slug %}">
{% csrf_token %}
<div class="modal-body table-responsive">
{{ job.internal_job_id }} {{ job.title}}
<hr>
<table class="table tab table-bordered mt-3">
<thead>
<th class="col">👥 {% trans "Participants" %}</th>
<th class="col">🧑‍💼 {% trans "Users" %}</th>
</thead>
<tbody>
<tr>
<td>
{{ form.participants.errors }}
{{ form.participants }}
</td>
<td> {{ form.users.errors }}
{{ form.users }}
</td>
</tr>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
<button type="submit" class="btn btn-main-action">{% trans "Save" %}</button>
</div>
</form>
</div>
</div>
</div>
<!-- Email Modal --> <!-- Email Modal -->
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true"> <div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">

View File

@ -497,7 +497,7 @@
</div> </div>
</div> </div>
</div> </div>
!-- Email Modal --> <!-- Email Modal -->
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true"> <div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document"> <div class="modal-dialog modal-lg" role="document">
<div class="modal-content kaauh-card"> <div class="modal-content kaauh-card">