diff --git a/NorahUniversity/__pycache__/settings.cpython-313.pyc b/NorahUniversity/__pycache__/settings.cpython-313.pyc index 4ae63cf..19cff8c 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-313.pyc and b/NorahUniversity/__pycache__/settings.cpython-313.pyc differ diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index 3eb57de..4e7bb3d 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -135,9 +135,9 @@ WSGI_APPLICATION = 'NorahUniversity.wsgi.application' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'haikal_db', - 'USER': 'faheed', - 'PASSWORD': 'Faheed@215', + 'NAME': 'norahuniversity', + 'USER': 'norahuniversity', + 'PASSWORD': 'norahuniversity', 'HOST': '127.0.0.1', 'PORT': '5432', } @@ -185,14 +185,26 @@ ACCOUNT_SIGNUP_FIELDS = ['email*', 'password1*', 'password2*'] ACCOUNT_UNIQUE_EMAIL = True ACCOUNT_EMAIL_VERIFICATION = 'none' ACCOUNT_USER_MODEL_USERNAME_FIELD = None -ACCOUNT_EMAIL_VERIFICATION = "mandatory" +ACCOUNT_EMAIL_VERIFICATION = "mandatory" ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True ACCOUNT_FORMS = {'signup': 'recruitment.forms.StaffSignupForm'} -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = '10.10.1.110' #'smtp.gmail.com' +EMAIL_PORT = 2225 #587 +EMAIL_USE_TLS = False +EMAIL_USE_SSL = False +EMAIL_TIMEOUT = 10 + +DEFAULT_FROM_EMAIL = 'norahuniversity@example.com' + +# Gmail SMTP credentials +# Remove the comment below if you want to use Gmail SMTP server +# EMAIL_HOST_USER = 'your_email@gmail.com' +# EMAIL_HOST_PASSWORD = 'your_password' # Crispy Forms Configuration CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index 3730ed6..cbf6857 100644 Binary files a/recruitment/__pycache__/forms.cpython-313.pyc and b/recruitment/__pycache__/forms.cpython-313.pyc differ diff --git a/recruitment/__pycache__/linkedin_service.cpython-313.pyc b/recruitment/__pycache__/linkedin_service.cpython-313.pyc index 46028cc..7d8f1c7 100644 Binary files a/recruitment/__pycache__/linkedin_service.cpython-313.pyc and b/recruitment/__pycache__/linkedin_service.cpython-313.pyc differ diff --git a/recruitment/__pycache__/models.cpython-313.pyc b/recruitment/__pycache__/models.cpython-313.pyc index 173f7d3..4fb5e79 100644 Binary files a/recruitment/__pycache__/models.cpython-313.pyc and b/recruitment/__pycache__/models.cpython-313.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-313.pyc b/recruitment/__pycache__/signals.cpython-313.pyc index 3cc942f..4c2eb87 100644 Binary files a/recruitment/__pycache__/signals.cpython-313.pyc and b/recruitment/__pycache__/signals.cpython-313.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-313.pyc b/recruitment/__pycache__/urls.cpython-313.pyc index 483e30f..02a6254 100644 Binary files a/recruitment/__pycache__/urls.cpython-313.pyc and b/recruitment/__pycache__/urls.cpython-313.pyc differ diff --git a/recruitment/__pycache__/utils.cpython-313.pyc b/recruitment/__pycache__/utils.cpython-313.pyc index 98a2eca..62e34b1 100644 Binary files a/recruitment/__pycache__/utils.cpython-313.pyc and b/recruitment/__pycache__/utils.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index 9ec5eb7..4ce6a38 100644 Binary files a/recruitment/__pycache__/views.cpython-313.pyc and b/recruitment/__pycache__/views.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views_frontend.cpython-313.pyc b/recruitment/__pycache__/views_frontend.cpython-313.pyc index 2fd1d55..df9b9a7 100644 Binary files a/recruitment/__pycache__/views_frontend.cpython-313.pyc and b/recruitment/__pycache__/views_frontend.cpython-313.pyc differ diff --git a/recruitment/email_service.py b/recruitment/email_service.py index 4780934..733d2a3 100644 --- a/recruitment/email_service.py +++ b/recruitment/email_service.py @@ -2,7 +2,7 @@ Email service for sending notifications related to agency messaging. """ -from django.core.mail import send_mail +from django.core.mail import send_mail, EmailMultiAlternatives from django.conf import settings from django.template.loader import render_to_string from django.utils.html import strip_tags @@ -144,3 +144,211 @@ def send_assignment_notification_email(assignment, message_type='created'): except Exception as e: logger.error(f"Failed to send assignment notification email: {str(e)}") return False + + +def send_interview_invitation_email(candidate, job, meeting_details=None, recipient_list=None): + """ + Send interview invitation email using HTML template. + + Args: + candidate: Candidate instance + job: Job instance + meeting_details: Dictionary with meeting information (optional) + recipient_list: List of additional email addresses (optional) + + Returns: + dict: Result with success status and error message if failed + """ + try: + # Prepare recipient list + recipients = [] + if candidate.email: + recipients.append(candidate.email) + if recipient_list: + recipients.extend(recipient_list) + + if not recipients: + return {'success': False, 'error': 'No recipient email addresses provided'} + + # Prepare context for template + context = { + 'candidate_name': candidate.full_name or candidate.name, + 'candidate_email': candidate.email, + 'candidate_phone': candidate.phone or '', + 'job_title': job.title, + 'department': getattr(job, 'department', ''), + 'company_name': getattr(settings, 'COMPANY_NAME', 'Norah University'), + } + + # Add meeting details if provided + if meeting_details: + context.update({ + 'meeting_topic': meeting_details.get('topic', f'Interview for {job.title}'), + 'meeting_date_time': meeting_details.get('date_time', ''), + 'meeting_duration': meeting_details.get('duration', '60 minutes'), + 'join_url': meeting_details.get('join_url', ''), + }) + + # Render HTML template + html_message = render_to_string('emails/interview_invitation.html', context) + plain_message = strip_tags(html_message) + + # Create email with both HTML and plain text versions + email = EmailMultiAlternatives( + subject=f'Interview Invitation: {job.title}', + body=plain_message, + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa'), + to=recipients, + ) + email.attach_alternative(html_message, "text/html") + + # Send email + email.send(fail_silently=False) + + logger.info(f"Interview invitation email sent successfully to {', '.join(recipients)}") + return { + 'success': True, + 'recipients_count': len(recipients), + 'message': f'Interview invitation sent successfully to {len(recipients)} recipient(s)' + } + + except Exception as e: + error_msg = f"Failed to send interview invitation email: {str(e)}" + logger.error(error_msg, exc_info=True) + return {'success': False, 'error': error_msg} + + +def send_bulk_email(subject, message, recipient_list, request=None, attachments=None, async_task_=False): + """ + Send bulk email to multiple recipients with HTML support and attachments. + + 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 attachments (optional) + async_task: Whether to run as background task (default: False) + + Returns: + dict: Result with success status and error message if failed + """ + # Handle async task execution + if async_task_: + print("hereeeeeee") + from django_q.tasks import async_task + + # Process attachments for background task serialization + # processed_attachments = [] + # if attachments: + # for attachment in attachments: + # if hasattr(attachment, 'read'): + # # File-like object - save to temporary file + # filename = getattr(attachment, 'name', 'attachment') + # content_type = getattr(attachment, 'content_type', 'application/octet-stream') + + # # Create temporary file + # with tempfile.NamedTemporaryFile(delete=False, suffix=f'_{filename}') as temp_file: + # content = attachment.read() + # temp_file.write(content) + # temp_file_path = temp_file.name + + # # Store file info for background task + # processed_attachments.append({ + # 'file_path': temp_file_path, + # 'filename': filename, + # 'content_type': content_type + # }) + # elif isinstance(attachment, tuple) and len(attachment) == 3: + # # (filename, content, content_type) tuple - can be serialized directly + # processed_attachments.append(attachment) + + # Queue the email sending as a background task + task_id = async_task( + 'recruitment.tasks.send_bulk_email_task', + subject, + message, + recipient_list, + request, + ) + logger.info(f"Bulk email queued as background task with ID: {task_id}") + return { + 'success': True, + 'async': True, + 'task_id': task_id, + 'message': f'Email queued for background sending to {len(recipient_list)} recipient(s)' + } + + # Synchronous execution (default behavior) + try: + if not recipient_list: + return {'success': False, 'error': 'No recipients provided'} + + # Clean recipient list and remove duplicates + clean_recipients = [] + seen_emails = set() + + for recipient in recipient_list: + email = recipient.strip().lower() + if email and email not in seen_emails: + clean_recipients.append(email) + seen_emails.add(email) + + if not clean_recipients: + return {'success': False, 'error': 'No valid email addresses found'} + + # Prepare email content + from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@kaauh.edu.sa') + + # Check if message contains HTML tags + is_html = '<' in message and '>' in message + + if is_html: + # Create HTML email with plain text fallback + plain_message = strip_tags(message) + + # Create email with both HTML and plain text versions + email = EmailMultiAlternatives( + subject=subject, + body=plain_message, + from_email=from_email, + to=clean_recipients, + ) + email.attach_alternative(message, "text/html") + else: + # Plain text email + email = EmailMultiAlternatives( + subject=subject, + body=message, + from_email=from_email, + to=clean_recipients, + ) + + # Add attachments if provided + # if attachments: + # for attachment in attachments: + # if hasattr(attachment, 'read'): + # # File-like object + # filename = getattr(attachment, 'name', 'attachment') + # content = attachment.read() + # content_type = getattr(attachment, 'content_type', 'application/octet-stream') + # email.attach(filename, content, content_type) + # elif isinstance(attachment, tuple) and len(attachment) == 3: + # # (filename, content, content_type) tuple + # filename, content, content_type = attachment + # email.attach(filename, content, content_type) + + # Send email + email.send(fail_silently=False) + + logger.info(f"Bulk email sent successfully to {len(clean_recipients)} recipients") + return { + 'success': True, + 'recipients_count': len(clean_recipients), + 'message': f'Email sent successfully to {len(clean_recipients)} recipient(s)' + } + + except Exception as e: + error_msg = f"Failed to send bulk email: {str(e)}" + logger.error(error_msg, exc_info=True) + return {'success': False, 'error': error_msg} diff --git a/recruitment/forms.py b/recruitment/forms.py index 590a0ea..0501c2b 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -642,7 +642,7 @@ class LinkedPostContentForm(forms.ModelForm): class Meta: model = JobPosting fields = ['linkedin_post_formated_data'] - + class FormTemplateIsActiveForm(forms.ModelForm): class Meta: model = FormTemplate @@ -1188,14 +1188,175 @@ class ParticipantsSelectForm(forms.ModelForm): widget=forms.CheckboxSelectMultiple, required=False, label=_("Select Participants")) - + 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 - \ No newline at end of file + + +class CandidateEmailForm(forms.Form): + """Form for composing emails to participants about a candidate""" + + subject = forms.CharField( + max_length=200, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter email subject', + 'required': True + }), + label=_('Subject'), + required=True + ) + + message = forms.CharField( + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 8, + 'placeholder': 'Enter your message here...', + 'required': True + }), + label=_('Message'), + required=True + ) + + recipients = forms.MultipleChoiceField( + widget=forms.CheckboxSelectMultiple(attrs={ + 'class': 'form-check' + }), + label=_('Recipients'), + required=True + ) + + 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, candidate, *args, **kwargs): + super().__init__(*args, **kwargs) + self.job = job + self.candidate = candidate + + # Get all participants and users for this job + recipient_choices = [] + + # Add job participants + 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 + + # 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 + + def _get_initial_message(self): + """Generate initial message with candidate and meeting information""" + message_parts = [] + + # Add candidate information + if self.candidate: + message_parts.append(f"Candidate Information:") + message_parts.append(f"Name: {self.candidate.name}") + message_parts.append(f"Email: {self.candidate.email}") + message_parts.append(f"Phone: {self.candidate.phone}") + + # Add latest meeting information if available + latest_meeting = self.candidate.get_latest_meeting + if latest_meeting: + message_parts.append(f"\nMeeting Information:") + message_parts.append(f"Topic: {latest_meeting.topic}") + message_parts.append(f"Date & Time: {latest_meeting.start_time.strftime('%B %d, %Y at %I:%M %p')}") + message_parts.append(f"Duration: {latest_meeting.duration} minutes") + if latest_meeting.join_url: + message_parts.append(f"Join URL: {latest_meeting.join_url}") + + 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 get_email_addresses(self): + """Extract email addresses from selected recipients""" + email_addresses = [] + recipients = self.cleaned_data.get('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 + + 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', '') + + # 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 + + return message diff --git a/recruitment/migrations/0001_initial.py b/recruitment/migrations/0001_initial.py index 1e9fde3..306ceb7 100644 --- a/recruitment/migrations/0001_initial.py +++ b/recruitment/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2025-10-29 18:04 +# Generated by Django 5.2.6 on 2025-10-30 10:22 import django.core.validators import django.db.models.deletion diff --git a/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc b/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc index 8d49e50..b86dad7 100644 Binary files a/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc and b/recruitment/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/recruitment/models.py b/recruitment/models.py index 5e80ec2..1c7676e 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -38,7 +38,7 @@ class Profile(models.Model): class JobPosting(Base): # Basic Job Information - + JOB_TYPES = [ ("FULL_TIME", "Full-time"), ("PART_TIME", "Part-time"), @@ -61,12 +61,12 @@ class JobPosting(Base): help_text=_("Internal staff involved in the recruitment process for this job"), ) - participants=models.ManyToManyField('Participants', + 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) department = models.CharField(max_length=100, blank=True) @@ -362,21 +362,21 @@ class JobPosting(Base): @property def offer_candidates_count(self): return self.all_candidates.filter(stage="Offer").count() or 0 - + @property def hired_candidates_count(self): return self.all_candidates.filter(stage="Hired").count() or 0 - + @property def vacancy_fill_rate(self): total_positions = self.open_positions - + no_of_positions_filled = self.candidates.filter(stage__in=['HIRED']).count() if total_positions > 0: vacancy_fill_rate = no_of_positions_filled / total_positions else: - vacancy_fill_rate = 0.0 + vacancy_fill_rate = 0.0 return vacancy_fill_rate @@ -678,12 +678,12 @@ class Candidate(Base): ).exists() return future_meetings or today_future_meetings - + # @property # def time_to_hire(self): # time_to_hire=self.hired_date-self.created_at # return time_to_hire - + class TrainingMaterial(Base): @@ -751,43 +751,19 @@ class ZoomMeeting(Base): # Timestamps def __str__(self): - return self.topic - + return self.topic\ @property def get_job(self): - try: - job=self.interview.job.first() - return job - except: - return None + return self.interview.job @property def get_candidate(self): - try: - candidate=self.interview.candidate.first() - return candidate - except: - return None - + return self.interview.candidate @property - def get_external_participants(self): - try: - interview=self.interview.first() - if interview: - return interview.job.participants.all() - return None - except: - return None + def get_participants(self): + return self.interview.job.participants.all() @property - def get_users_participants(self): - try: - interview=self.interview.first() - if interview: - return interview.job.users.all() - return None - except: - return None - - + def get_users(self): + return self.interview.job.users.all() class MeetingComment(Base): """ @@ -1629,8 +1605,8 @@ class InterviewSchedule(Base): models.Index(fields=['end_date']), models.Index(fields=['created_by']), ] - - + + class ScheduledInterview(Base): """Stores individual scheduled interviews""" @@ -1641,8 +1617,8 @@ class ScheduledInterview(Base): related_name="scheduled_interviews", db_index=True ) - - + + job = models.ForeignKey( "JobPosting", on_delete=models.CASCADE, related_name="scheduled_interviews", db_index=True ) @@ -1766,4 +1742,3 @@ class Participants(Base): def __str__(self): return f"{self.name} - {self.email}" - \ No newline at end of file diff --git a/recruitment/tasks.py b/recruitment/tasks.py index c7ec331..06cb795 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -147,7 +147,7 @@ def format_job_description(pk): 2. **Format the Qualifications:** Organize and format the raw QUALIFICATIONS data into clear, readable sections using `

` headings and `