update and bug fixes
This commit is contained in:
parent
a1d2dcebfb
commit
239f7ba1fa
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
|
||||
else:
|
||||
recipients.append(candidate.email)
|
||||
|
||||
|
||||
if recipient_list:
|
||||
recipients.extend(recipient_list)
|
||||
|
||||
|
||||
|
||||
if not recipients:
|
||||
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,
|
||||
supporting synchronous or asynchronous dispatch.
|
||||
"""
|
||||
|
||||
|
||||
# --- 1. Categorization and Custom Message Preparation (CORRECTED) ---
|
||||
if not from_interview:
|
||||
|
||||
|
||||
agency_emails = []
|
||||
pure_candidate_emails = []
|
||||
candidate_through_agency_emails = []
|
||||
|
||||
|
||||
if not recipient_list:
|
||||
return {'success': False, 'error': 'No recipients provided'}
|
||||
|
||||
# This must contain (final_recipient_email, customized_message) for ALL sends
|
||||
customized_sends = []
|
||||
|
||||
customized_sends = []
|
||||
|
||||
# 1a. Classify Recipients and Prepare Custom Messages
|
||||
for email in recipient_list:
|
||||
for email in recipient_list:
|
||||
email = email.strip().lower()
|
||||
|
||||
|
||||
try:
|
||||
candidate = get_object_or_404(Application, person__email=email)
|
||||
except Exception:
|
||||
logger.warning(f"Candidate not found for email: {email}")
|
||||
continue
|
||||
|
||||
|
||||
candidate_name = candidate.person.full_name
|
||||
|
||||
|
||||
|
||||
|
||||
# --- Candidate belongs to an agency (Final Recipient: Agency) ---
|
||||
if candidate.hiring_agency and candidate.hiring_agency.email:
|
||||
agency_email = candidate.hiring_agency.email
|
||||
agency_message = f"Hi, {candidate_name}" + "\n" + 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)
|
||||
candidate_through_agency_emails.append(candidate.email) # For sync block only
|
||||
|
||||
|
||||
# --- Pure Candidate (Final Recipient: Candidate) ---
|
||||
else:
|
||||
candidate_message = f"Hi, {candidate_name}" + "\n" + 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
|
||||
|
||||
|
||||
# Calculate total recipients based on the size of the final send list
|
||||
total_recipients = len(customized_sends)
|
||||
|
||||
@ -295,21 +295,22 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
||||
else:
|
||||
# For interview flow
|
||||
total_recipients = len(recipient_list)
|
||||
|
||||
|
||||
|
||||
|
||||
# --- 2. Handle ASYNC Dispatch (FIXED: Single loop used) ---
|
||||
if async_task_:
|
||||
try:
|
||||
|
||||
|
||||
processed_attachments = attachments if attachments else []
|
||||
task_ids = []
|
||||
|
||||
job_id=job.id
|
||||
sender_user_id=request.user.id if request and hasattr(request, 'user') and request.user.is_authenticated else None
|
||||
if not from_interview:
|
||||
# Loop through ALL final customized sends
|
||||
for recipient_email, custom_message in customized_sends:
|
||||
task_id = async_task(
|
||||
'recruitment.tasks.send_bulk_email_task',
|
||||
'recruitment.tasks.send_bulk_email_task',
|
||||
subject,
|
||||
custom_message, # Pass the custom message
|
||||
[recipient_email], # Pass the specific recipient as a list of one
|
||||
@ -317,10 +318,10 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
||||
sender_user_id,
|
||||
job_id,
|
||||
hook='recruitment.tasks.email_success_hook',
|
||||
|
||||
|
||||
)
|
||||
task_ids.append(task_id)
|
||||
|
||||
|
||||
logger.info(f"{len(task_ids)} tasks ({total_recipients} emails) queued.")
|
||||
|
||||
return {
|
||||
@ -329,19 +330,19 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
||||
'task_ids': task_ids,
|
||||
'message': f'Emails queued for background sending to {len(task_ids)} recipient(s).'
|
||||
}
|
||||
|
||||
|
||||
else: # from_interview is True (generic send to all participants)
|
||||
task_id = async_task(
|
||||
'recruitment.tasks.send_bulk_email_task',
|
||||
subject,
|
||||
message,
|
||||
recipient_list, # Send the original message to the entire list
|
||||
processed_attachments,
|
||||
hook='recruitment.tasks.email_success_hook'
|
||||
processed_attachments,
|
||||
hook='recruitment.tasks.email_success_hook'
|
||||
)
|
||||
task_ids.append(task_id)
|
||||
logger.info(f"Interview emails queued. ID: {task_id}")
|
||||
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'async': True,
|
||||
@ -352,103 +353,91 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments=
|
||||
|
||||
except ImportError:
|
||||
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:
|
||||
logger.error(f"Failed to queue async tasks: {str(e)}", exc_info=True)
|
||||
return {'success': False, 'error': f"Failed to queue async tasks: {str(e)}"}
|
||||
|
||||
|
||||
else:
|
||||
# --- 3. Handle SYNCHRONOUS Send (No changes needed here, as it was fixed previously) ---
|
||||
try:
|
||||
# NOTE: The synchronous block below should also use the 'customized_sends'
|
||||
# list for consistency instead of rebuilding messages from 'pure_candidate_emails'
|
||||
# and 'agency_emails', but keeping your current logic structure to minimize changes.
|
||||
|
||||
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
|
||||
is_html = '<' in message and '>' in message
|
||||
successful_sends = 0
|
||||
try:
|
||||
# NOTE: The synchronous block below should also use the 'customized_sends'
|
||||
# list for consistency instead of rebuilding messages from 'pure_candidate_emails'
|
||||
# NOTE: The synchronous block below should also use the 'customized_sends'
|
||||
# list for consistency instead of rebuilding messages from 'pure_candidate_emails'
|
||||
# and 'agency_emails', but keeping your current logic structure to minimize changes.
|
||||
|
||||
|
||||
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa')
|
||||
is_html = '<' in message and '>' in message
|
||||
successful_sends = 0
|
||||
|
||||
# Helper Function for Sync Send (as provided)
|
||||
def send_individual_email(recipient, body_message):
|
||||
# ... (Existing helper function logic) ...
|
||||
nonlocal successful_sends
|
||||
|
||||
if is_html:
|
||||
plain_message = strip_tags(body_message)
|
||||
email_obj = EmailMultiAlternatives(subject=subject, body=plain_message, from_email=from_email, to=[recipient])
|
||||
email_obj.attach_alternative(body_message, "text/html")
|
||||
else:
|
||||
email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient])
|
||||
|
||||
if attachments:
|
||||
for attachment in attachments:
|
||||
if hasattr(attachment, 'read'):
|
||||
filename = getattr(attachment, 'name', 'attachment')
|
||||
content = attachment.read()
|
||||
content_type = getattr(attachment, 'content_type', 'application/octet-stream')
|
||||
email_obj.attach(filename, content, content_type)
|
||||
elif isinstance(attachment, tuple) and len(attachment) == 3:
|
||||
filename, content, content_type = attachment
|
||||
email_obj.attach(filename, content, content_type)
|
||||
|
||||
try:
|
||||
result=email_obj.send(fail_silently=False)
|
||||
if result==1:
|
||||
try:
|
||||
user=get_object_or_404(User,email=recipient)
|
||||
new_message = Message.objects.create(
|
||||
sender=request.user,
|
||||
recipient=user,
|
||||
job=job,
|
||||
subject=subject,
|
||||
content=message, # Store the full HTML or plain content
|
||||
message_type='DIRECT',
|
||||
is_read=False, # It's just sent, not read yet
|
||||
)
|
||||
logger.info(f"Stored sent message ID {new_message.id} in DB.")
|
||||
except Exception as e:
|
||||
logger.error(f"Email sent to {recipient}, but failed to store in DB: {str(e)}")
|
||||
# Helper Function for Sync Send (as provided)
|
||||
def send_individual_email(recipient, body_message):
|
||||
# ... (Existing helper function logic) ...
|
||||
nonlocal successful_sends
|
||||
|
||||
|
||||
else:
|
||||
logger.error("fialed to send email")
|
||||
|
||||
successful_sends += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True)
|
||||
if 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 not from_interview:
|
||||
# Send Emails - Pure Candidates
|
||||
for email in pure_candidate_emails:
|
||||
candidate_name = Application.objects.filter(person__email=email).first().person.full_name
|
||||
candidate_message = f"Hi, {candidate_name}" + "\n" + message
|
||||
send_individual_email(email, candidate_message)
|
||||
|
||||
# Send Emails - Agencies
|
||||
i = 0
|
||||
for email in agency_emails:
|
||||
candidate_email = candidate_through_agency_emails[i]
|
||||
candidate_name = Application.objects.filter(person__email=candidate_email).first().person.full_name
|
||||
agency_message = f"Hi, {candidate_name}" + "\n" + message
|
||||
send_individual_email(email, agency_message)
|
||||
i += 1
|
||||
|
||||
logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.")
|
||||
return {
|
||||
'success': True,
|
||||
'recipients_count': successful_sends,
|
||||
'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.'
|
||||
}
|
||||
else:
|
||||
for email in recipient_list:
|
||||
send_individual_email(email, message)
|
||||
|
||||
logger.info(f"Interview email processing complete. Sent successfully to {successful_sends} out of {total_recipients} recipients.")
|
||||
return {
|
||||
'success': True,
|
||||
'recipients_count': successful_sends,
|
||||
'message': f'Interview emails sent successfully to {successful_sends} recipient(s).'
|
||||
}
|
||||
if attachments:
|
||||
for attachment in attachments:
|
||||
if hasattr(attachment, 'read'):
|
||||
filename = getattr(attachment, 'name', 'attachment')
|
||||
content = attachment.read()
|
||||
content_type = getattr(attachment, 'content_type', 'application/octet-stream')
|
||||
email_obj.attach(filename, content, content_type)
|
||||
elif isinstance(attachment, tuple) and len(attachment) == 3:
|
||||
filename, content, content_type = attachment
|
||||
email_obj.attach(filename, content, content_type)
|
||||
|
||||
try:
|
||||
email_obj.send(fail_silently=False)
|
||||
successful_sends += 1
|
||||
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)}"
|
||||
|
||||
@ -2453,3 +2453,52 @@ class PasswordResetForm(forms.Form):
|
||||
raise forms.ValidationError(_('New passwords do not match.'))
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class StaffAssignmentForm(forms.ModelForm):
|
||||
"""Form for assigning staff to a job posting"""
|
||||
|
||||
class Meta:
|
||||
model = JobPosting
|
||||
fields = ['assigned_to']
|
||||
widgets = {
|
||||
'assigned_to': forms.Select(attrs={
|
||||
'class': 'form-select',
|
||||
'placeholder': _('Select staff member'),
|
||||
'required': True
|
||||
}),
|
||||
}
|
||||
labels = {
|
||||
'assigned_to': _('Assign Staff Member'),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Filter users to only show staff members
|
||||
self.fields['assigned_to'].queryset = User.objects.filter(
|
||||
user_type='staff'
|
||||
).order_by('first_name', 'last_name')
|
||||
|
||||
# Add empty choice for unassigning
|
||||
self.fields['assigned_to'].required = False
|
||||
self.fields['assigned_to'].empty_label = _('-- Unassign Staff --')
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_class = 'g-3'
|
||||
self.helper.form_id = 'staff-assignment-form'
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Field('assigned_to', css_class='form-control'),
|
||||
Div(
|
||||
Submit('submit', _('Assign Staff'), css_class='btn btn-primary'),
|
||||
css_class='col-12 mt-3'
|
||||
),
|
||||
)
|
||||
|
||||
def clean_assigned_to(self):
|
||||
"""Validate the assigned staff member"""
|
||||
assigned_to = self.cleaned_data.get('assigned_to')
|
||||
if assigned_to and assigned_to.user_type != 'staff':
|
||||
raise forms.ValidationError(_('Only staff members can be assigned to jobs.'))
|
||||
return assigned_to
|
||||
|
||||
@ -22,6 +22,8 @@ from .models import (
|
||||
)
|
||||
from .forms import generate_api_key, generate_api_secret
|
||||
from django.contrib.auth import get_user_model
|
||||
from django_q.models import Schedule
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -41,6 +43,7 @@ def format_job(sender, instance, created, **kwargs):
|
||||
instance.pk,
|
||||
# hook='myapp.tasks.email_sent_callback' # Optional callback
|
||||
)
|
||||
|
||||
else:
|
||||
existing_schedule = Schedule.objects.filter(
|
||||
func="recruitment.tasks.form_close",
|
||||
|
||||
@ -487,7 +487,7 @@ def create_interview_and_meeting(
|
||||
interview_date=slot_date,
|
||||
interview_time=slot_time
|
||||
)
|
||||
|
||||
|
||||
# Log success or use Django-Q result system for monitoring
|
||||
logger.info(f"Successfully scheduled interview for {Application.name}")
|
||||
return True # Task succeeded
|
||||
@ -606,6 +606,8 @@ def form_close(job_id):
|
||||
job.is_active = False
|
||||
job.template_form.is_active = False
|
||||
job.save()
|
||||
#TODO:send email to admins
|
||||
|
||||
|
||||
|
||||
def sync_hired_candidates_task(job_slug):
|
||||
@ -777,7 +779,7 @@ def _task_send_individual_email(subject, body_message, recipient, attachments,se
|
||||
|
||||
try:
|
||||
result=email_obj.send(fail_silently=False)
|
||||
|
||||
|
||||
if result==1:
|
||||
try:
|
||||
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:
|
||||
logger.error(f"Email sent to {recipient}, but failed to store in DB: {str(e)}")
|
||||
|
||||
|
||||
else:
|
||||
|
||||
else:
|
||||
logger.error("fialed to send email")
|
||||
|
||||
|
||||
|
||||
|
||||
except Exception as e:
|
||||
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:
|
||||
return {'success': False, 'error': 'No recipients provided to task.'}
|
||||
|
||||
|
||||
sender=get_object_or_404(User,pk=sender_user_id)
|
||||
job=get_object_or_404(JobPosting,pk=job_id)
|
||||
# Since the async caller sends one task per recipient, total_recipients should be 1.
|
||||
@ -843,3 +845,5 @@ def email_success_hook(task):
|
||||
logger.info(f"Task ID {task.id} succeeded. Result: {task.result}")
|
||||
else:
|
||||
logger.error(f"Task ID {task.id} failed. Error: {task.result}")
|
||||
|
||||
|
||||
|
||||
@ -35,7 +35,8 @@ urlpatterns = [
|
||||
),
|
||||
path("jobs/linkedin/login/", views.linkedin_login, name="linkedin_login"),
|
||||
path("jobs/linkedin/callback/", views.linkedin_callback, name="linkedin_callback"),
|
||||
|
||||
path("jobs/<slug:slug>/staff-assignment/", views.staff_assignment_view, name="staff_assignment_view"),
|
||||
|
||||
# Candidate URLs
|
||||
path(
|
||||
"candidates/", views_frontend.ApplicationListView.as_view(), name="candidate_list"
|
||||
@ -109,7 +110,7 @@ urlpatterns = [
|
||||
),
|
||||
# Meeting URLs
|
||||
# path("meetings/", views.ZoomMeetingListView.as_view(), name="list_meetings"),
|
||||
|
||||
|
||||
# JobPosting functional views URLs (keeping for compatibility)
|
||||
path("api/create/", views.create_job, name="create_job_api"),
|
||||
path("api/<slug:slug>/edit/", views.edit_job, name="edit_job_api"),
|
||||
@ -271,7 +272,7 @@ urlpatterns = [
|
||||
views.interview_detail_view,
|
||||
name="interview_detail",
|
||||
),
|
||||
|
||||
|
||||
# users urls
|
||||
path("user/<int:pk>", views.user_detail, name="user_detail"),
|
||||
path(
|
||||
@ -576,7 +577,7 @@ urlpatterns = [
|
||||
views.confirm_schedule_interviews_view,
|
||||
name="confirm_schedule_interviews_view",
|
||||
),
|
||||
|
||||
|
||||
path(
|
||||
"meetings/create-meeting/",
|
||||
views.ZoomMeetingCreateView.as_view(),
|
||||
@ -632,16 +633,16 @@ urlpatterns = [
|
||||
|
||||
|
||||
path("interviews/meetings/", views.MeetingListView.as_view(), name="list_meetings"),
|
||||
|
||||
|
||||
# 1. Onsite Reschedule URL
|
||||
path(
|
||||
'<slug:slug>/candidate/<int:candidate_id>/onsite/reschedule/<int:meeting_id>/',
|
||||
views.reschedule_onsite_meeting,
|
||||
name='reschedule_onsite_meeting'
|
||||
),
|
||||
|
||||
|
||||
# 2. Onsite Delete URL
|
||||
|
||||
|
||||
path(
|
||||
'job/<slug:slug>/candidates/<int:candidate_pk>/delete-onsite-meeting/<int:meeting_id>/',
|
||||
views.delete_onsite_meeting_for_candidate,
|
||||
@ -653,8 +654,8 @@ urlpatterns = [
|
||||
views.schedule_onsite_meeting_for_candidate,
|
||||
name='schedule_onsite_meeting_for_candidate' # This is the name used in the button
|
||||
),
|
||||
|
||||
|
||||
|
||||
|
||||
# Detail View (assuming slug is on ScheduledInterview)
|
||||
path("interviews/meetings/<slug:slug>/", views.meeting_details, name="meeting_details"),
|
||||
|
||||
|
||||
@ -30,7 +30,8 @@ from .forms import (
|
||||
ProfileImageUploadForm,
|
||||
ParticipantsSelectForm,
|
||||
ApplicationForm,
|
||||
PasswordResetForm
|
||||
PasswordResetForm,
|
||||
StaffAssignmentForm,
|
||||
)
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
@ -120,7 +121,7 @@ from .models import (
|
||||
JobPosting,
|
||||
ScheduledInterview,
|
||||
JobPostingImage,
|
||||
|
||||
|
||||
HiringAgency,
|
||||
AgencyJobAssignment,
|
||||
AgencyAccessLink,
|
||||
@ -250,7 +251,7 @@ class ZoomMeetingCreateView(StaffRequiredMixin, CreateView):
|
||||
messages.error(self.request, f"Error creating meeting: {e}")
|
||||
return redirect(reverse("create_meeting", kwargs={"slug": instance.slug}))
|
||||
|
||||
|
||||
|
||||
|
||||
class ZoomMeetingDetailsView(StaffRequiredMixin, DetailView):
|
||||
model = ZoomMeetingDetails
|
||||
@ -496,12 +497,12 @@ def job_detail(request, slug):
|
||||
|
||||
# --- 2. Quality Metrics (JSON Aggregation) ---
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
candidates_with_score = applicants.filter(is_resume_parsed=True).annotate(
|
||||
annotated_match_score=Coalesce(Cast(SCORE_PATH, output_field=IntegerField()), 0)
|
||||
)
|
||||
|
||||
|
||||
total_candidates = applicants.count()
|
||||
avg_match_score_result = candidates_with_score.aggregate(
|
||||
avg_score=Avg("annotated_match_score")
|
||||
@ -600,7 +601,7 @@ ALLOWED_EXTENSIONS = (".pdf", ".docx")
|
||||
|
||||
def job_cvs_download(request, 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)
|
||||
zip_buffer = io.BytesIO()
|
||||
@ -642,7 +643,7 @@ def job_cvs_download(request, slug):
|
||||
|
||||
# Set the header for the browser to download the file
|
||||
response["Content-Disposition"] = (
|
||||
'attachment; filename=f"all_cvs_for_{job.title}.zip"'
|
||||
f'attachment; filename="all_cvs_for_{job.title}.zip"'
|
||||
)
|
||||
|
||||
return response
|
||||
@ -742,7 +743,7 @@ def kaauh_career(request):
|
||||
if selected_department and selected_department in department_type_keys:
|
||||
active_jobs = active_jobs.filter(department=selected_department)
|
||||
selected_workplace_type = request.GET.get("workplace_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)
|
||||
@ -1468,7 +1469,7 @@ def _handle_preview_submission(request, slug, job):
|
||||
preview_schedule.append(
|
||||
{"application": application, "date": slot["date"], "time": slot["time"]}
|
||||
)
|
||||
|
||||
|
||||
# Save the form data to session for later use
|
||||
schedule_data = {
|
||||
"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,
|
||||
"candidate_ids": [c.id for c in applications],
|
||||
"schedule_interview_type":schedule_interview_type
|
||||
|
||||
|
||||
}
|
||||
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_end = schedule_data.get("break_end_time")
|
||||
|
||||
schedule = InterviewSchedule.objects.create(
|
||||
schedule = InterviewSchedule.objects.create(
|
||||
job=job,
|
||||
created_by=request.user,
|
||||
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
|
||||
messages.error(request, f"Error creating schedule: {e}")
|
||||
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)
|
||||
|
||||
# 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':
|
||||
print("inside...")
|
||||
|
||||
|
||||
if request.method == 'POST':
|
||||
form = OnsiteLocationForm(request.POST)
|
||||
form = OnsiteLocationForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
|
||||
if not available_slots:
|
||||
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})
|
||||
@ -1606,27 +1607,27 @@ def _handle_confirm_schedule(request, slug, job):
|
||||
room_number = form.cleaned_data['room_number']
|
||||
topic=form.cleaned_data['topic']
|
||||
|
||||
|
||||
|
||||
try:
|
||||
# 1. Iterate over candidates and create a NEW Location object for EACH
|
||||
for i, candidate in enumerate(candidates):
|
||||
if i < len(available_slots):
|
||||
slot = available_slots[i]
|
||||
|
||||
|
||||
|
||||
|
||||
location_start_dt = datetime.combine(slot['date'], schedule.start_time)
|
||||
|
||||
# --- CORE FIX: Create a NEW Location object inside the loop ---
|
||||
onsite_location = OnsiteLocationDetails.objects.create(
|
||||
start_time=location_start_dt,
|
||||
duration=schedule.interview_duration,
|
||||
start_time=location_start_dt,
|
||||
duration=schedule.interview_duration,
|
||||
physical_address=physical_address,
|
||||
room_number=room_number,
|
||||
location_type="Onsite",
|
||||
topic=topic
|
||||
|
||||
|
||||
)
|
||||
|
||||
|
||||
# 2. Create the ScheduledInterview, linking the unique location
|
||||
ScheduledInterview.objects.create(
|
||||
application=candidate,
|
||||
@ -1634,7 +1635,7 @@ def _handle_confirm_schedule(request, slug, job):
|
||||
schedule=schedule,
|
||||
interview_date=slot['date'],
|
||||
interview_time=slot['time'],
|
||||
interview_location=onsite_location,
|
||||
interview_location=onsite_location,
|
||||
)
|
||||
|
||||
messages.success(
|
||||
@ -1645,7 +1646,7 @@ def _handle_confirm_schedule(request, slug, job):
|
||||
# Clear session data keys upon successful completion
|
||||
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]
|
||||
|
||||
|
||||
return redirect('job_detail', slug=job.slug)
|
||||
|
||||
except Exception as e:
|
||||
@ -1657,11 +1658,11 @@ def _handle_confirm_schedule(request, slug, job):
|
||||
# Form is invalid, re-render with errors
|
||||
# Ensure 'job' is passed to prevent NoReverseMatch
|
||||
return render(request, 'interviews/onsite_location_form.html', {'form': form, 'schedule': schedule, 'job': job})
|
||||
|
||||
|
||||
else:
|
||||
# For a GET request
|
||||
form = OnsiteLocationForm()
|
||||
|
||||
|
||||
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,
|
||||
"candidates": job.interview_candidates,
|
||||
"current_stage": "Interview",
|
||||
|
||||
|
||||
}
|
||||
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):
|
||||
"""
|
||||
Deletes a specific Zoom (Remote) meeting instance.
|
||||
The ZoomMeetingDetails object inherits from InterviewLocation,
|
||||
which is linked to ScheduledInterview. Deleting the subclass
|
||||
The ZoomMeetingDetails object inherits from InterviewLocation,
|
||||
which is linked to ScheduledInterview. Deleting the subclass
|
||||
should trigger CASCADE/SET_NULL correctly on the FK chain.
|
||||
"""
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
candidate = get_object_or_404(Application, pk=candidate_pk)
|
||||
|
||||
|
||||
# Target the specific Zoom meeting details instance
|
||||
meeting = get_object_or_404(ZoomMeetingDetails, pk=meeting_id)
|
||||
|
||||
|
||||
if request.method == "POST":
|
||||
# 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
|
||||
if (
|
||||
result["status"] == "success"
|
||||
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.
|
||||
meeting.delete()
|
||||
messages.success(request, f"Remote meeting for {candidate.name} deleted successfully.")
|
||||
else:
|
||||
messages.error(request, result["message"])
|
||||
|
||||
|
||||
return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug}))
|
||||
|
||||
context = {
|
||||
@ -2927,6 +2928,34 @@ def admin_settings(request):
|
||||
context = {"staffs": staffs, "form": form}
|
||||
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
|
||||
|
||||
@ -3004,6 +3033,8 @@ def zoom_webhook_view(request):
|
||||
@staff_user_required
|
||||
def add_meeting_comment(request, slug):
|
||||
"""Add a comment to a meeting"""
|
||||
# from .forms import MeetingCommentForm
|
||||
|
||||
meeting = get_object_or_404(ZoomMeetingDetails, slug=slug)
|
||||
|
||||
if request.method == "POST":
|
||||
@ -3219,7 +3250,7 @@ def agency_detail(request, slug):
|
||||
candidates = Application.objects.filter(hiring_agency=agency).order_by(
|
||||
"-created_at"
|
||||
)
|
||||
|
||||
|
||||
# Statistics
|
||||
total_candidates = candidates.count()
|
||||
active_candidates = candidates.filter(
|
||||
@ -4577,7 +4608,7 @@ def message_detail(request, message_id):
|
||||
|
||||
@login_required
|
||||
def message_create(request):
|
||||
"""Create a new message"""
|
||||
"""Create a new message"""
|
||||
from .email_service import EmailService
|
||||
if request.method == "POST":
|
||||
form = MessageForm(request.user, request.POST)
|
||||
@ -4586,24 +4617,51 @@ def message_create(request):
|
||||
message = form.save(commit=False)
|
||||
message.sender = request.user
|
||||
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_email = form.cleaned_data['recipient'].email # Assuming recipient is a User or Model with an 'email' field
|
||||
subject = form.cleaned_data['subject']
|
||||
custom_message = form.cleaned_data['content']
|
||||
job_id = form.cleaned_data['job'].id if 'job' in form.cleaned_data and form.cleaned_data['job'] else None
|
||||
sender_user_id = request.user.id
|
||||
|
||||
|
||||
task_id = async_task(
|
||||
'recruitment.tasks.send_bulk_email_task',
|
||||
'recruitment.tasks.send_bulk_email_task',
|
||||
subject,
|
||||
custom_message, # Pass the custom message
|
||||
[recipient_email], # Pass the specific recipient as a list of one
|
||||
|
||||
|
||||
sender_user_id=sender_user_id,
|
||||
job_id=job_id,
|
||||
hook='recruitment.tasks.email_success_hook')
|
||||
|
||||
|
||||
logger.info(f"{task_id} queued.")
|
||||
return redirect("message_list")
|
||||
else:
|
||||
@ -4644,7 +4702,34 @@ def message_reply(request, message_id):
|
||||
message.recipient = parent_message.sender
|
||||
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)
|
||||
else:
|
||||
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
|
||||
|
||||
job = get_object_or_404(JobPosting, slug=job_slug)
|
||||
|
||||
|
||||
# # candidate = get_object_or_404(Application, slug=candidate_slug, job=job)
|
||||
# if request.method == "POST":
|
||||
# form = CandidateEmailForm(job, candidate, request.POST)
|
||||
@ -5111,7 +5196,7 @@ def compose_candidate_email(request, job_slug):
|
||||
|
||||
|
||||
if request.method == 'POST':
|
||||
|
||||
|
||||
candidate_ids = request.POST.getlist('candidate_ids')
|
||||
candidates=Application.objects.filter(id__in=candidate_ids)
|
||||
form = CandidateEmailForm(job, candidates, request.POST)
|
||||
@ -5119,7 +5204,7 @@ def compose_candidate_email(request, job_slug):
|
||||
print("form is valid ...")
|
||||
# Get email addresses
|
||||
email_addresses = form.get_email_addresses()
|
||||
|
||||
|
||||
|
||||
if not email_addresses:
|
||||
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
|
||||
from_interview=False,
|
||||
job=job
|
||||
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
request,
|
||||
f"Email sent successfully to {len(email_addresses)} recipient(s).",
|
||||
f"Email will be sent shortly to recipient(s)",
|
||||
)
|
||||
|
||||
|
||||
return redirect("candidate_interview_view", slug=job.slug)
|
||||
response = HttpResponse(status=200)
|
||||
response.headers["HX-Refresh"] = "true"
|
||||
return response
|
||||
# return redirect("candidate_interview_view", slug=job.slug)
|
||||
else:
|
||||
messages.error(
|
||||
request,
|
||||
@ -5181,12 +5284,10 @@ def compose_candidate_email(request, job_slug):
|
||||
{"form": form, "job": job, "candidate": candidates},
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
else:
|
||||
# Form validation errors
|
||||
print('form is not valid')
|
||||
print(form.errors)
|
||||
messages.error(request, "Please correct the errors below.")
|
||||
|
||||
# For HTMX requests, return error response
|
||||
@ -5472,7 +5573,7 @@ def create_interview_participants(request, slug):
|
||||
Uses interview_pk because ScheduledInterview has no slug.
|
||||
"""
|
||||
schedule_interview = get_object_or_404(ScheduledInterview, slug=slug)
|
||||
|
||||
|
||||
# Get the slug from the related InterviewLocation (the "meeting")
|
||||
meeting_slug = schedule_interview.interview_location.slug # ✅ Correct
|
||||
|
||||
@ -5561,9 +5662,29 @@ def send_interview_email(request, slug):
|
||||
)
|
||||
|
||||
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(
|
||||
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")
|
||||
@ -5590,33 +5711,33 @@ class MeetingListView(ListView):
|
||||
"""
|
||||
A unified view to list both Remote and Onsite Scheduled Interviews.
|
||||
"""
|
||||
model = ScheduledInterview
|
||||
template_name = "meetings/list_meetings.html"
|
||||
model = ScheduledInterview
|
||||
template_name = "meetings/list_meetings.html"
|
||||
context_object_name = "meetings"
|
||||
paginate_by = 100
|
||||
|
||||
def get_queryset(self):
|
||||
# Start with a base queryset, ensuring an InterviewLocation link exists.
|
||||
queryset = super().get_queryset().filter(interview_location__isnull=False).select_related(
|
||||
'interview_location',
|
||||
'job',
|
||||
'application__person',
|
||||
'application',
|
||||
'interview_location',
|
||||
'job',
|
||||
'application__person',
|
||||
'application',
|
||||
).prefetch_related(
|
||||
'interview_location__zoommeetingdetails',
|
||||
'interview_location__onsitelocationdetails',
|
||||
'interview_location__zoommeetingdetails',
|
||||
'interview_location__onsitelocationdetails',
|
||||
)
|
||||
# Note: Printing the queryset here can consume memory for large sets.
|
||||
|
||||
|
||||
# Get filters from GET request
|
||||
search_query = self.request.GET.get("q")
|
||||
status_filter = self.request.GET.get("status")
|
||||
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)
|
||||
|
||||
# 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')
|
||||
normalized_type = type_filter.title()
|
||||
print(normalized_type)
|
||||
@ -5629,53 +5750,53 @@ class MeetingListView(ListView):
|
||||
if search_query:
|
||||
queryset = queryset.filter(interview_location__topic__icontains=search_query)
|
||||
|
||||
# 4. Status Filter
|
||||
# 4. Status Filter
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
# 5. Candidate Name Filter
|
||||
|
||||
# 5. Candidate Name Filter
|
||||
if candidate_name_filter:
|
||||
queryset = queryset.filter(
|
||||
Q(application__person__first_name__icontains=candidate_name_filter) |
|
||||
Q(application__person__last_name__icontains=candidate_name_filter)
|
||||
)
|
||||
|
||||
|
||||
return queryset.order_by("-interview_date", "-interview_time")
|
||||
|
||||
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
# Pass filters back to the template for retention
|
||||
context["search_query"] = self.request.GET.get("q", "")
|
||||
context["status_filter"] = self.request.GET.get("status", "")
|
||||
context["candidate_name_filter"] = self.request.GET.get("candidate_name", "")
|
||||
context["type_filter"] = self.request.GET.get("type", "")
|
||||
|
||||
|
||||
|
||||
|
||||
# CORRECTED: Pass the status choices from the model class for the filter dropdown
|
||||
context["status_choices"] = self.model.InterviewStatus.choices
|
||||
|
||||
|
||||
meetings_data = []
|
||||
|
||||
|
||||
for interview in context.get(self.context_object_name, []):
|
||||
location = interview.interview_location
|
||||
details = None
|
||||
details = None
|
||||
|
||||
if not location:
|
||||
continue
|
||||
|
||||
continue
|
||||
|
||||
# Determine and fetch the CONCRETE details object (prefetched)
|
||||
if location.location_type == location.LocationType.REMOTE:
|
||||
details = getattr(location, 'zoommeetingdetails', None)
|
||||
details = getattr(location, 'zoommeetingdetails', None)
|
||||
elif location.location_type == location.LocationType.ONSITE:
|
||||
details = getattr(location, 'onsitelocationdetails', None)
|
||||
|
||||
|
||||
# Combine date and time for template display/sorting
|
||||
start_datetime = None
|
||||
if interview.interview_date and interview.interview_time:
|
||||
start_datetime = datetime.combine(interview.interview_date, interview.interview_time)
|
||||
|
||||
|
||||
# SUCCESS: Build the data dictionary
|
||||
meetings_data.append({
|
||||
'interview': interview,
|
||||
@ -5683,43 +5804,43 @@ class MeetingListView(ListView):
|
||||
'details': details,
|
||||
'type': location.location_type,
|
||||
'topic': location.topic,
|
||||
# 'slug': interview.slug,
|
||||
'slug': interview.slug,
|
||||
'start_time': start_datetime, # Combined datetime object
|
||||
# Duration should ideally be on ScheduledInterview or fetched from details
|
||||
'duration': getattr(details, 'duration', 'N/A'),
|
||||
'duration': getattr(details, 'duration', 'N/A'),
|
||||
# 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,
|
||||
'meeting_id': getattr(details, 'meeting_id', None),
|
||||
# Use the primary status from the ScheduledInterview record
|
||||
'status': interview.status,
|
||||
})
|
||||
|
||||
|
||||
context["meetings_data"] = meetings_data
|
||||
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def reschedule_onsite_meeting(request, slug, candidate_id, meeting_id):
|
||||
"""Handles the rescheduling of an Onsite Interview (updates OnsiteLocationDetails)."""
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
candidate = get_object_or_404(Application, pk=candidate_id)
|
||||
|
||||
|
||||
# Fetch the OnsiteLocationDetails instance, ensuring it belongs to this candidate.
|
||||
# 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.
|
||||
onsite_meeting = get_object_or_404(
|
||||
OnsiteLocationDetails,
|
||||
OnsiteLocationDetails,
|
||||
pk=meeting_id,
|
||||
# Correct filter: Use the reverse link through the ScheduledInterview model.
|
||||
# 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':
|
||||
form = OnsiteReshuduleForm(request.POST, instance=onsite_meeting)
|
||||
|
||||
|
||||
if form.is_valid():
|
||||
instance = form.save(commit=False)
|
||||
|
||||
|
||||
if instance.start_time < timezone.now():
|
||||
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})
|
||||
@ -5734,10 +5855,10 @@ def reschedule_onsite_meeting(request, slug, candidate_id, meeting_id):
|
||||
scheduled_interview.save()
|
||||
except ScheduledInterview.DoesNotExist:
|
||||
messages.warning(request, "Parent schedule record not found. Status not updated.")
|
||||
|
||||
|
||||
instance.save()
|
||||
messages.success(request, "Onsite meeting successfully rescheduled! ✅")
|
||||
|
||||
|
||||
return redirect(reverse("candidate_interview_view", kwargs={'slug': job.slug}))
|
||||
|
||||
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)
|
||||
candidate = get_object_or_404(Application, pk=candidate_pk)
|
||||
|
||||
|
||||
# Target the specific Onsite meeting details instance
|
||||
meeting = get_object_or_404(OnsiteLocationDetails, pk=meeting_id)
|
||||
|
||||
|
||||
if request.method == "POST":
|
||||
# Delete the local Django object.
|
||||
# Delete the local Django object.
|
||||
# This deletes the base InterviewLocation and updates the ScheduledInterview FK.
|
||||
meeting.delete()
|
||||
messages.success(request, f"Onsite meeting for {candidate.name} deleted successfully.")
|
||||
|
||||
|
||||
return redirect(reverse("candidate_interview_view", kwargs={"slug": job.slug}))
|
||||
|
||||
context = {
|
||||
@ -5798,17 +5919,17 @@ def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk):
|
||||
"""
|
||||
job = get_object_or_404(JobPosting, slug=slug)
|
||||
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})
|
||||
|
||||
if request.method == 'POST':
|
||||
# Use the new form
|
||||
form = OnsiteScheduleForm(request.POST)
|
||||
form = OnsiteScheduleForm(request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
|
||||
cleaned_data = form.cleaned_data
|
||||
|
||||
|
||||
# 1. Create OnsiteLocationDetails
|
||||
onsite_loc = OnsiteLocationDetails(
|
||||
topic=cleaned_data['topic'],
|
||||
@ -5816,8 +5937,8 @@ def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk):
|
||||
room_number=cleaned_data['room_number'],
|
||||
start_time=cleaned_data['start_time'],
|
||||
duration=cleaned_data['duration'],
|
||||
status=OnsiteLocationDetails.Status.WAITING,
|
||||
location_type=InterviewLocation.LocationType.ONSITE,
|
||||
status=OnsiteLocationDetails.Status.WAITING,
|
||||
location_type=InterviewLocation.LocationType.ONSITE,
|
||||
)
|
||||
onsite_loc.save()
|
||||
|
||||
@ -5835,7 +5956,7 @@ def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk):
|
||||
interview_time=interview_time,
|
||||
status=ScheduledInterview.InterviewStatus.SCHEDULED,
|
||||
)
|
||||
|
||||
|
||||
messages.success(request, "Onsite interview scheduled successfully. ✅")
|
||||
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
|
||||
}
|
||||
# Use the new form
|
||||
form = OnsiteScheduleForm(initial=initial_data)
|
||||
|
||||
form = OnsiteScheduleForm(initial=initial_data)
|
||||
|
||||
context = {
|
||||
"form": form,
|
||||
"job": job,
|
||||
"candidate": candidate,
|
||||
"action_url": action_url,
|
||||
}
|
||||
|
||||
|
||||
return render(request, "meetings/schedule_onsite_meeting_form.html", context)
|
||||
|
||||
|
||||
@ -5892,7 +6013,7 @@ def meeting_details(request, slug):
|
||||
|
||||
# Forms for modals
|
||||
participant_form = InterviewParticpantsForm(instance=interview)
|
||||
|
||||
|
||||
|
||||
# email_form = InterviewEmailForm(
|
||||
# candidate=candidate,
|
||||
|
||||
@ -317,7 +317,7 @@
|
||||
</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 %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mt-2" role="alert">
|
||||
@ -417,9 +417,21 @@
|
||||
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>
|
||||
|
||||
<!-- Message Count JavaScript -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Update unread message count on page load
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<div class="row">
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
|
||||
<div class="col-12">
|
||||
{% if messages %}
|
||||
<ul class="messages">
|
||||
@ -15,9 +15,15 @@
|
||||
</ul>
|
||||
{% endif %}
|
||||
<div class="card">
|
||||
|
||||
|
||||
<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 %}
|
||||
<!-- Recipients Field -->
|
||||
<!-- Recipients Field -->
|
||||
@ -41,7 +47,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Subject Field -->
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.subject.id_for_label }}" class="form-label fw-bold">
|
||||
@ -57,7 +63,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Message Field -->
|
||||
<div class="mb-3">
|
||||
@ -171,8 +177,8 @@
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('email-compose-form');
|
||||
const sendBtn = document.getElementById('send-email-btn');
|
||||
const form = document.getElementById('email-compose-form1');
|
||||
const sendBtn = document.getElementById('send-email-btn1');
|
||||
const loadingOverlay = document.getElementById('email-loading-overlay');
|
||||
const messagesContainer = document.getElementById('email-messages-container');
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
--kaauh-teal-light: #4bb3be; /* For active glow */
|
||||
--kaauh-border: #eaeff3;
|
||||
--kaauh-primary-text: #343a40;
|
||||
|
||||
|
||||
/* Consistent Status/Color Map (aligning with theme/bootstrap defaults) */
|
||||
--color-draft: #6c757d; /* Secondary Gray */
|
||||
--color-active: var(--kaauh-teal); /* Primary Teal */
|
||||
@ -26,7 +26,7 @@
|
||||
/* Primary Color Overrides for Bootstrap Classes */
|
||||
.text-primary { color: var(--kaauh-teal) !important; }
|
||||
.bg-primary { background-color: var(--kaauh-teal) !important; }
|
||||
|
||||
|
||||
/* Status Badge Theme Mapping */
|
||||
.status-badge.bg-success { background-color: var(--color-active) !important; }
|
||||
.status-badge.bg-secondary { background-color: var(--color-draft) !important; }
|
||||
@ -89,9 +89,9 @@
|
||||
.nav-tabs {
|
||||
border-bottom: 1px solid var(--kaauh-border);
|
||||
background-color: #f8f9fa;
|
||||
padding: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
@ -109,7 +109,7 @@
|
||||
font-weight: 600;
|
||||
z-index: 2;
|
||||
border-right-color: transparent !important;
|
||||
margin-bottom: -1px;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
/* Main Action Button Style */
|
||||
@ -141,7 +141,7 @@
|
||||
border-left: 4px solid var(--kaauh-teal);
|
||||
background-color: #f0faff;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -154,7 +154,7 @@
|
||||
<li class="breadcrumb-item"><a href="{% url 'job_list' %}" class="text-secondary">Jobs</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page" style="
|
||||
color: #F43B5E; /* Rosy Accent Color */
|
||||
font-weight: 600;
|
||||
font-weight: 600;
|
||||
">Job Detail</li>
|
||||
</ol>
|
||||
</nav>
|
||||
@ -169,7 +169,7 @@
|
||||
<div>
|
||||
<h2 class="mb-1">{{ job.title }}</h2>
|
||||
<small class="text-light">{% trans "JOB ID: "%}{{ job.internal_job_id }}</small>
|
||||
|
||||
|
||||
{# Deadline #}
|
||||
{% if job.application_deadline %}
|
||||
<div class="text-light mt-1">
|
||||
@ -180,10 +180,10 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mt-2 mt-md-0">
|
||||
|
||||
|
||||
{# Status badge #}
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="status-badge
|
||||
<span class="status-badge
|
||||
{% if job.status == "ACTIVE" %}bg-success
|
||||
{% elif job.status == "DRAFT" %}bg-secondary
|
||||
{% elif job.status == "CLOSED" %}bg-warning
|
||||
@ -192,7 +192,7 @@
|
||||
{% else %}bg-secondary{% endif %}">
|
||||
{{ job.get_status_display }}
|
||||
</span>
|
||||
|
||||
|
||||
<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>
|
||||
</button>
|
||||
@ -216,7 +216,7 @@
|
||||
|
||||
{# CONTENT: CORE DETAILS (No Tabs) #}
|
||||
<div class="card-body">
|
||||
|
||||
|
||||
<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>
|
||||
</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" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{# Description Blocks (Main Content) #}
|
||||
{% if job.has_description_content %}
|
||||
<div class="mb-4">
|
||||
@ -301,6 +301,11 @@
|
||||
<i class="fas fa-cogs me-1"></i> {% trans "Form Template" %}
|
||||
</button>
|
||||
</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">
|
||||
<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" %}
|
||||
@ -364,12 +369,59 @@
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</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">
|
||||
<h5 class="mb-3"><i class="fab fa-linkedin me-2 text-info"></i>{% trans "LinkedIn Integration" %}</h5>
|
||||
<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 %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
<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" %}
|
||||
</button>
|
||||
@ -443,12 +495,12 @@
|
||||
<div class="card shadow-sm no-hover mb-4">
|
||||
<div class="card-header">
|
||||
<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" %}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
|
||||
|
||||
<div class="row g-3 stats-grid">
|
||||
|
||||
{# 1. Job Avg. Score #}
|
||||
@ -505,7 +557,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -663,4 +715,4 @@
|
||||
});
|
||||
</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|add_class:"form-select" }}
|
||||
{% 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|add_class:"form-control" }}
|
||||
{% 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|add_class:"form-control" }}
|
||||
{% 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