message and email #33

Merged
ismail merged 9 commits from frontend into main 2025-11-19 12:56:51 +03:00
26 changed files with 776 additions and 864 deletions

3
.env
View File

@ -1,3 +0,0 @@
DB_NAME=norahuniversity
DB_USER=norahuniversity
DB_PASSWORD=norahuniversity

1
.gitignore vendored
View File

@ -53,6 +53,7 @@ htmlcov/
# Media and Static files (if served locally and not meant for version control)
media/
static/
# Deployment files
*.tar.gz

View File

@ -28,7 +28,7 @@ urlpatterns = [
path('application/<slug:slug>/apply/', views.application_detail, name='application_detail'),
path('application/<slug:slug>/signup/', views.candidate_signup, name='candidate_signup'),
path('application/<slug:slug>/success/', views.application_success, name='application_success'),
path('application/applicant/profile', views.applicant_profile, name='applicant_profile'),
# path('application/applicant/profile', views.applicant_profile, name='applicant_profile'),
path('api/templates/', views.list_form_templates, name='list_form_templates'),
path('api/templates/save/', views.save_form_template, name='save_form_template'),

View File

@ -1,15 +1,24 @@
"""
Email service for sending notifications related to agency messaging.
"""
from .models import Application
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__)
from django.core.mail import send_mail, EmailMultiAlternatives
from django.conf import settings
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.contrib.auth import get_user_model
import logging
from .models import Message
logger = logging.getLogger(__name__)
User=get_user_model()
class EmailService:
"""
@ -225,17 +234,10 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi
return {'success': False, 'error': error_msg}
from .models import Application
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):
def send_bulk_email(subject, message, recipient_list, request=None, attachments=None, async_task_=False, from_interview=False,job=None):
"""
Send bulk email to multiple recipients with HTML support and attachments,
supporting synchronous or asynchronous dispatch.
@ -301,7 +303,8 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
processed_attachments = attachments if attachments else []
task_ids = []
job_id=job.id
sender_user_id=request.user.id if request and hasattr(request, 'user') and request.user.is_authenticated else None
if not from_interview:
# Loop through ALL final customized sends
for recipient_email, custom_message in customized_sends:
@ -311,7 +314,10 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
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'
sender_user_id,
job_id,
hook='recruitment.tasks.email_success_hook',
)
task_ids.append(task_id)
@ -350,80 +356,101 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
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)}"}
else:
# --- 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 (as provided)
def send_individual_email(recipient, body_message):
# ... (Existing helper function logic) ...
nonlocal successful_sends
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 hasattr(attachment, 'read'):
filename = getattr(attachment, 'name', 'attachment')
content = attachment.read()
content_type = getattr(attachment, 'content_type', 'application/octet-stream')
email_obj.attach(filename, content, content_type)
elif isinstance(attachment, tuple) and len(attachment) == 3:
filename, content, content_type = attachment
email_obj.attach(filename, content, content_type)
try:
email_obj.send(fail_silently=False)
successful_sends += 1
# 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 (as provided)
def send_individual_email(recipient, body_message):
# ... (Existing helper function logic) ...
nonlocal successful_sends
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 hasattr(attachment, 'read'):
filename = getattr(attachment, 'name', 'attachment')
content = attachment.read()
content_type = getattr(attachment, 'content_type', 'application/octet-stream')
email_obj.attach(filename, content, content_type)
elif isinstance(attachment, tuple) and len(attachment) == 3:
filename, content, content_type = attachment
email_obj.attach(filename, content, content_type)
try:
result=email_obj.send(fail_silently=False)
if result==1:
try:
user=get_object_or_404(User,email=recipient)
new_message = Message.objects.create(
sender=request.user,
recipient=user,
job=job,
subject=subject,
content=message, # Store the full HTML or plain content
message_type='DIRECT',
is_read=False, # It's just sent, not read yet
)
logger.info(f"Stored sent message ID {new_message.id} in DB.")
except Exception as e:
logger.error(f"Email sent to {recipient}, but failed to store in DB: {str(e)}")
else:
logger.error("fialed to send email")
successful_sends += 1
except Exception as e:
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
if not from_interview:
# Send Emails - Pure Candidates
for email in pure_candidate_emails:
candidate_name = Application.objects.filter(person__email=email).first().person.full_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 = Application.objects.filter(person__email=candidate_email).first().person.full_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:
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
if not from_interview:
# Send Emails - Pure Candidates
for email in pure_candidate_emails:
candidate_name = Application.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 = Application.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)}"
logger.error(error_msg, exc_info=True)
return {'success': False, 'error': error_msg}
error_msg = f"Failed to process bulk email send request: {str(e)}"
logger.error(error_msg, exc_info=True)
return {'success': False, 'error': error_msg}

View File

