message and email #33
3
.env
3
.env
@ -1,3 +0,0 @@
|
||||
DB_NAME=norahuniversity
|
||||
DB_USER=norahuniversity
|
||||
DB_PASSWORD=norahuniversity
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -53,6 +53,7 @@ htmlcov/
|
||||
|
||||
# Media and Static files (if served locally and not meant for version control)
|
||||
media/
|
||||
static/
|
||||
|
||||
# Deployment files
|
||||
*.tar.gz
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -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'),
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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}
|
||||
@ -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 candidate’s 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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"),
|
||||
|
||||
]
|
||||
|
||||
@ -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 |
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
@ -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 }}">
|
||||
|
||||
@ -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 -->
|
||||
|
||||
@ -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" %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user