Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend
This commit is contained in:
commit
60e3f81620
1
.gitignore
vendored
1
.gitignore
vendored
@ -22,6 +22,7 @@ var/
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
|
.env
|
||||||
|
|
||||||
# Django stuff:
|
# Django stuff:
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -178,10 +178,10 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi
|
|||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
recipients.append(candidate.email)
|
recipients.append(candidate.email)
|
||||||
|
|
||||||
if recipient_list:
|
if recipient_list:
|
||||||
recipients.extend(recipient_list)
|
recipients.extend(recipient_list)
|
||||||
|
|
||||||
|
|
||||||
if not recipients:
|
if not recipients:
|
||||||
return {'success': False, 'error': 'No recipient email addresses provided'}
|
return {'success': False, 'error': 'No recipient email addresses provided'}
|
||||||
@ -242,51 +242,51 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
|||||||
Send bulk email to multiple recipients with HTML support and attachments,
|
Send bulk email to multiple recipients with HTML support and attachments,
|
||||||
supporting synchronous or asynchronous dispatch.
|
supporting synchronous or asynchronous dispatch.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# --- 1. Categorization and Custom Message Preparation (CORRECTED) ---
|
# --- 1. Categorization and Custom Message Preparation (CORRECTED) ---
|
||||||
if not from_interview:
|
if not from_interview:
|
||||||
|
|
||||||
agency_emails = []
|
agency_emails = []
|
||||||
pure_candidate_emails = []
|
pure_candidate_emails = []
|
||||||
candidate_through_agency_emails = []
|
candidate_through_agency_emails = []
|
||||||
|
|
||||||
if not recipient_list:
|
if not recipient_list:
|
||||||
return {'success': False, 'error': 'No recipients provided'}
|
return {'success': False, 'error': 'No recipients provided'}
|
||||||
|
|
||||||
# This must contain (final_recipient_email, customized_message) for ALL sends
|
# This must contain (final_recipient_email, customized_message) for ALL sends
|
||||||
customized_sends = []
|
customized_sends = []
|
||||||
|
|
||||||
# 1a. Classify Recipients and Prepare Custom Messages
|
# 1a. Classify Recipients and Prepare Custom Messages
|
||||||
for email in recipient_list:
|
for email in recipient_list:
|
||||||
email = email.strip().lower()
|
email = email.strip().lower()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
candidate = get_object_or_404(Application, person__email=email)
|
candidate = get_object_or_404(Application, person__email=email)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(f"Candidate not found for email: {email}")
|
logger.warning(f"Candidate not found for email: {email}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
candidate_name = candidate.person.full_name
|
candidate_name = candidate.person.full_name
|
||||||
|
|
||||||
|
|
||||||
# --- Candidate belongs to an agency (Final Recipient: Agency) ---
|
# --- Candidate belongs to an agency (Final Recipient: Agency) ---
|
||||||
if candidate.hiring_agency and candidate.hiring_agency.email:
|
if candidate.hiring_agency and candidate.hiring_agency.email:
|
||||||
agency_email = candidate.hiring_agency.email
|
agency_email = candidate.hiring_agency.email
|
||||||
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
||||||
|
|
||||||
# Add Agency email as the recipient with the custom message
|
# Add Agency email as the recipient with the custom message
|
||||||
customized_sends.append((agency_email, agency_message))
|
customized_sends.append((agency_email, agency_message))
|
||||||
agency_emails.append(agency_email)
|
agency_emails.append(agency_email)
|
||||||
candidate_through_agency_emails.append(candidate.email) # For sync block only
|
candidate_through_agency_emails.append(candidate.email) # For sync block only
|
||||||
|
|
||||||
# --- Pure Candidate (Final Recipient: Candidate) ---
|
# --- Pure Candidate (Final Recipient: Candidate) ---
|
||||||
else:
|
else:
|
||||||
candidate_message = f"Hi, {candidate_name}" + "\n" + message
|
candidate_message = f"Hi, {candidate_name}" + "\n" + message
|
||||||
|
|
||||||
# Add Candidate email as the recipient with the custom message
|
# Add Candidate email as the recipient with the custom message
|
||||||
customized_sends.append((email, candidate_message))
|
customized_sends.append((email, candidate_message))
|
||||||
pure_candidate_emails.append(email) # For sync block only
|
pure_candidate_emails.append(email) # For sync block only
|
||||||
|
|
||||||
# Calculate total recipients based on the size of the final send list
|
# Calculate total recipients based on the size of the final send list
|
||||||
total_recipients = len(customized_sends)
|
total_recipients = len(customized_sends)
|
||||||
|
|
||||||
@ -295,21 +295,22 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
|||||||
else:
|
else:
|
||||||
# For interview flow
|
# For interview flow
|
||||||
total_recipients = len(recipient_list)
|
total_recipients = len(recipient_list)
|
||||||
|
|
||||||
|
|
||||||
# --- 2. Handle ASYNC Dispatch (FIXED: Single loop used) ---
|
# --- 2. Handle ASYNC Dispatch (FIXED: Single loop used) ---
|
||||||
if async_task_:
|
if async_task_:
|
||||||
try:
|
try:
|
||||||
|
|
||||||
processed_attachments = attachments if attachments else []
|
processed_attachments = attachments if attachments else []
|
||||||
task_ids = []
|
task_ids = []
|
||||||
|
|
||||||
job_id=job.id
|
job_id=job.id
|
||||||
sender_user_id=request.user.id if request and hasattr(request, 'user') and request.user.is_authenticated else None
|
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:
|
||||||
task_id = async_task(
|
task_id = async_task(
|
||||||
'recruitment.tasks.send_bulk_email_task',
|
'recruitment.tasks.send_bulk_email_task',
|
||||||
subject,
|
subject,
|
||||||
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
|
||||||
@ -317,10 +318,10 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
|||||||
sender_user_id,
|
sender_user_id,
|
||||||
job_id,
|
job_id,
|
||||||
hook='recruitment.tasks.email_success_hook',
|
hook='recruitment.tasks.email_success_hook',
|
||||||
|
|
||||||
)
|
)
|
||||||
task_ids.append(task_id)
|
task_ids.append(task_id)
|
||||||
|
|
||||||
logger.info(f"{len(task_ids)} tasks ({total_recipients} emails) queued.")
|
logger.info(f"{len(task_ids)} tasks ({total_recipients} emails) queued.")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -329,19 +330,19 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
|||||||
'task_ids': task_ids,
|
'task_ids': task_ids,
|
||||||
'message': f'Emails queued for background sending to {len(task_ids)} recipient(s).'
|
'message': f'Emails queued for background sending to {len(task_ids)} recipient(s).'
|
||||||
}
|
}
|
||||||
|
|
||||||
else: # from_interview is True (generic send to all participants)
|
else: # from_interview is True (generic send to all participants)
|
||||||
task_id = async_task(
|
task_id = async_task(
|
||||||
'recruitment.tasks.send_bulk_email_task',
|
'recruitment.tasks.send_bulk_email_task',
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
recipient_list, # Send the original message to the entire list
|
recipient_list, # Send the original message to the entire list
|
||||||
processed_attachments,
|
processed_attachments,
|
||||||
hook='recruitment.tasks.email_success_hook'
|
hook='recruitment.tasks.email_success_hook'
|
||||||
)
|
)
|
||||||
task_ids.append(task_id)
|
task_ids.append(task_id)
|
||||||
logger.info(f"Interview emails queued. ID: {task_id}")
|
logger.info(f"Interview emails queued. ID: {task_id}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'async': True,
|
'async': True,
|
||||||
@ -352,103 +353,91 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
|||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.error("Async execution requested, but django_q or required modules not found. Defaulting to sync.")
|
logger.error("Async execution requested, but django_q or required modules not found. Defaulting to sync.")
|
||||||
async_task_ = False
|
async_task_ = False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to queue async tasks: {str(e)}", exc_info=True)
|
logger.error(f"Failed to queue async tasks: {str(e)}", exc_info=True)
|
||||||
return {'success': False, 'error': f"Failed to queue async tasks: {str(e)}"}
|
return {'success': False, 'error': f"Failed to queue async tasks: {str(e)}"}
|
||||||
|
|
||||||
else:
|
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
|
||||||
try:
|
try:
|
||||||
# NOTE: The synchronous block below should also use the 'customized_sends'
|
# NOTE: The synchronous block below should also use the 'customized_sends'
|
||||||
# list for consistency instead of rebuilding messages from 'pure_candidate_emails'
|
# list for consistency instead of rebuilding messages from 'pure_candidate_emails'
|
||||||
# and 'agency_emails', but keeping your current logic structure to minimize changes.
|
# and 'agency_emails', but keeping your current logic structure to minimize changes.
|
||||||
|
|
||||||
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
|
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
|
||||||
is_html = '<' in message and '>' in message
|
is_html = '<' in message and '>' in message
|
||||||
successful_sends = 0
|
successful_sends = 0
|
||||||
|
|
||||||
# Helper Function for Sync Send (as provided)
|
# Helper Function for Sync Send (as provided)
|
||||||
def send_individual_email(recipient, body_message):
|
def send_individual_email(recipient, body_message):
|
||||||
# ... (Existing helper function logic) ...
|
# ... (Existing helper function logic) ...
|
||||||
nonlocal successful_sends
|
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)}")
|
|
||||||
|
|
||||||
|
if is_html:
|
||||||
else:
|
plain_message = strip_tags(body_message)
|
||||||
logger.error("fialed to send email")
|
email_obj = EmailMultiAlternatives(subject=subject, body=plain_message, from_email=from_email, to=[recipient])
|
||||||
|
email_obj.attach_alternative(body_message, "text/html")
|
||||||
successful_sends += 1
|
else:
|
||||||
except Exception as e:
|
email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
|
||||||
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
|
|
||||||
|
|
||||||
if not from_interview:
|
if attachments:
|
||||||
# Send Emails - Pure Candidates
|
for attachment in attachments:
|
||||||
for email in pure_candidate_emails:
|
if hasattr(attachment, 'read'):
|
||||||
candidate_name = Application.objects.filter(person__email=email).first().person.full_name
|
filename = getattr(attachment, 'name', 'attachment')
|
||||||
candidate_message = f"Hi, {candidate_name}" + "\n" + message
|
content = attachment.read()
|
||||||
send_individual_email(email, candidate_message)
|
content_type = getattr(attachment, 'content_type', 'application/octet-stream')
|
||||||
|
email_obj.attach(filename, content, content_type)
|
||||||
# Send Emails - Agencies
|
elif isinstance(attachment, tuple) and len(attachment) == 3:
|
||||||
i = 0
|
filename, content, content_type = attachment
|
||||||
for email in agency_emails:
|
email_obj.attach(filename, content, content_type)
|
||||||
candidate_email = candidate_through_agency_emails[i]
|
|
||||||
candidate_name = Application.objects.filter(person__email=candidate_email).first().person.full_name
|
try:
|
||||||
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
email_obj.send(fail_silently=False)
|
||||||
send_individual_email(email, agency_message)
|
successful_sends += 1
|
||||||
i += 1
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
|
||||||
logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.")
|
|
||||||
return {
|
if not from_interview:
|
||||||
'success': True,
|
# Send Emails - Pure Candidates
|
||||||
'recipients_count': successful_sends,
|
for email in pure_candidate_emails:
|
||||||
'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.'
|
candidate_name = Application.objects.filter(email=email).first().first_name
|
||||||
}
|
candidate_message = f"Hi, {candidate_name}" + "\n" + message
|
||||||
else:
|
send_individual_email(email, candidate_message)
|
||||||
for email in recipient_list:
|
|
||||||
send_individual_email(email, message)
|
# Send Emails - Agencies
|
||||||
|
i = 0
|
||||||
logger.info(f"Interview email processing complete. Sent successfully to {successful_sends} out of {total_recipients} recipients.")
|
for email in agency_emails:
|
||||||
return {
|
candidate_email = candidate_through_agency_emails[i]
|
||||||
'success': True,
|
candidate_name = Application.objects.filter(email=candidate_email).first().first_name
|
||||||
'recipients_count': successful_sends,
|
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
||||||
'message': f'Interview emails sent successfully to {successful_sends} recipient(s).'
|
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:
|
||||||
error_msg = f"Failed to process bulk email send request: {str(e)}"
|
error_msg = f"Failed to process bulk email send request: {str(e)}"
|
||||||
|
|||||||
@ -2453,3 +2453,52 @@ class PasswordResetForm(forms.Form):
|
|||||||
raise forms.ValidationError(_('New passwords do not match.'))
|
raise forms.ValidationError(_('New passwords do not match.'))
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class StaffAssignmentForm(forms.ModelForm):
|
||||||
|
"""Form for assigning staff to a job posting"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = JobPosting
|
||||||
|
fields = ['assigned_to']
|
||||||
|
widgets = {
|
||||||
|
'assigned_to': forms.Select(attrs={
|
||||||
|
'class': 'form-select',
|
||||||
|
'placeholder': _('Select staff member'),
|
||||||
|
'required': True
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'assigned_to': _('Assign Staff Member'),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# Filter users to only show staff members
|
||||||
|
self.fields['assigned_to'].queryset = User.objects.filter(
|
||||||
|
user_type='staff'
|
||||||
|
).order_by('first_name', 'last_name')
|
||||||
|
|
||||||
|
# Add empty choice for unassigning
|
||||||
|
self.fields['assigned_to'].required = False
|
||||||
|
self.fields['assigned_to'].empty_label = _('-- Unassign Staff --')
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_method = 'post'
|
||||||
|
self.helper.form_class = 'g-3'
|
||||||
|
self.helper.form_id = 'staff-assignment-form'
|
||||||
|
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
Field('assigned_to', css_class='form-control'),
|
||||||
|
Div(
|
||||||
|
Submit('submit', _('Assign Staff'), css_class='btn btn-primary'),
|
||||||
|
css_class='col-12 mt-3'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_assigned_to(self):
|
||||||
|
"""Validate the assigned staff member"""
|
||||||
|
assigned_to = self.cleaned_data.get('assigned_to')
|
||||||
|
if assigned_to and assigned_to.user_type != 'staff':
|
||||||
|
raise forms.ValidationError(_('Only staff members can be assigned to jobs.'))
|
||||||
|
return assigned_to
|
||||||
|
|||||||
@ -22,6 +22,8 @@ from .models import (
|
|||||||
)
|
)
|
||||||
from .forms import generate_api_key, generate_api_secret
|
from .forms import generate_api_key, generate_api_secret
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django_q.models import Schedule
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -41,6 +43,7 @@ def format_job(sender, instance, created, **kwargs):
|
|||||||
instance.pk,
|
instance.pk,
|
||||||
# hook='myapp.tasks.email_sent_callback' # Optional callback
|
# hook='myapp.tasks.email_sent_callback' # Optional callback
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
existing_schedule = Schedule.objects.filter(
|
existing_schedule = Schedule.objects.filter(
|
||||||
func="recruitment.tasks.form_close",
|
func="recruitment.tasks.form_close",
|
||||||
|
|||||||
@ -487,7 +487,7 @@ def create_interview_and_meeting(
|
|||||||
interview_date=slot_date,
|
interview_date=slot_date,
|
||||||
interview_time=slot_time
|
interview_time=slot_time
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log success or use Django-Q result system for monitoring
|
# Log success or use Django-Q result system for monitoring
|
||||||
logger.info(f"Successfully scheduled interview for {Application.name}")
|
logger.info(f"Successfully scheduled interview for {Application.name}")
|
||||||
return True # Task succeeded
|
return True # Task succeeded
|
||||||
@ -606,6 +606,8 @@ def form_close(job_id):
|
|||||||
job.is_active = False
|
job.is_active = False
|
||||||
job.template_form.is_active = False
|
job.template_form.is_active = False
|
||||||
job.save()
|
job.save()
|
||||||
|
#TODO:send email to admins
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def sync_hired_candidates_task(job_slug):
|
def sync_hired_candidates_task(job_slug):
|
||||||
@ -777,7 +779,7 @@ def _task_send_individual_email(subject, body_message, recipient, attachments,se
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result=email_obj.send(fail_silently=False)
|
result=email_obj.send(fail_silently=False)
|
||||||
|
|
||||||
if result==1:
|
if result==1:
|
||||||
try:
|
try:
|
||||||
user=get_object_or_404(User,email=recipient)
|
user=get_object_or_404(User,email=recipient)
|
||||||
@ -794,11 +796,11 @@ def _task_send_individual_email(subject, body_message, recipient, attachments,se
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Email sent to {recipient}, but failed to store in DB: {str(e)}")
|
logger.error(f"Email sent to {recipient}, but failed to store in DB: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.error("fialed to send email")
|
logger.error("fialed to send email")
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
|
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
|
||||||
|
|
||||||
@ -814,7 +816,7 @@ def send_bulk_email_task(subject, message, recipient_list,attachments=None,sende
|
|||||||
|
|
||||||
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)
|
sender=get_object_or_404(User,pk=sender_user_id)
|
||||||
job=get_object_or_404(JobPosting,pk=job_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.
|
||||||
@ -843,3 +845,5 @@ def email_success_hook(task):
|
|||||||
logger.info(f"Task ID {task.id} succeeded. Result: {task.result}")
|
logger.info(f"Task ID {task.id} succeeded. Result: {task.result}")
|
||||||
else:
|
else:
|
||||||
logger.error(f"Task ID {task.id} failed. Error: {task.result}")
|
logger.error(f"Task ID {task.id} failed. Error: {task.result}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,8 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path("jobs/linkedin/login/", views.linkedin_login, name="linkedin_login"),
|
path("jobs/linkedin/login/", views.linkedin_login, name="linkedin_login"),
|
||||||
path("jobs/linkedin/callback/", views.linkedin_callback, name="linkedin_callback"),
|
path("jobs/linkedin/callback/", views.linkedin_callback, name="linkedin_callback"),
|
||||||
|
path("jobs/<slug:slug>/staff-assignment/", views.staff_assignment_view, name="staff_assignment_view"),
|
||||||
|
|
||||||
# Candidate URLs
|
# Candidate URLs
|
||||||
path(
|
path(
|
||||||
"candidates/", views_frontend.ApplicationListView.as_view(), name="candidate_list"
|
"candidates/", views_frontend.ApplicationListView.as_view(), name="candidate_list"
|
||||||
@ -109,7 +110,7 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
# Meeting URLs
|
# Meeting URLs
|
||||||
# path("meetings/", views.ZoomMeetingListView.as_view(), name="list_meetings"),
|
# path("meetings/", views.ZoomMeetingListView.as_view(), name="list_meetings"),
|
||||||
|
|
||||||
# JobPosting functional views URLs (keeping for compatibility)
|
# JobPosting functional views URLs (keeping for compatibility)
|
||||||
path("api/create/", views.create_job, name="create_job_api"),
|
path("api/create/", views.create_job, name="create_job_api"),
|
||||||
path("api/<slug:slug>/edit/", views.edit_job, name="edit_job_api"),
|
path("api/<slug:slug>/edit/", views.edit_job, name="edit_job_api"),
|
||||||
@ -271,7 +272,7 @@ urlpatterns = [
|
|||||||
views.interview_detail_view,
|
views.interview_detail_view,
|
||||||
name="interview_detail",
|
name="interview_detail",
|
||||||
),
|
),
|
||||||
|
|
||||||
# users urls
|
# users urls
|
||||||
path("user/<int:pk>", views.user_detail, name="user_detail"),
|
path("user/<int:pk>", views.user_detail, name="user_detail"),
|
||||||
path(
|
path(
|
||||||
@ -576,7 +577,7 @@ urlpatterns = [
|
|||||||
views.confirm_schedule_interviews_view,
|
views.confirm_schedule_interviews_view,
|
||||||
name="confirm_schedule_interviews_view",
|
name="confirm_schedule_interviews_view",
|
||||||
),
|
),
|
||||||
|
|
||||||
path(
|
path(
|
||||||
"meetings/create-meeting/",
|
"meetings/create-meeting/",
|
||||||
views.ZoomMeetingCreateView.as_view(),
|
views.ZoomMeetingCreateView.as_view(),
|
||||||
@ -632,16 +633,16 @@ urlpatterns = [
|
|||||||
|
|
||||||
|
|
||||||
path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
|
path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
|
||||||
|
|
||||||
# 1. Onsite Reschedule URL
|
# 1. Onsite Reschedule URL
|
||||||
path(
|
path(
|
||||||
'<slug:slug>/candidate/<int:candidate_id>/onsite/reschedule/<int:meeting_id>/',
|
'<slug:slug>/candidate/<int:candidate_id>/onsite/reschedule/<int:meeting_id>/',
|
||||||
views.reschedule_onsite_meeting,
|
views.reschedule_onsite_meeting,
|
||||||
name='reschedule_onsite_meeting'
|
name='reschedule_onsite_meeting'
|
||||||
),
|
),
|
||||||
|
|
||||||
# 2. Onsite Delete URL
|
# 2. Onsite Delete URL
|
||||||
|
|
||||||
path(
|
path(
|
||||||
'job/<slug:slug>/candidates/<int:candidate_pk>/delete-onsite-meeting/<int:meeting_id>/',
|
'job/<slug:slug>/candidates/<int:candidate_pk>/delete-onsite-meeting/<int:meeting_id>/',
|
||||||
views.delete_onsite_meeting_for_candidate,
|
views.delete_onsite_meeting_for_candidate,
|
||||||
@ -653,8 +654,8 @@ urlpatterns = [
|
|||||||
views.schedule_onsite_meeting_for_candidate,
|
views.schedule_onsite_meeting_for_candidate,
|
||||||
name='schedule_onsite_meeting_for_candidate' # This is the name used in the button
|
name='schedule_onsite_meeting_for_candidate' # This is the name used in the button
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
||||||
# Detail View (assuming slug is on ScheduledInterview)
|
# Detail View (assuming slug is on ScheduledInterview)
|
||||||
path("interviews/meetings/<slug:slug>/", views.meeting_details, name="meeting_details"),
|
path("interviews/meetings/<slug:slug>/", views.meeting_details, name="meeting_details"),
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,8 @@ from .forms import (
|
|||||||
ProfileImageUploadForm,
|
ProfileImageUploadForm,
|
||||||
ParticipantsSelectForm,
|
ParticipantsSelectForm,
|
||||||
ApplicationForm,
|
ApplicationForm,
|
||||||
PasswordResetForm
|
PasswordResetForm,
|
||||||
|
StaffAssignmentForm,
|
||||||
)
|
)
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
@ -120,7 +121,7 @@ from .models import (
|
|||||||
JobPosting,
|
JobPosting,
|
||||||
ScheduledInterview,
|
ScheduledInterview,
|
||||||
JobPostingImage,
|
JobPostingImage,
|
||||||
|
|
||||||
HiringAgency,
|
HiringAgency,
|
||||||
AgencyJobAssignment,
|
AgencyJobAssignment,
|
||||||
AgencyAccessLink,
|
AgencyAccessLink,
|
||||||
@ -250,7 +251,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 ZoomMeetingDetailsView(StaffRequiredMixin, DetailView):
|
class ZoomMeetingDetailsView(StaffRequiredMixin, DetailView):
|
||||||
model = ZoomMeetingDetails
|
model = ZoomMeetingDetails
|
||||||
@ -496,12 +497,12 @@ def job_detail(request, slug):
|
|||||||
|
|
||||||
# --- 2. Quality Metrics (JSON Aggregation) ---
|
# --- 2. Quality Metrics (JSON Aggregation) ---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
candidates_with_score = applicants.filter(is_resume_parsed=True).annotate(
|
candidates_with_score = applicants.filter(is_resume_parsed=True).annotate(
|
||||||
annotated_match_score=Coalesce(Cast(SCORE_PATH, output_field=IntegerField()), 0)
|
annotated_match_score=Coalesce(Cast(SCORE_PATH, output_field=IntegerField()), 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
total_candidates = applicants.count()
|
total_candidates = applicants.count()
|
||||||
avg_match_score_result = candidates_with_score.aggregate(
|
avg_match_score_result = candidates_with_score.aggregate(
|
||||||
avg_score=Avg("annotated_match_score")
|
avg_score=Avg("annotated_match_score")
|
||||||
@ -600,7 +601,7 @@ ALLOWED_EXTENSIONS = (".pdf", ".docx")
|
|||||||
|
|
||||||
def job_cvs_download(request, slug):
|
def job_cvs_download(request, slug):
|
||||||
job = get_object_or_404(JobPosting, slug=slug)
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
entries = Candidate.objects.filter(job=job)
|
entries = Application.objects.filter(job=job)
|
||||||
|
|
||||||
# 2. Create an in-memory byte stream (BytesIO)
|
# 2. Create an in-memory byte stream (BytesIO)
|
||||||
zip_buffer = io.BytesIO()
|
zip_buffer = io.BytesIO()
|
||||||
@ -642,7 +643,7 @@ def job_cvs_download(request, slug):
|
|||||||
|
|
||||||
# Set the header for the browser to download the file
|
# Set the header for the browser to download the file
|
||||||
response["Content-Disposition"] = (
|
response["Content-Disposition"] = (
|
||||||
'attachment; filename=f"all_cvs_for_{job.title}.zip"'
|
f'attachment; filename="all_cvs_for_{job.title}.zip"'
|
||||||
)
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@ -742,7 +743,7 @@ def kaauh_career(request):
|
|||||||
if selected_department and selected_department in department_type_keys:
|
if selected_department and selected_department in department_type_keys:
|
||||||
active_jobs = active_jobs.filter(department=selected_department)
|
active_jobs = active_jobs.filter(department=selected_department)
|
||||||
selected_workplace_type = request.GET.get("workplace_type", "")
|
selected_workplace_type = request.GET.get("workplace_type", "")
|
||||||
|
|
||||||
selected_job_type = request.GET.get("employment_type", "")
|
selected_job_type = request.GET.get("employment_type", "")
|
||||||
|
|
||||||
job_type_keys = active_jobs.order_by("job_type").distinct("job_type").values_list("job_type", flat=True)
|
job_type_keys = active_jobs.order_by("job_type").distinct("job_type").values_list("job_type", flat=True)
|
||||||
@ -1468,7 +1469,7 @@ def _handle_preview_submission(request, slug, job):
|
|||||||
preview_schedule.append(
|
preview_schedule.append(
|
||||||
{"application": application, "date": slot["date"], "time": slot["time"]}
|
{"application": application, "date": slot["date"], "time": slot["time"]}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save the form data to session for later use
|
# Save the form data to session for later use
|
||||||
schedule_data = {
|
schedule_data = {
|
||||||
"start_date": start_date.isoformat(),
|
"start_date": start_date.isoformat(),
|
||||||
@ -1482,7 +1483,7 @@ def _handle_preview_submission(request, slug, job):
|
|||||||
"break_end_time": break_end_time.isoformat() if break_end_time else None,
|
"break_end_time": break_end_time.isoformat() if break_end_time else None,
|
||||||
"candidate_ids": [c.id for c in applications],
|
"candidate_ids": [c.id for c in applications],
|
||||||
"schedule_interview_type":schedule_interview_type
|
"schedule_interview_type":schedule_interview_type
|
||||||
|
|
||||||
}
|
}
|
||||||
request.session[SESSION_DATA_KEY] = schedule_data
|
request.session[SESSION_DATA_KEY] = schedule_data
|
||||||
|
|
||||||
@ -1538,7 +1539,7 @@ def _handle_confirm_schedule(request, slug, job):
|
|||||||
break_start = schedule_data.get("break_start_time")
|
break_start = schedule_data.get("break_start_time")
|
||||||
break_end = schedule_data.get("break_end_time")
|
break_end = schedule_data.get("break_end_time")
|
||||||
|
|
||||||
schedule = InterviewSchedule.objects.create(
|
schedule = InterviewSchedule.objects.create(
|
||||||
job=job,
|
job=job,
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
|
start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
|
||||||
@ -1557,7 +1558,7 @@ def _handle_confirm_schedule(request, slug, job):
|
|||||||
# Clear data on failure to prevent stale data causing repeated errors
|
# Clear data on failure to prevent stale data causing repeated errors
|
||||||
messages.error(request, f"Error creating schedule: {e}")
|
messages.error(request, f"Error creating schedule: {e}")
|
||||||
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
|
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
|
||||||
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
|
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
|
||||||
return redirect("schedule_interviews", slug=slug)
|
return redirect("schedule_interviews", slug=slug)
|
||||||
|
|
||||||
# 3. Setup candidates and get slots
|
# 3. Setup candidates and get slots
|
||||||
@ -1591,12 +1592,12 @@ def _handle_confirm_schedule(request, slug, job):
|
|||||||
|
|
||||||
elif schedule_data.get("schedule_interview_type") == 'Onsite':
|
elif schedule_data.get("schedule_interview_type") == 'Onsite':
|
||||||
print("inside...")
|
print("inside...")
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = OnsiteLocationForm(request.POST)
|
form = OnsiteLocationForm(request.POST)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
|
||||||
if not available_slots:
|
if not available_slots:
|
||||||
messages.error(request, "No available slots found for the selected schedule range.")
|
messages.error(request, "No available slots found for the selected schedule range.")
|
||||||
return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
|
return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
|
||||||
@ -1606,27 +1607,27 @@ def _handle_confirm_schedule(request, slug, job):
|
|||||||
room_number = form.cleaned_data['room_number']
|
room_number = form.cleaned_data['room_number']
|
||||||
topic=form.cleaned_data['topic']
|
topic=form.cleaned_data['topic']
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. Iterate over candidates and create a NEW Location object for EACH
|
# 1. Iterate over candidates and create a NEW Location object for EACH
|
||||||
for i, candidate in enumerate(candidates):
|
for i, candidate in enumerate(candidates):
|
||||||
if i < len(available_slots):
|
if i < len(available_slots):
|
||||||
slot = available_slots[i]
|
slot = available_slots[i]
|
||||||
|
|
||||||
|
|
||||||
location_start_dt = datetime.combine(slot['date'], schedule.start_time)
|
location_start_dt = datetime.combine(slot['date'], schedule.start_time)
|
||||||
|
|
||||||
# --- CORE FIX: Create a NEW Location object inside the loop ---
|
# --- CORE FIX: Create a NEW Location object inside the loop ---
|
||||||
onsite_location = OnsiteLocationDetails.objects.create(
|
onsite_location = OnsiteLocationDetails.objects.create(
|
||||||
start_time=location_start_dt,
|
start_time=location_start_dt,
|
||||||
duration=schedule.interview_duration,
|
duration=schedule.interview_duration,
|
||||||
physical_address=physical_address,
|
physical_address=physical_address,
|
||||||
room_number=room_number,
|
room_number=room_number,
|
||||||
location_type="Onsite",
|
location_type="Onsite",
|
||||||
topic=topic
|
topic=topic
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Create the ScheduledInterview, linking the unique location
|
# 2. Create the ScheduledInterview, linking the unique location
|
||||||
ScheduledInterview.objects.create(
|
ScheduledInterview.objects.create(
|
||||||
application=candidate,
|
application=candidate,
|
||||||
@ -1634,7 +1635,7 @@ def _handle_confirm_schedule(request, slug, job):
|
|||||||
schedule=schedule,
|
schedule=schedule,
|
||||||
interview_date=slot['date'],
|
interview_date=slot['date'],
|
||||||
interview_time=slot['time'],
|
interview_time=slot['time'],
|
||||||
interview_location=onsite_location,
|
interview_location=onsite_location,
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(
|
messages.success(
|
||||||
@ -1645,7 +1646,7 @@ def _handle_confirm_schedule(request, slug, job):
|
|||||||
# Clear session data keys upon successful completion
|
# Clear session data keys upon successful completion
|
||||||
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
|
if SESSION_DATA_KEY in request.session: del request.session[SESSION_DATA_KEY]
|
||||||
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
|
if SESSION_ID_KEY in request.session: del request.session[SESSION_ID_KEY]
|
||||||
|
|
||||||
return redirect('job_detail', slug=job.slug)
|
return redirect('job_detail', slug=job.slug)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -1657,11 +1658,11 @@ def _handle_confirm_schedule(request, slug, job):
|
|||||||
# Form is invalid, re-render with errors
|
# Form is invalid, re-render with errors
|
||||||
# Ensure 'job' is passed to prevent NoReverseMatch
|
# Ensure 'job' is passed to prevent NoReverseMatch
|
||||||
return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
|
return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# For a GET request
|
# For a GET request
|
||||||
form = OnsiteLocationForm()
|
form = OnsiteLocationForm()
|
||||||
|
|
||||||
return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
|
return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
|
||||||
|
|
||||||
|
|
||||||
@ -1915,7 +1916,7 @@ def candidate_interview_view(request, slug):
|
|||||||
"job": job,
|
"job": job,
|
||||||
"candidates": job.interview_candidates,
|
"candidates": job.interview_candidates,
|
||||||
"current_stage": "Interview",
|
"current_stage": "Interview",
|
||||||
|
|
||||||
}
|
}
|
||||||
return render(request, "recruitment/candidate_interview_view.html", context)
|
return render(request, "recruitment/candidate_interview_view.html", context)
|
||||||
|
|
||||||
@ -2025,32 +2026,32 @@ def delete_meeting_for_candidate(request, slug, candidate_pk, meeting_id):
|
|||||||
def delete_zoom_meeting_for_candidate(request, slug, candidate_pk, meeting_id):
|
def delete_zoom_meeting_for_candidate(request, slug, candidate_pk, meeting_id):
|
||||||
"""
|
"""
|
||||||
Deletes a specific Zoom (Remote) meeting instance.
|
Deletes a specific Zoom (Remote) meeting instance.
|
||||||
The ZoomMeetingDetails object inherits from InterviewLocation,
|
The ZoomMeetingDetails object inherits from InterviewLocation,
|
||||||
which is linked to ScheduledInterview. Deleting the subclass
|
which is linked to ScheduledInterview. Deleting the subclass
|
||||||
should trigger CASCADE/SET_NULL correctly on the FK chain.
|
should trigger CASCADE/SET_NULL correctly on the FK chain.
|
||||||
"""
|
"""
|
||||||
job = get_object_or_404(JobPosting, slug=slug)
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
candidate = get_object_or_404(Application, pk=candidate_pk)
|
candidate = get_object_or_404(Application, pk=candidate_pk)
|
||||||
|
|
||||||
# Target the specific Zoom meeting details instance
|
# Target the specific Zoom meeting details instance
|
||||||
meeting = get_object_or_404(ZoomMeetingDetails, pk=meeting_id)
|
meeting = get_object_or_404(ZoomMeetingDetails, pk=meeting_id)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
# 1. Attempt to delete the meeting from the external Zoom API
|
# 1. Attempt to delete the meeting from the external Zoom API
|
||||||
result = delete_zoom_meeting(meeting.meeting_id)
|
result = delete_zoom_meeting(meeting.meeting_id)
|
||||||
|
|
||||||
# 2. Check for success OR if the meeting was already deleted externally
|
# 2. Check for success OR if the meeting was already deleted externally
|
||||||
if (
|
if (
|
||||||
result["status"] == "success"
|
result["status"] == "success"
|
||||||
or "Meeting does not exist" in result["details"]["message"]
|
or "Meeting does not exist" in result["details"]["message"]
|
||||||
):
|
):
|
||||||
# 3. Delete the local Django object. This will delete the base
|
# 3. Delete the local Django object. This will delete the base
|
||||||
# InterviewLocation object and update the ScheduledInterview FK.
|
# InterviewLocation object and update the ScheduledInterview FK.
|
||||||
meeting.delete()
|
meeting.delete()
|
||||||
messages.success(request, f"Remote meeting for {candidate.name} deleted successfully.")
|
messages.success(request, f"Remote meeting for {candidate.name} deleted successfully.")
|
||||||
else:
|
else:
|
||||||
messages.error(request, result["message"])
|
messages.error(request, result["message"])
|
||||||
|
|
||||||
return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug}))
|
return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug}))
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
@ -2927,6 +2928,34 @@ def admin_settings(request):
|
|||||||
context = {"staffs": staffs, "form": form}
|
context = {"staffs": staffs, "form": form}
|
||||||
return render(request, "user/admin_settings.html", context)
|
return render(request, "user/admin_settings.html", context)
|
||||||
|
|
||||||
|
@staff_user_required
|
||||||
|
def staff_assignment_view(request, slug):
|
||||||
|
"""
|
||||||
|
View to assign staff to a job posting
|
||||||
|
"""
|
||||||
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
|
staff_users = User.objects.filter(user_type="staff", is_superuser=False)
|
||||||
|
applications = job.applications.all()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = StaffAssignmentForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
assignment = form.save(commit=False)
|
||||||
|
messages.success(request, f"Staff assigned to job '{job.title}' successfully!")
|
||||||
|
return redirect("job_detail", slug=job.slug)
|
||||||
|
else:
|
||||||
|
messages.error(request, "Please correct the errors below.")
|
||||||
|
else:
|
||||||
|
form = StaffAssignmentForm()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"job": job,
|
||||||
|
"applications": applications,
|
||||||
|
"staff_users": staff_users,
|
||||||
|
"form": form,
|
||||||
|
}
|
||||||
|
return render(request, "recruitment/staff_assignment_view.html", context)
|
||||||
|
|
||||||
|
|
||||||
from django.contrib.auth.forms import SetPasswordForm
|
from django.contrib.auth.forms import SetPasswordForm
|
||||||
|
|
||||||
@ -3004,6 +3033,8 @@ def zoom_webhook_view(request):
|
|||||||
@staff_user_required
|
@staff_user_required
|
||||||
def add_meeting_comment(request, slug):
|
def add_meeting_comment(request, slug):
|
||||||
"""Add a comment to a meeting"""
|
"""Add a comment to a meeting"""
|
||||||
|
# from .forms import MeetingCommentForm
|
||||||
|
|
||||||
meeting = get_object_or_404(ZoomMeetingDetails, slug=slug)
|
meeting = get_object_or_404(ZoomMeetingDetails, slug=slug)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@ -3219,7 +3250,7 @@ def agency_detail(request, slug):
|
|||||||
candidates = Application.objects.filter(hiring_agency=agency).order_by(
|
candidates = Application.objects.filter(hiring_agency=agency).order_by(
|
||||||
"-created_at"
|
"-created_at"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
total_candidates = candidates.count()
|
total_candidates = candidates.count()
|
||||||
active_candidates = candidates.filter(
|
active_candidates = candidates.filter(
|
||||||
@ -4577,7 +4608,7 @@ 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
|
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)
|
||||||
@ -4586,24 +4617,51 @@ 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!")
|
# Send email if message_type is 'email' and recipient has email
|
||||||
|
if message.message_type == 'email' and message.recipient and message.recipient.email:
|
||||||
|
try:
|
||||||
|
from .email_service import send_bulk_email
|
||||||
|
|
||||||
|
email_result = send_bulk_email(
|
||||||
|
subject=message.subject,
|
||||||
|
message=message.content,
|
||||||
|
recipient_list=[message.recipient.email],
|
||||||
|
request=request,
|
||||||
|
attachments=None,
|
||||||
|
async_task_=True,
|
||||||
|
from_interview=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if email_result["success"]:
|
||||||
|
message.is_email_sent = True
|
||||||
|
message.email_address = message.recipient.email
|
||||||
|
message.save(update_fields=['is_email_sent', 'email_address'])
|
||||||
|
messages.success(request, "Message sent successfully via email!")
|
||||||
|
else:
|
||||||
|
messages.warning(request, f"Message saved but email failed: {email_result.get('message', 'Unknown error')}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messages.warning(request, f"Message saved but email sending failed: {str(e)}")
|
||||||
|
else:
|
||||||
|
messages.success(request, "Message sent successfully!")
|
||||||
|
|
||||||
["recipient", "job", "subject", "content", "message_type"]
|
["recipient", "job", "subject", "content", "message_type"]
|
||||||
recipient_email = form.cleaned_data['recipient'].email # Assuming recipient is a User or Model with an 'email' field
|
recipient_email = form.cleaned_data['recipient'].email # Assuming recipient is a User or Model with an 'email' field
|
||||||
subject = form.cleaned_data['subject']
|
subject = form.cleaned_data['subject']
|
||||||
custom_message = form.cleaned_data['content']
|
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
|
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
|
sender_user_id = request.user.id
|
||||||
|
|
||||||
task_id = async_task(
|
task_id = async_task(
|
||||||
'recruitment.tasks.send_bulk_email_task',
|
'recruitment.tasks.send_bulk_email_task',
|
||||||
subject,
|
subject,
|
||||||
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
|
||||||
|
|
||||||
sender_user_id=sender_user_id,
|
sender_user_id=sender_user_id,
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
hook='recruitment.tasks.email_success_hook')
|
hook='recruitment.tasks.email_success_hook')
|
||||||
|
|
||||||
logger.info(f"{task_id} queued.")
|
logger.info(f"{task_id} queued.")
|
||||||
return redirect("message_list")
|
return redirect("message_list")
|
||||||
else:
|
else:
|
||||||
@ -4644,7 +4702,34 @@ def message_reply(request, message_id):
|
|||||||
message.recipient = parent_message.sender
|
message.recipient = parent_message.sender
|
||||||
message.save()
|
message.save()
|
||||||
|
|
||||||
messages.success(request, "Reply sent successfully!")
|
# Send email if message_type is 'email' and recipient has email
|
||||||
|
if message.message_type == 'email' and message.recipient and message.recipient.email:
|
||||||
|
try:
|
||||||
|
from .email_service import send_bulk_email
|
||||||
|
|
||||||
|
email_result = send_bulk_email(
|
||||||
|
subject=message.subject,
|
||||||
|
message=message.content,
|
||||||
|
recipient_list=[message.recipient.email],
|
||||||
|
request=request,
|
||||||
|
attachments=None,
|
||||||
|
async_task_=True,
|
||||||
|
from_interview=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if email_result["success"]:
|
||||||
|
message.is_email_sent = True
|
||||||
|
message.email_address = message.recipient.email
|
||||||
|
message.save(update_fields=['is_email_sent', 'email_address'])
|
||||||
|
messages.success(request, "Reply sent successfully via email!")
|
||||||
|
else:
|
||||||
|
messages.warning(request, f"Reply saved but email failed: {email_result.get('message', 'Unknown error')}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messages.warning(request, f"Reply saved but email sending failed: {str(e)}")
|
||||||
|
else:
|
||||||
|
messages.success(request, "Reply sent successfully!")
|
||||||
|
|
||||||
return redirect("message_detail", message_id=parent_message.id)
|
return redirect("message_detail", message_id=parent_message.id)
|
||||||
else:
|
else:
|
||||||
messages.error(request, "Please correct the errors below.")
|
messages.error(request, "Please correct the errors below.")
|
||||||
@ -5102,7 +5187,7 @@ def compose_candidate_email(request, job_slug):
|
|||||||
from .email_service import send_bulk_email
|
from .email_service import send_bulk_email
|
||||||
|
|
||||||
job = get_object_or_404(JobPosting, slug=job_slug)
|
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||||
|
|
||||||
# # candidate = get_object_or_404(Application, slug=candidate_slug, job=job)
|
# # candidate = get_object_or_404(Application, slug=candidate_slug, job=job)
|
||||||
# if request.method == "POST":
|
# if request.method == "POST":
|
||||||
# form = CandidateEmailForm(job, candidate, request.POST)
|
# form = CandidateEmailForm(job, candidate, request.POST)
|
||||||
@ -5111,7 +5196,7 @@ def compose_candidate_email(request, job_slug):
|
|||||||
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
|
||||||
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)
|
||||||
@ -5119,7 +5204,7 @@ def compose_candidate_email(request, job_slug):
|
|||||||
print("form is valid ...")
|
print("form is valid ...")
|
||||||
# Get email addresses
|
# Get email addresses
|
||||||
email_addresses = form.get_email_addresses()
|
email_addresses = form.get_email_addresses()
|
||||||
|
|
||||||
|
|
||||||
if not email_addresses:
|
if not email_addresses:
|
||||||
messages.error(request, 'No email selected')
|
messages.error(request, 'No email selected')
|
||||||
@ -5147,17 +5232,35 @@ def compose_candidate_email(request, job_slug):
|
|||||||
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
|
job=job
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if email_result["success"]:
|
if email_result["success"]:
|
||||||
|
for candidate in candidates:
|
||||||
|
if hasattr(candidate, 'person') and candidate.person:
|
||||||
|
try:
|
||||||
|
Message.objects.create(
|
||||||
|
sender=request.user,
|
||||||
|
recipient=candidate.person.user,
|
||||||
|
subject=subject,
|
||||||
|
content=message,
|
||||||
|
job=job,
|
||||||
|
message_type='email',
|
||||||
|
is_email_sent=True,
|
||||||
|
email_address=candidate.person.email if candidate.person.email else candidate.email
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but don't fail the entire process
|
||||||
|
print(f"Error creating message")
|
||||||
|
|
||||||
messages.success(
|
messages.success(
|
||||||
request,
|
request,
|
||||||
f"Email sent successfully to {len(email_addresses)} recipient(s).",
|
f"Email will be sent shortly to recipient(s)",
|
||||||
)
|
)
|
||||||
|
response = HttpResponse(status=200)
|
||||||
|
response.headers["HX-Refresh"] = "true"
|
||||||
return redirect("candidate_interview_view", slug=job.slug)
|
return response
|
||||||
|
# return redirect("candidate_interview_view", slug=job.slug)
|
||||||
else:
|
else:
|
||||||
messages.error(
|
messages.error(
|
||||||
request,
|
request,
|
||||||
@ -5181,12 +5284,10 @@ def compose_candidate_email(request, job_slug):
|
|||||||
{"form": form, "job": job, "candidate": candidates},
|
{"form": form, "job": job, "candidate": candidates},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Form validation errors
|
# Form validation errors
|
||||||
print('form is not valid')
|
|
||||||
print(form.errors)
|
|
||||||
messages.error(request, "Please correct the errors below.")
|
messages.error(request, "Please correct the errors below.")
|
||||||
|
|
||||||
# For HTMX requests, return error response
|
# For HTMX requests, return error response
|
||||||
@ -5472,7 +5573,7 @@ def create_interview_participants(request, slug):
|
|||||||
Uses interview_pk because ScheduledInterview has no slug.
|
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)
|
||||||
|
|
||||||
# Get the slug from the related InterviewLocation (the "meeting")
|
# Get the slug from the related InterviewLocation (the "meeting")
|
||||||
meeting_slug = schedule_interview.interview_location.slug # ✅ Correct
|
meeting_slug = schedule_interview.interview_location.slug # ✅ Correct
|
||||||
|
|
||||||
@ -5561,9 +5662,29 @@ def send_interview_email(request, slug):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if email_result["success"]:
|
if email_result["success"]:
|
||||||
|
# Create Message records for each participant after successful email send
|
||||||
|
messages_created = 0
|
||||||
|
for participant in participants:
|
||||||
|
if hasattr(participant, 'user') and participant.user:
|
||||||
|
try:
|
||||||
|
Message.objects.create(
|
||||||
|
sender=request.user,
|
||||||
|
recipient=participant.user,
|
||||||
|
subject=subject,
|
||||||
|
content=msg_participants,
|
||||||
|
job=job,
|
||||||
|
message_type='email',
|
||||||
|
is_email_sent=True,
|
||||||
|
email_address=participant.email if hasattr(participant, 'email') else ''
|
||||||
|
)
|
||||||
|
messages_created += 1
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but don't fail the entire process
|
||||||
|
print(f"Error creating message for {participant.email if hasattr(participant, 'email') else participant}: {e}")
|
||||||
|
|
||||||
messages.success(
|
messages.success(
|
||||||
request,
|
request,
|
||||||
f"Email sent successfully to {total_recipients} recipient(s).",
|
f"Email will be sent shortly to {total_recipients} recipient(s).",
|
||||||
)
|
)
|
||||||
|
|
||||||
return redirect("list_meetings")
|
return redirect("list_meetings")
|
||||||
@ -5590,33 +5711,33 @@ class MeetingListView(ListView):
|
|||||||
"""
|
"""
|
||||||
A unified view to list both Remote and Onsite Scheduled Interviews.
|
A unified view to list both Remote and Onsite Scheduled Interviews.
|
||||||
"""
|
"""
|
||||||
model = ScheduledInterview
|
model = ScheduledInterview
|
||||||
template_name = "meetings/list_meetings.html"
|
template_name = "meetings/list_meetings.html"
|
||||||
context_object_name = "meetings"
|
context_object_name = "meetings"
|
||||||
paginate_by = 100
|
paginate_by = 100
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# Start with a base queryset, ensuring an InterviewLocation link exists.
|
# Start with a base queryset, ensuring an InterviewLocation link exists.
|
||||||
queryset = super().get_queryset().filter(interview_location__isnull=False).select_related(
|
queryset = super().get_queryset().filter(interview_location__isnull=False).select_related(
|
||||||
'interview_location',
|
'interview_location',
|
||||||
'job',
|
'job',
|
||||||
'application__person',
|
'application__person',
|
||||||
'application',
|
'application',
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
'interview_location__zoommeetingdetails',
|
'interview_location__zoommeetingdetails',
|
||||||
'interview_location__onsitelocationdetails',
|
'interview_location__onsitelocationdetails',
|
||||||
)
|
)
|
||||||
# Note: Printing the queryset here can consume memory for large sets.
|
# Note: Printing the queryset here can consume memory for large sets.
|
||||||
|
|
||||||
# Get filters from GET request
|
# Get filters from GET request
|
||||||
search_query = self.request.GET.get("q")
|
search_query = self.request.GET.get("q")
|
||||||
status_filter = self.request.GET.get("status")
|
status_filter = self.request.GET.get("status")
|
||||||
candidate_name_filter = self.request.GET.get("candidate_name")
|
candidate_name_filter = self.request.GET.get("candidate_name")
|
||||||
type_filter = self.request.GET.get("type")
|
type_filter = self.request.GET.get("type")
|
||||||
print(type_filter)
|
print(type_filter)
|
||||||
|
|
||||||
# 2. Type Filter: Filter based on the base InterviewLocation's type
|
# 2. Type Filter: Filter based on the base InterviewLocation's type
|
||||||
if type_filter:
|
if type_filter:
|
||||||
# Use .title() to handle case variations from URL (e.g., 'remote' -> 'Remote')
|
# Use .title() to handle case variations from URL (e.g., 'remote' -> 'Remote')
|
||||||
normalized_type = type_filter.title()
|
normalized_type = type_filter.title()
|
||||||
print(normalized_type)
|
print(normalized_type)
|
||||||
@ -5629,53 +5750,53 @@ class MeetingListView(ListView):
|
|||||||
if search_query:
|
if search_query:
|
||||||
queryset = queryset.filter(interview_location__topic__icontains=search_query)
|
queryset = queryset.filter(interview_location__topic__icontains=search_query)
|
||||||
|
|
||||||
# 4. Status Filter
|
# 4. Status Filter
|
||||||
if status_filter:
|
if status_filter:
|
||||||
queryset = queryset.filter(status=status_filter)
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
|
||||||
# 5. Candidate Name Filter
|
# 5. Candidate Name Filter
|
||||||
if candidate_name_filter:
|
if candidate_name_filter:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
Q(application__person__first_name__icontains=candidate_name_filter) |
|
Q(application__person__first_name__icontains=candidate_name_filter) |
|
||||||
Q(application__person__last_name__icontains=candidate_name_filter)
|
Q(application__person__last_name__icontains=candidate_name_filter)
|
||||||
)
|
)
|
||||||
|
|
||||||
return queryset.order_by("-interview_date", "-interview_time")
|
return queryset.order_by("-interview_date", "-interview_time")
|
||||||
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
# Pass filters back to the template for retention
|
# Pass filters back to the template for retention
|
||||||
context["search_query"] = self.request.GET.get("q", "")
|
context["search_query"] = self.request.GET.get("q", "")
|
||||||
context["status_filter"] = self.request.GET.get("status", "")
|
context["status_filter"] = self.request.GET.get("status", "")
|
||||||
context["candidate_name_filter"] = self.request.GET.get("candidate_name", "")
|
context["candidate_name_filter"] = self.request.GET.get("candidate_name", "")
|
||||||
context["type_filter"] = self.request.GET.get("type", "")
|
context["type_filter"] = self.request.GET.get("type", "")
|
||||||
|
|
||||||
|
|
||||||
# CORRECTED: Pass the status choices from the model class for the filter dropdown
|
# CORRECTED: Pass the status choices from the model class for the filter dropdown
|
||||||
context["status_choices"] = self.model.InterviewStatus.choices
|
context["status_choices"] = self.model.InterviewStatus.choices
|
||||||
|
|
||||||
meetings_data = []
|
meetings_data = []
|
||||||
|
|
||||||
for interview in context.get(self.context_object_name, []):
|
for interview in context.get(self.context_object_name, []):
|
||||||
location = interview.interview_location
|
location = interview.interview_location
|
||||||
details = None
|
details = None
|
||||||
|
|
||||||
if not location:
|
if not location:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Determine and fetch the CONCRETE details object (prefetched)
|
# Determine and fetch the CONCRETE details object (prefetched)
|
||||||
if location.location_type == location.LocationType.REMOTE:
|
if location.location_type == location.LocationType.REMOTE:
|
||||||
details = getattr(location, 'zoommeetingdetails', None)
|
details = getattr(location, 'zoommeetingdetails', None)
|
||||||
elif location.location_type == location.LocationType.ONSITE:
|
elif location.location_type == location.LocationType.ONSITE:
|
||||||
details = getattr(location, 'onsitelocationdetails', None)
|
details = getattr(location, 'onsitelocationdetails', None)
|
||||||
|
|
||||||
# Combine date and time for template display/sorting
|
# Combine date and time for template display/sorting
|
||||||
start_datetime = None
|
start_datetime = None
|
||||||
if interview.interview_date and interview.interview_time:
|
if interview.interview_date and interview.interview_time:
|
||||||
start_datetime = datetime.combine(interview.interview_date, interview.interview_time)
|
start_datetime = datetime.combine(interview.interview_date, interview.interview_time)
|
||||||
|
|
||||||
# SUCCESS: Build the data dictionary
|
# SUCCESS: Build the data dictionary
|
||||||
meetings_data.append({
|
meetings_data.append({
|
||||||
'interview': interview,
|
'interview': interview,
|
||||||
@ -5683,43 +5804,43 @@ 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'),
|
||||||
# Use details.join_url and fallback to None, if Remote
|
# Use details.join_url and fallback to None, if Remote
|
||||||
'join_url': getattr(details, 'join_url', None) if location.location_type == location.LocationType.REMOTE else None,
|
'join_url': getattr(details, 'join_url', None) if location.location_type == location.LocationType.REMOTE else None,
|
||||||
'meeting_id': getattr(details, 'meeting_id', None),
|
'meeting_id': getattr(details, 'meeting_id', None),
|
||||||
# Use the primary status from the ScheduledInterview record
|
# Use the primary status from the ScheduledInterview record
|
||||||
'status': interview.status,
|
'status': interview.status,
|
||||||
})
|
})
|
||||||
|
|
||||||
context["meetings_data"] = meetings_data
|
context["meetings_data"] = meetings_data
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def reschedule_onsite_meeting(request, slug, candidate_id, meeting_id):
|
def reschedule_onsite_meeting(request, slug, candidate_id, meeting_id):
|
||||||
"""Handles the rescheduling of an Onsite Interview (updates OnsiteLocationDetails)."""
|
"""Handles the rescheduling of an Onsite Interview (updates OnsiteLocationDetails)."""
|
||||||
job = get_object_or_404(JobPosting, slug=slug)
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
candidate = get_object_or_404(Application, pk=candidate_id)
|
candidate = get_object_or_404(Application, pk=candidate_id)
|
||||||
|
|
||||||
# Fetch the OnsiteLocationDetails instance, ensuring it belongs to this candidate.
|
# Fetch the OnsiteLocationDetails instance, ensuring it belongs to this candidate.
|
||||||
# We use the reverse relationship: onsitelocationdetails -> interviewlocation -> scheduledinterview -> application
|
# We use the reverse relationship: onsitelocationdetails -> interviewlocation -> scheduledinterview -> application
|
||||||
# The 'interviewlocation_ptr' is the foreign key field name if OnsiteLocationDetails is a proxy/multi-table inheritance model.
|
# The 'interviewlocation_ptr' is the foreign key field name if OnsiteLocationDetails is a proxy/multi-table inheritance model.
|
||||||
onsite_meeting = get_object_or_404(
|
onsite_meeting = get_object_or_404(
|
||||||
OnsiteLocationDetails,
|
OnsiteLocationDetails,
|
||||||
pk=meeting_id,
|
pk=meeting_id,
|
||||||
# Correct filter: Use the reverse link through the ScheduledInterview model.
|
# Correct filter: Use the reverse link through the ScheduledInterview model.
|
||||||
# This assumes your ScheduledInterview model links back to a generic InterviewLocation base.
|
# This assumes your ScheduledInterview model links back to a generic InterviewLocation base.
|
||||||
interviewlocation_ptr__scheduled_interview__application=candidate
|
interviewlocation_ptr__scheduled_interview__application=candidate
|
||||||
)
|
)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = OnsiteReshuduleForm(request.POST, instance=onsite_meeting)
|
form = OnsiteReshuduleForm(request.POST, instance=onsite_meeting)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
instance = form.save(commit=False)
|
instance = form.save(commit=False)
|
||||||
|
|
||||||
if instance.start_time < timezone.now():
|
if instance.start_time < timezone.now():
|
||||||
messages.error(request, "Start time must be in the future for rescheduling.")
|
messages.error(request, "Start time must be in the future for rescheduling.")
|
||||||
return render(request, "meetings/reschedule_onsite.html", {"form": form, "job": job, "candidate": candidate, "meeting": onsite_meeting})
|
return render(request, "meetings/reschedule_onsite.html", {"form": form, "job": job, "candidate": candidate, "meeting": onsite_meeting})
|
||||||
@ -5734,10 +5855,10 @@ def reschedule_onsite_meeting(request, slug, candidate_id, meeting_id):
|
|||||||
scheduled_interview.save()
|
scheduled_interview.save()
|
||||||
except ScheduledInterview.DoesNotExist:
|
except ScheduledInterview.DoesNotExist:
|
||||||
messages.warning(request, "Parent schedule record not found. Status not updated.")
|
messages.warning(request, "Parent schedule record not found. Status not updated.")
|
||||||
|
|
||||||
instance.save()
|
instance.save()
|
||||||
messages.success(request, "Onsite meeting successfully rescheduled! ✅")
|
messages.success(request, "Onsite meeting successfully rescheduled! ✅")
|
||||||
|
|
||||||
return redirect(reverse("candidate_interview_view", kwargs={'slug': job.slug}))
|
return redirect(reverse("candidate_interview_view", kwargs={'slug': job.slug}))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@ -5762,16 +5883,16 @@ def delete_onsite_meeting_for_candidate(request, slug, candidate_pk, meeting_id)
|
|||||||
"""
|
"""
|
||||||
job = get_object_or_404(JobPosting, slug=slug)
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
candidate = get_object_or_404(Application, pk=candidate_pk)
|
candidate = get_object_or_404(Application, pk=candidate_pk)
|
||||||
|
|
||||||
# Target the specific Onsite meeting details instance
|
# Target the specific Onsite meeting details instance
|
||||||
meeting = get_object_or_404(OnsiteLocationDetails, pk=meeting_id)
|
meeting = get_object_or_404(OnsiteLocationDetails, pk=meeting_id)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
# Delete the local Django object.
|
# Delete the local Django object.
|
||||||
# This deletes the base InterviewLocation and updates the ScheduledInterview FK.
|
# This deletes the base InterviewLocation and updates the ScheduledInterview FK.
|
||||||
meeting.delete()
|
meeting.delete()
|
||||||
messages.success(request, f"Onsite meeting for {candidate.name} deleted successfully.")
|
messages.success(request, f"Onsite meeting for {candidate.name} deleted successfully.")
|
||||||
|
|
||||||
return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug}))
|
return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug}))
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
@ -5798,17 +5919,17 @@ def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk):
|
|||||||
"""
|
"""
|
||||||
job = get_object_or_404(JobPosting, slug=slug)
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
candidate = get_object_or_404(Application, pk=candidate_pk)
|
candidate = get_object_or_404(Application, pk=candidate_pk)
|
||||||
|
|
||||||
action_url = reverse('schedule_onsite_meeting_for_candidate',
|
action_url = reverse('schedule_onsite_meeting_for_candidate',
|
||||||
kwargs={'slug': job.slug, 'candidate_pk': candidate.pk})
|
kwargs={'slug': job.slug, 'candidate_pk': candidate.pk})
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
# Use the new form
|
# Use the new form
|
||||||
form = OnsiteScheduleForm(request.POST)
|
form = OnsiteScheduleForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
|
||||||
cleaned_data = form.cleaned_data
|
cleaned_data = form.cleaned_data
|
||||||
|
|
||||||
# 1. Create OnsiteLocationDetails
|
# 1. Create OnsiteLocationDetails
|
||||||
onsite_loc = OnsiteLocationDetails(
|
onsite_loc = OnsiteLocationDetails(
|
||||||
topic=cleaned_data['topic'],
|
topic=cleaned_data['topic'],
|
||||||
@ -5816,8 +5937,8 @@ def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk):
|
|||||||
room_number=cleaned_data['room_number'],
|
room_number=cleaned_data['room_number'],
|
||||||
start_time=cleaned_data['start_time'],
|
start_time=cleaned_data['start_time'],
|
||||||
duration=cleaned_data['duration'],
|
duration=cleaned_data['duration'],
|
||||||
status=OnsiteLocationDetails.Status.WAITING,
|
status=OnsiteLocationDetails.Status.WAITING,
|
||||||
location_type=InterviewLocation.LocationType.ONSITE,
|
location_type=InterviewLocation.LocationType.ONSITE,
|
||||||
)
|
)
|
||||||
onsite_loc.save()
|
onsite_loc.save()
|
||||||
|
|
||||||
@ -5835,7 +5956,7 @@ def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk):
|
|||||||
interview_time=interview_time,
|
interview_time=interview_time,
|
||||||
status=ScheduledInterview.InterviewStatus.SCHEDULED,
|
status=ScheduledInterview.InterviewStatus.SCHEDULED,
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, "Onsite interview scheduled successfully. ✅")
|
messages.success(request, "Onsite interview scheduled successfully. ✅")
|
||||||
return redirect(reverse("candidate_interview_view", kwargs={'slug': job.slug}))
|
return redirect(reverse("candidate_interview_view", kwargs={'slug': job.slug}))
|
||||||
|
|
||||||
@ -5846,15 +5967,15 @@ def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk):
|
|||||||
'job': job, # Pass the object itself for ModelChoiceField
|
'job': job, # Pass the object itself for ModelChoiceField
|
||||||
}
|
}
|
||||||
# Use the new form
|
# Use the new form
|
||||||
form = OnsiteScheduleForm(initial=initial_data)
|
form = OnsiteScheduleForm(initial=initial_data)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"form": form,
|
"form": form,
|
||||||
"job": job,
|
"job": job,
|
||||||
"candidate": candidate,
|
"candidate": candidate,
|
||||||
"action_url": action_url,
|
"action_url": action_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, "meetings/schedule_onsite_meeting_form.html", context)
|
return render(request, "meetings/schedule_onsite_meeting_form.html", context)
|
||||||
|
|
||||||
|
|
||||||
@ -5892,7 +6013,7 @@ def meeting_details(request, slug):
|
|||||||
|
|
||||||
# Forms for modals
|
# Forms for modals
|
||||||
participant_form = InterviewParticpantsForm(instance=interview)
|
participant_form = InterviewParticpantsForm(instance=interview)
|
||||||
|
|
||||||
|
|
||||||
# email_form = InterviewEmailForm(
|
# email_form = InterviewEmailForm(
|
||||||
# candidate=candidate,
|
# candidate=candidate,
|
||||||
|
|||||||
@ -317,7 +317,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
||||||
<main class="container-fluid flex-grow-1" style="max-width: 1600px; margin: 0 auto;">
|
<main id="messageContent" class="container-fluid flex-grow-1" style="max-width: 1600px; margin: 0 auto;">
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mt-2" role="alert">
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mt-2" role="alert">
|
||||||
@ -417,9 +417,21 @@
|
|||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeOpenBootstrapModal() {
|
||||||
|
const openModalElement = document.querySelector('.modal.show');
|
||||||
|
if (openModalElement) {
|
||||||
|
const modal = bootstrap.Modal.getInstance(openModalElement);
|
||||||
|
if (modal) {
|
||||||
|
modal.hide();
|
||||||
|
} else {
|
||||||
|
console.warn("Found an open modal element, but could not get the Bootstrap Modal instance.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("No open Bootstrap Modal found to close.");
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Message Count JavaScript -->
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Update unread message count on page load
|
// Update unread message count on page load
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<ul class="messages">
|
<ul class="messages">
|
||||||
@ -15,9 +15,15 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form hx-boost="true" method="post" id="email-compose-form" action="{% url 'compose_candidate_email' job.slug %}" hx-include="#candidate-form">
|
<form hx-boost="true" method="post" id="email-compose-form" action="{% url 'compose_candidate_email' job.slug %}"
|
||||||
|
hx-include="#candidate-form"
|
||||||
|
hx-target="#messageContent"
|
||||||
|
hx-select="#messageContent"
|
||||||
|
hx-push-url="false"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-on::after-request="new bootstrap.Modal('#emailModal')).hide()">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<!-- Recipients Field -->
|
<!-- Recipients Field -->
|
||||||
<!-- Recipients Field -->
|
<!-- Recipients Field -->
|
||||||
@ -41,7 +47,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Subject Field -->
|
<!-- Subject Field -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="{{ form.subject.id_for_label }}" class="form-label fw-bold">
|
<label for="{{ form.subject.id_for_label }}" class="form-label fw-bold">
|
||||||
@ -57,7 +63,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Message Field -->
|
<!-- Message Field -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -171,8 +177,8 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const form = document.getElementById('email-compose-form');
|
const form = document.getElementById('email-compose-form1');
|
||||||
const sendBtn = document.getElementById('send-email-btn');
|
const sendBtn = document.getElementById('send-email-btn1');
|
||||||
const loadingOverlay = document.getElementById('email-loading-overlay');
|
const loadingOverlay = document.getElementById('email-loading-overlay');
|
||||||
const messagesContainer = document.getElementById('email-messages-container');
|
const messagesContainer = document.getElementById('email-messages-container');
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
--kaauh-teal-light: #4bb3be; /* For active glow */
|
--kaauh-teal-light: #4bb3be; /* For active glow */
|
||||||
--kaauh-border: #eaeff3;
|
--kaauh-border: #eaeff3;
|
||||||
--kaauh-primary-text: #343a40;
|
--kaauh-primary-text: #343a40;
|
||||||
|
|
||||||
/* Consistent Status/Color Map (aligning with theme/bootstrap defaults) */
|
/* Consistent Status/Color Map (aligning with theme/bootstrap defaults) */
|
||||||
--color-draft: #6c757d; /* Secondary Gray */
|
--color-draft: #6c757d; /* Secondary Gray */
|
||||||
--color-active: var(--kaauh-teal); /* Primary Teal */
|
--color-active: var(--kaauh-teal); /* Primary Teal */
|
||||||
@ -26,7 +26,7 @@
|
|||||||
/* Primary Color Overrides for Bootstrap Classes */
|
/* Primary Color Overrides for Bootstrap Classes */
|
||||||
.text-primary { color: var(--kaauh-teal) !important; }
|
.text-primary { color: var(--kaauh-teal) !important; }
|
||||||
.bg-primary { background-color: var(--kaauh-teal) !important; }
|
.bg-primary { background-color: var(--kaauh-teal) !important; }
|
||||||
|
|
||||||
/* Status Badge Theme Mapping */
|
/* Status Badge Theme Mapping */
|
||||||
.status-badge.bg-success { background-color: var(--color-active) !important; }
|
.status-badge.bg-success { background-color: var(--color-active) !important; }
|
||||||
.status-badge.bg-secondary { background-color: var(--color-draft) !important; }
|
.status-badge.bg-secondary { background-color: var(--color-draft) !important; }
|
||||||
@ -89,9 +89,9 @@
|
|||||||
.nav-tabs {
|
.nav-tabs {
|
||||||
border-bottom: 1px solid var(--kaauh-border);
|
border-bottom: 1px solid var(--kaauh-border);
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-tabs .nav-link {
|
.nav-tabs .nav-link {
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 3px solid transparent;
|
border-bottom: 3px solid transparent;
|
||||||
@ -109,7 +109,7 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
border-right-color: transparent !important;
|
border-right-color: transparent !important;
|
||||||
margin-bottom: -1px;
|
margin-bottom: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main Action Button Style */
|
/* Main Action Button Style */
|
||||||
@ -141,7 +141,7 @@
|
|||||||
border-left: 4px solid var(--kaauh-teal);
|
border-left: 4px solid var(--kaauh-teal);
|
||||||
background-color: #f0faff;
|
background-color: #f0faff;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -154,7 +154,7 @@
|
|||||||
<li class="breadcrumb-item"><a href="{% url 'job_list' %}" class="text-secondary">Jobs</a></li>
|
<li class="breadcrumb-item"><a href="{% url 'job_list' %}" class="text-secondary">Jobs</a></li>
|
||||||
<li class="breadcrumb-item active" aria-current="page" style="
|
<li class="breadcrumb-item active" aria-current="page" style="
|
||||||
color: #F43B5E; /* Rosy Accent Color */
|
color: #F43B5E; /* Rosy Accent Color */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
">Job Detail</li>
|
">Job Detail</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
@ -169,7 +169,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="mb-1">{{ job.title }}</h2>
|
<h2 class="mb-1">{{ job.title }}</h2>
|
||||||
<small class="text-light">{% trans "JOB ID: "%}{{ job.internal_job_id }}</small>
|
<small class="text-light">{% trans "JOB ID: "%}{{ job.internal_job_id }}</small>
|
||||||
|
|
||||||
{# Deadline #}
|
{# Deadline #}
|
||||||
{% if job.application_deadline %}
|
{% if job.application_deadline %}
|
||||||
<div class="text-light mt-1">
|
<div class="text-light mt-1">
|
||||||
@ -180,10 +180,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex align-items-center gap-2 mt-2 mt-md-0">
|
<div class="d-flex align-items-center gap-2 mt-2 mt-md-0">
|
||||||
|
|
||||||
{# Status badge #}
|
{# Status badge #}
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<span class="status-badge
|
<span class="status-badge
|
||||||
{% if job.status == "ACTIVE" %}bg-success
|
{% if job.status == "ACTIVE" %}bg-success
|
||||||
{% elif job.status == "DRAFT" %}bg-secondary
|
{% elif job.status == "DRAFT" %}bg-secondary
|
||||||
{% elif job.status == "CLOSED" %}bg-warning
|
{% elif job.status == "CLOSED" %}bg-warning
|
||||||
@ -192,7 +192,7 @@
|
|||||||
{% else %}bg-secondary{% endif %}">
|
{% else %}bg-secondary{% endif %}">
|
||||||
{{ job.get_status_display }}
|
{{ job.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button type="button" class="btn btn-outline-light btn-sm ms-2" data-bs-toggle="modal" data-bs-target="#editStatusModal">
|
<button type="button" class="btn btn-outline-light btn-sm ms-2" data-bs-toggle="modal" data-bs-target="#editStatusModal">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -216,7 +216,7 @@
|
|||||||
|
|
||||||
{# CONTENT: CORE DETAILS (No Tabs) #}
|
{# CONTENT: CORE DETAILS (No Tabs) #}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
||||||
<h5 class="text-muted mb-3">{% trans "Administrative & Location" %}
|
<h5 class="text-muted mb-3">{% trans "Administrative & Location" %}
|
||||||
<a href="{% url 'job_update' job.slug %}" class="btn btn-main-action btn-sm"><li class="fa fa-edit"></li>{% trans "Edit JOb" %}</a>
|
<a href="{% url 'job_update' job.slug %}" class="btn btn-main-action btn-sm"><li class="fa fa-edit"></li>{% trans "Edit JOb" %}</a>
|
||||||
</h5>
|
</h5>
|
||||||
@ -249,7 +249,7 @@
|
|||||||
<i class="fas fa-edit me-2 text-primary"></i> <strong>{% trans "Updated At:" %}</strong> {{ job.updated_at|default:"N/A" }}
|
<i class="fas fa-edit me-2 text-primary"></i> <strong>{% trans "Updated At:" %}</strong> {{ job.updated_at|default:"N/A" }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Description Blocks (Main Content) #}
|
{# Description Blocks (Main Content) #}
|
||||||
{% if job.has_description_content %}
|
{% if job.has_description_content %}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@ -301,6 +301,11 @@
|
|||||||
<i class="fas fa-cogs me-1"></i> {% trans "Form Template" %}
|
<i class="fas fa-cogs me-1"></i> {% trans "Form Template" %}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item flex-fill" role="presentation">
|
||||||
|
<button class="nav-link" id="staff-tab" data-bs-toggle="tab" data-bs-target="#staff-pane" type="button" role="tab" aria-controls="staff-pane" aria-selected="false">
|
||||||
|
<i class="fas fa-user-tie me-1 text-primary"></i> {% trans "Staff" %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li class="nav-item flex-fill" role="presentation">
|
<li class="nav-item flex-fill" role="presentation">
|
||||||
<button class="nav-link" id="linkedin-tab" data-bs-toggle="tab" data-bs-target="#linkedin-pane" type="button" role="tab" aria-controls="linkedin-pane" aria-selected="false">
|
<button class="nav-link" id="linkedin-tab" data-bs-toggle="tab" data-bs-target="#linkedin-pane" type="button" role="tab" aria-controls="linkedin-pane" aria-selected="false">
|
||||||
<i class="fab fa-linkedin me-1 text-info"></i> {% trans "LinkedIn" %}
|
<i class="fab fa-linkedin me-1 text-info"></i> {% trans "LinkedIn" %}
|
||||||
@ -364,12 +369,59 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# TAB 4: LINKEDIN INTEGRATION CONTENT #}
|
{# TAB 4: STAFF ASSIGNMENT CONTENT #}
|
||||||
|
<div class="tab-pane fade" id="staff-pane" role="tabpanel" aria-labelledby="staff-tab">
|
||||||
|
<h5 class="mb-3"><i class="fas fa-user-tie me-2 text-primary"></i>{% trans "Staff Assignment" %}</h5>
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
{% trans "Assign staff members to manage this job posting and track applications." %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="{% url 'staff_assignment_view' job.slug %}" class="btn btn-main-action">
|
||||||
|
<i class="fas fa-user-plus me-1"></i> {% trans "Assign Staff Member" %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if job.staff_assignments.exists %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<h6 class="text-muted">{% trans "Current Assignments" %}</h6>
|
||||||
|
{% for assignment in job.staff_assignments.all %}
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>{{ assignment.staff.get_full_name|default:assignment.staff.username }}</strong>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">{{ assignment.staff.email }}</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% if assignment.staff.is_active %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if assignment.notes %}
|
||||||
|
<small class="text-muted d-block mt-1">{{ assignment.notes }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info p-2 small mb-0">
|
||||||
|
<i class="fas fa-info-circle me-1"></i> {% trans "No staff members assigned to this job yet." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# TAB 5: LINKEDIN INTEGRATION CONTENT #}
|
||||||
<div class="tab-pane fade" id="linkedin-pane" role="tabpanel" aria-labelledby="linkedin-tab">
|
<div class="tab-pane fade" id="linkedin-pane" role="tabpanel" aria-labelledby="linkedin-tab">
|
||||||
<h5 class="mb-3"><i class="fab fa-linkedin me-2 text-info"></i>{% trans "LinkedIn Integration" %}</h5>
|
<h5 class="mb-3"><i class="fab fa-linkedin me-2 text-info"></i>{% trans "LinkedIn Integration" %}</h5>
|
||||||
<div class="d-grid gap-3">
|
<div class="d-grid gap-3">
|
||||||
@ -397,7 +449,7 @@
|
|||||||
{% if job.posted_to_linkedin %}{% trans "Re-post to LinkedIn" %}{% else %}{% trans "Post to LinkedIn" %}{% endif %}
|
{% if job.posted_to_linkedin %}{% trans "Re-post to LinkedIn" %}{% else %}{% trans "Post to LinkedIn" %}{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<button type="button" class="btn btn-outline-secondary w-100" data-bs-toggle="modal" data-bs-target="#myModalForm">
|
<button type="button" class="btn btn-outline-secondary w-100" data-bs-toggle="modal" data-bs-target="#myModalForm">
|
||||||
<i class="fas fa-image me-1"></i> {% trans "Upload Image for Post" %}
|
<i class="fas fa-image me-1"></i> {% trans "Upload Image for Post" %}
|
||||||
</button>
|
</button>
|
||||||
@ -443,12 +495,12 @@
|
|||||||
<div class="card shadow-sm no-hover mb-4">
|
<div class="card shadow-sm no-hover mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h6 class="mb-0">
|
<h6 class="mb-0">
|
||||||
<i class="fas fa-info-circle me-1 text-primary"></i>
|
<i class="fas fa-info-circle me-1 text-primary"></i>
|
||||||
{% trans "Key Performance Indicators" %}
|
{% trans "Key Performance Indicators" %}
|
||||||
</h6>
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
|
|
||||||
<div class="row g-3 stats-grid">
|
<div class="row g-3 stats-grid">
|
||||||
|
|
||||||
{# 1. Job Avg. Score #}
|
{# 1. Job Avg. Score #}
|
||||||
@ -505,7 +557,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -663,4 +715,4 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
432
templates/recruitment/staff_assignment_view.html
Normal file
432
templates/recruitment/staff_assignment_view.html
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Assign Staff to {{ job.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block customCSS %}
|
||||||
|
<style>
|
||||||
|
/* Theme Variables and Global Styles */
|
||||||
|
:root {
|
||||||
|
--kaauh-teal: #00636e;
|
||||||
|
--kaauh-teal-dark: #004a53;
|
||||||
|
--kaauh-border: #eaeff3;
|
||||||
|
--kaauh-primary-text: #343a40;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary Color Overrides */
|
||||||
|
.text-primary { color: var(--kaauh-teal) !important; }
|
||||||
|
.bg-primary { background-color: var(--kaauh-teal) !important; }
|
||||||
|
.btn-main-action {
|
||||||
|
background-color: var(--kaauh-teal) !important;
|
||||||
|
border-color: var(--kaauh-teal) !important;
|
||||||
|
color: white !important;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.btn-main-action:hover {
|
||||||
|
background-color: var(--kaauh-teal-dark) !important;
|
||||||
|
border-color: var(--kaauh-teal-dark) !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.btn-outline-secondary {
|
||||||
|
color: var(--kaauh-teal-dark) !important;
|
||||||
|
border-color: var(--kaauh-teal) !important;
|
||||||
|
}
|
||||||
|
.btn-outline-secondary:hover {
|
||||||
|
background-color: var(--kaauh-teal-dark) !important;
|
||||||
|
color: white !important;
|
||||||
|
border-color: var(--kaauh-teal-dark) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header styling */
|
||||||
|
.job-header-card {
|
||||||
|
background: linear-gradient(135deg, var(--kaauh-teal), var(--kaauh-teal-dark));
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.75rem 0.75rem 0 0;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 4px 10px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.job-header-card h1 {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card enhancements */
|
||||||
|
.kaauh-card, .card {
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
.kaauh-card:not(.no-hover):hover,
|
||||||
|
.card:not(.no-hover):hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standard Card Header */
|
||||||
|
.card-header {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-bottom: 1px solid var(--kaauh-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styling */
|
||||||
|
.form-control, .form-select {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
.form-control:focus, .form-select:focus {
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Staff member cards */
|
||||||
|
.staff-card {
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.staff-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge styling */
|
||||||
|
.badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table styling */
|
||||||
|
.table {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.table th {
|
||||||
|
background-color: var(--kaauh-border);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
border-bottom: 2px solid var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background-color: #f1f3f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page header styling */
|
||||||
|
.page-header {
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-header h3 mb-1">Assign Staff to Job</h1>
|
||||||
|
<p class="text-secondary mb-0">Job: {{ job.title }} ({{ job.internal_job_id }})</p>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>Back to Job Details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Job Information Card -->
|
||||||
|
<div class="kaauh-card mb-4">
|
||||||
|
<div class="job-header-card">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-briefcase me-2"></i>Job Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p><strong>Department:</strong> {{ job.get_department_display|default:job.department }}</p>
|
||||||
|
<p><strong>Job Type:</strong> {{ job.get_job_type_display }}</p>
|
||||||
|
<p><strong>Workplace Type:</strong> {{ job.get_workplace_type_display }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p><strong>Status:</strong>
|
||||||
|
<span class="badge {% if job.status == 'ACTIVE' %}bg-success{% elif job.status == 'CLOSED' %}bg-danger{% else %}bg-secondary{% endif %}">
|
||||||
|
{{ job.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p><strong>Applications:</strong> {{ applications.count }}</p>
|
||||||
|
<p><strong>Created:</strong> {{ job.created_at|date:"M d, Y" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Staff Assignment Form -->
|
||||||
|
<div class="kaauh-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0 text-primary"><i class="fas fa-user-tie me-2"></i>Staff Assignment</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" id="staffAssignmentForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label for="{{ form.staff.id_for_label }}" class="form-label fw-bold">
|
||||||
|
{{ form.staff.label }} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.staff }}
|
||||||
|
{% if form.staff.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{% for error in form.staff.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">Select a staff member to assign to this job posting.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label for="{{ form.assignment_date.id_for_label }}" class="form-label fw-bold">
|
||||||
|
{{ form.assignment_date.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.assignment_date }}
|
||||||
|
{% if form.assignment_date.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{% for error in form.assignment_date.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">Date when staff assignment becomes effective.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label for="{{ form.notes.id_for_label }}" class="form-label fw-bold">
|
||||||
|
{{ form.notes.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.notes }}
|
||||||
|
{% if form.notes.errors %}
|
||||||
|
<div class="text-danger small mt-1">
|
||||||
|
{% for error in form.notes.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">Optional notes about this staff assignment.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-main-action">
|
||||||
|
<i class="fas fa-user-plus me-2"></i>Assign Staff to Job
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary ms-2">
|
||||||
|
<i class="fas fa-times me-2"></i>Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Staff Assignments -->
|
||||||
|
{% if job.staff_assignments.exists %}
|
||||||
|
<div class="kaauh-card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0 text-primary"><i class="fas fa-users me-2"></i>Current Staff Assignments</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Staff Member</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Assignment Date</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for assignment in job.staff_assignments.all %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ assignment.staff.get_full_name|default:assignment.staff.username }}</strong>
|
||||||
|
{% if assignment.staff.is_active %}
|
||||||
|
<span class="badge bg-success ms-2">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger ms-2">Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ assignment.staff.email }}</td>
|
||||||
|
<td>{{ assignment.assignment_date|date:"M d, Y" }}</td>
|
||||||
|
<td>{{ assignment.notes|default:"-" }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-primary">Assigned</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Available Staff Members -->
|
||||||
|
<div class="kaauh-card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0 text-primary"><i class="fas fa-users me-2"></i>Available Staff Members</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
{% for staff_user in staff_users %}
|
||||||
|
<div class="col-md-6 col-lg-4 mb-3">
|
||||||
|
<div class="staff-card card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<div class="rounded-circle text-white d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px; background-color: var(--kaauh-teal);">
|
||||||
|
{% if staff_user.first_name %}
|
||||||
|
{{ staff_user.first_name.0 }}{{ staff_user.last_name.0 }}
|
||||||
|
{% else %}
|
||||||
|
{{ staff_user.username.0|upper }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">{{ staff_user.get_full_name|default:staff_user.username }}</h6>
|
||||||
|
<small class="text-muted">{{ staff_user.email }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
{% if staff_user.is_active %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="badge bg-primary ms-1">Staff</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||||
|
No staff members available. Please create staff accounts first.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for Staff Assignment Confirmation -->
|
||||||
|
<div class="modal fade" id="staffAssignmentModal" tabindex="-1" aria-labelledby="staffAssignmentModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="staffAssignmentModalLabel">
|
||||||
|
<i class="fas fa-user-plus me-2"></i>Confirm Staff Assignment
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Are you sure you want to assign this staff member to the job <strong>{{ job.title }}</strong>?</p>
|
||||||
|
<div id="selectedStaffInfo"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-main-action" id="confirmAssignmentBtn">
|
||||||
|
<i class="fas fa-check me-2"></i>Confirm Assignment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('staffAssignmentForm');
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('staffAssignmentModal'));
|
||||||
|
const confirmBtn = document.getElementById('confirmAssignmentBtn');
|
||||||
|
const selectedStaffInfo = document.getElementById('selectedStaffInfo');
|
||||||
|
|
||||||
|
// Handle form submission with modal confirmation
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const staffSelect = document.getElementById('{{ form.staff.id_for_label }}');
|
||||||
|
const selectedOption = staffSelect.options[staffSelect.selectedIndex];
|
||||||
|
|
||||||
|
if (selectedOption.value) {
|
||||||
|
// Show selected staff info in modal
|
||||||
|
selectedStaffInfo.innerHTML = `
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Selected Staff Member:</strong><br>
|
||||||
|
Name: ${selectedOption.text}<br>
|
||||||
|
This assignment will take effect immediately.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modal.show();
|
||||||
|
} else {
|
||||||
|
alert('Please select a staff member.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle modal confirmation
|
||||||
|
if (confirmBtn) {
|
||||||
|
confirmBtn.addEventListener('click', function() {
|
||||||
|
// Hide modal
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-focus on staff select field
|
||||||
|
const staffSelect = document.getElementById('{{ form.staff.id_for_label }}');
|
||||||
|
if (staffSelect) {
|
||||||
|
staffSelect.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Loading…
x
Reference in New Issue
Block a user