Merge pull request 'message and email' (#33) from frontend into main

Reviewed-on: #33
Reviewed-by: ismail <ismail.mosa.ibrahim@gmail.com>
This commit is contained in:
ismail 2025-11-19 12:56:51 +03:00
commit a1d2dcebfb
25 changed files with 776 additions and 861 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@ -1,15 +1,24 @@
""" """
Email service for sending notifications related to agency messaging. 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.core.mail import send_mail, EmailMultiAlternatives
from django.conf import settings from django.conf import settings
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.contrib.auth import get_user_model
import logging import logging
from .models import Message
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User=get_user_model()
class EmailService: class EmailService:
""" """
@ -225,17 +234,10 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi
return {'success': False, 'error': error_msg} return {'success': False, 'error': error_msg}
from .models import 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, Send bulk email to multiple recipients with HTML support and attachments,
supporting synchronous or asynchronous dispatch. 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 [] processed_attachments = attachments if attachments else []
task_ids = [] 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: if not from_interview:
# Loop through ALL final customized sends # Loop through ALL final customized sends
for recipient_email, custom_message in 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 custom_message, # Pass the custom message
[recipient_email], # Pass the specific recipient as a list of one [recipient_email], # Pass the specific recipient as a list of one
processed_attachments, processed_attachments,
hook='recruitment.tasks.email_success_hook' sender_user_id,
job_id,
hook='recruitment.tasks.email_success_hook',
) )
task_ids.append(task_id) task_ids.append(task_id)
@ -350,80 +356,101 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
except Exception as e: except Exception as e:
logger.error(f"Failed to queue async tasks: {str(e)}", exc_info=True) logger.error(f"Failed to queue async tasks: {str(e)}", exc_info=True)
return {'success': False, 'error': f"Failed to queue async tasks: {str(e)}"} return {'success': False, 'error': f"Failed to queue async tasks: {str(e)}"}
else:
# --- 3. Handle SYNCHRONOUS Send (No changes needed here, as it was fixed previously) --- # --- 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: try:
email_obj.send(fail_silently=False) # NOTE: The synchronous block below should also use the 'customized_sends'
successful_sends += 1 # 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: except Exception as e:
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True) error_msg = f"Failed to process bulk email send request: {str(e)}"
logger.error(error_msg, exc_info=True)
if not from_interview: return {'success': False, 'error': error_msg}
# 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}

View File

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

View File

