Compare commits
13 Commits
3934d1cebe
...
9b2bf34431
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b2bf34431 | |||
| 2ceb5ee2b0 | |||
| e675a8260c | |||
| 3fa308b998 | |||
| 4471b22c2f | |||
| 0e30485185 | |||
| 7224004816 | |||
| 60e3f81620 | |||
| e33f3e86b9 | |||
| 619db6426e | |||
| 239f7ba1fa | |||
| a1d2dcebfb | |||
| 4ee0406453 |
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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,105 +353,85 @@ 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:
|
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')
|
|
||||||
is_html = '<' in message and '>' in message
|
|
||||||
successful_sends = 0
|
|
||||||
|
|
||||||
# Helper Function for Sync Send (as provided)
|
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
|
||||||
def send_individual_email(recipient, body_message):
|
is_html = '<' in message and '>' in message
|
||||||
# ... (Existing helper function logic) ...
|
successful_sends = 0
|
||||||
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)}")
|
|
||||||
|
|
||||||
|
# Helper Function for Sync Send (as provided)
|
||||||
else:
|
def send_individual_email(recipient, body_message):
|
||||||
logger.error("fialed to send email")
|
# ... (Existing helper function logic) ...
|
||||||
|
nonlocal successful_sends
|
||||||
successful_sends += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
|
|
||||||
|
|
||||||
if not from_interview:
|
if is_html:
|
||||||
# Send Emails - Pure Candidates
|
plain_message = strip_tags(body_message)
|
||||||
for email in pure_candidate_emails:
|
email_obj = EmailMultiAlternatives(subject=subject, body=plain_message, from_email=from_email, to=[recipient])
|
||||||
candidate_name = Application.objects.filter(person__email=email).first().person.full_name
|
email_obj.attach_alternative(body_message, "text/html")
|
||||||
candidate_message = f"Hi, {candidate_name}" + "\n" + message
|
|
||||||
send_individual_email(email, candidate_message)
|
|
||||||
|
|
||||||
# Send Emails - Agencies
|
|
||||||
i = 0
|
|
||||||
for email in agency_emails:
|
|
||||||
candidate_email = candidate_through_agency_emails[i]
|
|
||||||
candidate_name = Application.objects.filter(person__email=candidate_email).first().person.full_name
|
|
||||||
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
|
||||||
send_individual_email(email, agency_message)
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.")
|
|
||||||
return {
|
|
||||||
'success': True,
|
|
||||||
'recipients_count': successful_sends,
|
|
||||||
'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.'
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
for email in recipient_list:
|
email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
|
||||||
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:
|
if attachments:
|
||||||
error_msg = f"Failed to process bulk email send request: {str(e)}"
|
for attachment in attachments:
|
||||||
logger.error(error_msg, exc_info=True)
|
if hasattr(attachment, 'read'):
|
||||||
return {'success': False, 'error': error_msg}
|
filename = getattr(attachment, 'name', 'attachment')
|
||||||
|
content = attachment.read()
|
||||||
|
content_type = getattr(attachment, 'content_type', 'application/octet-stream')
|
||||||
|
email_obj.attach(filename, content, content_type)
|
||||||
|
elif isinstance(attachment, tuple) and len(attachment) == 3:
|
||||||
|
filename, content, content_type = attachment
|
||||||
|
email_obj.attach(filename, content, content_type)
|
||||||
|
|
||||||
|
try:
|
||||||
|
email_obj.send(fail_silently=False)
|
||||||
|
successful_sends += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
|
||||||
|
|
||||||
|
if not from_interview:
|
||||||
|
# Send Emails - Pure Candidates
|
||||||
|
for email in pure_candidate_emails:
|
||||||
|
candidate_name = Application.objects.filter(email=email).first().first_name
|
||||||
|
candidate_message = f"Hi, {candidate_name}" + "\n" + message
|
||||||
|
send_individual_email(email, candidate_message)
|
||||||
|
|
||||||
|
# Send Emails - Agencies
|
||||||
|
i = 0
|
||||||
|
for email in agency_emails:
|
||||||
|
candidate_email = candidate_through_agency_emails[i]
|
||||||
|
candidate_name = Application.objects.filter(email=candidate_email).first().first_name
|
||||||
|
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
||||||
|
send_individual_email(email, agency_message)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.")
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'recipients_count': successful_sends,
|
||||||
|
'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.'
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
for email in recipient_list:
|
||||||
|
send_individual_email(email, message)
|
||||||
|
|
||||||
|
logger.info(f"Interview email processing complete. Sent successfully to {successful_sends} out of {total_recipients} recipients.")
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'recipients_count': successful_sends,
|
||||||
|
'message': f'Interview emails sent successfully to {successful_sends} recipient(s).'
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Failed to process bulk email send request: {str(e)}"
|
||||||
|
logger.error(error_msg, exc_info=True)
|
||||||
|
return {'success': False, 'error': error_msg}
|
||||||
@ -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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -984,6 +984,10 @@ class Application(Base):
|
|||||||
|
|
||||||
content_type = ContentType.objects.get_for_model(self.__class__)
|
content_type = ContentType.objects.get_for_model(self.__class__)
|
||||||
return Document.objects.filter(content_type=content_type, object_id=self.id)
|
return Document.objects.filter(content_type=content_type, object_id=self.id)
|
||||||
|
|
||||||
|
# @property
|
||||||
|
# def belong_to_agency(self):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TrainingMaterial(Base):
|
class TrainingMaterial(Base):
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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');
|
||||||
|
|
||||||
|
|||||||
@ -364,12 +364,37 @@ body { background-color: #f0f2f5; font-family: 'Inter', sans-serif; }
|
|||||||
</div>
|
</div>
|
||||||
<form method="post" action="{% url 'create_interview_participants' interview.slug %}">
|
<form method="post" action="{% url 'create_interview_participants' interview.slug %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="modal-body">
|
<div class="modal-body table-responsive">
|
||||||
{{ form.participants.errors }}
|
|
||||||
{{ form.participants }}
|
{{ meeting.name }}
|
||||||
{{ form.system_users.errors }}
|
|
||||||
{{ form.system_users }}
|
<hr>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
<table class="table tab table-bordered mt-3">
|
||||||
|
<thead>
|
||||||
|
<th class="col">👥 {% trans "Participants" %}</th>
|
||||||
|
<th class="col">🧑💼 {% trans "Users" %}</th>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ form.participants.errors }}
|
||||||
|
{{ form.participants }}
|
||||||
|
</td>
|
||||||
|
<td> {{ form.system_users.errors }}
|
||||||
|
{{ form.system_users }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-danger-red" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
<button type="button" class="btn btn-danger-red" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
||||||
<button type="submit" class="btn btn-primary-teal">{% trans "Save" %}</button>
|
<button type="submit" class="btn btn-primary-teal">{% trans "Save" %}</button>
|
||||||
@ -387,7 +412,7 @@ body { background-color: #f0f2f5; font-family: 'Inter', sans-serif; }
|
|||||||
<h5 class="modal-title">📧 {% trans "Compose Interview Invitation" %}</h5>
|
<h5 class="modal-title">📧 {% trans "Compose Interview Invitation" %}</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{% url 'send_interview_email' interview.pk %}">
|
<form method="post" action="{% url 'send_interview_email' interview.slug %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
@ -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