diff --git a/recruitment/__pycache__/forms.cpython-312.pyc b/recruitment/__pycache__/forms.cpython-312.pyc index 67ec042..3a0a81a 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 e86961d..84a3449 100644 Binary files a/recruitment/__pycache__/models.cpython-312.pyc and b/recruitment/__pycache__/models.cpython-312.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-312.pyc b/recruitment/__pycache__/urls.cpython-312.pyc index e4baf7c..06a411f 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 336d5a1..675f4d0 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 8395d13..90447c9 100644 --- a/recruitment/email_service.py +++ b/recruitment/email_service.py @@ -225,108 +225,147 @@ def send_interview_invitation_email(candidate, job, meeting_details=None, recipi return {'success': False, 'error': error_msg} from .models import Candidate -def send_bulk_email(subject, message, recipient_list, request=None, attachments=None, async_task_=False): +from django.shortcuts import get_object_or_404 +# Assuming other necessary imports like logger, settings, EmailMultiAlternatives, strip_tags are present + +from .models import Candidate +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): """ Send bulk email to multiple recipients with HTML support and attachments, supporting synchronous or asynchronous dispatch. """ - - - # Define messages (Placeholders) - - - all_candidate_emails = [] - candidate_through_agency_emails=[] - participant_emails = [] - agency_emails = [] - left_candidate_emails = [] - - if not recipient_list: - return {'success': False, 'error': 'No recipients provided'} - - - for email in recipient_list: - email = email.strip().lower() # Clean input email - if email: - - candidate = Candidate.objects.filter(email=email).first() - if candidate: - all_candidate_emails.append(email) - else: - participant_emails.append(email) - - - for email in all_candidate_emails: - - candidate = Candidate.objects.filter(email=email).first() - - if candidate: - - if candidate.hiring_source == 'Agency' and hasattr(candidate, 'hiring_agency') and candidate.hiring_agency: - agency = candidate.hiring_agency - candidate_through_agency_emails.append(email) - if agency and agency.email: - agency_emails.append(agency.email) - else: - left_candidate_emails.append(email) - else: - left_candidate_emails.append(email) - # Determine unique recipients - unique_left_candidates = list(set(left_candidate_emails)) # Convert to list for async task - unique_agencies = list(agency_emails) - unique_participants = list(set(participant_emails)) + # --- 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'} - total_recipients = len(unique_left_candidates) + len(unique_agencies) + len(unique_participants) - if total_recipients == 0: - return {'success': False, 'error': 'No valid email addresses found after categorization'} + # This must contain (final_recipient_email, customized_message) for ALL sends + customized_sends = [] + + # 1a. Classify Recipients and Prepare Custom Messages + for email in recipient_list: + email = email.strip().lower() + + try: + candidate = get_object_or_404(Candidate, email=email) + except Exception: + logger.warning(f"Candidate not found for email: {email}") + continue + + candidate_name = candidate.first_name + + # --- Candidate belongs to an agency (Final Recipient: Agency) --- + if candidate.belong_to_an_agency and 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)) + 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)) + 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) - - # --- 3. Handle ASYNC Dispatch --- + if total_recipients == 0: + return {'success': False, 'error': 'No valid recipients found for sending.'} + else: + # For interview flow + total_recipients = len(recipient_list) + + + # --- 2. Handle ASYNC Dispatch (FIXED: Single loop used) --- if async_task_: try: - from django_q.tasks import async_task - # Simple, serializable attachment format assumed: list of (filename, content, content_type) tuples processed_attachments = attachments if attachments else [] - task_ids = [] + + 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', + subject, + 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' + ) + task_ids.append(task_id) + + logger.info(f"{len(task_ids)} tasks ({total_recipients} emails) queued.") + + return { + 'success': True, + 'async': True, + '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' + ) + task_ids.append(task_id) + logger.info(f"Interview emails queued. ID: {task_id}") + + return { + 'success': True, + 'async': True, + 'task_ids': task_ids, + 'message': f'Interview emails queued for background sending to {total_recipients} recipient(s)' + } - # Queue Left Candidates - if unique_left_candidates: - task_id = async_task( - 'recruitment.tasks.send_bulk_email_task', - subject, - message, - recipient_list, - processed_attachments, # Pass serializable data - hook='recruitment.tasks.email_success_hook' # Example hook - ) - task_ids.append(task_id) - - logger.info(f" email queued. ID: {task_id}") - return { - 'success': True, - 'async': True, - 'task_ids': task_ids, - 'message': f'Emails queued for background sending to {total_recipients} recipient(s)' - } except ImportError: logger.error("Async execution requested, but django_q or required modules not found. Defaulting to sync.") - async_task_ = False # Fallback to sync + 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)}"} - # --- 4. Handle SYNCHRONOUS Send (If async_task_=False or fallback) --- + # --- 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 + # Helper Function for Sync Send (as provided) def send_individual_email(recipient, body_message): + # ... (Existing helper function logic) ... nonlocal successful_sends if is_html: @@ -336,7 +375,6 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= else: email_obj = EmailMultiAlternatives(subject=subject, body=body_message, from_email=from_email, to=[recipient]) - # Attachment Logic if attachments: for attachment in attachments: if hasattr(attachment, 'read'): @@ -349,38 +387,43 @@ def send_bulk_email(subject, message, recipient_list, request=None, attachments= email_obj.attach(filename, content, content_type) try: - # FIX: Added the critical .send() call 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) - - # Send Emails - for email in unique_left_candidates: - candidate_name=Candidate.objects.filter(email=email).first().first_name - candidate_message = f"Hi, {candidate_name}"+"\n"+message - - send_individual_email(email, candidate_message) - - i=0 - for email in unique_agencies: - candidate_name=Candidate.objects.filter(email=candidate_through_agency_emails[i]).first().first_name - agency_message = f"Hi, {candidate_name}"+"\n"+message - send_individual_email(email, agency_message) - - for email in unique_participants: - - participant_message = "Hello Participant! This is a general notification for you." - send_individual_email(email, participant_message) - - - 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.' - } + if not from_interview: + # Send Emails - Pure Candidates + for email in pure_candidate_emails: + candidate_name = Candidate.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 = Candidate.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 b553d1e..0e969c6 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -1243,24 +1243,24 @@ class ParticipantsForm(forms.ModelForm): } -class ParticipantsSelectForm(forms.ModelForm): - """Form for selecting Participants""" +# class ParticipantsSelectForm(forms.ModelForm): +# """Form for selecting Participants""" - participants=forms.ModelMultipleChoiceField( - queryset=Participants.objects.all(), - widget=forms.CheckboxSelectMultiple, - required=False, - label=_("Select Participants")) +# participants=forms.ModelMultipleChoiceField( +# queryset=Participants.objects.all(), +# widget=forms.CheckboxSelectMultiple, +# required=False, +# label=_("Select Participants")) - users=forms.ModelMultipleChoiceField( - queryset=User.objects.all(), - widget=forms.CheckboxSelectMultiple, - required=False, - label=_("Select Users")) +# users=forms.ModelMultipleChoiceField( +# queryset=User.objects.all(), +# widget=forms.CheckboxSelectMultiple, +# required=False, +# label=_("Select Users")) - class Meta: - model = JobPosting - fields = ['participants','users'] # No direct fields from Participants model +# class Meta: +# model = JobPosting +# fields = ['participants','users'] # No direct fields from Participants model class CandidateEmailForm(forms.Form): @@ -1272,14 +1272,7 @@ class CandidateEmailForm(forms.Form): label=_('Select Candidates'), # Use a descriptive label required=False ) - - # to = forms.MultipleChoiceField( - # widget=forms.CheckboxSelectMultiple(attrs={ - # 'class': 'form-check' - # }), - # label=_('candidates'), - # required=True - # ) + subject = forms.CharField( max_length=200, @@ -1303,58 +1296,13 @@ class CandidateEmailForm(forms.Form): required=True ) - recipients = forms.MultipleChoiceField( - widget=forms.CheckboxSelectMultiple(attrs={ - 'class': 'form-check' - }), - label=_('Recipients'), - required=False - ) - - # include_candidate_info = forms.BooleanField( - # widget=forms.CheckboxInput(attrs={ - # 'class': 'form-check-input' - # }), - # label=_('Include candidate information'), - # initial=True, - # required=False - # ) - - # include_meeting_details = forms.BooleanField( - # widget=forms.CheckboxInput(attrs={ - # 'class': 'form-check-input' - # }), - # label=_('Include meeting details'), - # initial=True, - # required=False - # ) + def __init__(self, job, candidates, *args, **kwargs): super().__init__(*args, **kwargs) self.job = job self.candidates=candidates - stage=self.candidates.first().stage - - # Get all participants and users for this job - recipient_choices = [] - - # Add job participants - #show particpants only in the interview stage - if stage=='Interview': - for participant in job.participants.all(): - recipient_choices.append( - (f'participant_{participant.id}', f'{participant.name} - {participant.designation} (Participant)') - ) - - # Add job users - for user in job.users.all(): - recipient_choices.append( - (f'user_{user.id}', f'{user.get_full_name() or user.username} - {user.email} (User)') - ) - - self.fields['recipients'].choices = recipient_choices - self.fields['recipients'].initial = [choice[0] for choice in recipient_choices] # Select all by default candidate_choices=[] for candidate in candidates: @@ -1366,11 +1314,11 @@ class CandidateEmailForm(forms.Form): self.fields['to'].choices =candidate_choices self.fields['to'].initial = [choice[0] for choice in candidate_choices] - # # Set initial subject - # self.fields['subject'].initial = f'Interview Update: {candidate.name} - {job.title}' + # Set initial message with candidate and meeting info initial_message = self._get_initial_message() + if initial_message: self.fields['message'].initial = initial_message @@ -1398,7 +1346,7 @@ class CandidateEmailForm(forms.Form): f"Best regards, The KAAUH Hiring team" ] - elif candidate.stage == 'Exam': + elif 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!", @@ -1450,42 +1398,13 @@ class CandidateEmailForm(forms.Form): return '\n'.join(message_parts) - # def clean_recipients(self): - # """Ensure at least one recipient is selected""" - # recipients = self.cleaned_data.get('recipients') - # if not recipients: - # raise forms.ValidationError(_('Please select at least one recipient.')) - # return recipients - - # def clean_to(self): - # """Ensure at least one recipient is selected""" - # candidates = self.cleaned_data.get('to') - # print(candidates) - # if not candidates: - # raise forms.ValidationError(_('Please select at least one candidate.')) - # return candidates def get_email_addresses(self): """Extract email addresses from selected recipients""" email_addresses = [] - recipients = self.cleaned_data.get('recipients', []) + candidates=self.cleaned_data.get('to',[]) - if recipients: - for recipient in recipients: - if recipient.startswith('participant_'): - participant_id = recipient.split('_')[1] - try: - participant = Participants.objects.get(id=participant_id) - email_addresses.append(participant.email) - except Participants.DoesNotExist: - continue - elif recipient.startswith('user_'): - user_id = recipient.split('_')[1] - try: - user = User.objects.get(id=user_id) - email_addresses.append(user.email) - except User.DoesNotExist: - continue + if candidates: for candidate in candidates: if candidate.startswith('candidate_'): @@ -1499,29 +1418,187 @@ class CandidateEmailForm(forms.Form): return list(set(email_addresses)) # Remove duplicates + def get_formatted_message(self): """Get the formatted message with optional additional information""" - message = self.cleaned_data.get('message', 'mesaage from system user hiii') - - # # Add candidate information if requested - # if self.cleaned_data.get('include_candidate_info') and self.candidate: - # candidate_info = f"\n\n--- Candidate Information ---\n" - # candidate_info += f"Name: {self.candidate.name}\n" - # candidate_info += f"Email: {self.candidate.email}\n" - # candidate_info += f"Phone: {self.candidate.phone}\n" - # message += candidate_info - - # # Add meeting details if requested - # if self.cleaned_data.get('include_meeting_details') and self.candidate: - # latest_meeting = self.candidate.get_latest_meeting - # if latest_meeting: - # meeting_info = f"\n\n--- Meeting Details ---\n" - # meeting_info += f"Topic: {latest_meeting.topic}\n" - # meeting_info += f"Date & Time: {latest_meeting.start_time.strftime('%B %d, %Y at %I:%M %p')}\n" - # meeting_info += f"Duration: {latest_meeting.duration} minutes\n" - # if latest_meeting.join_url: - # meeting_info += f"Join URL: {latest_meeting.join_url}\n" - # message += meeting_info + message = self.cleaned_data.get('message', '') return message + + +class InterviewParticpantsForm(forms.ModelForm): + participants = forms.ModelMultipleChoiceField( + queryset=Participants.objects.all(), + widget=forms.CheckboxSelectMultiple, + required=False , + + ) + system_users=forms.ModelMultipleChoiceField( + queryset=User.objects.all(), + widget=forms.CheckboxSelectMultiple, + required=False, + label=_("Select Users")) + + class Meta: + model = InterviewSchedule + fields = ['participants','system_users'] + + + +class InterviewEmailForm(forms.Form): + subject = forms.CharField( + max_length=200, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter email subject', + 'required': True + }), + label=_('Subject'), + required=True + ) + + message_for_candidate= forms.CharField( + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 8, + 'placeholder': 'Enter your message here...', + 'required': True + }), + label=_('Message'), + required=False + ) + message_for_agency= forms.CharField( + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 8, + 'placeholder': 'Enter your message here...', + 'required': True + }), + label=_('Message'), + required=False + ) + message_for_participants= forms.CharField( + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 8, + 'placeholder': 'Enter your message here...', + 'required': True + }), + label=_('Message'), + required=False + ) + + def __init__(self, *args,candidate, external_participants, system_participants,meeting,job,**kwargs): + super().__init__(*args, **kwargs) + + # --- Data Preparation --- + # Note: Added error handling for agency name if it's missing (though it shouldn't be based on your check) + formatted_date = meeting.start_time.strftime('%Y-%m-%d') + formatted_time = meeting.start_time.strftime('%I:%M %p') + zoom_link = meeting.join_url + duration = meeting.duration + 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 ]) + + # Combine and ensure no leading/trailing commas if one list is empty + participant_names = ", ".join(filter(None, [external_participants_names, system_participants_names])) + + + # --- 1. Candidate Message (More concise and structured) --- + 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 virtual interview are as follows: + +- **Date:** {formatted_date} +- **Time:** {formatted_time} (RIYADH TIME) +- **Duration:** {duration} +- **Meeting Link:** {zoom_link} + +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. + +We look forward to meeting you. + +Best regards, +KAAUH Hiring Team +""" + + + # --- 2. Agency Message (Professional and clear details) --- + agency_message = f""" +Dear {agency_name}, + +We have scheduled an interview for your candidate, **{candidate.full_name}**, for the **{job_title}** role. + +Please forward the following details to the candidate and ensure they are fully prepared. + +**Interview Details:** + +- **Candidate:** {candidate.full_name} +- **Job Title:** {job_title} +- **Date:** {formatted_date} +- **Time:** {formatted_time} (RIYADH TIME) +- **Duration:** {duration} +- **Meeting Link:** {zoom_link} + +Please let us know if you or the candidate have any questions. + +Best regards, +KAAUH Hiring Team +""" + + # --- 3. Participants Message (Action-oriented and informative) --- + participants_message = f""" +Hi Team, + +This is a reminder of the upcoming interview you are scheduled to participate in for the **{job_title}** position. + +**Interview Summary:** + +- **Candidate:** {candidate.full_name} +- **Date:** {formatted_date} +- **Time:** {formatted_time} (RIYADH TIME) +- **Duration:** {duration} +- **Your Fellow Interviewers:** {participant_names} + +**Action Items:** + +1. Please review **{candidate.full_name}'s** resume and notes. +2. The official calendar invite contains the meeting link ({zoom_link}) and should be used to join. +3. Be ready to start promptly at the scheduled time. + +Thank you for your participation. + +Best regards, +KAAUH HIRING TEAM +""" + + # Set initial data + self.initial['subject'] = f"Interview Invitation: {job_title} at KAAUH - {candidate.full_name}" + # .strip() removes the leading/trailing blank lines caused by the f""" format + self.initial['message_for_candidate'] = candidate_message.strip() + self.initial['message_for_agency'] = agency_message.strip() + self.initial['message_for_participants'] = participants_message.strip() + + + + + + + + + + + + + + \ No newline at end of file diff --git a/recruitment/migrations/0002_scheduledinterview_participants.py b/recruitment/migrations/0002_scheduledinterview_participants.py new file mode 100644 index 0000000..71c0a2c --- /dev/null +++ b/recruitment/migrations/0002_scheduledinterview_participants.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-06 15:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='scheduledinterview', + name='participants', + field=models.ManyToManyField(blank=True, to='recruitment.participants'), + ), + ] diff --git a/recruitment/migrations/0003_scheduledinterview_system_users.py b/recruitment/migrations/0003_scheduledinterview_system_users.py new file mode 100644 index 0000000..e365758 --- /dev/null +++ b/recruitment/migrations/0003_scheduledinterview_system_users.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.7 on 2025-11-06 15:37 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0002_scheduledinterview_participants'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='scheduledinterview', + name='system_users', + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/recruitment/migrations/0004_remove_jobposting_participants_and_more.py b/recruitment/migrations/0004_remove_jobposting_participants_and_more.py new file mode 100644 index 0000000..9368b3a --- /dev/null +++ b/recruitment/migrations/0004_remove_jobposting_participants_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.7 on 2025-11-06 15:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0003_scheduledinterview_system_users'), + ] + + operations = [ + migrations.RemoveField( + model_name='jobposting', + name='participants', + ), + migrations.RemoveField( + model_name='jobposting', + name='users', + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index cc73897..b712d0c 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -54,18 +54,18 @@ class JobPosting(Base): ("HYBRID", "Hybrid"), ] - users=models.ManyToManyField( - User, - blank=True,related_name="jobs_assigned", - verbose_name=_("Internal Participant"), - help_text=_("Internal staff involved in the recruitment process for this job"), - ) + # users=models.ManyToManyField( + # User, + # blank=True,related_name="jobs_assigned", + # verbose_name=_("Internal Participant"), + # help_text=_("Internal staff involved in the recruitment process for this job"), + # ) - participants=models.ManyToManyField('Participants', - blank=True,related_name="jobs_participating", - verbose_name=_("External Participant"), - help_text=_("External participants involved in the recruitment process for this job"), - ) + # participants=models.ManyToManyField('Participants', + # blank=True,related_name="jobs_participating", + # verbose_name=_("External Participant"), + # help_text=_("External participants involved in the recruitment process for this job"), + # ) # Core Fields title = models.CharField(max_length=200) @@ -421,6 +421,7 @@ class Candidate(Base): related_name="candidates", verbose_name=_("Job"), ) + first_name = models.CharField(max_length=255, verbose_name=_("First Name")) last_name = models.CharField(max_length=255, verbose_name=_("Last Name")) email = models.EmailField(db_index=True, verbose_name=_("Email")) # Added index @@ -706,6 +707,11 @@ class Candidate(Base): time_to_hire = self.hired_date - self.created_at.date() return time_to_hire.days return 0 + + @property + def belong_to_an_agency(self): + return self.hiring_source=='Agency' + class TrainingMaterial(Base): title = models.CharField(max_length=255, verbose_name=_("Title")) @@ -772,7 +778,7 @@ class ZoomMeeting(Base): # Timestamps def __str__(self): - return self.topic\ + return self.topic @property def get_job(self): return self.interview.job @@ -781,10 +787,10 @@ class ZoomMeeting(Base): return self.interview.candidate @property def get_participants(self): - return self.interview.job.participants.all() + return self.interview.participants.all() @property def get_users(self): - return self.interview.job.users.all() + return self.interview.system_users.all() class MeetingComment(Base): """ @@ -1639,6 +1645,9 @@ class ScheduledInterview(Base): db_index=True ) + participants = models.ManyToManyField('Participants', blank=True) + system_users=models.ManyToManyField(User,blank=True) + job = models.ForeignKey( "JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True @@ -1753,6 +1762,7 @@ class Notification(models.Model): class Participants(Base): """Model to store Participants details""" + name = models.CharField(max_length=255, verbose_name=_("Participant Name"),null=True,blank=True) email= models.EmailField(verbose_name=_("Email")) phone = models.CharField(max_length=12,verbose_name=_("Phone Number"),null=True,blank=True) diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 06cb795..415aebc 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -746,70 +746,74 @@ def sync_candidate_to_source_task(candidate_id, source_id): return {"success": False, "error": error_msg} -def send_bulk_email_task(subject, message, recipient_list, request=None, attachments=None): - """ - Django-Q background task to send bulk email to multiple recipients. - Args: - subject: Email subject - message: Email message (can be HTML) - recipient_list: List of email addresses - request: Django request object (optional) - attachments: List of file attachment data (optional) - - Returns: - dict: Result with success status and error message if failed - """ - from .email_service import send_bulk_email - import os - - logger.info(f"Starting bulk email task for {len(recipient_list)} recipients") +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): + """Internal helper to create and send a single email.""" + + from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa') + is_html = '<' in body_message and '>' in body_message + + 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 isinstance(attachment, tuple) and len(attachment) == 3: + filename, content, content_type = attachment + email_obj.attach(filename, content, content_type) + try: - # Process attachments - convert file data back to file objects if needed - # processed_attachments = [] - # if attachments: - # for attachment in attachments: - # if isinstance(attachment, dict) and 'file_path' in attachment: - # # This is a serialized file from background task - # file_path = attachment['file_path'] - # filename = attachment.get('filename', os.path.basename(file_path)) - # content_type = attachment.get('content_type', 'application/octet-stream') - - # try: - # with open(file_path, 'rb') as f: - # content = f.read() - # processed_attachments.append((filename, content, content_type)) - - # # Clean up temporary file - # try: - # os.unlink(file_path) - # except OSError: - # pass # File might already be deleted - - # except FileNotFoundError: - # logger.warning(f"Attachment file not found: {file_path}") - # continue - # else: - # # Direct attachment (file object or tuple) - # processed_attachments.append(attachment) - - # Call the existing send_bulk_email function synchronously within the task - result = send_bulk_email( - subject=subject, - message=message, - recipient_list=recipient_list, - request=request, - ) - - if result['success']: - logger.info(f"Bulk email task completed successfully for {result.get('recipients_count', len(recipient_list))} recipients") - else: - logger.error(f"Bulk email task failed: {result.get('error', 'Unknown error')}") - - return result - + email_obj.send(fail_silently=False) + return True except Exception as e: - error_msg = f"Critical error in bulk email task: {str(e)}" - logger.error(error_msg, exc_info=True) - return {'success': False, 'error': error_msg} + logger.error(f"Task failed to send email to {recipient}: {str(e)}", exc_info=True) + return False + + +def send_bulk_email_task(subject, message, recipient_list, attachments=None, hook='recruitment.tasks.email_success_hook'): + """ + 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") + successful_sends = 0 + total_recipients = len(recipient_list) + + if not recipient_list: + return {'success': False, 'error': 'No recipients provided to task.'} + + # 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): + successful_sends += 1 + + if successful_sends > 0: + logger.info(f"Bulk email task completed successfully. Sent to {successful_sends}/{total_recipients} recipients.") + return { + 'success': True, + 'recipients_count': successful_sends, + 'message': f"Sent successfully to {successful_sends} recipient(s)." + } + else: + logger.error(f"Bulk email task failed: No emails were sent successfully.") + return {'success': False, 'error': "No emails were sent successfully in the background task."} + + +def email_success_hook(task): + """ + The success hook must accept the Task object as the first and only required positional argument. + """ + if task.success: + logger.info(f"Task ID {task.id} succeeded. Result: {task.result}") + else: + logger.error(f"Task ID {task.id} failed. Error: {task.result}") + \ No newline at end of file diff --git a/recruitment/urls.py b/recruitment/urls.py index 6dc3037..e8ed14e 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -232,4 +232,7 @@ urlpatterns = [ # Email composition URLs path('jobs//candidates/compose_email/', views.compose_candidate_email, name='compose_candidate_email'), + path('interview/partcipants//',views.create_interview_participants,name='create_interview_participants'), + path('interview/email//',views.send_interview_email,name='send_interview_email'), + ] diff --git a/recruitment/views.py b/recruitment/views.py index 7473177..10d7e38 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -41,9 +41,9 @@ from .forms import ( AgencyAccessLinkForm, AgencyJobAssignmentForm, LinkedPostContentForm, - ParticipantsSelectForm, CandidateEmailForm, - SourceForm + SourceForm, + InterviewEmailForm ) from easyaudit.models import CRUDEvent, LoginEvent, RequestEvent from rest_framework import viewsets @@ -198,6 +198,30 @@ class ZoomMeetingDetailsView(LoginRequiredMixin, DetailView): model = ZoomMeeting template_name = "meetings/meeting_details.html" context_object_name = "meeting" + def get_context_data(self, **kwargs): + context=super().get_context_data(**kwargs) + meeting = self.object + interview=meeting.interview + candidate = interview.candidate + job=meeting.get_job + + # Assuming interview.participants and interview.system_users hold the people: + participants = list(interview.participants.all()) + list(interview.system_users.all()) + external_participants=list(interview.participants.all()) + system_participants= list(interview.system_users.all()) + total_participants=len(participants) + form = InterviewParticpantsForm(instance=interview) + context['form']=form + context['email_form'] = InterviewEmailForm( + candidate=candidate, + external_participants=external_participants, + system_participants=system_participants, + meeting=meeting, + job=job + ) + context['total_participants']=total_participants + return context + class ZoomMeetingUpdateView(LoginRequiredMixin, UpdateView): @@ -1457,40 +1481,12 @@ def candidate_update_status(request, slug): def candidate_interview_view(request,slug): job = get_object_or_404(JobPosting,slug=slug) - if request.method == "POST": - form = ParticipantsSelectForm(request.POST, instance=job) - print(form.errors) - - if form.is_valid(): - - # Save the main instance (JobPosting) - job_instance = form.save(commit=False) - job_instance.save() - - # MANUALLY set the M2M relationships based on submitted data - job_instance.participants.set(form.cleaned_data['participants']) - job_instance.users.set(form.cleaned_data['users']) - - messages.success(request, "Interview participants updated successfully.") - return redirect("candidate_interview_view", slug=job.slug) - - else: - initial_data = { - 'participants': job.participants.all(), - 'users': job.users.all(), - } - form = ParticipantsSelectForm(instance=job, initial=initial_data) - - else: - form = ParticipantsSelectForm(instance=job) - context = { "job":job, "candidates":job.interview_candidates, 'current_stage':'Interview', - 'form':form, - 'participants_count': job.participants.count() + job.users.count(), + } return render(request,"recruitment/candidate_interview_view.html",context) @@ -3699,6 +3695,7 @@ def api_candidate_detail(request, candidate_id): return JsonResponse({'success': False, 'error': str(e)}) + @login_required def compose_candidate_email(request, job_slug): """Compose email to participants about a candidate""" @@ -3710,6 +3707,7 @@ def compose_candidate_email(request, job_slug): if request.method == 'POST': + print("........................................................inside candidate conpose.............") candidate_ids = request.POST.getlist('candidate_ids') candidates=Candidate.objects.filter(id__in=candidate_ids) form = CandidateEmailForm(job, candidates, request.POST) @@ -3717,6 +3715,7 @@ def compose_candidate_email(request, job_slug): print("form is valid ...") # Get email addresses email_addresses = form.get_email_addresses() + print(email_addresses) if not email_addresses: @@ -3731,66 +3730,31 @@ def compose_candidate_email(request, job_slug): return redirect('dashboard') + message = form.get_formatted_message() + subject = form.cleaned_data.get('subject') - # Check if this is an interview invitation - subject = form.cleaned_data.get('subject', '').lower() - is_interview_invitation = 'interview' in subject or 'meeting' in subject - - if is_interview_invitation: - # Use HTML template for interview invitations - # meeting_details = None - # if form.cleaned_data.get('include_meeting_details'): - # # Try to get meeting details from candidate - # meeting_details = { - # 'topic': f'Interview for {job.title}', - # 'date_time': getattr(candidate, 'interview_date', 'To be scheduled'), - # 'duration': '60 minutes', - # 'join_url': getattr(candidate, 'meeting_url', ''), - # } - - from .email_service import send_interview_invitation_email - email_result = send_interview_invitation_email( - candidates=candidates, - job=job, - # meeting_details=meeting_details, - recipient_list=email_addresses - ) - else: - # Get formatted message for regular emails - message = form.get_formatted_message() - subject = form.cleaned_data.get('subject') - - # Send emails using email service (no attachments, synchronous to avoid pickle issues) - - email_result = send_bulk_email( - subject=subject, - message=message, - recipient_list=email_addresses, - request=request, - async_task_=True # Changed to False to avoid pickle issues - ) + # Send emails using email service (no attachments, synchronous to avoid pickle issues) + + 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 + ) if email_result['success']: messages.success(request, f'Email sent successfully to {len(email_addresses)} recipient(s).') - # # For HTMX requests, return success response - # if 'HX-Request' in request.headers: - # return JsonResponse({ - # 'success': True, - # 'message': f'Email sent successfully to {len(email_addresses)} recipient(s).' - # }) + return redirect('candidate_interview_view', slug=job.slug) else: messages.error(request, f'Failed to send email: {email_result.get("message", "Unknown error")}') - # For HTMX requests, return error response - # if 'HX-Request' in request.headers: - # return JsonResponse({ - # 'success': False, - # 'error': email_result.get("message", "Failed to send email") - # }) - + return render(request, 'includes/email_compose_form.html', { 'form': form, 'job': job, @@ -3798,21 +3762,7 @@ def compose_candidate_email(request, job_slug): }) # except Exception as e: - # logger.error(f"Error sending candidate email: {e}") - # messages.error(request, f'An error occurred while sending the email: {str(e)}') - - # # For HTMX requests, return error response - # if 'HX-Request' in request.headers: - # return JsonResponse({ - # 'success': False, - # 'error': f'An error occurred while sending the email: {str(e)}' - # }) - - # return render(request, 'includes/email_compose_form.html', { - # 'form': form, - # 'job': job, - # 'candidate': candidate - # }) + else: # Form validation errors print('form is not valid') @@ -3825,7 +3775,7 @@ def compose_candidate_email(request, job_slug): 'success': False, 'error': 'Please correct the form errors and try again.' }) - + return render(request, 'includes/email_compose_form.html', { 'form': form, 'job': job, @@ -3836,14 +3786,6 @@ def compose_candidate_email(request, job_slug): # GET request - show the form form = CandidateEmailForm(job, candidates) - # try: - # l = [x.split("_")[1] for x in candidates] - # print(l) - # candidates_qs = Candidate.objects.filter(pk__in=l) - # print(candidates_qs) - # form.initial["to"]. = candidates_qs - # except: - # pass print("GET request made for candidate email form") @@ -4021,3 +3963,103 @@ def source_toggle_status(request, slug): # For GET requests, return error return JsonResponse({'success': False, 'error': 'Method not allowed'}) + + + +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}) + + +from django.core.mail import send_mail +def send_interview_email(request, slug): + from .email_service import send_bulk_email + + interview = get_object_or_404(ScheduledInterview, slug=slug) + + # 2. Retrieve the required data for the form's constructor + candidate = interview.candidate + job=interview.job + meeting=interview.zoom_meeting + participants = list(interview.participants.all()) + list(interview.system_users.all()) + external_participants=list(interview.participants.all()) + system_participants=list(interview.system_users.all()) + + participant_emails = [p.email for p in participants if hasattr(p, 'email')] + print(participant_emails) + total_recipients=1+len(participant_emails) + + # --- POST REQUEST HANDLING --- + if request.method == 'POST': + + form = InterviewEmailForm( + request.POST, + candidate=candidate, + external_participants=external_participants, + system_participants=system_participants, + meeting=meeting, + job=job + ) + + if form.is_valid(): + # 4. Extract cleaned data + subject = form.cleaned_data['subject'] + msg_candidate = form.cleaned_data['message_for_candidate'] + msg_agency = form.cleaned_data['message_for_agency'] + msg_participants = form.cleaned_data['message_for_participants'] + + # --- SEND EMAILS Candidate or agency--- + if candidate.belong_to_an_agency: + send_mail( + subject, + msg_agency, + settings.DEFAULT_FROM_EMAIL, + [candidate.hiring_agency.email], + fail_silently=False, + ) + else: + send_mail( + subject, + msg_candidate, + settings.DEFAULT_FROM_EMAIL, + [candidate.email], + fail_silently=False, + ) + + + email_result = send_bulk_email( + subject=subject, + message=msg_participants, + recipient_list=participant_emails, + request=request, + attachments=None, + async_task_=True, # Changed to False to avoid pickle issues, + from_interview=True + ) + + if email_result['success']: + messages.success(request, f'Email sent successfully to {total_recipients} recipient(s).') + + return redirect('list_meetings') + else: + messages.error(request, f'Failed to send email: {email_result.get("message", "Unknown error")}') + return redirect('list_meetings') + + + + diff --git a/templates/includes/email_compose_form.html b/templates/includes/email_compose_form.html index ffc2162..ee9e212 100644 --- a/templates/includes/email_compose_form.html +++ b/templates/includes/email_compose_form.html @@ -1,5 +1,5 @@ {% load i18n %} - +{{ form.media }}
@@ -41,25 +41,7 @@ {% endif %}
-
- -
- {% for choice in form.recipients %} -
- {{ choice }} -
- {% endfor %} -
- {% if form.recipients.errors %} -
- {% for error in form.recipients.errors %} - {{ error }} - {% endfor %} -
- {% endif %} -
+
{% endif %}
-{% comment %} - -
-
-
-
- - -
-
-
-
- - -
-
-
-
{% endcomment %} -
diff --git a/templates/interviews/interview_participants_form.html b/templates/interviews/interview_participants_form.html new file mode 100644 index 0000000..f30fa2e --- /dev/null +++ b/templates/interviews/interview_participants_form.html @@ -0,0 +1,5 @@ +
+ {% csrf_token %} + {{ form.as_p }} + +
diff --git a/templates/interviews/preview_schedule.html b/templates/interviews/preview_schedule.html index 28fa4e0..b5c6f52 100644 --- a/templates/interviews/preview_schedule.html +++ b/templates/interviews/preview_schedule.html @@ -1,82 +1,167 @@ {% extends "base.html" %} {% load static %} +{%load i18n %} + +{% block customCSS %} + +{% endblock %} {% block content %} -
-
+
+ +

- Interview Schedule Preview for {{ job.title }} + Interview Schedule Preview: **{{ job.title }}**

-
-
-
Schedule Details
-
+
+
+

{% trans "Schedule Parameters" %}

+
+
-

Period: {{ start_date|date:"F j, Y" }} to {{ end_date|date:"F j, Y" }}

-

- Working Days: +

Working Hours: {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}

+

Interview Duration: {{ interview_duration }} minutes

+

Buffer Time: {{ buffer_time }} minutes

+
+ +
+

Interview Period: {{ start_date|date:"F j, Y" }} — {{ end_date|date:"F j, Y" }}

+

Active Days: {% for day_id in working_days %} - {% if day_id == 0 %}Monday{% endif %} - {% if day_id == 1 %}Tuesday{% endif %} - {% if day_id == 2 %}Wednesday{% endif %} - {% if day_id == 3 %}Thursday{% endif %} - {% if day_id == 4 %}Friday{% endif %} - {% if day_id == 5 %}Saturday{% endif %} - {% if day_id == 6 %}Sunday{% endif %} + {% if day_id == 0 %}Mon{% endif %} + {% if day_id == 1 %}Tue{% endif %} + {% if day_id == 2 %}Wed{% endif %} + {% if day_id == 3 %}Thu{% endif %} + {% if day_id == 4 %}Fri{% endif %} + {% if day_id == 5 %}Sat{% endif %} + {% if day_id == 6 %}Sun{% endif %} {% if not forloop.last %}, {% endif %} {% endfor %}

-

Working Hours: {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}

-

Interview Duration: {{ interview_duration }} minutes

-

Buffer Time: {{ buffer_time }} minutes

-
-
-

Daily Break Times:

- {% if breaks %} - -
- {% for break in breaks %} - - - {{ break.start_time|time:"g:i A" }} — {{ break.end_time|time:"g:i A" }} - - {% endfor %} -
- {% else %} -

No daily breaks scheduled.

- {% endif %}
+ +
{% trans "Daily Break Times" %}
+ {% if breaks %} +
+ {% for break in breaks %} + + + {{ break.start_time|time:"g:i A" }} — {{ break.end_time|time:"g:i A" }} + + {% endfor %} +
+ {% else %} +

No daily breaks scheduled.

+ {% endif %}
-
-
-
Scheduled Interviews
+
+
+

{% trans "Scheduled Interviews Overview" %}

- -
+
- -
- - +
{% trans "Detailed List" %}
+
+
+ - - - - + + + + {% for item in schedule %} - + @@ -85,20 +170,19 @@
DateTimeCandidateEmailDateTimeCandidateEmail
{{ item.date|date:"F j, Y" }}{{ item.time|time:"g:i A" }}{{ item.time|time:"g:i A" }} {{ item.candidate.name }} {{ item.candidate.email }}
-
+ {% csrf_token %} - - - Back to Edit + + {% trans "Back to Edit" %} +
- @@ -118,6 +202,8 @@ document.addEventListener('DOMContentLoaded', function() { title: '{{ item.candidate.name }}', start: '{{ item.date|date:"Y-m-d" }}T{{ item.time|time:"H:i:s" }}', url: '#', + // Use the theme color for candidate events + color: 'var(--kaauh-teal-dark)', extendedProps: { email: '{{ item.candidate.email }}', time: '{{ item.time|time:"g:i A" }}' @@ -127,27 +213,36 @@ document.addEventListener('DOMContentLoaded', function() { {% for break in breaks %} { title: 'Break', + // FullCalendar requires a specific date for breaks, using start_date as a placeholder for daily breaks. + // Note: Breaks displayed on the monthly grid will only show on start_date, but weekly/daily view should reflect it daily if implemented correctly in the backend or using recurring events. start: '{{ start_date|date:"Y-m-d" }}T{{ break.start_time|time:"H:i:s" }}', end: '{{ start_date|date:"Y-m-d" }}T{{ break.end_time|time:"H:i:s" }}', - color: '#ff9f89', + color: '#ff9f89', // A nice soft orange/salmon color for breaks display: 'background' }, {% endfor %} ], eventClick: function(info) { - // Show candidate details in a modal or alert + // Log details to console instead of using alert() if (info.event.title !== 'Break') { - // IMPORTANT: Since alert() is forbidden, using console log as a fallback. - // In a production environment, this would be a custom modal dialog. - console.log('Candidate: ' + info.event.title + - '\nDate: ' + info.event.start.toLocaleDateString() + - '\nTime: ' + info.event.extendedProps.time + - '\nEmail: ' + info.event.extendedProps.email); + console.log('--- Candidate Interview Details ---'); + console.log('Candidate: ' + info.event.title); + console.log('Date: ' + info.event.start.toLocaleDateString()); + console.log('Time: ' + info.event.extendedProps.time); + console.log('Email: ' + info.event.extendedProps.email); + console.log('-----------------------------------'); + // You would typically open a Bootstrap modal here instead of using console.log } info.jsEvent.preventDefault(); + }, + eventDidMount: function(info) { + // Darken the text for background events (breaks) for better contrast + if (info.event.display === 'background') { + info.el.style.backgroundColor = '#ff9f89'; + } } }); calendar.render(); }); -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/templates/meetings/meeting_details.html b/templates/meetings/meeting_details.html index 8e25e32..d74bc79 100644 --- a/templates/meetings/meeting_details.html +++ b/templates/meetings/meeting_details.html @@ -1,5 +1,6 @@ {% extends 'base.html' %} {% load static i18n %} +{% load widget_tweaks %} {% block customCSS %}