diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index 55879c6..d69c70a 100644 Binary files a/recruitment/__pycache__/forms.cpython-313.pyc and b/recruitment/__pycache__/forms.cpython-313.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-313.pyc b/recruitment/__pycache__/signals.cpython-313.pyc index 6fc1dcd..2412a52 100644 Binary files a/recruitment/__pycache__/signals.cpython-313.pyc and b/recruitment/__pycache__/signals.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index 5cf1366..87066dd 100644 Binary files a/recruitment/__pycache__/views.cpython-313.pyc and b/recruitment/__pycache__/views.cpython-313.pyc differ diff --git a/recruitment/email_service.py b/recruitment/email_service.py index 8ddedda..92d630a 100644 --- a/recruitment/email_service.py +++ b/recruitment/email_service.py @@ -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)}" diff --git a/recruitment/forms.py b/recruitment/forms.py index ea32ac2..a2a9291 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -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 diff --git a/recruitment/signals.py b/recruitment/signals.py index ab502eb..e567816 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -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", diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 8ecf803..7a185d6 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -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}") + + diff --git a/recruitment/urls.py b/recruitment/urls.py index c72eff2..5b60895 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -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//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//edit/", views.edit_job, name="edit_job_api"), @@ -271,7 +272,7 @@ urlpatterns = [ views.interview_detail_view, name="interview_detail", ), - + # users urls path("user/", 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( '/candidate//onsite/reschedule//', views.reschedule_onsite_meeting, name='reschedule_onsite_meeting' ), - + # 2. Onsite Delete URL - + path( 'job//candidates//delete-onsite-meeting//', 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//", views.meeting_details, name="meeting_details"), diff --git a/recruitment/views.py b/recruitment/views.py index 6a5a7b9..1a01d9d 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -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, diff --git a/templates/base.html b/templates/base.html index e5d3209..c4737e1 100644 --- a/templates/base.html +++ b/templates/base.html @@ -317,7 +317,7 @@ -
+
{% if messages %} {% for message in messages %}