@ -12,8 +12,9 @@ from . linkedin_service import LinkedInService
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from . models import JobPosting from . models import JobPosting
from django.utils import timezone 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 # Add python-docx import for Word document processing
try: try:
from docx import Document from docx import Document
@ -28,7 +29,7 @@ logger = logging.getLogger(__name__)
OPENROUTER_API_KEY ='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' OPENROUTER_API_KEY ='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'
# OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free' # 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 = 'openai/gpt-oss-20b'
# OPENROUTER_MODEL = 'mistralai/mistral-small-3.2-24b-instruct:free' # 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. Background task to process a Zoom webhook event and update the local ZoomMeeting status.
It handles: created, updated, started, ended, and deleted events. It handles: created, updated, started, ended, and deleted events.
""" """
print(payload)
event_type = payload.get('event') event_type = payload.get('event')
object_data = payload['payload']['object'] object_data = payload['payload']['object']
@ -535,9 +535,7 @@ def handle_zoom_webhook_event(payload):
# elif event_type == 'meeting.updated': # elif event_type == 'meeting.updated':
# Only update time fields if they are in the payload # Only update time fields if they are in the payload
print(object_data) print(object_data)
meeting_start_time = object_data.get('start_time', meeting_instance.start_time) meeting_instance.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.duration = object_data.get('duration', meeting_instance.duration) meeting_instance.duration = object_data.get('duration', meeting_instance.duration)
meeting_instance.timezone = object_data.get('timezone', meeting_instance.timezone) 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.core.mail import EmailMultiAlternatives
from django.utils.html import strip_tags 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.""" """Internal helper to create and send a single email."""
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa') 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) email_obj.attach(filename, content, content_type)
try: try:
email_obj.send(fail_silently=False) result=email_obj.send(fail_silently=False)
return True
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: except Exception as e:
logger.error(f"Task failed to send email to {recipient}: {str(e)}", exc_info=True) logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
return False
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. Receives arguments directly from the async_task call.
""" """
logger.info(f"Starting bulk email task for {len(recipient_list)} recipients") 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: if not recipient_list:
return {'success': False, 'error': 'No recipients provided to task.'} 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. # Since the async caller sends one task per recipient, total_recipients should be 1.
for recipient in recipient_list: for recipient in recipient_list:
# The 'message' is the custom message specific to this recipient. # 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 successful_sends += 1
if successful_sends > 0: if successful_sends > 0:

View File

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

View File

@ -129,7 +129,8 @@ from .models import (
Message, Message,
Document, Document,
OnsiteLocationDetails, OnsiteLocationDetails,
InterviewLocation InterviewLocation,
InterviewNote
) )
@ -249,123 +250,7 @@ class ZoomMeetingCreateView(StaffRequiredMixin, CreateView):
messages.error(self.request, f"Error creating meeting: {e}") messages.error(self.request, f"Error creating meeting: {e}")
return redirect(reverse("create_meeting", kwargs={"slug": instance.slug})) 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): class ZoomMeetingDetailsView(StaffRequiredMixin, DetailView):
model = ZoomMeetingDetails model = ZoomMeetingDetails
@ -1268,8 +1153,8 @@ def application_submit_form(request, template_slug):
) )
def applicant_profile(request): # def applicant_profile(request):
return render(request, "applicant/applicant_profile.html") # return render(request, "applicant/applicant_profile.html")
@csrf_exempt @csrf_exempt
@ -3122,7 +3007,7 @@ def add_meeting_comment(request, slug):
meeting = get_object_or_404(ZoomMeetingDetails, slug=slug) meeting = get_object_or_404(ZoomMeetingDetails, slug=slug)
if request.method == "POST": if request.method == "POST":
form = MeetingCommentForm(request.POST) form = InterviewNoteForm(request.POST)
if form.is_valid(): if form.is_valid():
comment = form.save(commit=False) comment = form.save(commit=False)
comment.meeting = meeting comment.meeting = meeting
@ -3143,7 +3028,7 @@ def add_meeting_comment(request, slug):
return redirect("meeting_details", slug=slug) return redirect("meeting_details", slug=slug)
else: else:
form = MeetingCommentForm() form = InterviewNoteForm()
context = { context = {
"form": form, "form": form,
@ -3169,7 +3054,7 @@ def edit_meeting_comment(request, slug, comment_id):
return redirect("meeting_details", slug=slug) return redirect("meeting_details", slug=slug)
if request.method == "POST": if request.method == "POST":
form = MeetingCommentForm(request.POST, instance=comment) form = InterviewNoteForm(request.POST, instance=comment)
if form.is_valid(): if form.is_valid():
comment = form.save() comment = form.save()
messages.success(request, "Comment updated successfully!") messages.success(request, "Comment updated successfully!")
@ -3187,7 +3072,7 @@ def edit_meeting_comment(request, slug, comment_id):
return redirect("meeting_details", slug=slug) return redirect("meeting_details", slug=slug)
else: else:
form = MeetingCommentForm(instance=comment) form = InterviewNoteForm(instance=comment)
context = {"form": form, "meeting": meeting, "comment": comment} context = {"form": form, "meeting": meeting, "comment": comment}
return render(request, "includes/edit_comment_form.html", context) return render(request, "includes/edit_comment_form.html", context)
@ -4692,7 +4577,8 @@ def message_detail(request, message_id):
@login_required @login_required
def message_create(request): def message_create(request):
"""Create a new message""" """Create a new message"""
from .email_service import EmailService
if request.method == "POST": if request.method == "POST":
form = MessageForm(request.user, request.POST) form = MessageForm(request.user, request.POST)
@ -4700,8 +4586,25 @@ def message_create(request):
message = form.save(commit=False) message = form.save(commit=False)
message.sender = request.user message.sender = request.user
message.save() message.save()
messages.success(request, "Message sent successfully!") 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") return redirect("message_list")
else: else:
messages.error(request, "Please correct the errors below.") messages.error(request, "Please correct the errors below.")
@ -4714,6 +4617,8 @@ def message_create(request):
if request.user.user_type != "staff": if request.user.user_type != "staff":
return render(request, "messages/candidate_message_form.html", context) return render(request, "messages/candidate_message_form.html", context)
return render(request, "messages/message_form.html", context) return render(request, "messages/message_form.html", context)
@login_required @login_required
def message_reply(request, message_id): def message_reply(request, message_id):
"""Reply to a message""" """Reply to a message"""
@ -5206,7 +5111,7 @@ def compose_candidate_email(request, job_slug):
if request.method == 'POST': if request.method == 'POST':
print("........................................................inside candidate conpose.............")
candidate_ids = request.POST.getlist('candidate_ids') candidate_ids = request.POST.getlist('candidate_ids')
candidates=Application.objects.filter(id__in=candidate_ids) candidates=Application.objects.filter(id__in=candidate_ids)
form = CandidateEmailForm(job, candidates, request.POST) 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) # Send emails using email service (no attachments, synchronous to avoid pickle issues)
email_result = send_bulk_email( email_result = send_bulk_email( #
subject=subject, subject=subject,
message=message, message=message,
recipient_list=email_addresses, recipient_list=email_addresses,
request=request, request=request,
attachments=None, attachments=None,
async_task_=True, # Changed to False to avoid pickle issues async_task_=True, # Changed to False to avoid pickle issues
from_interview=False from_interview=False,
job=job
) )
if email_result["success"]: if email_result["success"]:
@ -5538,25 +5445,50 @@ def candidate_signup(request, slug):
from .forms import InterviewParticpantsForm 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): 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) 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": if request.method == "POST":
form = InterviewParticpantsForm(request.POST, instance=schedule_interview) form = InterviewParticpantsForm(request.POST, instance=schedule_interview)
if form.is_valid(): if form.is_valid():
# Save the main Candidate object, but don't commit to DB yet form.save() # No need for commit=False — it's not a create, just update
candidate = form.save(commit=False) messages.success(request, "Participants updated successfully.")
candidate.save() return redirect("meeting_details", slug=meeting_slug)
# 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: else:
form = InterviewParticpantsForm(instance=schedule_interview) form = InterviewParticpantsForm(instance=schedule_interview)
return render( 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, 'details': details,
'type': location.location_type, 'type': location.location_type,
'topic': location.topic, 'topic': location.topic,
'slug': interview.slug, # 'slug': interview.slug,
'start_time': start_datetime, # Combined datetime object 'start_time': start_datetime, # Combined datetime object
# Duration should ideally be on ScheduledInterview or fetched from details # Duration should ideally be on ScheduledInterview or fetched from details
'duration': getattr(details, 'duration', 'N/A'), '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) return render(request, "meetings/schedule_onsite_meeting_form.html", context)
from django.http import Http404
def meeting_details(request, slug):
# Fetch the meeting (InterviewLocation or subclass) by slug
meeting = get_object_or_404(
InterviewLocation.objects.select_related(
'scheduled_interview__application__person',
'scheduled_interview__job',
'zoommeetingdetails',
'onsitelocationdetails',
).prefetch_related(
'scheduled_interview__participants',
'scheduled_interview__system_users',
'scheduled_interview__notes',
),
slug=slug
)
try:
interview = meeting.scheduled_interview
except ScheduledInterview.DoesNotExist:
raise Http404("No interview is associated with this meeting.")
candidate = interview.application
job = interview.job
external_participants = interview.participants.all()
system_participants = interview.system_users.all()
total_participants = external_participants.count() + system_participants.count()
# Forms for modals
participant_form = InterviewParticpantsForm(instance=interview)
# email_form = InterviewEmailForm(
# candidate=candidate,
# external_participants=external_participants, # QuerySet of Participants
# system_participants=system_participants, # QuerySet of Users
# meeting=meeting, # ← This is InterviewLocation (e.g., ZoomMeetingDetails)
# job=job,
# )
context = {
'meeting': meeting,
'interview': interview,
'candidate': candidate,
'job': job,
'external_participants': external_participants,
'system_participants': system_participants,
'total_participants': total_participants,
'form': participant_form,
# 'email_form': email_form,
}
return render(request, 'interviews/detail_interview.html', context)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

View File

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

View File

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

View File

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

View File

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

View File

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