diff --git a/.env b/.env deleted file mode 100644 index b9e2bf0..0000000 --- a/.env +++ /dev/null @@ -1,3 +0,0 @@ -DB_NAME=norahuniversity -DB_USER=norahuniversity -DB_PASSWORD=norahuniversity \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5617eb3..f765b7b 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ htmlcov/ # Media and Static files (if served locally and not meant for version control) media/ +static/ # Deployment files *.tar.gz diff --git a/NorahUniversity/__pycache__/settings.cpython-312.pyc b/NorahUniversity/__pycache__/settings.cpython-312.pyc index d8f9217..835e75d 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-312.pyc and b/NorahUniversity/__pycache__/settings.cpython-312.pyc differ diff --git a/NorahUniversity/__pycache__/urls.cpython-312.pyc b/NorahUniversity/__pycache__/urls.cpython-312.pyc index b2d58f6..483cfbe 100644 Binary files a/NorahUniversity/__pycache__/urls.cpython-312.pyc and b/NorahUniversity/__pycache__/urls.cpython-312.pyc differ diff --git a/NorahUniversity/urls.py b/NorahUniversity/urls.py index 555b9ce..ae9308a 100644 --- a/NorahUniversity/urls.py +++ b/NorahUniversity/urls.py @@ -28,7 +28,7 @@ urlpatterns = [ path('application//apply/', views.application_detail, name='application_detail'), path('application//signup/', views.candidate_signup, name='candidate_signup'), path('application//success/', views.application_success, name='application_success'), - path('application/applicant/profile', views.applicant_profile, name='applicant_profile'), + # path('application/applicant/profile', views.applicant_profile, name='applicant_profile'), path('api/templates/', views.list_form_templates, name='list_form_templates'), path('api/templates/save/', views.save_form_template, name='save_form_template'), diff --git a/recruitment/__pycache__/forms.cpython-312.pyc b/recruitment/__pycache__/forms.cpython-312.pyc index 5cd8f96..1bfac84 100644 Binary files a/recruitment/__pycache__/forms.cpython-312.pyc and b/recruitment/__pycache__/forms.cpython-312.pyc differ diff --git a/recruitment/__pycache__/models.cpython-312.pyc b/recruitment/__pycache__/models.cpython-312.pyc index d2c2570..51e8e50 100644 Binary files a/recruitment/__pycache__/models.cpython-312.pyc and b/recruitment/__pycache__/models.cpython-312.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-312.pyc b/recruitment/__pycache__/signals.cpython-312.pyc index 9fd593d..65003fd 100644 Binary files a/recruitment/__pycache__/signals.cpython-312.pyc and b/recruitment/__pycache__/signals.cpython-312.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-312.pyc b/recruitment/__pycache__/urls.cpython-312.pyc index 543d155..c1e186f 100644 Binary files a/recruitment/__pycache__/urls.cpython-312.pyc and b/recruitment/__pycache__/urls.cpython-312.pyc differ diff --git a/recruitment/__pycache__/views.cpython-312.pyc b/recruitment/__pycache__/views.cpython-312.pyc index 603b919..3a494ac 100644 Binary files a/recruitment/__pycache__/views.cpython-312.pyc and b/recruitment/__pycache__/views.cpython-312.pyc differ diff --git a/recruitment/email_service.py b/recruitment/email_service.py index e4adc32..8ddedda 100644 --- a/recruitment/email_service.py +++ b/recruitment/email_service.py @@ -1,15 +1,24 @@ """ Email service for sending notifications related to agency messaging. """ +from .models import Application +from django.shortcuts import get_object_or_404 +import logging +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.utils.html import strip_tags +from django_q.tasks import async_task # Import needed at the top for clarity +logger = logging.getLogger(__name__) from django.core.mail import send_mail, EmailMultiAlternatives from django.conf import settings from django.template.loader import render_to_string from django.utils.html import strip_tags +from django.contrib.auth import get_user_model import logging - +from .models import Message logger = logging.getLogger(__name__) - +User=get_user_model() class EmailService: """ @@ -225,17 +234,10 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi return {'success': False, 'error': error_msg} -from .models import Application -from django.shortcuts import get_object_or_404 -import logging -from django.conf import settings -from django.core.mail import EmailMultiAlternatives -from django.utils.html import strip_tags -from django_q.tasks import async_task # Import needed at the top for clarity -logger = logging.getLogger(__name__) -def send_bulk_email(subject, message, recipient_list, request=None, attachments=None, async_task_=False, from_interview=False): + +def send_bulk_email(subject, message, recipient_list, request=None, attachments=None, async_task_=False, from_interview=False,job=None): """ Send bulk email to multiple recipients with HTML support and attachments, supporting synchronous or asynchronous dispatch. @@ -301,7 +303,8 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= processed_attachments = attachments if attachments else [] 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: @@ -311,7 +314,10 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= custom_message, # Pass the custom message [recipient_email], # Pass the specific recipient as a list of one processed_attachments, - hook='recruitment.tasks.email_success_hook' + sender_user_id, + job_id, + hook='recruitment.tasks.email_success_hook', + ) task_ids.append(task_id) @@ -350,80 +356,101 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= 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 - - # 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: - email_obj.send(fail_silently=False) - successful_sends += 1 + # 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)}") + + + else: + logger.error("fialed to send email") + + successful_sends += 1 + except Exception as e: + logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True) + + if not from_interview: + # Send Emails - Pure Candidates + for email in pure_candidate_emails: + candidate_name = Application.objects.filter(person__email=email).first().person.full_name + candidate_message = f"Hi, {candidate_name}" + "\n" + message + send_individual_email(email, candidate_message) + + # Send Emails - Agencies + i = 0 + for email in agency_emails: + candidate_email = candidate_through_agency_emails[i] + candidate_name = Application.objects.filter(person__email=candidate_email).first().person.full_name + agency_message = f"Hi, {candidate_name}" + "\n" + message + send_individual_email(email, agency_message) + i += 1 + + logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.") + return { + 'success': True, + 'recipients_count': successful_sends, + 'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.' + } + else: + for email in recipient_list: + send_individual_email(email, message) + + logger.info(f"Interview email processing complete. Sent successfully to {successful_sends} out of {total_recipients} recipients.") + return { + 'success': True, + 'recipients_count': successful_sends, + 'message': f'Interview emails sent successfully to {successful_sends} recipient(s).' + } + except Exception as e: - logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True) - - if not from_interview: - # Send Emails - Pure Candidates - for email in pure_candidate_emails: - candidate_name = Application.objects.filter(email=email).first().first_name - candidate_message = f"Hi, {candidate_name}" + "\n" + message - send_individual_email(email, candidate_message) - - # Send Emails - Agencies - i = 0 - for email in agency_emails: - candidate_email = candidate_through_agency_emails[i] - candidate_name = Application.objects.filter(email=candidate_email).first().first_name - agency_message = f"Hi, {candidate_name}" + "\n" + message - send_individual_email(email, agency_message) - i += 1 - - logger.info(f"Bulk email processing complete. Sent successfully to {successful_sends} out of {total_recipients} unique recipients.") - return { - 'success': True, - 'recipients_count': successful_sends, - 'message': f'Email processing complete. {successful_sends} email(s) were sent successfully to {total_recipients} unique intended recipients.' - } - else: - for email in recipient_list: - send_individual_email(email, message) - - logger.info(f"Interview email processing complete. Sent successfully to {successful_sends} out of {total_recipients} recipients.") - return { - 'success': True, - 'recipients_count': successful_sends, - 'message': f'Interview emails sent successfully to {successful_sends} recipient(s).' - } - - except Exception as e: - error_msg = f"Failed to process bulk email send request: {str(e)}" - logger.error(error_msg, exc_info=True) - return {'success': False, 'error': error_msg} \ No newline at end of file + error_msg = f"Failed to process bulk email send request: {str(e)}" + logger.error(error_msg, exc_info=True) + return {'success': False, 'error': error_msg} \ No newline at end of file diff --git a/recruitment/forms.py b/recruitment/forms.py index 3929bed..ea32ac2 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -1621,7 +1621,7 @@ class CandidateEmailForm(forms.Form): elif candidate and candidate.stage == 'Interview': message_parts = [ f"Than you, for your interest in the {self.job.title} role.", - f"We're pleased to inform you that your initial screening was successful!", + f"We're pleased to inform you that you have cleared your exam!", f"The next step is the mandatory online assessment exam.", f"Please complete the assessment by using the following link:", f"https://kaauh/hire/exam", @@ -1698,6 +1698,7 @@ class CandidateEmailForm(forms.Form): return message + class InterviewParticpantsForm(forms.ModelForm): participants = forms.ModelMultipleChoiceField( queryset=Participants.objects.all(), @@ -1706,7 +1707,7 @@ class InterviewParticpantsForm(forms.ModelForm): ) system_users=forms.ModelMultipleChoiceField( - queryset=User.objects.all(), + queryset=User.objects.filter(user_type='staff'), widget=forms.CheckboxSelectMultiple, required=False, label=_("Select Users")) @@ -1861,107 +1862,107 @@ class InterviewParticpantsForm(forms.ModelForm): # self.initial['message_for_participants'] = participants_message.strip() -class InterviewEmailForm(forms.Form): - # ... (Field definitions) +# class InterviewEmailForm(forms.Form): +# # ... (Field definitions) - def __init__(self, *args, candidate, external_participants, system_participants, meeting, job, **kwargs): - super().__init__(*args, **kwargs) +# def __init__(self, *args, candidate, external_participants, system_participants, meeting, job, **kwargs): +# super().__init__(*args, **kwargs) - location = meeting.interview_location +# location = meeting - # --- Data Preparation --- +# # --- Data Preparation --- - # Safely access details through the related InterviewLocation object - if location and location.start_time: - formatted_date = location.start_time.strftime('%Y-%m-%d') - formatted_time = location.start_time.strftime('%I:%M %p') - duration = location.duration - meeting_link = location.details_url if location.details_url else "N/A (See Location Topic)" - else: - # Handle case where location or time is missing/None - formatted_date = "TBD - Awaiting Scheduling" - formatted_time = "TBD" - duration = "N/A" - meeting_link = "Not Available" +# # Safely access details through the related InterviewLocation object +# if location and location.start_time: +# formatted_date = location.start_time.strftime('%Y-%m-%d') +# formatted_time = location.start_time.strftime('%I:%M %p') +# duration = location.duration +# meeting_link = location.details_url if location.details_url else "N/A (See Location Topic)" +# else: +# # Handle case where location or time is missing/None +# formatted_date = "TBD - Awaiting Scheduling" +# formatted_time = "TBD" +# duration = "N/A" +# meeting_link = "Not Available" - job_title = job.title - agency_name = candidate.hiring_agency.name if candidate.belong_to_an_agency and candidate.hiring_agency else "Hiring Agency" +# job_title = job.title +# agency_name = candidate.hiring_agency.name if candidate.belong_to_an_agency and candidate.hiring_agency else "Hiring Agency" - # --- Combined Participants List for Internal Email --- - external_participants_names = ", ".join([p.name for p in external_participants ]) - system_participants_names = ", ".join([p.first_name for p in system_participants ]) - participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names])) +# # --- Combined Participants List for Internal Email --- +# external_participants_names = ", ".join([p.name for p in external_participants ]) +# system_participants_names = ", ".join([p.first_name for p in system_participants ]) +# participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names])) - # --- 1. Candidate Message (Use meeting_link) --- - candidate_message = f""" -Dear {candidate.full_name}, +# # --- 1. Candidate Message (Use meeting_link) --- +# candidate_message = f""" +# Dear {candidate.full_name}, -Thank you for your interest in the **{job_title}** position at KAAUH. We're pleased to invite you to an interview! +# Thank you for your interest in the **{job_title}** position at KAAUH. We're pleased to invite you to an interview! -The details of your virtual interview are as follows: +# The details of your virtual interview are as follows: -- **Date:** {formatted_date} -- **Time:** {formatted_time} (RIYADH TIME) -- **Duration:** {duration} -- **Meeting Link:** {meeting_link} +# - **Date:** {formatted_date} +# - **Time:** {formatted_time} (RIYADH TIME) +# - **Duration:** {duration} +# - **Meeting Link:** {meeting_link} -Please click the link at the scheduled time to join the interview. +# Please click the link at the scheduled time to join the interview. -Kindly reply to this email to **confirm your attendance** or to propose an alternative time if necessary. +# Kindly reply to this email to **confirm your attendance** or to propose an alternative time if necessary. -We look forward to meeting you. +# We look forward to meeting you. -Best regards, -KAAUH Hiring Team -""" - # ... (Messages for agency and participants remain the same, using the updated safe variables) +# Best regards, +# KAAUH Hiring Team +# """ +# # ... (Messages for agency and participants remain the same, using the updated safe variables) - # --- 2. Agency Message (Professional and clear details) --- - agency_message = f""" -Dear {agency_name}, -... -**Interview Details:** -... -- **Date:** {formatted_date} -- **Time:** {formatted_time} (RIYADH TIME) -- **Duration:** {duration} -- **Meeting Link:** {meeting_link} -... -""" +# # --- 2. Agency Message (Professional and clear details) --- +# agency_message = f""" +# Dear {agency_name}, +# ... +# **Interview Details:** +# ... +# - **Date:** {formatted_date} +# - **Time:** {formatted_time} (RIYADH TIME) +# - **Duration:** {duration} +# - **Meeting Link:** {meeting_link} +# ... +# """ - # --- 3. Participants Message (Action-oriented and informative) --- - participants_message = f""" -Hi Team, -... -**Interview Summary:** +# # --- 3. Participants Message (Action-oriented and informative) --- +# participants_message = f""" +# Hi Team, +# ... +# **Interview Summary:** -- **Candidate:** {candidate.full_name} -- **Date:** {formatted_date} -- **Time:** {formatted_time} (RIYADH TIME) -- **Duration:** {duration} -- **Your Fellow Interviewers:** {participant_names} +# - **Candidate:** {candidate.full_name} +# - **Date:** {formatted_date} +# - **Time:** {formatted_time} (RIYADH TIME) +# - **Duration:** {duration} +# - **Your Fellow Interviewers:** {participant_names} -**Action Items:** +# **Action Items:** -1. Please review **{candidate.full_name}'s** resume and notes. -2. The official calendar invite contains the meeting link ({meeting_link}) and should be used to join. -3. Be ready to start promptly at the scheduled time. -... -""" - # Set initial data - self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}" - self.initial['message_for_candidate'] = candidate_message.strip() - self.initial['message_for_agency'] = agency_message.strip() - self.initial['message_for_participants'] = participants_message.strip() +# 1. Please review **{candidate.full_name}'s** resume and notes. +# 2. The official calendar invite contains the meeting link ({meeting_link}) and should be used to join. +# 3. Be ready to start promptly at the scheduled time. +# ... +# """ +# # Set initial data +# self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}" +# self.initial['message_for_candidate'] = candidate_message.strip() +# self.initial['message_for_agency'] = agency_message.strip() +# self.initial['message_for_participants'] = participants_message.strip() -# class OnsiteLocationForm(forms.ModelForm): -# class Meta: -# model= -# fields=['location'] -# widgets={ -# 'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}), -# } +# # class OnsiteLocationForm(forms.ModelForm): +# # class Meta: +# # model= +# # fields=['location'] +# # widgets={ +# # 'location': forms.TextInput(attrs={'placeholder': 'Enter Interview Location'}), +# # } #during bulk schedule class OnsiteLocationForm(forms.ModelForm): @@ -1986,6 +1987,125 @@ class OnsiteLocationForm(forms.ModelForm): } +class InterviewEmailForm(forms.Form): + subject = forms.CharField(max_length=255, widget=forms.TextInput(attrs={'class': 'form-control'})) + message_for_candidate = forms.CharField(widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 6})) + message_for_agency = forms.CharField(widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 6})) + message_for_participants = forms.CharField(widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 6})) + + def __init__(self, *args, candidate, external_participants, system_participants, meeting, job, **kwargs): + """ + meeting: an InterviewLocation instance (e.g., ZoomMeetingDetails or OnsiteLocationDetails) + """ + super().__init__(*args, **kwargs) + + # ✅ meeting is already the InterviewLocation — do NOT use .interview_location + location = meeting + + # --- Determine concrete details (Zoom or Onsite) --- + if location.location_type == location.LocationType.REMOTE: + details = getattr(location, 'zoommeetingdetails', None) + elif location.location_type == location.LocationType.ONSITE: + details = getattr(location, 'onsitelocationdetails', None) + else: + details = None + + # --- Extract meeting info safely --- + if details and details.start_time: + formatted_date = details.start_time.strftime('%Y-%m-%d') + formatted_time = details.start_time.strftime('%I:%M %p') + duration = details.duration + meeting_link = location.details_url or "N/A (See Location Topic)" + else: + formatted_date = "TBD - Awaiting Scheduling" + formatted_time = "TBD" + duration = "N/A" + meeting_link = "Not Available" + + job_title = job.title + agency_name = ( + candidate.hiring_agency.name + if candidate.belong_to_agency and candidate.hiring_agency + else "Hiring Agency" + ) + + # --- Participant names for internal email --- + external_names = ", ".join([p.name for p in external_participants]) + system_names = ", ".join([u.get_full_name() or u.username for u in system_participants]) + participant_names = ", ".join(filter(None, [external_names, system_names])) + + # --- Candidate Message --- + candidate_message = f""" +Dear {candidate.full_name}, + +Thank you for your interest in the **{job_title}** position at KAAUH. We're pleased to invite you to an interview! + +The details of your interview are as follows: + +- **Date:** {formatted_date} +- **Time:** {formatted_time} (RIYADH TIME) +- **Duration:** {duration} minutes +- **Meeting Link/Location:** {meeting_link} + +Please be ready at the scheduled time. + +Kindly reply to confirm your attendance or propose an alternative if needed. + +We look forward to meeting you. + +Best regards, +KAAUH Hiring Team +""".strip() + + # --- Agency Message --- + agency_message = f""" +Dear {agency_name}, + +This is to inform you that your candidate, **{candidate.full_name}**, has been scheduled for an interview for the **{job_title}** position. + +**Interview Details:** +- **Date:** {formatted_date} +- **Time:** {formatted_time} (RIYADH TIME) +- **Duration:** {duration} minutes +- **Meeting Link/Location:** {meeting_link} + +Please ensure the candidate is informed and prepared. + +Best regards, +KAAUH Hiring Team +""".strip() + + # --- Participants (Interview Panel) Message --- + participants_message = f""" +Hi Team, + +You are scheduled to interview **{candidate.full_name}** for the **{job_title}** role. + +**Interview Summary:** +- **Candidate:** {candidate.full_name} +- **Date:** {formatted_date} +- **Time:** {formatted_time} (RIYADH TIME) +- **Duration:** {duration} minutes +- **Location/Link:** {meeting_link} +- **Fellow Interviewers:** {participant_names} + +**Action Items:** +1. Review the candidate’s resume and application notes. +2. Join via the link above (or be at the physical location) on time. +3. Coordinate among yourselves for role coverage. + +Thank you! +""".strip() + + # --- Set initial values --- + self.initial.update({ + 'subject': f"Interview Invitation: {job_title} - {candidate.full_name}", + 'message_for_candidate': candidate_message, + 'message_for_agency': agency_message, + 'message_for_participants': participants_message, + }) + + class OnsiteReshuduleForm(forms.ModelForm): class Meta: model = OnsiteLocationDetails diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 7eaf1d4..8ecf803 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -12,8 +12,9 @@ from . linkedin_service import LinkedInService from django.shortcuts import get_object_or_404 from . models import JobPosting from django.utils import timezone -from . models import InterviewSchedule,ScheduledInterview,ZoomMeetingDetails - +from . models import InterviewSchedule,ScheduledInterview,ZoomMeetingDetails,Message +from django.contrib.auth import get_user_model +User = get_user_model() # Add python-docx import for Word document processing try: from docx import Document @@ -28,7 +29,7 @@ logger = logging.getLogger(__name__) OPENROUTER_API_KEY ='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' # OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free' -OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct' +OPENROUTER_MODEL = 'openai/gpt-oss-20b' # OPENROUTER_MODEL = 'openai/gpt-oss-20b' # OPENROUTER_MODEL = 'mistralai/mistral-small-3.2-24b-instruct:free' @@ -506,7 +507,6 @@ def handle_zoom_webhook_event(payload): Background task to process a Zoom webhook event and update the local ZoomMeeting status. It handles: created, updated, started, ended, and deleted events. """ - print(payload) event_type = payload.get('event') object_data = payload['payload']['object'] @@ -535,9 +535,7 @@ def handle_zoom_webhook_event(payload): # elif event_type == 'meeting.updated': # Only update time fields if they are in the payload print(object_data) - meeting_start_time = object_data.get('start_time', meeting_instance.start_time) - if meeting_start_time: - meeting_instance.start_time = datetime.fromisoformat(meeting_start_time) + meeting_instance.start_time = object_data.get('start_time', meeting_instance.start_time) meeting_instance.duration = object_data.get('duration', meeting_instance.duration) meeting_instance.timezone = object_data.get('timezone', meeting_instance.timezone) @@ -758,7 +756,7 @@ from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.utils.html import strip_tags -def _task_send_individual_email(subject, body_message, recipient, attachments): +def _task_send_individual_email(subject, body_message, recipient, attachments,sender,job): """Internal helper to create and send a single email.""" from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa') @@ -778,16 +776,36 @@ def _task_send_individual_email(subject, body_message, recipient, attachments): email_obj.attach(filename, content, content_type) try: - email_obj.send(fail_silently=False) - return True + 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=sender, + recipient=user, + job=job, + subject=subject, + content=body_message, # Store the full HTML or plain content + message_type='DIRECT', + is_read=False, # It's just sent, not read yet + ) + logger.info(f"Stored sent message ID {new_message.id} in DB.") + except Exception as e: + logger.error(f"Email sent to {recipient}, but failed to store in DB: {str(e)}") + + + else: + logger.error("fialed to send email") + + except Exception as e: - logger.error(f"Task failed to send email to {recipient}: {str(e)}", exc_info=True) - return False + logger.error(f"Failed to send email to {recipient}: {str(e)}", exc_info=True) -def send_bulk_email_task(subject, message, recipient_list, attachments=None, hook='recruitment.tasks.email_success_hook'): +def send_bulk_email_task(subject, message, recipient_list,attachments=None,sender_user_id=None,job_id=None, hook='recruitment.tasks.email_success_hook'): """ - Django-Q background task to send pre-formatted email to a list of recipients. + Django-Q background task to send pre-formatted email to a list of recipients., Receives arguments directly from the async_task call. """ logger.info(f"Starting bulk email task for {len(recipient_list)} recipients") @@ -796,11 +814,13 @@ def send_bulk_email_task(subject, message, recipient_list, attachments=None, hoo if not recipient_list: 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. for recipient in recipient_list: # The 'message' is the custom message specific to this recipient. - if _task_send_individual_email(subject, message, recipient, attachments): + if _task_send_individual_email(subject, message, recipient, attachments,sender,job): successful_sends += 1 if successful_sends > 0: diff --git a/recruitment/urls.py b/recruitment/urls.py index 0e57def..c72eff2 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -656,5 +656,6 @@ urlpatterns = [ # Detail View (assuming slug is on ScheduledInterview) - # path("interviews/meetings//", views.MeetingDetailView.as_view(), name="meeting_details"), + path("interviews/meetings//", views.meeting_details, name="meeting_details"), + ] diff --git a/recruitment/views.py b/recruitment/views.py index 675ee16..6a5a7b9 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -129,7 +129,8 @@ from .models import ( Message, Document, OnsiteLocationDetails, - InterviewLocation + InterviewLocation, + InterviewNote ) @@ -249,123 +250,7 @@ class ZoomMeetingCreateView(StaffRequiredMixin, CreateView): messages.error(self.request, f"Error creating meeting: {e}") return redirect(reverse("create_meeting", kwargs={"slug": instance.slug})) - -# class ZoomMeetingListView(StaffRequiredMixin, ListView): -# model = ZoomMeetingDetails -# template_name = "meetings/list_meetings.html" -# context_object_name = "meetings" -# paginate_by = 10 - -# def get_queryset(self): -# queryset = super().get_queryset().order_by("-start_time") - -# # Prefetch related interview data efficiently - -# queryset = queryset.prefetch_related( -# Prefetch( -# "interview", # related_name from ZoomMeeting to ScheduledInterview -# queryset=ScheduledInterview.objects.select_related("application", "job"), -# to_attr="interview_details", # Changed to not start with underscore -# ) -# ) - -# # Handle search by topic or meeting_id -# search_query = self.request.GET.get( -# "q", "" -# ) # Renamed from 'search' to 'q' for consistency -# if search_query: -# queryset = queryset.filter( -# Q(topic__icontains=search_query) | Q(meeting_id__icontains=search_query) -# ) - -# # Handle filter by status -# status_filter = self.request.GET.get("status", "") -# if status_filter: -# queryset = queryset.filter(status=status_filter) - -# # Handle search by candidate name -# candidate_name = self.request.GET.get("candidate_name", "") -# if candidate_name: -# # Filter based on the name of the candidate associated with the meeting's interview -# queryset = queryset.filter( -# Q(interview__application__first_name__icontains=candidate_name) -# | Q(interview__application__last_name__icontains=candidate_name) -# ) - -# return queryset - -# def get_context_data(self, **kwargs): -# context = super().get_context_data(**kwargs) -# context["search_query"] = self.request.GET.get("q", "") -# context["status_filter"] = self.request.GET.get("status", "") -# context["candidate_name_filter"] = self.request.GET.get("candidate_name", "") -# return context - - - -# @login_required -# def InterviewListView(request): -# # interview_type=request.GET.get('interview_type','Remote') -# # print(interview_type) -# interview_type='Onsite' -# meetings=ScheduledInterview.objects.filter(schedule__interview_type=interview_type) -# return render(request, "meetings/list_meetings.html",{ -# 'meetings':meetings, -# }) - - -# search_query = request.GET.get("q", "") # Renamed from 'search' to 'q' for consistency -# if search_query: -# interviews = interviews.filter( -# Q(topic__icontains=search_query) | Q(meeting_id__icontains=search_query) -# ) - -# # Handle filter by status -# status_filter = request.GET.get("status", "") -# if status_filter: -# queryset = queryset.filter(status=status_filter) - -# # Handle search by candidate name -# candidate_name = request.GET.get("candidate_name", "") -# if candidate_name: -# # Filter based on the name of the candidate associated with the meeting's interview -# queryset = queryset.filter( -# Q(interview__candidate__first_name__icontains=candidate_name) | -# Q(interview__candidate__last_name__icontains=candidate_name) -# ) - - -# @login_required -# def InterviewListView(request): -# # interview_type=request.GET.get('interview_type','Remote') -# # print(interview_type) -# interview_type='Onsite' -# meetings=ScheduledInterview.objects.filter(schedule__interview_type=interview_type) -# return render(request, "meetings/list_meetings.html",{ -# 'meetings':meetings, -# }) - - -# search_query = request.GET.get("q", "") # Renamed from 'search' to 'q' for consistency -# if search_query: -# interviews = interviews.filter( -# Q(topic__icontains=search_query) | Q(meeting_id__icontains=search_query) -# ) - -# # Handle filter by status -# status_filter = request.GET.get("status", "") -# if status_filter: -# queryset = queryset.filter(status=status_filter) - -# # Handle search by candidate name -# candidate_name = request.GET.get("candidate_name", "") -# if candidate_name: -# # Filter based on the name of the candidate associated with the meeting's interview -# queryset = queryset.filter( -# Q(interview__candidate__first_name__icontains=candidate_name) | -# Q(interview__candidate__last_name__icontains=candidate_name) -# ) - + class ZoomMeetingDetailsView(StaffRequiredMixin, DetailView): model = ZoomMeetingDetails @@ -1268,8 +1153,8 @@ def application_submit_form(request, template_slug): ) -def applicant_profile(request): - return render(request, "applicant/applicant_profile.html") +# def applicant_profile(request): +# return render(request, "applicant/applicant_profile.html") @csrf_exempt @@ -3122,7 +3007,7 @@ def add_meeting_comment(request, slug): meeting = get_object_or_404(ZoomMeetingDetails, slug=slug) if request.method == "POST": - form = MeetingCommentForm(request.POST) + form = InterviewNoteForm(request.POST) if form.is_valid(): comment = form.save(commit=False) comment.meeting = meeting @@ -3143,7 +3028,7 @@ def add_meeting_comment(request, slug): return redirect("meeting_details", slug=slug) else: - form = MeetingCommentForm() + form = InterviewNoteForm() context = { "form": form, @@ -3169,7 +3054,7 @@ def edit_meeting_comment(request, slug, comment_id): return redirect("meeting_details", slug=slug) if request.method == "POST": - form = MeetingCommentForm(request.POST, instance=comment) + form = InterviewNoteForm(request.POST, instance=comment) if form.is_valid(): comment = form.save() messages.success(request, "Comment updated successfully!") @@ -3187,7 +3072,7 @@ def edit_meeting_comment(request, slug, comment_id): return redirect("meeting_details", slug=slug) else: - form = MeetingCommentForm(instance=comment) + form = InterviewNoteForm(instance=comment) context = {"form": form, "meeting": meeting, "comment": comment} return render(request, "includes/edit_comment_form.html", context) @@ -4692,7 +4577,8 @@ 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) @@ -4700,8 +4586,25 @@ def message_create(request): message = form.save(commit=False) message.sender = request.user message.save() - messages.success(request, "Message sent successfully!") + ["recipient", "job", "subject", "content", "message_type"] + recipient_email = form.cleaned_data['recipient'].email # Assuming recipient is a User or Model with an 'email' field + subject = form.cleaned_data['subject'] + custom_message = form.cleaned_data['content'] + job_id = form.cleaned_data['job'].id if 'job' in form.cleaned_data and form.cleaned_data['job'] else None + sender_user_id = request.user.id + + task_id = async_task( + 'recruitment.tasks.send_bulk_email_task', + subject, + custom_message, # Pass the custom message + [recipient_email], # Pass the specific recipient as a list of one + + sender_user_id=sender_user_id, + job_id=job_id, + hook='recruitment.tasks.email_success_hook') + + logger.info(f"{task_id} queued.") return redirect("message_list") else: messages.error(request, "Please correct the errors below.") @@ -4714,6 +4617,8 @@ def message_create(request): if request.user.user_type != "staff": return render(request, "messages/candidate_message_form.html", context) return render(request, "messages/message_form.html", context) + + @login_required def message_reply(request, message_id): """Reply to a message""" @@ -5206,7 +5111,7 @@ def compose_candidate_email(request, job_slug): if request.method == 'POST': - print("........................................................inside candidate conpose.............") + candidate_ids = request.POST.getlist('candidate_ids') candidates=Application.objects.filter(id__in=candidate_ids) form = CandidateEmailForm(job, candidates, request.POST) @@ -5233,14 +5138,16 @@ def compose_candidate_email(request, job_slug): # Send emails using email service (no attachments, synchronous to avoid pickle issues) - email_result = send_bulk_email( + email_result = send_bulk_email( # subject=subject, message=message, recipient_list=email_addresses, request=request, attachments=None, async_task_=True, # Changed to False to avoid pickle issues - from_interview=False + from_interview=False, + job=job + ) if email_result["success"]: @@ -5538,25 +5445,50 @@ def candidate_signup(request, slug): from .forms import InterviewParticpantsForm +# def create_interview_participants(request, slug): +# schedule_interview = get_object_or_404(ScheduledInterview, slug=slug) +# interview_slug = schedule_interview.zoom_meeting.slug +# if request.method == "POST": +# form = InterviewParticpantsForm(request.POST, instance=schedule_interview) +# if form.is_valid(): +# # Save the main Candidate object, but don't commit to DB yet +# candidate = form.save(commit=False) +# candidate.save() +# # This is important for ManyToMany fields: save the many-to-many data +# form.save_m2m() +# return redirect( +# "meeting_details", slug=interview_slug +# ) # Redirect to a success page +# else: +# form = InterviewParticpantsForm(instance=schedule_interview) + +# return render( +# request, "interviews/interview_participants_form.html", {"form": form} +# ) + def create_interview_participants(request, slug): + """ + Manage participants for a ScheduledInterview. + Uses interview_pk because ScheduledInterview has no slug. + """ schedule_interview = get_object_or_404(ScheduledInterview, slug=slug) - interview_slug = schedule_interview.zoom_meeting.slug + + # Get the slug from the related InterviewLocation (the "meeting") + meeting_slug = schedule_interview.interview_location.slug # ✅ Correct + if request.method == "POST": form = InterviewParticpantsForm(request.POST, instance=schedule_interview) if form.is_valid(): - # Save the main Candidate object, but don't commit to DB yet - candidate = form.save(commit=False) - candidate.save() - # This is important for ManyToMany fields: save the many-to-many data - form.save_m2m() - return redirect( - "meeting_details", slug=interview_slug - ) # Redirect to a success page + form.save() # No need for commit=False — it's not a create, just update + messages.success(request, "Participants updated successfully.") + return redirect("meeting_details", slug=meeting_slug) else: form = InterviewParticpantsForm(instance=schedule_interview) return render( - request, "interviews/interview_participants_form.html", {"form": form} + request, + "interviews/interview_participants_form.html", + {"form": form, "interview": schedule_interview} ) @@ -5751,7 +5683,7 @@ class MeetingListView(ListView): 'details': details, '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'), @@ -5925,3 +5857,62 @@ def schedule_onsite_meeting_for_candidate(request, slug, candidate_pk): return render(request, "meetings/schedule_onsite_meeting_form.html", context) + + +from django.http import Http404 + + +def meeting_details(request, slug): + # Fetch the meeting (InterviewLocation or subclass) by slug + meeting = get_object_or_404( + InterviewLocation.objects.select_related( + 'scheduled_interview__application__person', + 'scheduled_interview__job', + 'zoommeetingdetails', + 'onsitelocationdetails', + ).prefetch_related( + 'scheduled_interview__participants', + 'scheduled_interview__system_users', + 'scheduled_interview__notes', + ), + slug=slug + ) + + try: + interview = meeting.scheduled_interview + except ScheduledInterview.DoesNotExist: + raise Http404("No interview is associated with this meeting.") + + candidate = interview.application + job = interview.job + + external_participants = interview.participants.all() + system_participants = interview.system_users.all() + total_participants = external_participants.count() + system_participants.count() + + # Forms for modals + participant_form = InterviewParticpantsForm(instance=interview) + + + # email_form = InterviewEmailForm( + # candidate=candidate, + # external_participants=external_participants, # QuerySet of Participants + # system_participants=system_participants, # QuerySet of Users + # meeting=meeting, # ← This is InterviewLocation (e.g., ZoomMeetingDetails) + # job=job, + # ) + + context = { + 'meeting': meeting, + 'interview': interview, + 'candidate': candidate, + 'job': job, + 'external_participants': external_participants, + 'system_participants': system_participants, + 'total_participants': total_participants, + 'form': participant_form, + # 'email_form': email_form, + } + + return render(request, 'interviews/detail_interview.html', context) + diff --git a/static/image/kaauh.jpeg b/static/image/kaauh.jpeg deleted file mode 100644 index f569ae6..0000000 Binary files a/static/image/kaauh.jpeg and /dev/null differ diff --git a/static/image/kaauh.png b/static/image/kaauh.png deleted file mode 100644 index bd28a2f..0000000 Binary files a/static/image/kaauh.png and /dev/null differ diff --git a/static/image/kaauh_banner.png b/static/image/kaauh_banner.png deleted file mode 100644 index 4065c35..0000000 Binary files a/static/image/kaauh_banner.png and /dev/null differ diff --git a/static/image/kaauh_green.png b/static/image/kaauh_green.png deleted file mode 100644 index 561e750..0000000 Binary files a/static/image/kaauh_green.png and /dev/null differ diff --git a/static/image/kaauh_green1.png b/static/image/kaauh_green1.png deleted file mode 100644 index 10dbccb..0000000 Binary files a/static/image/kaauh_green1.png and /dev/null differ diff --git a/static/image/vision2.svg b/static/image/vision2.svg deleted file mode 100644 index 43156d8..0000000 --- a/static/image/vision2.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/templates/applicant/partials/candidate_facing_base.html b/templates/applicant/partials/candidate_facing_base.html index 785d21c..b2e1c1b 100644 --- a/templates/applicant/partials/candidate_facing_base.html +++ b/templates/applicant/partials/candidate_facing_base.html @@ -323,7 +323,7 @@ {% translate "Applications" %} {% endcomment %}