From bfd2ad935a6a36ec8f06c776c6b3ab5edc1ff391 Mon Sep 17 00:00:00 2001 From: Faheed Date: Sun, 9 Nov 2025 13:32:59 +0300 Subject: [PATCH] update meeting --- recruitment/__pycache__/forms.cpython-312.pyc | Bin 63914 -> 66384 bytes .../__pycache__/models.cpython-312.pyc | Bin 84841 -> 84640 bytes recruitment/__pycache__/urls.cpython-312.pyc | Bin 17904 -> 18177 bytes recruitment/__pycache__/views.cpython-312.pyc | Bin 140579 -> 143687 bytes recruitment/email_service.py | 251 ++++++++------ recruitment/forms.py | 325 +++++++++++------- .../0002_scheduledinterview_participants.py | 18 + .../0003_scheduledinterview_system_users.py | 20 ++ ...remove_jobposting_participants_and_more.py | 21 ++ recruitment/models.py | 38 +- recruitment/tasks.py | 130 +++---- recruitment/urls.py | 3 + recruitment/views.py | 252 ++++++++------ templates/includes/email_compose_form.html | 53 +-- .../interview_participants_form.html | 5 + templates/interviews/preview_schedule.html | 227 ++++++++---- templates/meetings/meeting_details.html | 163 ++++++++- .../recruitment/candidate_interview_view.html | 59 +--- .../recruitment/candidate_screening_view.html | 2 +- 19 files changed, 977 insertions(+), 590 deletions(-) create mode 100644 recruitment/migrations/0002_scheduledinterview_participants.py create mode 100644 recruitment/migrations/0003_scheduledinterview_system_users.py create mode 100644 recruitment/migrations/0004_remove_jobposting_participants_and_more.py create mode 100644 templates/interviews/interview_participants_form.html diff --git a/recruitment/__pycache__/forms.cpython-312.pyc b/recruitment/__pycache__/forms.cpython-312.pyc index 67ec042f71e5e635f93295832c1d1dc71f937491..3a0a81a2342db8530f2c2f2b7c9895b3284a5696 100644 GIT binary patch delta 9315 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 delta 12747 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 diff --git a/recruitment/__pycache__/urls.cpython-312.pyc b/recruitment/__pycache__/urls.cpython-312.pyc index e4baf7c9886d8cd9704d50ab1c135c558bbdff58..06a411f9266b0dc3d174b6e3eb7cc3108c030c8a 100644 GIT binary patch delta 1770 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 G`2PU`ec9Up delta 16337 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 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 %}