@ -1621,7 +1621,7 @@ class CandidateEmailForm(forms.Form):
elif candidate and 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!",
f"We're pleased to inform you that you have cleared your exam!",
f"The next step is the mandatory online assessment exam.",
f"Please complete the assessment by using the following link:",
f"https://kaauh/hire/exam",
@ -1698,6 +1698,7 @@ class CandidateEmailForm(forms.Form):
return message
class InterviewParticpantsForm(forms.ModelForm):
participants = forms.ModelMultipleChoiceField(
queryset=Participants.objects.all(),
@ -1706,7 +1707,7 @@ class InterviewParticpantsForm(forms.ModelForm):
)
system_users=forms.ModelMultipleChoiceField(
queryset=User.objects.all(),
queryset=User.objects.filter(user_type='staff'),
widget=forms.CheckboxSelectMultiple,
required=False,
label=_("Select Users"))
@ -1861,107 +1862,107 @@ class InterviewParticpantsForm(forms.ModelForm):
# self.initial['message_for_participants'] = participants_message.strip()
class InterviewEmailForm(forms.Form):
# ... (Field definitions)
# class InterviewEmailForm(forms.Form):
# # ... (Field definitions)
def __init__(self, *args, candidate, external_participants, system_participants, meeting, job, **kwargs):
super().__init__(*args, **kwargs)
# def __init__(self, *args, candidate, external_participants, system_participants, meeting, job, **kwargs):
# super().__init__(*args, **kwargs)
location = meeting.interview_location
# location = meeting
# --- Data Preparation ---
# # --- Data Preparation ---
# Safely access details through the related InterviewLocation object
if location and location.start_time:
formatted_date = location.start_time.strftime('%Y-%m-%d')
formatted_time = location.start_time.strftime('%I:%M %p')
duration = location.duration
meeting_link = location.details_url if location.details_url else "N/A (See Location Topic)"
else:
# Handle case where location or time is missing/None
formatted_date = "TBD - Awaiting Scheduling"
formatted_time = "TBD"
duration = "N/A"
meeting_link = "Not Available"
# # Safely access details through the related InterviewLocation object
# if location and location.start_time:
# formatted_date = location.start_time.strftime('%Y-%m-%d')
# formatted_time = location.start_time.strftime('%I:%M %p')
# duration = location.duration
# meeting_link = location.details_url if location.details_url else "N/A (See Location Topic)"
# else:
# # Handle case where location or time is missing/None
# formatted_date = "TBD - Awaiting Scheduling"
# formatted_time = "TBD"
# duration = "N/A"
# meeting_link = "Not Available"
job_title = job.title
agency_name = candidate.hiring_agency.name if candidate.belong_to_an_agency and candidate.hiring_agency else "Hiring Agency"
# 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 ])
participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names]))
# # --- 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 ])
# participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names]))
# --- 1. Candidate Message (Use meeting_link) ---
candidate_message = f"""
Dear {candidate.full_name},
# # --- 1. Candidate Message (Use meeting_link) ---
# 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!
# 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:
# The details of your virtual interview are as follows:
- **Date:** {formatted_date}
- **Time:** {formatted_time} (RIYADH TIME)
- **Duration:** {duration}
- **Meeting Link:** {meeting_link}
# - **Date:** {formatted_date}
# - **Time:** {formatted_time} (RIYADH TIME)
# - **Duration:** {duration}
# - **Meeting Link:** {meeting_link}
Please click the link at the scheduled time to join the interview.
# Please click the link at the scheduled time to join the interview.
Kindly reply to this email to **confirm your attendance** or to propose an alternative time if necessary.
# Kindly reply to this email to **confirm your attendance** or to propose an alternative time if necessary.
We look forward to meeting you.
# We look forward to meeting you.
Best regards,
KAAUH Hiring Team
"""
# ... (Messages for agency and participants remain the same, using the updated safe variables)
# Best regards,
# KAAUH Hiring Team
# """
# # ... (Messages for agency and participants remain the same, using the updated safe variables)
# --- 2. Agency Message (Professional and clear details) ---
agency_message = f"""
Dear {agency_name},
...
**Interview Details:**
...
- **Date:** {formatted_date}
- **Time:** {formatted_time} (RIYADH TIME)
- **Duration:** {duration}
- **Meeting Link:** {meeting_link}
...
"""
# # --- 2. Agency Message (Professional and clear details) ---
# agency_message = f"""
# Dear {agency_name},
# ...
# **Interview Details:**
# ...
# - **Date:** {formatted_date}
# - **Time:** {formatted_time} (RIYADH TIME)
# - **Duration:** {duration}
# - **Meeting Link:** {meeting_link}
# ...
# """
# --- 3. Participants Message (Action-oriented and informative) ---
participants_message = f"""
Hi Team,
...
**Interview Summary:**
# # --- 3. Participants Message (Action-oriented and informative) ---
# participants_message = f"""
# Hi Team,
# ...
# **Interview Summary:**
- **Candidate:** {candidate.full_name}
- **Date:** {formatted_date}
- **Time:** {formatted_time} (RIYADH TIME)
- **Duration:** {duration}
- **Your Fellow Interviewers:** {participant_names}
# - **Candidate:** {candidate.full_name}
# - **Date:** {formatted_date}
# - **Time:** {formatted_time} (RIYADH TIME)
# - **Duration:** {duration}
# - **Your Fellow Interviewers:** {participant_names}
**Action Items:**
# **Action Items:**
1. Please review **{candidate.full_name}'s** resume and notes.
2. The official calendar invite contains the meeting link ({meeting_link}) and should be used to join.
3. Be ready to start promptly at the scheduled time.
...
"""
# Set initial data
self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}"
self.initial['message_for_candidate'] = candidate_message.strip()
self.initial['message_for_agency'] = agency_message.strip()
self.initial['message_for_participants'] = participants_message.strip()
# 1. Please review **{candidate.full_name}'s** resume and notes.
# 2. The official calendar invite contains the meeting link ({meeting_link}) and should be used to join.
# 3. Be ready to start promptly at the scheduled time.
# ...
# """
# # Set initial data
# self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}"
# self.initial['message_for_candidate'] = candidate_message.strip()
# self.initial['message_for_agency'] = agency_message.strip()
# self.initial['message_for_participants'] = participants_message.strip()
# class OnsiteLocationForm(forms.ModelForm):
# class Meta:
# model=
# fields=['location']
# widgets={
# 'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}),
# }
# # class OnsiteLocationForm(forms.ModelForm):
# # class Meta:
# # model=
# # fields=['location']
# # widgets={
# # 'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}),
# # }
#during bulk schedule
class OnsiteLocationForm(forms.ModelForm):
@ -1986,6 +1987,125 @@ class OnsiteLocationForm(forms.ModelForm):
}
class InterviewEmailForm(forms.Form):
subject = forms.CharField(max_length=255, widget=forms.TextInput(attrs={'class': 'form-control'}))
message_for_candidate = forms.CharField(widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 6}))
message_for_agency = forms.CharField(widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 6}))
message_for_participants = forms.CharField(widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 6}))
def __init__(self, *args, candidate, external_participants, system_participants, meeting, job, **kwargs):
"""
meeting: an InterviewLocation instance (e.g., ZoomMeetingDetails or OnsiteLocationDetails)
"""
super().__init__(*args, **kwargs)
# ✅ meeting is already the InterviewLocation — do NOT use .interview_location
location = meeting
# --- Determine concrete details (Zoom or Onsite) ---
if location.location_type == location.LocationType.REMOTE:
details = getattr(location, 'zoommeetingdetails', None)
elif location.location_type == location.LocationType.ONSITE:
details = getattr(location, 'onsitelocationdetails', None)
else:
details = None
# --- Extract meeting info safely ---
if details and details.start_time:
formatted_date = details.start_time.strftime('%Y-%m-%d')
formatted_time = details.start_time.strftime('%I:%M %p')
duration = details.duration
meeting_link = location.details_url or "N/A (See Location Topic)"
else:
formatted_date = "TBD - Awaiting Scheduling"
formatted_time = "TBD"
duration = "N/A"
meeting_link = "Not Available"
job_title = job.title
agency_name = (
candidate.hiring_agency.name
if candidate.belong_to_agency and candidate.hiring_agency
else "Hiring Agency"
)
# --- Participant names for internal email ---
external_names = ", ".join([p.name for p in external_participants])
system_names = ", ".join([u.get_full_name() or u.username for u in system_participants])
participant_names = ", ".join(filter(None, [external_names, system_names]))
# --- Candidate Message ---
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 interview are as follows:
- **Date:** {formatted_date}
- **Time:** {formatted_time} (RIYADH TIME)
- **Duration:** {duration} minutes
- **Meeting Link/Location:** {meeting_link}
Please be ready at the scheduled time.
Kindly reply to confirm your attendance or propose an alternative if needed.
We look forward to meeting you.
Best regards,
KAAUH Hiring Team
""".strip()
# --- Agency Message ---
agency_message = f"""
Dear {agency_name},
This is to inform you that your candidate, **{candidate.full_name}**, has been scheduled for an interview for the **{job_title}** position.
**Interview Details:**
- **Date:** {formatted_date}
- **Time:** {formatted_time} (RIYADH TIME)
- **Duration:** {duration} minutes
- **Meeting Link/Location:** {meeting_link}
Please ensure the candidate is informed and prepared.
Best regards,
KAAUH Hiring Team
""".strip()
# --- Participants (Interview Panel) Message ---
participants_message = f"""
Hi Team,
You are scheduled to interview **{candidate.full_name}** for the **{job_title}** role.
**Interview Summary:**
- **Candidate:** {candidate.full_name}
- **Date:** {formatted_date}
- **Time:** {formatted_time} (RIYADH TIME)
- **Duration:** {duration} minutes
- **Location/Link:** {meeting_link}
- **Fellow Interviewers:** {participant_names}
**Action Items:**
1. Review the candidates resume and application notes.
2. Join via the link above (or be at the physical location) on time.
3. Coordinate among yourselves for role coverage.
Thank you!
""".strip()
# --- Set initial values ---
self.initial.update({
'subject': f"Interview Invitation: {job_title} - {candidate.full_name}",
'message_for_candidate': candidate_message,
'message_for_agency': agency_message,
'message_for_participants': participants_message,
})
class OnsiteReshuduleForm(forms.ModelForm):
class Meta:
model = OnsiteLocationDetails

View File

@ -12,8 +12,9 @@ from . linkedin_service import LinkedInService
from django.shortcuts import get_object_or_404
from . models import JobPosting
from django.utils import timezone
from . models import InterviewSchedule,ScheduledInterview,ZoomMeetingDetails
from . models import InterviewSchedule,ScheduledInterview,ZoomMeetingDetails,Message
from django.contrib.auth import get_user_model
User = get_user_model()
# Add python-docx import for Word document processing
try:
from docx import Document
@ -28,7 +29,7 @@ logger = logging.getLogger(__name__)
OPENROUTER_API_KEY ='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'
# OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free'
OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct'
OPENROUTER_MODEL = 'openai/gpt-oss-20b'
# OPENROUTER_MODEL = 'openai/gpt-oss-20b'
# OPENROUTER_MODEL = 'mistralai/mistral-small-3.2-24b-instruct:free'
@ -506,7 +507,6 @@ def handle_zoom_webhook_event(payload):
Background task to process a Zoom webhook event and update the local ZoomMeeting status.
It handles: created, updated, started, ended, and deleted events.
"""
print(payload)
event_type = payload.get('event')
object_data = payload['payload']['object']
@ -535,9 +535,7 @@ def handle_zoom_webhook_event(payload):
# elif event_type == 'meeting.updated':
# Only update time fields if they are in the payload
print(object_data)
meeting_start_time = object_data.get('start_time', meeting_instance.start_time)
if meeting_start_time:
meeting_instance.start_time = datetime.fromisoformat(meeting_start_time)
meeting_instance.start_time = object_data.get('start_time', meeting_instance.start_time)
meeting_instance.duration = object_data.get('duration', meeting_instance.duration)
meeting_instance.timezone = object_data.get('timezone', meeting_instance.timezone)
@ -758,7 +756,7 @@ 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):
def _task_send_individual_email(subject, body_message, recipient, attachments,sender,job):
"""Internal helper to create and send a single email."""
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
@ -778,16 +776,36 @@ def _task_send_individual_email(subject, body_message, recipient, attachments):
email_obj.attach(filename, content, content_type)
try:
email_obj.send(fail_silently=False)
return True
result=email_obj.send(fail_silently=False)
if result==1:
try:
user=get_object_or_404(User,email=recipient)
new_message = Message.objects.create(
sender=sender,
recipient=user,
job=job,
subject=subject,
content=body_message, # Store the full HTML or plain content
message_type='DIRECT',
is_read=False, # It's just sent, not read yet
)
logger.info(f"Stored sent message ID {new_message.id} in DB.")
except Exception as e:
logger.error(f"Email sent to {recipient}, but failed to store in DB: {str(e)}")
else:
logger.error("fialed to send email")
except Exception as e:
logger.error(f"Task failed to send email to {recipient}: {str(e)}", exc_info=True)
return False
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
def send_bulk_email_task(subject, message, recipient_list, attachments=None, hook='recruitment.tasks.email_success_hook'):
def send_bulk_email_task(subject, message, recipient_list,attachments=None,sender_user_id=None,job_id=None, hook='recruitment.tasks.email_success_hook'):
"""
Django-Q background task to send pre-formatted email to a list of recipients.
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")
@ -796,11 +814,13 @@ def send_bulk_email_task(subject, message, recipient_list, attachments=None, hoo
if not recipient_list:
return {'success': False, 'error': 'No recipients provided to task.'}
sender=get_object_or_404(User,pk=sender_user_id)
job=get_object_or_404(JobPosting,pk=job_id)
# 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):
if _task_send_individual_email(subject, message, recipient, attachments,sender,job):
successful_sends += 1
if successful_sends > 0:

View File

@ -656,5 +656,6 @@ urlpatterns = [
# Detail View (assuming slug is on ScheduledInterview)
# path("interviews/meetings/<slug:slug>/", views.MeetingDetailView.as_view(), name="meeting_details"),
path("interviews/meetings/<slug:slug>/", views.meeting_details, name="meeting_details"),
]

View File

@ -129,7 +129,8 @@ from .models import (
Message,
Document,
OnsiteLocationDetails,
InterviewLocation
InterviewLocation,
InterviewNote
)
@ -249,123 +250,7 @@ class ZoomMeetingCreateView(StaffRequiredMixin, CreateView):
messages.error(self.request, f"Error creating meeting: {e}")
return redirect(reverse("create_meeting", kwargs={"slug": instance.slug}))
# class ZoomMeetingListView(StaffRequiredMixin, ListView):
# model = ZoomMeetingDetails
# template_name = "meetings/list_meetings.html"
# context_object_name = "meetings"
# paginate_by = 10
# def get_queryset(self):
# queryset = super().get_queryset().order_by("-start_time")
# # Prefetch related interview data efficiently
# queryset = queryset.prefetch_related(
# Prefetch(
# "interview", # related_name from ZoomMeeting to ScheduledInterview
# queryset=ScheduledInterview.objects.select_related("application", "job"),
# to_attr="interview_details", # Changed to not start with underscore
# )
# )
# # Handle search by topic or meeting_id
# search_query = self.request.GET.get(
# "q", ""
# ) # Renamed from 'search' to 'q' for consistency
# if search_query:
# queryset = queryset.filter(
# Q(topic__icontains=search_query) | Q(meeting_id__icontains=search_query)
# )
# # Handle filter by status
# status_filter = self.request.GET.get("status", "")
# if status_filter:
# queryset = queryset.filter(status=status_filter)
# # Handle search by candidate name
# candidate_name = self.request.GET.get("candidate_name", "")
# if candidate_name:
# # Filter based on the name of the candidate associated with the meeting's interview
# queryset = queryset.filter(
# Q(interview__application__first_name__icontains=candidate_name)
# | Q(interview__application__last_name__icontains=candidate_name)
# )
# return queryset
# def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context["search_query"] = self.request.GET.get("q", "")
# context["status_filter"] = self.request.GET.get("status", "")
# context["candidate_name_filter"] = self.request.GET.get("candidate_name", "")
# return context
# @login_required
# def InterviewListView(request):
# # interview_type=request.GET.get('interview_type','Remote')
# # print(interview_type)
# interview_type='Onsite'
# meetings=ScheduledInterview.objects.filter(schedule__interview_type=interview_type)
# return render(request, "meetings/list_meetings.html",{
# 'meetings':meetings,
# })
# search_query = request.GET.get("q", "") # Renamed from 'search' to 'q' for consistency
# if search_query:
# interviews = interviews.filter(
# Q(topic__icontains=search_query) | Q(meeting_id__icontains=search_query)
# )
# # Handle filter by status
# status_filter = request.GET.get("status", "")
# if status_filter:
# queryset = queryset.filter(status=status_filter)
# # Handle search by candidate name
# candidate_name = request.GET.get("candidate_name", "")
# if candidate_name:
# # Filter based on the name of the candidate associated with the meeting's interview
# queryset = queryset.filter(
# Q(interview__candidate__first_name__icontains=candidate_name) |
# Q(interview__candidate__last_name__icontains=candidate_name)
# )
# @login_required
# def InterviewListView(request):
# # interview_type=request.GET.get('interview_type','Remote')
# # print(interview_type)
# interview_type='Onsite'
# meetings=ScheduledInterview.objects.filter(schedule__interview_type=interview_type)
# return render(request, "meetings/list_meetings.html",{
# 'meetings':meetings,
# })
# search_query = request.GET.get("q", "") # Renamed from 'search' to 'q' for consistency
# if search_query:
# interviews = interviews.filter(
# Q(topic__icontains=search_query) | Q(meeting_id__icontains=search_query)
# )
# # Handle filter by status
# status_filter = request.GET.get("status", "")
# if status_filter:
# queryset = queryset.filter(status=status_filter)
# # Handle search by candidate name
# candidate_name = request.GET.get("candidate_name", "")
# if candidate_name:
# # Filter based on the name of the candidate associated with the meeting's interview
# queryset = queryset.filter(
# Q(interview__candidate__first_name__icontains=candidate_name) |
# Q(interview__candidate__last_name__icontains=candidate_name)
# )
class ZoomMeetingDetailsView(StaffRequiredMixin, DetailView):
model = ZoomMeetingDetails
@ -1268,8 +1153,8 @@ def application_submit_form(request, template_slug):
)
def applicant_profile(request):
return render(request, "applicant/applicant_profile.html")
# def applicant_profile(request):
# return render(request, "applicant/applicant_profile.html")
@csrf_exempt
@ -3122,7 +3007,7 @@ def add_meeting_comment(request, slug):
meeting = get_object_or_404(ZoomMeetingDetails, slug=slug)
if request.method == "POST":
form = MeetingCommentForm(request.POST)
form = InterviewNoteForm(request.POST)
if form.is_valid():
comment = form.save(commit=False)
comment.meeting = meeting
@ -3143,7 +3028,7 @@ def add_meeting_comment(request, slug):
return redirect("meeting_details", slug=slug)
else:
form = MeetingCommentForm()
form = InterviewNoteForm()
context = {
"form": form,
@ -3169,7 +3054,7 @@ def edit_meeting_comment(request, slug, comment_id):
return redirect("meeting_details", slug=slug)
if request.method == "POST":
form = MeetingCommentForm(request.POST, instance=comment)
form = InterviewNoteForm(request.POST, instance=comment)
if form.is_valid():
comment = form.save()
messages.success(request, "Comment updated successfully!")
@ -3187,7 +3072,7 @@ def edit_meeting_comment(request, slug, comment_id):
return redirect("meeting_details", slug=slug)
else:
form = MeetingCommentForm(instance=comment)
form = InterviewNoteForm(instance=comment)
context = {"form": form, "meeting": meeting, "comment": comment}
return render(request, "includes/edit_comment_form.html", context)
@ -4692,7 +4577,8 @@ def message_detail(request, message_id):
@login_required
def message_create(request):
"""Create a new message"""
"""Create a new message"""
from .email_service import EmailService
if request.method == "POST":
form = MessageForm(request.user, request.POST)
@ -4700,8 +4586,25 @@ def message_create(request):
message = form.save(commit=False)
message.sender = request.user
message.save()
messages.success(request, "Message sent successfully!")
["recipient", "job", "subject", "content", "message_type"]
recipient_email = form.cleaned_data['recipient'].email # Assuming recipient is a User or Model with an 'email' field
subject = form.cleaned_data['subject']
custom_message = form.cleaned_data['content']
job_id = form.cleaned_data['job'].id if 'job' in form.cleaned_data and form.cleaned_data['job'] else None
sender_user_id = request.user.id
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
sender_user_id=sender_user_id,
job_id=job_id,
hook='recruitment.tasks.email_success_hook')
logger.info(f"{task_id} queued.")
return redirect("message_list")
else:
messages.error(request, "Please correct the errors below.")
@ -4714,6 +4617,8 @@ def message_create(request):
if request.user.user_type != "staff":
return render(request, "messages/candidate_message_form.html", context)
return render(request, "messages/message_form.html", context)
@login_required
def message_reply(request, message_id):
"""Reply to a message"""
@ -5206,7 +5111,7 @@ def compose_candidate_email(request, job_slug):
if request.method == 'POST':
print("........................................................inside candidate conpose.............")
candidate_ids = request.POST.getlist('candidate_ids')
candidates=Application.objects.filter(id__in=candidate_ids)
form = CandidateEmailForm(job, candidates, request.POST)
@ -5233,14 +5138,16 @@ def compose_candidate_email(request, job_slug):
# Send emails using email service (no attachments, synchronous to avoid pickle issues)
email_result = send_bulk_email(
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
from_interview=False,
job=job
)
if email_result["success"]:
@ -5538,25 +5445,50 @@ def candidate_signup(request, slug):
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}
# )
def create_interview_participants(request, slug):
"""
Manage participants for a ScheduledInterview.
Uses interview_pk because ScheduledInterview has no slug.
"""
schedule_interview = get_object_or_404(ScheduledInterview, slug=slug)
interview_slug = schedule_interview.zoom_meeting.slug
# Get the slug from the related InterviewLocation (the "meeting")
meeting_slug = schedule_interview.interview_location.slug # ✅ Correct
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
form.save() # No need for commit=False — it's not a create, just update
messages.success(request, "Participants updated successfully.")
return redirect("meeting_details", slug=meeting_slug)
else:
form = InterviewParticpantsForm(instance=schedule_interview)
return render(
request, "interviews/interview_participants_form.html", {"form": form}
request,
"interviews/interview_participants_form.html",
{"form": form, "interview": schedule_interview}
)
@ -5751,7 +5683,7 @@ class MeetingListView(ListView):
'details': details,
'type': location.location_type,
'topic': location.topic,
'slug': interview.slug,
# 'slug': interview.slug,
'start_time': start_datetime, # Combined datetime object
# Duration should ideally be on ScheduledInterview or fetched from details
'duration': getattr(details, 'duration', 'N/A'),
@ -5925,3 +5857,62 @@ def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk):
return render(request, "meetings/schedule_onsite_meeting_form.html", context)
from django.http import Http404
def meeting_details(request, slug):
# Fetch the meeting (InterviewLocation or subclass) by slug
meeting = get_object_or_404(
InterviewLocation.objects.select_related(
'scheduled_interview__application__person',
'scheduled_interview__job',
'zoommeetingdetails',
'onsitelocationdetails',
).prefetch_related(
'scheduled_interview__participants',
'scheduled_interview__system_users',
'scheduled_interview__notes',
),
slug=slug
)
try:
interview = meeting.scheduled_interview
except ScheduledInterview.DoesNotExist:
raise Http404("No interview is associated with this meeting.")
candidate = interview.application
job = interview.job
external_participants = interview.participants.all()
system_participants = interview.system_users.all()
total_participants = external_participants.count() + system_participants.count()
# Forms for modals
participant_form = InterviewParticpantsForm(instance=interview)
# email_form = InterviewEmailForm(
# candidate=candidate,
# external_participants=external_participants, # QuerySet of Participants
# system_participants=system_participants, # QuerySet of Users
# meeting=meeting, # ← This is InterviewLocation (e.g., ZoomMeetingDetails)
# job=job,
# )
context = {
'meeting': meeting,
'interview': interview,
'candidate': candidate,
'job': job,
'external_participants': external_participants,
'system_participants': system_participants,
'total_participants': total_participants,
'form': participant_form,
# 'email_form': email_form,
}
return render(request, 'interviews/detail_interview.html', context)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@ -323,7 +323,7 @@
<a class="nav-link text-secondary" href="{% url 'applicant_profile' %}">{% translate "Applications" %}</a>
</li> {% endcomment %}
<li class="nav-item">
<a class="nav-link text-secondary" href="{% url 'applicant_profile' %}">{% translate "Profile" %}</a>
<a class="nav-link text-secondary" href="{% url 'candidate_portal_dashboard' %}">{% translate "Profile" %}</a>
</li>
<li class="nav-item">
<a class="nav-link text-secondary" href="{% url 'kaauh_career' %}">{% translate "Careers" %}</a>

View File

@ -1,12 +1,9 @@
{% extends 'base.html' %}
{% load static i18n %}
{% load widget_tweaks %}
{% block customCSS %}
<style>
/* -------------------------------------------------------------------------- */
/* KAAT-S Redesign CSS - Compacted and Reordered Layout */
/* -------------------------------------------------------------------------- */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
@ -20,286 +17,189 @@
--kaauh-link: #007bff;
--kaauh-link-hover: #0056b3;
}
body {
background-color: #f0f2f5;
font-family: 'Inter', sans-serif;
}
/* ------------------ Card & Header Styles ------------------ */
.card {
border: none;
border-radius: 8px; /* Slightly smaller radius */
box-shadow: 0 3px 10px rgba(0,0,0,0.04); /* Lighter shadow */
margin-bottom: 1rem;
}
.card-body {
padding: 1rem 1.25rem; /* Reduced padding */
}
body { background-color: #f0f2f5; font-family: 'Inter', sans-serif; }
.card { border: none; border-radius: 8px; box-shadow: 0 3px 10px rgba(0,0,0,0.04); margin-bottom: 1rem; }
.card-body { padding: 1rem 1.25rem; }
#comments-card .card-header {
background-color: white;
color: var(--kaauh-teal-dark);
padding: 0.75rem 1.25rem; /* Reduced header padding */
padding: 0.75rem 1.25rem;
font-weight: 600;
border-radius: 8px 8px 0 0;
border-bottom: 1px solid var(--kaauh-border);
}
/* ------------------ Main Title & Status ------------------ */
.main-title-container {
padding: 0 0 1rem 0; /* Space below the main title */
}
.main-title-container h1 {
font-size: 1.75rem; /* Reduced size */
font-weight: 700;
font-size: 1.75rem; font-weight: 700;
}
.status-badge {
font-size: 0.7rem; /* Smaller badge */
padding: 0.3em 0.7em;
border-radius: 12px;
font-size: 0.7rem; padding: 0.3em 0.7em; border-radius: 12px;
}
.bg-scheduled { background-color: #00636e !important; color: white !important;}
.bg-completed { background-color: #198754 !important; color: white !important;}
.bg-waiting { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;}
.bg-started { background-color: var(--kaauh-teal) !important; color: white !important;}
.bg-ended { background-color: var(--kaauh-danger) !important; color: white !important;}
/* ------------------ Detail Row & Content Styles (Made Smaller) ------------------ */
.bg-scheduled { background-color: #00636e !important; color: white !important; }
.bg-completed { background-color: #198754 !important; color: white !important; }
.bg-waiting { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important; }
.bg-started { background-color: var(--kaauh-teal) !important; color: white !important; }
.bg-ended { background-color: var(--kaauh-danger) !important; color: white !important; }
.bg-cancelled { background-color: #6c757d !important; color: white !important; }
.detail-section h2, .card h2 {
color: var(--kaauh-teal-dark);
font-weight: 700;
font-size: 1.25rem; /* Reduced size */
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--kaauh-border);
color: var(--kaauh-teal-dark); font-weight: 700; font-size: 1.25rem;
margin-bottom: 0.75rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--kaauh-border);
}
.detail-row-simple {
display: flex;
padding: 0.4rem 0; /* Reduced vertical padding */
border-bottom: 1px dashed var(--kaauh-border);
font-size: 0.85rem; /* Smaller text */
display: flex; padding: 0.4rem 0; border-bottom: 1px dashed var(--kaauh-border); font-size: 0.85rem;
}
.detail-label-simple {
font-weight: 600;
color: var(--kaauh-teal-dark);
flex-basis: 40%;
}
.detail-value-simple {
color: var(--kaauh-primary-text);
font-weight: 500;
flex-basis: 60%;
}
/* ------------------ Join Info & Copy Button ------------------ */
.detail-label-simple { font-weight: 600; color: var(--kaauh-teal-dark); flex-basis: 40%; }
.detail-value-simple { color: var(--kaauh-primary-text); font-weight: 500; flex-basis: 60%; }
.btn-primary-teal {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
padding: 0.6rem 1.2rem;
font-size: 0.95rem; /* Slightly smaller button */
border-radius: 6px;
color: white; /* Ensure text color is white for teal primary */
background-color: var(--kaauh-teal); border-color: var(--kaauh-teal); padding: 0.6rem 1.2rem;
font-size: 0.95rem; border-radius: 6px; color: white;
}
.btn-primary-teal:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
}
/* Added Danger Button Style for main delete */
.btn-primary-teal:hover { background-color: var(--kaauh-teal-dark); }
.btn-danger-red {
background-color: var(--kaauh-danger);
border-color: var(--kaauh-danger);
color: white;
padding: 0.6rem 1.2rem;
font-size: 0.95rem;
border-radius: 6px;
font-weight: 600;
}
.btn-danger-red:hover {
background-color: #c82333;
border-color: #bd2130;
background-color: var(--kaauh-danger); border-color: var(--kaauh-danger); color: white;
padding: 0.6rem 1.2rem; font-size: 0.95rem; border-radius: 6px; font-weight: 600;
}
.btn-danger-red:hover { background-color: #c82333; border-color: #bd2130; }
.btn-secondary-back {
/* Subtle Back Button */
background-color: transparent;
border: none;
color: var(--kaauh-secondary-text);
font-weight: 600;
font-size: 1rem;
padding: 0.5rem 0.75rem;
transition: color 0.2s;
background-color: transparent; border: none; color: var(--kaauh-secondary-text);
font-weight: 600; font-size: 1rem; padding: 0.5rem 0.75rem; transition: color 0.2s;
}
.btn-secondary-back:hover {
color: var(--kaauh-teal);
text-decoration: underline;
}
.btn-secondary-back:hover { color: var(--kaauh-teal); text-decoration: underline; }
.join-url-display {
background-color: white;
border: 1px solid var(--kaauh-border);
padding: 0.5rem; /* Reduced padding */
font-size: 0.85rem; /* Smaller text */
background-color: white; border: 1px solid var(--kaauh-border); padding: 0.5rem; font-size: 0.85rem;
}
.btn-copy-simple {
padding: 0.5rem 0.75rem;
background-color: var(--kaauh-teal-dark);
border: none;
color: white;
border-radius: 4px;
padding: 0.5rem 0.75rem; background-color: var(--kaauh-teal-dark); border: none; color: white; border-radius: 4px;
}
.btn-copy-simple:hover {
background-color: var(--kaauh-teal);
}
/* ------------------ Simple Table Styles ------------------ */
.btn-copy-simple:hover { background-color: var(--kaauh-teal); }
.simple-table {
width: 100%;
margin-top: 0.5rem;
border-collapse: collapse;
width: 100%; margin-top: 0.5rem; border-collapse: collapse;
}
.simple-table th {
background-color: var(--kaauh-teal-light);
color: var(--kaauh-teal-dark);
font-weight: 700;
padding: 8px 12px; /* Reduced padding */
border: 1px solid var(--kaauh-border);
font-size: 0.8rem; /* Smaller table header text */
background-color: var(--kaauh-teal-light); color: var(--kaauh-teal-dark); font-weight: 700;
padding: 8px 12px; border: 1px solid var(--kaauh-border); font-size: 0.8rem;
}
.simple-table td {
padding: 8px 12px; /* Reduced padding */
border: 1px solid var(--kaauh-border);
background-color: white;
font-size: 0.85rem; /* Smaller table body text */
padding: 8px 12px; border: 1px solid var(--kaauh-border); background-color: white; font-size: 0.85rem;
}
/* ------------------ Comment Specific Styles ------------------ */
.comment-item {
border: 1px solid var(--kaauh-border);
background-color: var(--kaauh-gray-light);
border-radius: 6px;
}
/* Style for in-page edit button */
.comment-item { border: 1px solid var(--kaauh-border); background-color: var(--kaauh-gray-light); border-radius: 6px; }
.btn-edit-comment {
background-color: transparent;
border: 1px solid var(--kaauh-teal);
color: var(--kaauh-teal);
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 4px;
font-weight: 500;
background-color: transparent; border: 1px solid var(--kaauh-teal); color: var(--kaauh-teal);
padding: 0.25rem 0.5rem; font-size: 0.75rem; border-radius: 4px; font-weight: 500;
}
.btn-edit-comment:hover {
background-color: var(--kaauh-teal-light);
}
.btn-edit-comment:hover { background-color: var(--kaauh-teal-light); }
</style>
{% endblock %}
{% block content %}
{% comment %}
NOTE: The variable 'meeting' has been renamed to 'interview' (ScheduledInterview)
NOTE: The variable 'meeting.slug' has been renamed to 'interview.slug'
NOTE: All 'meeting' URL names (update_meeting, delete_meeting, etc.) have been renamed
{% endcomment %}
<div class="container-fluid py-4">
{# --- TOP BAR / BACK BUTTON & ACTIONS (EDIT/DELETE) --- #}
{# --- TOP BAR --- #}
<div class="d-flex justify-content-between align-items-center mb-4">
{# Back Button #}
<a href="{% url 'interview_list' %}" class="btn btn-secondary-back">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Interviews" %}
<a href="{% url 'list_meetings' %}" class="btn btn-secondary-back">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Meetings" %}
</a>
{# Edit and Delete Buttons #}
<div class="d-flex gap-2">
<a href="{% url 'update_scheduled_interview' interview.slug %}" class="btn btn-primary-teal btn-sm">
<i class="fas fa-edit me-1"></i> {% trans "Edit Interview" %}
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-primary-teal btn-sm">
<i class="fas fa-edit me-1"></i> {% trans "Edit Meeting" %}
</a>
{# DELETE MEETING FORM #}
<form method="post" action="{% url 'delete_scheduled_interview' interview.slug %}" style="display: inline;">
<form method="post" action="{% url 'delete_meeting' meeting.slug %}" style="display: inline;">
{% csrf_token %}
<button type="submit" class="btn btn-danger-red btn-sm" onclick="return confirm('{% trans "Are you sure you want to delete this interview? This action is permanent." %}')">
<i class="fas fa-trash-alt me-1"></i> {% trans "Delete Interview" %}
<button type="submit" class="btn btn-danger-red btn-sm" onclick="return confirm('{% trans "Are you sure you want to delete this meeting? This action is permanent." %}')">
<i class="fas fa-trash-alt me-1"></i> {% trans "Delete Meeting" %}
</button>
</form>
</div>
</div>
{# ========================================================= #}
{# --- MAIN TITLE AT TOP --- #}
{# ========================================================= #}
{% with zoom_details=interview.zoom_details.0 %}
{# --- MAIN TITLE --- #}
<div class="main-title-container mb-4">
<h1 class="text-start" style="color: var(--kaauh-teal-dark);">
{% if interview.schedule.interview_type == 'Remote' %}
<i class="fas fa-video me-2" style="color: var(--kaauh-teal);"></i>
{{ zoom_details.topic|default:"[Remote Interview]" }}
{% else %}
<i class="fas fa-building me-2" style="color: var(--kaauh-teal);"></i>
{{ interview.schedule.location|default:"[Onsite Interview]" }}
{% endif %}
<span class="status-badge bg-{{ interview.status|lower|default:'bg-secondary' }} ms-3">
{{ interview.status|title|default:'N/A' }} ({{ interview.schedule.interview_type }})
<i class="fas fa-video me-2" style="color: var(--kaauh-teal);"></i>
{{ meeting.topic|default:"[Meeting Topic]" }}
<span class="status-badge bg-{{ interview.status|lower|default:'scheduled' }} ms-3">
{{ interview.get_status_display|default:"Scheduled" }}
</span>
</h1>
</div>
{# ========================================================= #}
{# --- SECTION 1: INTERVIEW & CONNECTION/LOCATION CARDS SIDE BY SIDE --- #}
{# ========================================================= #}
{# --- INTERVIEW & CONNECTION CARDS --- #}
<div class="row g-4 mb-5 align-items-stretch">
{# --- LEFT HALF: INTERVIEW DETAIL CARD --- #}
{# Interview Detail #}
<div class="col-lg-6">
<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 "Candidate & Job" %}</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">
{# NOTE: Assuming ScheduledInterview has direct relations to candidate and job #}
<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' interview.job.slug %}">{{ interview.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' interview.candidate.slug %}">{{ interview.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' interview.candidate.slug %}">{{ interview.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">{{ interview.job.job_type|default:"N/A" }}</div></div>
{% if interview.candidate.belong_to_agency %}
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Agency" %}:</div><div class="detail-value-simple"><a href="">{{ interview.candidate.hiring_agency.name|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">{{ 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">{{ candidate.full_name|default:"N/A" }}</div>
</div>
<div class="detail-row-simple">
<div class="detail-label-simple">{% trans "Candidate Email" %}:</div>
<div class="detail-value-simple">{{ candidate.email|default:"N/A" }}</div>
</div>
<div class="detail-row-simple">
<div class="detail-label-simple">{% trans "Job Type" %}:</div>
<div class="detail-value-simple">{{ job.job_type|default:"N/A" }}</div>
</div>
{% if candidate.belong_to_agency %}
<div class="detail-row-simple">
<div class="detail-label-simple">{% trans "Agency" %}:</div>
<div class="detail-value-simple">{{ candidate.hiring_agency.name|default:"N/A" }}</div>
</div>
{% endif %}
</div>
</div>
</div>
{# --- RIGHT HALF: CONNECTION/LOCATION DETAILS CARD --- #}
{# Connection Details #}
<div class="col-lg-6">
<div class="p-3 bg-white rounded shadow-sm h-100 d-flex flex-column">
<h2 class="text-start"><i class="fas fa-map-marker-alt me-2"></i> {% trans "Time & Location" %}</h2>
<h2 class="text-start"><i class="fas fa-info-circle me-2"></i> {% trans "Connection Details" %}</h2>
<div class="detail-row-group flex-grow-1">
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Date & Time" %}:</div><div class="detail-value-simple">{{ interview.interview_date|date:"M d, Y"|default:"N/A" }} @ {{ interview.interview_time|time:"H:i"|default:"N/A" }}</div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Duration" %}:</div><div class="detail-value-simple">{{ interview.schedule.interview_duration|default:"N/A" }} {% trans "minutes" %}</div></div>
{% if interview.schedule.interview_type == 'Onsite' %}
{# --- Onsite Details --- #}
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Location" %}:</div><div class="detail-value-simple">{{ interview.schedule.location|default:"TBD" }}</div></div>
<div class="detail-row-simple">
<div class="detail-label-simple">{% trans "Date & Time" %}:</div>
<div class="detail-value-simple">
{{ interview.interview_date }} {{ interview.interview_time }} ({{ meeting.timezone }})
</div>
</div>
<div class="detail-row-simple">
<div class="detail-label-simple">{% trans "Duration" %}:</div>
<div class="detail-value-simple">
{% if meeting.location_type == "Remote" %}
{{ meeting.zoommeetingdetails.duration|default:"N/A" }}
{% elif meeting.location_type == "Onsite" %}
{{ meeting.onsitelocationdetails.duration|default:"N/A" }}
{% else %}
N/A
{% endif %}
{% trans "minutes" %}
</div>
</div>
{% elif interview.schedule.interview_type == 'Remote' and zoom_details %}
{# --- Remote/Zoom Details --- #}
<h3 class="mt-3" style="font-size: 1.05rem; color: var(--kaauh-teal); font-weight: 600;">{% trans "Remote Details" %}</h3>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Meeting ID" %}:</div><div class="detail-value-simple">{{ zoom_details.meeting_id|default:"N/A" }}</div></div>
<div class="detail-row-simple"><div class="detail-label-simple">{% trans "Host Email" %}:</div><div class="detail-value-simple">{{ zoom_details.host_email|default:"N/A" }}</div></div>
{% if zoom_details.join_url %}
<div class="join-url-container pt-3">
<div id="copy-message" class="text-white rounded px-2 py-1 small fw-bold mb-2 text-center" style="opacity: 0; transition: opacity 0.3s; position: absolute; right: 0; top: 5px; background-color: var(--kaauh-success); z-index: 10;">{% trans "Copied!" %}</div>
<div class="join-url-display d-flex justify-content-between align-items-center position-relative">
{% if meeting.location_type == "Remote" %}
{% with zoom=meeting.zoommeetingdetails %}
<div class="detail-row-simple">
<div class="detail-label-simple">{% trans "Meeting ID" %}:</div>
<div class="detail-value-simple">{{ zoom.meeting_id|default:"N/A" }}</div>
</div>
<div class="detail-row-simple">
<div class="detail-label-simple">{% trans "Host Email" %}:</div>
<div class="detail-value-simple">{{ zoom.host_email|default:"N/A" }}</div>
</div>
{% if meeting.details_url %}
<div class="join-url-container pt-3" style="position: relative;">
<div id="copy-message" class="text-white rounded px-2 py-1 small fw-bold mb-2 text-center" style="opacity: 0; transition: opacity 0.3s; position: absolute; right: 0; top: -35px; background-color: var(--kaauh-success); z-index: 10;">
{% trans "Copied!" %}
</div>
<div class="join-url-display d-flex justify-content-between align-items-center">
<div class="text-truncate me-2">
<strong>{% trans "Join URL" %}:</strong>
<span id="meeting-join-url">{{ zoom_details.join_url }}</span>
<span id="meeting-join-url">{{ meeting.details_url }}</span>
</div>
<button class="btn-copy-simple ms-2 flex-shrink-0" onclick="copyLink()" title="{% trans 'Copy URL' %}">
<i class="fas fa-copy"></i>
@ -307,310 +207,230 @@ body {
</div>
</div>
{% endif %}
{% else %}
<p class="text-muted">{% trans "Location/Connection details are not available for this interview type." %}</p>
{% endwith %}
{% elif meeting.location_type == "Onsite" %}
{% with onsite=meeting.onsitelocationdetails %}
<div class="detail-row-simple">
<div class="detail-label-simple">{% trans "Address" %}:</div>
<div class="detail-value-simple">{{ onsite.physical_address|default:"N/A" }}</div>
</div>
<div class="detail-row-simple">
<div class="detail-label-simple">{% trans "Room" %}:</div>
<div class="detail-value-simple">{{ onsite.room_number|default:"TBD" }}</div>
</div>
{% endwith %}
{% endif %}
</div>
</div>
</div>
</div>
{% endwith %}
{# ========================================================= #}
{# --- SECTION 2: PERSONNEL TABLES --- #}
{# ========================================================= #}
{# --- PARTICIPANTS --- #}
<div class="row g-4 mt-1 mb-5">
{# --- PARTICIPANTS TABLE --- #}
<div class="col-lg-12">
<div class="p-3 bg-white rounded shadow-sm">
<div class="d-flex justify-content-between align-item-center" >
<div class="d-flex justify-content-between align-items-center">
<h2 class="text-start"><i class="fas fa-users-cog me-2"></i> {% trans "Assigned Participants" %}</h2>
<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" %} ({{ interview.participants.count|add:interview.system_users.count }})
</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 class="d-flex gap-2">
<button type="button" class="btn btn-primary-teal btn-sm"
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" data-bs-target="#emailModal">
<i class="fas fa-envelope"></i>
</button>
</div>
</div>
</div>
<table class="simple-table">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Role/Designation" %}</th>
<th>{% trans "Role" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Phone Number" %}</th>
<th>{% trans "Source Type" %}</th>
<th>{% trans "Phone" %}</th>
<th>{% trans "Type" %}</th>
</tr>
</thead>
<tbody>
{# External Participants #}
{% for participant in interview.participants.all %}
{% for participant in external_participants %}
<tr>
<td>{{participant.name}}</td>
<td>{{participant.designation}}</td>
<td>{{participant.email}}</td>
<td>{{participant.phone}}</td>
<td>{% trans "External Participants" %}</td>
<td>{{ participant.name }}</td>
<td>{{ participant.designation|default:"Participant" }}</td>
<td>{{ participant.email|default:"N/A" }}</td>
<td>{{ participant.phone|default:"N/A" }}</td>
<td>{% trans "External" %}</td>
</tr>
{% endfor %}
{# System Users (Internal Participants) #}
{% for user in interview.system_users.all %}
{% for user in system_participants %}
<tr>
<td>{{user.get_full_name}}</td>
<td>{% trans "System User" %}</td>
<td>{{user.email}}</td>
<td>{{user.phone}}</td>
<td>{{ user.get_full_name|default:user.username }}</td>
<td>Admin</td>
<td>{{ user.email|default:"N/A" }}</td>
<td>{{ user.phone|default:"N/A" }}</td>
<td>{% trans "System User" %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# ========================================================= #}
{# --- SECTION 3: COMMENTS (CORRECTED) --- #}
{# ========================================================= #}
{# --- COMMENTS --- #}
<div class="row g-4 mt-1">
<div class="col-lg-12">
<div class="card" id="comments-card" style="height: 100%;">
<div class="card" id="comments-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0" style="color: var(--kaauh-teal-dark);">
<h5 class="card-title mb-0">
<i class="fas fa-comments me-2"></i>
{% trans "Comments" %} ({% if interview.comments %}{{ interview.comments.count }}{% else %}0{% endif %})
{% trans "Comments" %} ({{ interview.notes.count }})
</h5>
</div>
<div class="card-body overflow-auto">
{# 1. COMMENT DISPLAY & IN-PAGE EDIT FORMS #}
<div class="card-body">
<div id="comment-section" class="mb-4">
{# NOTE: Assuming comment model has a ForeignKey to ScheduledInterview called 'interview' #}
{% if interview.comments.all %}
{% for comment in interview.comments.all|dictsortreversed:"created_at" %}
<div class="comment-item mb-3 p-3">
{# Read-Only Comment View #}
<div id="comment-view-{{ comment.pk }}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="comment-metadata" style="font-size: 0.9rem;">
<strong>{{ comment.author.get_full_name|default:comment.author.username }}</strong>
<span class="text-muted small ms-2">{{ comment.created_at|date:"M d, Y H:i" }}</span>
</div>
{% if comment.author == user or user.is_staff %}
<div class="comment-actions d-flex align-items-center gap-1">
{# Edit Button: Toggles the hidden form #}
<button type="button" class="btn btn-edit-comment py-0 px-1" onclick="toggleCommentEdit('{{ comment.pk }}')" id="edit-btn-{{ comment.pk }}" title="{% trans 'Edit Comment' %}">
<i class="fas fa-edit"></i>
</button>
{# Delete Form: Submits a POST request #}
<form method="post" action="{% url 'delete_meeting_comment' interview.slug comment.pk %}" style="display: inline;" id="delete-form-{{ comment.pk }}">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger py-0 px-1" title="{% trans 'Delete Comment' %}" onclick="return confirm('{% trans "Are you sure you want to delete this comment?" %}')">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
{% endif %}
</div>
<p class="mb-0 comment-content" style="font-size: 0.85rem; white-space: pre-wrap;">{{ comment.content|linebreaksbr }}</p>
{% for note in interview.notes.all|dictsortreversed:"created_at" %}
<div class="comment-item mb-3 p-3">
<div id="comment-view-{{ note.pk }}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="comment-metadata" style="font-size: 0.9rem;">
<strong>{{ note.author.get_full_name|default:note.author.username }}</strong>
<span class="text-muted small ms-2">{{ note.created_at|date:"M d, Y H:i" }}</span>
</div>
{# Hidden Edit Form #}
<div id="comment-edit-form-{{ comment.pk }}" style="display: none; margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--kaauh-border);">
<form method="POST" action="{% url 'edit_meeting_comment' interview.slug comment.pk %}" id="form-{{ comment.pk }}">
{% if note.author == user or user.is_staff %}
<div class="comment-actions d-flex align-items-center gap-1">
<button type="button" class="btn btn-edit-comment py-0 px-1" onclick="toggleCommentEdit('{{ note.pk }}')" id="edit-btn-{{ note.pk }}">
<i class="fas fa-edit"></i>
</button>
<form method="post" action="{% url 'delete_meeting_comment' meeting.slug note.pk %}" style="display: inline;">
{% csrf_token %}
<div class="mb-2">
<label for="id_content_{{ comment.pk }}" class="form-label small">{% trans "Edit Comment" %}</label>
{# NOTE: The textarea name must match your Comment model field (usually 'content') #}
<textarea name="content" id="id_content_{{ comment.pk }}" rows="3" class="form-control" required>{{ comment.content }}</textarea>
</div>
<button type="submit" class="btn btn-sm btn-success me-2">
<i class="fas fa-save me-1"></i> {% trans "Save Changes" %}
</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleCommentEdit('{{ comment.pk }}')">
{% trans "Cancel" %}
<button type="submit" class="btn btn-outline-danger py-0 px-1" onclick="return confirm('{% trans "Are you sure you want to delete this comment?" %}')">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<p class="text-muted">{% trans "No comments yet. Be the first to comment!" %}</p>
{% endif %}
<p class="mb-0" style="font-size: 0.85rem; white-space: pre-wrap;">{{ note.content|linebreaksbr }}</p>
</div>
<div id="comment-edit-form-{{ note.pk }}" style="display: none; margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--kaauh-border);">
<form method="POST" action="{% url 'edit_meeting_comment' meeting.slug note.pk %}">
{% csrf_token %}
<div class="mb-2">
<label class="form-label small">{% trans "Edit Comment" %}</label>
<textarea name="content" class="form-control" rows="3" required>{{ note.content }}</textarea>
</div>
<button type="submit" class="btn btn-success btn-sm me-2">
<i class="fas fa-save me-1"></i> {% trans "Save Changes" %}
</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleCommentEdit('{{ note.pk }}')">
{% trans "Cancel" %}
</button>
</form>
</div>
</div>
{% empty %}
<p class="text-muted">{% trans "No comments yet. Be the first to comment!" %}</p>
{% endfor %}
</div>
<hr>
{# 2. NEW COMMENT SUBMISSION (Remains the same) #}
<h6 class="mb-3" style="color: var(--kaauh-teal-dark);">{% trans "Add a New Comment" %}</h6>
{% if user.is_authenticated %}
<form method="POST" action="{% url 'add_meeting_comment' interview.slug %}">
{% csrf_token %}
{% if comment_form %}
{{ comment_form.as_p }}
{% else %}
<div class="mb-3">
<label for="id_content" class="form-label small">{% trans "Comment" %}</label>
<textarea name="content" id="id_content" rows="3" class="form-control" required></textarea>
</div>
{% endif %}
<button type="submit" class="btn btn-primary-teal btn-sm mt-2">
<i class="fas fa-paper-plane me-1"></i> {% trans "Submit Comment" %}
</button>
</form>
{% else %}
<p class="text-muted small">{% trans "You must be logged in to add a comment." %}</p>
{% endif %}
<h6 class="mb-3">{% trans "Add a New Comment" %}</h6>
<form method="POST" action="{% url 'add_meeting_comment' meeting.slug %}">
{% csrf_token %}
<div class="mb-3">
<label class="form-label small">{% trans "Comment" %}</label>
<textarea name="content" class="form-control" rows="3" required></textarea>
</div>
<button type="submit" class="btn btn-primary-teal btn-sm">
<i class="fas fa-paper-plane me-1"></i> {% trans "Submit Comment" %}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{# --- MODALS (Updated to use interview.slug) --- #}
<div class="modal fade" id="assignParticipants" tabindex="-1" aria-labelledby="assignParticipantsLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
{# MODALS #}
<!-- Participants Modal -->
<div class="modal fade" id="assignParticipants" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="jobAssignmentLabel">{% trans "Manage all participants" %}</h5>
<h5 class="modal-title">{% 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' interview.slug %}">
{% csrf_token %}
<div class="modal-body table-responsive">
{{ interview.name }} {# This might need checking - ScheduledInterview usually doesn't have a 'name' field #}
<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>
{% csrf_token %}
<div class="modal-body">
{{ form.participants.errors }}
{{ form.participants }}
{{ form.system_users.errors }}
{{ form.system_users }}
</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">{% trans "Save" %}</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="emailModal" tabindex="-1" aria-labelledby="emailModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<!-- Email Modal -->
<div class="modal fade" id="emailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header bg-light">
<h5 class="modal-title" id="emailModalLabel">📧 {% trans "Compose Interview Invitation" %}</h5>
<h5 class="modal-title">📧 {% 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' interview.slug %}">
<form method="post" action="{% url 'send_interview_email' interview.pk %}">
{% 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" }}
<label class="form-label fw-bold">{% trans "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 interview.candidate.belong_to_an_agency %}
<button class="nav-link active" id="candidate-tab" data-bs-toggle="tab" data-bs-target="#candidate-pane" type="button">
{% if candidate.belong_to_agency %}
{% trans "Agency Message" %}
{% else %}
{% trans "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">
{% trans "Panel Message (Interviewers)" %}
<button class="nav-link" id="participants-tab" data-bs-toggle="tab" data-bs-target="#participants-pane" type="button">
{% trans "Panel Message" %}
</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">{% trans "This email will be sent to the candidate or their hiring agency." %}</p>
{% if not interview.candidate.belong_to_an_agency %}
<div class="form-group">
<label for="{{ email_form.message_for_candidate.id_for_label }}" class="form-label d-none">{% trans "Candidate Message" %}</label>
{{ email_form.message_for_candidate | add_class:"form-control" }}
</div>
{% endif %}
{% if interview.candidate.belong_to_an_agency %}
<div class="form-group">
<label for="{{ email_form.message_for_agency.id_for_label }}" class="form-label d-none">{% trans "Agency Message" %}</label>
{{ email_form.message_for_agency | add_class:"form-control" }}
</div>
<div class="tab-pane fade show active" id="candidate-pane">
<p class="text-muted small">
{% if candidate.belong_to_agency %}
{% trans "This email will be sent to the hiring agency." %}
{% else %}
{% trans "This email will be sent to the candidate." %}
{% endif %}
</p>
{% if candidate.belong_to_agency %}
{{ email_form.message_for_agency|add_class:"form-control" }}
{% else %}
{{ email_form.message_for_candidate|add_class:"form-control" }}
{% endif %}
</div>
{# --- Participants Pane --- #}
<div class="tab-pane fade" id="participants-pane" role="tabpanel" aria-labelledby="participants-tab">
<p class="text-muted small">{% trans "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">{% trans "Participants Message" %}</label>
{{ email_form.message_for_participants | add_class:"form-control" }}
</div>
<div class="tab-pane fade" id="participants-pane">
<p class="text-muted small">{% trans "This email will be sent to all interview participants." %}</p>
{{ email_form.message_for_participants|add_class:"form-control" }}
</div>
</div>
</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">{% trans "Send Invitation" %}</button>
@ -619,90 +439,36 @@ body {
</div>
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
// --- COMMENT EDITING FUNCTION ---
function toggleCommentEdit(commentPk) {
const viewDiv = document.getElementById(`comment-view-${commentPk}`);
const editFormDiv = document.getElementById(`comment-edit-form-${commentPk}`);
const editButton = document.getElementById(`edit-btn-${commentPk}`);
const deleteForm = document.getElementById(`delete-form-${commentPk}`);
if (viewDiv.style.display !== 'none') {
// Switch to Edit Mode
viewDiv.style.display = 'none';
editFormDiv.style.display = 'block';
if (editButton) editButton.style.display = 'none'; // Hide edit button
if (deleteForm) deleteForm.style.display = 'none'; // Hide delete button
} else {
// Switch back to View Mode (Cancel)
viewDiv.style.display = 'block';
editFormDiv.style.display = 'none';
if (editButton) editButton.style.display = 'inline-block'; // Show edit button
if (deleteForm) deleteForm.style.display = 'inline'; // Show delete button
}
function toggleCommentEdit(commentPk) {
const viewDiv = document.getElementById(`comment-view-${commentPk}`);
const editFormDiv = document.getElementById(`comment-edit-form-${commentPk}`);
if (viewDiv.style.display === 'none') {
viewDiv.style.display = 'block';
editFormDiv.style.display = 'none';
} else {
viewDiv.style.display = 'none';
editFormDiv.style.display = 'block';
}
}
// --- COPY LINK FUNCTION ---
// CopyLink function implementation (slightly improved for message placement)
function copyLink() {
const urlElement = document.getElementById('meeting-join-url');
const displayContainer = urlElement.closest('.join-url-display');
const messageElement = document.getElementById('copy-message');
const textToCopy = urlElement.textContent || urlElement.innerText;
function copyLink() {
const urlElement = document.getElementById('meeting-join-url');
const textToCopy = urlElement.textContent || urlElement.innerText;
const messageElement = document.getElementById('copy-message');
clearTimeout(window.copyMessageTimeout);
function showMessage(success) {
messageElement.textContent = success ? '{% trans "Copied!" %}' : '{% trans "Copy Failed." %}';
messageElement.style.backgroundColor = success ? 'var(--kaauh-success)' : 'var(--kaauh-danger)';
messageElement.style.opacity = '1';
// Position the message relative to the display container
const rect = displayContainer.getBoundingClientRect();
// Note: This positioning logic relies on the .join-url-container being position:relative or position:absolute
messageElement.style.left = (rect.width / 2) - (messageElement.offsetWidth / 2) + 'px';
messageElement.style.top = '-35px';
window.copyMessageTimeout = setTimeout(() => {
messageElement.style.opacity = '0';
}, 2000);
}
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(textToCopy).then(() => {
showMessage(true);
}).catch(err => {
console.error('Could not copy text: ', err);
fallbackCopyTextToClipboard(textToCopy, showMessage);
});
} else {
fallbackCopyTextToClipboard(textToCopy, showMessage);
}
}
function fallbackCopyTextToClipboard(text, callback) {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
let success = false;
try {
success = document.execCommand('copy');
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
}
document.body.removeChild(textArea);
callback(success);
}
navigator.clipboard.writeText(textToCopy).then(() => {
messageElement.style.opacity = '1';
setTimeout(() => {
messageElement.style.opacity = '0';
}, 2000);
}).catch(err => {
console.error('Failed to copy: ', err);
});
}
</script>
{% endblock %}

View File

@ -251,7 +251,7 @@
</a>
<button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}"
data-bs-toggle="modal" data-bs-target="#deleteModal"
hx-post="{% url 'delete_meeting' meeting.slug %}"
hx-post=""
hx-target="#deleteModalBody"
hx-swap="outerHTML"
data-item-name="{{ meeting.topic }}">
@ -310,8 +310,8 @@
<i class="fas fa-sign-in-alt"></i>
</a>
{% endif %}
<a href="" class="btn btn-outline-primary" title="{% trans 'View' %}">
<i class="fas fa-eye"></i>
<a href="{% url 'meeting_details' meetings.first.interview_location.slug%}" class="btn btn-outline-primary" title="{% trans 'View' %}">
<i class="fas fa-eye"></i>{{meetings.first.interview_location.slug}}
</a>
{# CORRECTED: Passing the slug to the update URL #}
<a href="" class="btn btn-outline-secondary" title="{% trans 'Update' %}">
@ -320,7 +320,7 @@
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
data-bs-toggle="modal"
data-bs-target="#deleteModal"
hx-post="{% url 'delete_meeting' meeting.slug %}"
hx-post=""
hx-target="#deleteModalBody"
hx-swap="outerHTML"
data-item-name="{{ meeting.topic }}">

View File

@ -155,7 +155,7 @@
<i class="fas fa-user-friends me-2"></i> {% trans "Applicants List" %}
</h1>
<a href="{% url 'person_create' %}" class="btn btn-main-action">
<i class="fas fa-plus me-1"></i> {% trans "Add New" %}
<i class="fas fa-plus me-1"></i> {% trans "Add New Applicant" %}
</a>
</div>
@ -163,18 +163,14 @@
<div class="card mb-4 shadow-sm no-hover">
<div class="card-body">
<div class="row g-4">
<div class="col-md-6">
<label for="search" class="form-label small text-muted">{% trans "Search by Name or Email" %}</label>
<form method="get" action="" class="w-100">
<div class="input-group input-group-lg">
<input type="text" name="q" class="form-control" id="search"
placeholder="{% trans 'Search applicant...' %}"
value="{{ request.GET.q }}">
<button class="btn btn-main-action" type="submit">
<i class="fas fa-search"></i>
</button>
</div>
</form>
<div class="input-group input-group-lg">
<form method="get" action="" class="w-100">
{% include 'includes/search_form.html' %}
</form>
</div>
</div>
<div class="col-md-6">
@ -200,10 +196,10 @@
</select>
</div>
<div class="col-md-4 d-flex justify-content-end align-self-end">
<div class="col-md-4 d-flex">
<div class="filter-buttons">
<button type="submit" class="btn btn-main-action btn-sm">
<i class="fas fa-filter me-1"></i> {% trans "Apply" %}
<i class="fas fa-filter me-1"></i> {% trans "Apply Filter" %}
</button>
{% if request.GET.q or request.GET.nationality or request.GET.gender %}
<a href="{% url 'person_list' %}" class="btn btn-outline-secondary btn-sm">
@ -217,7 +213,8 @@
</div>
</div>
</div>
{% if people_list %}
<div id="person-list">
<!-- View Switcher -->

View File

@ -211,8 +211,8 @@
<option selected>
----------
</option>
<option value="Offer">
{% trans "To Offer" %}
<option value="Document Review">
{% trans "To Document Review" %}
</option>
<option value="Exam">
{% trans "To Exam" %}